2019年9月4日 星期三

[ASP.NET Core] Identity Server 4 – Refresh Token


 ASP.NET Core   Identity Server 4   Refresh Token  


Introduction


We use Access Token to access the secured resources, and Access Token typically has a limited and short lifetime to decrease the risk of token-stealing. 
However, most times we don’t want the end user to re-sign-in and get new Access Token when the Access Token expires.
The Refresh Token can be optionally issued together when issuing an Access Token, thus the client side can refresh token automatically and doesn’t need to bother end-user.

The process is as following:
·       Backend: API Server
·       Auth Server: Identity Server 4






We will focus on creating the Refreshing Token API on backend.


Environment


docker-openldap 1.2.4 (OpenLDAP 2.4.47)
ASP.NET Core 2.2.203
IdentityServer4 2.4.0
Nordes/IdentityServer4.LdapExtension 2.1.8



Implement


The source code is on my Github.


Identity Server 4 (Auth Server)

We can have more settings on Refresh Token in Identity Server 4:

Configuration
Description
Values
RefreshTokenUsage
How Refresh Token works
OneTimeOnly: The Refresh Token can only be used once
ReUse: The Refresh Token can be used many times before it expires
RefreshTokenExpiration
Lifetime
Absolute: the refresh token will expire on a fixed point in time (specified by the AbsoluteRefreshTokenLifetime)

Sliding: when refreshing the token, the lifetime of the refresh token will be renewed (by the amount specified in SlidingRefreshTokenLifetime). The lifetime will not exceed AbsoluteRefreshTokenLifetime.
AbsoluteRefreshTokenLifetime
Absolute life time
number (seconds)
SlidingRefreshTokenLifetime
Sliding life time
number (seconds)


Open InMemoryInitConfig.cs and set the above configuration on new or exist client:

InMemoryInitConfig.cs

public class InMemoryInitConfig
{
        // Skip the GetIdentityResources() and GetApiResources() functions…

        public static IEnumerable<Client> GetClients()
        {
            return new[]
            {
                new Client
                {
                    Enabled = true,
                    ClientId = "MyBackend",
                    ClientName = "MyBackend Client",
                    AllowedGrantTypes = GrantTypes.ResourceOwnerPassword,
                    AccessTokenType = AccessTokenType.Jwt,
                    AllowedScopes = {
                        "MyBackendApi1",
                        "MyBackendApi2",
                        IdentityServerConstants.StandardScopes.OpenId,
                        IdentityServerConstants.StandardScopes.Email,
                        IdentityServerConstants.StandardScopes.Profile,
                    },
                    AlwaysSendClientClaims = true,
                    UpdateAccessTokenClaimsOnRefresh = true,
                    AlwaysIncludeUserClaimsInIdToken = true,
                    AllowAccessTokensViaBrowser = true,
                    IncludeJwtId = true,
                    ClientSecrets = { new Secret("secret".Sha256()) },
                    AllowOfflineAccess = true,
                    AccessTokenLifetime = 3600,

                    RefreshTokenUsage = TokenUsage.OneTimeOnly, // Or ReUse
                    RefreshTokenExpiration = TokenExpiration.Sliding,
                    AbsoluteRefreshTokenLifetime = 360000,
                    SlidingRefreshTokenLifetime = 36000,
                }
            };
        }
    }



Refresh token’s Life time

Lets take a deeper look into the life time of a Refresh token.
Here are the three options of Refresh Token’s life time (when defining Idsrv4’s Clients):

Option
Description
RefreshTokenExpiration
Absolute or Sliding
AbsoluteRefreshTokenLifetime
Fixed expiry
SlidingRefreshTokenLifetime
Sliding expiry and works when setting RefreshTokenExpirtation as “Sliding” but when the total lifetime cannot exceed AbsoluteRefreshTokenLifetime.


The scenarios are as following.

RefreshTokenExpiration = Absolute and AbsoluteRefreshTokenLifetime = 3,600(1 hour)

ð  We can refresh token as many times as you want when (now – the time issuing the FIRST-TIME refresh token) < AbsoluteRefreshTokenLifetime

ð  Every Refresh Token’s won’t expire inside the 10 mins.

