17

I've followed the Quickstart in the documentation page and have a working configuration of three services (IdentityServer, one Api service, one ASPNET MVC application) using IdentityServer for authentication.

Everything works perfectly (login, login, authorization, etc.) until after 1 hour when the access_token expires. At this point, the MVC application starts to receive (correctly) a 401 from the API service (since the token is expired). At that point, I know I should use the refresh_token to get a new access_token.

I was looking for a mechanism that automatically refreshed the access_token and stumbled upon this: https://github.com/mderriey/TokenRenewal/blob/master/src/MvcClient/Startup.cs (from this answer). I tried to use that but it didn't work (the TokenEndpointResponse was null even though the authentication was successful).

I understand how to use a refresh_token to get a new access_token, but after I have it, how would I go inserting it back into the cookie so that future request have access to the new tokens?

Community
  • 1
  • 1
Carlos G.
  • 4,564
  • 4
  • 34
  • 57

6 Answers6

13

The McvHybrid sample has a good example for getting the new access_token and refresh_token back into the principal. Here's a link to the github file with the code, which is located in RenewTokens() as shown below.

    public async Task<IActionResult> RenewTokens()
    {
        var disco = await DiscoveryClient.GetAsync(Constants.Authority);
        if (disco.IsError) throw new Exception(disco.Error);

        var tokenClient = new TokenClient(disco.TokenEndpoint, "mvc.hybrid", "secret");
        var rt = await     HttpContext.Authentication.GetTokenAsync("refresh_token");
        var tokenResult = await tokenClient.RequestRefreshTokenAsync(rt);

        if (!tokenResult.IsError)
        {
            var old_id_token = await HttpContext.Authentication.GetTokenAsync("id_token");
            var new_access_token = tokenResult.AccessToken;
            var new_refresh_token = tokenResult.RefreshToken;

            var tokens = new List<AuthenticationToken>();
            tokens.Add(new AuthenticationToken { Name = OpenIdConnectParameterNames.IdToken, Value = old_id_token });
            tokens.Add(new AuthenticationToken { Name = OpenIdConnectParameterNames.AccessToken, Value = new_access_token });
            tokens.Add(new AuthenticationToken { Name = OpenIdConnectParameterNames.RefreshToken, Value = new_refresh_token });

            var expiresAt = DateTime.UtcNow + TimeSpan.FromSeconds(tokenResult.ExpiresIn);
            tokens.Add(new AuthenticationToken { Name = "expires_at", Value = expiresAt.ToString("o", CultureInfo.InvariantCulture) });

            var info = await HttpContext.Authentication.GetAuthenticateInfoAsync("Cookies");
            info.Properties.StoreTokens(tokens);
            await HttpContext.Authentication.SignInAsync("Cookies", info.Principal, info.Properties);

            return Redirect("~/Home/Secure");
        }

        ViewData["Error"] = tokenResult.Error;
        return View("Error");
    }
LugTread
  • 343
  • 3
  • 12
  • 3
    In the McvHybrid sample RenewTokens are only invoked manually (via clicking link). Do you know an example where refresh token is called when access token is about to expire? – Michael Freidgeim Jul 24 '17 at 03:57
  • You can easily throw the above code into some middleware that is then registered in your pipeline prior to MVC. – LugTread Jul 26 '17 at 20:45
  • 1
    @LugTread I don't suppose you'd happen to have some example of that would you? I'm pretty new to MVC in .NET Core and I have never written middleware, I'm looking into it now but if you've done something like and have a code sample that'd be awesome. Thank you. – bgs264 Aug 27 '17 at 20:16
  • The sample code is updated to the latest version: removed obsolete statements, added httpClientFactory, new extensions. You may want to update the code in your answer as well. –  Sep 20 '18 at 20:15
  • Why we are taking the old identity_token unlike access and refresh tokens? – Simple Code Nov 27 '18 at 10:14
9

As an option to RenewTokens method from MVC Client example, I made one filter that makes the job automatically, when the token is about 10 minutes or less to expire.

