Skip to content

Commit 8ca9423

Browse files
committed
React to API review feedback
1 parent bd5ffbe commit 8ca9423

13 files changed

+93
-187
lines changed

src/Http/Authentication.Abstractions/src/AuthenticationProperties.cs

-10
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ public class AuthenticationProperties
1616
internal const string IsPersistentKey = ".persistent";
1717
internal const string RedirectUriKey = ".redirect";
1818
internal const string RefreshKey = ".refresh";
19-
internal const string RefreshTokenKey = ".refreshToken";
2019
internal const string UtcDateTimeFormat = "r";
2120

2221
/// <summary>
@@ -117,15 +116,6 @@ public bool? AllowRefresh
117116
set => SetBool(RefreshKey, value);
118117
}
119118

120-
/// <summary>
121-
/// If set, the token must be valid for sign in to continue.
122-
/// </summary>
123-
public string? RefreshToken
124-
{
125-
get => GetParameter<string?>(RefreshTokenKey);
126-
set => SetParameter<string?>(RefreshTokenKey, value);
127-
}
128-
129119
/// <summary>
130120
/// Get a string value from the <see cref="Items"/> collection.
131121
/// </summary>

src/Http/Authentication.Abstractions/src/PublicAPI.Unshipped.txt

-2
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,3 @@
22
Microsoft.AspNetCore.Authentication.AuthenticationFailureException
33
Microsoft.AspNetCore.Authentication.AuthenticationFailureException.AuthenticationFailureException(string? message) -> void
44
Microsoft.AspNetCore.Authentication.AuthenticationFailureException.AuthenticationFailureException(string? message, System.Exception? innerException) -> void
5-
Microsoft.AspNetCore.Authentication.AuthenticationProperties.RefreshToken.get -> string?
6-
Microsoft.AspNetCore.Authentication.AuthenticationProperties.RefreshToken.set -> void

src/Identity/Core/src/IdentityApiEndpointRouteBuilderExtensions.cs

+23-16
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@
22
// The .NET Foundation licenses this file to you under the MIT license.
33

44
using System.Linq;
5-
using System.Security.Claims;
6-
using Microsoft.AspNetCore.Authentication;
5+
using Microsoft.AspNetCore.Authentication.BearerToken;
76
using Microsoft.AspNetCore.Authentication.BearerToken.DTO;
87
using Microsoft.AspNetCore.Builder;
98
using Microsoft.AspNetCore.Http;
@@ -12,6 +11,7 @@
1211
using Microsoft.AspNetCore.Identity;
1312
using Microsoft.AspNetCore.Identity.DTO;
1413
using Microsoft.Extensions.DependencyInjection;
14+
using Microsoft.Extensions.Options;
1515

1616
namespace Microsoft.AspNetCore.Routing;
1717

@@ -39,9 +39,9 @@ public static class IdentityApiEndpointRouteBuilderExtensions
3939
// NOTE: We cannot inject UserManager<TUser> directly because the TUser generic parameter is currently unsupported by RDG.
4040
// https://github.com/dotnet/aspnetcore/issues/47338
4141
routeGroup.MapPost("/register", async Task<Results<Ok, ValidationProblem>>
42-
([FromBody] RegisterRequest registration, [FromServices] IServiceProvider services) =>
42+
([FromBody] RegisterRequest registration, [FromServices] IServiceProvider sp) =>
4343
{
44-
var userManager = services.GetRequiredService<UserManager<TUser>>();
44+
var userManager = sp.GetRequiredService<UserManager<TUser>>();
4545

4646
var user = new TUser();
4747
await userManager.SetUserNameAsync(user, registration.Username);
@@ -56,17 +56,17 @@ public static class IdentityApiEndpointRouteBuilderExtensions
5656
});
5757

