1

Backstory: I'm trying to link up a new client application using .NET 5 (MVC) with an existing IdentityServer4. IdentityServer4 (IS4 in short) is used both to authenticate the client, as well as providing claims & roles and the access_token that the API (separate webapp) depends on to request data on the backend systems. On the new client I'm using the IdentityModel package to handle the authentication and authorization. So far I've managed have both authentication and authorization working, but I'm running into issues regarding the expiration date of the access_token.

I've currently configured the client in IS4 to have the following. If the line says '(default)', it means that it's the defaults as suggested or set by IdentityModel/IS4:

  • IdentityTokenLifetime: 300s / 5m (default)
  • IdentityAccessToken: 300s / 5m (shortened to allow me to test)
  • AuthorizationCodeLifetime: 300s / 5m (default)

Flow First example:

  1. User browses to webpage, and get's redirected to IS4 to log in.
  2. User fills in user/pass and successfully authenticates, and get's redirected back to the secure section of the web app.
  3. When user hits the secure webpage, an api request is done to an external api using the users' access_token to grab the users' data.
  4. Request comes back with users' data, and webpage with that data.
  5. Working perfectly.

Second example:

  1. User browses to webpage, and get's redirected to IS4 to log in.
  2. User is already authenticated with IS4 using cookie, thus successfully authenticates, and get's redirected back to the secure section of the web app.
  3. When user hits the secure webpage, an api request is done to an external api using the users' access_token to grab the users' data.
  4. Request comes back with users' data, and webpage with that data.
  5. Working perfectly.

Third example:

  1. User waits 15 minutes on the webpage, and then refreshes the page.
  2. User is already logged into the website, so no redirect to IS4 is happening.
  3. Since user refreshes, the user hits the secure webpage, an api request is done to an external api using the users' access_token to grab the users' data.
  4. Request comes back empty, as the access_token has expired (10 minutes prior)
  5. Sad smiley :'(

Fourth example:

  1. Following example three: User sees error and restarts browser.
  2. User browses to webpage, and get's redirected to IS4 to log in.
  3. User is already authenticated with IS4 using cookie, thus successfully authenticates, and get's redirected back to the secure section of the web app.
  4. When user hits the secure webpage, an api request is done to an external api using the users' access_token to grab the users' data.
  5. Request comes back with users' data, and webpage with that data. (because access_token is newly generated with future expiry date due to new IS4 'login')
  6. Working perfectly.

Example three is the problem I'm having. What I expect to happen is that the [Authorization]-check doesn't allow expired sessions (access_tokens) to pass through, and instead redirect the user to IS4 to re-authenticate automatically based on the valid cookie the user still has (like example four).

What I've tried to fix:

  • Lengthening the IdentityAccessToken lifetime: Doesn't fix the problem, instead just moves the problem to the new expire_date.
  • Using the IdentityModel client "Web5" example on our existing IS4 implementation, which is showing the same behavior.

--

The requirements of the application are to have a short access_token lifetime to allow quick updates of users' right and access based on changed claims/roles in the backend, meanwhile allowing for 'persistent' logins to reduce the amount of time users' spend having to fill their accoutn details in.

It is entirely possible that instead of a technical problem my thoughtprocess or understanding of these things is wrong. If so, please enlighten me as to what the flow should be, preferably with a working example.

--

IdentityModel in the client is configured as following:

JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();

services
    .AddAuthentication(options => {
        options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
        options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
    })
    .AddCookie(options =>
    {
        options.Events.OnSigningOut = async e =>
        {
            // revoke refresh token on sign-out
            await e.HttpContext.RevokeUserRefreshTokenAsync();
        };
    })
    .AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme, options => {
        options.GetClaimsFromUserInfoEndpoint = true;
        options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;

        options.Authority = Configuration.GetValue<string>("IdentityServer:Authority");
        options.ClientId = Configuration.GetValue<string>("IdentityServer:ClientId");
        options.ClientSecret = Configuration.GetValue<string>("IdentityServer:ClientSecret");
        options.RequireHttpsMetadata = Configuration.GetValue<bool>("IdentityServer:RequireHttpsMetadata");

        options.UsePkce = true;
        options.ResponseType = OidcConstants.ResponseTypes.CodeIdToken;
        options.SaveTokens = true;

        options.TokenValidationParameters = new TokenValidationParameters
        {
            NameClaimType = JwtClaimTypes.Name,
            RoleClaimType = JwtClaimTypes.Role
        };

        // Scopes
        options.Scope.Add("openid");
        options.Scope.Add("offline_access");
    })
    .AddOpenIdConnect("persistent", options => {
        options.CallbackPath = "/signin-persistent";
        options.Events = new OpenIdConnectEvents
        {
            OnRedirectToIdentityProvider = context =>
            {
                context.ProtocolMessage.Prompt = OidcConstants.PromptModes.None;
                return Task.FromResult<object>(null);
            },

            OnMessageReceived = context => {
                if (string.Equals(context.ProtocolMessage.Error, "login_required", StringComparison.Ordinal))
                {
                    context.HandleResponse();
                    context.Response.Redirect("/");
                }
                return Task.FromResult<object>(null);
            }
        };
        
        ...
        // Rest of 'persistent' is similar as the non-persistent one
        ... 
    });
    
