IdentityServer4 - Using Refresh Tokens after follo

2019-01-17 18:29发布

问题:

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?

回答1:

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");
    }


回答2:

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
{}


回答3:

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.



回答4:

I made middleware that makes the job automatically, when more than half of the life of the token passed. So you didn'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 out user if access token is expired. Put this second 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 aligned the 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.



回答5:

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.