5858
routeGroup.MapPost("/login", async Task<Results<UnauthorizedHttpResult, Ok<AccessTokenResponse>, SignInHttpResult>>
59-
([FromBody] LoginRequest login, [FromQuery] bool? cookieMode, [FromServices] IServiceProvider services) =>
59+
([FromBody] LoginRequest login, [FromQuery] bool? cookieMode, [FromServices] IServiceProvider sp) =>
6060
{
61-
var userManager = services.GetRequiredService<UserManager<TUser>>();
61+
var userManager = sp.GetRequiredService<UserManager<TUser>>();
6262
var user = await userManager.FindByNameAsync(login.Username);
6363

6464
if (user is null || !await userManager.CheckPasswordAsync(user, login.Password))
6565
{
6666
return TypedResults.Unauthorized();
6767
}
6868

69-
var claimsFactory = services.GetRequiredService<IUserClaimsPrincipalFactory<TUser>>();
69+
var claimsFactory = sp.GetRequiredService<IUserClaimsPrincipalFactory<TUser>>();
7070
var claimsPrincipal = await claimsFactory.CreateAsync(user);
7171

7272
var useCookies = cookieMode ?? false;
@@ -75,18 +75,25 @@ public static class IdentityApiEndpointRouteBuilderExtensions
7575
return TypedResults.SignIn(claimsPrincipal, authenticationScheme: scheme);
7676
});
7777

78-
routeGroup.MapPost("/refresh", Results<UnauthorizedHttpResult, Ok<AccessTokenResponse>, SignInHttpResult>
79-
([FromBody] RefreshRequest refreshRequest) =>
78+
routeGroup.MapPost("/refresh", async Task<Results<UnauthorizedHttpResult, Ok<AccessTokenResponse>, SignInHttpResult, ChallengeHttpResult>>
79+
([FromBody] RefreshRequest refreshRequest, [FromServices] IOptionsMonitor<BearerTokenOptions> optionsMonitor, [FromServices] TimeProvider timeProvider, [FromServices] IServiceProvider sp) =>
8080
{
81-
// This is the minimal principal that IsAuthenticated. The BearerTokenHander will recreate the full principal
82-
// from the refresh token if it is able. The sign in will fail without an identity name.
83-
var refreshPrincipal = new ClaimsPrincipal(new ClaimsIdentity(IdentityConstants.BearerScheme));
84-
var properties = new AuthenticationProperties
81+
var signInManager = sp.GetRequiredService<SignInManager<TUser>>();
82+
var identityBearerOptions = optionsMonitor.Get(IdentityConstants.BearerScheme);
83+
var refreshTokenProtector = identityBearerOptions.RefreshTokenProtector ?? throw new ArgumentException($"{nameof(identityBearerOptions.RefreshTokenProtector)} is null", nameof(optionsMonitor));
84+
var refreshTicket = refreshTokenProtector.Unprotect(refreshRequest.RefreshToken);
85+
86+
// Reject the /refresh attempt with a 401 if the token expired or the security stamp validation fails
87+
if (refreshTicket?.Properties?.ExpiresUtc is not { } expiresUtc ||
88+
timeProvider.GetUtcNow() >= expiresUtc ||
89+
await signInManager.ValidateSecurityStampAsync(refreshTicket.Principal) is not TUser user)
90+
8591
{
86-
RefreshToken = refreshRequest.RefreshToken
87-
};
92+
return TypedResults.Challenge();
93+
}
8894

89-
return TypedResults.SignIn(refreshPrincipal, properties, IdentityConstants.BearerScheme);
95+
var newPrincipal = await signInManager.CreateUserPrincipalAsync(user);
96+
return TypedResults.SignIn(newPrincipal, authenticationScheme: IdentityConstants.BearerScheme);
9097
});
9198

9299
return new IdentityEndpointsConventionBuilder(routeGroup);

src/Identity/Core/src/IdentityAuthenticationBuilderExtensions.cs