// Examples of IdentityModel suggest that calling this function make the boilerplate tasks of refreshing tokens and alike automatically work
services.AddAccessTokenManagement();
Jordy
  • 23
  • 5

1 Answers1

0

For this flow, with a backend application the solution is the use refresh tokens which can be obtained by requesting the offline_access scope and ensuring the client is configured to allow them.

The refresh token is returned alongside the access token and can be used to get a fresh access token (via a back channel token endpoint call) once the initial one expires. This can either be done on first failure (i.e. a 401 response from the API) or based on the expiry time of the access token (either by using the expires_in token endpoint response value or the exp claim in the access token itself.

Check out: https://identityserver4.readthedocs.io/en/latest/topics/refresh_tokens.html

And the sample: https://github.com/IdentityServer/IdentityServer4/tree/main/samples/Clients/src/MvcAutomaticTokenManagement

mackie
  • 4,996
  • 1
  • 17
  • 17
  • Thank you for the links and comment. In my application I'm already using the "offline_access" scope. As for the example, using bottom link, the only difference is of the cookie expire time; the rest of the configuration looks the same. I feel like I'm missing something however. Isn't the service of "services.AddAccessTokenManagement();" supposed to automatically refresh the token when expiring? If so, what could be a reason why it doesn't, based on the config in my initial post? (I'm going to edit my initial post to add the scopes I'm using) – Jordy Oct 28 '21 at 22:25
  • Good question. How are you actually retrieving the token when you need it? – mackie Oct 29 '21 at 07:57
  • Either "HttpContext.GetTokenAsync("access_token")" or "HttpContext.GetUserAccessTokenAsync();". In case of the first, the token get's filled from the initial IS4 login, but appears to stay the same during the session. The second way is able to refresh the token one time only and returns the new token (if needed), but doesn't update the token in HttpContext either, so I'm only able to use that token during that function call. I'm expecting the token to be updated for the whole HttpContext, meaning that the updated/changed roles can be used in [Authorize(Roles = "XX")] checks – Jordy Oct 29 '21 at 08:52
  • If it was me I'd be looking to set up a scenario to prove that the automatic renewal is being triggers, either by monitoring the logging or implementing a hook (overridden class, event handler etc) that I can put a breakpoint on or logging in place for. Are you definitely using the HttpClient plumbing that the sample does? If you delve into the code it all hangs off this handler being registered for the HttpClient binding: https://github.com/IdentityModel/IdentityModel.AspNetCore/blob/7a5c695fab848dcfffc790d57abd03eb9c254a93/src/AccessTokenManagement/UserAccessToken/UserAccessTokenHandler.cs – mackie Oct 29 '21 at 09:49
  • The precise version used in the sample: https://github.com/IdentityModel/IdentityModel.AspNetCore/blob/1.0.0-preview.7/src/AccessTokenManagement/TokenManagementServiceCollectionExtensions.cs - you'll see it registers the client and attaches the handler in the method on line 58 – mackie Oct 29 '21 at 09:52
  • Using the shared examples I was able to automatically request refresh_tokens on API calls. However I noticed that the access_tokens after refresh doesn't contain any 'roles' or other properties like 'clientKey' that are part of the initial access_token after successful login. How can I make sure the initial roles/values are always added to the refreshed access_token, making the refreshed access_token a 'clone' of the initial one? – Jordy Nov 01 '21 at 11:11
  • How are those claims added currently? I think there's an option to either use the original claims when first issued or to refresh them. I suspect you may need to override the ClaimsService or similar to have full control over what is returned. – mackie Nov 01 '21 at 18:36
  • By defining the scopes from the client, which are translated on IS4 side into claims using the IdentityResource and IdentityClaims tables. – Jordy Nov 02 '21 at 09:42
  • If I understand correctly, after succesful login the first access_token is generated using the auth-token. The access_token contains clientKey, userType, and other details. When refreshing the access_token using the refresh_token, those details are not part of the refreshed access_token. I've been sifting through IS4, but I can't figure out why it doesn't contain those values. In any case, whether I removed the 'scopes' from the client doesn't change the value of the initial access_token nor the refreshed one. – Jordy Nov 02 '21 at 10:37
  • Made some progress. When disabling the UpdateAccessTokenClaimsOnRefresh option in IS4 the refreshed access_token contains all the values as the initial token. Which makes sense, considering we aren't telling IS4 to update the claims. Now to figure out how to use (enable) UpdateAccessTokenClaimsOnRefresh options while generating the missing values (claims/roles/etc) as well. – Jordy Nov 02 '21 at 11:45