Description
Background and Motivation
The default timeout for bearer tokens in the BearerTokenHandler is one hour, and forcing a new login every hour is not acceptable. Unlike with cookies, we cannot implicitly refresh the bearer token by attaching an outgoing header to authenticated request to non-Identity endpoints, so we need to add a refresh token and a new /refresh endpoint that accepts the refresh_token as part of the JSON request body and returns a new bearer token and new refresh token identical to the initial /login response.
As part of this process, when paired with Identity, it will verify the security stamp (if any) and reload the ClaimsPrincipal from the database via the user store. Identity does this via a BearerTokenOptions.OnSigningIn
callback.
Proposed API
// namespace Microsoft.AspNetCore.Authentication.Abstractions.dll
namespace Microsoft.AspNetCore.Authentication;
public class AuthenticationProperties
{
+ /// <summary>
+ /// If set, the token must be valid for sign in to continue.
+ /// </summary>
+ [JsonIgnore]
+ public string? RefreshToken { get; set; }
}
// Microsoft.AspNetCore.Identity.dll
namespace Microsoft.Extensions.DependencyInjection;
- public static class IdentityApiEndpointsServiceCollectionExtensions
- {
- public static IdentityBuilder AddIdentityApiEndpoints<TUser>(this IServiceCollection services)
- where TUser : class, new();
- public static IdentityBuilder AddIdentityApiEndpoints<TUser>(this IServiceCollection services, Action<IdentityOptions> configure)
- where TUser : class, new();
- }
public static class IdentityServiceCollectionExtensions
{
+ public static IdentityBuilder AddIdentityApiEndpoints<TUser>(this IServiceCollection services)
+ where TUser : class, new();
+ public static IdentityBuilder AddIdentityApiEndpoints<TUser>(this IServiceCollection services, Action<IdentityOptions> configure)
+ where TUser : class, new();
}
namespace Microsoft.AspNetCore.Identity;
- public static class IdentityApiEndpointsIdentityBuilderExtensions
- {
- public static IdentityBuilder AddApiEndpoints(this IdentityBuilder builder);
- }
public static class IdentityBuilderExtensions
{
+ public static IdentityBuilder AddApiEndpoints(this IdentityBuilder builder);
}
+ public static class IdentityAuthenticationBuilderExtensions
+ {
+ public static AuthenticationBuilder AddIdentityBearerToken<TUser>(this AuthenticationBuilder builder)
+ where TUser : class, new()
+ public static AuthenticationBuilder AddIdentityBearerToken<TUser>(this AuthenticationBuilder builder, Action<BearerTokenOptions> configure)
+ where TUser : class, new()
+ }
// Microsoft.AspNetCore.Authentication.BearerToken.dll
namespace Microsoft.AspNetCore.Authentication.BearerToken;
public sealed class BearerTokenOptions : AuthenticationSchemeOptions
{
public TimeSpan BearerTokenExpiration { get; set; } = TimeSpan.FromHours(1);
+ public TimeSpan RefreshTokenExpiration { get; set; } = TimeSpan.FromDays(14);
- public ISecureDataFormat<AuthenticationTicket>? BearerTokenProtector { get; set; }\
+ public ISecureDataFormat<AuthenticationTicket>? TokenProtector { get; set; }
}
public class BearerTokenEvents
{
public Func<SigningInContext, Task> OnMessageReceived { get; set; } = context => Task.CompletedTask;
+ public Func<SigningInContext, Task> OnSigningIn { get; set; } = context => Task.CompletedTask;
public virtual Task MessageReceivedAsync(MessageReceivedContext context) => OnMessageReceived(context);
+ public virtual Task SigningInAsync(SigningInContext context) => OnSigningIn(context);
}
+ public class SigningInContext : PrincipalContext<BearerTokenOptions>
+ {
+ public SigningInContext(HttpContext context, AuthenticationScheme scheme, BearerTokenOptions options, ClaimsPrincipal principal, AuthenticationProperties? properties);
+
+ /// <summary>
+ /// The opaque bearer token to be written to the JSON response body as the "access_token".
+ /// If left unset, one will be generated automatically.
+ /// This should later be sent as part of the Authorization request header.
+ /// </summary>
+ public string? AccessToken { get; set; }
+
+ /// <summary>
+ /// The opaque refresh token written the to the JSON response body as the "refresh_token".
+ /// If left unset, it will be generated automatically.
+ /// This should later be sent as part of a request to refresh the <see cref="AccessToken"/>
+ /// </summary>
+ public string? RefreshToken { get; set; }
+ }
Usage Examples
In the default cookie + bearer token case where you call the AddIdentityApiEndpoints
IServiceCollection
extension method, the server code is unchanged from the #47227 example modulo the API renames.
// ...
builder.Services.AddIdentityApiEndpoints<IdentityUser>()
.AddEntityFrameworkStores<ApplicationDbContext>();
var app = builder.Build();
app.MapGroup("/identity").MapIdentityApi<IdentityUser>();
app.MapGet("/requires-auth", (ClaimsPrincipal user) => $"Hello, {user.Identity?.Name}!").RequireAuthorization();
// ...
But now, if you want to enable only bearer tokens but not cookies, you should call the new AddIdentityBearerToken<TUser>()
AuthenticationBuilder
extension method rather than calling AddBearerToken(IdentityConstants.BearerScheme)
like before so that identity can wire up the security stamp validation and the user store reloading during refresh as part of the BearerTokenOptions.OnSigningIn
callback.
// ...
builder.Services.AddIdentityCore<ApplicationUser>()
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddApiEndpoints();
builder.Services.AddAuthentication()
.AddIdentityBearerToken<ApplicationUser>();
var app = builder.Build();
// ...
If you want to manually refresh the access token with a refresh token without the MapIdentityApi
endpoints, you can call HttpContext.SignIn
yourself with AuthenticationProperties.RefreshToken
set:
app.MapPost("/refresh", (RefreshRequest refreshRequest) =>
{
// This is the minimal principal that IsAuthenticated. The BearerTokenHander will recreate the full principal
// from the refresh token if it is able. The sign in will fail without an identity name.
var refreshPrincipal = new ClaimsPrincipal(new ClaimsIdentity(IdentityConstants.BearerScheme));
var properties = new AuthenticationProperties
{
RefreshToken = refreshRequest.RefreshToken
};
return TypedResults.SignIn(refreshPrincipal, properties, IdentityConstants.BearerScheme);
});
And if you manually want to do something like security stamp validation, or updating the ClaimsPrincipal to something different than when it was created, you can hook OnSigningIn
manually:
authBuilder.AddBearerToken(IdentityConstants.BearerScheme, bearerOptions =>
{
bearerOptions.Events.OnSigningIn = async signInContext =>
{
// Only validate the security stamp and refresh the user from the store during /refresh
// not during the initial /login when the Principal is already newly created from the store.
if (signInContext.Properties.RefreshToken is null)
{
return;
}
var signInManager = signInContext.HttpContext.RequestServices.GetRequiredService<SignInManager<ApplicationUser>>();
// Reject the /refresh attempt if the security stamp validation fails which will result in a 401 challenge.
if (await signInManager.ValidateSecurityStampAsync(signInContext.Principal) is not TUser user)
{
signInContext.Principal = null;
return;
}
signInContext.Principal = await signInManager.CreateUserPrincipalAsync(user)
}
});
Or the OnSigningIn can go full manual mode and create the tokens itself:
authBuilder.AddBearerToken(IdentityConstants.BearerScheme, bearerOptions =>
{
bearerOptions.Events.OnSigningIn = signInContext =>
{
// In this case since the callback creates the tokens, it can be expected to validate the token and create the final
// ClaimsPrincipal before calling SignInAsync. So it doesn't ever need to use AuthenticationProperties.RefreshToken.
// It could continue to use AuthenticationProperties.RefreshToken by overriding the TokenProtector instead
signInContext.AccessToken = CreateAccessToken(signInContext.Principal, signInContext.ExpiresUtc);
signInContext.RefreshToken = CreateRefreshToken(signInContext.Principal);
}
});
Client
Assume httpClient
, username
and password
are already initialized.
// The request body is: { "username": "<username>", "password": "<password>" }
var loginResponse = await httpClient.PostAsJsonAsync("/identity/login", new { username, password });
// loginResponse is similar to the "Access Token Response" defined in the OAuth 2 spec
// {
// "token_type": "Bearer",
// "access_token": "...",
// "expires_in": 3600
// "refresh_token": "...",
// }
var loginContent = await loginResponse.Content.ReadFromJsonAsync<JsonElement>();
var accessToken = loginContent.GetProperty("access_token").GetString();
var refreshToken = loginContent.GetProperty("refresh_token").GetString();
httpClient.DefaultRequestHeaders.Authorization = new("Bearer", accessToken);
// Writes "Hello, <username>!"
Console.WriteLine(await httpClient.GetStringAsync("/requires-auth"));
await Task.Delay(TimeSpan.FromHours(2));
// The bearer token should expire in 1 hour by default. We just waited 2 hours.
var unauthorizedResponse = await httpClient.GetAsync("/requires-auth");
Trace.Assert(unauthorizedResponse.StatusCode == HttpStatusCode.Unauthorized);
// The request body is: { "refreshtoken": "{refreshToken}" }
var refreshResponse = await httpClient.PostAsJsonAsync("/identity/refresh", new { refreshToken });
// refreshResponse is identical to loginResponse with new tokens
var refreshContent = await refreshResponse.Content.ReadFromJsonAsync<JsonElement>();
var refreshedAccessToken = refreshContent.GetProperty("access_token").GetString();
// Writes "Hello, <possibly updated username>!"
Console.WriteLine(await httpClient.GetStringAsync("/requires-auth"));
Alternative Designs
We could create a new derived class for AuthenticationProperties
similar to OAuthChallengeProperties
or GoogleChallengeProperties
called BearerTokenProperties
as suggested by @kevinchalet in the PR at https://github.com/dotnet/aspnetcore/pull/48595/files#r1220367158. We'd probably want to add a public static readonly string RefereshTokenKey
for consistency and to be able to look for tokens on arbitrary AuthenticationProperties
.
We could then consider adding public new BearerTokenProperties Properties { get; set; }
to SigningInContext
to hide the property with the same name from the ProperitesContext
grandparent type, but that'd probably require at least shallow copying the properties from a passed-in AuthenticationProperties
. The other handlers with custom properties don't appear to do this.
We could consider not approving SigningInContext.AccessToken
and SigningInContext.RefreshToken
and say the one true way to customize tokens is with BearerTokenOptions.TokenProtector
. It's a little less flexible because you don't have easy access to the HttpContext in an ISecureDataFormat<AuthenticationTicket>
, but identity doesn't need this functionality. We could always add it later.
Risks
I think the risk is minimal. This is all either moving API added in preview4 or adding new API to support the refresh token scenario.