+1-27
Original file line numberDiff line numberDiff line change
@@ -33,32 +33,6 @@ public static AuthenticationBuilder AddIdentityBearerToken<TUser>(this Authentic
3333
ArgumentNullException.ThrowIfNull(builder);
3434
ArgumentNullException.ThrowIfNull(configureOptions);
3535

36-
return builder.AddBearerToken(IdentityConstants.BearerScheme, bearerOptions =>
37-
{
38-
bearerOptions.Events.OnSigningIn = HandleSigningIn<TUser>;
39-
configureOptions(bearerOptions);
40-
});
41-
}
42-
43-
private static async Task HandleSigningIn<TUser>(SigningInContext signInContext)
44-
where TUser : class, new()
45-
{
46-
// Only validate the security stamp and refresh the user from the store during /refresh
47-
// not during the initial /login when the Principal is already newly created from the store.
48-
if (signInContext.Properties.RefreshToken is null)
49-
{
50-
return;
51-
}
52-
53-
var signInManager = signInContext.HttpContext.RequestServices.GetRequiredService<SignInManager<TUser>>();
54-
55-
// Reject the /refresh attempt if the security stamp validation fails which will result in a 401 challenge.
56-
if (await signInManager.ValidateSecurityStampAsync(signInContext.Principal) is not TUser user)
57-
{
58-
signInContext.Principal = null;
59-
return;
60-
}
61-
62-
signInContext.Principal = await signInManager.CreateUserPrincipalAsync(user);
36+
return builder.AddBearerToken(IdentityConstants.BearerScheme, configureOptions);
6337
}
6438
}

src/Identity/test/Identity.FunctionalTests/MapIdentityTests.cs renamed to src/Identity/test/Identity.FunctionalTests/MapIdentityApiTests.cs

+2-5
Original file line numberDiff line numberDiff line change
@@ -10,22 +10,19 @@
1010
using System.Text.Json;
1111
using Identity.DefaultUI.WebSite;
1212
using Identity.DefaultUI.WebSite.Data;
13-
using Microsoft.AspNetCore.Authentication;
1413
using Microsoft.AspNetCore.Builder;
1514
using Microsoft.AspNetCore.Http;
16-
using Microsoft.AspNetCore.Identity.Test;
1715
using Microsoft.AspNetCore.Routing;
1816
using Microsoft.AspNetCore.TestHost;
1917
using Microsoft.AspNetCore.Testing;
2018
using Microsoft.Data.Sqlite;
2119
using Microsoft.EntityFrameworkCore;
2220
using Microsoft.Extensions.DependencyInjection;
23-
using Microsoft.Extensions.Primitives;
2421
using Microsoft.Net.Http.Headers;
2522

2623
namespace Microsoft.AspNetCore.Identity.FunctionalTests;
2724

28-
public class MapIdentityTests : LoggedTest
25+
public class MapIdentityApiTests : LoggedTest
2926
{
3027
private string Username { get; } = $"{Guid.NewGuid()}@example.com";
3128
private string Password { get; } = $"[PLACEHOLDER]-1a";
@@ -236,11 +233,11 @@ public async Task CanCustomizeRefreshTokenExpiration()
236233

237234
await using var app = await CreateAppAsync(services =>
238235
{
236+
services.AddSingleton<TimeProvider>(clock);
239237
services.AddIdentityCore<ApplicationUser>().AddApiEndpoints().AddEntityFrameworkStores<ApplicationDbContext>();
240238
services.AddAuthentication(IdentityConstants.BearerScheme).AddIdentityBearerToken<ApplicationUser>(options =>
241239
{
242240
options.RefreshTokenExpiration = expireTimeSpan;
243-
options.TimeProvider = clock;
244241
});
245242
});
246243

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using Microsoft.AspNetCore.DataProtection;
5+
using Microsoft.Extensions.Options;
6+
7+
namespace Microsoft.AspNetCore.Authentication.BearerToken;
8+
9+
internal sealed class BearerTokenConfigureOptions(IDataProtectionProvider dp) : IConfigureNamedOptions<BearerTokenOptions>
10+
{
11+
private const string _primaryPurpose = "Microsoft.AspNetCore.Authentication.BearerToken";
12+
13+
public void Configure(string? schemeName, BearerTokenOptions options)
14+
{
15+
if (schemeName is null)
16+
{
17+
return;
18+
}
19+
20+
options.BearerTokenProtector = new TicketDataFormat(dp.CreateProtector(_primaryPurpose, schemeName, "BearerToken"));
21+
options.RefreshTokenProtector = new TicketDataFormat(dp.CreateProtector(_primaryPurpose, schemeName, "RefreshToken"));
22+
}
23+
24+
public void Configure(BearerTokenOptions options)
25+
{
26+
throw new NotImplementedException();
27+
}
28+
}

src/Security/Authentication/BearerToken/src/BearerTokenEvents.cs

-11
Original file line numberDiff line numberDiff line change
@@ -13,20 +13,9 @@ public class BearerTokenEvents
1313
/// </summary>
1414
public Func<MessageReceivedContext, Task> OnMessageReceived { get; set; } = context => Task.CompletedTask;
1515

16-
/// <summary>
17-
/// Invoked when signing in.
18-
/// </summary>
19-
public Func<SigningInContext, Task> OnSigningIn { get; set; } = context => Task.CompletedTask;
20-
2116
/// <summary>
2217
/// Invoked when a protocol message is first received.
2318
/// </summary>
2419
/// <param name="context">The <see cref="MessageReceivedContext"/>.</param>
2520
public virtual Task MessageReceivedAsync(MessageReceivedContext context) => OnMessageReceived(context);
26-
27-
/// <summary>
28-
/// Invoked when signing in.
29-
/// </summary>
30-
/// <param name="context">The <see cref="SigningInContext"/>.</param>
31-
public virtual Task SigningInAsync(SigningInContext context) => OnSigningIn(context);
3221
}