public class TokenFilterAttribute : ActionFilterAttribute
{
    public override void OnActionExecuting(ActionExecutingContext filterContext)
    {
        var expat = filterContext.HttpContext.Authentication.GetTokenAsync("expires_at").Result;

        var dataExp = DateTime.Parse(expat, null, DateTimeStyles.RoundtripKind);

        if ((dataExp - DateTime.Now).TotalMinutes < 10)
        {
            var disco = DiscoveryClient.GetAsync("http://localhost:5000/").Result;
            if (disco.IsError) throw new Exception(disco.Error);

            var tokenClient = new TokenClient(disco.TokenEndpoint, "clientId",
                "clientSecret");

            var rt = filterContext.HttpContext.Authentication.GetTokenAsync("refresh_token").Result;
            var tokenResult = tokenClient.RequestRefreshTokenAsync(rt).Result;

            if (!tokenResult.IsError)
            {
                var oldIdToken = filterContext.HttpContext.Authentication.GetTokenAsync("id_token").Result;
                var newAccessToken = tokenResult.AccessToken;
                var newRefreshToken = tokenResult.RefreshToken;

                var tokens = new List<AuthenticationToken>
                {
                    new AuthenticationToken {Name = OpenIdConnectParameterNames.IdToken, Value = oldIdToken},
                    new AuthenticationToken
                    {
                        Name = OpenIdConnectParameterNames.AccessToken,
                        Value = newAccessToken
                    },
                    new AuthenticationToken
                    {
                        Name = OpenIdConnectParameterNames.RefreshToken,
                        Value = newRefreshToken
                    }
                };

                var expiresAt = DateTime.Now + TimeSpan.FromSeconds(tokenResult.ExpiresIn);
                tokens.Add(new AuthenticationToken
                {
                    Name = "expires_at",
                    Value = expiresAt.ToString("o", CultureInfo.InvariantCulture)
                });

                var info = filterContext.HttpContext.Authentication.GetAuthenticateInfoAsync("Cookies").Result;
                info.Properties.StoreTokens(tokens);
                filterContext.HttpContext.Authentication.SignInAsync("Cookies", info.Principal, info.Properties);
            }
        }
    }
}

Usage:

[Authorize]
[TokenFilter]
public class HomeController : Controller
{}
Rafael Trojack
  • 209
  • 1
  • 5
  • 12
  • 1
    Does this work if the access_token itself has expired but the refresh_token is still valid? Or will the middleware redirect you to the ID4 login page before this is able to refresh the access token in that scenario? – Watson Jul 05 '18 at 17:52
5

First, be sure to use IdentityModel library (nuget it). Second, since Auth 2.0 is out there are some breaking changes and HttpContext.Authentication used in Rafaels solution is now obsolete. Here are the changes which should be made to get it up and running as a filter again

var expat = filterContext.HttpContext.Authentication.GetTokenAsync("expires_at").Result;

should become:

var expat = filterContext.HttpContext.GetTokenAsync("expires_at").Result;

var rt = filterContext.HttpContext.Authentication.GetTokenAsync("refresh_token").Result;

should become:

var rt = filterContext.HttpContext.GetTokenAsync("refresh_token").Result;

var oldIdToken = filterContext.HttpContext.Authentication.GetTokenAsync("id_token").Result;

should become

var oldIdToken = filterContext.HttpContext.GetTokenAsync("id_token").Result;

var info = filterContext.HttpContext.Authentication.GetAuthenticateInfoAsync("Cookies").Result;

should become

var info = filterContext.HttpContext.AuthenticateAsync("Cookies").Result;

filterContext.HttpContext.Authentication.SignInAsync("Cookies", info.Principal, info.Properties);

should become

filterContext.HttpContext.SignInAsync("Cookies", info.Principal, info.Properties);

And this is the a whole code:

