How to handle expired access token in asp.net core using refresh token with OpenId Connect

20,691

Solution 1

It seems there is no programming in the openidconnect authentication for asp.net core to manage the access_token on the server after received.

I found that I can intercept the cookie validation event and check if the access token has expired. If so, make a manual HTTP call to the token endpoint with the grant_type=refresh_token.

By calling context.ShouldRenew = true; this will cause the cookie to be updated and sent back to the client in the response.

I have provided the basis of what I have done and will work to update this answer once all work as been resolved.

app.UseCookieAuthentication(new CookieAuthenticationOptions
        {
            AutomaticAuthenticate = true,
            AutomaticChallenge = true,
            AuthenticationScheme = "Cookies",
            ExpireTimeSpan = new TimeSpan(0, 0, 20),
            SlidingExpiration = false,
            CookieName = "WebAuth",
            Events = new CookieAuthenticationEvents()
            {
                OnValidatePrincipal = context =>
                {
                    if (context.Properties.Items.ContainsKey(".Token.expires_at"))
                    {
                        var expire = DateTime.Parse(context.Properties.Items[".Token.expires_at"]);
                        if (expire > DateTime.Now) //TODO:change to check expires in next 5 mintues.
                        {
                            logger.Warn($"Access token has expired, user: {context.HttpContext.User.Identity.Name}");

                            //TODO: send refresh token to ASOS. Update tokens in context.Properties.Items
                            //context.Properties.Items["Token.access_token"] = newToken;
                            context.ShouldRenew = true;
                        }
                    }
                    return Task.FromResult(0);
                }
            }
        });

Solution 2

You must enable the generation of refresh_token by setting in startup.cs:

  • Setting values to AuthorizationEndpointPath = "/connect/authorize"; // needed for refreshtoken
  • Setting values to TokenEndpointPath = "/connect/token"; // standard token endpoint name

In your token provider, before validating the token request at the end of the HandleTokenrequest method, make sure you have set the offline scope:

        // Call SetScopes with the list of scopes you want to grant
        // (specify offline_access to issue a refresh token).
        ticket.SetScopes(
            OpenIdConnectConstants.Scopes.Profile,
            OpenIdConnectConstants.Scopes.OfflineAccess);

If that is setup properly, you should receive a refresh_token back when you login with a password grant_type.

