playtoh
playtoh

Reputation: 391

OpenIdDict (Code Flow) - Handling Access Token Expiration

I'm working on a refactor in a ASP.Net Core 2.1 application to swap from the Implicit flow using a SPA, to an Authorization Code flow using an MVC client app. Since we're using the OpenIDDict library, I followed the documented Code Flow Example which was fantastic in getting up and running, but I quickly found that my access tokens were expiring and (as expected) the resource server began refusing requests.

My question is: How best do I refresh the access token?

I am new to OpenID Connect in general, but I understand the patterns in theory from the multitude of resources available. The verbiage is still a bit opaque to me (grant, principal, scopes, etc.), but given a good example I'm confident I can get this going.

Thanks in advance!

What I've tried:

Based on what seemed like similar questions, I attempted to implement a refresh token flow using the Refresh Flow example from the same source above. Although I believe I got the auth server plumbing setup correctly, I was unable to find any examples of this using a C# client (the above example uses an angular app).

Edit: When I send a post to my token endpoint with the refresh_token grant, I correctly get back a new access token. My issue is that I'm not sure how best to handle it from there. GetTokenAsync continues to return the stale token.

Client Startup:

services.AddAuthentication(options =>
{
    options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
})                
.AddCookie(options =>
{
    options.LoginPath = new PathString("/signin");     
})
.AddOpenIdConnect(options =>
{                    
    // Note: these settings must match the application details
    // inserted in the database at the server level.
    options.ClientId = "Portal"; //TODO replace via configuration   
    options.ClientSecret = "---";                                             

    options.RequireHttpsMetadata = false;
    options.GetClaimsFromUserInfoEndpoint = true;
    options.SaveTokens = true;

    options.ResponseType = OpenIdConnectResponseType.CodeIdToken;
    options.AuthenticationMethod = OpenIdConnectRedirectBehavior.RedirectGet;                    

    // Note: setting the Authority allows the OIDC client middleware to automatically
    // retrieve the identity provider's configuration and spare you from setting
    // the different endpoints URIs or the token validation parameters explicitly.
    options.Authority = "https://localhost:57851"; //TODO replace via configuration

    options.Scope.Add("email");
    options.Scope.Add("roles");                     
    options.Scope.Add("offline_access");

    options.SecurityTokenValidator = new JwtSecurityTokenHandler
    {
        // Disable the built-in JWT claims mapping feature.
        InboundClaimTypeMap = new Dictionary<string, string>()
    };

    options.TokenValidationParameters.NameClaimType = "name";
    options.TokenValidationParameters.RoleClaimType = "role";
});

Auth Startup:

.AddServer(options =>
            {
                // Register the ASP.NET Core MVC services used by OpenIddict.
                // Note: if you don't call this method, you won't be able to
                // bind OpenIdConnectRequest or OpenIdConnectResponse parameters.
                options.UseMvc();

                // Enable the authorization, logout, token and userinfo endpoints.
                options.EnableAuthorizationEndpoint("/connect/authorize")
                    .EnableLogoutEndpoint("/connect/logout")
                    .EnableTokenEndpoint("/connect/token")
                    .EnableUserinfoEndpoint("/api/userinfo");

                options
                    .AllowAuthorizationCodeFlow()
                    .AllowRefreshTokenFlow();

                // Mark the "email", "profile" and "roles" scopes as supported scopes.
                options.RegisterScopes(
                    OpenIdConnectConstants.Scopes.Email,
                    OpenIdConnectConstants.Scopes.Profile,
                    OpenIddictConstants.Scopes.Roles,
                    OpenIddictConstants.Scopes.OfflineAccess);

                // When request caching is enabled, authorization and logout requests
                // are stored in the distributed cache by OpenIddict and the user agent
                // is redirected to the same page with a single parameter (request_id).
                // This allows flowing large OpenID Connect requests even when using
                // an external authentication provider like Google, Facebook or Twitter.
                options.EnableRequestCaching();

                // During development, you can disable the HTTPS requirement.
                if (env.IsDevelopment())
                {
                    options.DisableHttpsRequirement();
                    options.AddEphemeralSigningKey(); // TODO: In production, use a X.509 certificate ?
                }

                options.SetAccessTokenLifetime(TimeSpan.FromMinutes(openIdConnectConfig.AccessTokenLifetimeInMinutes));
                options.SetRefreshTokenLifetime(TimeSpan.FromHours(12));                    
            })
            .AddValidation();

