Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Handling Expired Refresh Tokens in ASP.NET Core

See below for code that solved this issue

I'm trying to find the best and most efficient way to deal with a refresh token that has expired within ASP.NET Core 2.1.

Let me explain a bit more.

I am using OAUTH2 and OIDC to request Authorization Code grant flows (or Hybrid flow with OIDC). This flow/grant type gives me access to an AccessToken, and a RefreshToken (Authorization Code as well, but that is not for this question).

The access token and refresh token are stored by ASP.NET core, and can be retrieved using HttpContext.GetTokenAsync("access_token"); and HttpContext.GetTokenAsync("refresh_token"); respectively.

I can refresh the access_token without any issues. The issue comes into play when the refresh_token is expired, revoked or invalid in some way.

The correct flow would be to have the user log in and go back though the entire authentication flow again. Then the application gets a new set of tokens returned.

My question is how can I achieve this in the best and most correct method. I decided to write a custom middleware that attempts to renew the access_token if it has expired. The middleware then sets the new token into the AuthenticationProperties for the HttpContext so it can be used by any calls later down the pipe.

If refreshing the token fails for any reason, I need to call ChallengeAsync again. I am calling ChallengeAsync from the middleware.

This is where I am running into some interesting behavior. Most of the time this works, however, sometimes I'll get 500 errors with no helpful information as to what is failing. It almost seems like the middleware is having issues trying to call ChallengeAsync from the middleware, and maybe another middleware is also trying to access the context.

I'm not quite sure what is going on. I'm not quite sure if this is the right place to put this logic or not. Maybe I should not have this in middleware, maybe somewhere else. Maybe Polly for the HttpClient is the best place.

I'm open for any ideas.

Thanks for any help you can provide.

Code solution that worked for me


Thanks to Mickaël Derriey for the help and direction (be sure to see his answer for more information in the context of this solution). This is the solution that I've come up with, and it's working for me:

options.Events = new CookieAuthenticationEvents
{
    OnValidatePrincipal = context =>
    {
        //check to see if user is authenticated first
        if (context.Principal.Identity.IsAuthenticated)
        {
            //get the user's tokens
            var tokens = context.Properties.GetTokens();
            var refreshToken = tokens.FirstOrDefault(t => t.Name == "refresh_token");
            var accessToken = tokens.FirstOrDefault(t => t.Name == "access_token");
            var exp = tokens.FirstOrDefault(t => t.Name == "expires_at");
            var expires = DateTime.Parse(exp.Value);
            //check to see if the token has expired
            if (expires < DateTime.Now)
            {
                //token is expired, let's attempt to renew
                var tokenEndpoint = "https://token.endpoint.server";
                var tokenClient = new TokenClient(tokenEndpoint, clientId, clientSecret);
                var tokenResponse = tokenClient.RequestRefreshTokenAsync(refreshToken.Value).Result;
                //check for error while renewing - any error will trigger a new login.
                if (tokenResponse.IsError)
                {
                    //reject Principal
                    context.RejectPrincipal();
                    return Task.CompletedTask;
                }
                //set new token values
                refreshToken.Value = tokenResponse.RefreshToken;
                accessToken.Value = tokenResponse.AccessToken;
                //set new expiration date
                var newExpires = DateTime.UtcNow + TimeSpan.FromSeconds(tokenResponse.ExpiresIn);
                exp.Value = newExpires.ToString("o", CultureInfo.InvariantCulture);
                //set tokens in auth properties 
                context.Properties.StoreTokens(tokens);
                //trigger context to renew cookie with new token values
                context.ShouldRenew = true;
                return Task.CompletedTask;
            }
        }
        return Task.CompletedTask;
    }
};
like image 542
bugnuker Avatar asked Sep 04 '18 23:09

bugnuker


Video Answer


2 Answers

The access token and refresh token are stored by ASP.NET core

I think it's important to note that the tokens are stored in the cookie that identifies the user to your application.

Now this is my opinion, but I don't think a custom middleware is the right place to refresh tokens. The reason for this is that if you successfully refresh the token, you'll need to replace the existing one and send it back to the browser, in the form of a new cookie that will replace the existing one.

This is why I think the most relevant place to do this is when the cookie is being read by ASP.NET Core. Every authentication mechanism exposes several events; for cookies, there's one called ValidatePrincipal which is called on every request after the cookie has been read and an identity has succesfully been deserialized from it.

public void ConfigureServices(ServiceCollection services)
{
    services
        .AddAuthentication()
        .AddCookies(new CookieAuthenticationOptions
        {
            Events = new CookieAuthenticationEvents
            {
                OnValidatePrincipal = context =>
                {
                    // context.Principal gives you access to the logged-in user
                    // context.Properties.GetTokens() gives you access to all the tokens

                    return Task.CompletedTask;
                }
            }
        });
}