public class TokenFilterAttribute : ActionFilterAttribute
{
    public override void OnActionExecuting(ActionExecutingContext filterContext)
    {
        var expat = filterContext.HttpContext.GetTokenAsync("expires_at").Result;

        var dataExp = DateTime.Parse(expat, null, DateTimeStyles.RoundtripKind);

        if ((dataExp - DateTime.Now).TotalMinutes < 10)
        {
            var disco = DiscoveryClient.GetAsync("http://localhost:5000/").Result;
            if (disco.IsError) throw new Exception(disco.Error);

            var tokenClient = new TokenClient(disco.TokenEndpoint, "clientId",
            "clientSecret");

            var rt = filterContext.HttpContext.GetTokenAsync("refresh_token").Result;
            var tokenResult = tokenClient.RequestRefreshTokenAsync(rt).Result;

            if (!tokenResult.IsError)
            {
                var oldIdToken = filterContext.HttpContext.GetTokenAsync("id_token").Result;
                var newAccessToken = tokenResult.AccessToken;
                var newRefreshToken = tokenResult.RefreshToken;

                var tokens = new List<AuthenticationToken>
                {
                    new AuthenticationToken {Name = OpenIdConnectParameterNames.IdToken, Value = oldIdToken},
                    new AuthenticationToken
                    {
                        Name = OpenIdConnectParameterNames.AccessToken,
                        Value = newAccessToken
                    },
                    new AuthenticationToken
                    { 
                        Name = OpenIdConnectParameterNames.RefreshToken,
                        Value = newRefreshToken
                    }
                };

                var expiresAt = DateTime.Now + TimeSpan.FromSeconds(tokenResult.ExpiresIn);
                tokens.Add(new AuthenticationToken
                {
                    Name = "expires_at",
                    Value = expiresAt.ToString("o", CultureInfo.InvariantCulture)
                });

                var info = filterContext.HttpContext.AuthenticateAsync("Cookies").Result;
                info.Properties.StoreTokens(tokens);  
                filterContext.HttpContext.SignInAsync("Cookies", info.Principal, info.Properties);
            }
        }
    }
}

Usаge is the same as Rafael showed.

Ivaylo
  • 467
  • 6
  • 19
  • 1
    Note that both `DiscoveryClient` and `TokenClient` are now obsolete. [This](https://bartwullems.blogspot.com/2019/04/identitymodel-discoveryclient-is.html) article does a good job explaining the details. – McGuireV10 Dec 16 '19 at 17:54
3

The link you provided to https://github.com/mderriey/TokenRenewal/blob/master/src/MvcClient/Startup.cs really helped me!

The gotcha was in the AddOpenIdConnect section. The event you want is not the OnTokenValidated event. You should use the OnTokenResponseReceived event. It's at that point you'll have a proper access_token and refresh_token to add to the cookie.

kduenke
  • 181
  • 4
  • This little nugget of information has finally helped me after hours of pulling out my hair! All the examples I found referenced `OnTokenValidated`, yet the `TokenEndpointResponse` was always null, either I'm doing something wrong or something has changed, either way, `OnTokenResponseReceived` got my solution working. – XN16 Jun 19 '20 at 13:51
3

The IdentityServer4-Github has another (new?) MvcAutomaticTokenManagement example.

StartUp.cs calls the extension-method AddAutomaticTokenManagement(), which in turn calls lots of other stuff. Because the links in some other answers turned invalid, I would love to include all, but it is way too much code (and too many files) to quote - go check it out.

Most relevant(?) part:

    public override async Task ValidatePrincipal(CookieValidatePrincipalContext context)
    {
        // [removed about 20 lines of code to get and check tokens here...]
        if (dtRefresh < _clock.UtcNow)
        {
            var shouldRefresh = _pendingRefreshTokenRequests.TryAdd(refreshToken.Value, true);
            if (shouldRefresh)
            {
                try
                {
                    var response = await _service.RefreshTokenAsync(refreshToken.Value);

                    if (response.IsError)
                    {
                        _logger.LogWarning("Error refreshing token: {error}", response.Error);
                        return;
                    }

                    context.Properties.UpdateTokenValue("access_token", response.AccessToken);
                    context.Properties.UpdateTokenValue("refresh_token", response.RefreshToken);

                    var newExpiresAt = DateTime.UtcNow + TimeSpan.FromSeconds(response.ExpiresIn);
                    context.Properties.UpdateTokenValue("expires_at", newExpiresAt.ToString("o", CultureInfo.InvariantCulture));

                    await context.HttpContext.SignInAsync(context.Principal, context.Properties);
                }
                finally
                {
                    _pendingRefreshTokenRequests.TryRemove(refreshToken.Value, out _);
                }
            }
        }
    }

    public override async Task SigningOut(CookieSigningOutContext context)
    {
        // [removed about 15 lines of code to get and check tokens here...]
        var response = await _service.RevokeTokenAsync(refreshToken.Value);
        if (response.IsError)
        {
            _logger.LogWarning("Error revoking token: {error}", response.Error);
            return;
        }
    }
foldinglettuce
  • 522
  • 10
  • 28
Yahoo Serious
  • 3,728
  • 1
  • 33
  • 37
  • The implementation presented by Dominick Baier is really clean and works flawlessly - there is just one more dependency, the IdentityModel. Thanks @YahooSerious for pointing this resource. – Ivaylo Aug 08 '19 at 11:20
  • I'm having a problem with this in that the `context.Properties.GetTokens()` is stale – DaleyKD Jan 23 '20 at 02:46
  • If I added `context.ShouldRenew = true` to right before the `SignInAsync`, it seems to ensure that my tokens aren't stale next time. – DaleyKD Jan 23 '20 at 03:04
2

I made middleware that makes the job automatically, when more than half of the life of the access token has passed. So you don't need to call any method or apply any filter. Just insert this into Startup.cs and whole application is covered:

public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
    // Other code here

    app.UseAutomaticSilentRenew("http://localhost:5000/", "clientId", "clientSecret")
    app.UseAccessTokenLifetime();

    // And here
}

