19

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?

Vaccano
  • 78,325
  • 149
  • 468
  • 850
  • I raised a similar issue which may be of interest: https://github.com/AzureAD/microsoft-identity-web/issues/1256 – Mark Hobson Aug 12 '21 at 15:06

4 Answers4

31

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:

  1. 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.
  2. 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.

Vaccano
  • 78,325
  • 149
  • 468
  • 850
1

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.

ches151
  • 61
  • 2
  • It worth to add as of 2022, IdS has refresh token rotation enabled by default. It means that refresh token is automatically updated with each access token request, so you don't have to do anything special, it's all already set. – Niksr Dec 09 '22 at 14:28
1

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.

Gary Archer
  • 22,534
  • 2
  • 12
  • 24
0

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.

RJFalconer
  • 10,890
  • 5
  • 51
  • 66
Nan Yu
  • 26,101
  • 9
  • 68
  • 148