Then from your client you must issue the following request (I'm using Aurelia):

refreshToken() {
    let baseUrl = yourbaseUrl;

    let data = "client_id=" + this.appState.clientId
               + "&grant_type=refresh_token"
               + "&refresh_token=myRefreshToken";

    return this.http.fetch(baseUrl + 'connect/token', {
        method: 'post',
        body : data,
        headers: {
            'Content-Type': 'application/x-www-form-urlencoded',
            'Accept': 'application/json' 
        }
    });
}

and that's it, make sure that your auth provider in HandleRequestToken is not trying to manipulate the request that is of type refresh_token:

    public override async Task HandleTokenRequest(HandleTokenRequestContext context)
    {
        if (context.Request.IsPasswordGrantType())
        {
            // Password type request processing only
            // code that shall not touch any refresh_token request
        }
        else if(!context.Request.IsRefreshTokenGrantType())
        {
            context.Reject(
                    error: OpenIdConnectConstants.Errors.InvalidGrant,
                    description: "Invalid grant type.");
            return;
        }

        return;
    }

The refresh_token shall just be able to pass through this method and is handled by another piece of middleware that handles refresh_token.

If you want more in depth knowledge about what the auth server is doing, you can have a look at the code of the OpenIdConnectServerHandler:

https://github.com/aspnet-contrib/AspNet.Security.OpenIdConnect.Server/blob/master/src/AspNet.Security.OpenIdConnect.Server/OpenIdConnectServerHandler.Exchange.cs

On the client side you must also be able to handle the auto refresh of the token, here is an example of an http interceptor for Angular 1.X, where one handles 401 reponses, refresh the token, then retry the request:

'use strict';
app.factory('authInterceptorService',
    ['$q', '$injector', '$location', 'localStorageService',
    function ($q, $injector, $location, localStorageService) {

    var authInterceptorServiceFactory = {};
    var $http;

    var _request = function (config) {

        config.headers = config.headers || {};

        var authData = localStorageService.get('authorizationData');
        if (authData) {
            config.headers.Authorization = 'Bearer ' + authData.token;
        }

        return config;
    };

    var _responseError = function (rejection) {
        var deferred = $q.defer();
        if (rejection.status === 401) {
            var authService = $injector.get('authService');
            console.log("calling authService.refreshToken()");
            authService.refreshToken().then(function (response) {
                console.log("token refreshed, retrying to connect");
                _retryHttpRequest(rejection.config, deferred);
            }, function () {
                console.log("that didn't work, logging out.");
                authService.logOut();

                $location.path('/login');
                deferred.reject(rejection);
            });
        } else {
            deferred.reject(rejection);
        }
        return deferred.promise;
    };

    var _retryHttpRequest = function (config, deferred) {
        console.log('autorefresh');
        $http = $http || $injector.get('$http');
        $http(config).then(function (response) {
            deferred.resolve(response);
        },
        function (response) {
            deferred.reject(response);
        });
    }

    authInterceptorServiceFactory.request = _request;
    authInterceptorServiceFactory.responseError = _responseError;
    authInterceptorServiceFactory.retryHttpRequest = _retryHttpRequest;

    return authInterceptorServiceFactory;
}]);

And here is an example I just did for Aurelia, this time I wrapped my http client into an http handler that checks if the token is expired or not. If it is expired it will first refresh the token, then perform the request. It uses a promise to keep the interface with the client-side data services consistent. This handler exposes the same interface as the aurelia-fetch client.

import {inject} from 'aurelia-framework';
import {HttpClient} from 'aurelia-fetch-client';
import {AuthService} from './authService';

@inject(HttpClient, AuthService)
export class HttpHandler {

    constructor(httpClient, authService) {
        this.http = httpClient;
        this.authService = authService;
    }

    fetch(url, options){
        let _this = this;
        if(this.authService.tokenExpired()){
            console.log("token expired");
            return new Promise(
                function(resolve, reject) {
                    console.log("refreshing");
                    _this.authService.refreshToken()
                    .then(
                       function (response) {
                           console.log("token refreshed");
                        _this.http.fetch(url, options).then(
                            function (success) { 
                                console.log("call success", url);
                                resolve(success);
                            }, 
                            function (error) { 
                                console.log("call failed", url);
                                reject(error); 
                            }); 
                       }, function (error) {
                           console.log("token refresh failed");
                           reject(error);
                    });
                }
            );
        } 
        else {
            // token is not expired, we return the promise from the fetch client
            return this.http.fetch(url, options); 
        }
    }
}

For jquery you can look a jquery oAuth:

https://github.com/esbenp/jquery-oauth

Hope this helps.

Solution 3

Following on from @longday's answer, I have had success in using this code to force a client refresh without having to manually query an open id endpoint:

OnValidatePrincipal = context =>
{
    if (context.Properties.Items.ContainsKey(".Token.expires_at"))
    {
        var expire = DateTime.Parse(context.Properties.Items[".Token.expires_at"]);
        if (expire > DateTime.Now) //TODO:change to check expires in next 5 mintues.
        {
            context.ShouldRenew = true;
            context.RejectPrincipal();
        }
    }

    return Task.FromResult(0);
}
Share:
20,691
longday
Author by

longday

Updated on July 05, 2022

Comments

  • longday
    longday almost 2 years

    I have configured an ASOS OpenIdConnect Server using and an asp.net core mvc app that uses the "Microsoft.AspNetCore.Authentication.OpenIdConnect": "1.0.0 and "Microsoft.AspNetCore.Authentication.Cookies": "1.0.0". I have tested the "Authorization Code" workflow and everything works.

    The client web app processes the authentication as expected and creates a cookie storing the id_token, access_token, and refresh_token.

    How do I force Microsoft.AspNetCore.Authentication.OpenIdConnect to request a new access_token when it expires?

    The asp.net core mvc app ignores the expired access_token.

    I would like to have openidconnect see the expired access_token then make a call using the refresh token to get a new access_token. It should also update the cookie values. If the refresh token request fails I would expect openidconnect to "sign out" the cookie (remove it or something).

    app.UseCookieAuthentication(new CookieAuthenticationOptions
            {
                AutomaticAuthenticate = true,
                AutomaticChallenge = true,
                AuthenticationScheme = "Cookies"
            });
    
    app.UseOpenIdConnectAuthentication(new OpenIdConnectOptions
            {
                ClientId = "myClient",
                ClientSecret = "secret_secret_secret",
                PostLogoutRedirectUri = "http://localhost:27933/",
                RequireHttpsMetadata = false,
                GetClaimsFromUserInfoEndpoint = true,
                SaveTokens = true,
                ResponseType = OpenIdConnectResponseType.Code,
                AuthenticationMethod = OpenIdConnectRedirectBehavior.RedirectGet,
                Authority = http://localhost:27933,
                MetadataAddress = "http://localhost:27933/connect/config",
                Scope = { "email", "roles", "offline_access" },
            });