src/Security/Authentication/BearerToken/src/BearerTokenExtensions.cs

+3
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33

44
using Microsoft.AspNetCore.Authentication;
55
using Microsoft.AspNetCore.Authentication.BearerToken;
6+
using Microsoft.Extensions.DependencyInjection.Extensions;
7+
using Microsoft.Extensions.Options;
68

79
namespace Microsoft.Extensions.DependencyInjection;
810

@@ -62,6 +64,7 @@ public static AuthenticationBuilder AddBearerToken(this AuthenticationBuilder bu
6264
ArgumentNullException.ThrowIfNull(authenticationScheme);
6365
ArgumentNullException.ThrowIfNull(configure);
6466

67+
builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<IConfigureOptions<BearerTokenOptions>, BearerTokenConfigureOptions>());
6568
return builder.AddScheme<BearerTokenOptions, BearerTokenHandler>(authenticationScheme, configure);
6669
}
6770
}

src/Security/Authentication/BearerToken/src/BearerTokenHandler.cs

+10-52
Original file line numberDiff line numberDiff line change
@@ -4,32 +4,21 @@
44
using System.Security.Claims;
55
using System.Text.Encodings.Web;
66
using Microsoft.AspNetCore.Authentication.BearerToken.DTO;
7-
using Microsoft.AspNetCore.DataProtection;
87
using Microsoft.AspNetCore.Http;
98
using Microsoft.Extensions.Logging;
109
using Microsoft.Extensions.Options;
1110
using Microsoft.Net.Http.Headers;
1211

1312
namespace Microsoft.AspNetCore.Authentication.BearerToken;
1413

15-
internal sealed class BearerTokenHandler(
16-
IOptionsMonitor<BearerTokenOptions> optionsMonitor,
17-
ILoggerFactory loggerFactory,
18-
UrlEncoder urlEncoder,
19-
IDataProtectionProvider dataProtectionProvider)
14+
internal sealed class BearerTokenHandler(IOptionsMonitor<BearerTokenOptions> optionsMonitor, ILoggerFactory loggerFactory, UrlEncoder urlEncoder)
2015
: SignInAuthenticationHandler<BearerTokenOptions>(optionsMonitor, loggerFactory, urlEncoder)
2116
{
22-
private const string BearerTokenPurpose = "BearerToken";
23-
private const string RefreshTokenPurpose = "RefreshToken";
24-
2517
private static readonly long OneSecondTicks = TimeSpan.FromSeconds(1).Ticks;
2618

2719
private static readonly AuthenticateResult FailedUnprotectingToken = AuthenticateResult.Fail("Unprotected token failed");
2820
private static readonly AuthenticateResult TokenExpired = AuthenticateResult.Fail("Token expired");
2921

30-
private ISecureDataFormat<AuthenticationTicket> TokenProtector
31-
=> Options.TokenProtector ?? new TicketDataFormat(dataProtectionProvider.CreateProtector("Microsoft.AspNetCore.Authentication.BearerToken", Scheme.Name));
32-
3322
private new BearerTokenEvents Events => (BearerTokenEvents)base.Events!;
3423

3524
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
@@ -51,7 +40,7 @@ protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
5140
return AuthenticateResult.NoResult();
5241
}
5342