UseAutomaticSilentRenew - Renews access and refresh tokens
UseAccessTokenLifetime - Signs user out if access token is expired. Put this after UseAutomaticSilentRenew to make it work only if the UseAutomaticSilentRenew failed to obtain new access token earlier.

Implementation:

public static class OidcExtensions
{
    public static IApplicationBuilder UseAutomaticSilentRenew(this IApplicationBuilder builder, string authority, string clientId, string clientSecret, string cookieSchemeName = CookieAuthenticationDefaults.AuthenticationScheme)
    {
        return builder.UseMiddleware<AutomaticSilentRenewMiddleware>(authority, clientId, clientSecret, cookieSchemeName);
    }

    public static IApplicationBuilder UseAccessTokenLifetime(this IApplicationBuilder builder, string cookieSchemeName = CookieAuthenticationDefaults.AuthenticationScheme)
    {
        return builder.UseMiddleware<TokenLifetimeMiddleware>(OpenIdConnectParameterNames.AccessToken, cookieSchemeName);
    }

    public static IApplicationBuilder UseIdTokenLifetime(this IApplicationBuilder builder, string cookieSchemeName = CookieAuthenticationDefaults.AuthenticationScheme)
    {
        return builder.UseMiddleware<TokenLifetimeMiddleware>(OpenIdConnectParameterNames.IdToken, cookieSchemeName);
    }
}

public class AutomaticSilentRenewMiddleware
{
    private readonly RequestDelegate next;
    private readonly string authority;
    private readonly string clientId;
    private readonly string clientSecret;
    private readonly string cookieSchemeName;