Descriptor:

var descriptor = new OpenIddictApplicationDescriptor{
ClientId = config.Id,
ClientSecret = config.Secret,
DisplayName = config.DisplayName,                    
PostLogoutRedirectUris = { new Uri($"{config.ClientOrigin}/signout-callback-oidc") },
RedirectUris = { new Uri($"{config.ClientOrigin}/signin-oidc") },
Permissions =
{
    OpenIddictConstants.Permissions.Endpoints.Authorization,
    OpenIddictConstants.Permissions.Endpoints.Logout,
    OpenIddictConstants.Permissions.Endpoints.Token,
    OpenIddictConstants.Permissions.GrantTypes.AuthorizationCode,                        
    OpenIddictConstants.Permissions.GrantTypes.RefreshToken,                        
    OpenIddictConstants.Permissions.Scopes.Email,
    OpenIddictConstants.Permissions.Scopes.Profile,
    OpenIddictConstants.Permissions.Scopes.Roles
}};

Token Endpoint:

if (request.IsRefreshTokenGrantType()){
// Retrieve the claims principal stored in the refresh token.
var info = await HttpContext.AuthenticateAsync(OpenIdConnectServerDefaults.AuthenticationScheme);

// Retrieve the user profile corresponding to the refresh token.
// Note: if you want to automatically invalidate the refresh token
// when the user password/roles change, use the following line instead:
// var user = _signInManager.ValidateSecurityStampAsync(info.Principal);
var user = await _userManager.GetUserAsync(info.Principal);
if (user == null)
{
    return BadRequest(new OpenIdConnectResponse
    {
        Error = OpenIdConnectConstants.Errors.InvalidGrant,
        ErrorDescription = "The refresh token is no longer valid."
    });
}

// Ensure the user is still allowed to sign in.
if (!await _signInManager.CanSignInAsync(user))
{
    return BadRequest(new OpenIdConnectResponse
    {
        Error = OpenIdConnectConstants.Errors.InvalidGrant,
        ErrorDescription = "The user is no longer allowed to sign in."
    });
}

// Create a new authentication ticket, but reuse the properties stored
// in the refresh token, including the scopes originally granted.
var ticket = await CreateTicketAsync(request, user, info.Properties);
ticket.SetScopes(OpenIdConnectConstants.Scopes.OfflineAccess);      

return SignIn(ticket.Principal, ticket.Properties, ticket.AuthenticationScheme);}

Upvotes: 3

Views: 4943

Answers (1)

Drew Fleming
Drew Fleming

Reputation: 347

It is acceptable to attempt to call the protected resource with your access token even if it might be expired, invalid, etc. If the protected resource rejects the token, you can attempt to get a new access token by sending a POST to the /token endpoint with the refresh token. Here's some JS but the concept still applies.

var refreshAccessToken = function(req, res) {
    var form_data = qs.stringify(
    {
        grant_type: 'refresh_token',
        refresh_token: refresh_token
    });
    var headers = {
        'Content-Type': 'application/x-www-form-urlencoded',
        'Authorization': 'Basic ' + encodeClientCredentials(client.client_id, 
                                                            client.client_secret)
    };
    console.log('Refreshing token %s', refresh_token);
    var tokRes = request('POST', authServer.tokenEndpoint, {    
            body: form_data,
            headers: headers
    });
    if (tokRes.statusCode >= 200 && tokRes.statusCode < 300) {
        var body = JSON.parse(tokRes.getBody());

        access_token = body.access_token;
        console.log('Got access token: %s', access_token);
        if (body.refresh_token) {
            refresh_token = body.refresh_token;
            console.log('Got refresh token: %s', refresh_token);
        }
        scope = body.scope;
        console.log('Got scope: %s', scope);

        // try again
        res.redirect('/fetch_resource');
        return;
    } else {
        console.log('No refresh token, asking the user to get a new access token');
        // tell the user to get a new access token
        refresh_token = null;
        res.render('error', {error: 'Unable to refresh token.'});
        return;
    }
};

Upvotes: -1

Related Questions