i'm currently building a WebApp with authentication/authorization to access it and also to access several WebAPI's, all pointing to a Identity Server 4 host.
I have followed the official documentation of IdentityServer4 and its demos and for client authentications, token generations, user logging in, API's being called succesfully with tokens, all work fine, apparently, but recently i noticed that after some time of inactivity, the call to the API's start to receive 401 but the client application is still up with the same token.
It's like this:
Launch browser with debugging
Login with some user
Go to a view that calls one API to retrieve data for it
Keep navigating and testing, and everything else works fine
Now, the problem (after the previous step 4)
Stop debugging but keeping the browser up and running (keeping the cookies)
Changing code, implementing new stuff (basically passing some time)
Launch debug again
Using the same sessions/cookie on the already open browser, trying to navigate on the application works fine and does not required new login
Navigating to a view that will call the API using the current token, gives me the 401 when previously didnt
What i found out is that the token is expired, Visual Studio output points that out (also checking the token on https://jwt.io/ i can confirm the datetime).
Why the same token works fine for the ClientApp while invoking the API doesn't? Do i require to manually generate a new token because of the API's calls?
The configurations i'm using are:
---CLIENT application---
new Client
{
ClientId = "idWebApp",
ClientSecrets = new List<Secret> { new Secret("secret".Sha256()) },
AllowedGrantTypes = GrantTypes.Hybrid,
AllowAccessTokensViaBrowser = false,
EnableLocalLogin = true,
RedirectUris = { "http://localhost:5901/signin-oidc" },
FrontChannelLogoutUri = "http://localhost:5901/signout-oidc",
PostLogoutRedirectUris = { "http://localhost:5901/signout-callback-oidc" },
AllowOfflineAccess = true,
AllowedScopes = new List<string>
{
IdentityServerConstants.StandardScopes.OpenId,
IdentityServerConstants.StandardScopes.Profile,
IdentityServerConstants.StandardScopes.OfflineAccess,
"apiAccess",
},
RequireConsent = false,
}
---API resource---
(Just using simple ctor to initialize with a 'Name')
new ApiResource("apiAccess")
---Custom Claims---
new IdentityResource()
{
Name = "appCustomClaims",
UserClaims = new List<string>()
{
"customRole"
}
}
---Startup code of ClientApp---
services.AddAuthentication(options =>
{
options.DefaultScheme = "Cookies";
options.DefaultChallengeScheme = "oidc";
})
.AddCookie("Cookies")
.AddOpenIdConnect("oidc", options =>
{
options.Authority = "http://localhost:5900";
options.RequireHttpsMetadata = false;
options.ClientId = "idWebApp";
options.ClientSecret = "secret";
options.ResponseType = "code id_token";
options.Scope.Add("profile");
options.Scope.Add("offline_access");
options.ClaimActions.MapUniqueJsonKey("offline_access", "offline_access");
options.Scope.Add("appCustomClaims");
options.ClaimActions.MapJsonKey("customRole", "customRole");
options.Scope.Add("apiAccess");
options.GetClaimsFromUserInfoEndpoint = true;
options.SaveTokens = true;
options.TokenValidationParameters.RoleClaimType = "customRole";
});
Why the same token works fine for the ClientApp while invoking the API
doesn't?
Two things:
The expiration time of the access token is unrelated to your actions.
Once issued a JWT token can't be changed. By default the token expires after 3600 seconds.
The difference between the application and the api: the application uses cookies, the api a bearer token.
The cookie has its own expiration logic. This means that it expires at a different time, unrelated to the expiration time of the access token, and also can be kept alive because the cookie can be updated, unlike the JWT access token.
For offline_access you require to obtain a new access token, using the refresh token. As explained here.
Related
I have a Blazor Server App running, with Azure AD B2C Authentication enabled.
Everything seems to work well, and I can access the JWT Token of the user, that I can pass with my API requests to a backend...
However, after 1 hour, the token expires (I can also check in my logic to see if the token has expired or not). And in that case, I obviously would love to get a new token, using the refresh token...
But that's where the problem lies: the refresh_token token in the HttpContext seems to be empty, while the id_token contains the actual JWT bearer token.
What could be the cause for this? (I have had both tokens empty, but never that only the refresh_token was not empty).
Some code snippets that might help in pinpointing the issue:
Configuration of the authentication in the startup logic. (using SaveTokens)
builder.Services.AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme)
.AddMicrosoftIdentityWebApp(
options =>
{
builder.Configuration.Bind("AzureAdB2C", options);
options.ResponseType = OpenIdConnectResponseType.CodeIdToken;
options.Scope.Add("https://xxx.onmicrosoft.com/api/action");
options.UseTokenLifetime = true;
options.SaveTokens = true;
options.ReturnUrlParameter = CookieAuthenticationDefaults.ReturnUrlParameter;
}
,
options =>
{
options.Cookie.SameSite = SameSiteMode.None;
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
options.Cookie.IsEssential = true;
}
);
Access the tokens from the HttpContext
// Following variable is empty
var rToken = await _httpContextAccessor.HttpContext.GetTokenAsync("refresh_token");
// Following variable contain jwt token
var iToken = await _httpContextAccessor.HttpContext.GetTokenAsync("id_token");
Any idea, someone?
Change ResponseType to "code id_token token"
Add offline_access to your scopes
I am facing an issue authorizing client apps (users) with azure B2C.
On the backend I have an asp.net5 web api. As for the front-end I am using angular client.
I have registered both apps in my B2c tenants. I've added API Premissions on both apps, also granted admin consents.
Now, when I run the user flow (from the azure portal) and specify the web api in the form, the token works fine, I can make api calls and I get status 200.
However, when tokens are retrieved upon the client app (angular), I get 401 unauthorized response.
My authentication Midleware is configured as follows:
services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
}).AddJwtBearer(jwtConfig =>
{
jwtConfig.Audience = Configuration["AzureAdB2C:ClientId"];
jwtConfig.Authority = $"{Configuration["AzureAdB2C:Instance"]}/tfp/{Configuration["AzureAdB2C:Domain"]}/{Configuration["AzureAdB2C:SignUpSignInPolicyId"]}/v2.0";
jwtConfig.RequireHttpsMetadata = false;
jwtConfig.SaveToken = true;
jwtConfig.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidAudience = jwtConfig.Audience,
ValidIssuer = $"{Configuration["AzureAdB2C:Instance"]}/{Configuration["AzureAdB2C:TenantId"]}/v2.0/"
};
});
Anyone knows what could the problem be?
Solution:
I made some research, and altered the code a little bit, to get more information on what is happening, so I found out that the problem was at the scopes. I was specifying wrong scope name at the client app, therefore I was getting 401 unauthorized.
I have added AddOpenIdConnect to the ConfigureServices method of my ASP.NET Core 3.1 Razor application. It works great until the token expires, then I get 401 responses from my IDP.
I have seen an example that shows a way to wire up refresh tokens manually.
But I am hesitant to do that. It seems super unlikely that the folks at Microsoft did not think about refresh tokens.
Does ASP.NET Core 3.1 have a way to have refresh tokens automatically update the access token?
Here is what I came up with. Since there are not very many examples that I could find on how to do refresh tokens in ASP.NET Core with cookies, I thought I would post this here. (The one I link to in the question has issues.)
This is just my attempt at getting this working. It has not been used in any production setting. This code goes in the ConfigureServices method.
services.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, options =>
{
options.Events = new CookieAuthenticationEvents
{
// After the auth cookie has been validated, this event is called.
// In it we see if the access token is close to expiring. If it is
// then we use the refresh token to get a new access token and save them.
// If the refresh token does not work for some reason then we redirect to
// the login screen.
OnValidatePrincipal = async cookieCtx =>
{
var now = DateTimeOffset.UtcNow;
var expiresAt = cookieCtx.Properties.GetTokenValue("expires_at");
var accessTokenExpiration = DateTimeOffset.Parse(expiresAt);
var timeRemaining = accessTokenExpiration.Subtract(now);
// TODO: Get this from configuration with a fall back value.
var refreshThresholdMinutes = 5;
var refreshThreshold = TimeSpan.FromMinutes(refreshThresholdMinutes);
if (timeRemaining < refreshThreshold)
{
var refreshToken = cookieCtx.Properties.GetTokenValue("refresh_token");
// TODO: Get this HttpClient from a factory
var response = await new HttpClient().RequestRefreshTokenAsync(new RefreshTokenRequest
{
Address = tokenUrl,
ClientId = clientId,
ClientSecret = clientSecret,
RefreshToken = refreshToken
});
if (!response.IsError)
{
var expiresInSeconds = response.ExpiresIn;
var updatedExpiresAt = DateTimeOffset.UtcNow.AddSeconds(expiresInSeconds);
cookieCtx.Properties.UpdateTokenValue("expires_at", updatedExpiresAt.ToString());
cookieCtx.Properties.UpdateTokenValue("access_token", response.AccessToken);
cookieCtx.Properties.UpdateTokenValue("refresh_token", response.RefreshToken);
// Indicate to the cookie middleware that the cookie should be remade (since we have updated it)
cookieCtx.ShouldRenew = true;
}
else
{
cookieCtx.RejectPrincipal();
await cookieCtx.HttpContext.SignOutAsync();
}
}
}
};
})
.AddOpenIdConnect(options =>
{
options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.Authority = oidcDiscoveryUrl;
options.ClientId = clientId;
options.ClientSecret = clientSecret;
options.RequireHttpsMetadata = true;
options.ResponseType = OidcConstants.ResponseTypes.Code;
options.UsePkce = true;
// This scope allows us to get roles in the service.
options.Scope.Add("openid");
options.Scope.Add("profile");
options.Scope.Add("offline_access");
// This aligns the life of the cookie with the life of the token.
// Note this is not the actual expiration of the cookie as seen by the browser.
// It is an internal value stored in "expires_at".
options.UseTokenLifetime = false;
options.SaveTokens = true;
});
This code has two parts:
AddOpenIdConnect: This part of the code sets up OIDC for the application. Key settings here are:
SignInScheme: This lets ASP.NET Core know you want to use cookies to store your authentication information.
*UseTokenLifetime: As I understand it, this sets an internal "expires_at" value in the cookie to be the lifespan of the access token. (Not the actual cookie expiration, which stays at the session level.)
*SaveTokens: As I understand it, this is what causes the tokens to be saved in the cookie.
OnValidatePrincipal: This section is called when the cookie has been validated. In this section we check to see if the access token is near or past expiration. If it is then it gets refreshed and the updated values are stored in the cookie. If the token cannot be refreshed then the user is redirected to the login screen.
The code uses these values that must come from your configuration file:
clientId: OAuth2 Client ID. Also called Client Key, Consumer Key, etc.
clientSecret: OAuth2 Client Secret. Also called Consumer Secret, etc.
oidcDiscoveryUrl: Base part of the URL to your IDP's Well Known Configuration document. If your Well Known Configuration document is at https://youridp.domain.com/oauth2/oidcdiscovery/.well-known/openid-configuration then this value would be https://youridp.domain.com/oauth2/oidcdiscovery.
tokenUrl: Url to your IDP's token endpoint. For example: https:/youridp.domain.com/oauth2/token
refreshThresholdMinutes: If you wait till the access token is very close to expiring, then you run the risk of failing calls that rely on the access token. (If it is 5 miliseconds from expiration then it could expire, and fail a call, before you get a chance to refresh it.) This setting is the number of minutes before expiration you want to consider an access token ready to be refreshed.
* I am new to ASP.NET Core. As such I am not 100% sure that those settings do what I think. This is just a bit of code that is working for me and I thought I would share it. It may or may not work for you.
As far as I know, there's nothing built-in in ASP.NET Core 3.1 to refresh access tokens automatically. But I've found this convenient library from the IdentityServer4 authors which stores access and refresh tokens in memory (this can be overriden) and refreshes access tokens automatically when you request them from the library.
How to use the library: https://identitymodel.readthedocs.io/en/latest/aspnetcore/web.html.
NuGet package: https://www.nuget.org/packages/IdentityModel.AspNetCore/.
Source code: https://github.com/IdentityModel/IdentityModel.AspNetCore.
I implemented token refresh in a .NET 7.0 sample recently. There has always been an option to refresh tokens and rewrite cookies, in many MS OIDC stacks, including older ones: Owin, .NET Core etc. It seems poorly documented though, and I had to dig around in the aspnet source code to figure out the cookie rewrite step. So I thought I'd add to this thread in case useful to future readers.
REFRESH TOKEN GRANT
First send a standards based refresh token grant request:
private async Task<JsonNode> RefreshTokens(HttpContext context)
{
var tokenEndpoint = "https://login.example.com/oauth/v2/token";
var clientId = "myclientid";
var clientSecret = "myclientsecret";
var refreshToken = await context.GetTokenAsync("refresh_token");
var requestData = new[]
{
new KeyValuePair<string, string>("client_id", clientId),
new KeyValuePair<string, string>("client_secret", clientSecret),
new KeyValuePair<string, string>("grant_type", "refresh_token"),
new KeyValuePair<string, string>("refresh_token", refreshToken),
};
using (var client = new HttpClient())
{
client.DefaultRequestHeaders.Add("accept", "application/json");
var response = await client.PostAsync(tokenEndpoint, new FormUrlEncodedContent(requestData));
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync();
return JsonNode.Parse(json).AsObject();
}
}
REWRITE COOKIES
Then rewrite cookies, which is done by 'signing in' with a new set of tokens. A better method name might have been something like 'update authentication state'. If you then look at the HTTP response you will see an updated set-cookie header, with the new tokens.
Note that in a refresh token grant response, you may or may not receive a new refresh token and new ID token. If not, then supply the existing values.
private async Task RewriteCookies(JsonNode tokens, HttpContext context)
{
var accessToken = tokens["access_token"]?.ToString();
var refreshToken = tokens["refresh_token"]?.ToString();
var idToken = tokens["id_token"]?.ToString();
var newTokens = new List<AuthenticationToken>();
newTokens.Add(new AuthenticationToken{ Name = "access_token", Value = accessToken });
if (string.IsNullOrWhiteSpace(refreshToken))
{
refreshToken = await context.GetTokenAsync("refresh_token");
}
newTokens.Add(new AuthenticationToken{ Name = "refresh_token", Value = refreshToken });
if (string.IsNullOrWhiteSpace(idToken))
{
idToken = await context.GetTokenAsync("id_token");
}
newTokens.Add(new AuthenticationToken{ Name = "id_token", Value = idToken });
var properties = context.Features.Get<IAuthenticateResultFeature>().AuthenticateResult.Properties;
properties.StoreTokens(newTokens);
await context.SignInAsync(context.User, properties);
}
SUMMARY
Being able to refresh access tokens when you receive a 401 response from an API is an essential capability in any web app. Use short lived access tokens and then code similar to the above, to renew them with good usability.
Note that relying on an expiry time is not fully reliable. API token validation can fail due to infrastructure events in some cases. APIs then return 401 for access tokens that are not expired. The web app should handle this via a refresh, followed by a retry of the API request.
AddOpenIdConnect is used to configure the handler that performs the OpenID Connect protocol to get tokens from your identity provider. But it doesn't know where you want to save the tokens. It could be any of the following:
Cookie
Memory
Database
You could store the tokens in a cookie then check the token's expire time and refresh the tokens by intercepting the cookie's validation event (as the example shows).
But AddOpenIdConnect doesn't have the logic to control where the user want to store the tokens and automatically implement token refresh.
You can also try to wrap the middleware as the ADAL.NET/MSAL.NET to provide cache features and then you can acquire/refresh tokens silently.
I use two different clients. The IdentityServer4 provides API protections and log in form. Can I configure clients to avoid single sign on. I mean that even if I logged in the first client I need to log in the second client too.
My ID4 configuration:
internal static IEnumerable<Client> GetClients(IEnumerable<RegisteredClient> clients)
{
return clients.Select(x =>
{
var scopes = x.AllowedScopes.ToList();
scopes.Add(IdentityServerConstants.StandardScopes.OpenId);
scopes.Add(IdentityServerConstants.StandardScopes.Profile);
scopes.Add(IdentityServerConstants.StandardScopes.OfflineAccess);
var client = new Client
{
ClientId = x.Id,
ClientName = x.Name,
AllowedGrantTypes = GrantTypes.Hybrid,
RequireConsent = false,
RefreshTokenExpiration = TokenExpiration.Sliding,
RefreshTokenUsage = TokenUsage.ReUse,
ClientSecrets = {new Secret(x.Secret.Sha256())},
RedirectUris = new[] {$"{x.Url}/signin-oidc"},
PostLogoutRedirectUris = new[] {$"{x.Url}/signout-callback-oidc"},
UpdateAccessTokenClaimsOnRefresh = true,
AllowAccessTokensViaBrowser = true,
AllowedScopes = scopes,
AllowedCorsOrigins = {x.Url},
AllowOfflineAccess = true
};
return client;
});
}
All client have the same register code (Maybe it is a problem):
const string oidcScheme = "oidc";
const string coockieScheme = CookieAuthenticationDefaults.AuthenticationScheme;
services.AddAuthentication(options =>
{
options.DefaultScheme = coockieScheme;
options.DefaultChallengeScheme = oidcScheme;
})
.AddCookie(coockieScheme)
.AddOpenIdConnect(oidcScheme, options =>
{
options.SignInScheme = coockieScheme;
options.Authority = identitySettings.Authority;
options.RequireHttpsMetadata = false;
options.ClientId = identitySettings.Id;
options.ClientSecret = identitySettings.Secret;
options.ResponseType = "code id_token";
options.Scope.Add("offline_access");
foreach (var scope in identitySettings.Scopes)
{
options.Scope.Add(scope);
}
options.GetClaimsFromUserInfoEndpoint = true;
options.SaveTokens = true;
});
any help will be useful.
As long as you are in the same browser session, and your apps are having the same authority (are using the same Identity Server) this will not work.
I'll explain you why - once you log in from the first client, Identity Server creates a cookie (with all the relevant data needed in it).
Now comes the second client - the authority (the Identity Server) is the same that has issued the cookie. So Identity Server recognizes your session, sees that you are already authenticated and redirects you to the second client, without asking for credentials.
After all, this is the idea of Identity Server:
IdentityServer4 is an OpenID Connect and OAuth 2.0 framework for ASP.NET Core 2.
It enables the following features in your applications:
Authentication as a Service
Centralized login logic and workflow for all of your applications (web, native, mobile, services). IdentityServer is an officially certified implementation of OpenID Connect.
Single Sign-on / Sign-out
Single sign-on (and out) over multiple application types.
and more....
This is from the official documentation.
You have to either go for different authorities (Identity Server instances) for each client, or re-think is Identity Server the right solution for you in this case.
NOT RECOMMENDED
I'm not recommending this, because it kind of overrides the SSO idea of Identity Server, however if you still want to do it then - you can achieve what you want if you override the IProfileService. There is a method public Task IsActiveAsync(IsActiveContext context) and this context has a property IsActive which determines if the current principal is active in the current client.
You can try and implement some custom logic here, and based on the user ID (context.Subject.GetSubjectId()) and the client id (context.Client.ClientId) to determine if the user is already logged in this client or not.
EDIT
After your comment - this is something that doesn't come OOTB from Identity Server (if I can say it like this), but luckily you have an option.
Policy based authorization per client. Like this, your user can authenticate against Identity Server (and all of its clients), but only the specific clients will authorize him. You can treat this policies as a custom authorize attribute (more or less).
Like this, a user will receive unauthorized in clients, where he.. is not authorized. Hope that this clears the thing and helps :)
You can set prompt=login from all your clients.
prompt
none - no UI will be shown during the request. If this is not possible (e.g. because the user has to sign in or consent) an error is returned
login - the login UI will be shown, even if the user is already signed-in and has a valid session
https://identityserver4.readthedocs.io/en/latest/endpoints/authorize.html
This will force the second client to login again regardless of the previous client's login status.
I'm trying to integrate OpenId Connect into long-time existing webforms application. I was able to migrate the app to use OWIN and I'm using OpenIdConnectAuthenticationMiddleware to authenticate against my IdP provider. All goes fine until the point where I need to construct new identity obtained from IdP and set the cookie - which part I think is not happening.
Important parts of my Startup.Configure method:
app.SetDefaultSignInAsAuthenticationType(CookieAuthenticationDefaults.AuthenticationType);
app.UseCookieAuthentication(new CookieAuthenticationOptions
{
AuthenticationType = DefaultAuthenticationTypes.ApplicationCookie,
LoginPath = new PathString("/login.aspx"),
CookieManager = new SystemWebCookieManager() //custom cookie manager
});
app.UseOpenIdConnectAuthentication(new OpenIdConnectAuthenticationOptions
{
Authority = "https://[development_domain]/core",
ClientId = "VDWeb",
ResponseType = "code id_token token",
Scope = "openid profile",
UseTokenLifetime = true,
SignInAsAuthenticationType = "Cookies",
Notifications = new OpenIdConnectAuthenticationNotifications
{
SecurityTokenValidated = async n =>
{
var userInfo = await EndpointAndTokenHelper.CallUserInfoEndpoint(n.ProtocolMessage.AccessToken);
//now store Preferred name :
var prefNameClaim = new Claim(
Thinktecture.IdentityModel.Client.JwtClaimTypes.PreferredUserName,
userInfo.Value<string>("preferred_username"));
var myIdentity = new ClaimsIdentity(
n.AuthenticationTicket.Identity.AuthenticationType,
Thinktecture.IdentityModel.Client.JwtClaimTypes.PreferredUserName,
Thinktecture.IdentityModel.Client.JwtClaimTypes.Role);
myIdentity.AddClaim(prefNameClaim);
//add unique_user_key claim
var subjectClaim = n.AuthenticationTicket.Identity.FindFirst(Thinktecture.IdentityModel.Client.JwtClaimTypes.Subject);
myIdentity.AddClaim(new Claim("unique_user_key", subjectClaim.Value));
myIdentity.AddClaim(new Claim("id_token", n.ProtocolMessage.IdToken));
var ticket = new AuthenticationTicket(myIdentity, n.AuthenticationTicket.Properties);
var currentUtc = new SystemClock().UtcNow;
ticket.Properties.IssuedUtc = currentUtc;
ticket.Properties.ExpiresUtc = currentUtc.Add(TimeSpan.FromHours(12));
n.AuthenticationTicket = ticket;
},
}
});
I can confirm AuthentocationTicket is populated properly but auth cookie is NOT set. I do know about this issue https://katanaproject.codeplex.com/workitem/197 and I have tried all workarounds offered for this issue but none helped. Interestingly enough, when I try to drop my own cookie inside of SecurityTokenValidated event - n.Response.Cookies.Append("Test", "Test");, I can see the cookie is set properly.
One of the workarounds suggest implementing your own CookieManager. What makes me curious is that when I put a breakpoint into cookie setter in this custom manager, it is not hit, i.e. middleware seems not even trying to set the cookie. So the main question I have - at what point exactly the middleware will try to set the cookie? Is it when I set my AuthenticationTicket?
Edit 1: adding more information. I tried to compare with another web app, this time MVC, that I configured to use the same IdP and that works as expected. Startup code for both apps is the same. When debugging thru SecurityTokenValidated event, I can see that MVC app (working) has created System.Security.Principal.WindowsPrincipal identity while webforms app (non-working) created System.Security.Principal.GenericIdentity identity.
I have also added this little snipped
app.UseStageMarker(PipelineStage.Authenticate);
app.Use((context, next) =>
{
var identity = context.Request.User.Identity;
return next.Invoke();
});
just to see what identity get populated on this pipeline stage. For MVC app (working) I see the identity I added by setting AuthenticationTicket, for webforms app I still see non-authenticated GenericIdentity.
OK, this is embarrassing - the problem was in CookieAuthenticationOptions, apparently AuthenticationType = DefaultAuthenticationTypes.ApplicationCookie is NOT the same as AuthenticationType = "Cookies". Once set this later way, it is working fine.
Can you use the default cookie manager and see if that results in a cookie being set?