ð  Notice setting SlidingRefreshTokenLifetime is no meaning in this case.


For example, when Idsrv4 issues the first refresh-token by user’s credentials on 20:00, you cannot refresh token anymore after 21:00.







RefreshTokenExpiration = Sliding and AbsoluteRefreshTokenLifetime = 3,600(1 hour) and SlidingRefreshTokenLifetime = 300(5 mins)

ð  We can refresh token when (now – the time issuing the FIRST-TIME refresh token) < AbsoluteRefreshTokenLifetime

ð  But every Refresh Token is only alive for 5 min.


For example, when Idsrv4 issues the first refresh-token by user’s credentials on 20:00, you can refresh token before 21:00.
While refreshing token at 20:30, you will get new tokens including of a new Refresh Token. Refreshing token with this new Refresh Token after 20:35 will get the error: "invalid_grant".






RefreshTokenExpiration = Sliding and AbsoluteRefreshTokenLifetime = 3,600(1 hour) and SlidingRefreshTokenLifetime = 7,200(2 hours)

ð  We can refresh token when (now – the time issuing the FIRST-TIME refresh token) < AbsoluteRefreshTokenLifetime

ð  Every Refresh Token is alive for 2 hours.

ð  However refreshing token will fail when exceeding AbsoluteRefreshTokenLifetime.


For example, when Idsrv4 issues the first refresh-token by user’s credentials on 20:00, we can refresh token before 21:00 as many times as we want. However, even we get a new Refresh Token on 20:58, we cannot use it to refresh again on 21:02.







Client (Backend Server)

Identity Server 4 has the Refresh Token endpoint in default, so all we have to do is
1.  Create a RESTful API
2.  Request for the new Tokens thru Idsrv4’s Refresh Token endpoint



IdentityClient.cs


public class IdentityClient : IIdentityClient
{
        private const string SECRETKEY = "secret";
        private const string CLIENTID = "MyBackend";
        private readonly AppSettings configuration = null;
        private readonly HttpClient httpClient = null;
        private readonly string remoteServiceBaseUrl = string.Empty;

        public IdentityClient(
            IOptions<AppSettings> configuration,
            HttpClient httpClient)
        {
            this.configuration = configuration.Value;
            this.httpClient = httpClient;
            this.remoteServiceBaseUrl = this.configuration.Host.AuthServer;
        }
       
        public async Task<TokenResponse> RefreshTokenAsync(string refreshToken)
        {
            var discoResponse = await this.discoverDocumentAsync();

            TokenResponse tokenResponse = await this.httpClient.RequestRefreshTokenAsync(new RefreshTokenRequest
            {
                Address = discoResponse.TokenEndpoint,
                ClientId = CLIENTID,
                ClientSecret = SECRETKEY,
                RefreshToken = refreshToken
            });

            return tokenResponse;
        }

        private async Task<DiscoveryResponse> discoverDocumentAsync()
        {
            var discoResponse = await this.httpClient.GetDiscoveryDocumentAsync(new DiscoveryDocumentRequest
            {
                Address = this.remoteServiceBaseUrl,
                Policy =
                {
                    RequireHttps = true // default: true
                }
            });

            if (discoResponse.IsError)
            {
                throw new Exception(discoResponse.Error);
            }

            return discoResponse;
        }
}



AuthController.cs: RefreshToken API

Update AuthController and create a RefreshToken API:

[HttpPost("RefreshToken")]
[AllowAnonymous]
public async Task<JObject> RefreshToken([FromBody] string refreshToken)
{
            var tokenResponse = await this.auth.RefreshTokenAsync(refreshToken);
            this.HttpContext.Response.StatusCode = tokenResponse.IsError? (int)HttpStatusCode.BadRequest : (int)HttpStatusCode.OK;
            return tokenResponse.Json;
}



Test refreshing token

Now we can have the following tests to verify if refreshing token is as we expected.


1.  Get (Access and Refresh) tokens




2.  Send request to RefreshToken API with Refresh Token to get the new tokens (Expected to get the new tokens)




Notice that if the Refresh Token expires, we will get the error “
invalid_grant”:





