Skip to content

Add token refresh endpoints to identity #47228

Closed
@halter73

Description

@halter73

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.

Metadata

Metadata

Assignees

Labels

Priority:0Work that we can't release withoutapi-approvedAPI was approved in API review, it can be implementedarea-identityIncludes: Identity and providersenhancementThis issue represents an ask for new feature or an enhancement to an existing onefeature-token-identity

Type

No type

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions