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

3

20

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" },
        });
Lodestar answered 13/10, 2016 at 23:55 Comment(0)
L
24

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);
                }
            }
        });
Lodestar answered 18/10, 2016 at 20:12 Comment(10)
Should (expire > DateTime.Now) actually become (expire > DateTime.UtcNow) ? docs.asp.net/en/latest/security/authentication/cookie.html suggests the use of utc time.Joleen
No, the datetime.parse reads the ".Token.expires_at" value in as UTC and parses to local time. ".Token.expires_at "2016-10-19T16:02:39.0008091+00:00".Lodestar
@Lodestar FWIW, this is also how I'd implement that. You should mark your answer as the accepted one ;)Kislev
I want to do exactly as the above scenario is laid out - I have a token that has expired, but i have code deeper that uses: _authenticationResult = await authContext.AcquireTokenSilentAsync(_authConfigOptions.AzureAd.WebserviceAppIdUri.ToString(), credential, new UserIdentifier(userObjectID, UserIdentifierType.UniqueId)); Is there someway I can push this to use the authcontext default tokencache?Askwith
the expires_at should also be updated, otherwise the if (expire < DateTime.Now) will always return false for each request once the first token expires and you will get a new token in each request. In addition, it should be expire > DateTime.Now or expire < DateTime.Now?Novokuznetsk
Should you really return a return Task.FromResult(0) in the OnValidatePrincipal? Why? And could Task.CompletedTask be used instead?Misbeliever
Should be expire < DateTime.Now. Also, Task.CompletedTask can be used if supported.Dvina
Thank you for your post, @longday. Now I am having a problem implementing your solution in asp.net project - not asp.net core. In asp.net CookieAuthenticationOptions, there is no property named Events. Would you tell me how to solve this problem? Thanks again.Kelsy
You could use DateTimeOffset.Parse and it should keep it as UTC.Choanocyte
@Harry, in classic ASP.net, the CookieAuthenticationOptions has a Provider property. By implementing a class that inherits from CookieAuthenticationProvider and overriding one of its methods, you can do something very similar to the Events shown above.Demagogy
J
4

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.

Joleen answered 16/10, 2016 at 2:24 Comment(3)
How do I get the client side to auto process an expired access_token by requesting a new token using the refresh_token? I am using client library "Microsoft.AspNetCore.Authentication.OpenIdConnect": "1.0.0. If I have to process the refresh token manually, what are the best methods? How do I update the client cookie?Lodestar
The implementation depends on your frontend framework... I am using OAuth 2 tokens so I actually set the token in a cookie myself before sending it back to the client... If the cookie has the same name it will be replaced by the new one. You have 2 ways of doing the refresh automatically: 1) Preventive, where you check the expiry time of the token and refresh BEFORE performing the actually request 2) Reactive: you listen to http statuses in your client, and if you get a 401, you refresh the token then retry the request. If you are using Angular or Aurelia, you can configure an http interceptorJoleen
I have added an example to my answer for angular 1.X, hope this helps.Joleen
E
4

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);
}
Evette answered 17/2, 2020 at 10:3 Comment(2)
Please update with full code,Navvy
Check @longday's answer above. My answer is a small slice of that codeEvette

© 2022 - 2024 — McMap. All rights reserved.