54-
var ticket = TokenProtector.Unprotect(token, BearerTokenPurpose);
43+
var ticket = Options.BearerTokenProtector.Unprotect(token);
5544

5645
if (ticket?.Properties?.ExpiresUtc is not { } expiresUtc)
5746
{
@@ -78,48 +67,17 @@ protected override async Task HandleSignInAsync(ClaimsPrincipal user, Authentica
7867

7968
properties ??= new();
8069
properties.ExpiresUtc ??= utcNow + Options.BearerTokenExpiration;
81-
var isRefresh = properties.RefreshToken is not null;
82-
83-
if (isRefresh)
84-
{
85-
var refreshTicket = TokenProtector.Unprotect(properties.RefreshToken, RefreshTokenPurpose);
86-
87-
if (refreshTicket?.Properties?.ExpiresUtc is not { } expiresUtc || TimeProvider.GetUtcNow() >= expiresUtc)
88-
{
89-
await ChallengeAsync(properties);
90-
return;
91-
}
92-
93-
user = refreshTicket.Principal;
94-
}
95-
96-
var signingInContext = new SigningInContext(Context, Scheme, Options, user, properties);
97-
98-
await Events.SigningInAsync(signingInContext);
99-
100-
if (signingInContext.Principal?.Identity?.Name is null)
101-
{
102-
await ChallengeAsync(properties);
103-
return;
104-
}
10570

10671
var response = new AccessTokenResponse
10772
{
108-
AccessToken = signingInContext.AccessToken ?? TokenProtector.Protect(CreateAccessTicket(signingInContext), BearerTokenPurpose),
109-
ExpiresInSeconds = CalculateExpiresInSeconds(utcNow, signingInContext.Properties.ExpiresUtc),
110-
RefreshToken = signingInContext.RefreshToken ?? TokenProtector.Protect(CreateRefreshTicket(user, utcNow), RefreshTokenPurpose),
73+
AccessToken = Options.BearerTokenProtector.Protect(CreateBearerTicket(user, properties)),
74+
ExpiresInSeconds = CalculateExpiresInSeconds(utcNow, properties.ExpiresUtc),
75+
RefreshToken = Options.RefreshTokenProtector.Protect(CreateRefreshTicket(user, utcNow)),
11176
};
11277

113-
await Context.Response.WriteAsJsonAsync(response, BearerTokenJsonSerializerContext.Default.AccessTokenResponse);
78+
Logger.AuthenticationSchemeSignedIn(Scheme.Name);
11479

115-
if (isRefresh)
116-
{
117-
Logger.AuthenticationSchemeSignedInWithRefreshToken(Scheme.Name);
118-
}
119-
else
120-
{
121-
Logger.AuthenticationSchemeSignedIn(Scheme.Name);
122-
}
80+
await Context.Response.WriteAsJsonAsync(response, BearerTokenJsonSerializerContext.Default.AccessTokenResponse);
12381
}
12482

12583
// No-op to avoid interfering with any mass sign-out logic.
@@ -152,8 +110,8 @@ static DateTimeOffset FloorSeconds(DateTimeOffset dateTimeOffset)
152110
});
153111
}
154112

155-
private static AuthenticationTicket CreateAccessTicket(SigningInContext context)
156-
=> new(context.Principal!, context.Properties, context.Scheme.Name);
113+
private AuthenticationTicket CreateBearerTicket(ClaimsPrincipal user, AuthenticationProperties properties)
114+
=> new(user, properties, $"{Scheme.Name}:AccessToken");
157115

158116
private AuthenticationTicket CreateRefreshTicket(ClaimsPrincipal user, DateTimeOffset utcNow)
159117
{
@@ -162,6 +120,6 @@ private AuthenticationTicket CreateRefreshTicket(ClaimsPrincipal user, DateTimeO
162120
ExpiresUtc = utcNow + Options.RefreshTokenExpiration
163121
};
164122

165-
return new AuthenticationTicket(user, refreshProperties, $"{Scheme.Name}:{RefreshTokenPurpose}");
123+
return new AuthenticationTicket(user, refreshProperties, $"{Scheme.Name}:RefreshToken");
166124
}
167125
}

0 commit comments

Comments
 (0)