3.  Send request to secured API with the new Access Token (Expected to be able to access API)



Revoke Refresh Token

Sometimes we want to revoke(cancel) the Refresh Token before it expires.
Identity Server 4’s Revoke token endpoint makes us easily to make the Refresh Token invalid at once as the following example.


Only Refresh Token and Reference Token can be revoked!




IdentityClient.cs

public class IdentityClient : IIdentityClient
{
        private const string SECRETKEY = "secret";
        private const string CLIENTID = "MyBackend";
        private readonly AppSettings configuration = null;
        private readonly HttpClient httpClient = null;
        private readonly string remoteServiceBaseUrl = string.Empty;

        public IdentityClient(
            IOptions<AppSettings> configuration,
            HttpClient httpClient)
        {
            this.configuration = configuration.Value;
            this.httpClient = httpClient;
            this.remoteServiceBaseUrl = this.configuration.Host.AuthServer;
        }

        public async Task<TokenRevocationResponse> RevokeTokenAsync(string token)
        {
            var discoResponse = await this.discoverDocumentAsync();

            TokenRevocationResponse revokeResposne = await this.httpClient.RevokeTokenAsync(new TokenRevocationRequest
            {
                Address = discoResponse.RevocationEndpoint,
                ClientId = CLIENTID,
                ClientSecret = SECRETKEY,
                Token = token
            });

            return revokeResposne;
        }

        private async Task<DiscoveryResponse> discoverDocumentAsync()
        {
            var discoResponse = await this.httpClient.GetDiscoveryDocumentAsync(new DiscoveryDocumentRequest
            {
                Address = this.remoteServiceBaseUrl,
                Policy =
                {
                    RequireHttps = true // default: true
                }
            });

            if (discoResponse.IsError)
            {
                throw new Exception(discoResponse.Error);
            }

            return discoResponse;
        }
}



AuthController.cs : RevokeToken API

[HttpPost("RevokeToken")]
[Authorize]
public async Task<string> RevokeToken([FromBody] string token)
{
            var revokeResponse = await this.auth.RevokeTokenAsync(token);
            this.HttpContext.Response.StatusCode = revokeResponse.IsError ? (int)HttpStatusCode.BadRequest : (int)HttpStatusCode.OK;
            return revokeResponse.Error;
}


When we revoke the Refresh Token and then try to get the new tokens by it, we will get the error “invalid_grant”:




Force Secured APIs to response 498 when Access Token expires

The default response is 401 Unauthorized when client trying to access the secured API with the expired Access Token.
We can use ASP.NET Core’s Pipeline to replace the default Http status code from 401 to 498 (Invalid Toke), so that we can distinguish the expired-token response from illegal-token response.


TokenExpiredMiddleware.cs

public class TokenExpiredMiddleware
    {
        private readonly RequestDelegate next;

        public TokenExpiredMiddleware(RequestDelegate next)
        {
            this.next = next;
        }

        public async Task InvokeAsync(HttpContext context)
        {
            #region Incoming
            // Skip incoming request
            #endregion

            await this.next(context);

            #region Outgoing
            context.Response.Headers.TryGetValue("WWW-Authenticate", out StringValues authorizationHeader);
            if (authorizationHeader.ToString().Contains("The token is expired"))
            {
                context.Response.StatusCode = 498; // Overwrite 401(Unauthorized) to 498(Invalid Token)
            }
            #endregion
        }
    }


Then create an Extension to manage the custom pipelines.

PipelineExtensions.cs

public static class PipelineExtensions
{
        public static IApplicationBuilder UseTokenExpiredResponse(this IApplicationBuilder builder)
        {
            return builder.UseMiddleware<TokenExpiredMiddleware>();
        }
}


Finally enable the pipeline on Startup.cs, notice that the TokenExpired pipeline should be BEFORE app.UseAuthentication().

Startup.cs

public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
            // Custom Token expired response
            app.UseTokenExpiredResponse();

            // Authentication
            app.UseAuthentication();

            app.UseHttpsRedirection();
            app.UseMvc();
}




So that we can get the 498 response when Access Token expires:




Source Code




Reference








沒有留言:

張貼留言