    public AutomaticSilentRenewMiddleware(RequestDelegate next, string authority, string clientId, string clientSecret, string cookieSchemeName)
    {
        this.next = next;
        this.authority = authority;
        this.clientId = clientId;
        this.clientSecret = clientSecret;
        this.cookieSchemeName = cookieSchemeName;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        string oldAccessToken = await context.GetTokenAsync(OpenIdConnectParameterNames.AccessToken);
        if (!string.IsNullOrEmpty(oldAccessToken))
        {
            JwtSecurityToken tokenInfo = new JwtSecurityToken(oldAccessToken);

            // Renew access token if pass halfway of its lifetime
            if (tokenInfo.ValidFrom + (tokenInfo.ValidTo - tokenInfo.ValidFrom) / 2 < DateTime.UtcNow)
            {
                string tokenEndpoint;
                var disco = await DiscoveryClient.GetAsync(authority);
                if (!disco.IsError)
                {
                    tokenEndpoint = disco.TokenEndpoint;
                }
                else
                {
                    // If failed to get discovery document use default URI
                    tokenEndpoint = authority + "/connect/token";
                }
                TokenClient tokenClient = new TokenClient(tokenEndpoint, clientId, clientSecret);
                string oldRefreshToken = await context.GetTokenAsync(OpenIdConnectParameterNames.RefreshToken);
                TokenResponse tokenResult = await tokenClient.RequestRefreshTokenAsync(oldRefreshToken);

                if (!tokenResult.IsError)
                {
                    string idToken = await context.GetTokenAsync(OpenIdConnectParameterNames.IdToken);
                    string newAccessToken = tokenResult.AccessToken;
                    string newRefreshToken = tokenResult.RefreshToken;

                    var tokens = new List<AuthenticationToken>
                    {
                        new AuthenticationToken { Name = OpenIdConnectParameterNames.IdToken, Value = idToken },
                        new AuthenticationToken { Name = OpenIdConnectParameterNames.AccessToken, Value = newAccessToken },
                        new AuthenticationToken { Name = OpenIdConnectParameterNames.RefreshToken, Value = newRefreshToken }
                    };

                    AuthenticateResult info = await context.AuthenticateAsync(cookieSchemeName);
                    info.Properties.StoreTokens(tokens);
                    await context.SignInAsync(cookieSchemeName, info.Principal, info.Properties);
                }
            }
        }

        await next.Invoke(context);
    }
}

public class TokenLifetimeMiddleware
{
    private readonly RequestDelegate next;
    private readonly string tokenName;
    private readonly string cookieSchemeName;

    public TokenLifetimeMiddleware(RequestDelegate next, string tokenName, string cookieSchemeName)
    {
        this.next = next;
        this.tokenName = tokenName;
        this.cookieSchemeName = cookieSchemeName;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        string token = await context.GetTokenAsync(tokenName);
        if (!string.IsNullOrEmpty(token))
        {
            DateTime validTo = new JwtSecurityToken(token).ValidTo;
            if (validTo < DateTime.UtcNow)
            {
                // Sign out if token is no longer valid
                await context.SignOutAsync(cookieSchemeName);
            }
        }

        await next.Invoke(context);
    }
}

Note: I didn't set cookie expiration time because in our case it depends on refresh token lifetime witch is not provided by identity server. If I'd aligned expiration of the cookie with the expiration of the access token I would't be able to refresh access token after its expiration.

Oh, and another thing. UseAccessTokenLifetime clears the cookie but doesn't signs out the user. Sign out occurs after you reload the page. Didn't find a way to fix it.

maxc137
  • 2,291
  • 3
  • 20
  • 32
  • Will this code handle the refresh token having expired or been removed? – Linda Lawton - DaImTo Sep 06 '19 at 06:03
  • If refresh token is expired (or doesn't exits for some reason), there is nothing we can do to renew access token. So UseAutomaticSilentRenew won't do anything and then UseAccessTokenLifetime will logout the user if access token is expired – maxc137 Sep 07 '19 at 07:54
  • If you want to log the user out and redirect them to a page, add this to InvokeAsync in the TokenLifetimeMiddle.cs: ... await context.SignOutAsync(cookieSchemeName); await context.SignOutAsync(OpenIdConnectDefaults.AuthenticationScheme, new AuthenticationProperties { RedirectUri = "/Home/LogoutComplete" }); – Fordy Sep 24 '21 at 17:53