The nice thing about this approach is that if you manage to renew the token and store it in the AuthenticationProperties, the context variable which is of type CookieValidatePrincipalContext, has a property called ShouldRenew. Setting that property to true instructs the middleware to issue a new cookie.

If you can't renew the token or you find the refresh token is expired and you want to prevent the user from going forward, that same class has a RejectPrincipal method which instructs the cookie middleware to treat the request as if it was aonymous.

The nice thing about this is that if your MVC app only allows authenticated users to access it, MVC will take care of issuing the HTTP 401 response which the authentication system will catch and turn into a Challenge and the user will be redirected back to the Identity Provider.

I have some code that shows how this would work over at the mderriey/TokenRenewal repository on GitHub. While the intent is different, it shows the mechanics of how to use these events.

like image 128
Mickaël Derriey Avatar answered Sep 18 '22 10:09

Mickaël Derriey


I have created an alternative implementation that has some additional benefits:

  • Compatible with ASP.NET Core v3.1
  • Re-uses the OpenID configuration options that have been passed to the AddOpenIdConnect method. This makes client-configuration a bit easier.
  • Uses the Open ID Connect discovery document to determine the token end-point. You could choose to cache the configuration to save an additional roundtrip to Identity Server.
  • Doesn't block the thread during the authentication calls (async operation) improving scalability.

This is the updated OnValidatePrincipal method:

private async Task OnValidatePrincipal(CookieValidatePrincipalContext context)
{
    const string accessTokenName = "access_token";
    const string refreshTokenName = "refresh_token";
    const string expirationTokenName = "expires_at";

    if (context.Principal.Identity.IsAuthenticated)
    {
        var exp = context.Properties.GetTokenValue(expirationTokenName);
        if (exp != null)
        {
            var expires = DateTime.Parse(exp, CultureInfo.InvariantCulture).ToUniversalTime();
            if (expires < DateTime.UtcNow)
            {
                // If we don't have the refresh token, then check if this client has set the
                // "AllowOfflineAccess" property set in Identity Server and if we have requested
                // the "OpenIdConnectScope.OfflineAccess" scope when requesting an access token.
                var refreshToken = context.Properties.GetTokenValue(refreshTokenName);
                if (refreshToken == null)
                {
                    context.RejectPrincipal();
                    return;
                }

                var cancellationToken = context.HttpContext.RequestAborted;

                // Obtain the OpenIdConnect options that have been registered with the
                // "AddOpenIdConnect" call. Make sure we get the same scheme that has
                // been passed to the "AddOpenIdConnect" call.
                //
                // TODO: Cache the token client options
                // The OpenId Connect configuration will not change, unless there has
                // been a change to the client's settings. In that case, it is a good
                // idea not to refresh and make sure the user does re-authenticate.
                var serviceProvider = context.HttpContext.RequestServices;
                var openIdConnectOptions = serviceProvider.GetRequiredService<IOptionsSnapshot<OpenIdConnectOptions>>().Get(OpenIdConnectScheme);
                var configuration = openIdConnectOptions.Configuration ?? await openIdConnectOptions.ConfigurationManager.GetConfigurationAsync(cancellationToken).ConfigureAwait(false);
                
                // Set the proper token client options
                var tokenClientOptions = new TokenClientOptions
                {
                    Address = configuration.TokenEndpoint,
                    ClientId = openIdConnectOptions.ClientId,
                    ClientSecret = openIdConnectOptions.ClientSecret
                };
                
                var httpClientFactory = serviceProvider.GetService<IHttpClientFactory>();
                using var httpClient = httpClientFactory.CreateClient();

                var tokenClient = new TokenClient(httpClient, tokenClientOptions);
                var tokenResponse = await tokenClient.RequestRefreshTokenAsync(refreshToken, cancellationToken: cancellationToken).ConfigureAwait(false);
                if (tokenResponse.IsError)
                {
                    context.RejectPrincipal();
                    return;
                }

                // Update the tokens
                var expirationValue = DateTime.UtcNow.AddSeconds(tokenResponse.ExpiresIn).ToString("o", CultureInfo.InvariantCulture);
                context.Properties.StoreTokens(new []
                {
                    new AuthenticationToken { Name = refreshTokenName, Value = tokenResponse.RefreshToken },
                    new AuthenticationToken { Name = accessTokenName, Value = tokenResponse.AccessToken },
                    new AuthenticationToken { Name = expirationTokenName, Value = expirationValue }
                });

                // Update the cookie with the new tokens
                context.ShouldRenew = true;
            }
        }
    }
}

like image 25
Ramon de Klein Avatar answered Sep 20 '22 10:09

Ramon de Klein