ASP.NET
Core Identity
Server 4 Refresh Token
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.
▋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
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.
▋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:
沒有留言:
張貼留言