From 365b26e5285659c9203e07c55992e316825d98b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20V=C4=B1=CC=81zner?= Date: Fri, 31 Jul 2026 10:20:20 +0200 Subject: [PATCH 1/7] Add WebAuthn Signal API support for passkeys --- .../Core/src/IdentityJsonSerializerContext.cs | 1 + src/Identity/Core/src/PasskeyHandler.cs | 2 +- .../Core/src/Passkeys/PasskeyServerDomain.cs | 12 +++ .../Core/src/Passkeys/PasskeySignalOptions.cs | 40 +++++++ src/Identity/Core/src/PublicAPI.Unshipped.txt | 1 + src/Identity/Core/src/SignInManager.cs | 57 ++++++++++ .../test/Identity.Test/SignInManagerTest.cs | 102 +++++++++++++++++- src/Identity/test/Shared/MockHelpers.cs | 7 +- .../Account/Pages/ConfirmEmailChange.razor | 7 ++ .../Account/Pages/Manage/Passkeys.razor | 5 + .../Account/Shared/PasskeySignals.razor | 38 +++++++ .../Account/Shared/PasskeySignals.razor.js | 14 +++ .../BlazorWebCSharp.1/Components/App.razor | 1 + .../BlazorTemplateTest.cs | 69 ++++++++++++ .../Templates.Tests/template-baselines.json | 18 ++++ 15 files changed, 370 insertions(+), 4 deletions(-) create mode 100644 src/Identity/Core/src/Passkeys/PasskeyServerDomain.cs create mode 100644 src/Identity/Core/src/Passkeys/PasskeySignalOptions.cs create mode 100644 src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Shared/PasskeySignals.razor create mode 100644 src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Shared/PasskeySignals.razor.js diff --git a/src/Identity/Core/src/IdentityJsonSerializerContext.cs b/src/Identity/Core/src/IdentityJsonSerializerContext.cs index 34b5843b8f89..8c4460d3e0f4 100644 --- a/src/Identity/Core/src/IdentityJsonSerializerContext.cs +++ b/src/Identity/Core/src/IdentityJsonSerializerContext.cs @@ -13,6 +13,7 @@ namespace Microsoft.AspNetCore.Identity; [JsonSerializable(typeof(PublicKeyCredential))] [JsonSerializable(typeof(PasskeyAttestationState))] [JsonSerializable(typeof(PasskeyAssertionState))] +[JsonSerializable(typeof(PasskeySignalOptions))] [JsonSourceGenerationOptions( JsonSerializerDefaults.Web, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, diff --git a/src/Identity/Core/src/PasskeyHandler.cs b/src/Identity/Core/src/PasskeyHandler.cs index 0081d672fa00..c21edbb68e29 100644 --- a/src/Identity/Core/src/PasskeyHandler.cs +++ b/src/Identity/Core/src/PasskeyHandler.cs @@ -678,5 +678,5 @@ private ValueTask ValidateOriginAsync(CollectedClientData clientData, Http } private string GetServerDomain(HttpContext httpContext) - => _options.ServerDomain ?? httpContext.Request.Host.Host; + => PasskeyServerDomain.Resolve(_options, httpContext); } diff --git a/src/Identity/Core/src/Passkeys/PasskeyServerDomain.cs b/src/Identity/Core/src/Passkeys/PasskeyServerDomain.cs new file mode 100644 index 000000000000..b445f822bd9c --- /dev/null +++ b/src/Identity/Core/src/Passkeys/PasskeyServerDomain.cs @@ -0,0 +1,12 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Http; + +namespace Microsoft.AspNetCore.Identity; + +internal static class PasskeyServerDomain +{ + public static string Resolve(IdentityPasskeyOptions options, HttpContext httpContext) + => options.ServerDomain ?? httpContext.Request.Host.Host; +} diff --git a/src/Identity/Core/src/Passkeys/PasskeySignalOptions.cs b/src/Identity/Core/src/Passkeys/PasskeySignalOptions.cs new file mode 100644 index 000000000000..70cc555193ea --- /dev/null +++ b/src/Identity/Core/src/Passkeys/PasskeySignalOptions.cs @@ -0,0 +1,40 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Identity; + +/// +/// Represents the information needed to signal the current state of a user's passkeys to authenticators. +/// +/// +/// This is a superset of the options accepted by the WebAuthn signalAllAcceptedCredentials +/// and signalCurrentUserDetails methods. +/// See . +/// +internal sealed class PasskeySignalOptions +{ + /// + /// Gets the relying party identifier. + /// + public required string RpId { get; init; } + + /// + /// Gets the user handle of the user that owns the credentials. + /// + public required BufferSource UserId { get; init; } + + /// + /// Gets the credential IDs that are currently registered for the user. + /// + public required IReadOnlyList AllAcceptedCredentialIds { get; init; } + + /// + /// Gets the name of the user. + /// + public required string Name { get; init; } + + /// + /// Gets the display name of the user. + /// + public required string DisplayName { get; init; } +} diff --git a/src/Identity/Core/src/PublicAPI.Unshipped.txt b/src/Identity/Core/src/PublicAPI.Unshipped.txt index 7dc5c58110bf..6587b8bf06e5 100644 --- a/src/Identity/Core/src/PublicAPI.Unshipped.txt +++ b/src/Identity/Core/src/PublicAPI.Unshipped.txt @@ -1 +1,2 @@ #nullable enable +virtual Microsoft.AspNetCore.Identity.SignInManager.MakePasskeySignalOptionsAsync(TUser! user, Microsoft.AspNetCore.Identity.PasskeyUserEntity! userEntity) -> System.Threading.Tasks.Task! diff --git a/src/Identity/Core/src/SignInManager.cs b/src/Identity/Core/src/SignInManager.cs index e303012f9849..746f259ce0c4 100644 --- a/src/Identity/Core/src/SignInManager.cs +++ b/src/Identity/Core/src/SignInManager.cs @@ -7,6 +7,7 @@ using System.Linq; using System.Security.Claims; using System.Text; +using System.Text.Json; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; @@ -33,6 +34,7 @@ public class SignInManager where TUser : class private readonly IAuthenticationSchemeProvider _schemes; private readonly IUserConfirmation _confirmation; private readonly IPasskeyHandler? _passkeyHandler; + private readonly IdentityPasskeyOptions _passkeyOptions; private readonly SignInManagerMetrics? _metrics; private HttpContext? _context; private TwoFactorAuthenticationInfo? _twoFactorInfo; @@ -70,6 +72,7 @@ public SignInManager(UserManager userManager, // SignInManagerMetrics created from constructor because of difficulties registering internal type. _metrics = userManager.ServiceProvider?.GetService() is { } factory ? new SignInManagerMetrics(factory) : null; _passkeyHandler = userManager.ServiceProvider?.GetService>(); + _passkeyOptions = userManager.ServiceProvider?.GetService>()?.Value ?? new IdentityPasskeyOptions(); } /// @@ -543,6 +546,60 @@ public virtual async Task MakePasskeyRequestOptionsAsync(TUser? user) return result.RequestOptionsJson; } + /// + /// Generates the options used to signal the current state of a user's passkeys to authenticators. + /// + /// + /// + /// The returned JSON contains the arguments for both the PublicKeyCredential.signalAllAcceptedCredentials() + /// and PublicKeyCredential.signalCurrentUserDetails() JavaScript APIs, which let an authenticator + /// stop offering passkeys that were removed from the server and keep the user's details up to date. + /// + /// + /// Because these APIs reveal how many passkeys a user has, only call them when the user is authenticated. + /// The must match the one passed to + /// when the passkeys were created, + /// otherwise the authenticator will not recognize the user and the signal will have no effect. + /// + /// + /// See . + /// + /// + /// The user whose passkeys should be signaled. + /// The user entity associated with the user's passkeys. + /// A JSON string representing the passkey signal options. + /// + /// The following example shows how the result is used from JavaScript. + /// + /// const { rpId, userId, allAcceptedCredentialIds, name, displayName } = signalOptions; + /// await PublicKeyCredential.signalAllAcceptedCredentials?.({ rpId, userId, allAcceptedCredentialIds }); + /// await PublicKeyCredential.signalCurrentUserDetails?.({ rpId, userId, name, displayName }); + /// + /// + public virtual async Task MakePasskeySignalOptionsAsync(TUser user, PasskeyUserEntity userEntity) + { + ArgumentNullException.ThrowIfNull(user); + ArgumentNullException.ThrowIfNull(userEntity); + + var userId = await UserManager.GetUserIdAsync(user); + if (!string.Equals(userId, userEntity.Id, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"The user entity ID '{userEntity.Id}' does not match the ID '{userId}' of the specified user."); + } + + var passkeys = await UserManager.GetPasskeysAsync(user); + var options = new PasskeySignalOptions + { + RpId = PasskeyServerDomain.Resolve(_passkeyOptions, Context), + UserId = BufferSource.FromString(userEntity.Id), + AllAcceptedCredentialIds = [.. passkeys.Select(p => BufferSource.FromBytes(p.CredentialId))], + Name = userEntity.Name, + DisplayName = userEntity.DisplayName, + }; + return JsonSerializer.Serialize(options, IdentityJsonSerializerContext.Default.PasskeySignalOptions); + } + /// /// Performs passkey attestation for the given . /// diff --git a/src/Identity/test/Identity.Test/SignInManagerTest.cs b/src/Identity/test/Identity.Test/SignInManagerTest.cs index 2dcba35b6fd0..be708581fb04 100644 --- a/src/Identity/test/Identity.Test/SignInManagerTest.cs +++ b/src/Identity/test/Identity.Test/SignInManagerTest.cs @@ -5,6 +5,7 @@ using System.Diagnostics; using System.Diagnostics.Metrics; using System.Security.Claims; +using System.Text; using System.Text.Json; using System.Text.Json.Nodes; using Microsoft.AspNetCore.Authentication; @@ -122,9 +123,10 @@ public async Task CheckPasswordSignInReturnsLockedOutWhenLockedOut() private static Mock> SetupUserManager( PocoUser user, IMeterFactory meterFactory = null, - IPasskeyHandler passkeyHandler = null) + IPasskeyHandler passkeyHandler = null, + IdentityPasskeyOptions passkeyOptions = null) { - var manager = MockHelpers.MockUserManager(meterFactory, passkeyHandler); + var manager = MockHelpers.MockUserManager(meterFactory, passkeyHandler, passkeyOptions); manager.Setup(m => m.FindByNameAsync(user.UserName)).ReturnsAsync(user); manager.Setup(m => m.FindByIdAsync(user.Id)).ReturnsAsync(user); manager.Setup(m => m.GetUserIdAsync(user)).ReturnsAsync(user.Id.ToString()); @@ -648,6 +650,102 @@ public async Task PasskeySignInReturnsLockedOutWhenLockedOut() auth.Verify(); } + [Fact] + public async Task CanMakePasskeySignalOptions() + { + var user = new PocoUser { UserName = "Foo" }; + var manager = SetupUserManager(user); + manager + .Setup(m => m.GetPasskeysAsync(user)) + .ReturnsAsync([ + CreatePasskey([1, 2, 3]), + CreatePasskey([4, 5, 6]), + ]); + var context = new DefaultHttpContext(); + context.Request.Host = new HostString("contoso.com", 5001); + var helper = SetupSignInManager(manager.Object, context); + + var optionsJson = await helper.MakePasskeySignalOptionsAsync(user, new() + { + Id = user.Id, + Name = "Foo", + DisplayName = "Foo Bar", + }); + + var options = JsonSerializer.Deserialize(optionsJson); + Assert.Equal("contoso.com", options.GetProperty("rpId").GetString()); + Assert.Equal(Base64Url.EncodeToString(Encoding.UTF8.GetBytes(user.Id)), options.GetProperty("userId").GetString()); + Assert.Equal("Foo", options.GetProperty("name").GetString()); + Assert.Equal("Foo Bar", options.GetProperty("displayName").GetString()); + Assert.Collection(options.GetProperty("allAcceptedCredentialIds").EnumerateArray(), + id => Assert.Equal(Base64Url.EncodeToString([1, 2, 3]), id.GetString()), + id => Assert.Equal(Base64Url.EncodeToString([4, 5, 6]), id.GetString())); + } + + [Fact] + public async Task MakePasskeySignalOptionsUsesConfiguredServerDomain() + { + var user = new PocoUser { UserName = "Foo" }; + var passkeyOptions = new IdentityPasskeyOptions { ServerDomain = "fabrikam.com" }; + var manager = SetupUserManager(user, passkeyOptions: passkeyOptions); + manager.Setup(m => m.GetPasskeysAsync(user)).ReturnsAsync([]); + var context = new DefaultHttpContext(); + context.Request.Host = new HostString("contoso.com"); + var helper = SetupSignInManager(manager.Object, context); + + var optionsJson = await helper.MakePasskeySignalOptionsAsync(user, new() + { + Id = user.Id, + Name = "Foo", + DisplayName = "Foo", + }); + + var options = JsonSerializer.Deserialize(optionsJson); + Assert.Equal("fabrikam.com", options.GetProperty("rpId").GetString()); + } + + [Fact] + public async Task MakePasskeySignalOptionsWithoutPasskeysReturnsEmptyCredentialList() + { + var user = new PocoUser { UserName = "Foo" }; + var manager = SetupUserManager(user); + manager.Setup(m => m.GetPasskeysAsync(user)).ReturnsAsync([]); + var context = new DefaultHttpContext(); + var helper = SetupSignInManager(manager.Object, context); + + var optionsJson = await helper.MakePasskeySignalOptionsAsync(user, new() + { + Id = user.Id, + Name = "Foo", + DisplayName = "Foo", + }); + + var options = JsonSerializer.Deserialize(optionsJson); + Assert.Empty(options.GetProperty("allAcceptedCredentialIds").EnumerateArray()); + } + + [Fact] + public async Task MakePasskeySignalOptionsThrowsWhenUserEntityIdDoesNotMatchUser() + { + var user = new PocoUser { UserName = "Foo" }; + var manager = SetupUserManager(user); + var context = new DefaultHttpContext(); + var helper = SetupSignInManager(manager.Object, context); + + var ex = await Assert.ThrowsAsync( + () => helper.MakePasskeySignalOptionsAsync(user, new() + { + Id = "some-other-id", + Name = "Foo", + DisplayName = "Foo", + })); + + Assert.Equal($"The user entity ID 'some-other-id' does not match the ID '{user.Id}' of the specified user.", ex.Message); + } + + private static UserPasskeyInfo CreatePasskey(byte[] credentialId) + => new(credentialId, [], default, 0, null, false, false, false, [], []); + private static void SetupPasskeyAuth(HttpContext context, Mock auth) { // Calling AuthenticateAsync will return a failure result diff --git a/src/Identity/test/Shared/MockHelpers.cs b/src/Identity/test/Shared/MockHelpers.cs index 309cbfc07196..08cba307b3d6 100644 --- a/src/Identity/test/Shared/MockHelpers.cs +++ b/src/Identity/test/Shared/MockHelpers.cs @@ -16,7 +16,8 @@ public static class MockHelpers public static Mock> MockUserManager( IMeterFactory meterFactory = null, - IPasskeyHandler passkeyHandler = null) + IPasskeyHandler passkeyHandler = null, + IdentityPasskeyOptions passkeyOptions = null) where TUser : class { var services = new ServiceCollection(); @@ -28,6 +29,10 @@ public static Mock> MockUserManager( { services.AddSingleton(passkeyHandler); } + if (passkeyOptions != null) + { + services.AddSingleton(Options.Create(passkeyOptions)); + } var store = new Mock>(); var mgr = new Mock>(store.Object, null, null, null, null, null, null, services.BuildServiceProvider(), null); diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Pages/ConfirmEmailChange.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Pages/ConfirmEmailChange.razor index 6b9052849d68..37633d31f7e1 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Pages/ConfirmEmailChange.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Pages/ConfirmEmailChange.razor @@ -15,8 +15,14 @@ +@if (updatedUser is not null) +{ + +} + @code { private string? message; + private ApplicationUser? updatedUser; [CascadingParameter] private HttpContext HttpContext { get; set; } = default!; @@ -70,6 +76,7 @@ } await SignInManager.RefreshSignInAsync(user); + updatedUser = user; message = "Thank you for confirming your email change."; } } diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Pages/Manage/Passkeys.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Pages/Manage/Passkeys.razor index f2d98bb53d98..2044ea90ab72 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Pages/Manage/Passkeys.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Pages/Manage/Passkeys.razor @@ -48,6 +48,11 @@ else

No passkeys are registered.

} +@if (user is not null) +{ + +} +
@if (currentPasskeys is { Count: >= MaxPasskeyCount }) { diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Shared/PasskeySignals.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Shared/PasskeySignals.razor new file mode 100644 index 000000000000..e6f1222abad7 --- /dev/null +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Shared/PasskeySignals.razor @@ -0,0 +1,38 @@ +@using Microsoft.AspNetCore.Identity +@using BlazorWebCSharp._1.Data + +@inject UserManager UserManager +@inject SignInManager SignInManager + +@if (signalOptionsJson is not null) +{ + +} + +@code { + [Parameter] + [EditorRequired] + public ApplicationUser User { get; set; } = default!; + + private string? signalOptionsJson; + + protected override async Task OnInitializedAsync() + { + if (!UserManager.SupportsUserPasskey) + { + return; + } + + // These must match the user entity passed to MakePasskeyCreationOptionsAsync when the + // passkey was created, otherwise the authenticator won't recognize the user. + var userId = await UserManager.GetUserIdAsync(User); + var userName = await UserManager.GetUserNameAsync(User) ?? "User"; + + signalOptionsJson = await SignInManager.MakePasskeySignalOptionsAsync(User, new() + { + Id = userId, + Name = userName, + DisplayName = userName, + }); + } +} diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Shared/PasskeySignals.razor.js b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Shared/PasskeySignals.razor.js new file mode 100644 index 000000000000..5e1a87a2468e --- /dev/null +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Shared/PasskeySignals.razor.js @@ -0,0 +1,14 @@ +customElements.define('passkey-signals', class extends HTMLElement { + async connectedCallback() { + const { rpId, userId, allAcceptedCredentialIds, name, displayName } = JSON.parse(this.getAttribute('options')); + try { + // Tells the authenticator which passkeys are still valid so that deleted ones are no + // longer offered at sign-in, and keeps the displayed user details up to date. + // Not all browsers support these, and they are best-effort, so failures are not surfaced. + await window.PublicKeyCredential?.signalAllAcceptedCredentials?.({ rpId, userId, allAcceptedCredentialIds }); + await window.PublicKeyCredential?.signalCurrentUserDetails?.({ rpId, userId, name, displayName }); + } catch (error) { + console.error(error); + } + } +}); diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/App.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/App.razor index c7e72eb2f460..65b3302e8d92 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/App.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/App.razor @@ -46,6 +46,7 @@ @*#if (IndividualLocalAuth) + ##endif*@ diff --git a/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorTemplateTest.cs b/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorTemplateTest.cs index f6334c1cca6a..5c6586882867 100644 --- a/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorTemplateTest.cs +++ b/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorTemplateTest.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Runtime.InteropServices; +using System.Text.Json; using Microsoft.AspNetCore.BrowserTesting; using Microsoft.Playwright; using Templates.Test.Helpers; @@ -159,6 +160,22 @@ await Task.WhenAll( Assert.True(result.HasValue); var authenticatorId = result.Value.GetProperty("authenticatorId").GetString(); + // Record the WebAuthn signal calls made by each page so that we can assert on them later. + // We define the signal methods if they're missing so that the assertions don't depend on + // the browser version bundled with Playwright. + await page.AddInitScriptAsync(""" + window.__passkeySignals = []; + if (window.PublicKeyCredential) { + for (const name of ['signalAllAcceptedCredentials', 'signalCurrentUserDetails']) { + const original = window.PublicKeyCredential[name]; + window.PublicKeyCredential[name] = function (options) { + window.__passkeySignals.push({ name, options }); + return original ? original.call(this, options) : Promise.resolve(); + }; + } + } + """); + await Task.WhenAll( page.WaitForURLAsync("**/Account/Login**", new() { WaitUntil = WaitUntilState.NetworkIdle }), page.ClickAsync("text=Login")); @@ -246,6 +263,18 @@ await page.EvaluateAsync(""" await page.WaitForSelectorAsync("text=Passkey updated successfully"); + // The page signals the browser's passkey provider with the passkeys that are + // still valid, so that deleted ones stop being offered at sign-in. + var acceptedCredentials = await GetSignalledCredentialIdsAsync(page); + var storedCredentials = await GetAuthenticatorCredentialsAsync(cdpSession, authenticatorId); + Assert.Single(storedCredentials); + Assert.Equal(storedCredentials, acceptedCredentials); + + var userDetails = await GetPasskeySignalAsync(page, "signalCurrentUserDetails"); + Assert.Equal(new Uri(page.Url).Host, userDetails.GetProperty("rpId").GetString()); + Assert.Equal(userName, userDetails.GetProperty("name").GetString()); + Assert.Equal(userName, userDetails.GetProperty("displayName").GetString()); + // Logout so that we can test the passkey login flow await Task.WhenAll( page.WaitForURLAsync("**/Account/Login**", new() { WaitUntil = WaitUntilState.NetworkIdle }), @@ -286,6 +315,21 @@ await page.EvaluateAsync(""" // Verify that we can visit the "Auth Required" page again await page.ClickAsync("text=Auth Required"); await page.WaitForSelectorAsync("text=You are authenticated"); + + // Deleting the passkey signals the provider with an empty credential list, + // which is what removes the passkey from the sign-in options + await Task.WhenAll( + page.WaitForURLAsync("**/Account/Manage**", new() { WaitUntil = WaitUntilState.NetworkIdle }), + page.ClickAsync("a[href=\"Account/Manage\"]")); + + await Task.WhenAll( + page.WaitForURLAsync("**/Account/Manage/Passkeys**", new() { WaitUntil = WaitUntilState.NetworkIdle }), + page.ClickAsync("a[href=\"Account/Manage/Passkeys\"]")); + + await page.ClickAsync("button[value=\"delete\"]"); + await page.WaitForSelectorAsync("text=Passkey deleted successfully"); + + Assert.Empty(await GetSignalledCredentialIdsAsync(page)); } } @@ -326,6 +370,31 @@ static async Task IncrementCounterAsync(IPage page) Assert.Fail($"The counter did not increment after {MaxIncrementAttempts} attempts"); } + + static async Task GetPasskeySignalAsync(IPage page, string name) + { + await page.WaitForFunctionAsync($"() => window.__passkeySignals.some(s => s.name === '{name}')"); + return await page.EvaluateAsync($"() => window.__passkeySignals.find(s => s.name === '{name}').options"); + } + + static async Task GetSignalledCredentialIdsAsync(IPage page) + { + var options = await GetPasskeySignalAsync(page, "signalAllAcceptedCredentials"); + return [.. options.GetProperty("allAcceptedCredentialIds").EnumerateArray().Select(id => NormalizeBase64(id.GetString()))]; + } + + static async Task GetAuthenticatorCredentialsAsync(ICDPSession cdpSession, string authenticatorId) + { + var result = await cdpSession.SendAsync("WebAuthn.getCredentials", new Dictionary + { + ["authenticatorId"] = authenticatorId, + }); + return [.. result.Value.GetProperty("credentials").EnumerateArray().Select(c => NormalizeBase64(c.GetProperty("credentialId").GetString()))]; + } + + // The signal API uses base64url while CDP uses base64, so compare a canonical form. + static string NormalizeBase64(string value) + => value.Replace('+', '-').Replace('/', '_').TrimEnd('='); } protected void EnsureBrowserAvailable(BrowserKind browserKind) diff --git a/src/ProjectTemplates/test/Templates.Tests/template-baselines.json b/src/ProjectTemplates/test/Templates.Tests/template-baselines.json index c908aad66194..7d115e2d1fd5 100644 --- a/src/ProjectTemplates/test/Templates.Tests/template-baselines.json +++ b/src/ProjectTemplates/test/Templates.Tests/template-baselines.json @@ -640,6 +640,8 @@ "Components/Account/Shared/ExternalLoginPicker.razor", "Components/Account/Shared/ManageLayout.razor", "Components/Account/Shared/ManageNavMenu.razor", + "Components/Account/Shared/PasskeySignals.razor", + "Components/Account/Shared/PasskeySignals.razor.js", "Components/Account/Shared/PasskeySubmit.razor", "Components/Account/Shared/PasskeySubmit.razor.js", "Components/Account/Shared/RedirectToLogin.razor", @@ -839,6 +841,8 @@ "Components/Account/Shared/ExternalLoginPicker.razor", "Components/Account/Shared/ManageLayout.razor", "Components/Account/Shared/ManageNavMenu.razor", + "Components/Account/Shared/PasskeySignals.razor", + "Components/Account/Shared/PasskeySignals.razor.js", "Components/Account/Shared/PasskeySubmit.razor", "Components/Account/Shared/PasskeySubmit.razor.js", "Components/Account/Shared/RedirectToLogin.razor", @@ -968,6 +972,8 @@ "Components/Account/Shared/ExternalLoginPicker.razor", "Components/Account/Shared/ManageLayout.razor", "Components/Account/Shared/ManageNavMenu.razor", + "Components/Account/Shared/PasskeySignals.razor", + "Components/Account/Shared/PasskeySignals.razor.js", "Components/Account/Shared/PasskeySubmit.razor", "Components/Account/Shared/PasskeySubmit.razor.js", "Components/Account/Shared/RedirectToLogin.razor", @@ -1181,6 +1187,8 @@ "{ProjectName}/Components/Account/Shared/ExternalLoginPicker.razor", "{ProjectName}/Components/Account/Shared/ManageLayout.razor", "{ProjectName}/Components/Account/Shared/ManageNavMenu.razor", + "{ProjectName}/Components/Account/Shared/PasskeySignals.razor", + "{ProjectName}/Components/Account/Shared/PasskeySignals.razor.js", "{ProjectName}/Components/Account/Shared/PasskeySubmit.razor", "{ProjectName}/Components/Account/Shared/PasskeySubmit.razor.js", "{ProjectName}/Components/Account/Shared/ShowRecoveryCodes.razor", @@ -1393,6 +1401,8 @@ "{ProjectName}/Components/Account/Shared/ExternalLoginPicker.razor", "{ProjectName}/Components/Account/Shared/ManageLayout.razor", "{ProjectName}/Components/Account/Shared/ManageNavMenu.razor", + "{ProjectName}/Components/Account/Shared/PasskeySignals.razor", + "{ProjectName}/Components/Account/Shared/PasskeySignals.razor.js", "{ProjectName}/Components/Account/Shared/PasskeySubmit.razor", "{ProjectName}/Components/Account/Shared/PasskeySubmit.razor.js", "{ProjectName}/Components/Account/Shared/ShowRecoveryCodes.razor", @@ -1853,6 +1863,8 @@ "{ProjectName}/Components/Account/Shared/ExternalLoginPicker.razor", "{ProjectName}/Components/Account/Shared/ManageLayout.razor", "{ProjectName}/Components/Account/Shared/ManageNavMenu.razor", + "{ProjectName}/Components/Account/Shared/PasskeySignals.razor", + "{ProjectName}/Components/Account/Shared/PasskeySignals.razor.js", "{ProjectName}/Components/Account/Shared/PasskeySubmit.razor", "{ProjectName}/Components/Account/Shared/PasskeySubmit.razor.js", "{ProjectName}/Components/Account/Shared/ShowRecoveryCodes.razor", @@ -1930,6 +1942,8 @@ "Components/Account/Shared/ExternalLoginPicker.razor", "Components/Account/Shared/ManageLayout.razor", "Components/Account/Shared/ManageNavMenu.razor", + "Components/Account/Shared/PasskeySignals.razor", + "Components/Account/Shared/PasskeySignals.razor.js", "Components/Account/Shared/PasskeySubmit.razor", "Components/Account/Shared/PasskeySubmit.razor.js", "Components/Account/Shared/RedirectToLogin.razor", @@ -2076,6 +2090,8 @@ "{ProjectName}/Components/Account/Shared/ExternalLoginPicker.razor", "{ProjectName}/Components/Account/Shared/ManageLayout.razor", "{ProjectName}/Components/Account/Shared/ManageNavMenu.razor", + "{ProjectName}/Components/Account/Shared/PasskeySignals.razor", + "{ProjectName}/Components/Account/Shared/PasskeySignals.razor.js", "{ProjectName}/Components/Account/Shared/PasskeySubmit.razor", "{ProjectName}/Components/Account/Shared/PasskeySubmit.razor.js", "{ProjectName}/Components/Account/Shared/ShowRecoveryCodes.razor", @@ -2212,6 +2228,8 @@ "{ProjectName}/Components/Account/Shared/ExternalLoginPicker.razor", "{ProjectName}/Components/Account/Shared/ManageLayout.razor", "{ProjectName}/Components/Account/Shared/ManageNavMenu.razor", + "{ProjectName}/Components/Account/Shared/PasskeySignals.razor", + "{ProjectName}/Components/Account/Shared/PasskeySignals.razor.js", "{ProjectName}/Components/Account/Shared/PasskeySubmit.razor", "{ProjectName}/Components/Account/Shared/PasskeySubmit.razor.js", "{ProjectName}/Components/Account/Shared/ShowRecoveryCodes.razor", From 8beab7a69da28e15f6d225f15056080cc4bef214 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20V=C3=ADzner?= <148648143+rolandVi@users.noreply.github.com> Date: Mon, 3 Aug 2026 10:41:04 +0200 Subject: [PATCH 2/7] Apply suggestions from code review Co-authored-by: Stephen Halter --- src/Identity/Core/src/SignInManager.cs | 6 +++--- .../test/Templates.Blazor.Tests/BlazorTemplateTest.cs | 10 ++++------ 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/src/Identity/Core/src/SignInManager.cs b/src/Identity/Core/src/SignInManager.cs index 746f259ce0c4..f17c482148a1 100644 --- a/src/Identity/Core/src/SignInManager.cs +++ b/src/Identity/Core/src/SignInManager.cs @@ -557,9 +557,9 @@ public virtual async Task MakePasskeyRequestOptionsAsync(TUser? user) /// /// /// Because these APIs reveal how many passkeys a user has, only call them when the user is authenticated. - /// The must match the one passed to - /// when the passkeys were created, - /// otherwise the authenticator will not recognize the user and the signal will have no effect. + /// The must have the same that was passed to + /// when the passkeys were created, + /// otherwise the authenticator will not recognize the user and the signal will have no effect. /// /// /// See . diff --git a/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorTemplateTest.cs b/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorTemplateTest.cs index 5c6586882867..ddaa88b2199f 100644 --- a/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorTemplateTest.cs +++ b/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorTemplateTest.cs @@ -380,7 +380,7 @@ static async Task GetPasskeySignalAsync(IPage page, string name) static async Task GetSignalledCredentialIdsAsync(IPage page) { var options = await GetPasskeySignalAsync(page, "signalAllAcceptedCredentials"); - return [.. options.GetProperty("allAcceptedCredentialIds").EnumerateArray().Select(id => NormalizeBase64(id.GetString()))]; + return [.. options.GetProperty("allAcceptedCredentialIds").EnumerateArray().Select(id => id.GetString())]; } static async Task GetAuthenticatorCredentialsAsync(ICDPSession cdpSession, string authenticatorId) @@ -389,12 +389,10 @@ static async Task GetAuthenticatorCredentialsAsync(ICDPSession cdpSess { ["authenticatorId"] = authenticatorId, }); - return [.. result.Value.GetProperty("credentials").EnumerateArray().Select(c => NormalizeBase64(c.GetProperty("credentialId").GetString()))]; + var credentials = result.Value.GetProperty("credentials").EnumerateArray(); + // The signal API uses base64url while CDP uses base64. + return [.. credentials.Select(c => Base64Url.EncodeToString(Convert.FromBase64String(c.GetProperty("credentialId").GetString())))]; } - - // The signal API uses base64url while CDP uses base64, so compare a canonical form. - static string NormalizeBase64(string value) - => value.Replace('+', '-').Replace('/', '_').TrimEnd('='); } protected void EnsureBrowserAvailable(BrowserKind browserKind) From ac4227be94a614a6b1785d04a769db99e1fe8629 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20V=C4=B1=CC=81zner?= Date: Mon, 3 Aug 2026 12:17:36 +0200 Subject: [PATCH 3/7] Move passkey signal options to IPasskeyHandler --- src/Identity/Core/src/IPasskeyHandler.cs | 25 + src/Identity/Core/src/PasskeyHandler.cs | 36 +- .../Core/src/PasskeySignalOptionsResult.cs | 21 + .../Core/src/Passkeys/PasskeyServerDomain.cs | 12 - src/Identity/Core/src/PublicAPI.Unshipped.txt | 9 + src/Identity/Core/src/SignInManager.cs | 2963 ++++++++--------- .../Passkeys/PasskeyHandlerSignalTest.cs | 130 + .../test/Identity.Test/SignInManagerTest.cs | 89 +- src/Identity/test/Shared/MockHelpers.cs | 7 +- .../Account/Shared/PasskeySignals.razor | 2 +- 10 files changed, 1732 insertions(+), 1562 deletions(-) create mode 100644 src/Identity/Core/src/PasskeySignalOptionsResult.cs delete mode 100644 src/Identity/Core/src/Passkeys/PasskeyServerDomain.cs create mode 100644 src/Identity/test/Identity.Test/Passkeys/PasskeyHandlerSignalTest.cs diff --git a/src/Identity/Core/src/IPasskeyHandler.cs b/src/Identity/Core/src/IPasskeyHandler.cs index 50898659edf0..0f4b2a1a4594 100644 --- a/src/Identity/Core/src/IPasskeyHandler.cs +++ b/src/Identity/Core/src/IPasskeyHandler.cs @@ -12,6 +12,16 @@ namespace Microsoft.AspNetCore.Identity; public interface IPasskeyHandler where TUser : class { + /// + /// Gets a value indicating whether this handler supports generating passkey signal options. + /// + /// + /// Returns unless the handler implements + /// and can retrieve + /// the user's passkeys. + /// + bool SupportsSignalOptions => false; + /// /// Generates passkey creation options for the specified user entity and HTTP context. /// @@ -28,6 +38,21 @@ public interface IPasskeyHandler /// A representing the result. Task MakeRequestOptionsAsync(TUser? user, HttpContext httpContext); + /// + /// Generates the options used to signal the current state of a user's passkeys to authenticators. + /// + /// + /// Handlers that implement this method should also return from + /// . See . + /// + /// The user whose passkeys should be signaled. + /// The passkey user entity associated with the user's passkeys. + /// The HTTP context associated with the request. + /// A representing the result. + /// Thrown when the handler does not support generating signal options. + Task MakeSignalOptionsAsync(TUser user, PasskeyUserEntity userEntity, HttpContext httpContext) + => throw new NotSupportedException($"'{GetType()}' does not support generating passkey signal options."); + /// /// Performs passkey attestation using the provided . /// diff --git a/src/Identity/Core/src/PasskeyHandler.cs b/src/Identity/Core/src/PasskeyHandler.cs index c21edbb68e29..a94af0d7d4ea 100644 --- a/src/Identity/Core/src/PasskeyHandler.cs +++ b/src/Identity/Core/src/PasskeyHandler.cs @@ -34,6 +34,9 @@ public PasskeyHandler(UserManager userManager, IOptions + public bool SupportsSignalOptions => _userManager.SupportsUserPasskey; + /// public async Task MakeCreationOptionsAsync(PasskeyUserEntity userEntity, HttpContext httpContext) { @@ -157,6 +160,37 @@ async Task GetAllowCredentialsAsync() } } + /// + public async Task MakeSignalOptionsAsync(TUser user, PasskeyUserEntity userEntity, HttpContext httpContext) + { + ArgumentNullException.ThrowIfNull(user); + ArgumentNullException.ThrowIfNull(userEntity); + ArgumentNullException.ThrowIfNull(httpContext); + + var userId = await _userManager.GetUserIdAsync(user).ConfigureAwait(false); + if (!string.Equals(userId, userEntity.Id, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"The user entity ID '{userEntity.Id}' does not match the ID '{userId}' of the specified user."); + } + + var passkeys = await _userManager.GetPasskeysAsync(user).ConfigureAwait(false); + var options = new PasskeySignalOptions + { + RpId = GetServerDomain(httpContext), + UserId = BufferSource.FromString(userEntity.Id), + AllAcceptedCredentialIds = [.. passkeys.Select(p => BufferSource.FromBytes(p.CredentialId))], + Name = userEntity.Name, + DisplayName = userEntity.DisplayName, + }; + var optionsJson = JsonSerializer.Serialize(options, IdentityJsonSerializerContext.Default.PasskeySignalOptions); + + return new PasskeySignalOptionsResult + { + SignalOptionsJson = optionsJson, + }; + } + /// public async Task PerformAttestationAsync(PasskeyAttestationContext context) { @@ -678,5 +712,5 @@ private ValueTask ValidateOriginAsync(CollectedClientData clientData, Http } private string GetServerDomain(HttpContext httpContext) - => PasskeyServerDomain.Resolve(_options, httpContext); + => _options.ServerDomain ?? httpContext.Request.Host.Host; } diff --git a/src/Identity/Core/src/PasskeySignalOptionsResult.cs b/src/Identity/Core/src/PasskeySignalOptionsResult.cs new file mode 100644 index 000000000000..5be667bdf69e --- /dev/null +++ b/src/Identity/Core/src/PasskeySignalOptionsResult.cs @@ -0,0 +1,21 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Identity; + +/// +/// Represents the result of a passkey signal options generation. +/// +public sealed class PasskeySignalOptionsResult +{ + /// + /// Gets or sets the JSON representation of the signal options. + /// + /// + /// The structure of this JSON is a superset of the options accepted by the + /// PublicKeyCredential.signalAllAcceptedCredentials() and + /// PublicKeyCredential.signalCurrentUserDetails() JavaScript APIs. + /// See . + /// + public required string SignalOptionsJson { get; init; } +} diff --git a/src/Identity/Core/src/Passkeys/PasskeyServerDomain.cs b/src/Identity/Core/src/Passkeys/PasskeyServerDomain.cs deleted file mode 100644 index b445f822bd9c..000000000000 --- a/src/Identity/Core/src/Passkeys/PasskeyServerDomain.cs +++ /dev/null @@ -1,12 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Microsoft.AspNetCore.Http; - -namespace Microsoft.AspNetCore.Identity; - -internal static class PasskeyServerDomain -{ - public static string Resolve(IdentityPasskeyOptions options, HttpContext httpContext) - => options.ServerDomain ?? httpContext.Request.Host.Host; -} diff --git a/src/Identity/Core/src/PublicAPI.Unshipped.txt b/src/Identity/Core/src/PublicAPI.Unshipped.txt index 6587b8bf06e5..252d88ca84d4 100644 --- a/src/Identity/Core/src/PublicAPI.Unshipped.txt +++ b/src/Identity/Core/src/PublicAPI.Unshipped.txt @@ -1,2 +1,11 @@ #nullable enable +Microsoft.AspNetCore.Identity.IPasskeyHandler.MakeSignalOptionsAsync(TUser! user, Microsoft.AspNetCore.Identity.PasskeyUserEntity! userEntity, Microsoft.AspNetCore.Http.HttpContext! httpContext) -> System.Threading.Tasks.Task! +Microsoft.AspNetCore.Identity.IPasskeyHandler.SupportsSignalOptions.get -> bool +Microsoft.AspNetCore.Identity.PasskeyHandler.MakeSignalOptionsAsync(TUser! user, Microsoft.AspNetCore.Identity.PasskeyUserEntity! userEntity, Microsoft.AspNetCore.Http.HttpContext! httpContext) -> System.Threading.Tasks.Task! +Microsoft.AspNetCore.Identity.PasskeyHandler.SupportsSignalOptions.get -> bool +Microsoft.AspNetCore.Identity.PasskeySignalOptionsResult +Microsoft.AspNetCore.Identity.PasskeySignalOptionsResult.PasskeySignalOptionsResult() -> void +Microsoft.AspNetCore.Identity.PasskeySignalOptionsResult.SignalOptionsJson.get -> string! +Microsoft.AspNetCore.Identity.PasskeySignalOptionsResult.SignalOptionsJson.init -> void virtual Microsoft.AspNetCore.Identity.SignInManager.MakePasskeySignalOptionsAsync(TUser! user, Microsoft.AspNetCore.Identity.PasskeyUserEntity! userEntity) -> System.Threading.Tasks.Task! +virtual Microsoft.AspNetCore.Identity.SignInManager.SupportsPasskeySignalOptions.get -> bool diff --git a/src/Identity/Core/src/SignInManager.cs b/src/Identity/Core/src/SignInManager.cs index f17c482148a1..109d0f711192 100644 --- a/src/Identity/Core/src/SignInManager.cs +++ b/src/Identity/Core/src/SignInManager.cs @@ -1,1486 +1,1483 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.Diagnostics.Metrics; -using System.Linq; -using System.Security.Claims; -using System.Text; -using System.Text.Json; -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; - -namespace Microsoft.AspNetCore.Identity; - -/// -/// Provides the APIs for user sign in. -/// -/// The type encapsulating a user. -public class SignInManager where TUser : class -{ - private const string LoginProviderKey = "LoginProvider"; - private const string XsrfKey = "XsrfId"; - private const string PasskeyOperationKey = "PasskeyOperation"; - private const string PasskeyStateKey = "PasskeyState"; - - private static readonly bool AlwaysResetLockoutOnSuccess = - AppContext.TryGetSwitch("Microsoft.AspNetCore.Identity.CheckPasswordSignInAlwaysResetLockoutOnSuccess", out var enabled) && enabled; - - private readonly IHttpContextAccessor _contextAccessor; - private readonly IAuthenticationSchemeProvider _schemes; - private readonly IUserConfirmation _confirmation; - private readonly IPasskeyHandler? _passkeyHandler; - private readonly IdentityPasskeyOptions _passkeyOptions; - private readonly SignInManagerMetrics? _metrics; - private HttpContext? _context; - private TwoFactorAuthenticationInfo? _twoFactorInfo; - private PasskeyAuthenticationInfo? _passkeyInfo; - - /// - /// Creates a new instance of . - /// - /// An instance of used to retrieve users from and persist users. - /// The accessor used to access the . - /// The factory to use to create claims principals for a user. - /// The accessor used to access the . - /// The logger used to log messages, warnings and errors. - /// The scheme provider that is used enumerate the authentication schemes. - /// The used check whether a user account is confirmed. - public SignInManager(UserManager userManager, - IHttpContextAccessor contextAccessor, - IUserClaimsPrincipalFactory claimsFactory, - IOptions optionsAccessor, - ILogger> logger, - IAuthenticationSchemeProvider schemes, - IUserConfirmation confirmation) - { - ArgumentNullException.ThrowIfNull(userManager); - ArgumentNullException.ThrowIfNull(contextAccessor); - ArgumentNullException.ThrowIfNull(claimsFactory); - - UserManager = userManager; - _contextAccessor = contextAccessor; - ClaimsFactory = claimsFactory; - Options = optionsAccessor?.Value ?? new IdentityOptions(); - Logger = logger; - _schemes = schemes; - _confirmation = confirmation; - // SignInManagerMetrics created from constructor because of difficulties registering internal type. - _metrics = userManager.ServiceProvider?.GetService() is { } factory ? new SignInManagerMetrics(factory) : null; - _passkeyHandler = userManager.ServiceProvider?.GetService>(); - _passkeyOptions = userManager.ServiceProvider?.GetService>()?.Value ?? new IdentityPasskeyOptions(); - } - - /// - /// Gets the used to log messages from the manager. - /// - /// - /// The used to log messages from the manager. - /// - public virtual ILogger Logger { get; set; } - - /// - /// The used. - /// - public UserManager UserManager { get; set; } - - /// - /// The used. - /// - public IUserClaimsPrincipalFactory ClaimsFactory { get; set; } - - /// - /// The used. - /// - public IdentityOptions Options { get; set; } - - /// - /// The authentication scheme to sign in with. Defaults to . - /// - public string AuthenticationScheme { get; set; } = IdentityConstants.ApplicationScheme; - - /// - /// The used. - /// - public HttpContext Context - { - get - { - var context = _context ?? _contextAccessor?.HttpContext; - if (context == null) - { - throw new InvalidOperationException("HttpContext must not be null."); - } - return context; - } - set - { - _context = value; - } - } - - /// - /// Creates a for the specified , as an asynchronous operation. - /// - /// The user to create a for. - /// The task object representing the asynchronous operation, containing the ClaimsPrincipal for the specified user. - public virtual async Task CreateUserPrincipalAsync(TUser user) => await ClaimsFactory.CreateAsync(user); - - /// - /// Returns true if the principal has an identity with the application cookie identity - /// - /// The instance. - /// True if the user is logged in with identity. - public virtual bool IsSignedIn(ClaimsPrincipal principal) - { - ArgumentNullException.ThrowIfNull(principal); - return principal.Identities != null && - principal.Identities.Any(i => i.AuthenticationType == AuthenticationScheme); - } - - /// - /// Returns a flag indicating whether the specified user can sign in. - /// - /// The user whose sign-in status should be returned. - /// - /// The task object representing the asynchronous operation, containing a flag that is true - /// if the specified user can sign-in, otherwise false. - /// - public virtual async Task CanSignInAsync(TUser user) - { - if (Options.SignIn.RequireConfirmedEmail && !(await UserManager.IsEmailConfirmedAsync(user))) - { - Logger.LogDebug(EventIds.UserCannotSignInWithoutConfirmedEmail, "User cannot sign in without a confirmed email."); - return false; - } - if (Options.SignIn.RequireConfirmedPhoneNumber && !(await UserManager.IsPhoneNumberConfirmedAsync(user))) - { - Logger.LogDebug(EventIds.UserCannotSignInWithoutConfirmedPhoneNumber, "User cannot sign in without a confirmed phone number."); - return false; - } - if (Options.SignIn.RequireConfirmedAccount && !(await _confirmation.IsConfirmedAsync(UserManager, user))) - { - Logger.LogDebug(EventIds.UserCannotSignInWithoutConfirmedAccount, "User cannot sign in without a confirmed account."); - return false; - } - return true; - } - - /// - /// Signs in the specified , whilst preserving the existing - /// AuthenticationProperties of the current signed-in user like rememberMe, as an asynchronous operation. - /// - /// The user to sign-in. - /// The task object representing the asynchronous operation. - public virtual async Task RefreshSignInAsync(TUser user) - { - var startTimestamp = Stopwatch.GetTimestamp(); - try - { - var (success, isPersistent) = await RefreshSignInCoreAsync(user); - var signInResult = success ? SignInResult.Success : SignInResult.Failed; - _metrics?.AuthenticateSignIn(typeof(TUser).FullName!, AuthenticationScheme, signInResult, SignInType.Refresh, isPersistent, startTimestamp); - } - catch (Exception ex) - { - _metrics?.AuthenticateSignIn(typeof(TUser).FullName!, AuthenticationScheme, result: null, SignInType.Refresh, isPersistent: null, startTimestamp, ex); - throw; - } - } - - private async Task<(bool success, bool? isPersistent)> RefreshSignInCoreAsync(TUser user) - { - var auth = await Context.AuthenticateAsync(AuthenticationScheme); - if (!auth.Succeeded || auth.Principal?.Identity?.IsAuthenticated != true) - { - Logger.LogError("RefreshSignInAsync prevented because the user is not currently authenticated. Use SignInAsync instead for initial sign in."); - return (false, auth.Properties?.IsPersistent); - } - - var authenticatedUserId = UserManager.GetUserId(auth.Principal); - var newUserId = await UserManager.GetUserIdAsync(user); - if (authenticatedUserId == null || authenticatedUserId != newUserId) - { - Logger.LogError("RefreshSignInAsync prevented because currently authenticated user has a different UserId. Use SignInAsync instead to change users."); - return (false, auth.Properties?.IsPersistent); - } - - IList claims = Array.Empty(); - var authenticationMethod = auth.Principal?.FindFirst(ClaimTypes.AuthenticationMethod); - var amr = auth.Principal?.FindFirst("amr"); - - if (authenticationMethod != null || amr != null) - { - claims = new List(); - if (authenticationMethod != null) - { - claims.Add(authenticationMethod); - } - if (amr != null) - { - claims.Add(amr); - } - } - - await SignInWithClaimsAsync(user, auth.Properties, claims); - return (true, auth.Properties?.IsPersistent ?? false); - } - - /// - /// Signs in the specified . - /// - /// The user to sign-in. - /// Flag indicating whether the sign-in cookie should persist after the browser is closed. - /// Name of the method used to authenticate the user. - /// The task object representing the asynchronous operation. - [SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "Required for backwards compatibility")] - public virtual Task SignInAsync(TUser user, bool isPersistent, string? authenticationMethod = null) - => SignInAsync(user, new AuthenticationProperties { IsPersistent = isPersistent }, authenticationMethod); - - /// - /// Signs in the specified . - /// - /// The user to sign-in. - /// Properties applied to the login and authentication cookie. - /// Name of the method used to authenticate the user. - /// The task object representing the asynchronous operation. - [SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "Required for backwards compatibility")] - public virtual Task SignInAsync(TUser user, AuthenticationProperties authenticationProperties, string? authenticationMethod = null) - { - IList additionalClaims = Array.Empty(); - if (authenticationMethod != null) - { - additionalClaims = new List(); - additionalClaims.Add(new Claim(ClaimTypes.AuthenticationMethod, authenticationMethod)); - } - return SignInWithClaimsAsync(user, authenticationProperties, additionalClaims); - } - - /// - /// Signs in the specified . - /// - /// The user to sign-in. - /// Flag indicating whether the sign-in cookie should persist after the browser is closed. - /// Additional claims that will be stored in the cookie. - /// The task object representing the asynchronous operation. - public virtual Task SignInWithClaimsAsync(TUser user, bool isPersistent, IEnumerable additionalClaims) - => SignInWithClaimsAsync(user, new AuthenticationProperties { IsPersistent = isPersistent }, additionalClaims); - - /// - /// Signs in the specified . - /// - /// The user to sign-in. - /// Properties applied to the login and authentication cookie. - /// Additional claims that will be stored in the cookie. - /// The task object representing the asynchronous operation. - public virtual async Task SignInWithClaimsAsync(TUser user, AuthenticationProperties? authenticationProperties, IEnumerable additionalClaims) - { - try - { - var userPrincipal = await CreateUserPrincipalAsync(user); - foreach (var claim in additionalClaims) - { - userPrincipal.Identities.First().AddClaim(claim); - } - - authenticationProperties ??= new AuthenticationProperties(); - await Context.SignInAsync(AuthenticationScheme, - userPrincipal, - authenticationProperties); - - // This is useful for updating claims immediately when hitting MapIdentityApi's /account/info endpoint with cookies. - Context.User = userPrincipal; - - _metrics?.SignInUserPrincipal(typeof(TUser).FullName!, AuthenticationScheme, authenticationProperties.IsPersistent); - } - catch (Exception ex) - { - _metrics?.SignInUserPrincipal(typeof(TUser).FullName!, AuthenticationScheme, isPersistent: null, ex); - throw; - } - } - - /// - /// Signs the current user out of the application. - /// - public virtual async Task SignOutAsync() - { - try - { - await Context.SignOutAsync(AuthenticationScheme); - - if (await _schemes.GetSchemeAsync(IdentityConstants.ExternalScheme) != null) - { - await Context.SignOutAsync(IdentityConstants.ExternalScheme); - } - if (await _schemes.GetSchemeAsync(IdentityConstants.TwoFactorUserIdScheme) != null) - { - await Context.SignOutAsync(IdentityConstants.TwoFactorUserIdScheme); - } - - _metrics?.SignOutUserPrincipal(typeof(TUser).FullName!, AuthenticationScheme); - } - catch (Exception ex) - { - _metrics?.SignOutUserPrincipal(typeof(TUser).FullName!, AuthenticationScheme, ex); - throw; - } - } - - /// - /// Validates the security stamp for the specified against - /// the persisted stamp for the current user, as an asynchronous operation. - /// - /// The principal whose stamp should be validated. - /// The task object representing the asynchronous operation. The task will contain the - /// if the stamp matches the persisted value, otherwise it will return null. - public virtual async Task ValidateSecurityStampAsync(ClaimsPrincipal? principal) - { - if (principal == null) - { - return null; - } - var user = await UserManager.GetUserAsync(principal); - if (await ValidateSecurityStampAsync(user, principal.FindFirstValue(Options.ClaimsIdentity.SecurityStampClaimType))) - { - return user; - } - Logger.LogDebug(EventIds.SecurityStampValidationFailedId4, "Failed to validate a security stamp."); - return null; - } - - /// - /// Validates the security stamp for the specified from one of - /// the two factor principals (remember client or user id) against - /// the persisted stamp for the current user, as an asynchronous operation. - /// - /// The principal whose stamp should be validated. - /// The task object representing the asynchronous operation. The task will contain the - /// if the stamp matches the persisted value, otherwise it will return null. - public virtual async Task ValidateTwoFactorSecurityStampAsync(ClaimsPrincipal? principal) - { - if (principal == null || principal.Identity?.Name == null) - { - return null; - } - var user = await UserManager.FindByIdAsync(principal.Identity.Name); - if (await ValidateSecurityStampAsync(user, principal.FindFirstValue(Options.ClaimsIdentity.SecurityStampClaimType))) - { - return user; - } - Logger.LogDebug(EventIds.TwoFactorSecurityStampValidationFailed, "Failed to validate a security stamp."); - return null; - } - - /// - /// Validates the security stamp for the specified . If no user is specified, or if the store - /// does not support security stamps, validation is considered successful. - /// - /// The user whose stamp should be validated. - /// The expected security stamp value. - /// The result of the validation. - public virtual async Task ValidateSecurityStampAsync(TUser? user, string? securityStamp) - => user != null && - // Only validate the security stamp if the store supports it - (!UserManager.SupportsUserSecurityStamp || securityStamp == await UserManager.GetSecurityStampAsync(user)); - - /// - /// Attempts to sign in the specified and combination - /// as an asynchronous operation. - /// - /// The user to sign in. - /// The password to attempt to sign in with. - /// Flag indicating whether the sign-in cookie should persist after the browser is closed. - /// Flag indicating if the user account should be locked if the sign in fails. - /// The task object representing the asynchronous operation containing the - /// for the sign-in attempt. - public virtual async Task PasswordSignInAsync(TUser user, string password, - bool isPersistent, bool lockoutOnFailure) - { - var startTimestamp = Stopwatch.GetTimestamp(); - try - { - ArgumentNullException.ThrowIfNull(user); - - var attempt = await CheckPasswordSignInAsync(user, password, lockoutOnFailure); - var result = attempt.Succeeded - ? await SignInOrTwoFactorAsync(user, isPersistent) - : attempt; - _metrics?.AuthenticateSignIn(typeof(TUser).FullName!, AuthenticationScheme, result, SignInType.Password, isPersistent, startTimestamp); - - return result; - } - catch (Exception ex) - { - _metrics?.AuthenticateSignIn(typeof(TUser).FullName!, AuthenticationScheme, result: null, SignInType.Password, isPersistent, startTimestamp, ex); - throw; - } - } - - /// - /// Attempts to sign in the specified and combination - /// as an asynchronous operation. - /// - /// The user name to sign in. - /// The password to attempt to sign in with. - /// Flag indicating whether the sign-in cookie should persist after the browser is closed. - /// Flag indicating if the user account should be locked if the sign in fails. - /// The task object representing the asynchronous operation containing the - /// for the sign-in attempt. - public virtual async Task PasswordSignInAsync(string userName, string password, - bool isPersistent, bool lockoutOnFailure) - { - var startTimestamp = Stopwatch.GetTimestamp(); - var user = await UserManager.FindByNameAsync(userName); - if (user == null) - { - _metrics?.AuthenticateSignIn(typeof(TUser).FullName!, AuthenticationScheme, SignInResult.Failed, SignInType.Password, isPersistent, startTimestamp); - return SignInResult.Failed; - } - - return await PasswordSignInAsync(user, password, isPersistent, lockoutOnFailure); - } - - /// - /// Attempts a password sign in for a user. - /// - /// The user to sign in. - /// The password to attempt to sign in with. - /// Flag indicating if the user account should be locked if the sign in fails. - /// The task object representing the asynchronous operation containing the - /// for the sign-in attempt. - public virtual async Task CheckPasswordSignInAsync(TUser user, string password, bool lockoutOnFailure) - { - try - { - ArgumentNullException.ThrowIfNull(user); - - var result = await CheckPasswordSignInCoreAsync(user, password, lockoutOnFailure); - _metrics?.CheckPasswordSignIn(typeof(TUser).FullName!, result); - - return result; - } - catch (Exception ex) - { - _metrics?.CheckPasswordSignIn(typeof(TUser).FullName!, result: null, ex); - throw; - } - } - - private async Task CheckPasswordSignInCoreAsync(TUser user, string password, bool lockoutOnFailure) - { - var error = await PreSignInCheck(user); - if (error != null) - { - return error; - } - - if (await UserManager.CheckPasswordAsync(user, password)) - { - var alwaysLockout = AlwaysResetLockoutOnSuccess; - // Only reset the lockout when not in quirks mode if either TFA is not enabled or the client is remembered for TFA. - if (alwaysLockout || !await IsTwoFactorEnabledAsync(user) || await IsTwoFactorClientRememberedAsync(user)) - { - var resetLockoutResult = await ResetLockoutWithResult(user); - if (!resetLockoutResult.Succeeded) - { - // ResetLockout got an unsuccessful result that could be caused by concurrency failures indicating an - // attacker could be trying to bypass the MaxFailedAccessAttempts limit. Return the same failure we do - // when failing to increment the lockout to avoid giving an attacker extra guesses at the password. - return SignInResult.Failed; - } - } - - return SignInResult.Success; - } - Logger.LogDebug(EventIds.InvalidPassword, "User failed to provide the correct password."); - - if (UserManager.SupportsUserLockout && lockoutOnFailure) - { - // If lockout is requested, increment access failed count which might lock out the user - var incrementLockoutResult = await UserManager.AccessFailedAsync(user) ?? IdentityResult.Success; - if (!incrementLockoutResult.Succeeded) - { - // Return the same failure we do when resetting the lockout fails after a correct password. - return SignInResult.Failed; - } - - if (await UserManager.IsLockedOutAsync(user)) - { - return await LockedOut(user); - } - } - return SignInResult.Failed; - } - - /// - /// Generates passkey creation options for the specified . - /// - /// The user entity for which to create passkey options. - /// A JSON string representing the created passkey options. - public virtual async Task MakePasskeyCreationOptionsAsync(PasskeyUserEntity userEntity) - { - ThrowIfNoPasskeyHandler(); - ArgumentNullException.ThrowIfNull(userEntity); - - var result = await _passkeyHandler.MakeCreationOptionsAsync(userEntity, Context); - await StorePasskeyAuthenticationInfoAsync(PasskeyOperations.Attestation, result.AttestationState); - return result.CreationOptionsJson; - } - - /// - /// Creates passkey assertion options for the specified . - /// - /// The user for whom to create passkey assertion options. - /// A JSON string representing the created passkey assertion options. - public virtual async Task MakePasskeyRequestOptionsAsync(TUser? user) - { - ThrowIfNoPasskeyHandler(); - - var result = await _passkeyHandler.MakeRequestOptionsAsync(user, Context); - await StorePasskeyAuthenticationInfoAsync(PasskeyOperations.Assertion, result.AssertionState); - return result.RequestOptionsJson; - } - - /// - /// Generates the options used to signal the current state of a user's passkeys to authenticators. - /// - /// - /// - /// The returned JSON contains the arguments for both the PublicKeyCredential.signalAllAcceptedCredentials() - /// and PublicKeyCredential.signalCurrentUserDetails() JavaScript APIs, which let an authenticator - /// stop offering passkeys that were removed from the server and keep the user's details up to date. - /// - /// - /// Because these APIs reveal how many passkeys a user has, only call them when the user is authenticated. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.Metrics; +using System.Linq; +using System.Security.Claims; +using System.Text; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Microsoft.AspNetCore.Identity; + +/// +/// Provides the APIs for user sign in. +/// +/// The type encapsulating a user. +public class SignInManager where TUser : class +{ + private const string LoginProviderKey = "LoginProvider"; + private const string XsrfKey = "XsrfId"; + private const string PasskeyOperationKey = "PasskeyOperation"; + private const string PasskeyStateKey = "PasskeyState"; + + private static readonly bool AlwaysResetLockoutOnSuccess = + AppContext.TryGetSwitch("Microsoft.AspNetCore.Identity.CheckPasswordSignInAlwaysResetLockoutOnSuccess", out var enabled) && enabled; + + private readonly IHttpContextAccessor _contextAccessor; + private readonly IAuthenticationSchemeProvider _schemes; + private readonly IUserConfirmation _confirmation; + private readonly IPasskeyHandler? _passkeyHandler; + private readonly SignInManagerMetrics? _metrics; + private HttpContext? _context; + private TwoFactorAuthenticationInfo? _twoFactorInfo; + private PasskeyAuthenticationInfo? _passkeyInfo; + + /// + /// Creates a new instance of . + /// + /// An instance of used to retrieve users from and persist users. + /// The accessor used to access the . + /// The factory to use to create claims principals for a user. + /// The accessor used to access the . + /// The logger used to log messages, warnings and errors. + /// The scheme provider that is used enumerate the authentication schemes. + /// The used check whether a user account is confirmed. + public SignInManager(UserManager userManager, + IHttpContextAccessor contextAccessor, + IUserClaimsPrincipalFactory claimsFactory, + IOptions optionsAccessor, + ILogger> logger, + IAuthenticationSchemeProvider schemes, + IUserConfirmation confirmation) + { + ArgumentNullException.ThrowIfNull(userManager); + ArgumentNullException.ThrowIfNull(contextAccessor); + ArgumentNullException.ThrowIfNull(claimsFactory); + + UserManager = userManager; + _contextAccessor = contextAccessor; + ClaimsFactory = claimsFactory; + Options = optionsAccessor?.Value ?? new IdentityOptions(); + Logger = logger; + _schemes = schemes; + _confirmation = confirmation; + // SignInManagerMetrics created from constructor because of difficulties registering internal type. + _metrics = userManager.ServiceProvider?.GetService() is { } factory ? new SignInManagerMetrics(factory) : null; + _passkeyHandler = userManager.ServiceProvider?.GetService>(); + } + + /// + /// Gets the used to log messages from the manager. + /// + /// + /// The used to log messages from the manager. + /// + public virtual ILogger Logger { get; set; } + + /// + /// The used. + /// + public UserManager UserManager { get; set; } + + /// + /// The used. + /// + public IUserClaimsPrincipalFactory ClaimsFactory { get; set; } + + /// + /// The used. + /// + public IdentityOptions Options { get; set; } + + /// + /// The authentication scheme to sign in with. Defaults to . + /// + public string AuthenticationScheme { get; set; } = IdentityConstants.ApplicationScheme; + + /// + /// The used. + /// + public HttpContext Context + { + get + { + var context = _context ?? _contextAccessor?.HttpContext; + if (context == null) + { + throw new InvalidOperationException("HttpContext must not be null."); + } + return context; + } + set + { + _context = value; + } + } + + /// + /// Creates a for the specified , as an asynchronous operation. + /// + /// The user to create a for. + /// The task object representing the asynchronous operation, containing the ClaimsPrincipal for the specified user. + public virtual async Task CreateUserPrincipalAsync(TUser user) => await ClaimsFactory.CreateAsync(user); + + /// + /// Returns true if the principal has an identity with the application cookie identity + /// + /// The instance. + /// True if the user is logged in with identity. + public virtual bool IsSignedIn(ClaimsPrincipal principal) + { + ArgumentNullException.ThrowIfNull(principal); + return principal.Identities != null && + principal.Identities.Any(i => i.AuthenticationType == AuthenticationScheme); + } + + /// + /// Returns a flag indicating whether the specified user can sign in. + /// + /// The user whose sign-in status should be returned. + /// + /// The task object representing the asynchronous operation, containing a flag that is true + /// if the specified user can sign-in, otherwise false. + /// + public virtual async Task CanSignInAsync(TUser user) + { + if (Options.SignIn.RequireConfirmedEmail && !(await UserManager.IsEmailConfirmedAsync(user))) + { + Logger.LogDebug(EventIds.UserCannotSignInWithoutConfirmedEmail, "User cannot sign in without a confirmed email."); + return false; + } + if (Options.SignIn.RequireConfirmedPhoneNumber && !(await UserManager.IsPhoneNumberConfirmedAsync(user))) + { + Logger.LogDebug(EventIds.UserCannotSignInWithoutConfirmedPhoneNumber, "User cannot sign in without a confirmed phone number."); + return false; + } + if (Options.SignIn.RequireConfirmedAccount && !(await _confirmation.IsConfirmedAsync(UserManager, user))) + { + Logger.LogDebug(EventIds.UserCannotSignInWithoutConfirmedAccount, "User cannot sign in without a confirmed account."); + return false; + } + return true; + } + + /// + /// Signs in the specified , whilst preserving the existing + /// AuthenticationProperties of the current signed-in user like rememberMe, as an asynchronous operation. + /// + /// The user to sign-in. + /// The task object representing the asynchronous operation. + public virtual async Task RefreshSignInAsync(TUser user) + { + var startTimestamp = Stopwatch.GetTimestamp(); + try + { + var (success, isPersistent) = await RefreshSignInCoreAsync(user); + var signInResult = success ? SignInResult.Success : SignInResult.Failed; + _metrics?.AuthenticateSignIn(typeof(TUser).FullName!, AuthenticationScheme, signInResult, SignInType.Refresh, isPersistent, startTimestamp); + } + catch (Exception ex) + { + _metrics?.AuthenticateSignIn(typeof(TUser).FullName!, AuthenticationScheme, result: null, SignInType.Refresh, isPersistent: null, startTimestamp, ex); + throw; + } + } + + private async Task<(bool success, bool? isPersistent)> RefreshSignInCoreAsync(TUser user) + { + var auth = await Context.AuthenticateAsync(AuthenticationScheme); + if (!auth.Succeeded || auth.Principal?.Identity?.IsAuthenticated != true) + { + Logger.LogError("RefreshSignInAsync prevented because the user is not currently authenticated. Use SignInAsync instead for initial sign in."); + return (false, auth.Properties?.IsPersistent); + } + + var authenticatedUserId = UserManager.GetUserId(auth.Principal); + var newUserId = await UserManager.GetUserIdAsync(user); + if (authenticatedUserId == null || authenticatedUserId != newUserId) + { + Logger.LogError("RefreshSignInAsync prevented because currently authenticated user has a different UserId. Use SignInAsync instead to change users."); + return (false, auth.Properties?.IsPersistent); + } + + IList claims = Array.Empty(); + var authenticationMethod = auth.Principal?.FindFirst(ClaimTypes.AuthenticationMethod); + var amr = auth.Principal?.FindFirst("amr"); + + if (authenticationMethod != null || amr != null) + { + claims = new List(); + if (authenticationMethod != null) + { + claims.Add(authenticationMethod); + } + if (amr != null) + { + claims.Add(amr); + } + } + + await SignInWithClaimsAsync(user, auth.Properties, claims); + return (true, auth.Properties?.IsPersistent ?? false); + } + + /// + /// Signs in the specified . + /// + /// The user to sign-in. + /// Flag indicating whether the sign-in cookie should persist after the browser is closed. + /// Name of the method used to authenticate the user. + /// The task object representing the asynchronous operation. + [SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "Required for backwards compatibility")] + public virtual Task SignInAsync(TUser user, bool isPersistent, string? authenticationMethod = null) + => SignInAsync(user, new AuthenticationProperties { IsPersistent = isPersistent }, authenticationMethod); + + /// + /// Signs in the specified . + /// + /// The user to sign-in. + /// Properties applied to the login and authentication cookie. + /// Name of the method used to authenticate the user. + /// The task object representing the asynchronous operation. + [SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "Required for backwards compatibility")] + public virtual Task SignInAsync(TUser user, AuthenticationProperties authenticationProperties, string? authenticationMethod = null) + { + IList additionalClaims = Array.Empty(); + if (authenticationMethod != null) + { + additionalClaims = new List(); + additionalClaims.Add(new Claim(ClaimTypes.AuthenticationMethod, authenticationMethod)); + } + return SignInWithClaimsAsync(user, authenticationProperties, additionalClaims); + } + + /// + /// Signs in the specified . + /// + /// The user to sign-in. + /// Flag indicating whether the sign-in cookie should persist after the browser is closed. + /// Additional claims that will be stored in the cookie. + /// The task object representing the asynchronous operation. + public virtual Task SignInWithClaimsAsync(TUser user, bool isPersistent, IEnumerable additionalClaims) + => SignInWithClaimsAsync(user, new AuthenticationProperties { IsPersistent = isPersistent }, additionalClaims); + + /// + /// Signs in the specified . + /// + /// The user to sign-in. + /// Properties applied to the login and authentication cookie. + /// Additional claims that will be stored in the cookie. + /// The task object representing the asynchronous operation. + public virtual async Task SignInWithClaimsAsync(TUser user, AuthenticationProperties? authenticationProperties, IEnumerable additionalClaims) + { + try + { + var userPrincipal = await CreateUserPrincipalAsync(user); + foreach (var claim in additionalClaims) + { + userPrincipal.Identities.First().AddClaim(claim); + } + + authenticationProperties ??= new AuthenticationProperties(); + await Context.SignInAsync(AuthenticationScheme, + userPrincipal, + authenticationProperties); + + // This is useful for updating claims immediately when hitting MapIdentityApi's /account/info endpoint with cookies. + Context.User = userPrincipal; + + _metrics?.SignInUserPrincipal(typeof(TUser).FullName!, AuthenticationScheme, authenticationProperties.IsPersistent); + } + catch (Exception ex) + { + _metrics?.SignInUserPrincipal(typeof(TUser).FullName!, AuthenticationScheme, isPersistent: null, ex); + throw; + } + } + + /// + /// Signs the current user out of the application. + /// + public virtual async Task SignOutAsync() + { + try + { + await Context.SignOutAsync(AuthenticationScheme); + + if (await _schemes.GetSchemeAsync(IdentityConstants.ExternalScheme) != null) + { + await Context.SignOutAsync(IdentityConstants.ExternalScheme); + } + if (await _schemes.GetSchemeAsync(IdentityConstants.TwoFactorUserIdScheme) != null) + { + await Context.SignOutAsync(IdentityConstants.TwoFactorUserIdScheme); + } + + _metrics?.SignOutUserPrincipal(typeof(TUser).FullName!, AuthenticationScheme); + } + catch (Exception ex) + { + _metrics?.SignOutUserPrincipal(typeof(TUser).FullName!, AuthenticationScheme, ex); + throw; + } + } + + /// + /// Validates the security stamp for the specified against + /// the persisted stamp for the current user, as an asynchronous operation. + /// + /// The principal whose stamp should be validated. + /// The task object representing the asynchronous operation. The task will contain the + /// if the stamp matches the persisted value, otherwise it will return null. + public virtual async Task ValidateSecurityStampAsync(ClaimsPrincipal? principal) + { + if (principal == null) + { + return null; + } + var user = await UserManager.GetUserAsync(principal); + if (await ValidateSecurityStampAsync(user, principal.FindFirstValue(Options.ClaimsIdentity.SecurityStampClaimType))) + { + return user; + } + Logger.LogDebug(EventIds.SecurityStampValidationFailedId4, "Failed to validate a security stamp."); + return null; + } + + /// + /// Validates the security stamp for the specified from one of + /// the two factor principals (remember client or user id) against + /// the persisted stamp for the current user, as an asynchronous operation. + /// + /// The principal whose stamp should be validated. + /// The task object representing the asynchronous operation. The task will contain the + /// if the stamp matches the persisted value, otherwise it will return null. + public virtual async Task ValidateTwoFactorSecurityStampAsync(ClaimsPrincipal? principal) + { + if (principal == null || principal.Identity?.Name == null) + { + return null; + } + var user = await UserManager.FindByIdAsync(principal.Identity.Name); + if (await ValidateSecurityStampAsync(user, principal.FindFirstValue(Options.ClaimsIdentity.SecurityStampClaimType))) + { + return user; + } + Logger.LogDebug(EventIds.TwoFactorSecurityStampValidationFailed, "Failed to validate a security stamp."); + return null; + } + + /// + /// Validates the security stamp for the specified . If no user is specified, or if the store + /// does not support security stamps, validation is considered successful. + /// + /// The user whose stamp should be validated. + /// The expected security stamp value. + /// The result of the validation. + public virtual async Task ValidateSecurityStampAsync(TUser? user, string? securityStamp) + => user != null && + // Only validate the security stamp if the store supports it + (!UserManager.SupportsUserSecurityStamp || securityStamp == await UserManager.GetSecurityStampAsync(user)); + + /// + /// Attempts to sign in the specified and combination + /// as an asynchronous operation. + /// + /// The user to sign in. + /// The password to attempt to sign in with. + /// Flag indicating whether the sign-in cookie should persist after the browser is closed. + /// Flag indicating if the user account should be locked if the sign in fails. + /// The task object representing the asynchronous operation containing the + /// for the sign-in attempt. + public virtual async Task PasswordSignInAsync(TUser user, string password, + bool isPersistent, bool lockoutOnFailure) + { + var startTimestamp = Stopwatch.GetTimestamp(); + try + { + ArgumentNullException.ThrowIfNull(user); + + var attempt = await CheckPasswordSignInAsync(user, password, lockoutOnFailure); + var result = attempt.Succeeded + ? await SignInOrTwoFactorAsync(user, isPersistent) + : attempt; + _metrics?.AuthenticateSignIn(typeof(TUser).FullName!, AuthenticationScheme, result, SignInType.Password, isPersistent, startTimestamp); + + return result; + } + catch (Exception ex) + { + _metrics?.AuthenticateSignIn(typeof(TUser).FullName!, AuthenticationScheme, result: null, SignInType.Password, isPersistent, startTimestamp, ex); + throw; + } + } + + /// + /// Attempts to sign in the specified and combination + /// as an asynchronous operation. + /// + /// The user name to sign in. + /// The password to attempt to sign in with. + /// Flag indicating whether the sign-in cookie should persist after the browser is closed. + /// Flag indicating if the user account should be locked if the sign in fails. + /// The task object representing the asynchronous operation containing the + /// for the sign-in attempt. + public virtual async Task PasswordSignInAsync(string userName, string password, + bool isPersistent, bool lockoutOnFailure) + { + var startTimestamp = Stopwatch.GetTimestamp(); + var user = await UserManager.FindByNameAsync(userName); + if (user == null) + { + _metrics?.AuthenticateSignIn(typeof(TUser).FullName!, AuthenticationScheme, SignInResult.Failed, SignInType.Password, isPersistent, startTimestamp); + return SignInResult.Failed; + } + + return await PasswordSignInAsync(user, password, isPersistent, lockoutOnFailure); + } + + /// + /// Attempts a password sign in for a user. + /// + /// The user to sign in. + /// The password to attempt to sign in with. + /// Flag indicating if the user account should be locked if the sign in fails. + /// The task object representing the asynchronous operation containing the + /// for the sign-in attempt. + public virtual async Task CheckPasswordSignInAsync(TUser user, string password, bool lockoutOnFailure) + { + try + { + ArgumentNullException.ThrowIfNull(user); + + var result = await CheckPasswordSignInCoreAsync(user, password, lockoutOnFailure); + _metrics?.CheckPasswordSignIn(typeof(TUser).FullName!, result); + + return result; + } + catch (Exception ex) + { + _metrics?.CheckPasswordSignIn(typeof(TUser).FullName!, result: null, ex); + throw; + } + } + + private async Task CheckPasswordSignInCoreAsync(TUser user, string password, bool lockoutOnFailure) + { + var error = await PreSignInCheck(user); + if (error != null) + { + return error; + } + + if (await UserManager.CheckPasswordAsync(user, password)) + { + var alwaysLockout = AlwaysResetLockoutOnSuccess; + // Only reset the lockout when not in quirks mode if either TFA is not enabled or the client is remembered for TFA. + if (alwaysLockout || !await IsTwoFactorEnabledAsync(user) || await IsTwoFactorClientRememberedAsync(user)) + { + var resetLockoutResult = await ResetLockoutWithResult(user); + if (!resetLockoutResult.Succeeded) + { + // ResetLockout got an unsuccessful result that could be caused by concurrency failures indicating an + // attacker could be trying to bypass the MaxFailedAccessAttempts limit. Return the same failure we do + // when failing to increment the lockout to avoid giving an attacker extra guesses at the password. + return SignInResult.Failed; + } + } + + return SignInResult.Success; + } + Logger.LogDebug(EventIds.InvalidPassword, "User failed to provide the correct password."); + + if (UserManager.SupportsUserLockout && lockoutOnFailure) + { + // If lockout is requested, increment access failed count which might lock out the user + var incrementLockoutResult = await UserManager.AccessFailedAsync(user) ?? IdentityResult.Success; + if (!incrementLockoutResult.Succeeded) + { + // Return the same failure we do when resetting the lockout fails after a correct password. + return SignInResult.Failed; + } + + if (await UserManager.IsLockedOutAsync(user)) + { + return await LockedOut(user); + } + } + return SignInResult.Failed; + } + + /// + /// Generates passkey creation options for the specified . + /// + /// The user entity for which to create passkey options. + /// A JSON string representing the created passkey options. + public virtual async Task MakePasskeyCreationOptionsAsync(PasskeyUserEntity userEntity) + { + ThrowIfNoPasskeyHandler(); + ArgumentNullException.ThrowIfNull(userEntity); + + var result = await _passkeyHandler.MakeCreationOptionsAsync(userEntity, Context); + await StorePasskeyAuthenticationInfoAsync(PasskeyOperations.Attestation, result.AttestationState); + return result.CreationOptionsJson; + } + + /// + /// Creates passkey assertion options for the specified . + /// + /// The user for whom to create passkey assertion options. + /// A JSON string representing the created passkey assertion options. + public virtual async Task MakePasskeyRequestOptionsAsync(TUser? user) + { + ThrowIfNoPasskeyHandler(); + + var result = await _passkeyHandler.MakeRequestOptionsAsync(user, Context); + await StorePasskeyAuthenticationInfoAsync(PasskeyOperations.Assertion, result.AssertionState); + return result.RequestOptionsJson; + } + + /// + /// Gets a value indicating whether the registered supports + /// generating passkey signal options. + /// + /// + /// Check this before calling , + /// which throws when the handler does not support signal options. + /// + public virtual bool SupportsPasskeySignalOptions => _passkeyHandler?.SupportsSignalOptions ?? false; + + /// + /// Generates the options used to signal the current state of a user's passkeys to authenticators. + /// + /// + /// + /// The returned JSON contains the arguments for both the PublicKeyCredential.signalAllAcceptedCredentials() + /// and PublicKeyCredential.signalCurrentUserDetails() JavaScript APIs, which let an authenticator + /// stop offering passkeys that were removed from the server and keep the user's details up to date. + /// + /// + /// Because these APIs reveal how many passkeys a user has, only call them when the user is authenticated. /// The must have the same that was passed to /// when the passkeys were created, /// otherwise the authenticator will not recognize the user and the signal will have no effect. - /// - /// - /// See . - /// - /// - /// The user whose passkeys should be signaled. - /// The user entity associated with the user's passkeys. - /// A JSON string representing the passkey signal options. - /// - /// The following example shows how the result is used from JavaScript. - /// - /// const { rpId, userId, allAcceptedCredentialIds, name, displayName } = signalOptions; - /// await PublicKeyCredential.signalAllAcceptedCredentials?.({ rpId, userId, allAcceptedCredentialIds }); - /// await PublicKeyCredential.signalCurrentUserDetails?.({ rpId, userId, name, displayName }); - /// - /// - public virtual async Task MakePasskeySignalOptionsAsync(TUser user, PasskeyUserEntity userEntity) - { - ArgumentNullException.ThrowIfNull(user); - ArgumentNullException.ThrowIfNull(userEntity); - - var userId = await UserManager.GetUserIdAsync(user); - if (!string.Equals(userId, userEntity.Id, StringComparison.Ordinal)) - { - throw new InvalidOperationException( - $"The user entity ID '{userEntity.Id}' does not match the ID '{userId}' of the specified user."); - } - - var passkeys = await UserManager.GetPasskeysAsync(user); - var options = new PasskeySignalOptions - { - RpId = PasskeyServerDomain.Resolve(_passkeyOptions, Context), - UserId = BufferSource.FromString(userEntity.Id), - AllAcceptedCredentialIds = [.. passkeys.Select(p => BufferSource.FromBytes(p.CredentialId))], - Name = userEntity.Name, - DisplayName = userEntity.DisplayName, - }; - return JsonSerializer.Serialize(options, IdentityJsonSerializerContext.Default.PasskeySignalOptions); - } - - /// - /// Performs passkey attestation for the given . - /// - /// - /// The should be obtained by JSON-serializing the result of the - /// navigator.credentials.create() JavaScript API. The argument to navigator.credentials.create() - /// should be obtained by calling . - /// - /// The credentials obtained by JSON-serializing the result of the navigator.credentials.create() JavaScript function. - /// - /// A task object representing the asynchronous operation containing the . - /// - public virtual async Task PerformPasskeyAttestationAsync([StringSyntax(StringSyntaxAttribute.Json)] string credentialJson) - { - ThrowIfNoPasskeyHandler(); - ArgumentException.ThrowIfNullOrEmpty(credentialJson); - - var passkeyInfo = await RetrievePasskeyAuthenticationInfoAsync() - ?? throw new InvalidOperationException( - "No passkey attestation is underway. " + - $"Make sure to call '{nameof(SignInManager<>)}.{nameof(MakePasskeyCreationOptionsAsync)}()' to initiate a passkey attestation."); - if (!string.Equals(PasskeyOperations.Attestation, passkeyInfo.Operation, StringComparison.Ordinal)) - { - throw new InvalidOperationException( - $"Expected passkey operation '{PasskeyOperations.Attestation}', but got '{passkeyInfo.Operation}'. " + - $"This may indicate that you have not previously called '{nameof(SignInManager<>)}.{nameof(MakePasskeyCreationOptionsAsync)}()'."); - } - var context = new PasskeyAttestationContext - { - CredentialJson = credentialJson, - AttestationState = passkeyInfo.State, - HttpContext = Context, - }; - var result = await _passkeyHandler.PerformAttestationAsync(context); - if (!result.Succeeded) - { - Logger.LogDebug(EventIds.PasskeyAttestationFailed, "Passkey attestation failed: {message}", result.Failure.Message); - } - - return result; - } - - /// - /// Performs passkey assertion for the given . - /// - /// - /// The should be obtained by JSON-serializing the result of the - /// navigator.credentials.get() JavaScript API. The argument to navigator.credentials.get() - /// should be obtained by calling . - /// Upon success, the should be stored on the - /// using . - /// - /// The credentials obtained by JSON-serializing the result of the navigator.credentials.get() JavaScript function. - /// - /// A task object representing the asynchronous operation containing the . - /// - public virtual async Task> PerformPasskeyAssertionAsync([StringSyntax(StringSyntaxAttribute.Json)] string credentialJson) - { - ThrowIfNoPasskeyHandler(); - ArgumentException.ThrowIfNullOrEmpty(credentialJson); - - var passkeyInfo = await RetrievePasskeyAuthenticationInfoAsync() - ?? throw new InvalidOperationException( - "No passkey assertion is underway. " + - $"Make sure to call '{nameof(SignInManager<>)}.{nameof(MakePasskeyRequestOptionsAsync)}()' to initiate a passkey assertion."); - if (!string.Equals(PasskeyOperations.Assertion, passkeyInfo.Operation, StringComparison.Ordinal)) - { - throw new InvalidOperationException( - $"Expected passkey operation '{PasskeyOperations.Assertion}', but got '{passkeyInfo.Operation}'. " + - $"This may indicate that you have not previously called '{nameof(SignInManager<>)}.{nameof(MakePasskeyRequestOptionsAsync)}()'."); - } - var context = new PasskeyAssertionContext - { - CredentialJson = credentialJson, - AssertionState = passkeyInfo.State, - HttpContext = Context, - }; - var result = await _passkeyHandler.PerformAssertionAsync(context); - if (!result.Succeeded) - { - Logger.LogDebug(EventIds.PasskeyAssertionFailed, "Passkey assertion failed: {message}", result.Failure.Message); - } - - return result; - } - - /// - /// Performs a passkey assertion and attempts to sign in the user. - /// - /// - /// The should be obtained by JSON-serializing the result of the - /// navigator.credentials.get() JavaScript API. The argument to navigator.credentials.get() - /// should be obtained by calling . - /// - /// The credentials obtained by JSON-serializing the result of the navigator.credentials.get() JavaScript function. - /// - /// The task object representing the asynchronous operation containing the - /// for the sign-in attempt. - /// - public virtual async Task PasskeySignInAsync([StringSyntax(StringSyntaxAttribute.Json)] string credentialJson) - { - var startTimestamp = Stopwatch.GetTimestamp(); - try - { - var result = await PasskeySignInCoreAsync(credentialJson); - _metrics?.AuthenticateSignIn(typeof(TUser).FullName!, AuthenticationScheme, result, SignInType.Passkey, isPersistent: false, startTimestamp); - - return result; - } - catch (Exception ex) - { - _metrics?.AuthenticateSignIn(typeof(TUser).FullName!, AuthenticationScheme, result: null, SignInType.Passkey, isPersistent: false, startTimestamp, ex); - throw; - } - } - - private async Task PasskeySignInCoreAsync(string credentialJson) - { - ArgumentException.ThrowIfNullOrEmpty(credentialJson); - - var assertionResult = await PerformPasskeyAssertionAsync(credentialJson); - if (!assertionResult.Succeeded) - { - return SignInResult.Failed; - } - - var error = await PreSignInCheck(assertionResult.User); - if (error != null) - { - return error; - } - - // After a successful assertion, we need to update the passkey so that it has the latest - // sign count and authenticator data. - var setPasskeyResult = await UserManager.AddOrUpdatePasskeyAsync(assertionResult.User, assertionResult.Passkey); - if (!setPasskeyResult.Succeeded) - { - return SignInResult.Failed; - } - - return await SignInOrTwoFactorAsync(assertionResult.User, isPersistent: false, bypassTwoFactor: true); - } - - [MemberNotNull(nameof(_passkeyHandler))] - private void ThrowIfNoPasskeyHandler() - { - if (_passkeyHandler is null) - { - throw new InvalidOperationException( - $"This operation requires an {nameof(IPasskeyHandler<>)} service to be registered."); - } - } - - private async Task StorePasskeyAuthenticationInfoAsync(string operation, string? state) - { - var props = new AuthenticationProperties(); - props.Items[PasskeyOperationKey] = operation; - props.Items[PasskeyStateKey] = state; - var claimsIdentity = new ClaimsIdentity(IdentityConstants.TwoFactorUserIdScheme); - var claimsPrincipal = new ClaimsPrincipal(claimsIdentity); - await Context.SignInAsync(IdentityConstants.TwoFactorUserIdScheme, claimsPrincipal, props); - } - - private async Task RetrievePasskeyAuthenticationInfoAsync() - { - return _passkeyInfo ??= await RetrievePasskeyInfoCoreAsync(); - - async Task RetrievePasskeyInfoCoreAsync() - { - var result = await Context.AuthenticateAsync(IdentityConstants.TwoFactorUserIdScheme); - await Context.SignOutAsync(IdentityConstants.TwoFactorUserIdScheme); - - if (result.Properties is not { } properties) - { - return null; - } - - if (!properties.Items.TryGetValue(PasskeyOperationKey, out var operation) || - !properties.Items.TryGetValue(PasskeyStateKey, out var state)) - { - return null; - } - - return new() - { - Operation = operation, - State = state, - }; - } - } - - /// - /// Returns a flag indicating if the current client browser has been remembered by two factor authentication - /// for the user attempting to login, as an asynchronous operation. - /// - /// The user attempting to login. - /// - /// The task object representing the asynchronous operation containing true if the browser has been remembered - /// for the current user. - /// - public virtual async Task IsTwoFactorClientRememberedAsync(TUser user) - { - if (await _schemes.GetSchemeAsync(IdentityConstants.TwoFactorRememberMeScheme) == null) - { - return false; - } - - var userId = await UserManager.GetUserIdAsync(user); - var result = await Context.AuthenticateAsync(IdentityConstants.TwoFactorRememberMeScheme); - return (result?.Principal != null && result.Principal.FindFirstValue(ClaimTypes.Name) == userId); - } - - /// - /// Sets a flag on the browser to indicate the user has selected "Remember this browser" for two factor authentication purposes, - /// as an asynchronous operation. - /// - /// The user who choose "remember this browser". - /// The task object representing the asynchronous operation. - public virtual async Task RememberTwoFactorClientAsync(TUser user) - { - try - { - var principal = await StoreRememberClient(user); - await Context.SignInAsync(IdentityConstants.TwoFactorRememberMeScheme, - principal, - new AuthenticationProperties { IsPersistent = true }); - _metrics?.RememberTwoFactorClient(typeof(TUser).FullName!, IdentityConstants.TwoFactorRememberMeScheme); - } - catch (Exception ex) - { - _metrics?.RememberTwoFactorClient(typeof(TUser).FullName!, IdentityConstants.TwoFactorRememberMeScheme, ex); - throw; - } - } - - /// - /// Clears the "Remember this browser flag" from the current browser, as an asynchronous operation. - /// - /// The task object representing the asynchronous operation. - public virtual async Task ForgetTwoFactorClientAsync() - { - try - { - await Context.SignOutAsync(IdentityConstants.TwoFactorRememberMeScheme); - _metrics?.ForgetTwoFactorClient(typeof(TUser).FullName!, IdentityConstants.TwoFactorRememberMeScheme); - } - catch (Exception ex) - { - _metrics?.ForgetTwoFactorClient(typeof(TUser).FullName!, IdentityConstants.TwoFactorRememberMeScheme, ex); - throw; - } - } - - /// - /// Signs in the user without two factor authentication using a two factor recovery code. - /// - /// The two factor recovery code. - /// - public virtual async Task TwoFactorRecoveryCodeSignInAsync(string recoveryCode) - { - var startTimestamp = Stopwatch.GetTimestamp(); - try - { - var result = await TwoFactorRecoveryCodeSignInCoreAsync(recoveryCode); - _metrics?.AuthenticateSignIn(typeof(TUser).FullName!, AuthenticationScheme, result, SignInType.TwoFactorRecoveryCode, isPersistent: false, startTimestamp); - - return result; - } - catch (Exception ex) - { - _metrics?.AuthenticateSignIn(typeof(TUser).FullName!, AuthenticationScheme, result: null, SignInType.TwoFactorRecoveryCode, isPersistent: false, startTimestamp, ex); - throw; - } - } - - private async Task TwoFactorRecoveryCodeSignInCoreAsync(string recoveryCode) - { - var twoFactorInfo = await RetrieveTwoFactorInfoAsync(); - if (twoFactorInfo == null) - { - return SignInResult.Failed; - } - - var result = await UserManager.RedeemTwoFactorRecoveryCodeAsync(twoFactorInfo.User, recoveryCode); - if (result.Succeeded) - { - return await DoTwoFactorSignInAsync(twoFactorInfo.User, twoFactorInfo, isPersistent: false, rememberClient: false); - } - - // We don't protect against brute force attacks since codes are expected to be random. - return SignInResult.Failed; - } - - private async Task DoTwoFactorSignInAsync(TUser user, TwoFactorAuthenticationInfo twoFactorInfo, bool isPersistent, bool rememberClient) - { - var resetLockoutResult = await ResetLockoutWithResult(user); - if (!resetLockoutResult.Succeeded) - { - // ResetLockout got an unsuccessful result that could be caused by concurrency failures indicating an - // attacker could be trying to bypass the MaxFailedAccessAttempts limit. Return the same failure we do - // when failing to increment the lockout to avoid giving an attacker extra guesses at the two factor code. - return SignInResult.Failed; - } - - var claims = new List - { - new Claim("amr", "mfa") - }; - - if (twoFactorInfo.LoginProvider != null) - { - claims.Add(new Claim(ClaimTypes.AuthenticationMethod, twoFactorInfo.LoginProvider)); - } - // Cleanup external cookie - if (await _schemes.GetSchemeAsync(IdentityConstants.ExternalScheme) != null) - { - await Context.SignOutAsync(IdentityConstants.ExternalScheme); - } - // Cleanup two factor user id cookie - if (await _schemes.GetSchemeAsync(IdentityConstants.TwoFactorUserIdScheme) != null) - { - await Context.SignOutAsync(IdentityConstants.TwoFactorUserIdScheme); - if (rememberClient) - { - await RememberTwoFactorClientAsync(user); - } - } - await SignInWithClaimsAsync(user, isPersistent, claims); - return SignInResult.Success; - } - - /// - /// Validates the sign in code from an authenticator app and creates and signs in the user, as an asynchronous operation. - /// - /// The two factor authentication code to validate. - /// Flag indicating whether the sign-in cookie should persist after the browser is closed. - /// Flag indicating whether the current browser should be remember, suppressing all further - /// two factor authentication prompts. - /// The task object representing the asynchronous operation containing the - /// for the sign-in attempt. - public virtual async Task TwoFactorAuthenticatorSignInAsync(string code, bool isPersistent, bool rememberClient) - { - var startTimestamp = Stopwatch.GetTimestamp(); - try - { - var result = await TwoFactorAuthenticatorSignInCoreAsync(code, isPersistent, rememberClient); - _metrics?.AuthenticateSignIn(typeof(TUser).FullName!, AuthenticationScheme, result, SignInType.TwoFactorAuthenticator, isPersistent, startTimestamp); - - return result; - } - catch (Exception ex) - { - _metrics?.AuthenticateSignIn(typeof(TUser).FullName!, AuthenticationScheme, result: null, SignInType.TwoFactorAuthenticator, isPersistent, startTimestamp, ex); - throw; - } - } - - private async Task TwoFactorAuthenticatorSignInCoreAsync(string code, bool isPersistent, bool rememberClient) - { - var twoFactorInfo = await RetrieveTwoFactorInfoAsync(); - if (twoFactorInfo == null) - { - return SignInResult.Failed; - } - - var user = twoFactorInfo.User; - var error = await PreSignInCheck(user); - if (error != null) - { - return error; - } - - if (await UserManager.VerifyTwoFactorTokenAsync(user, Options.Tokens.AuthenticatorTokenProvider, code)) - { - return await DoTwoFactorSignInAsync(user, twoFactorInfo, isPersistent, rememberClient); - } - // If the token is incorrect, record the failure which also may cause the user to be locked out - if (UserManager.SupportsUserLockout) - { - var incrementLockoutResult = await UserManager.AccessFailedAsync(user) ?? IdentityResult.Success; - if (!incrementLockoutResult.Succeeded) - { - // Return the same failure we do when resetting the lockout fails after a correct two factor code. - // This is currently redundant, but it's here in case the code gets copied elsewhere. - return SignInResult.Failed; - } - - if (await UserManager.IsLockedOutAsync(user)) - { - return await LockedOut(user); - } - } - return SignInResult.Failed; - } - - /// - /// Validates the two factor sign in code and creates and signs in the user, as an asynchronous operation. - /// - /// The two factor authentication provider to validate the code against. - /// The two factor authentication code to validate. - /// Flag indicating whether the sign-in cookie should persist after the browser is closed. - /// Flag indicating whether the current browser should be remember, suppressing all further - /// two factor authentication prompts. - /// The task object representing the asynchronous operation containing the - /// for the sign-in attempt. - public virtual async Task TwoFactorSignInAsync(string provider, string code, bool isPersistent, bool rememberClient) - { - var startTimestamp = Stopwatch.GetTimestamp(); - try - { - var result = await TwoFactorSignInCoreAsync(provider, code, isPersistent, rememberClient); - _metrics?.AuthenticateSignIn(typeof(TUser).FullName!, AuthenticationScheme, result, SignInType.TwoFactor, isPersistent, startTimestamp); - - return result; - } - catch (Exception ex) - { - _metrics?.AuthenticateSignIn(typeof(TUser).FullName!, AuthenticationScheme, result: null, SignInType.TwoFactor, isPersistent, startTimestamp, ex); - throw; - } - } - - private async Task TwoFactorSignInCoreAsync(string provider, string code, bool isPersistent, bool rememberClient) - { - var twoFactorInfo = await RetrieveTwoFactorInfoAsync(); - if (twoFactorInfo == null) - { - return SignInResult.Failed; - } - - var user = twoFactorInfo.User; - var error = await PreSignInCheck(user); - if (error != null) - { - return error; - } - if (await UserManager.VerifyTwoFactorTokenAsync(user, provider, code)) - { - return await DoTwoFactorSignInAsync(user, twoFactorInfo, isPersistent, rememberClient); - } - // If the token is incorrect, record the failure which also may cause the user to be locked out - if (UserManager.SupportsUserLockout) - { - var incrementLockoutResult = await UserManager.AccessFailedAsync(user) ?? IdentityResult.Success; - if (!incrementLockoutResult.Succeeded) - { - // Return the same failure we do when resetting the lockout fails after a correct two factor code. - // This is currently redundant, but it's here in case the code gets copied elsewhere. - return SignInResult.Failed; - } - - if (await UserManager.IsLockedOutAsync(user)) - { - return await LockedOut(user); - } - } - return SignInResult.Failed; - } - - /// - /// Gets the for the current two factor authentication login, as an asynchronous operation. - /// - /// The task object representing the asynchronous operation containing the - /// for the sign-in attempt. - public virtual async Task GetTwoFactorAuthenticationUserAsync() - { - var info = await RetrieveTwoFactorInfoAsync(); - if (info == null) - { - return null; - } - - return info.User; - } - - /// - /// Signs in a user via a previously registered third party login, as an asynchronous operation. - /// - /// The login provider to use. - /// The unique provider identifier for the user. - /// Flag indicating whether the sign-in cookie should persist after the browser is closed. - /// The task object representing the asynchronous operation containing the - /// for the sign-in attempt. - public virtual Task ExternalLoginSignInAsync(string loginProvider, string providerKey, bool isPersistent) - => ExternalLoginSignInAsync(loginProvider, providerKey, isPersistent, bypassTwoFactor: false); - - /// - /// Signs in a user via a previously registered third party login, as an asynchronous operation. - /// - /// The login provider to use. - /// The unique provider identifier for the user. - /// Flag indicating whether the sign-in cookie should persist after the browser is closed. - /// Flag indicating whether to bypass two factor authentication. - /// The task object representing the asynchronous operation containing the - /// for the sign-in attempt. - public virtual async Task ExternalLoginSignInAsync(string loginProvider, string providerKey, bool isPersistent, bool bypassTwoFactor) - { - var startTimestamp = Stopwatch.GetTimestamp(); - try - { - var result = await ExternalLoginSignInCoreAsync(loginProvider, providerKey, isPersistent, bypassTwoFactor); - _metrics?.AuthenticateSignIn(typeof(TUser).FullName!, AuthenticationScheme, result, SignInType.External, isPersistent, startTimestamp); - - return result; - } - catch (Exception ex) - { - _metrics?.AuthenticateSignIn(typeof(TUser).FullName!, AuthenticationScheme, result: null, SignInType.External, isPersistent, startTimestamp, ex); - throw; - } - } - - private async Task ExternalLoginSignInCoreAsync(string loginProvider, string providerKey, bool isPersistent, bool bypassTwoFactor) - { - var user = await UserManager.FindByLoginAsync(loginProvider, providerKey); - if (user == null) - { - return SignInResult.Failed; - } - - var error = await PreSignInCheck(user); - if (error != null) - { - return error; - } - return await SignInOrTwoFactorAsync(user, isPersistent, loginProvider, bypassTwoFactor); - } - - /// - /// Gets a collection of s for the known external login providers. - /// - /// A collection of s for the known external login providers. - public virtual async Task> GetExternalAuthenticationSchemesAsync() - { - var schemes = await _schemes.GetAllSchemesAsync(); - return schemes.Where(s => !string.IsNullOrEmpty(s.DisplayName)); - } - - /// - /// Gets the external login information for the current login, as an asynchronous operation. - /// - /// Flag indication whether a Cross Site Request Forgery token was expected in the current request. - /// The task object representing the asynchronous operation containing the - /// for the sign-in attempt. - public virtual async Task GetExternalLoginInfoAsync(string? expectedXsrf = null) - { - var auth = await Context.AuthenticateAsync(IdentityConstants.ExternalScheme); - var items = auth?.Properties?.Items; - if (auth?.Principal == null || items == null || !items.TryGetValue(LoginProviderKey, out var provider)) - { - return null; - } - - if (expectedXsrf != null) - { - if (!items.TryGetValue(XsrfKey, out var userId) || - userId != expectedXsrf) - { - return null; - } - } - - var providerKey = auth.Principal.FindFirstValue(ClaimTypes.NameIdentifier) ?? auth.Principal.FindFirstValue("sub"); - if (providerKey == null || provider == null) - { - return null; - } - - var providerDisplayName = (await GetExternalAuthenticationSchemesAsync()).FirstOrDefault(p => p.Name == provider)?.DisplayName - ?? provider; - return new ExternalLoginInfo(auth.Principal, provider, providerKey, providerDisplayName) - { - AuthenticationTokens = auth.Properties?.GetTokens(), - AuthenticationProperties = auth.Properties - }; - } - - /// - /// Stores any authentication tokens found in the external authentication cookie into the associated user. - /// - /// The information from the external login provider. - /// The that represents the asynchronous operation, containing the of the operation. - public virtual async Task UpdateExternalAuthenticationTokensAsync(ExternalLoginInfo externalLogin) - { - ArgumentNullException.ThrowIfNull(externalLogin); - - if (externalLogin.AuthenticationTokens != null && externalLogin.AuthenticationTokens.Any()) - { - var user = await UserManager.FindByLoginAsync(externalLogin.LoginProvider, externalLogin.ProviderKey); - if (user == null) - { - return IdentityResult.Failed(); - } - - foreach (var token in externalLogin.AuthenticationTokens) - { - var result = await UserManager.SetAuthenticationTokenAsync(user, externalLogin.LoginProvider, token.Name, token.Value); - if (!result.Succeeded) - { - return result; - } - } - } - - return IdentityResult.Success; - } - - /// - /// Configures the redirect URL and user identifier for the specified external login . - /// - /// The provider to configure. - /// The external login URL users should be redirected to during the login flow. - /// The current user's identifier, which will be used to provide CSRF protection. - /// A configured . - public virtual AuthenticationProperties ConfigureExternalAuthenticationProperties(string? provider, [StringSyntax(StringSyntaxAttribute.Uri)] string? redirectUrl, string? userId = null) - { - var properties = new AuthenticationProperties { RedirectUri = redirectUrl }; - properties.Items[LoginProviderKey] = provider; - if (userId != null) - { - properties.Items[XsrfKey] = userId; - } - return properties; - } - - /// - /// Creates a claims principal for the specified 2fa information. - /// - /// The user whose is logging in via 2fa. - /// The 2fa provider. - /// A containing the user 2fa information. - internal static ClaimsPrincipal StoreTwoFactorInfo(string userId, string? loginProvider) - { - var identity = new ClaimsIdentity(IdentityConstants.TwoFactorUserIdScheme); - identity.AddClaim(new Claim(ClaimTypes.Name, userId)); - if (loginProvider != null) - { - identity.AddClaim(new Claim(ClaimTypes.AuthenticationMethod, loginProvider)); - } - return new ClaimsPrincipal(identity); - } - - internal async Task StoreRememberClient(TUser user) - { - var userId = await UserManager.GetUserIdAsync(user); - var rememberBrowserIdentity = new ClaimsIdentity(IdentityConstants.TwoFactorRememberMeScheme); - rememberBrowserIdentity.AddClaim(new Claim(ClaimTypes.Name, userId)); - if (UserManager.SupportsUserSecurityStamp) - { - var stamp = await UserManager.GetSecurityStampAsync(user); - rememberBrowserIdentity.AddClaim(new Claim(Options.ClaimsIdentity.SecurityStampClaimType, stamp)); - } - return new ClaimsPrincipal(rememberBrowserIdentity); - } - - /// - /// Check if the has two factor enabled. - /// - /// - /// - /// The task object representing the asynchronous operation containing true if the user has two factor enabled. - /// - public virtual async Task IsTwoFactorEnabledAsync(TUser user) - => UserManager.SupportsUserTwoFactor && - await UserManager.GetTwoFactorEnabledAsync(user) && - (await UserManager.GetValidTwoFactorProvidersAsync(user)).Count > 0; - - /// - /// Signs in the specified if is set to false. - /// Otherwise stores the for use after a two factor check. - /// - /// - /// Flag indicating whether the sign-in cookie should persist after the browser is closed. - /// The login provider to use. Default is null - /// Flag indicating whether to bypass two factor authentication. Default is false - /// Returns a - protected virtual async Task SignInOrTwoFactorAsync(TUser user, bool isPersistent, string? loginProvider = null, bool bypassTwoFactor = false) - { - if (!bypassTwoFactor && await IsTwoFactorEnabledAsync(user)) - { - if (!await IsTwoFactorClientRememberedAsync(user)) - { - // Allow the two-factor flow to continue later within the same request with or without a TwoFactorUserIdScheme in - // the event that the two-factor code or recovery code has already been provided as is the case for MapIdentityApi. - _twoFactorInfo = new() - { - User = user, - LoginProvider = loginProvider, - }; - - if (await _schemes.GetSchemeAsync(IdentityConstants.TwoFactorUserIdScheme) != null) - { - // Store the userId for use after two factor check - var userId = await UserManager.GetUserIdAsync(user); - await Context.SignInAsync(IdentityConstants.TwoFactorUserIdScheme, StoreTwoFactorInfo(userId, loginProvider)); - } - - return SignInResult.TwoFactorRequired; - } - } - // Cleanup external cookie - if (loginProvider != null) - { - await Context.SignOutAsync(IdentityConstants.ExternalScheme); - } - if (loginProvider == null) - { - await SignInWithClaimsAsync(user, isPersistent, new Claim[] { new Claim("amr", "pwd") }); - } - else - { - await SignInAsync(user, isPersistent, loginProvider); - } - return SignInResult.Success; - } - - private async Task RetrieveTwoFactorInfoAsync() - { - if (_twoFactorInfo != null) - { - return _twoFactorInfo; - } - - var result = await Context.AuthenticateAsync(IdentityConstants.TwoFactorUserIdScheme); - if (result?.Principal == null) - { - return null; - } - - var userId = result.Principal.FindFirstValue(ClaimTypes.Name); - if (userId == null) - { - return null; - } - - var user = await UserManager.FindByIdAsync(userId); - if (user == null) - { - return null; - } - - return new TwoFactorAuthenticationInfo - { - User = user, - LoginProvider = result.Principal.FindFirstValue(ClaimTypes.AuthenticationMethod), - }; - } - - /// - /// Used to determine if a user is considered locked out. - /// - /// The user. - /// Whether a user is considered locked out. - protected virtual async Task IsLockedOut(TUser user) - { - return UserManager.SupportsUserLockout && await UserManager.IsLockedOutAsync(user); - } - - /// - /// Returns a locked out SignInResult. - /// - /// The user. - /// A locked out SignInResult - protected virtual Task LockedOut(TUser user) - { - Logger.LogDebug(EventIds.UserLockedOut, "User is currently locked out."); - return Task.FromResult(SignInResult.LockedOut); - } - - /// - /// Used to ensure that a user is allowed to sign in. - /// - /// The user - /// Null if the user should be allowed to sign in, otherwise the SignInResult why they should be denied. - protected virtual async Task PreSignInCheck(TUser user) - { - if (!await CanSignInAsync(user)) - { - return SignInResult.NotAllowed; - } - if (await IsLockedOut(user)) - { - return await LockedOut(user); - } - return null; - } - - /// - /// Used to reset a user's lockout count. - /// - /// The user - /// The that represents the asynchronous operation, containing the of the operation. - protected virtual async Task ResetLockout(TUser user) - { - if (UserManager.SupportsUserLockout) - { - // The IdentityResult should not be null according to the annotations, but our own tests return null and I'm trying to limit breakages. - var result = await UserManager.ResetAccessFailedCountAsync(user) ?? IdentityResult.Success; - - if (!result.Succeeded) - { - throw new IdentityResultException(result); - } - } - } - - private async Task ResetLockoutWithResult(TUser user) - { - // Avoid relying on throwing an exception if we're not in a derived class. - if (GetType() == typeof(SignInManager)) - { - if (!UserManager.SupportsUserLockout) - { - return IdentityResult.Success; - } - - return await UserManager.ResetAccessFailedCountAsync(user) ?? IdentityResult.Success; - } - - try - { - var resetLockoutTask = ResetLockout(user); - - if (resetLockoutTask is Task resultTask) - { - return await resultTask ?? IdentityResult.Success; - } - - await resetLockoutTask; - return IdentityResult.Success; - } - catch (IdentityResultException ex) - { - return ex.IdentityResult; - } - } - - private sealed class IdentityResultException : Exception - { - internal IdentityResultException(IdentityResult result) : base() - { - IdentityResult = result; - } - - internal IdentityResult IdentityResult { get; set; } - - public override string Message - { - get - { - var sb = new StringBuilder("ResetLockout failed."); - - foreach (var error in IdentityResult.Errors) - { - sb.AppendLine(); - sb.Append(error.Code); - sb.Append(": "); - sb.Append(error.Description); - } - - return sb.ToString(); - } - } - } - - internal sealed class TwoFactorAuthenticationInfo - { - public required TUser User { get; init; } - public string? LoginProvider { get; init; } - } - - internal sealed class PasskeyAuthenticationInfo - { - public required string? Operation { get; init; } - public required string? State { get; init; } - - } - - private static class PasskeyOperations - { - public const string Attestation = "Attestation"; - public const string Assertion = "Assertion"; - } -} + /// + /// + /// See . + /// + /// + /// The user whose passkeys should be signaled. + /// The user entity associated with the user's passkeys. + /// A JSON string representing the passkey signal options. + /// + /// Thrown when the registered does not support signal options. + /// See . + /// + /// + /// The following example shows how the result is used from JavaScript. + /// + /// const { rpId, userId, allAcceptedCredentialIds, name, displayName } = signalOptions; + /// await PublicKeyCredential.signalAllAcceptedCredentials?.({ rpId, userId, allAcceptedCredentialIds }); + /// await PublicKeyCredential.signalCurrentUserDetails?.({ rpId, userId, name, displayName }); + /// + /// + public virtual async Task MakePasskeySignalOptionsAsync(TUser user, PasskeyUserEntity userEntity) + { + ThrowIfNoPasskeyHandler(); + ArgumentNullException.ThrowIfNull(user); + ArgumentNullException.ThrowIfNull(userEntity); + + var result = await _passkeyHandler.MakeSignalOptionsAsync(user, userEntity, Context); + return result.SignalOptionsJson; + } + + /// + /// Performs passkey attestation for the given . + /// + /// + /// The should be obtained by JSON-serializing the result of the + /// navigator.credentials.create() JavaScript API. The argument to navigator.credentials.create() + /// should be obtained by calling . + /// + /// The credentials obtained by JSON-serializing the result of the navigator.credentials.create() JavaScript function. + /// + /// A task object representing the asynchronous operation containing the . + /// + public virtual async Task PerformPasskeyAttestationAsync([StringSyntax(StringSyntaxAttribute.Json)] string credentialJson) + { + ThrowIfNoPasskeyHandler(); + ArgumentException.ThrowIfNullOrEmpty(credentialJson); + + var passkeyInfo = await RetrievePasskeyAuthenticationInfoAsync() + ?? throw new InvalidOperationException( + "No passkey attestation is underway. " + + $"Make sure to call '{nameof(SignInManager<>)}.{nameof(MakePasskeyCreationOptionsAsync)}()' to initiate a passkey attestation."); + if (!string.Equals(PasskeyOperations.Attestation, passkeyInfo.Operation, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"Expected passkey operation '{PasskeyOperations.Attestation}', but got '{passkeyInfo.Operation}'. " + + $"This may indicate that you have not previously called '{nameof(SignInManager<>)}.{nameof(MakePasskeyCreationOptionsAsync)}()'."); + } + var context = new PasskeyAttestationContext + { + CredentialJson = credentialJson, + AttestationState = passkeyInfo.State, + HttpContext = Context, + }; + var result = await _passkeyHandler.PerformAttestationAsync(context); + if (!result.Succeeded) + { + Logger.LogDebug(EventIds.PasskeyAttestationFailed, "Passkey attestation failed: {message}", result.Failure.Message); + } + + return result; + } + + /// + /// Performs passkey assertion for the given . + /// + /// + /// The should be obtained by JSON-serializing the result of the + /// navigator.credentials.get() JavaScript API. The argument to navigator.credentials.get() + /// should be obtained by calling . + /// Upon success, the should be stored on the + /// using . + /// + /// The credentials obtained by JSON-serializing the result of the navigator.credentials.get() JavaScript function. + /// + /// A task object representing the asynchronous operation containing the . + /// + public virtual async Task> PerformPasskeyAssertionAsync([StringSyntax(StringSyntaxAttribute.Json)] string credentialJson) + { + ThrowIfNoPasskeyHandler(); + ArgumentException.ThrowIfNullOrEmpty(credentialJson); + + var passkeyInfo = await RetrievePasskeyAuthenticationInfoAsync() + ?? throw new InvalidOperationException( + "No passkey assertion is underway. " + + $"Make sure to call '{nameof(SignInManager<>)}.{nameof(MakePasskeyRequestOptionsAsync)}()' to initiate a passkey assertion."); + if (!string.Equals(PasskeyOperations.Assertion, passkeyInfo.Operation, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"Expected passkey operation '{PasskeyOperations.Assertion}', but got '{passkeyInfo.Operation}'. " + + $"This may indicate that you have not previously called '{nameof(SignInManager<>)}.{nameof(MakePasskeyRequestOptionsAsync)}()'."); + } + var context = new PasskeyAssertionContext + { + CredentialJson = credentialJson, + AssertionState = passkeyInfo.State, + HttpContext = Context, + }; + var result = await _passkeyHandler.PerformAssertionAsync(context); + if (!result.Succeeded) + { + Logger.LogDebug(EventIds.PasskeyAssertionFailed, "Passkey assertion failed: {message}", result.Failure.Message); + } + + return result; + } + + /// + /// Performs a passkey assertion and attempts to sign in the user. + /// + /// + /// The should be obtained by JSON-serializing the result of the + /// navigator.credentials.get() JavaScript API. The argument to navigator.credentials.get() + /// should be obtained by calling . + /// + /// The credentials obtained by JSON-serializing the result of the navigator.credentials.get() JavaScript function. + /// + /// The task object representing the asynchronous operation containing the + /// for the sign-in attempt. + /// + public virtual async Task PasskeySignInAsync([StringSyntax(StringSyntaxAttribute.Json)] string credentialJson) + { + var startTimestamp = Stopwatch.GetTimestamp(); + try + { + var result = await PasskeySignInCoreAsync(credentialJson); + _metrics?.AuthenticateSignIn(typeof(TUser).FullName!, AuthenticationScheme, result, SignInType.Passkey, isPersistent: false, startTimestamp); + + return result; + } + catch (Exception ex) + { + _metrics?.AuthenticateSignIn(typeof(TUser).FullName!, AuthenticationScheme, result: null, SignInType.Passkey, isPersistent: false, startTimestamp, ex); + throw; + } + } + + private async Task PasskeySignInCoreAsync(string credentialJson) + { + ArgumentException.ThrowIfNullOrEmpty(credentialJson); + + var assertionResult = await PerformPasskeyAssertionAsync(credentialJson); + if (!assertionResult.Succeeded) + { + return SignInResult.Failed; + } + + var error = await PreSignInCheck(assertionResult.User); + if (error != null) + { + return error; + } + + // After a successful assertion, we need to update the passkey so that it has the latest + // sign count and authenticator data. + var setPasskeyResult = await UserManager.AddOrUpdatePasskeyAsync(assertionResult.User, assertionResult.Passkey); + if (!setPasskeyResult.Succeeded) + { + return SignInResult.Failed; + } + + return await SignInOrTwoFactorAsync(assertionResult.User, isPersistent: false, bypassTwoFactor: true); + } + + [MemberNotNull(nameof(_passkeyHandler))] + private void ThrowIfNoPasskeyHandler() + { + if (_passkeyHandler is null) + { + throw new InvalidOperationException( + $"This operation requires an {nameof(IPasskeyHandler<>)} service to be registered."); + } + } + + private async Task StorePasskeyAuthenticationInfoAsync(string operation, string? state) + { + var props = new AuthenticationProperties(); + props.Items[PasskeyOperationKey] = operation; + props.Items[PasskeyStateKey] = state; + var claimsIdentity = new ClaimsIdentity(IdentityConstants.TwoFactorUserIdScheme); + var claimsPrincipal = new ClaimsPrincipal(claimsIdentity); + await Context.SignInAsync(IdentityConstants.TwoFactorUserIdScheme, claimsPrincipal, props); + } + + private async Task RetrievePasskeyAuthenticationInfoAsync() + { + return _passkeyInfo ??= await RetrievePasskeyInfoCoreAsync(); + + async Task RetrievePasskeyInfoCoreAsync() + { + var result = await Context.AuthenticateAsync(IdentityConstants.TwoFactorUserIdScheme); + await Context.SignOutAsync(IdentityConstants.TwoFactorUserIdScheme); + + if (result.Properties is not { } properties) + { + return null; + } + + if (!properties.Items.TryGetValue(PasskeyOperationKey, out var operation) || + !properties.Items.TryGetValue(PasskeyStateKey, out var state)) + { + return null; + } + + return new() + { + Operation = operation, + State = state, + }; + } + } + + /// + /// Returns a flag indicating if the current client browser has been remembered by two factor authentication + /// for the user attempting to login, as an asynchronous operation. + /// + /// The user attempting to login. + /// + /// The task object representing the asynchronous operation containing true if the browser has been remembered + /// for the current user. + /// + public virtual async Task IsTwoFactorClientRememberedAsync(TUser user) + { + if (await _schemes.GetSchemeAsync(IdentityConstants.TwoFactorRememberMeScheme) == null) + { + return false; + } + + var userId = await UserManager.GetUserIdAsync(user); + var result = await Context.AuthenticateAsync(IdentityConstants.TwoFactorRememberMeScheme); + return (result?.Principal != null && result.Principal.FindFirstValue(ClaimTypes.Name) == userId); + } + + /// + /// Sets a flag on the browser to indicate the user has selected "Remember this browser" for two factor authentication purposes, + /// as an asynchronous operation. + /// + /// The user who choose "remember this browser". + /// The task object representing the asynchronous operation. + public virtual async Task RememberTwoFactorClientAsync(TUser user) + { + try + { + var principal = await StoreRememberClient(user); + await Context.SignInAsync(IdentityConstants.TwoFactorRememberMeScheme, + principal, + new AuthenticationProperties { IsPersistent = true }); + _metrics?.RememberTwoFactorClient(typeof(TUser).FullName!, IdentityConstants.TwoFactorRememberMeScheme); + } + catch (Exception ex) + { + _metrics?.RememberTwoFactorClient(typeof(TUser).FullName!, IdentityConstants.TwoFactorRememberMeScheme, ex); + throw; + } + } + + /// + /// Clears the "Remember this browser flag" from the current browser, as an asynchronous operation. + /// + /// The task object representing the asynchronous operation. + public virtual async Task ForgetTwoFactorClientAsync() + { + try + { + await Context.SignOutAsync(IdentityConstants.TwoFactorRememberMeScheme); + _metrics?.ForgetTwoFactorClient(typeof(TUser).FullName!, IdentityConstants.TwoFactorRememberMeScheme); + } + catch (Exception ex) + { + _metrics?.ForgetTwoFactorClient(typeof(TUser).FullName!, IdentityConstants.TwoFactorRememberMeScheme, ex); + throw; + } + } + + /// + /// Signs in the user without two factor authentication using a two factor recovery code. + /// + /// The two factor recovery code. + /// + public virtual async Task TwoFactorRecoveryCodeSignInAsync(string recoveryCode) + { + var startTimestamp = Stopwatch.GetTimestamp(); + try + { + var result = await TwoFactorRecoveryCodeSignInCoreAsync(recoveryCode); + _metrics?.AuthenticateSignIn(typeof(TUser).FullName!, AuthenticationScheme, result, SignInType.TwoFactorRecoveryCode, isPersistent: false, startTimestamp); + + return result; + } + catch (Exception ex) + { + _metrics?.AuthenticateSignIn(typeof(TUser).FullName!, AuthenticationScheme, result: null, SignInType.TwoFactorRecoveryCode, isPersistent: false, startTimestamp, ex); + throw; + } + } + + private async Task TwoFactorRecoveryCodeSignInCoreAsync(string recoveryCode) + { + var twoFactorInfo = await RetrieveTwoFactorInfoAsync(); + if (twoFactorInfo == null) + { + return SignInResult.Failed; + } + + var result = await UserManager.RedeemTwoFactorRecoveryCodeAsync(twoFactorInfo.User, recoveryCode); + if (result.Succeeded) + { + return await DoTwoFactorSignInAsync(twoFactorInfo.User, twoFactorInfo, isPersistent: false, rememberClient: false); + } + + // We don't protect against brute force attacks since codes are expected to be random. + return SignInResult.Failed; + } + + private async Task DoTwoFactorSignInAsync(TUser user, TwoFactorAuthenticationInfo twoFactorInfo, bool isPersistent, bool rememberClient) + { + var resetLockoutResult = await ResetLockoutWithResult(user); + if (!resetLockoutResult.Succeeded) + { + // ResetLockout got an unsuccessful result that could be caused by concurrency failures indicating an + // attacker could be trying to bypass the MaxFailedAccessAttempts limit. Return the same failure we do + // when failing to increment the lockout to avoid giving an attacker extra guesses at the two factor code. + return SignInResult.Failed; + } + + var claims = new List + { + new Claim("amr", "mfa") + }; + + if (twoFactorInfo.LoginProvider != null) + { + claims.Add(new Claim(ClaimTypes.AuthenticationMethod, twoFactorInfo.LoginProvider)); + } + // Cleanup external cookie + if (await _schemes.GetSchemeAsync(IdentityConstants.ExternalScheme) != null) + { + await Context.SignOutAsync(IdentityConstants.ExternalScheme); + } + // Cleanup two factor user id cookie + if (await _schemes.GetSchemeAsync(IdentityConstants.TwoFactorUserIdScheme) != null) + { + await Context.SignOutAsync(IdentityConstants.TwoFactorUserIdScheme); + if (rememberClient) + { + await RememberTwoFactorClientAsync(user); + } + } + await SignInWithClaimsAsync(user, isPersistent, claims); + return SignInResult.Success; + } + + /// + /// Validates the sign in code from an authenticator app and creates and signs in the user, as an asynchronous operation. + /// + /// The two factor authentication code to validate. + /// Flag indicating whether the sign-in cookie should persist after the browser is closed. + /// Flag indicating whether the current browser should be remember, suppressing all further + /// two factor authentication prompts. + /// The task object representing the asynchronous operation containing the + /// for the sign-in attempt. + public virtual async Task TwoFactorAuthenticatorSignInAsync(string code, bool isPersistent, bool rememberClient) + { + var startTimestamp = Stopwatch.GetTimestamp(); + try + { + var result = await TwoFactorAuthenticatorSignInCoreAsync(code, isPersistent, rememberClient); + _metrics?.AuthenticateSignIn(typeof(TUser).FullName!, AuthenticationScheme, result, SignInType.TwoFactorAuthenticator, isPersistent, startTimestamp); + + return result; + } + catch (Exception ex) + { + _metrics?.AuthenticateSignIn(typeof(TUser).FullName!, AuthenticationScheme, result: null, SignInType.TwoFactorAuthenticator, isPersistent, startTimestamp, ex); + throw; + } + } + + private async Task TwoFactorAuthenticatorSignInCoreAsync(string code, bool isPersistent, bool rememberClient) + { + var twoFactorInfo = await RetrieveTwoFactorInfoAsync(); + if (twoFactorInfo == null) + { + return SignInResult.Failed; + } + + var user = twoFactorInfo.User; + var error = await PreSignInCheck(user); + if (error != null) + { + return error; + } + + if (await UserManager.VerifyTwoFactorTokenAsync(user, Options.Tokens.AuthenticatorTokenProvider, code)) + { + return await DoTwoFactorSignInAsync(user, twoFactorInfo, isPersistent, rememberClient); + } + // If the token is incorrect, record the failure which also may cause the user to be locked out + if (UserManager.SupportsUserLockout) + { + var incrementLockoutResult = await UserManager.AccessFailedAsync(user) ?? IdentityResult.Success; + if (!incrementLockoutResult.Succeeded) + { + // Return the same failure we do when resetting the lockout fails after a correct two factor code. + // This is currently redundant, but it's here in case the code gets copied elsewhere. + return SignInResult.Failed; + } + + if (await UserManager.IsLockedOutAsync(user)) + { + return await LockedOut(user); + } + } + return SignInResult.Failed; + } + + /// + /// Validates the two factor sign in code and creates and signs in the user, as an asynchronous operation. + /// + /// The two factor authentication provider to validate the code against. + /// The two factor authentication code to validate. + /// Flag indicating whether the sign-in cookie should persist after the browser is closed. + /// Flag indicating whether the current browser should be remember, suppressing all further + /// two factor authentication prompts. + /// The task object representing the asynchronous operation containing the + /// for the sign-in attempt. + public virtual async Task TwoFactorSignInAsync(string provider, string code, bool isPersistent, bool rememberClient) + { + var startTimestamp = Stopwatch.GetTimestamp(); + try + { + var result = await TwoFactorSignInCoreAsync(provider, code, isPersistent, rememberClient); + _metrics?.AuthenticateSignIn(typeof(TUser).FullName!, AuthenticationScheme, result, SignInType.TwoFactor, isPersistent, startTimestamp); + + return result; + } + catch (Exception ex) + { + _metrics?.AuthenticateSignIn(typeof(TUser).FullName!, AuthenticationScheme, result: null, SignInType.TwoFactor, isPersistent, startTimestamp, ex); + throw; + } + } + + private async Task TwoFactorSignInCoreAsync(string provider, string code, bool isPersistent, bool rememberClient) + { + var twoFactorInfo = await RetrieveTwoFactorInfoAsync(); + if (twoFactorInfo == null) + { + return SignInResult.Failed; + } + + var user = twoFactorInfo.User; + var error = await PreSignInCheck(user); + if (error != null) + { + return error; + } + if (await UserManager.VerifyTwoFactorTokenAsync(user, provider, code)) + { + return await DoTwoFactorSignInAsync(user, twoFactorInfo, isPersistent, rememberClient); + } + // If the token is incorrect, record the failure which also may cause the user to be locked out + if (UserManager.SupportsUserLockout) + { + var incrementLockoutResult = await UserManager.AccessFailedAsync(user) ?? IdentityResult.Success; + if (!incrementLockoutResult.Succeeded) + { + // Return the same failure we do when resetting the lockout fails after a correct two factor code. + // This is currently redundant, but it's here in case the code gets copied elsewhere. + return SignInResult.Failed; + } + + if (await UserManager.IsLockedOutAsync(user)) + { + return await LockedOut(user); + } + } + return SignInResult.Failed; + } + + /// + /// Gets the for the current two factor authentication login, as an asynchronous operation. + /// + /// The task object representing the asynchronous operation containing the + /// for the sign-in attempt. + public virtual async Task GetTwoFactorAuthenticationUserAsync() + { + var info = await RetrieveTwoFactorInfoAsync(); + if (info == null) + { + return null; + } + + return info.User; + } + + /// + /// Signs in a user via a previously registered third party login, as an asynchronous operation. + /// + /// The login provider to use. + /// The unique provider identifier for the user. + /// Flag indicating whether the sign-in cookie should persist after the browser is closed. + /// The task object representing the asynchronous operation containing the + /// for the sign-in attempt. + public virtual Task ExternalLoginSignInAsync(string loginProvider, string providerKey, bool isPersistent) + => ExternalLoginSignInAsync(loginProvider, providerKey, isPersistent, bypassTwoFactor: false); + + /// + /// Signs in a user via a previously registered third party login, as an asynchronous operation. + /// + /// The login provider to use. + /// The unique provider identifier for the user. + /// Flag indicating whether the sign-in cookie should persist after the browser is closed. + /// Flag indicating whether to bypass two factor authentication. + /// The task object representing the asynchronous operation containing the + /// for the sign-in attempt. + public virtual async Task ExternalLoginSignInAsync(string loginProvider, string providerKey, bool isPersistent, bool bypassTwoFactor) + { + var startTimestamp = Stopwatch.GetTimestamp(); + try + { + var result = await ExternalLoginSignInCoreAsync(loginProvider, providerKey, isPersistent, bypassTwoFactor); + _metrics?.AuthenticateSignIn(typeof(TUser).FullName!, AuthenticationScheme, result, SignInType.External, isPersistent, startTimestamp); + + return result; + } + catch (Exception ex) + { + _metrics?.AuthenticateSignIn(typeof(TUser).FullName!, AuthenticationScheme, result: null, SignInType.External, isPersistent, startTimestamp, ex); + throw; + } + } + + private async Task ExternalLoginSignInCoreAsync(string loginProvider, string providerKey, bool isPersistent, bool bypassTwoFactor) + { + var user = await UserManager.FindByLoginAsync(loginProvider, providerKey); + if (user == null) + { + return SignInResult.Failed; + } + + var error = await PreSignInCheck(user); + if (error != null) + { + return error; + } + return await SignInOrTwoFactorAsync(user, isPersistent, loginProvider, bypassTwoFactor); + } + + /// + /// Gets a collection of s for the known external login providers. + /// + /// A collection of s for the known external login providers. + public virtual async Task> GetExternalAuthenticationSchemesAsync() + { + var schemes = await _schemes.GetAllSchemesAsync(); + return schemes.Where(s => !string.IsNullOrEmpty(s.DisplayName)); + } + + /// + /// Gets the external login information for the current login, as an asynchronous operation. + /// + /// Flag indication whether a Cross Site Request Forgery token was expected in the current request. + /// The task object representing the asynchronous operation containing the + /// for the sign-in attempt. + public virtual async Task GetExternalLoginInfoAsync(string? expectedXsrf = null) + { + var auth = await Context.AuthenticateAsync(IdentityConstants.ExternalScheme); + var items = auth?.Properties?.Items; + if (auth?.Principal == null || items == null || !items.TryGetValue(LoginProviderKey, out var provider)) + { + return null; + } + + if (expectedXsrf != null) + { + if (!items.TryGetValue(XsrfKey, out var userId) || + userId != expectedXsrf) + { + return null; + } + } + + var providerKey = auth.Principal.FindFirstValue(ClaimTypes.NameIdentifier) ?? auth.Principal.FindFirstValue("sub"); + if (providerKey == null || provider == null) + { + return null; + } + + var providerDisplayName = (await GetExternalAuthenticationSchemesAsync()).FirstOrDefault(p => p.Name == provider)?.DisplayName + ?? provider; + return new ExternalLoginInfo(auth.Principal, provider, providerKey, providerDisplayName) + { + AuthenticationTokens = auth.Properties?.GetTokens(), + AuthenticationProperties = auth.Properties + }; + } + + /// + /// Stores any authentication tokens found in the external authentication cookie into the associated user. + /// + /// The information from the external login provider. + /// The that represents the asynchronous operation, containing the of the operation. + public virtual async Task UpdateExternalAuthenticationTokensAsync(ExternalLoginInfo externalLogin) + { + ArgumentNullException.ThrowIfNull(externalLogin); + + if (externalLogin.AuthenticationTokens != null && externalLogin.AuthenticationTokens.Any()) + { + var user = await UserManager.FindByLoginAsync(externalLogin.LoginProvider, externalLogin.ProviderKey); + if (user == null) + { + return IdentityResult.Failed(); + } + + foreach (var token in externalLogin.AuthenticationTokens) + { + var result = await UserManager.SetAuthenticationTokenAsync(user, externalLogin.LoginProvider, token.Name, token.Value); + if (!result.Succeeded) + { + return result; + } + } + } + + return IdentityResult.Success; + } + + /// + /// Configures the redirect URL and user identifier for the specified external login . + /// + /// The provider to configure. + /// The external login URL users should be redirected to during the login flow. + /// The current user's identifier, which will be used to provide CSRF protection. + /// A configured . + public virtual AuthenticationProperties ConfigureExternalAuthenticationProperties(string? provider, [StringSyntax(StringSyntaxAttribute.Uri)] string? redirectUrl, string? userId = null) + { + var properties = new AuthenticationProperties { RedirectUri = redirectUrl }; + properties.Items[LoginProviderKey] = provider; + if (userId != null) + { + properties.Items[XsrfKey] = userId; + } + return properties; + } + + /// + /// Creates a claims principal for the specified 2fa information. + /// + /// The user whose is logging in via 2fa. + /// The 2fa provider. + /// A containing the user 2fa information. + internal static ClaimsPrincipal StoreTwoFactorInfo(string userId, string? loginProvider) + { + var identity = new ClaimsIdentity(IdentityConstants.TwoFactorUserIdScheme); + identity.AddClaim(new Claim(ClaimTypes.Name, userId)); + if (loginProvider != null) + { + identity.AddClaim(new Claim(ClaimTypes.AuthenticationMethod, loginProvider)); + } + return new ClaimsPrincipal(identity); + } + + internal async Task StoreRememberClient(TUser user) + { + var userId = await UserManager.GetUserIdAsync(user); + var rememberBrowserIdentity = new ClaimsIdentity(IdentityConstants.TwoFactorRememberMeScheme); + rememberBrowserIdentity.AddClaim(new Claim(ClaimTypes.Name, userId)); + if (UserManager.SupportsUserSecurityStamp) + { + var stamp = await UserManager.GetSecurityStampAsync(user); + rememberBrowserIdentity.AddClaim(new Claim(Options.ClaimsIdentity.SecurityStampClaimType, stamp)); + } + return new ClaimsPrincipal(rememberBrowserIdentity); + } + + /// + /// Check if the has two factor enabled. + /// + /// + /// + /// The task object representing the asynchronous operation containing true if the user has two factor enabled. + /// + public virtual async Task IsTwoFactorEnabledAsync(TUser user) + => UserManager.SupportsUserTwoFactor && + await UserManager.GetTwoFactorEnabledAsync(user) && + (await UserManager.GetValidTwoFactorProvidersAsync(user)).Count > 0; + + /// + /// Signs in the specified if is set to false. + /// Otherwise stores the for use after a two factor check. + /// + /// + /// Flag indicating whether the sign-in cookie should persist after the browser is closed. + /// The login provider to use. Default is null + /// Flag indicating whether to bypass two factor authentication. Default is false + /// Returns a + protected virtual async Task SignInOrTwoFactorAsync(TUser user, bool isPersistent, string? loginProvider = null, bool bypassTwoFactor = false) + { + if (!bypassTwoFactor && await IsTwoFactorEnabledAsync(user)) + { + if (!await IsTwoFactorClientRememberedAsync(user)) + { + // Allow the two-factor flow to continue later within the same request with or without a TwoFactorUserIdScheme in + // the event that the two-factor code or recovery code has already been provided as is the case for MapIdentityApi. + _twoFactorInfo = new() + { + User = user, + LoginProvider = loginProvider, + }; + + if (await _schemes.GetSchemeAsync(IdentityConstants.TwoFactorUserIdScheme) != null) + { + // Store the userId for use after two factor check + var userId = await UserManager.GetUserIdAsync(user); + await Context.SignInAsync(IdentityConstants.TwoFactorUserIdScheme, StoreTwoFactorInfo(userId, loginProvider)); + } + + return SignInResult.TwoFactorRequired; + } + } + // Cleanup external cookie + if (loginProvider != null) + { + await Context.SignOutAsync(IdentityConstants.ExternalScheme); + } + if (loginProvider == null) + { + await SignInWithClaimsAsync(user, isPersistent, new Claim[] { new Claim("amr", "pwd") }); + } + else + { + await SignInAsync(user, isPersistent, loginProvider); + } + return SignInResult.Success; + } + + private async Task RetrieveTwoFactorInfoAsync() + { + if (_twoFactorInfo != null) + { + return _twoFactorInfo; + } + + var result = await Context.AuthenticateAsync(IdentityConstants.TwoFactorUserIdScheme); + if (result?.Principal == null) + { + return null; + } + + var userId = result.Principal.FindFirstValue(ClaimTypes.Name); + if (userId == null) + { + return null; + } + + var user = await UserManager.FindByIdAsync(userId); + if (user == null) + { + return null; + } + + return new TwoFactorAuthenticationInfo + { + User = user, + LoginProvider = result.Principal.FindFirstValue(ClaimTypes.AuthenticationMethod), + }; + } + + /// + /// Used to determine if a user is considered locked out. + /// + /// The user. + /// Whether a user is considered locked out. + protected virtual async Task IsLockedOut(TUser user) + { + return UserManager.SupportsUserLockout && await UserManager.IsLockedOutAsync(user); + } + + /// + /// Returns a locked out SignInResult. + /// + /// The user. + /// A locked out SignInResult + protected virtual Task LockedOut(TUser user) + { + Logger.LogDebug(EventIds.UserLockedOut, "User is currently locked out."); + return Task.FromResult(SignInResult.LockedOut); + } + + /// + /// Used to ensure that a user is allowed to sign in. + /// + /// The user + /// Null if the user should be allowed to sign in, otherwise the SignInResult why they should be denied. + protected virtual async Task PreSignInCheck(TUser user) + { + if (!await CanSignInAsync(user)) + { + return SignInResult.NotAllowed; + } + if (await IsLockedOut(user)) + { + return await LockedOut(user); + } + return null; + } + + /// + /// Used to reset a user's lockout count. + /// + /// The user + /// The that represents the asynchronous operation, containing the of the operation. + protected virtual async Task ResetLockout(TUser user) + { + if (UserManager.SupportsUserLockout) + { + // The IdentityResult should not be null according to the annotations, but our own tests return null and I'm trying to limit breakages. + var result = await UserManager.ResetAccessFailedCountAsync(user) ?? IdentityResult.Success; + + if (!result.Succeeded) + { + throw new IdentityResultException(result); + } + } + } + + private async Task ResetLockoutWithResult(TUser user) + { + // Avoid relying on throwing an exception if we're not in a derived class. + if (GetType() == typeof(SignInManager)) + { + if (!UserManager.SupportsUserLockout) + { + return IdentityResult.Success; + } + + return await UserManager.ResetAccessFailedCountAsync(user) ?? IdentityResult.Success; + } + + try + { + var resetLockoutTask = ResetLockout(user); + + if (resetLockoutTask is Task resultTask) + { + return await resultTask ?? IdentityResult.Success; + } + + await resetLockoutTask; + return IdentityResult.Success; + } + catch (IdentityResultException ex) + { + return ex.IdentityResult; + } + } + + private sealed class IdentityResultException : Exception + { + internal IdentityResultException(IdentityResult result) : base() + { + IdentityResult = result; + } + + internal IdentityResult IdentityResult { get; set; } + + public override string Message + { + get + { + var sb = new StringBuilder("ResetLockout failed."); + + foreach (var error in IdentityResult.Errors) + { + sb.AppendLine(); + sb.Append(error.Code); + sb.Append(": "); + sb.Append(error.Description); + } + + return sb.ToString(); + } + } + } + + internal sealed class TwoFactorAuthenticationInfo + { + public required TUser User { get; init; } + public string? LoginProvider { get; init; } + } + + internal sealed class PasskeyAuthenticationInfo + { + public required string? Operation { get; init; } + public required string? State { get; init; } + + } + + private static class PasskeyOperations + { + public const string Attestation = "Attestation"; + public const string Assertion = "Assertion"; + } +} diff --git a/src/Identity/test/Identity.Test/Passkeys/PasskeyHandlerSignalTest.cs b/src/Identity/test/Identity.Test/Passkeys/PasskeyHandlerSignalTest.cs new file mode 100644 index 000000000000..d7add9c41056 --- /dev/null +++ b/src/Identity/test/Identity.Test/Passkeys/PasskeyHandlerSignalTest.cs @@ -0,0 +1,130 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable enable + +using System.Buffers.Text; +using System.Text; +using System.Text.Json; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; +using Moq; + +namespace Microsoft.AspNetCore.Identity.Test; + +public class PasskeyHandlerSignalTest +{ + [Fact] + public async Task CanMakeSignalOptions() + { + var user = new PocoUser { UserName = "Foo" }; + var userManager = SetupUserManager(user, CreatePasskey([1, 2, 3]), CreatePasskey([4, 5, 6])); + var handler = CreateHandler(userManager); + var httpContext = CreateHttpContext("contoso.com", port: 5001); + + var result = await handler.MakeSignalOptionsAsync(user, CreateUserEntity(user, "Foo", "Foo Bar"), httpContext); + + var options = JsonSerializer.Deserialize(result.SignalOptionsJson); + Assert.Equal("contoso.com", options.GetProperty("rpId").GetString()); + Assert.Equal(Base64Url.EncodeToString(Encoding.UTF8.GetBytes(user.Id)), options.GetProperty("userId").GetString()); + Assert.Equal("Foo", options.GetProperty("name").GetString()); + Assert.Equal("Foo Bar", options.GetProperty("displayName").GetString()); + Assert.Collection(options.GetProperty("allAcceptedCredentialIds").EnumerateArray(), + id => Assert.Equal(Base64Url.EncodeToString([1, 2, 3]), id.GetString()), + id => Assert.Equal(Base64Url.EncodeToString([4, 5, 6]), id.GetString())); + } + + [Fact] + public void SupportsSignalOptionsIsTrue() + { + var user = new PocoUser { UserName = "Foo" }; + var handler = CreateHandler(SetupUserManager(user)); + + Assert.True(handler.SupportsSignalOptions); + } + + [Fact] + public void SupportsSignalOptionsIsFalseWhenStoreDoesNotSupportPasskeys() + { + var user = new PocoUser { UserName = "Foo" }; + var userManager = MockHelpers.MockUserManager(); + userManager.Setup(m => m.SupportsUserPasskey).Returns(false); + var handler = CreateHandler(userManager.Object); + + Assert.False(handler.SupportsSignalOptions); + } + + [Fact] + public async Task MakeSignalOptionsUsesConfiguredServerDomain() + { + var user = new PocoUser { UserName = "Foo" }; + var userManager = SetupUserManager(user); + var handler = CreateHandler(userManager, new() { ServerDomain = "fabrikam.com" }); + var httpContext = CreateHttpContext("contoso.com"); + + var result = await handler.MakeSignalOptionsAsync(user, CreateUserEntity(user), httpContext); + + var options = JsonSerializer.Deserialize(result.SignalOptionsJson); + Assert.Equal("fabrikam.com", options.GetProperty("rpId").GetString()); + } + + [Fact] + public async Task MakeSignalOptionsWithoutPasskeysReturnsEmptyCredentialList() + { + var user = new PocoUser { UserName = "Foo" }; + var handler = CreateHandler(SetupUserManager(user)); + + var result = await handler.MakeSignalOptionsAsync(user, CreateUserEntity(user), CreateHttpContext()); + + var options = JsonSerializer.Deserialize(result.SignalOptionsJson); + Assert.Empty(options.GetProperty("allAcceptedCredentialIds").EnumerateArray()); + } + + [Fact] + public async Task MakeSignalOptionsThrowsWhenUserEntityIdDoesNotMatchUser() + { + var user = new PocoUser { UserName = "Foo" }; + var handler = CreateHandler(SetupUserManager(user)); + var userEntity = new PasskeyUserEntity + { + Id = "some-other-id", + Name = "Foo", + DisplayName = "Foo", + }; + + var ex = await Assert.ThrowsAsync( + () => handler.MakeSignalOptionsAsync(user, userEntity, CreateHttpContext())); + + Assert.Equal($"The user entity ID 'some-other-id' does not match the ID '{user.Id}' of the specified user.", ex.Message); + } + + private static PasskeyHandler CreateHandler(UserManager userManager, IdentityPasskeyOptions? options = null) + => new(userManager, Options.Create(options ?? new IdentityPasskeyOptions())); + + private static UserManager SetupUserManager(PocoUser user, params UserPasskeyInfo[] passkeys) + { + var manager = MockHelpers.MockUserManager(); + manager.Setup(m => m.SupportsUserPasskey).Returns(true); + manager.Setup(m => m.GetUserIdAsync(user)).ReturnsAsync(user.Id); + manager.Setup(m => m.GetPasskeysAsync(user)).ReturnsAsync(passkeys); + return manager.Object; + } + + private static HttpContext CreateHttpContext(string host = "contoso.com", int? port = null) + { + var httpContext = new DefaultHttpContext(); + httpContext.Request.Host = port is { } portValue ? new HostString(host, portValue) : new HostString(host); + return httpContext; + } + + private static PasskeyUserEntity CreateUserEntity(PocoUser user, string name = "Foo", string displayName = "Foo") + => new() + { + Id = user.Id, + Name = name, + DisplayName = displayName, + }; + + private static UserPasskeyInfo CreatePasskey(byte[] credentialId) + => new(credentialId, [], default, 0, null, false, false, false, [], []); +} diff --git a/src/Identity/test/Identity.Test/SignInManagerTest.cs b/src/Identity/test/Identity.Test/SignInManagerTest.cs index be708581fb04..3f2c16ea9f38 100644 --- a/src/Identity/test/Identity.Test/SignInManagerTest.cs +++ b/src/Identity/test/Identity.Test/SignInManagerTest.cs @@ -5,7 +5,6 @@ using System.Diagnostics; using System.Diagnostics.Metrics; using System.Security.Claims; -using System.Text; using System.Text.Json; using System.Text.Json.Nodes; using Microsoft.AspNetCore.Authentication; @@ -123,10 +122,9 @@ public async Task CheckPasswordSignInReturnsLockedOutWhenLockedOut() private static Mock> SetupUserManager( PocoUser user, IMeterFactory meterFactory = null, - IPasskeyHandler passkeyHandler = null, - IdentityPasskeyOptions passkeyOptions = null) + IPasskeyHandler passkeyHandler = null) { - var manager = MockHelpers.MockUserManager(meterFactory, passkeyHandler, passkeyOptions); + var manager = MockHelpers.MockUserManager(meterFactory, passkeyHandler); manager.Setup(m => m.FindByNameAsync(user.UserName)).ReturnsAsync(user); manager.Setup(m => m.FindByIdAsync(user.Id)).ReturnsAsync(user); manager.Setup(m => m.GetUserIdAsync(user)).ReturnsAsync(user.Id.ToString()); @@ -654,78 +652,54 @@ public async Task PasskeySignInReturnsLockedOutWhenLockedOut() public async Task CanMakePasskeySignalOptions() { var user = new PocoUser { UserName = "Foo" }; - var manager = SetupUserManager(user); - manager - .Setup(m => m.GetPasskeysAsync(user)) - .ReturnsAsync([ - CreatePasskey([1, 2, 3]), - CreatePasskey([4, 5, 6]), - ]); + var userEntity = new PasskeyUserEntity { Id = user.Id, Name = "Foo", DisplayName = "Foo Bar" }; + var expectedOptionsJson = ""; + var passkeyHandler = new Mock>(); + passkeyHandler + .Setup(h => h.MakeSignalOptionsAsync(user, userEntity, It.IsAny())) + .Returns(Task.FromResult(new PasskeySignalOptionsResult + { + SignalOptionsJson = expectedOptionsJson, + })) + .Verifiable(); + var manager = SetupUserManager(user, passkeyHandler: passkeyHandler.Object); var context = new DefaultHttpContext(); - context.Request.Host = new HostString("contoso.com", 5001); var helper = SetupSignInManager(manager.Object, context); - var optionsJson = await helper.MakePasskeySignalOptionsAsync(user, new() - { - Id = user.Id, - Name = "Foo", - DisplayName = "Foo Bar", - }); - - var options = JsonSerializer.Deserialize(optionsJson); - Assert.Equal("contoso.com", options.GetProperty("rpId").GetString()); - Assert.Equal(Base64Url.EncodeToString(Encoding.UTF8.GetBytes(user.Id)), options.GetProperty("userId").GetString()); - Assert.Equal("Foo", options.GetProperty("name").GetString()); - Assert.Equal("Foo Bar", options.GetProperty("displayName").GetString()); - Assert.Collection(options.GetProperty("allAcceptedCredentialIds").EnumerateArray(), - id => Assert.Equal(Base64Url.EncodeToString([1, 2, 3]), id.GetString()), - id => Assert.Equal(Base64Url.EncodeToString([4, 5, 6]), id.GetString())); + var optionsJson = await helper.MakePasskeySignalOptionsAsync(user, userEntity); + + Assert.Equal(expectedOptionsJson, optionsJson); + passkeyHandler.Verify(); } - [Fact] - public async Task MakePasskeySignalOptionsUsesConfiguredServerDomain() + [Theory] + [InlineData(true)] + [InlineData(false)] + public void SupportsPasskeySignalOptionsMatchesPasskeyHandler(bool supportsSignalOptions) { var user = new PocoUser { UserName = "Foo" }; - var passkeyOptions = new IdentityPasskeyOptions { ServerDomain = "fabrikam.com" }; - var manager = SetupUserManager(user, passkeyOptions: passkeyOptions); - manager.Setup(m => m.GetPasskeysAsync(user)).ReturnsAsync([]); + var passkeyHandler = new Mock>(); + passkeyHandler.Setup(h => h.SupportsSignalOptions).Returns(supportsSignalOptions); + var manager = SetupUserManager(user, passkeyHandler: passkeyHandler.Object); var context = new DefaultHttpContext(); - context.Request.Host = new HostString("contoso.com"); var helper = SetupSignInManager(manager.Object, context); - var optionsJson = await helper.MakePasskeySignalOptionsAsync(user, new() - { - Id = user.Id, - Name = "Foo", - DisplayName = "Foo", - }); - - var options = JsonSerializer.Deserialize(optionsJson); - Assert.Equal("fabrikam.com", options.GetProperty("rpId").GetString()); + Assert.Equal(supportsSignalOptions, helper.SupportsPasskeySignalOptions); } [Fact] - public async Task MakePasskeySignalOptionsWithoutPasskeysReturnsEmptyCredentialList() + public void SupportsPasskeySignalOptionsIsFalseWithoutPasskeyHandler() { var user = new PocoUser { UserName = "Foo" }; var manager = SetupUserManager(user); - manager.Setup(m => m.GetPasskeysAsync(user)).ReturnsAsync([]); var context = new DefaultHttpContext(); var helper = SetupSignInManager(manager.Object, context); - var optionsJson = await helper.MakePasskeySignalOptionsAsync(user, new() - { - Id = user.Id, - Name = "Foo", - DisplayName = "Foo", - }); - - var options = JsonSerializer.Deserialize(optionsJson); - Assert.Empty(options.GetProperty("allAcceptedCredentialIds").EnumerateArray()); + Assert.False(helper.SupportsPasskeySignalOptions); } [Fact] - public async Task MakePasskeySignalOptionsThrowsWhenUserEntityIdDoesNotMatchUser() + public async Task MakePasskeySignalOptionsThrowsWithoutPasskeyHandler() { var user = new PocoUser { UserName = "Foo" }; var manager = SetupUserManager(user); @@ -735,17 +709,14 @@ public async Task MakePasskeySignalOptionsThrowsWhenUserEntityIdDoesNotMatchUser var ex = await Assert.ThrowsAsync( () => helper.MakePasskeySignalOptionsAsync(user, new() { - Id = "some-other-id", + Id = user.Id, Name = "Foo", DisplayName = "Foo", })); - Assert.Equal($"The user entity ID 'some-other-id' does not match the ID '{user.Id}' of the specified user.", ex.Message); + Assert.Equal("This operation requires an IPasskeyHandler service to be registered.", ex.Message); } - private static UserPasskeyInfo CreatePasskey(byte[] credentialId) - => new(credentialId, [], default, 0, null, false, false, false, [], []); - private static void SetupPasskeyAuth(HttpContext context, Mock auth) { // Calling AuthenticateAsync will return a failure result diff --git a/src/Identity/test/Shared/MockHelpers.cs b/src/Identity/test/Shared/MockHelpers.cs index 08cba307b3d6..309cbfc07196 100644 --- a/src/Identity/test/Shared/MockHelpers.cs +++ b/src/Identity/test/Shared/MockHelpers.cs @@ -16,8 +16,7 @@ public static class MockHelpers public static Mock> MockUserManager( IMeterFactory meterFactory = null, - IPasskeyHandler passkeyHandler = null, - IdentityPasskeyOptions passkeyOptions = null) + IPasskeyHandler passkeyHandler = null) where TUser : class { var services = new ServiceCollection(); @@ -29,10 +28,6 @@ public static Mock> MockUserManager( { services.AddSingleton(passkeyHandler); } - if (passkeyOptions != null) - { - services.AddSingleton(Options.Create(passkeyOptions)); - } var store = new Mock>(); var mgr = new Mock>(store.Object, null, null, null, null, null, null, services.BuildServiceProvider(), null); diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Shared/PasskeySignals.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Shared/PasskeySignals.razor index e6f1222abad7..46d8e471dd51 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Shared/PasskeySignals.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Shared/PasskeySignals.razor @@ -18,7 +18,7 @@ protected override async Task OnInitializedAsync() { - if (!UserManager.SupportsUserPasskey) + if (!SignInManager.SupportsPasskeySignalOptions) { return; } From 0f8b457d33e0a721e1914327e5c545b3bb383d46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20V=C4=B1=CC=81zner?= Date: Mon, 3 Aug 2026 13:45:31 +0200 Subject: [PATCH 4/7] Pipeline fix - Add missing System.Buffers.Text using to BlazorTemplateTest --- src/Identity/Core/src/SignInManager.cs | 2966 ++++++++--------- .../BlazorTemplateTest.cs | 1 + 2 files changed, 1484 insertions(+), 1483 deletions(-) diff --git a/src/Identity/Core/src/SignInManager.cs b/src/Identity/Core/src/SignInManager.cs index 109d0f711192..846936480663 100644 --- a/src/Identity/Core/src/SignInManager.cs +++ b/src/Identity/Core/src/SignInManager.cs @@ -1,1483 +1,1483 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.Diagnostics.Metrics; -using System.Linq; -using System.Security.Claims; -using System.Text; -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; - -namespace Microsoft.AspNetCore.Identity; - -/// -/// Provides the APIs for user sign in. -/// -/// The type encapsulating a user. -public class SignInManager where TUser : class -{ - private const string LoginProviderKey = "LoginProvider"; - private const string XsrfKey = "XsrfId"; - private const string PasskeyOperationKey = "PasskeyOperation"; - private const string PasskeyStateKey = "PasskeyState"; - - private static readonly bool AlwaysResetLockoutOnSuccess = - AppContext.TryGetSwitch("Microsoft.AspNetCore.Identity.CheckPasswordSignInAlwaysResetLockoutOnSuccess", out var enabled) && enabled; - - private readonly IHttpContextAccessor _contextAccessor; - private readonly IAuthenticationSchemeProvider _schemes; - private readonly IUserConfirmation _confirmation; - private readonly IPasskeyHandler? _passkeyHandler; - private readonly SignInManagerMetrics? _metrics; - private HttpContext? _context; - private TwoFactorAuthenticationInfo? _twoFactorInfo; - private PasskeyAuthenticationInfo? _passkeyInfo; - - /// - /// Creates a new instance of . - /// - /// An instance of used to retrieve users from and persist users. - /// The accessor used to access the . - /// The factory to use to create claims principals for a user. - /// The accessor used to access the . - /// The logger used to log messages, warnings and errors. - /// The scheme provider that is used enumerate the authentication schemes. - /// The used check whether a user account is confirmed. - public SignInManager(UserManager userManager, - IHttpContextAccessor contextAccessor, - IUserClaimsPrincipalFactory claimsFactory, - IOptions optionsAccessor, - ILogger> logger, - IAuthenticationSchemeProvider schemes, - IUserConfirmation confirmation) - { - ArgumentNullException.ThrowIfNull(userManager); - ArgumentNullException.ThrowIfNull(contextAccessor); - ArgumentNullException.ThrowIfNull(claimsFactory); - - UserManager = userManager; - _contextAccessor = contextAccessor; - ClaimsFactory = claimsFactory; - Options = optionsAccessor?.Value ?? new IdentityOptions(); - Logger = logger; - _schemes = schemes; - _confirmation = confirmation; - // SignInManagerMetrics created from constructor because of difficulties registering internal type. - _metrics = userManager.ServiceProvider?.GetService() is { } factory ? new SignInManagerMetrics(factory) : null; - _passkeyHandler = userManager.ServiceProvider?.GetService>(); - } - - /// - /// Gets the used to log messages from the manager. - /// - /// - /// The used to log messages from the manager. - /// - public virtual ILogger Logger { get; set; } - - /// - /// The used. - /// - public UserManager UserManager { get; set; } - - /// - /// The used. - /// - public IUserClaimsPrincipalFactory ClaimsFactory { get; set; } - - /// - /// The used. - /// - public IdentityOptions Options { get; set; } - - /// - /// The authentication scheme to sign in with. Defaults to . - /// - public string AuthenticationScheme { get; set; } = IdentityConstants.ApplicationScheme; - - /// - /// The used. - /// - public HttpContext Context - { - get - { - var context = _context ?? _contextAccessor?.HttpContext; - if (context == null) - { - throw new InvalidOperationException("HttpContext must not be null."); - } - return context; - } - set - { - _context = value; - } - } - - /// - /// Creates a for the specified , as an asynchronous operation. - /// - /// The user to create a for. - /// The task object representing the asynchronous operation, containing the ClaimsPrincipal for the specified user. - public virtual async Task CreateUserPrincipalAsync(TUser user) => await ClaimsFactory.CreateAsync(user); - - /// - /// Returns true if the principal has an identity with the application cookie identity - /// - /// The instance. - /// True if the user is logged in with identity. - public virtual bool IsSignedIn(ClaimsPrincipal principal) - { - ArgumentNullException.ThrowIfNull(principal); - return principal.Identities != null && - principal.Identities.Any(i => i.AuthenticationType == AuthenticationScheme); - } - - /// - /// Returns a flag indicating whether the specified user can sign in. - /// - /// The user whose sign-in status should be returned. - /// - /// The task object representing the asynchronous operation, containing a flag that is true - /// if the specified user can sign-in, otherwise false. - /// - public virtual async Task CanSignInAsync(TUser user) - { - if (Options.SignIn.RequireConfirmedEmail && !(await UserManager.IsEmailConfirmedAsync(user))) - { - Logger.LogDebug(EventIds.UserCannotSignInWithoutConfirmedEmail, "User cannot sign in without a confirmed email."); - return false; - } - if (Options.SignIn.RequireConfirmedPhoneNumber && !(await UserManager.IsPhoneNumberConfirmedAsync(user))) - { - Logger.LogDebug(EventIds.UserCannotSignInWithoutConfirmedPhoneNumber, "User cannot sign in without a confirmed phone number."); - return false; - } - if (Options.SignIn.RequireConfirmedAccount && !(await _confirmation.IsConfirmedAsync(UserManager, user))) - { - Logger.LogDebug(EventIds.UserCannotSignInWithoutConfirmedAccount, "User cannot sign in without a confirmed account."); - return false; - } - return true; - } - - /// - /// Signs in the specified , whilst preserving the existing - /// AuthenticationProperties of the current signed-in user like rememberMe, as an asynchronous operation. - /// - /// The user to sign-in. - /// The task object representing the asynchronous operation. - public virtual async Task RefreshSignInAsync(TUser user) - { - var startTimestamp = Stopwatch.GetTimestamp(); - try - { - var (success, isPersistent) = await RefreshSignInCoreAsync(user); - var signInResult = success ? SignInResult.Success : SignInResult.Failed; - _metrics?.AuthenticateSignIn(typeof(TUser).FullName!, AuthenticationScheme, signInResult, SignInType.Refresh, isPersistent, startTimestamp); - } - catch (Exception ex) - { - _metrics?.AuthenticateSignIn(typeof(TUser).FullName!, AuthenticationScheme, result: null, SignInType.Refresh, isPersistent: null, startTimestamp, ex); - throw; - } - } - - private async Task<(bool success, bool? isPersistent)> RefreshSignInCoreAsync(TUser user) - { - var auth = await Context.AuthenticateAsync(AuthenticationScheme); - if (!auth.Succeeded || auth.Principal?.Identity?.IsAuthenticated != true) - { - Logger.LogError("RefreshSignInAsync prevented because the user is not currently authenticated. Use SignInAsync instead for initial sign in."); - return (false, auth.Properties?.IsPersistent); - } - - var authenticatedUserId = UserManager.GetUserId(auth.Principal); - var newUserId = await UserManager.GetUserIdAsync(user); - if (authenticatedUserId == null || authenticatedUserId != newUserId) - { - Logger.LogError("RefreshSignInAsync prevented because currently authenticated user has a different UserId. Use SignInAsync instead to change users."); - return (false, auth.Properties?.IsPersistent); - } - - IList claims = Array.Empty(); - var authenticationMethod = auth.Principal?.FindFirst(ClaimTypes.AuthenticationMethod); - var amr = auth.Principal?.FindFirst("amr"); - - if (authenticationMethod != null || amr != null) - { - claims = new List(); - if (authenticationMethod != null) - { - claims.Add(authenticationMethod); - } - if (amr != null) - { - claims.Add(amr); - } - } - - await SignInWithClaimsAsync(user, auth.Properties, claims); - return (true, auth.Properties?.IsPersistent ?? false); - } - - /// - /// Signs in the specified . - /// - /// The user to sign-in. - /// Flag indicating whether the sign-in cookie should persist after the browser is closed. - /// Name of the method used to authenticate the user. - /// The task object representing the asynchronous operation. - [SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "Required for backwards compatibility")] - public virtual Task SignInAsync(TUser user, bool isPersistent, string? authenticationMethod = null) - => SignInAsync(user, new AuthenticationProperties { IsPersistent = isPersistent }, authenticationMethod); - - /// - /// Signs in the specified . - /// - /// The user to sign-in. - /// Properties applied to the login and authentication cookie. - /// Name of the method used to authenticate the user. - /// The task object representing the asynchronous operation. - [SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "Required for backwards compatibility")] - public virtual Task SignInAsync(TUser user, AuthenticationProperties authenticationProperties, string? authenticationMethod = null) - { - IList additionalClaims = Array.Empty(); - if (authenticationMethod != null) - { - additionalClaims = new List(); - additionalClaims.Add(new Claim(ClaimTypes.AuthenticationMethod, authenticationMethod)); - } - return SignInWithClaimsAsync(user, authenticationProperties, additionalClaims); - } - - /// - /// Signs in the specified . - /// - /// The user to sign-in. - /// Flag indicating whether the sign-in cookie should persist after the browser is closed. - /// Additional claims that will be stored in the cookie. - /// The task object representing the asynchronous operation. - public virtual Task SignInWithClaimsAsync(TUser user, bool isPersistent, IEnumerable additionalClaims) - => SignInWithClaimsAsync(user, new AuthenticationProperties { IsPersistent = isPersistent }, additionalClaims); - - /// - /// Signs in the specified . - /// - /// The user to sign-in. - /// Properties applied to the login and authentication cookie. - /// Additional claims that will be stored in the cookie. - /// The task object representing the asynchronous operation. - public virtual async Task SignInWithClaimsAsync(TUser user, AuthenticationProperties? authenticationProperties, IEnumerable additionalClaims) - { - try - { - var userPrincipal = await CreateUserPrincipalAsync(user); - foreach (var claim in additionalClaims) - { - userPrincipal.Identities.First().AddClaim(claim); - } - - authenticationProperties ??= new AuthenticationProperties(); - await Context.SignInAsync(AuthenticationScheme, - userPrincipal, - authenticationProperties); - - // This is useful for updating claims immediately when hitting MapIdentityApi's /account/info endpoint with cookies. - Context.User = userPrincipal; - - _metrics?.SignInUserPrincipal(typeof(TUser).FullName!, AuthenticationScheme, authenticationProperties.IsPersistent); - } - catch (Exception ex) - { - _metrics?.SignInUserPrincipal(typeof(TUser).FullName!, AuthenticationScheme, isPersistent: null, ex); - throw; - } - } - - /// - /// Signs the current user out of the application. - /// - public virtual async Task SignOutAsync() - { - try - { - await Context.SignOutAsync(AuthenticationScheme); - - if (await _schemes.GetSchemeAsync(IdentityConstants.ExternalScheme) != null) - { - await Context.SignOutAsync(IdentityConstants.ExternalScheme); - } - if (await _schemes.GetSchemeAsync(IdentityConstants.TwoFactorUserIdScheme) != null) - { - await Context.SignOutAsync(IdentityConstants.TwoFactorUserIdScheme); - } - - _metrics?.SignOutUserPrincipal(typeof(TUser).FullName!, AuthenticationScheme); - } - catch (Exception ex) - { - _metrics?.SignOutUserPrincipal(typeof(TUser).FullName!, AuthenticationScheme, ex); - throw; - } - } - - /// - /// Validates the security stamp for the specified against - /// the persisted stamp for the current user, as an asynchronous operation. - /// - /// The principal whose stamp should be validated. - /// The task object representing the asynchronous operation. The task will contain the - /// if the stamp matches the persisted value, otherwise it will return null. - public virtual async Task ValidateSecurityStampAsync(ClaimsPrincipal? principal) - { - if (principal == null) - { - return null; - } - var user = await UserManager.GetUserAsync(principal); - if (await ValidateSecurityStampAsync(user, principal.FindFirstValue(Options.ClaimsIdentity.SecurityStampClaimType))) - { - return user; - } - Logger.LogDebug(EventIds.SecurityStampValidationFailedId4, "Failed to validate a security stamp."); - return null; - } - - /// - /// Validates the security stamp for the specified from one of - /// the two factor principals (remember client or user id) against - /// the persisted stamp for the current user, as an asynchronous operation. - /// - /// The principal whose stamp should be validated. - /// The task object representing the asynchronous operation. The task will contain the - /// if the stamp matches the persisted value, otherwise it will return null. - public virtual async Task ValidateTwoFactorSecurityStampAsync(ClaimsPrincipal? principal) - { - if (principal == null || principal.Identity?.Name == null) - { - return null; - } - var user = await UserManager.FindByIdAsync(principal.Identity.Name); - if (await ValidateSecurityStampAsync(user, principal.FindFirstValue(Options.ClaimsIdentity.SecurityStampClaimType))) - { - return user; - } - Logger.LogDebug(EventIds.TwoFactorSecurityStampValidationFailed, "Failed to validate a security stamp."); - return null; - } - - /// - /// Validates the security stamp for the specified . If no user is specified, or if the store - /// does not support security stamps, validation is considered successful. - /// - /// The user whose stamp should be validated. - /// The expected security stamp value. - /// The result of the validation. - public virtual async Task ValidateSecurityStampAsync(TUser? user, string? securityStamp) - => user != null && - // Only validate the security stamp if the store supports it - (!UserManager.SupportsUserSecurityStamp || securityStamp == await UserManager.GetSecurityStampAsync(user)); - - /// - /// Attempts to sign in the specified and combination - /// as an asynchronous operation. - /// - /// The user to sign in. - /// The password to attempt to sign in with. - /// Flag indicating whether the sign-in cookie should persist after the browser is closed. - /// Flag indicating if the user account should be locked if the sign in fails. - /// The task object representing the asynchronous operation containing the - /// for the sign-in attempt. - public virtual async Task PasswordSignInAsync(TUser user, string password, - bool isPersistent, bool lockoutOnFailure) - { - var startTimestamp = Stopwatch.GetTimestamp(); - try - { - ArgumentNullException.ThrowIfNull(user); - - var attempt = await CheckPasswordSignInAsync(user, password, lockoutOnFailure); - var result = attempt.Succeeded - ? await SignInOrTwoFactorAsync(user, isPersistent) - : attempt; - _metrics?.AuthenticateSignIn(typeof(TUser).FullName!, AuthenticationScheme, result, SignInType.Password, isPersistent, startTimestamp); - - return result; - } - catch (Exception ex) - { - _metrics?.AuthenticateSignIn(typeof(TUser).FullName!, AuthenticationScheme, result: null, SignInType.Password, isPersistent, startTimestamp, ex); - throw; - } - } - - /// - /// Attempts to sign in the specified and combination - /// as an asynchronous operation. - /// - /// The user name to sign in. - /// The password to attempt to sign in with. - /// Flag indicating whether the sign-in cookie should persist after the browser is closed. - /// Flag indicating if the user account should be locked if the sign in fails. - /// The task object representing the asynchronous operation containing the - /// for the sign-in attempt. - public virtual async Task PasswordSignInAsync(string userName, string password, - bool isPersistent, bool lockoutOnFailure) - { - var startTimestamp = Stopwatch.GetTimestamp(); - var user = await UserManager.FindByNameAsync(userName); - if (user == null) - { - _metrics?.AuthenticateSignIn(typeof(TUser).FullName!, AuthenticationScheme, SignInResult.Failed, SignInType.Password, isPersistent, startTimestamp); - return SignInResult.Failed; - } - - return await PasswordSignInAsync(user, password, isPersistent, lockoutOnFailure); - } - - /// - /// Attempts a password sign in for a user. - /// - /// The user to sign in. - /// The password to attempt to sign in with. - /// Flag indicating if the user account should be locked if the sign in fails. - /// The task object representing the asynchronous operation containing the - /// for the sign-in attempt. - public virtual async Task CheckPasswordSignInAsync(TUser user, string password, bool lockoutOnFailure) - { - try - { - ArgumentNullException.ThrowIfNull(user); - - var result = await CheckPasswordSignInCoreAsync(user, password, lockoutOnFailure); - _metrics?.CheckPasswordSignIn(typeof(TUser).FullName!, result); - - return result; - } - catch (Exception ex) - { - _metrics?.CheckPasswordSignIn(typeof(TUser).FullName!, result: null, ex); - throw; - } - } - - private async Task CheckPasswordSignInCoreAsync(TUser user, string password, bool lockoutOnFailure) - { - var error = await PreSignInCheck(user); - if (error != null) - { - return error; - } - - if (await UserManager.CheckPasswordAsync(user, password)) - { - var alwaysLockout = AlwaysResetLockoutOnSuccess; - // Only reset the lockout when not in quirks mode if either TFA is not enabled or the client is remembered for TFA. - if (alwaysLockout || !await IsTwoFactorEnabledAsync(user) || await IsTwoFactorClientRememberedAsync(user)) - { - var resetLockoutResult = await ResetLockoutWithResult(user); - if (!resetLockoutResult.Succeeded) - { - // ResetLockout got an unsuccessful result that could be caused by concurrency failures indicating an - // attacker could be trying to bypass the MaxFailedAccessAttempts limit. Return the same failure we do - // when failing to increment the lockout to avoid giving an attacker extra guesses at the password. - return SignInResult.Failed; - } - } - - return SignInResult.Success; - } - Logger.LogDebug(EventIds.InvalidPassword, "User failed to provide the correct password."); - - if (UserManager.SupportsUserLockout && lockoutOnFailure) - { - // If lockout is requested, increment access failed count which might lock out the user - var incrementLockoutResult = await UserManager.AccessFailedAsync(user) ?? IdentityResult.Success; - if (!incrementLockoutResult.Succeeded) - { - // Return the same failure we do when resetting the lockout fails after a correct password. - return SignInResult.Failed; - } - - if (await UserManager.IsLockedOutAsync(user)) - { - return await LockedOut(user); - } - } - return SignInResult.Failed; - } - - /// - /// Generates passkey creation options for the specified . - /// - /// The user entity for which to create passkey options. - /// A JSON string representing the created passkey options. - public virtual async Task MakePasskeyCreationOptionsAsync(PasskeyUserEntity userEntity) - { - ThrowIfNoPasskeyHandler(); - ArgumentNullException.ThrowIfNull(userEntity); - - var result = await _passkeyHandler.MakeCreationOptionsAsync(userEntity, Context); - await StorePasskeyAuthenticationInfoAsync(PasskeyOperations.Attestation, result.AttestationState); - return result.CreationOptionsJson; - } - - /// - /// Creates passkey assertion options for the specified . - /// - /// The user for whom to create passkey assertion options. - /// A JSON string representing the created passkey assertion options. - public virtual async Task MakePasskeyRequestOptionsAsync(TUser? user) - { - ThrowIfNoPasskeyHandler(); - - var result = await _passkeyHandler.MakeRequestOptionsAsync(user, Context); - await StorePasskeyAuthenticationInfoAsync(PasskeyOperations.Assertion, result.AssertionState); - return result.RequestOptionsJson; - } - - /// - /// Gets a value indicating whether the registered supports - /// generating passkey signal options. - /// - /// - /// Check this before calling , - /// which throws when the handler does not support signal options. - /// - public virtual bool SupportsPasskeySignalOptions => _passkeyHandler?.SupportsSignalOptions ?? false; - - /// - /// Generates the options used to signal the current state of a user's passkeys to authenticators. - /// - /// - /// - /// The returned JSON contains the arguments for both the PublicKeyCredential.signalAllAcceptedCredentials() - /// and PublicKeyCredential.signalCurrentUserDetails() JavaScript APIs, which let an authenticator - /// stop offering passkeys that were removed from the server and keep the user's details up to date. - /// - /// - /// Because these APIs reveal how many passkeys a user has, only call them when the user is authenticated. - /// The must have the same that was passed to - /// when the passkeys were created, - /// otherwise the authenticator will not recognize the user and the signal will have no effect. - /// - /// - /// See . - /// - /// - /// The user whose passkeys should be signaled. - /// The user entity associated with the user's passkeys. - /// A JSON string representing the passkey signal options. - /// - /// Thrown when the registered does not support signal options. - /// See . - /// - /// - /// The following example shows how the result is used from JavaScript. - /// - /// const { rpId, userId, allAcceptedCredentialIds, name, displayName } = signalOptions; - /// await PublicKeyCredential.signalAllAcceptedCredentials?.({ rpId, userId, allAcceptedCredentialIds }); - /// await PublicKeyCredential.signalCurrentUserDetails?.({ rpId, userId, name, displayName }); - /// - /// - public virtual async Task MakePasskeySignalOptionsAsync(TUser user, PasskeyUserEntity userEntity) - { - ThrowIfNoPasskeyHandler(); - ArgumentNullException.ThrowIfNull(user); - ArgumentNullException.ThrowIfNull(userEntity); - - var result = await _passkeyHandler.MakeSignalOptionsAsync(user, userEntity, Context); - return result.SignalOptionsJson; - } - - /// - /// Performs passkey attestation for the given . - /// - /// - /// The should be obtained by JSON-serializing the result of the - /// navigator.credentials.create() JavaScript API. The argument to navigator.credentials.create() - /// should be obtained by calling . - /// - /// The credentials obtained by JSON-serializing the result of the navigator.credentials.create() JavaScript function. - /// - /// A task object representing the asynchronous operation containing the . - /// - public virtual async Task PerformPasskeyAttestationAsync([StringSyntax(StringSyntaxAttribute.Json)] string credentialJson) - { - ThrowIfNoPasskeyHandler(); - ArgumentException.ThrowIfNullOrEmpty(credentialJson); - - var passkeyInfo = await RetrievePasskeyAuthenticationInfoAsync() - ?? throw new InvalidOperationException( - "No passkey attestation is underway. " + - $"Make sure to call '{nameof(SignInManager<>)}.{nameof(MakePasskeyCreationOptionsAsync)}()' to initiate a passkey attestation."); - if (!string.Equals(PasskeyOperations.Attestation, passkeyInfo.Operation, StringComparison.Ordinal)) - { - throw new InvalidOperationException( - $"Expected passkey operation '{PasskeyOperations.Attestation}', but got '{passkeyInfo.Operation}'. " + - $"This may indicate that you have not previously called '{nameof(SignInManager<>)}.{nameof(MakePasskeyCreationOptionsAsync)}()'."); - } - var context = new PasskeyAttestationContext - { - CredentialJson = credentialJson, - AttestationState = passkeyInfo.State, - HttpContext = Context, - }; - var result = await _passkeyHandler.PerformAttestationAsync(context); - if (!result.Succeeded) - { - Logger.LogDebug(EventIds.PasskeyAttestationFailed, "Passkey attestation failed: {message}", result.Failure.Message); - } - - return result; - } - - /// - /// Performs passkey assertion for the given . - /// - /// - /// The should be obtained by JSON-serializing the result of the - /// navigator.credentials.get() JavaScript API. The argument to navigator.credentials.get() - /// should be obtained by calling . - /// Upon success, the should be stored on the - /// using . - /// - /// The credentials obtained by JSON-serializing the result of the navigator.credentials.get() JavaScript function. - /// - /// A task object representing the asynchronous operation containing the . - /// - public virtual async Task> PerformPasskeyAssertionAsync([StringSyntax(StringSyntaxAttribute.Json)] string credentialJson) - { - ThrowIfNoPasskeyHandler(); - ArgumentException.ThrowIfNullOrEmpty(credentialJson); - - var passkeyInfo = await RetrievePasskeyAuthenticationInfoAsync() - ?? throw new InvalidOperationException( - "No passkey assertion is underway. " + - $"Make sure to call '{nameof(SignInManager<>)}.{nameof(MakePasskeyRequestOptionsAsync)}()' to initiate a passkey assertion."); - if (!string.Equals(PasskeyOperations.Assertion, passkeyInfo.Operation, StringComparison.Ordinal)) - { - throw new InvalidOperationException( - $"Expected passkey operation '{PasskeyOperations.Assertion}', but got '{passkeyInfo.Operation}'. " + - $"This may indicate that you have not previously called '{nameof(SignInManager<>)}.{nameof(MakePasskeyRequestOptionsAsync)}()'."); - } - var context = new PasskeyAssertionContext - { - CredentialJson = credentialJson, - AssertionState = passkeyInfo.State, - HttpContext = Context, - }; - var result = await _passkeyHandler.PerformAssertionAsync(context); - if (!result.Succeeded) - { - Logger.LogDebug(EventIds.PasskeyAssertionFailed, "Passkey assertion failed: {message}", result.Failure.Message); - } - - return result; - } - - /// - /// Performs a passkey assertion and attempts to sign in the user. - /// - /// - /// The should be obtained by JSON-serializing the result of the - /// navigator.credentials.get() JavaScript API. The argument to navigator.credentials.get() - /// should be obtained by calling . - /// - /// The credentials obtained by JSON-serializing the result of the navigator.credentials.get() JavaScript function. - /// - /// The task object representing the asynchronous operation containing the - /// for the sign-in attempt. - /// - public virtual async Task PasskeySignInAsync([StringSyntax(StringSyntaxAttribute.Json)] string credentialJson) - { - var startTimestamp = Stopwatch.GetTimestamp(); - try - { - var result = await PasskeySignInCoreAsync(credentialJson); - _metrics?.AuthenticateSignIn(typeof(TUser).FullName!, AuthenticationScheme, result, SignInType.Passkey, isPersistent: false, startTimestamp); - - return result; - } - catch (Exception ex) - { - _metrics?.AuthenticateSignIn(typeof(TUser).FullName!, AuthenticationScheme, result: null, SignInType.Passkey, isPersistent: false, startTimestamp, ex); - throw; - } - } - - private async Task PasskeySignInCoreAsync(string credentialJson) - { - ArgumentException.ThrowIfNullOrEmpty(credentialJson); - - var assertionResult = await PerformPasskeyAssertionAsync(credentialJson); - if (!assertionResult.Succeeded) - { - return SignInResult.Failed; - } - - var error = await PreSignInCheck(assertionResult.User); - if (error != null) - { - return error; - } - - // After a successful assertion, we need to update the passkey so that it has the latest - // sign count and authenticator data. - var setPasskeyResult = await UserManager.AddOrUpdatePasskeyAsync(assertionResult.User, assertionResult.Passkey); - if (!setPasskeyResult.Succeeded) - { - return SignInResult.Failed; - } - - return await SignInOrTwoFactorAsync(assertionResult.User, isPersistent: false, bypassTwoFactor: true); - } - - [MemberNotNull(nameof(_passkeyHandler))] - private void ThrowIfNoPasskeyHandler() - { - if (_passkeyHandler is null) - { - throw new InvalidOperationException( - $"This operation requires an {nameof(IPasskeyHandler<>)} service to be registered."); - } - } - - private async Task StorePasskeyAuthenticationInfoAsync(string operation, string? state) - { - var props = new AuthenticationProperties(); - props.Items[PasskeyOperationKey] = operation; - props.Items[PasskeyStateKey] = state; - var claimsIdentity = new ClaimsIdentity(IdentityConstants.TwoFactorUserIdScheme); - var claimsPrincipal = new ClaimsPrincipal(claimsIdentity); - await Context.SignInAsync(IdentityConstants.TwoFactorUserIdScheme, claimsPrincipal, props); - } - - private async Task RetrievePasskeyAuthenticationInfoAsync() - { - return _passkeyInfo ??= await RetrievePasskeyInfoCoreAsync(); - - async Task RetrievePasskeyInfoCoreAsync() - { - var result = await Context.AuthenticateAsync(IdentityConstants.TwoFactorUserIdScheme); - await Context.SignOutAsync(IdentityConstants.TwoFactorUserIdScheme); - - if (result.Properties is not { } properties) - { - return null; - } - - if (!properties.Items.TryGetValue(PasskeyOperationKey, out var operation) || - !properties.Items.TryGetValue(PasskeyStateKey, out var state)) - { - return null; - } - - return new() - { - Operation = operation, - State = state, - }; - } - } - - /// - /// Returns a flag indicating if the current client browser has been remembered by two factor authentication - /// for the user attempting to login, as an asynchronous operation. - /// - /// The user attempting to login. - /// - /// The task object representing the asynchronous operation containing true if the browser has been remembered - /// for the current user. - /// - public virtual async Task IsTwoFactorClientRememberedAsync(TUser user) - { - if (await _schemes.GetSchemeAsync(IdentityConstants.TwoFactorRememberMeScheme) == null) - { - return false; - } - - var userId = await UserManager.GetUserIdAsync(user); - var result = await Context.AuthenticateAsync(IdentityConstants.TwoFactorRememberMeScheme); - return (result?.Principal != null && result.Principal.FindFirstValue(ClaimTypes.Name) == userId); - } - - /// - /// Sets a flag on the browser to indicate the user has selected "Remember this browser" for two factor authentication purposes, - /// as an asynchronous operation. - /// - /// The user who choose "remember this browser". - /// The task object representing the asynchronous operation. - public virtual async Task RememberTwoFactorClientAsync(TUser user) - { - try - { - var principal = await StoreRememberClient(user); - await Context.SignInAsync(IdentityConstants.TwoFactorRememberMeScheme, - principal, - new AuthenticationProperties { IsPersistent = true }); - _metrics?.RememberTwoFactorClient(typeof(TUser).FullName!, IdentityConstants.TwoFactorRememberMeScheme); - } - catch (Exception ex) - { - _metrics?.RememberTwoFactorClient(typeof(TUser).FullName!, IdentityConstants.TwoFactorRememberMeScheme, ex); - throw; - } - } - - /// - /// Clears the "Remember this browser flag" from the current browser, as an asynchronous operation. - /// - /// The task object representing the asynchronous operation. - public virtual async Task ForgetTwoFactorClientAsync() - { - try - { - await Context.SignOutAsync(IdentityConstants.TwoFactorRememberMeScheme); - _metrics?.ForgetTwoFactorClient(typeof(TUser).FullName!, IdentityConstants.TwoFactorRememberMeScheme); - } - catch (Exception ex) - { - _metrics?.ForgetTwoFactorClient(typeof(TUser).FullName!, IdentityConstants.TwoFactorRememberMeScheme, ex); - throw; - } - } - - /// - /// Signs in the user without two factor authentication using a two factor recovery code. - /// - /// The two factor recovery code. - /// - public virtual async Task TwoFactorRecoveryCodeSignInAsync(string recoveryCode) - { - var startTimestamp = Stopwatch.GetTimestamp(); - try - { - var result = await TwoFactorRecoveryCodeSignInCoreAsync(recoveryCode); - _metrics?.AuthenticateSignIn(typeof(TUser).FullName!, AuthenticationScheme, result, SignInType.TwoFactorRecoveryCode, isPersistent: false, startTimestamp); - - return result; - } - catch (Exception ex) - { - _metrics?.AuthenticateSignIn(typeof(TUser).FullName!, AuthenticationScheme, result: null, SignInType.TwoFactorRecoveryCode, isPersistent: false, startTimestamp, ex); - throw; - } - } - - private async Task TwoFactorRecoveryCodeSignInCoreAsync(string recoveryCode) - { - var twoFactorInfo = await RetrieveTwoFactorInfoAsync(); - if (twoFactorInfo == null) - { - return SignInResult.Failed; - } - - var result = await UserManager.RedeemTwoFactorRecoveryCodeAsync(twoFactorInfo.User, recoveryCode); - if (result.Succeeded) - { - return await DoTwoFactorSignInAsync(twoFactorInfo.User, twoFactorInfo, isPersistent: false, rememberClient: false); - } - - // We don't protect against brute force attacks since codes are expected to be random. - return SignInResult.Failed; - } - - private async Task DoTwoFactorSignInAsync(TUser user, TwoFactorAuthenticationInfo twoFactorInfo, bool isPersistent, bool rememberClient) - { - var resetLockoutResult = await ResetLockoutWithResult(user); - if (!resetLockoutResult.Succeeded) - { - // ResetLockout got an unsuccessful result that could be caused by concurrency failures indicating an - // attacker could be trying to bypass the MaxFailedAccessAttempts limit. Return the same failure we do - // when failing to increment the lockout to avoid giving an attacker extra guesses at the two factor code. - return SignInResult.Failed; - } - - var claims = new List - { - new Claim("amr", "mfa") - }; - - if (twoFactorInfo.LoginProvider != null) - { - claims.Add(new Claim(ClaimTypes.AuthenticationMethod, twoFactorInfo.LoginProvider)); - } - // Cleanup external cookie - if (await _schemes.GetSchemeAsync(IdentityConstants.ExternalScheme) != null) - { - await Context.SignOutAsync(IdentityConstants.ExternalScheme); - } - // Cleanup two factor user id cookie - if (await _schemes.GetSchemeAsync(IdentityConstants.TwoFactorUserIdScheme) != null) - { - await Context.SignOutAsync(IdentityConstants.TwoFactorUserIdScheme); - if (rememberClient) - { - await RememberTwoFactorClientAsync(user); - } - } - await SignInWithClaimsAsync(user, isPersistent, claims); - return SignInResult.Success; - } - - /// - /// Validates the sign in code from an authenticator app and creates and signs in the user, as an asynchronous operation. - /// - /// The two factor authentication code to validate. - /// Flag indicating whether the sign-in cookie should persist after the browser is closed. - /// Flag indicating whether the current browser should be remember, suppressing all further - /// two factor authentication prompts. - /// The task object representing the asynchronous operation containing the - /// for the sign-in attempt. - public virtual async Task TwoFactorAuthenticatorSignInAsync(string code, bool isPersistent, bool rememberClient) - { - var startTimestamp = Stopwatch.GetTimestamp(); - try - { - var result = await TwoFactorAuthenticatorSignInCoreAsync(code, isPersistent, rememberClient); - _metrics?.AuthenticateSignIn(typeof(TUser).FullName!, AuthenticationScheme, result, SignInType.TwoFactorAuthenticator, isPersistent, startTimestamp); - - return result; - } - catch (Exception ex) - { - _metrics?.AuthenticateSignIn(typeof(TUser).FullName!, AuthenticationScheme, result: null, SignInType.TwoFactorAuthenticator, isPersistent, startTimestamp, ex); - throw; - } - } - - private async Task TwoFactorAuthenticatorSignInCoreAsync(string code, bool isPersistent, bool rememberClient) - { - var twoFactorInfo = await RetrieveTwoFactorInfoAsync(); - if (twoFactorInfo == null) - { - return SignInResult.Failed; - } - - var user = twoFactorInfo.User; - var error = await PreSignInCheck(user); - if (error != null) - { - return error; - } - - if (await UserManager.VerifyTwoFactorTokenAsync(user, Options.Tokens.AuthenticatorTokenProvider, code)) - { - return await DoTwoFactorSignInAsync(user, twoFactorInfo, isPersistent, rememberClient); - } - // If the token is incorrect, record the failure which also may cause the user to be locked out - if (UserManager.SupportsUserLockout) - { - var incrementLockoutResult = await UserManager.AccessFailedAsync(user) ?? IdentityResult.Success; - if (!incrementLockoutResult.Succeeded) - { - // Return the same failure we do when resetting the lockout fails after a correct two factor code. - // This is currently redundant, but it's here in case the code gets copied elsewhere. - return SignInResult.Failed; - } - - if (await UserManager.IsLockedOutAsync(user)) - { - return await LockedOut(user); - } - } - return SignInResult.Failed; - } - - /// - /// Validates the two factor sign in code and creates and signs in the user, as an asynchronous operation. - /// - /// The two factor authentication provider to validate the code against. - /// The two factor authentication code to validate. - /// Flag indicating whether the sign-in cookie should persist after the browser is closed. - /// Flag indicating whether the current browser should be remember, suppressing all further - /// two factor authentication prompts. - /// The task object representing the asynchronous operation containing the - /// for the sign-in attempt. - public virtual async Task TwoFactorSignInAsync(string provider, string code, bool isPersistent, bool rememberClient) - { - var startTimestamp = Stopwatch.GetTimestamp(); - try - { - var result = await TwoFactorSignInCoreAsync(provider, code, isPersistent, rememberClient); - _metrics?.AuthenticateSignIn(typeof(TUser).FullName!, AuthenticationScheme, result, SignInType.TwoFactor, isPersistent, startTimestamp); - - return result; - } - catch (Exception ex) - { - _metrics?.AuthenticateSignIn(typeof(TUser).FullName!, AuthenticationScheme, result: null, SignInType.TwoFactor, isPersistent, startTimestamp, ex); - throw; - } - } - - private async Task TwoFactorSignInCoreAsync(string provider, string code, bool isPersistent, bool rememberClient) - { - var twoFactorInfo = await RetrieveTwoFactorInfoAsync(); - if (twoFactorInfo == null) - { - return SignInResult.Failed; - } - - var user = twoFactorInfo.User; - var error = await PreSignInCheck(user); - if (error != null) - { - return error; - } - if (await UserManager.VerifyTwoFactorTokenAsync(user, provider, code)) - { - return await DoTwoFactorSignInAsync(user, twoFactorInfo, isPersistent, rememberClient); - } - // If the token is incorrect, record the failure which also may cause the user to be locked out - if (UserManager.SupportsUserLockout) - { - var incrementLockoutResult = await UserManager.AccessFailedAsync(user) ?? IdentityResult.Success; - if (!incrementLockoutResult.Succeeded) - { - // Return the same failure we do when resetting the lockout fails after a correct two factor code. - // This is currently redundant, but it's here in case the code gets copied elsewhere. - return SignInResult.Failed; - } - - if (await UserManager.IsLockedOutAsync(user)) - { - return await LockedOut(user); - } - } - return SignInResult.Failed; - } - - /// - /// Gets the for the current two factor authentication login, as an asynchronous operation. - /// - /// The task object representing the asynchronous operation containing the - /// for the sign-in attempt. - public virtual async Task GetTwoFactorAuthenticationUserAsync() - { - var info = await RetrieveTwoFactorInfoAsync(); - if (info == null) - { - return null; - } - - return info.User; - } - - /// - /// Signs in a user via a previously registered third party login, as an asynchronous operation. - /// - /// The login provider to use. - /// The unique provider identifier for the user. - /// Flag indicating whether the sign-in cookie should persist after the browser is closed. - /// The task object representing the asynchronous operation containing the - /// for the sign-in attempt. - public virtual Task ExternalLoginSignInAsync(string loginProvider, string providerKey, bool isPersistent) - => ExternalLoginSignInAsync(loginProvider, providerKey, isPersistent, bypassTwoFactor: false); - - /// - /// Signs in a user via a previously registered third party login, as an asynchronous operation. - /// - /// The login provider to use. - /// The unique provider identifier for the user. - /// Flag indicating whether the sign-in cookie should persist after the browser is closed. - /// Flag indicating whether to bypass two factor authentication. - /// The task object representing the asynchronous operation containing the - /// for the sign-in attempt. - public virtual async Task ExternalLoginSignInAsync(string loginProvider, string providerKey, bool isPersistent, bool bypassTwoFactor) - { - var startTimestamp = Stopwatch.GetTimestamp(); - try - { - var result = await ExternalLoginSignInCoreAsync(loginProvider, providerKey, isPersistent, bypassTwoFactor); - _metrics?.AuthenticateSignIn(typeof(TUser).FullName!, AuthenticationScheme, result, SignInType.External, isPersistent, startTimestamp); - - return result; - } - catch (Exception ex) - { - _metrics?.AuthenticateSignIn(typeof(TUser).FullName!, AuthenticationScheme, result: null, SignInType.External, isPersistent, startTimestamp, ex); - throw; - } - } - - private async Task ExternalLoginSignInCoreAsync(string loginProvider, string providerKey, bool isPersistent, bool bypassTwoFactor) - { - var user = await UserManager.FindByLoginAsync(loginProvider, providerKey); - if (user == null) - { - return SignInResult.Failed; - } - - var error = await PreSignInCheck(user); - if (error != null) - { - return error; - } - return await SignInOrTwoFactorAsync(user, isPersistent, loginProvider, bypassTwoFactor); - } - - /// - /// Gets a collection of s for the known external login providers. - /// - /// A collection of s for the known external login providers. - public virtual async Task> GetExternalAuthenticationSchemesAsync() - { - var schemes = await _schemes.GetAllSchemesAsync(); - return schemes.Where(s => !string.IsNullOrEmpty(s.DisplayName)); - } - - /// - /// Gets the external login information for the current login, as an asynchronous operation. - /// - /// Flag indication whether a Cross Site Request Forgery token was expected in the current request. - /// The task object representing the asynchronous operation containing the - /// for the sign-in attempt. - public virtual async Task GetExternalLoginInfoAsync(string? expectedXsrf = null) - { - var auth = await Context.AuthenticateAsync(IdentityConstants.ExternalScheme); - var items = auth?.Properties?.Items; - if (auth?.Principal == null || items == null || !items.TryGetValue(LoginProviderKey, out var provider)) - { - return null; - } - - if (expectedXsrf != null) - { - if (!items.TryGetValue(XsrfKey, out var userId) || - userId != expectedXsrf) - { - return null; - } - } - - var providerKey = auth.Principal.FindFirstValue(ClaimTypes.NameIdentifier) ?? auth.Principal.FindFirstValue("sub"); - if (providerKey == null || provider == null) - { - return null; - } - - var providerDisplayName = (await GetExternalAuthenticationSchemesAsync()).FirstOrDefault(p => p.Name == provider)?.DisplayName - ?? provider; - return new ExternalLoginInfo(auth.Principal, provider, providerKey, providerDisplayName) - { - AuthenticationTokens = auth.Properties?.GetTokens(), - AuthenticationProperties = auth.Properties - }; - } - - /// - /// Stores any authentication tokens found in the external authentication cookie into the associated user. - /// - /// The information from the external login provider. - /// The that represents the asynchronous operation, containing the of the operation. - public virtual async Task UpdateExternalAuthenticationTokensAsync(ExternalLoginInfo externalLogin) - { - ArgumentNullException.ThrowIfNull(externalLogin); - - if (externalLogin.AuthenticationTokens != null && externalLogin.AuthenticationTokens.Any()) - { - var user = await UserManager.FindByLoginAsync(externalLogin.LoginProvider, externalLogin.ProviderKey); - if (user == null) - { - return IdentityResult.Failed(); - } - - foreach (var token in externalLogin.AuthenticationTokens) - { - var result = await UserManager.SetAuthenticationTokenAsync(user, externalLogin.LoginProvider, token.Name, token.Value); - if (!result.Succeeded) - { - return result; - } - } - } - - return IdentityResult.Success; - } - - /// - /// Configures the redirect URL and user identifier for the specified external login . - /// - /// The provider to configure. - /// The external login URL users should be redirected to during the login flow. - /// The current user's identifier, which will be used to provide CSRF protection. - /// A configured . - public virtual AuthenticationProperties ConfigureExternalAuthenticationProperties(string? provider, [StringSyntax(StringSyntaxAttribute.Uri)] string? redirectUrl, string? userId = null) - { - var properties = new AuthenticationProperties { RedirectUri = redirectUrl }; - properties.Items[LoginProviderKey] = provider; - if (userId != null) - { - properties.Items[XsrfKey] = userId; - } - return properties; - } - - /// - /// Creates a claims principal for the specified 2fa information. - /// - /// The user whose is logging in via 2fa. - /// The 2fa provider. - /// A containing the user 2fa information. - internal static ClaimsPrincipal StoreTwoFactorInfo(string userId, string? loginProvider) - { - var identity = new ClaimsIdentity(IdentityConstants.TwoFactorUserIdScheme); - identity.AddClaim(new Claim(ClaimTypes.Name, userId)); - if (loginProvider != null) - { - identity.AddClaim(new Claim(ClaimTypes.AuthenticationMethod, loginProvider)); - } - return new ClaimsPrincipal(identity); - } - - internal async Task StoreRememberClient(TUser user) - { - var userId = await UserManager.GetUserIdAsync(user); - var rememberBrowserIdentity = new ClaimsIdentity(IdentityConstants.TwoFactorRememberMeScheme); - rememberBrowserIdentity.AddClaim(new Claim(ClaimTypes.Name, userId)); - if (UserManager.SupportsUserSecurityStamp) - { - var stamp = await UserManager.GetSecurityStampAsync(user); - rememberBrowserIdentity.AddClaim(new Claim(Options.ClaimsIdentity.SecurityStampClaimType, stamp)); - } - return new ClaimsPrincipal(rememberBrowserIdentity); - } - - /// - /// Check if the has two factor enabled. - /// - /// - /// - /// The task object representing the asynchronous operation containing true if the user has two factor enabled. - /// - public virtual async Task IsTwoFactorEnabledAsync(TUser user) - => UserManager.SupportsUserTwoFactor && - await UserManager.GetTwoFactorEnabledAsync(user) && - (await UserManager.GetValidTwoFactorProvidersAsync(user)).Count > 0; - - /// - /// Signs in the specified if is set to false. - /// Otherwise stores the for use after a two factor check. - /// - /// - /// Flag indicating whether the sign-in cookie should persist after the browser is closed. - /// The login provider to use. Default is null - /// Flag indicating whether to bypass two factor authentication. Default is false - /// Returns a - protected virtual async Task SignInOrTwoFactorAsync(TUser user, bool isPersistent, string? loginProvider = null, bool bypassTwoFactor = false) - { - if (!bypassTwoFactor && await IsTwoFactorEnabledAsync(user)) - { - if (!await IsTwoFactorClientRememberedAsync(user)) - { - // Allow the two-factor flow to continue later within the same request with or without a TwoFactorUserIdScheme in - // the event that the two-factor code or recovery code has already been provided as is the case for MapIdentityApi. - _twoFactorInfo = new() - { - User = user, - LoginProvider = loginProvider, - }; - - if (await _schemes.GetSchemeAsync(IdentityConstants.TwoFactorUserIdScheme) != null) - { - // Store the userId for use after two factor check - var userId = await UserManager.GetUserIdAsync(user); - await Context.SignInAsync(IdentityConstants.TwoFactorUserIdScheme, StoreTwoFactorInfo(userId, loginProvider)); - } - - return SignInResult.TwoFactorRequired; - } - } - // Cleanup external cookie - if (loginProvider != null) - { - await Context.SignOutAsync(IdentityConstants.ExternalScheme); - } - if (loginProvider == null) - { - await SignInWithClaimsAsync(user, isPersistent, new Claim[] { new Claim("amr", "pwd") }); - } - else - { - await SignInAsync(user, isPersistent, loginProvider); - } - return SignInResult.Success; - } - - private async Task RetrieveTwoFactorInfoAsync() - { - if (_twoFactorInfo != null) - { - return _twoFactorInfo; - } - - var result = await Context.AuthenticateAsync(IdentityConstants.TwoFactorUserIdScheme); - if (result?.Principal == null) - { - return null; - } - - var userId = result.Principal.FindFirstValue(ClaimTypes.Name); - if (userId == null) - { - return null; - } - - var user = await UserManager.FindByIdAsync(userId); - if (user == null) - { - return null; - } - - return new TwoFactorAuthenticationInfo - { - User = user, - LoginProvider = result.Principal.FindFirstValue(ClaimTypes.AuthenticationMethod), - }; - } - - /// - /// Used to determine if a user is considered locked out. - /// - /// The user. - /// Whether a user is considered locked out. - protected virtual async Task IsLockedOut(TUser user) - { - return UserManager.SupportsUserLockout && await UserManager.IsLockedOutAsync(user); - } - - /// - /// Returns a locked out SignInResult. - /// - /// The user. - /// A locked out SignInResult - protected virtual Task LockedOut(TUser user) - { - Logger.LogDebug(EventIds.UserLockedOut, "User is currently locked out."); - return Task.FromResult(SignInResult.LockedOut); - } - - /// - /// Used to ensure that a user is allowed to sign in. - /// - /// The user - /// Null if the user should be allowed to sign in, otherwise the SignInResult why they should be denied. - protected virtual async Task PreSignInCheck(TUser user) - { - if (!await CanSignInAsync(user)) - { - return SignInResult.NotAllowed; - } - if (await IsLockedOut(user)) - { - return await LockedOut(user); - } - return null; - } - - /// - /// Used to reset a user's lockout count. - /// - /// The user - /// The that represents the asynchronous operation, containing the of the operation. - protected virtual async Task ResetLockout(TUser user) - { - if (UserManager.SupportsUserLockout) - { - // The IdentityResult should not be null according to the annotations, but our own tests return null and I'm trying to limit breakages. - var result = await UserManager.ResetAccessFailedCountAsync(user) ?? IdentityResult.Success; - - if (!result.Succeeded) - { - throw new IdentityResultException(result); - } - } - } - - private async Task ResetLockoutWithResult(TUser user) - { - // Avoid relying on throwing an exception if we're not in a derived class. - if (GetType() == typeof(SignInManager)) - { - if (!UserManager.SupportsUserLockout) - { - return IdentityResult.Success; - } - - return await UserManager.ResetAccessFailedCountAsync(user) ?? IdentityResult.Success; - } - - try - { - var resetLockoutTask = ResetLockout(user); - - if (resetLockoutTask is Task resultTask) - { - return await resultTask ?? IdentityResult.Success; - } - - await resetLockoutTask; - return IdentityResult.Success; - } - catch (IdentityResultException ex) - { - return ex.IdentityResult; - } - } - - private sealed class IdentityResultException : Exception - { - internal IdentityResultException(IdentityResult result) : base() - { - IdentityResult = result; - } - - internal IdentityResult IdentityResult { get; set; } - - public override string Message - { - get - { - var sb = new StringBuilder("ResetLockout failed."); - - foreach (var error in IdentityResult.Errors) - { - sb.AppendLine(); - sb.Append(error.Code); - sb.Append(": "); - sb.Append(error.Description); - } - - return sb.ToString(); - } - } - } - - internal sealed class TwoFactorAuthenticationInfo - { - public required TUser User { get; init; } - public string? LoginProvider { get; init; } - } - - internal sealed class PasskeyAuthenticationInfo - { - public required string? Operation { get; init; } - public required string? State { get; init; } - - } - - private static class PasskeyOperations - { - public const string Attestation = "Attestation"; - public const string Assertion = "Assertion"; - } -} +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.Metrics; +using System.Linq; +using System.Security.Claims; +using System.Text; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Microsoft.AspNetCore.Identity; + +/// +/// Provides the APIs for user sign in. +/// +/// The type encapsulating a user. +public class SignInManager where TUser : class +{ + private const string LoginProviderKey = "LoginProvider"; + private const string XsrfKey = "XsrfId"; + private const string PasskeyOperationKey = "PasskeyOperation"; + private const string PasskeyStateKey = "PasskeyState"; + + private static readonly bool AlwaysResetLockoutOnSuccess = + AppContext.TryGetSwitch("Microsoft.AspNetCore.Identity.CheckPasswordSignInAlwaysResetLockoutOnSuccess", out var enabled) && enabled; + + private readonly IHttpContextAccessor _contextAccessor; + private readonly IAuthenticationSchemeProvider _schemes; + private readonly IUserConfirmation _confirmation; + private readonly IPasskeyHandler? _passkeyHandler; + private readonly SignInManagerMetrics? _metrics; + private HttpContext? _context; + private TwoFactorAuthenticationInfo? _twoFactorInfo; + private PasskeyAuthenticationInfo? _passkeyInfo; + + /// + /// Creates a new instance of . + /// + /// An instance of used to retrieve users from and persist users. + /// The accessor used to access the . + /// The factory to use to create claims principals for a user. + /// The accessor used to access the . + /// The logger used to log messages, warnings and errors. + /// The scheme provider that is used enumerate the authentication schemes. + /// The used check whether a user account is confirmed. + public SignInManager(UserManager userManager, + IHttpContextAccessor contextAccessor, + IUserClaimsPrincipalFactory claimsFactory, + IOptions optionsAccessor, + ILogger> logger, + IAuthenticationSchemeProvider schemes, + IUserConfirmation confirmation) + { + ArgumentNullException.ThrowIfNull(userManager); + ArgumentNullException.ThrowIfNull(contextAccessor); + ArgumentNullException.ThrowIfNull(claimsFactory); + + UserManager = userManager; + _contextAccessor = contextAccessor; + ClaimsFactory = claimsFactory; + Options = optionsAccessor?.Value ?? new IdentityOptions(); + Logger = logger; + _schemes = schemes; + _confirmation = confirmation; + // SignInManagerMetrics created from constructor because of difficulties registering internal type. + _metrics = userManager.ServiceProvider?.GetService() is { } factory ? new SignInManagerMetrics(factory) : null; + _passkeyHandler = userManager.ServiceProvider?.GetService>(); + } + + /// + /// Gets the used to log messages from the manager. + /// + /// + /// The used to log messages from the manager. + /// + public virtual ILogger Logger { get; set; } + + /// + /// The used. + /// + public UserManager UserManager { get; set; } + + /// + /// The used. + /// + public IUserClaimsPrincipalFactory ClaimsFactory { get; set; } + + /// + /// The used. + /// + public IdentityOptions Options { get; set; } + + /// + /// The authentication scheme to sign in with. Defaults to . + /// + public string AuthenticationScheme { get; set; } = IdentityConstants.ApplicationScheme; + + /// + /// The used. + /// + public HttpContext Context + { + get + { + var context = _context ?? _contextAccessor?.HttpContext; + if (context == null) + { + throw new InvalidOperationException("HttpContext must not be null."); + } + return context; + } + set + { + _context = value; + } + } + + /// + /// Creates a for the specified , as an asynchronous operation. + /// + /// The user to create a for. + /// The task object representing the asynchronous operation, containing the ClaimsPrincipal for the specified user. + public virtual async Task CreateUserPrincipalAsync(TUser user) => await ClaimsFactory.CreateAsync(user); + + /// + /// Returns true if the principal has an identity with the application cookie identity + /// + /// The instance. + /// True if the user is logged in with identity. + public virtual bool IsSignedIn(ClaimsPrincipal principal) + { + ArgumentNullException.ThrowIfNull(principal); + return principal.Identities != null && + principal.Identities.Any(i => i.AuthenticationType == AuthenticationScheme); + } + + /// + /// Returns a flag indicating whether the specified user can sign in. + /// + /// The user whose sign-in status should be returned. + /// + /// The task object representing the asynchronous operation, containing a flag that is true + /// if the specified user can sign-in, otherwise false. + /// + public virtual async Task CanSignInAsync(TUser user) + { + if (Options.SignIn.RequireConfirmedEmail && !(await UserManager.IsEmailConfirmedAsync(user))) + { + Logger.LogDebug(EventIds.UserCannotSignInWithoutConfirmedEmail, "User cannot sign in without a confirmed email."); + return false; + } + if (Options.SignIn.RequireConfirmedPhoneNumber && !(await UserManager.IsPhoneNumberConfirmedAsync(user))) + { + Logger.LogDebug(EventIds.UserCannotSignInWithoutConfirmedPhoneNumber, "User cannot sign in without a confirmed phone number."); + return false; + } + if (Options.SignIn.RequireConfirmedAccount && !(await _confirmation.IsConfirmedAsync(UserManager, user))) + { + Logger.LogDebug(EventIds.UserCannotSignInWithoutConfirmedAccount, "User cannot sign in without a confirmed account."); + return false; + } + return true; + } + + /// + /// Signs in the specified , whilst preserving the existing + /// AuthenticationProperties of the current signed-in user like rememberMe, as an asynchronous operation. + /// + /// The user to sign-in. + /// The task object representing the asynchronous operation. + public virtual async Task RefreshSignInAsync(TUser user) + { + var startTimestamp = Stopwatch.GetTimestamp(); + try + { + var (success, isPersistent) = await RefreshSignInCoreAsync(user); + var signInResult = success ? SignInResult.Success : SignInResult.Failed; + _metrics?.AuthenticateSignIn(typeof(TUser).FullName!, AuthenticationScheme, signInResult, SignInType.Refresh, isPersistent, startTimestamp); + } + catch (Exception ex) + { + _metrics?.AuthenticateSignIn(typeof(TUser).FullName!, AuthenticationScheme, result: null, SignInType.Refresh, isPersistent: null, startTimestamp, ex); + throw; + } + } + + private async Task<(bool success, bool? isPersistent)> RefreshSignInCoreAsync(TUser user) + { + var auth = await Context.AuthenticateAsync(AuthenticationScheme); + if (!auth.Succeeded || auth.Principal?.Identity?.IsAuthenticated != true) + { + Logger.LogError("RefreshSignInAsync prevented because the user is not currently authenticated. Use SignInAsync instead for initial sign in."); + return (false, auth.Properties?.IsPersistent); + } + + var authenticatedUserId = UserManager.GetUserId(auth.Principal); + var newUserId = await UserManager.GetUserIdAsync(user); + if (authenticatedUserId == null || authenticatedUserId != newUserId) + { + Logger.LogError("RefreshSignInAsync prevented because currently authenticated user has a different UserId. Use SignInAsync instead to change users."); + return (false, auth.Properties?.IsPersistent); + } + + IList claims = Array.Empty(); + var authenticationMethod = auth.Principal?.FindFirst(ClaimTypes.AuthenticationMethod); + var amr = auth.Principal?.FindFirst("amr"); + + if (authenticationMethod != null || amr != null) + { + claims = new List(); + if (authenticationMethod != null) + { + claims.Add(authenticationMethod); + } + if (amr != null) + { + claims.Add(amr); + } + } + + await SignInWithClaimsAsync(user, auth.Properties, claims); + return (true, auth.Properties?.IsPersistent ?? false); + } + + /// + /// Signs in the specified . + /// + /// The user to sign-in. + /// Flag indicating whether the sign-in cookie should persist after the browser is closed. + /// Name of the method used to authenticate the user. + /// The task object representing the asynchronous operation. + [SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "Required for backwards compatibility")] + public virtual Task SignInAsync(TUser user, bool isPersistent, string? authenticationMethod = null) + => SignInAsync(user, new AuthenticationProperties { IsPersistent = isPersistent }, authenticationMethod); + + /// + /// Signs in the specified . + /// + /// The user to sign-in. + /// Properties applied to the login and authentication cookie. + /// Name of the method used to authenticate the user. + /// The task object representing the asynchronous operation. + [SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "Required for backwards compatibility")] + public virtual Task SignInAsync(TUser user, AuthenticationProperties authenticationProperties, string? authenticationMethod = null) + { + IList additionalClaims = Array.Empty(); + if (authenticationMethod != null) + { + additionalClaims = new List(); + additionalClaims.Add(new Claim(ClaimTypes.AuthenticationMethod, authenticationMethod)); + } + return SignInWithClaimsAsync(user, authenticationProperties, additionalClaims); + } + + /// + /// Signs in the specified . + /// + /// The user to sign-in. + /// Flag indicating whether the sign-in cookie should persist after the browser is closed. + /// Additional claims that will be stored in the cookie. + /// The task object representing the asynchronous operation. + public virtual Task SignInWithClaimsAsync(TUser user, bool isPersistent, IEnumerable additionalClaims) + => SignInWithClaimsAsync(user, new AuthenticationProperties { IsPersistent = isPersistent }, additionalClaims); + + /// + /// Signs in the specified . + /// + /// The user to sign-in. + /// Properties applied to the login and authentication cookie. + /// Additional claims that will be stored in the cookie. + /// The task object representing the asynchronous operation. + public virtual async Task SignInWithClaimsAsync(TUser user, AuthenticationProperties? authenticationProperties, IEnumerable additionalClaims) + { + try + { + var userPrincipal = await CreateUserPrincipalAsync(user); + foreach (var claim in additionalClaims) + { + userPrincipal.Identities.First().AddClaim(claim); + } + + authenticationProperties ??= new AuthenticationProperties(); + await Context.SignInAsync(AuthenticationScheme, + userPrincipal, + authenticationProperties); + + // This is useful for updating claims immediately when hitting MapIdentityApi's /account/info endpoint with cookies. + Context.User = userPrincipal; + + _metrics?.SignInUserPrincipal(typeof(TUser).FullName!, AuthenticationScheme, authenticationProperties.IsPersistent); + } + catch (Exception ex) + { + _metrics?.SignInUserPrincipal(typeof(TUser).FullName!, AuthenticationScheme, isPersistent: null, ex); + throw; + } + } + + /// + /// Signs the current user out of the application. + /// + public virtual async Task SignOutAsync() + { + try + { + await Context.SignOutAsync(AuthenticationScheme); + + if (await _schemes.GetSchemeAsync(IdentityConstants.ExternalScheme) != null) + { + await Context.SignOutAsync(IdentityConstants.ExternalScheme); + } + if (await _schemes.GetSchemeAsync(IdentityConstants.TwoFactorUserIdScheme) != null) + { + await Context.SignOutAsync(IdentityConstants.TwoFactorUserIdScheme); + } + + _metrics?.SignOutUserPrincipal(typeof(TUser).FullName!, AuthenticationScheme); + } + catch (Exception ex) + { + _metrics?.SignOutUserPrincipal(typeof(TUser).FullName!, AuthenticationScheme, ex); + throw; + } + } + + /// + /// Validates the security stamp for the specified against + /// the persisted stamp for the current user, as an asynchronous operation. + /// + /// The principal whose stamp should be validated. + /// The task object representing the asynchronous operation. The task will contain the + /// if the stamp matches the persisted value, otherwise it will return null. + public virtual async Task ValidateSecurityStampAsync(ClaimsPrincipal? principal) + { + if (principal == null) + { + return null; + } + var user = await UserManager.GetUserAsync(principal); + if (await ValidateSecurityStampAsync(user, principal.FindFirstValue(Options.ClaimsIdentity.SecurityStampClaimType))) + { + return user; + } + Logger.LogDebug(EventIds.SecurityStampValidationFailedId4, "Failed to validate a security stamp."); + return null; + } + + /// + /// Validates the security stamp for the specified from one of + /// the two factor principals (remember client or user id) against + /// the persisted stamp for the current user, as an asynchronous operation. + /// + /// The principal whose stamp should be validated. + /// The task object representing the asynchronous operation. The task will contain the + /// if the stamp matches the persisted value, otherwise it will return null. + public virtual async Task ValidateTwoFactorSecurityStampAsync(ClaimsPrincipal? principal) + { + if (principal == null || principal.Identity?.Name == null) + { + return null; + } + var user = await UserManager.FindByIdAsync(principal.Identity.Name); + if (await ValidateSecurityStampAsync(user, principal.FindFirstValue(Options.ClaimsIdentity.SecurityStampClaimType))) + { + return user; + } + Logger.LogDebug(EventIds.TwoFactorSecurityStampValidationFailed, "Failed to validate a security stamp."); + return null; + } + + /// + /// Validates the security stamp for the specified . If no user is specified, or if the store + /// does not support security stamps, validation is considered successful. + /// + /// The user whose stamp should be validated. + /// The expected security stamp value. + /// The result of the validation. + public virtual async Task ValidateSecurityStampAsync(TUser? user, string? securityStamp) + => user != null && + // Only validate the security stamp if the store supports it + (!UserManager.SupportsUserSecurityStamp || securityStamp == await UserManager.GetSecurityStampAsync(user)); + + /// + /// Attempts to sign in the specified and combination + /// as an asynchronous operation. + /// + /// The user to sign in. + /// The password to attempt to sign in with. + /// Flag indicating whether the sign-in cookie should persist after the browser is closed. + /// Flag indicating if the user account should be locked if the sign in fails. + /// The task object representing the asynchronous operation containing the + /// for the sign-in attempt. + public virtual async Task PasswordSignInAsync(TUser user, string password, + bool isPersistent, bool lockoutOnFailure) + { + var startTimestamp = Stopwatch.GetTimestamp(); + try + { + ArgumentNullException.ThrowIfNull(user); + + var attempt = await CheckPasswordSignInAsync(user, password, lockoutOnFailure); + var result = attempt.Succeeded + ? await SignInOrTwoFactorAsync(user, isPersistent) + : attempt; + _metrics?.AuthenticateSignIn(typeof(TUser).FullName!, AuthenticationScheme, result, SignInType.Password, isPersistent, startTimestamp); + + return result; + } + catch (Exception ex) + { + _metrics?.AuthenticateSignIn(typeof(TUser).FullName!, AuthenticationScheme, result: null, SignInType.Password, isPersistent, startTimestamp, ex); + throw; + } + } + + /// + /// Attempts to sign in the specified and combination + /// as an asynchronous operation. + /// + /// The user name to sign in. + /// The password to attempt to sign in with. + /// Flag indicating whether the sign-in cookie should persist after the browser is closed. + /// Flag indicating if the user account should be locked if the sign in fails. + /// The task object representing the asynchronous operation containing the + /// for the sign-in attempt. + public virtual async Task PasswordSignInAsync(string userName, string password, + bool isPersistent, bool lockoutOnFailure) + { + var startTimestamp = Stopwatch.GetTimestamp(); + var user = await UserManager.FindByNameAsync(userName); + if (user == null) + { + _metrics?.AuthenticateSignIn(typeof(TUser).FullName!, AuthenticationScheme, SignInResult.Failed, SignInType.Password, isPersistent, startTimestamp); + return SignInResult.Failed; + } + + return await PasswordSignInAsync(user, password, isPersistent, lockoutOnFailure); + } + + /// + /// Attempts a password sign in for a user. + /// + /// The user to sign in. + /// The password to attempt to sign in with. + /// Flag indicating if the user account should be locked if the sign in fails. + /// The task object representing the asynchronous operation containing the + /// for the sign-in attempt. + public virtual async Task CheckPasswordSignInAsync(TUser user, string password, bool lockoutOnFailure) + { + try + { + ArgumentNullException.ThrowIfNull(user); + + var result = await CheckPasswordSignInCoreAsync(user, password, lockoutOnFailure); + _metrics?.CheckPasswordSignIn(typeof(TUser).FullName!, result); + + return result; + } + catch (Exception ex) + { + _metrics?.CheckPasswordSignIn(typeof(TUser).FullName!, result: null, ex); + throw; + } + } + + private async Task CheckPasswordSignInCoreAsync(TUser user, string password, bool lockoutOnFailure) + { + var error = await PreSignInCheck(user); + if (error != null) + { + return error; + } + + if (await UserManager.CheckPasswordAsync(user, password)) + { + var alwaysLockout = AlwaysResetLockoutOnSuccess; + // Only reset the lockout when not in quirks mode if either TFA is not enabled or the client is remembered for TFA. + if (alwaysLockout || !await IsTwoFactorEnabledAsync(user) || await IsTwoFactorClientRememberedAsync(user)) + { + var resetLockoutResult = await ResetLockoutWithResult(user); + if (!resetLockoutResult.Succeeded) + { + // ResetLockout got an unsuccessful result that could be caused by concurrency failures indicating an + // attacker could be trying to bypass the MaxFailedAccessAttempts limit. Return the same failure we do + // when failing to increment the lockout to avoid giving an attacker extra guesses at the password. + return SignInResult.Failed; + } + } + + return SignInResult.Success; + } + Logger.LogDebug(EventIds.InvalidPassword, "User failed to provide the correct password."); + + if (UserManager.SupportsUserLockout && lockoutOnFailure) + { + // If lockout is requested, increment access failed count which might lock out the user + var incrementLockoutResult = await UserManager.AccessFailedAsync(user) ?? IdentityResult.Success; + if (!incrementLockoutResult.Succeeded) + { + // Return the same failure we do when resetting the lockout fails after a correct password. + return SignInResult.Failed; + } + + if (await UserManager.IsLockedOutAsync(user)) + { + return await LockedOut(user); + } + } + return SignInResult.Failed; + } + + /// + /// Generates passkey creation options for the specified . + /// + /// The user entity for which to create passkey options. + /// A JSON string representing the created passkey options. + public virtual async Task MakePasskeyCreationOptionsAsync(PasskeyUserEntity userEntity) + { + ThrowIfNoPasskeyHandler(); + ArgumentNullException.ThrowIfNull(userEntity); + + var result = await _passkeyHandler.MakeCreationOptionsAsync(userEntity, Context); + await StorePasskeyAuthenticationInfoAsync(PasskeyOperations.Attestation, result.AttestationState); + return result.CreationOptionsJson; + } + + /// + /// Creates passkey assertion options for the specified . + /// + /// The user for whom to create passkey assertion options. + /// A JSON string representing the created passkey assertion options. + public virtual async Task MakePasskeyRequestOptionsAsync(TUser? user) + { + ThrowIfNoPasskeyHandler(); + + var result = await _passkeyHandler.MakeRequestOptionsAsync(user, Context); + await StorePasskeyAuthenticationInfoAsync(PasskeyOperations.Assertion, result.AssertionState); + return result.RequestOptionsJson; + } + + /// + /// Gets a value indicating whether the registered supports + /// generating passkey signal options. + /// + /// + /// Check this before calling , + /// which throws when the handler does not support signal options. + /// + public virtual bool SupportsPasskeySignalOptions => _passkeyHandler?.SupportsSignalOptions ?? false; + + /// + /// Generates the options used to signal the current state of a user's passkeys to authenticators. + /// + /// + /// + /// The returned JSON contains the arguments for both the PublicKeyCredential.signalAllAcceptedCredentials() + /// and PublicKeyCredential.signalCurrentUserDetails() JavaScript APIs, which let an authenticator + /// stop offering passkeys that were removed from the server and keep the user's details up to date. + /// + /// + /// Because these APIs reveal how many passkeys a user has, only call them when the user is authenticated. + /// The must have the same that was passed to + /// when the passkeys were created, + /// otherwise the authenticator will not recognize the user and the signal will have no effect. + /// + /// + /// See . + /// + /// + /// The user whose passkeys should be signaled. + /// The user entity associated with the user's passkeys. + /// A JSON string representing the passkey signal options. + /// + /// Thrown when the registered does not support signal options. + /// See . + /// + /// + /// The following example shows how the result is used from JavaScript. + /// + /// const { rpId, userId, allAcceptedCredentialIds, name, displayName } = signalOptions; + /// await PublicKeyCredential.signalAllAcceptedCredentials?.({ rpId, userId, allAcceptedCredentialIds }); + /// await PublicKeyCredential.signalCurrentUserDetails?.({ rpId, userId, name, displayName }); + /// + /// + public virtual async Task MakePasskeySignalOptionsAsync(TUser user, PasskeyUserEntity userEntity) + { + ThrowIfNoPasskeyHandler(); + ArgumentNullException.ThrowIfNull(user); + ArgumentNullException.ThrowIfNull(userEntity); + + var result = await _passkeyHandler.MakeSignalOptionsAsync(user, userEntity, Context); + return result.SignalOptionsJson; + } + + /// + /// Performs passkey attestation for the given . + /// + /// + /// The should be obtained by JSON-serializing the result of the + /// navigator.credentials.create() JavaScript API. The argument to navigator.credentials.create() + /// should be obtained by calling . + /// + /// The credentials obtained by JSON-serializing the result of the navigator.credentials.create() JavaScript function. + /// + /// A task object representing the asynchronous operation containing the . + /// + public virtual async Task PerformPasskeyAttestationAsync([StringSyntax(StringSyntaxAttribute.Json)] string credentialJson) + { + ThrowIfNoPasskeyHandler(); + ArgumentException.ThrowIfNullOrEmpty(credentialJson); + + var passkeyInfo = await RetrievePasskeyAuthenticationInfoAsync() + ?? throw new InvalidOperationException( + "No passkey attestation is underway. " + + $"Make sure to call '{nameof(SignInManager<>)}.{nameof(MakePasskeyCreationOptionsAsync)}()' to initiate a passkey attestation."); + if (!string.Equals(PasskeyOperations.Attestation, passkeyInfo.Operation, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"Expected passkey operation '{PasskeyOperations.Attestation}', but got '{passkeyInfo.Operation}'. " + + $"This may indicate that you have not previously called '{nameof(SignInManager<>)}.{nameof(MakePasskeyCreationOptionsAsync)}()'."); + } + var context = new PasskeyAttestationContext + { + CredentialJson = credentialJson, + AttestationState = passkeyInfo.State, + HttpContext = Context, + }; + var result = await _passkeyHandler.PerformAttestationAsync(context); + if (!result.Succeeded) + { + Logger.LogDebug(EventIds.PasskeyAttestationFailed, "Passkey attestation failed: {message}", result.Failure.Message); + } + + return result; + } + + /// + /// Performs passkey assertion for the given . + /// + /// + /// The should be obtained by JSON-serializing the result of the + /// navigator.credentials.get() JavaScript API. The argument to navigator.credentials.get() + /// should be obtained by calling . + /// Upon success, the should be stored on the + /// using . + /// + /// The credentials obtained by JSON-serializing the result of the navigator.credentials.get() JavaScript function. + /// + /// A task object representing the asynchronous operation containing the . + /// + public virtual async Task> PerformPasskeyAssertionAsync([StringSyntax(StringSyntaxAttribute.Json)] string credentialJson) + { + ThrowIfNoPasskeyHandler(); + ArgumentException.ThrowIfNullOrEmpty(credentialJson); + + var passkeyInfo = await RetrievePasskeyAuthenticationInfoAsync() + ?? throw new InvalidOperationException( + "No passkey assertion is underway. " + + $"Make sure to call '{nameof(SignInManager<>)}.{nameof(MakePasskeyRequestOptionsAsync)}()' to initiate a passkey assertion."); + if (!string.Equals(PasskeyOperations.Assertion, passkeyInfo.Operation, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"Expected passkey operation '{PasskeyOperations.Assertion}', but got '{passkeyInfo.Operation}'. " + + $"This may indicate that you have not previously called '{nameof(SignInManager<>)}.{nameof(MakePasskeyRequestOptionsAsync)}()'."); + } + var context = new PasskeyAssertionContext + { + CredentialJson = credentialJson, + AssertionState = passkeyInfo.State, + HttpContext = Context, + }; + var result = await _passkeyHandler.PerformAssertionAsync(context); + if (!result.Succeeded) + { + Logger.LogDebug(EventIds.PasskeyAssertionFailed, "Passkey assertion failed: {message}", result.Failure.Message); + } + + return result; + } + + /// + /// Performs a passkey assertion and attempts to sign in the user. + /// + /// + /// The should be obtained by JSON-serializing the result of the + /// navigator.credentials.get() JavaScript API. The argument to navigator.credentials.get() + /// should be obtained by calling . + /// + /// The credentials obtained by JSON-serializing the result of the navigator.credentials.get() JavaScript function. + /// + /// The task object representing the asynchronous operation containing the + /// for the sign-in attempt. + /// + public virtual async Task PasskeySignInAsync([StringSyntax(StringSyntaxAttribute.Json)] string credentialJson) + { + var startTimestamp = Stopwatch.GetTimestamp(); + try + { + var result = await PasskeySignInCoreAsync(credentialJson); + _metrics?.AuthenticateSignIn(typeof(TUser).FullName!, AuthenticationScheme, result, SignInType.Passkey, isPersistent: false, startTimestamp); + + return result; + } + catch (Exception ex) + { + _metrics?.AuthenticateSignIn(typeof(TUser).FullName!, AuthenticationScheme, result: null, SignInType.Passkey, isPersistent: false, startTimestamp, ex); + throw; + } + } + + private async Task PasskeySignInCoreAsync(string credentialJson) + { + ArgumentException.ThrowIfNullOrEmpty(credentialJson); + + var assertionResult = await PerformPasskeyAssertionAsync(credentialJson); + if (!assertionResult.Succeeded) + { + return SignInResult.Failed; + } + + var error = await PreSignInCheck(assertionResult.User); + if (error != null) + { + return error; + } + + // After a successful assertion, we need to update the passkey so that it has the latest + // sign count and authenticator data. + var setPasskeyResult = await UserManager.AddOrUpdatePasskeyAsync(assertionResult.User, assertionResult.Passkey); + if (!setPasskeyResult.Succeeded) + { + return SignInResult.Failed; + } + + return await SignInOrTwoFactorAsync(assertionResult.User, isPersistent: false, bypassTwoFactor: true); + } + + [MemberNotNull(nameof(_passkeyHandler))] + private void ThrowIfNoPasskeyHandler() + { + if (_passkeyHandler is null) + { + throw new InvalidOperationException( + $"This operation requires an {nameof(IPasskeyHandler<>)} service to be registered."); + } + } + + private async Task StorePasskeyAuthenticationInfoAsync(string operation, string? state) + { + var props = new AuthenticationProperties(); + props.Items[PasskeyOperationKey] = operation; + props.Items[PasskeyStateKey] = state; + var claimsIdentity = new ClaimsIdentity(IdentityConstants.TwoFactorUserIdScheme); + var claimsPrincipal = new ClaimsPrincipal(claimsIdentity); + await Context.SignInAsync(IdentityConstants.TwoFactorUserIdScheme, claimsPrincipal, props); + } + + private async Task RetrievePasskeyAuthenticationInfoAsync() + { + return _passkeyInfo ??= await RetrievePasskeyInfoCoreAsync(); + + async Task RetrievePasskeyInfoCoreAsync() + { + var result = await Context.AuthenticateAsync(IdentityConstants.TwoFactorUserIdScheme); + await Context.SignOutAsync(IdentityConstants.TwoFactorUserIdScheme); + + if (result.Properties is not { } properties) + { + return null; + } + + if (!properties.Items.TryGetValue(PasskeyOperationKey, out var operation) || + !properties.Items.TryGetValue(PasskeyStateKey, out var state)) + { + return null; + } + + return new() + { + Operation = operation, + State = state, + }; + } + } + + /// + /// Returns a flag indicating if the current client browser has been remembered by two factor authentication + /// for the user attempting to login, as an asynchronous operation. + /// + /// The user attempting to login. + /// + /// The task object representing the asynchronous operation containing true if the browser has been remembered + /// for the current user. + /// + public virtual async Task IsTwoFactorClientRememberedAsync(TUser user) + { + if (await _schemes.GetSchemeAsync(IdentityConstants.TwoFactorRememberMeScheme) == null) + { + return false; + } + + var userId = await UserManager.GetUserIdAsync(user); + var result = await Context.AuthenticateAsync(IdentityConstants.TwoFactorRememberMeScheme); + return (result?.Principal != null && result.Principal.FindFirstValue(ClaimTypes.Name) == userId); + } + + /// + /// Sets a flag on the browser to indicate the user has selected "Remember this browser" for two factor authentication purposes, + /// as an asynchronous operation. + /// + /// The user who choose "remember this browser". + /// The task object representing the asynchronous operation. + public virtual async Task RememberTwoFactorClientAsync(TUser user) + { + try + { + var principal = await StoreRememberClient(user); + await Context.SignInAsync(IdentityConstants.TwoFactorRememberMeScheme, + principal, + new AuthenticationProperties { IsPersistent = true }); + _metrics?.RememberTwoFactorClient(typeof(TUser).FullName!, IdentityConstants.TwoFactorRememberMeScheme); + } + catch (Exception ex) + { + _metrics?.RememberTwoFactorClient(typeof(TUser).FullName!, IdentityConstants.TwoFactorRememberMeScheme, ex); + throw; + } + } + + /// + /// Clears the "Remember this browser flag" from the current browser, as an asynchronous operation. + /// + /// The task object representing the asynchronous operation. + public virtual async Task ForgetTwoFactorClientAsync() + { + try + { + await Context.SignOutAsync(IdentityConstants.TwoFactorRememberMeScheme); + _metrics?.ForgetTwoFactorClient(typeof(TUser).FullName!, IdentityConstants.TwoFactorRememberMeScheme); + } + catch (Exception ex) + { + _metrics?.ForgetTwoFactorClient(typeof(TUser).FullName!, IdentityConstants.TwoFactorRememberMeScheme, ex); + throw; + } + } + + /// + /// Signs in the user without two factor authentication using a two factor recovery code. + /// + /// The two factor recovery code. + /// + public virtual async Task TwoFactorRecoveryCodeSignInAsync(string recoveryCode) + { + var startTimestamp = Stopwatch.GetTimestamp(); + try + { + var result = await TwoFactorRecoveryCodeSignInCoreAsync(recoveryCode); + _metrics?.AuthenticateSignIn(typeof(TUser).FullName!, AuthenticationScheme, result, SignInType.TwoFactorRecoveryCode, isPersistent: false, startTimestamp); + + return result; + } + catch (Exception ex) + { + _metrics?.AuthenticateSignIn(typeof(TUser).FullName!, AuthenticationScheme, result: null, SignInType.TwoFactorRecoveryCode, isPersistent: false, startTimestamp, ex); + throw; + } + } + + private async Task TwoFactorRecoveryCodeSignInCoreAsync(string recoveryCode) + { + var twoFactorInfo = await RetrieveTwoFactorInfoAsync(); + if (twoFactorInfo == null) + { + return SignInResult.Failed; + } + + var result = await UserManager.RedeemTwoFactorRecoveryCodeAsync(twoFactorInfo.User, recoveryCode); + if (result.Succeeded) + { + return await DoTwoFactorSignInAsync(twoFactorInfo.User, twoFactorInfo, isPersistent: false, rememberClient: false); + } + + // We don't protect against brute force attacks since codes are expected to be random. + return SignInResult.Failed; + } + + private async Task DoTwoFactorSignInAsync(TUser user, TwoFactorAuthenticationInfo twoFactorInfo, bool isPersistent, bool rememberClient) + { + var resetLockoutResult = await ResetLockoutWithResult(user); + if (!resetLockoutResult.Succeeded) + { + // ResetLockout got an unsuccessful result that could be caused by concurrency failures indicating an + // attacker could be trying to bypass the MaxFailedAccessAttempts limit. Return the same failure we do + // when failing to increment the lockout to avoid giving an attacker extra guesses at the two factor code. + return SignInResult.Failed; + } + + var claims = new List + { + new Claim("amr", "mfa") + }; + + if (twoFactorInfo.LoginProvider != null) + { + claims.Add(new Claim(ClaimTypes.AuthenticationMethod, twoFactorInfo.LoginProvider)); + } + // Cleanup external cookie + if (await _schemes.GetSchemeAsync(IdentityConstants.ExternalScheme) != null) + { + await Context.SignOutAsync(IdentityConstants.ExternalScheme); + } + // Cleanup two factor user id cookie + if (await _schemes.GetSchemeAsync(IdentityConstants.TwoFactorUserIdScheme) != null) + { + await Context.SignOutAsync(IdentityConstants.TwoFactorUserIdScheme); + if (rememberClient) + { + await RememberTwoFactorClientAsync(user); + } + } + await SignInWithClaimsAsync(user, isPersistent, claims); + return SignInResult.Success; + } + + /// + /// Validates the sign in code from an authenticator app and creates and signs in the user, as an asynchronous operation. + /// + /// The two factor authentication code to validate. + /// Flag indicating whether the sign-in cookie should persist after the browser is closed. + /// Flag indicating whether the current browser should be remember, suppressing all further + /// two factor authentication prompts. + /// The task object representing the asynchronous operation containing the + /// for the sign-in attempt. + public virtual async Task TwoFactorAuthenticatorSignInAsync(string code, bool isPersistent, bool rememberClient) + { + var startTimestamp = Stopwatch.GetTimestamp(); + try + { + var result = await TwoFactorAuthenticatorSignInCoreAsync(code, isPersistent, rememberClient); + _metrics?.AuthenticateSignIn(typeof(TUser).FullName!, AuthenticationScheme, result, SignInType.TwoFactorAuthenticator, isPersistent, startTimestamp); + + return result; + } + catch (Exception ex) + { + _metrics?.AuthenticateSignIn(typeof(TUser).FullName!, AuthenticationScheme, result: null, SignInType.TwoFactorAuthenticator, isPersistent, startTimestamp, ex); + throw; + } + } + + private async Task TwoFactorAuthenticatorSignInCoreAsync(string code, bool isPersistent, bool rememberClient) + { + var twoFactorInfo = await RetrieveTwoFactorInfoAsync(); + if (twoFactorInfo == null) + { + return SignInResult.Failed; + } + + var user = twoFactorInfo.User; + var error = await PreSignInCheck(user); + if (error != null) + { + return error; + } + + if (await UserManager.VerifyTwoFactorTokenAsync(user, Options.Tokens.AuthenticatorTokenProvider, code)) + { + return await DoTwoFactorSignInAsync(user, twoFactorInfo, isPersistent, rememberClient); + } + // If the token is incorrect, record the failure which also may cause the user to be locked out + if (UserManager.SupportsUserLockout) + { + var incrementLockoutResult = await UserManager.AccessFailedAsync(user) ?? IdentityResult.Success; + if (!incrementLockoutResult.Succeeded) + { + // Return the same failure we do when resetting the lockout fails after a correct two factor code. + // This is currently redundant, but it's here in case the code gets copied elsewhere. + return SignInResult.Failed; + } + + if (await UserManager.IsLockedOutAsync(user)) + { + return await LockedOut(user); + } + } + return SignInResult.Failed; + } + + /// + /// Validates the two factor sign in code and creates and signs in the user, as an asynchronous operation. + /// + /// The two factor authentication provider to validate the code against. + /// The two factor authentication code to validate. + /// Flag indicating whether the sign-in cookie should persist after the browser is closed. + /// Flag indicating whether the current browser should be remember, suppressing all further + /// two factor authentication prompts. + /// The task object representing the asynchronous operation containing the + /// for the sign-in attempt. + public virtual async Task TwoFactorSignInAsync(string provider, string code, bool isPersistent, bool rememberClient) + { + var startTimestamp = Stopwatch.GetTimestamp(); + try + { + var result = await TwoFactorSignInCoreAsync(provider, code, isPersistent, rememberClient); + _metrics?.AuthenticateSignIn(typeof(TUser).FullName!, AuthenticationScheme, result, SignInType.TwoFactor, isPersistent, startTimestamp); + + return result; + } + catch (Exception ex) + { + _metrics?.AuthenticateSignIn(typeof(TUser).FullName!, AuthenticationScheme, result: null, SignInType.TwoFactor, isPersistent, startTimestamp, ex); + throw; + } + } + + private async Task TwoFactorSignInCoreAsync(string provider, string code, bool isPersistent, bool rememberClient) + { + var twoFactorInfo = await RetrieveTwoFactorInfoAsync(); + if (twoFactorInfo == null) + { + return SignInResult.Failed; + } + + var user = twoFactorInfo.User; + var error = await PreSignInCheck(user); + if (error != null) + { + return error; + } + if (await UserManager.VerifyTwoFactorTokenAsync(user, provider, code)) + { + return await DoTwoFactorSignInAsync(user, twoFactorInfo, isPersistent, rememberClient); + } + // If the token is incorrect, record the failure which also may cause the user to be locked out + if (UserManager.SupportsUserLockout) + { + var incrementLockoutResult = await UserManager.AccessFailedAsync(user) ?? IdentityResult.Success; + if (!incrementLockoutResult.Succeeded) + { + // Return the same failure we do when resetting the lockout fails after a correct two factor code. + // This is currently redundant, but it's here in case the code gets copied elsewhere. + return SignInResult.Failed; + } + + if (await UserManager.IsLockedOutAsync(user)) + { + return await LockedOut(user); + } + } + return SignInResult.Failed; + } + + /// + /// Gets the for the current two factor authentication login, as an asynchronous operation. + /// + /// The task object representing the asynchronous operation containing the + /// for the sign-in attempt. + public virtual async Task GetTwoFactorAuthenticationUserAsync() + { + var info = await RetrieveTwoFactorInfoAsync(); + if (info == null) + { + return null; + } + + return info.User; + } + + /// + /// Signs in a user via a previously registered third party login, as an asynchronous operation. + /// + /// The login provider to use. + /// The unique provider identifier for the user. + /// Flag indicating whether the sign-in cookie should persist after the browser is closed. + /// The task object representing the asynchronous operation containing the + /// for the sign-in attempt. + public virtual Task ExternalLoginSignInAsync(string loginProvider, string providerKey, bool isPersistent) + => ExternalLoginSignInAsync(loginProvider, providerKey, isPersistent, bypassTwoFactor: false); + + /// + /// Signs in a user via a previously registered third party login, as an asynchronous operation. + /// + /// The login provider to use. + /// The unique provider identifier for the user. + /// Flag indicating whether the sign-in cookie should persist after the browser is closed. + /// Flag indicating whether to bypass two factor authentication. + /// The task object representing the asynchronous operation containing the + /// for the sign-in attempt. + public virtual async Task ExternalLoginSignInAsync(string loginProvider, string providerKey, bool isPersistent, bool bypassTwoFactor) + { + var startTimestamp = Stopwatch.GetTimestamp(); + try + { + var result = await ExternalLoginSignInCoreAsync(loginProvider, providerKey, isPersistent, bypassTwoFactor); + _metrics?.AuthenticateSignIn(typeof(TUser).FullName!, AuthenticationScheme, result, SignInType.External, isPersistent, startTimestamp); + + return result; + } + catch (Exception ex) + { + _metrics?.AuthenticateSignIn(typeof(TUser).FullName!, AuthenticationScheme, result: null, SignInType.External, isPersistent, startTimestamp, ex); + throw; + } + } + + private async Task ExternalLoginSignInCoreAsync(string loginProvider, string providerKey, bool isPersistent, bool bypassTwoFactor) + { + var user = await UserManager.FindByLoginAsync(loginProvider, providerKey); + if (user == null) + { + return SignInResult.Failed; + } + + var error = await PreSignInCheck(user); + if (error != null) + { + return error; + } + return await SignInOrTwoFactorAsync(user, isPersistent, loginProvider, bypassTwoFactor); + } + + /// + /// Gets a collection of s for the known external login providers. + /// + /// A collection of s for the known external login providers. + public virtual async Task> GetExternalAuthenticationSchemesAsync() + { + var schemes = await _schemes.GetAllSchemesAsync(); + return schemes.Where(s => !string.IsNullOrEmpty(s.DisplayName)); + } + + /// + /// Gets the external login information for the current login, as an asynchronous operation. + /// + /// Flag indication whether a Cross Site Request Forgery token was expected in the current request. + /// The task object representing the asynchronous operation containing the + /// for the sign-in attempt. + public virtual async Task GetExternalLoginInfoAsync(string? expectedXsrf = null) + { + var auth = await Context.AuthenticateAsync(IdentityConstants.ExternalScheme); + var items = auth?.Properties?.Items; + if (auth?.Principal == null || items == null || !items.TryGetValue(LoginProviderKey, out var provider)) + { + return null; + } + + if (expectedXsrf != null) + { + if (!items.TryGetValue(XsrfKey, out var userId) || + userId != expectedXsrf) + { + return null; + } + } + + var providerKey = auth.Principal.FindFirstValue(ClaimTypes.NameIdentifier) ?? auth.Principal.FindFirstValue("sub"); + if (providerKey == null || provider == null) + { + return null; + } + + var providerDisplayName = (await GetExternalAuthenticationSchemesAsync()).FirstOrDefault(p => p.Name == provider)?.DisplayName + ?? provider; + return new ExternalLoginInfo(auth.Principal, provider, providerKey, providerDisplayName) + { + AuthenticationTokens = auth.Properties?.GetTokens(), + AuthenticationProperties = auth.Properties + }; + } + + /// + /// Stores any authentication tokens found in the external authentication cookie into the associated user. + /// + /// The information from the external login provider. + /// The that represents the asynchronous operation, containing the of the operation. + public virtual async Task UpdateExternalAuthenticationTokensAsync(ExternalLoginInfo externalLogin) + { + ArgumentNullException.ThrowIfNull(externalLogin); + + if (externalLogin.AuthenticationTokens != null && externalLogin.AuthenticationTokens.Any()) + { + var user = await UserManager.FindByLoginAsync(externalLogin.LoginProvider, externalLogin.ProviderKey); + if (user == null) + { + return IdentityResult.Failed(); + } + + foreach (var token in externalLogin.AuthenticationTokens) + { + var result = await UserManager.SetAuthenticationTokenAsync(user, externalLogin.LoginProvider, token.Name, token.Value); + if (!result.Succeeded) + { + return result; + } + } + } + + return IdentityResult.Success; + } + + /// + /// Configures the redirect URL and user identifier for the specified external login . + /// + /// The provider to configure. + /// The external login URL users should be redirected to during the login flow. + /// The current user's identifier, which will be used to provide CSRF protection. + /// A configured . + public virtual AuthenticationProperties ConfigureExternalAuthenticationProperties(string? provider, [StringSyntax(StringSyntaxAttribute.Uri)] string? redirectUrl, string? userId = null) + { + var properties = new AuthenticationProperties { RedirectUri = redirectUrl }; + properties.Items[LoginProviderKey] = provider; + if (userId != null) + { + properties.Items[XsrfKey] = userId; + } + return properties; + } + + /// + /// Creates a claims principal for the specified 2fa information. + /// + /// The user whose is logging in via 2fa. + /// The 2fa provider. + /// A containing the user 2fa information. + internal static ClaimsPrincipal StoreTwoFactorInfo(string userId, string? loginProvider) + { + var identity = new ClaimsIdentity(IdentityConstants.TwoFactorUserIdScheme); + identity.AddClaim(new Claim(ClaimTypes.Name, userId)); + if (loginProvider != null) + { + identity.AddClaim(new Claim(ClaimTypes.AuthenticationMethod, loginProvider)); + } + return new ClaimsPrincipal(identity); + } + + internal async Task StoreRememberClient(TUser user) + { + var userId = await UserManager.GetUserIdAsync(user); + var rememberBrowserIdentity = new ClaimsIdentity(IdentityConstants.TwoFactorRememberMeScheme); + rememberBrowserIdentity.AddClaim(new Claim(ClaimTypes.Name, userId)); + if (UserManager.SupportsUserSecurityStamp) + { + var stamp = await UserManager.GetSecurityStampAsync(user); + rememberBrowserIdentity.AddClaim(new Claim(Options.ClaimsIdentity.SecurityStampClaimType, stamp)); + } + return new ClaimsPrincipal(rememberBrowserIdentity); + } + + /// + /// Check if the has two factor enabled. + /// + /// + /// + /// The task object representing the asynchronous operation containing true if the user has two factor enabled. + /// + public virtual async Task IsTwoFactorEnabledAsync(TUser user) + => UserManager.SupportsUserTwoFactor && + await UserManager.GetTwoFactorEnabledAsync(user) && + (await UserManager.GetValidTwoFactorProvidersAsync(user)).Count > 0; + + /// + /// Signs in the specified if is set to false. + /// Otherwise stores the for use after a two factor check. + /// + /// + /// Flag indicating whether the sign-in cookie should persist after the browser is closed. + /// The login provider to use. Default is null + /// Flag indicating whether to bypass two factor authentication. Default is false + /// Returns a + protected virtual async Task SignInOrTwoFactorAsync(TUser user, bool isPersistent, string? loginProvider = null, bool bypassTwoFactor = false) + { + if (!bypassTwoFactor && await IsTwoFactorEnabledAsync(user)) + { + if (!await IsTwoFactorClientRememberedAsync(user)) + { + // Allow the two-factor flow to continue later within the same request with or without a TwoFactorUserIdScheme in + // the event that the two-factor code or recovery code has already been provided as is the case for MapIdentityApi. + _twoFactorInfo = new() + { + User = user, + LoginProvider = loginProvider, + }; + + if (await _schemes.GetSchemeAsync(IdentityConstants.TwoFactorUserIdScheme) != null) + { + // Store the userId for use after two factor check + var userId = await UserManager.GetUserIdAsync(user); + await Context.SignInAsync(IdentityConstants.TwoFactorUserIdScheme, StoreTwoFactorInfo(userId, loginProvider)); + } + + return SignInResult.TwoFactorRequired; + } + } + // Cleanup external cookie + if (loginProvider != null) + { + await Context.SignOutAsync(IdentityConstants.ExternalScheme); + } + if (loginProvider == null) + { + await SignInWithClaimsAsync(user, isPersistent, new Claim[] { new Claim("amr", "pwd") }); + } + else + { + await SignInAsync(user, isPersistent, loginProvider); + } + return SignInResult.Success; + } + + private async Task RetrieveTwoFactorInfoAsync() + { + if (_twoFactorInfo != null) + { + return _twoFactorInfo; + } + + var result = await Context.AuthenticateAsync(IdentityConstants.TwoFactorUserIdScheme); + if (result?.Principal == null) + { + return null; + } + + var userId = result.Principal.FindFirstValue(ClaimTypes.Name); + if (userId == null) + { + return null; + } + + var user = await UserManager.FindByIdAsync(userId); + if (user == null) + { + return null; + } + + return new TwoFactorAuthenticationInfo + { + User = user, + LoginProvider = result.Principal.FindFirstValue(ClaimTypes.AuthenticationMethod), + }; + } + + /// + /// Used to determine if a user is considered locked out. + /// + /// The user. + /// Whether a user is considered locked out. + protected virtual async Task IsLockedOut(TUser user) + { + return UserManager.SupportsUserLockout && await UserManager.IsLockedOutAsync(user); + } + + /// + /// Returns a locked out SignInResult. + /// + /// The user. + /// A locked out SignInResult + protected virtual Task LockedOut(TUser user) + { + Logger.LogDebug(EventIds.UserLockedOut, "User is currently locked out."); + return Task.FromResult(SignInResult.LockedOut); + } + + /// + /// Used to ensure that a user is allowed to sign in. + /// + /// The user + /// Null if the user should be allowed to sign in, otherwise the SignInResult why they should be denied. + protected virtual async Task PreSignInCheck(TUser user) + { + if (!await CanSignInAsync(user)) + { + return SignInResult.NotAllowed; + } + if (await IsLockedOut(user)) + { + return await LockedOut(user); + } + return null; + } + + /// + /// Used to reset a user's lockout count. + /// + /// The user + /// The that represents the asynchronous operation, containing the of the operation. + protected virtual async Task ResetLockout(TUser user) + { + if (UserManager.SupportsUserLockout) + { + // The IdentityResult should not be null according to the annotations, but our own tests return null and I'm trying to limit breakages. + var result = await UserManager.ResetAccessFailedCountAsync(user) ?? IdentityResult.Success; + + if (!result.Succeeded) + { + throw new IdentityResultException(result); + } + } + } + + private async Task ResetLockoutWithResult(TUser user) + { + // Avoid relying on throwing an exception if we're not in a derived class. + if (GetType() == typeof(SignInManager)) + { + if (!UserManager.SupportsUserLockout) + { + return IdentityResult.Success; + } + + return await UserManager.ResetAccessFailedCountAsync(user) ?? IdentityResult.Success; + } + + try + { + var resetLockoutTask = ResetLockout(user); + + if (resetLockoutTask is Task resultTask) + { + return await resultTask ?? IdentityResult.Success; + } + + await resetLockoutTask; + return IdentityResult.Success; + } + catch (IdentityResultException ex) + { + return ex.IdentityResult; + } + } + + private sealed class IdentityResultException : Exception + { + internal IdentityResultException(IdentityResult result) : base() + { + IdentityResult = result; + } + + internal IdentityResult IdentityResult { get; set; } + + public override string Message + { + get + { + var sb = new StringBuilder("ResetLockout failed."); + + foreach (var error in IdentityResult.Errors) + { + sb.AppendLine(); + sb.Append(error.Code); + sb.Append(": "); + sb.Append(error.Description); + } + + return sb.ToString(); + } + } + } + + internal sealed class TwoFactorAuthenticationInfo + { + public required TUser User { get; init; } + public string? LoginProvider { get; init; } + } + + internal sealed class PasskeyAuthenticationInfo + { + public required string? Operation { get; init; } + public required string? State { get; init; } + + } + + private static class PasskeyOperations + { + public const string Attestation = "Attestation"; + public const string Assertion = "Assertion"; + } +} diff --git a/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorTemplateTest.cs b/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorTemplateTest.cs index ddaa88b2199f..fd15e3565f29 100644 --- a/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorTemplateTest.cs +++ b/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorTemplateTest.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Buffers.Text; using System.Runtime.InteropServices; using System.Text.Json; using Microsoft.AspNetCore.BrowserTesting; From de393b0bffde50b8dbf827c54639c2061b21e2e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20V=C4=B1=CC=81zner?= Date: Thu, 13 Aug 2026 16:35:34 +0200 Subject: [PATCH 5/7] Rename known passkeys signal APIs --- src/Identity/Core/src/IPasskeyHandler.cs | 18 +++++++------- .../Core/src/IdentityJsonSerializerContext.cs | 2 +- ...cs => KnownPasskeysSignalOptionsResult.cs} | 6 ++--- src/Identity/Core/src/PasskeyHandler.cs | 10 ++++---- ...tions.cs => KnownPasskeysSignalOptions.cs} | 4 ++-- src/Identity/Core/src/PublicAPI.Unshipped.txt | 20 ++++++++-------- src/Identity/Core/src/SignInManager.cs | 20 ++++++++-------- .../Passkeys/PasskeyHandlerSignalTest.cs | 24 +++++++++---------- .../test/Identity.Test/SignInManagerTest.cs | 22 ++++++++--------- .../Account/Shared/PasskeySignals.razor | 4 ++-- 10 files changed, 65 insertions(+), 65 deletions(-) rename src/Identity/Core/src/{PasskeySignalOptionsResult.cs => KnownPasskeysSignalOptionsResult.cs} (75%) rename src/Identity/Core/src/Passkeys/{PasskeySignalOptions.cs => KnownPasskeysSignalOptions.cs} (92%) diff --git a/src/Identity/Core/src/IPasskeyHandler.cs b/src/Identity/Core/src/IPasskeyHandler.cs index 0f4b2a1a4594..ca0538b042cc 100644 --- a/src/Identity/Core/src/IPasskeyHandler.cs +++ b/src/Identity/Core/src/IPasskeyHandler.cs @@ -13,14 +13,14 @@ public interface IPasskeyHandler where TUser : class { /// - /// Gets a value indicating whether this handler supports generating passkey signal options. + /// Gets a value indicating whether this handler supports generating known passkeys signal options. /// /// /// Returns unless the handler implements - /// and can retrieve + /// and can retrieve /// the user's passkeys. /// - bool SupportsSignalOptions => false; + bool SupportsKnownPasskeysSignalOptions => false; /// /// Generates passkey creation options for the specified user entity and HTTP context. @@ -39,19 +39,19 @@ public interface IPasskeyHandler Task MakeRequestOptionsAsync(TUser? user, HttpContext httpContext); /// - /// Generates the options used to signal the current state of a user's passkeys to authenticators. + /// Generates the options used to signal the current state of a user's known passkeys to authenticators. /// /// /// Handlers that implement this method should also return from - /// . See . + /// . See . /// /// The user whose passkeys should be signaled. /// The passkey user entity associated with the user's passkeys. /// The HTTP context associated with the request. - /// A representing the result. - /// Thrown when the handler does not support generating signal options. - Task MakeSignalOptionsAsync(TUser user, PasskeyUserEntity userEntity, HttpContext httpContext) - => throw new NotSupportedException($"'{GetType()}' does not support generating passkey signal options."); + /// A representing the result. + /// Thrown when the handler does not support generating known passkeys signal options. + Task MakeKnownPasskeysSignalOptionsAsync(TUser user, PasskeyUserEntity userEntity, HttpContext httpContext) + => throw new NotSupportedException($"'{GetType()}' does not support generating known passkeys signal options."); /// /// Performs passkey attestation using the provided . diff --git a/src/Identity/Core/src/IdentityJsonSerializerContext.cs b/src/Identity/Core/src/IdentityJsonSerializerContext.cs index 8c4460d3e0f4..76648bc29618 100644 --- a/src/Identity/Core/src/IdentityJsonSerializerContext.cs +++ b/src/Identity/Core/src/IdentityJsonSerializerContext.cs @@ -13,7 +13,7 @@ namespace Microsoft.AspNetCore.Identity; [JsonSerializable(typeof(PublicKeyCredential))] [JsonSerializable(typeof(PasskeyAttestationState))] [JsonSerializable(typeof(PasskeyAssertionState))] -[JsonSerializable(typeof(PasskeySignalOptions))] +[JsonSerializable(typeof(KnownPasskeysSignalOptions))] [JsonSourceGenerationOptions( JsonSerializerDefaults.Web, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, diff --git a/src/Identity/Core/src/PasskeySignalOptionsResult.cs b/src/Identity/Core/src/KnownPasskeysSignalOptionsResult.cs similarity index 75% rename from src/Identity/Core/src/PasskeySignalOptionsResult.cs rename to src/Identity/Core/src/KnownPasskeysSignalOptionsResult.cs index 5be667bdf69e..cf97ebd2d89f 100644 --- a/src/Identity/Core/src/PasskeySignalOptionsResult.cs +++ b/src/Identity/Core/src/KnownPasskeysSignalOptionsResult.cs @@ -4,12 +4,12 @@ namespace Microsoft.AspNetCore.Identity; /// -/// Represents the result of a passkey signal options generation. +/// Represents the result of a known passkeys signal options generation. /// -public sealed class PasskeySignalOptionsResult +public sealed class KnownPasskeysSignalOptionsResult { /// - /// Gets or sets the JSON representation of the signal options. + /// Gets or sets the JSON representation of the known passkeys signal options. /// /// /// The structure of this JSON is a superset of the options accepted by the diff --git a/src/Identity/Core/src/PasskeyHandler.cs b/src/Identity/Core/src/PasskeyHandler.cs index a94af0d7d4ea..140894a4bb22 100644 --- a/src/Identity/Core/src/PasskeyHandler.cs +++ b/src/Identity/Core/src/PasskeyHandler.cs @@ -35,7 +35,7 @@ public PasskeyHandler(UserManager userManager, IOptions - public bool SupportsSignalOptions => _userManager.SupportsUserPasskey; + public bool SupportsKnownPasskeysSignalOptions => _userManager.SupportsUserPasskey; /// public async Task MakeCreationOptionsAsync(PasskeyUserEntity userEntity, HttpContext httpContext) @@ -161,7 +161,7 @@ async Task GetAllowCredentialsAsync() } /// - public async Task MakeSignalOptionsAsync(TUser user, PasskeyUserEntity userEntity, HttpContext httpContext) + public async Task MakeKnownPasskeysSignalOptionsAsync(TUser user, PasskeyUserEntity userEntity, HttpContext httpContext) { ArgumentNullException.ThrowIfNull(user); ArgumentNullException.ThrowIfNull(userEntity); @@ -175,7 +175,7 @@ public async Task MakeSignalOptionsAsync(TUser user, } var passkeys = await _userManager.GetPasskeysAsync(user).ConfigureAwait(false); - var options = new PasskeySignalOptions + var options = new KnownPasskeysSignalOptions { RpId = GetServerDomain(httpContext), UserId = BufferSource.FromString(userEntity.Id), @@ -183,9 +183,9 @@ public async Task MakeSignalOptionsAsync(TUser user, Name = userEntity.Name, DisplayName = userEntity.DisplayName, }; - var optionsJson = JsonSerializer.Serialize(options, IdentityJsonSerializerContext.Default.PasskeySignalOptions); + var optionsJson = JsonSerializer.Serialize(options, IdentityJsonSerializerContext.Default.KnownPasskeysSignalOptions); - return new PasskeySignalOptionsResult + return new KnownPasskeysSignalOptionsResult { SignalOptionsJson = optionsJson, }; diff --git a/src/Identity/Core/src/Passkeys/PasskeySignalOptions.cs b/src/Identity/Core/src/Passkeys/KnownPasskeysSignalOptions.cs similarity index 92% rename from src/Identity/Core/src/Passkeys/PasskeySignalOptions.cs rename to src/Identity/Core/src/Passkeys/KnownPasskeysSignalOptions.cs index 70cc555193ea..5b2b9d25bd17 100644 --- a/src/Identity/Core/src/Passkeys/PasskeySignalOptions.cs +++ b/src/Identity/Core/src/Passkeys/KnownPasskeysSignalOptions.cs @@ -4,14 +4,14 @@ namespace Microsoft.AspNetCore.Identity; /// -/// Represents the information needed to signal the current state of a user's passkeys to authenticators. +/// Represents the information needed to signal the current state of a user's known passkeys to authenticators. /// /// /// This is a superset of the options accepted by the WebAuthn signalAllAcceptedCredentials /// and signalCurrentUserDetails methods. /// See . /// -internal sealed class PasskeySignalOptions +internal sealed class KnownPasskeysSignalOptions { /// /// Gets the relying party identifier. diff --git a/src/Identity/Core/src/PublicAPI.Unshipped.txt b/src/Identity/Core/src/PublicAPI.Unshipped.txt index 252d88ca84d4..49d6cd32656c 100644 --- a/src/Identity/Core/src/PublicAPI.Unshipped.txt +++ b/src/Identity/Core/src/PublicAPI.Unshipped.txt @@ -1,11 +1,11 @@ #nullable enable -Microsoft.AspNetCore.Identity.IPasskeyHandler.MakeSignalOptionsAsync(TUser! user, Microsoft.AspNetCore.Identity.PasskeyUserEntity! userEntity, Microsoft.AspNetCore.Http.HttpContext! httpContext) -> System.Threading.Tasks.Task! -Microsoft.AspNetCore.Identity.IPasskeyHandler.SupportsSignalOptions.get -> bool -Microsoft.AspNetCore.Identity.PasskeyHandler.MakeSignalOptionsAsync(TUser! user, Microsoft.AspNetCore.Identity.PasskeyUserEntity! userEntity, Microsoft.AspNetCore.Http.HttpContext! httpContext) -> System.Threading.Tasks.Task! -Microsoft.AspNetCore.Identity.PasskeyHandler.SupportsSignalOptions.get -> bool -Microsoft.AspNetCore.Identity.PasskeySignalOptionsResult -Microsoft.AspNetCore.Identity.PasskeySignalOptionsResult.PasskeySignalOptionsResult() -> void -Microsoft.AspNetCore.Identity.PasskeySignalOptionsResult.SignalOptionsJson.get -> string! -Microsoft.AspNetCore.Identity.PasskeySignalOptionsResult.SignalOptionsJson.init -> void -virtual Microsoft.AspNetCore.Identity.SignInManager.MakePasskeySignalOptionsAsync(TUser! user, Microsoft.AspNetCore.Identity.PasskeyUserEntity! userEntity) -> System.Threading.Tasks.Task! -virtual Microsoft.AspNetCore.Identity.SignInManager.SupportsPasskeySignalOptions.get -> bool +Microsoft.AspNetCore.Identity.IPasskeyHandler.MakeKnownPasskeysSignalOptionsAsync(TUser! user, Microsoft.AspNetCore.Identity.PasskeyUserEntity! userEntity, Microsoft.AspNetCore.Http.HttpContext! httpContext) -> System.Threading.Tasks.Task! +Microsoft.AspNetCore.Identity.IPasskeyHandler.SupportsKnownPasskeysSignalOptions.get -> bool +Microsoft.AspNetCore.Identity.KnownPasskeysSignalOptionsResult +Microsoft.AspNetCore.Identity.KnownPasskeysSignalOptionsResult.KnownPasskeysSignalOptionsResult() -> void +Microsoft.AspNetCore.Identity.KnownPasskeysSignalOptionsResult.SignalOptionsJson.get -> string! +Microsoft.AspNetCore.Identity.KnownPasskeysSignalOptionsResult.SignalOptionsJson.init -> void +Microsoft.AspNetCore.Identity.PasskeyHandler.MakeKnownPasskeysSignalOptionsAsync(TUser! user, Microsoft.AspNetCore.Identity.PasskeyUserEntity! userEntity, Microsoft.AspNetCore.Http.HttpContext! httpContext) -> System.Threading.Tasks.Task! +Microsoft.AspNetCore.Identity.PasskeyHandler.SupportsKnownPasskeysSignalOptions.get -> bool +virtual Microsoft.AspNetCore.Identity.SignInManager.MakeKnownPasskeysSignalOptionsAsync(TUser! user, Microsoft.AspNetCore.Identity.PasskeyUserEntity! userEntity) -> System.Threading.Tasks.Task! +virtual Microsoft.AspNetCore.Identity.SignInManager.SupportsKnownPasskeysSignalOptions.get -> bool diff --git a/src/Identity/Core/src/SignInManager.cs b/src/Identity/Core/src/SignInManager.cs index 846936480663..9513b1cf4cda 100644 --- a/src/Identity/Core/src/SignInManager.cs +++ b/src/Identity/Core/src/SignInManager.cs @@ -545,16 +545,16 @@ public virtual async Task MakePasskeyRequestOptionsAsync(TUser? user) /// /// Gets a value indicating whether the registered supports - /// generating passkey signal options. + /// generating known passkeys signal options. /// /// - /// Check this before calling , - /// which throws when the handler does not support signal options. + /// Check this before calling , + /// which throws when the handler does not support known passkeys signal options. /// - public virtual bool SupportsPasskeySignalOptions => _passkeyHandler?.SupportsSignalOptions ?? false; + public virtual bool SupportsKnownPasskeysSignalOptions => _passkeyHandler?.SupportsKnownPasskeysSignalOptions ?? false; /// - /// Generates the options used to signal the current state of a user's passkeys to authenticators. + /// Generates the options used to signal the current state of a user's known passkeys to authenticators. /// /// /// @@ -574,10 +574,10 @@ public virtual async Task MakePasskeyRequestOptionsAsync(TUser? user) /// /// The user whose passkeys should be signaled. /// The user entity associated with the user's passkeys. - /// A JSON string representing the passkey signal options. + /// A JSON string representing the known passkeys signal options. /// - /// Thrown when the registered does not support signal options. - /// See . + /// Thrown when the registered does not support known passkeys signal options. + /// See . /// /// /// The following example shows how the result is used from JavaScript. @@ -587,13 +587,13 @@ public virtual async Task MakePasskeyRequestOptionsAsync(TUser? user) /// await PublicKeyCredential.signalCurrentUserDetails?.({ rpId, userId, name, displayName }); /// /// - public virtual async Task MakePasskeySignalOptionsAsync(TUser user, PasskeyUserEntity userEntity) + public virtual async Task MakeKnownPasskeysSignalOptionsAsync(TUser user, PasskeyUserEntity userEntity) { ThrowIfNoPasskeyHandler(); ArgumentNullException.ThrowIfNull(user); ArgumentNullException.ThrowIfNull(userEntity); - var result = await _passkeyHandler.MakeSignalOptionsAsync(user, userEntity, Context); + var result = await _passkeyHandler.MakeKnownPasskeysSignalOptionsAsync(user, userEntity, Context); return result.SignalOptionsJson; } diff --git a/src/Identity/test/Identity.Test/Passkeys/PasskeyHandlerSignalTest.cs b/src/Identity/test/Identity.Test/Passkeys/PasskeyHandlerSignalTest.cs index d7add9c41056..83a0dd0963b3 100644 --- a/src/Identity/test/Identity.Test/Passkeys/PasskeyHandlerSignalTest.cs +++ b/src/Identity/test/Identity.Test/Passkeys/PasskeyHandlerSignalTest.cs @@ -15,14 +15,14 @@ namespace Microsoft.AspNetCore.Identity.Test; public class PasskeyHandlerSignalTest { [Fact] - public async Task CanMakeSignalOptions() + public async Task CanMakeKnownPasskeysSignalOptions() { var user = new PocoUser { UserName = "Foo" }; var userManager = SetupUserManager(user, CreatePasskey([1, 2, 3]), CreatePasskey([4, 5, 6])); var handler = CreateHandler(userManager); var httpContext = CreateHttpContext("contoso.com", port: 5001); - var result = await handler.MakeSignalOptionsAsync(user, CreateUserEntity(user, "Foo", "Foo Bar"), httpContext); + var result = await handler.MakeKnownPasskeysSignalOptionsAsync(user, CreateUserEntity(user, "Foo", "Foo Bar"), httpContext); var options = JsonSerializer.Deserialize(result.SignalOptionsJson); Assert.Equal("contoso.com", options.GetProperty("rpId").GetString()); @@ -35,53 +35,53 @@ public async Task CanMakeSignalOptions() } [Fact] - public void SupportsSignalOptionsIsTrue() + public void SupportsKnownPasskeysSignalOptionsIsTrue() { var user = new PocoUser { UserName = "Foo" }; var handler = CreateHandler(SetupUserManager(user)); - Assert.True(handler.SupportsSignalOptions); + Assert.True(handler.SupportsKnownPasskeysSignalOptions); } [Fact] - public void SupportsSignalOptionsIsFalseWhenStoreDoesNotSupportPasskeys() + public void SupportsKnownPasskeysSignalOptionsIsFalseWhenStoreDoesNotSupportPasskeys() { var user = new PocoUser { UserName = "Foo" }; var userManager = MockHelpers.MockUserManager(); userManager.Setup(m => m.SupportsUserPasskey).Returns(false); var handler = CreateHandler(userManager.Object); - Assert.False(handler.SupportsSignalOptions); + Assert.False(handler.SupportsKnownPasskeysSignalOptions); } [Fact] - public async Task MakeSignalOptionsUsesConfiguredServerDomain() + public async Task MakeKnownPasskeysSignalOptionsUsesConfiguredServerDomain() { var user = new PocoUser { UserName = "Foo" }; var userManager = SetupUserManager(user); var handler = CreateHandler(userManager, new() { ServerDomain = "fabrikam.com" }); var httpContext = CreateHttpContext("contoso.com"); - var result = await handler.MakeSignalOptionsAsync(user, CreateUserEntity(user), httpContext); + var result = await handler.MakeKnownPasskeysSignalOptionsAsync(user, CreateUserEntity(user), httpContext); var options = JsonSerializer.Deserialize(result.SignalOptionsJson); Assert.Equal("fabrikam.com", options.GetProperty("rpId").GetString()); } [Fact] - public async Task MakeSignalOptionsWithoutPasskeysReturnsEmptyCredentialList() + public async Task MakeKnownPasskeysSignalOptionsWithoutPasskeysReturnsEmptyCredentialList() { var user = new PocoUser { UserName = "Foo" }; var handler = CreateHandler(SetupUserManager(user)); - var result = await handler.MakeSignalOptionsAsync(user, CreateUserEntity(user), CreateHttpContext()); + var result = await handler.MakeKnownPasskeysSignalOptionsAsync(user, CreateUserEntity(user), CreateHttpContext()); var options = JsonSerializer.Deserialize(result.SignalOptionsJson); Assert.Empty(options.GetProperty("allAcceptedCredentialIds").EnumerateArray()); } [Fact] - public async Task MakeSignalOptionsThrowsWhenUserEntityIdDoesNotMatchUser() + public async Task MakeKnownPasskeysSignalOptionsThrowsWhenUserEntityIdDoesNotMatchUser() { var user = new PocoUser { UserName = "Foo" }; var handler = CreateHandler(SetupUserManager(user)); @@ -93,7 +93,7 @@ public async Task MakeSignalOptionsThrowsWhenUserEntityIdDoesNotMatchUser() }; var ex = await Assert.ThrowsAsync( - () => handler.MakeSignalOptionsAsync(user, userEntity, CreateHttpContext())); + () => handler.MakeKnownPasskeysSignalOptionsAsync(user, userEntity, CreateHttpContext())); Assert.Equal($"The user entity ID 'some-other-id' does not match the ID '{user.Id}' of the specified user.", ex.Message); } diff --git a/src/Identity/test/Identity.Test/SignInManagerTest.cs b/src/Identity/test/Identity.Test/SignInManagerTest.cs index 3f2c16ea9f38..3e0215d462d4 100644 --- a/src/Identity/test/Identity.Test/SignInManagerTest.cs +++ b/src/Identity/test/Identity.Test/SignInManagerTest.cs @@ -649,15 +649,15 @@ public async Task PasskeySignInReturnsLockedOutWhenLockedOut() } [Fact] - public async Task CanMakePasskeySignalOptions() + public async Task CanMakeKnownPasskeysSignalOptions() { var user = new PocoUser { UserName = "Foo" }; var userEntity = new PasskeyUserEntity { Id = user.Id, Name = "Foo", DisplayName = "Foo Bar" }; var expectedOptionsJson = ""; var passkeyHandler = new Mock>(); passkeyHandler - .Setup(h => h.MakeSignalOptionsAsync(user, userEntity, It.IsAny())) - .Returns(Task.FromResult(new PasskeySignalOptionsResult + .Setup(h => h.MakeKnownPasskeysSignalOptionsAsync(user, userEntity, It.IsAny())) + .Returns(Task.FromResult(new KnownPasskeysSignalOptionsResult { SignalOptionsJson = expectedOptionsJson, })) @@ -666,7 +666,7 @@ public async Task CanMakePasskeySignalOptions() var context = new DefaultHttpContext(); var helper = SetupSignInManager(manager.Object, context); - var optionsJson = await helper.MakePasskeySignalOptionsAsync(user, userEntity); + var optionsJson = await helper.MakeKnownPasskeysSignalOptionsAsync(user, userEntity); Assert.Equal(expectedOptionsJson, optionsJson); passkeyHandler.Verify(); @@ -675,31 +675,31 @@ public async Task CanMakePasskeySignalOptions() [Theory] [InlineData(true)] [InlineData(false)] - public void SupportsPasskeySignalOptionsMatchesPasskeyHandler(bool supportsSignalOptions) + public void SupportsKnownPasskeysSignalOptionsMatchesPasskeyHandler(bool supportsKnownPasskeysSignalOptions) { var user = new PocoUser { UserName = "Foo" }; var passkeyHandler = new Mock>(); - passkeyHandler.Setup(h => h.SupportsSignalOptions).Returns(supportsSignalOptions); + passkeyHandler.Setup(h => h.SupportsKnownPasskeysSignalOptions).Returns(supportsKnownPasskeysSignalOptions); var manager = SetupUserManager(user, passkeyHandler: passkeyHandler.Object); var context = new DefaultHttpContext(); var helper = SetupSignInManager(manager.Object, context); - Assert.Equal(supportsSignalOptions, helper.SupportsPasskeySignalOptions); + Assert.Equal(supportsKnownPasskeysSignalOptions, helper.SupportsKnownPasskeysSignalOptions); } [Fact] - public void SupportsPasskeySignalOptionsIsFalseWithoutPasskeyHandler() + public void SupportsKnownPasskeysSignalOptionsIsFalseWithoutPasskeyHandler() { var user = new PocoUser { UserName = "Foo" }; var manager = SetupUserManager(user); var context = new DefaultHttpContext(); var helper = SetupSignInManager(manager.Object, context); - Assert.False(helper.SupportsPasskeySignalOptions); + Assert.False(helper.SupportsKnownPasskeysSignalOptions); } [Fact] - public async Task MakePasskeySignalOptionsThrowsWithoutPasskeyHandler() + public async Task MakeKnownPasskeysSignalOptionsThrowsWithoutPasskeyHandler() { var user = new PocoUser { UserName = "Foo" }; var manager = SetupUserManager(user); @@ -707,7 +707,7 @@ public async Task MakePasskeySignalOptionsThrowsWithoutPasskeyHandler() var helper = SetupSignInManager(manager.Object, context); var ex = await Assert.ThrowsAsync( - () => helper.MakePasskeySignalOptionsAsync(user, new() + () => helper.MakeKnownPasskeysSignalOptionsAsync(user, new() { Id = user.Id, Name = "Foo", diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Shared/PasskeySignals.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Shared/PasskeySignals.razor index 46d8e471dd51..1c585b07b47b 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Shared/PasskeySignals.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Shared/PasskeySignals.razor @@ -18,7 +18,7 @@ protected override async Task OnInitializedAsync() { - if (!SignInManager.SupportsPasskeySignalOptions) + if (!SignInManager.SupportsKnownPasskeysSignalOptions) { return; } @@ -28,7 +28,7 @@ var userId = await UserManager.GetUserIdAsync(User); var userName = await UserManager.GetUserNameAsync(User) ?? "User"; - signalOptionsJson = await SignInManager.MakePasskeySignalOptionsAsync(User, new() + signalOptionsJson = await SignInManager.MakeKnownPasskeysSignalOptionsAsync(User, new() { Id = userId, Name = userName, From 193a69a84df83305185a6c58f1123e442dc211d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20V=C4=B1=CC=81zner?= Date: Fri, 14 Aug 2026 09:41:07 +0200 Subject: [PATCH 6/7] Support signaling unknown passkey credentials --- src/Identity/Core/src/IPasskeyHandler.cs | 17 ++ .../Core/src/IdentityJsonSerializerContext.cs | 2 + src/Identity/Core/src/PasskeyHandler.cs | 42 +++++ .../src/Passkeys/PublicKeyCredentialId.cs | 15 ++ .../Passkeys/UnknownPasskeySignalOptions.cs | 24 +++ src/Identity/Core/src/PublicAPI.Unshipped.txt | 7 + src/Identity/Core/src/SignInManager.cs | 32 ++++ .../src/UnknownPasskeySignalOptionsResult.cs | 25 +++ .../Passkeys/PasskeyHandlerSignalTest.cs | 149 +++++++++++++++++- .../test/Identity.Test/SignInManagerTest.cs | 56 +++++++ .../Components/Account/Pages/Login.razor | 10 +- .../Account/Shared/PasskeySubmit.razor | 6 +- .../Account/Shared/PasskeySubmit.razor.js | 12 +- .../BlazorTemplateTest.cs | 57 ++++++- 14 files changed, 446 insertions(+), 8 deletions(-) create mode 100644 src/Identity/Core/src/Passkeys/PublicKeyCredentialId.cs create mode 100644 src/Identity/Core/src/Passkeys/UnknownPasskeySignalOptions.cs create mode 100644 src/Identity/Core/src/UnknownPasskeySignalOptionsResult.cs diff --git a/src/Identity/Core/src/IPasskeyHandler.cs b/src/Identity/Core/src/IPasskeyHandler.cs index ca0538b042cc..6503fdca6783 100644 --- a/src/Identity/Core/src/IPasskeyHandler.cs +++ b/src/Identity/Core/src/IPasskeyHandler.cs @@ -53,6 +53,23 @@ public interface IPasskeyHandler Task MakeKnownPasskeysSignalOptionsAsync(TUser user, PasskeyUserEntity userEntity, HttpContext httpContext) => throw new NotSupportedException($"'{GetType()}' does not support generating known passkeys signal options."); + /// + /// Generates options used to signal that a passkey credential is unknown to the server. + /// + /// + /// The signal permanently deletes the passkey from the browser's passkey provider. A handler must only return + /// options when the credential is not registered to any user on the server. + /// See . + /// + /// The JSON representation of the passkey credential. + /// The HTTP context associated with the request. + /// + /// An when the credential is unknown to the server, + /// otherwise . + /// + Task MakeUnknownPasskeySignalOptionsAsync(string credentialJson, HttpContext httpContext) + => Task.FromResult(null); + /// /// Performs passkey attestation using the provided . /// diff --git a/src/Identity/Core/src/IdentityJsonSerializerContext.cs b/src/Identity/Core/src/IdentityJsonSerializerContext.cs index 76648bc29618..e67ddaf32718 100644 --- a/src/Identity/Core/src/IdentityJsonSerializerContext.cs +++ b/src/Identity/Core/src/IdentityJsonSerializerContext.cs @@ -14,6 +14,8 @@ namespace Microsoft.AspNetCore.Identity; [JsonSerializable(typeof(PasskeyAttestationState))] [JsonSerializable(typeof(PasskeyAssertionState))] [JsonSerializable(typeof(KnownPasskeysSignalOptions))] +[JsonSerializable(typeof(PublicKeyCredentialId))] +[JsonSerializable(typeof(UnknownPasskeySignalOptions))] [JsonSourceGenerationOptions( JsonSerializerDefaults.Web, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, diff --git a/src/Identity/Core/src/PasskeyHandler.cs b/src/Identity/Core/src/PasskeyHandler.cs index 140894a4bb22..659627b8b0c2 100644 --- a/src/Identity/Core/src/PasskeyHandler.cs +++ b/src/Identity/Core/src/PasskeyHandler.cs @@ -191,6 +191,48 @@ public async Task MakeKnownPasskeysSignalOptio }; } + /// + public async Task MakeUnknownPasskeySignalOptionsAsync(string credentialJson, HttpContext httpContext) + { + if (!_userManager.SupportsUserPasskey) + { + return null; + } + + PublicKeyCredentialId? credential; + try + { + credential = JsonSerializer.Deserialize(credentialJson, IdentityJsonSerializerContext.Default.PublicKeyCredentialId); + } + catch (JsonException) + { + return null; + } + + if (credential is null) + { + return null; + } + + var user = await _userManager.FindByPasskeyIdAsync(credential.Id.ToArray()).ConfigureAwait(false); + if (user is not null) + { + return null; + } + + var options = new UnknownPasskeySignalOptions + { + RpId = GetServerDomain(httpContext), + CredentialId = credential.Id, + }; + var optionsJson = JsonSerializer.Serialize(options, IdentityJsonSerializerContext.Default.UnknownPasskeySignalOptions); + + return new UnknownPasskeySignalOptionsResult + { + SignalOptionsJson = optionsJson, + }; + } + /// public async Task PerformAttestationAsync(PasskeyAttestationContext context) { diff --git a/src/Identity/Core/src/Passkeys/PublicKeyCredentialId.cs b/src/Identity/Core/src/Passkeys/PublicKeyCredentialId.cs new file mode 100644 index 000000000000..149bb8d7a78c --- /dev/null +++ b/src/Identity/Core/src/Passkeys/PublicKeyCredentialId.cs @@ -0,0 +1,15 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Identity; + +/// +/// Represents the credential ID from a public key credential. +/// +internal sealed class PublicKeyCredentialId +{ + /// + /// Gets the credential ID. + /// + public required BufferSource Id { get; init; } +} diff --git a/src/Identity/Core/src/Passkeys/UnknownPasskeySignalOptions.cs b/src/Identity/Core/src/Passkeys/UnknownPasskeySignalOptions.cs new file mode 100644 index 000000000000..fb1a5d9ae004 --- /dev/null +++ b/src/Identity/Core/src/Passkeys/UnknownPasskeySignalOptions.cs @@ -0,0 +1,24 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Identity; + +/// +/// Represents the information needed to signal that a passkey is unknown to the server. +/// +/// +/// These options are accepted by the WebAuthn signalUnknownCredential method. +/// See . +/// +internal sealed class UnknownPasskeySignalOptions +{ + /// + /// Gets the relying party identifier. + /// + public required string RpId { get; init; } + + /// + /// Gets the credential ID that is unknown to the server. + /// + public required BufferSource CredentialId { get; init; } +} diff --git a/src/Identity/Core/src/PublicAPI.Unshipped.txt b/src/Identity/Core/src/PublicAPI.Unshipped.txt index b82768cb4e82..ede05896ae38 100644 --- a/src/Identity/Core/src/PublicAPI.Unshipped.txt +++ b/src/Identity/Core/src/PublicAPI.Unshipped.txt @@ -1,11 +1,13 @@ #nullable enable Microsoft.AspNetCore.Identity.IPasskeyHandler.MakeKnownPasskeysSignalOptionsAsync(TUser! user, Microsoft.AspNetCore.Identity.PasskeyUserEntity! userEntity, Microsoft.AspNetCore.Http.HttpContext! httpContext) -> System.Threading.Tasks.Task! +Microsoft.AspNetCore.Identity.IPasskeyHandler.MakeUnknownPasskeySignalOptionsAsync(string! credentialJson, Microsoft.AspNetCore.Http.HttpContext! httpContext) -> System.Threading.Tasks.Task! Microsoft.AspNetCore.Identity.IPasskeyHandler.SupportsKnownPasskeysSignalOptions.get -> bool Microsoft.AspNetCore.Identity.KnownPasskeysSignalOptionsResult Microsoft.AspNetCore.Identity.KnownPasskeysSignalOptionsResult.KnownPasskeysSignalOptionsResult() -> void Microsoft.AspNetCore.Identity.KnownPasskeysSignalOptionsResult.SignalOptionsJson.get -> string! Microsoft.AspNetCore.Identity.KnownPasskeysSignalOptionsResult.SignalOptionsJson.init -> void Microsoft.AspNetCore.Identity.PasskeyHandler.MakeKnownPasskeysSignalOptionsAsync(TUser! user, Microsoft.AspNetCore.Identity.PasskeyUserEntity! userEntity, Microsoft.AspNetCore.Http.HttpContext! httpContext) -> System.Threading.Tasks.Task! +Microsoft.AspNetCore.Identity.PasskeyHandler.MakeUnknownPasskeySignalOptionsAsync(string! credentialJson, Microsoft.AspNetCore.Http.HttpContext! httpContext) -> System.Threading.Tasks.Task! Microsoft.AspNetCore.Identity.PasskeyHandler.SupportsKnownPasskeysSignalOptions.get -> bool Microsoft.AspNetCore.Identity.PasskeyEndpointsOptions Microsoft.AspNetCore.Identity.PasskeyEndpointsOptions.Enroll.get -> string? @@ -17,7 +19,12 @@ Microsoft.AspNetCore.Identity.PasskeyEndpointsOptions.PrfUsageDetails.get -> str Microsoft.AspNetCore.Identity.PasskeyEndpointsOptions.PrfUsageDetails.set -> void Microsoft.AspNetCore.Routing.PasskeyEndpointsEndpointRouteBuilderExtensions Microsoft.Extensions.DependencyInjection.PasskeyEndpointsServiceCollectionExtensions +Microsoft.AspNetCore.Identity.UnknownPasskeySignalOptionsResult +Microsoft.AspNetCore.Identity.UnknownPasskeySignalOptionsResult.UnknownPasskeySignalOptionsResult() -> void +Microsoft.AspNetCore.Identity.UnknownPasskeySignalOptionsResult.SignalOptionsJson.get -> string! +Microsoft.AspNetCore.Identity.UnknownPasskeySignalOptionsResult.SignalOptionsJson.init -> void virtual Microsoft.AspNetCore.Identity.SignInManager.MakeKnownPasskeysSignalOptionsAsync(TUser! user, Microsoft.AspNetCore.Identity.PasskeyUserEntity! userEntity) -> System.Threading.Tasks.Task! +virtual Microsoft.AspNetCore.Identity.SignInManager.MakeUnknownPasskeySignalOptionsAsync(string! credentialJson) -> System.Threading.Tasks.Task! virtual Microsoft.AspNetCore.Identity.SignInManager.SupportsKnownPasskeysSignalOptions.get -> bool static Microsoft.AspNetCore.Routing.PasskeyEndpointsEndpointRouteBuilderExtensions.MapWellKnownPasskeyEndpoints(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder! endpoints) -> Microsoft.AspNetCore.Builder.IEndpointConventionBuilder! static Microsoft.Extensions.DependencyInjection.PasskeyEndpointsServiceCollectionExtensions.AddPasskeyEndpoints(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, System.Action! configure) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! diff --git a/src/Identity/Core/src/SignInManager.cs b/src/Identity/Core/src/SignInManager.cs index 9513b1cf4cda..c9dea78d8a68 100644 --- a/src/Identity/Core/src/SignInManager.cs +++ b/src/Identity/Core/src/SignInManager.cs @@ -597,6 +597,38 @@ public virtual async Task MakeKnownPasskeysSignalOptionsAsync(TUser user return result.SignalOptionsJson; } + /// + /// Generates options used to signal that a passkey credential is unknown to the server. + /// + /// + /// The returned JSON is accepted by the PublicKeyCredential.signalUnknownCredential() JavaScript API. + /// Calling that API permanently deletes the passkey from the browser's passkey provider. This method only + /// returns options when no user on the server has the credential. + /// + /// The JSON representation of the passkey credential. + /// + /// A JSON string representing the unknown passkey signal options when the credential is unknown to the server, + /// otherwise . + /// + /// + /// Thrown when no is registered. + /// + /// Thrown when is or empty. + /// + /// The following example shows how the result is used from JavaScript. + /// + /// await PublicKeyCredential.signalUnknownCredential?.(signalOptions); + /// + /// + public virtual async Task MakeUnknownPasskeySignalOptionsAsync(string credentialJson) + { + ThrowIfNoPasskeyHandler(); + ArgumentException.ThrowIfNullOrEmpty(credentialJson); + + var result = await _passkeyHandler.MakeUnknownPasskeySignalOptionsAsync(credentialJson, Context); + return result?.SignalOptionsJson; + } + /// /// Performs passkey attestation for the given . /// diff --git a/src/Identity/Core/src/UnknownPasskeySignalOptionsResult.cs b/src/Identity/Core/src/UnknownPasskeySignalOptionsResult.cs new file mode 100644 index 000000000000..4b4746da0a97 --- /dev/null +++ b/src/Identity/Core/src/UnknownPasskeySignalOptionsResult.cs @@ -0,0 +1,25 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Identity; + +/// +/// Represents the result of an unknown passkey signal options generation. +/// +public sealed class UnknownPasskeySignalOptionsResult +{ + /// + /// Gets or sets the JSON representation of the unknown passkey signal options. + /// + /// + /// The JSON is accepted by the PublicKeyCredential.signalUnknownCredential() JavaScript API. + /// Calling this API permanently deletes the passkey from the browser's passkey provider. + /// + /// + /// + /// const signalOptions = JSON.parse(signalOptionsJson); + /// await PublicKeyCredential.signalUnknownCredential?.(signalOptions); + /// + /// + public required string SignalOptionsJson { get; init; } +} diff --git a/src/Identity/test/Identity.Test/Passkeys/PasskeyHandlerSignalTest.cs b/src/Identity/test/Identity.Test/Passkeys/PasskeyHandlerSignalTest.cs index 83a0dd0963b3..2d1882be8cb6 100644 --- a/src/Identity/test/Identity.Test/Passkeys/PasskeyHandlerSignalTest.cs +++ b/src/Identity/test/Identity.Test/Passkeys/PasskeyHandlerSignalTest.cs @@ -98,16 +98,132 @@ public async Task MakeKnownPasskeysSignalOptionsThrowsWhenUserEntityIdDoesNotMat Assert.Equal($"The user entity ID 'some-other-id' does not match the ID '{user.Id}' of the specified user.", ex.Message); } + [Fact] + public async Task CanMakeUnknownPasskeySignalOptions() + { + var user = new PocoUser { UserName = "Foo" }; + var credentialId = (byte[])[1, 2, 3]; + var userManager = SetupUserManager(user); + var handler = CreateHandler(userManager); + + var result = await handler.MakeUnknownPasskeySignalOptionsAsync( + CreateAssertionCredentialJson(credentialId), + CreateHttpContext()); + + Assert.NotNull(result); + var options = JsonSerializer.Deserialize(result.SignalOptionsJson); + Assert.Equal("contoso.com", options.GetProperty("rpId").GetString()); + Assert.Equal(Base64Url.EncodeToString(credentialId), options.GetProperty("credentialId").GetString()); + Mock.Get(userManager).Verify( + m => m.FindByPasskeyIdAsync(It.Is(id => id.SequenceEqual(credentialId))), + Times.Once); + } + + [Fact] + public async Task MakeUnknownPasskeySignalOptionsReturnsNullWhenCredentialBelongsToAUser() + { + var user = new PocoUser { UserName = "Foo" }; + var credentialId = (byte[])[1, 2, 3]; + var userManager = SetupUserManagerMock(user); + userManager + .Setup(m => m.FindByPasskeyIdAsync(It.Is(id => id.SequenceEqual(credentialId)))) + .ReturnsAsync(user); + var handler = CreateHandler(userManager.Object); + + var result = await handler.MakeUnknownPasskeySignalOptionsAsync( + CreateAssertionCredentialJson(credentialId), + CreateHttpContext()); + + Assert.Null(result); + } + + [Fact] + public async Task MakeUnknownPasskeySignalOptionsReturnsNullForMalformedJson() + { + var user = new PocoUser { UserName = "Foo" }; + var handler = CreateHandler(SetupUserManager(user)); + + var result = await handler.MakeUnknownPasskeySignalOptionsAsync("{", CreateHttpContext()); + + Assert.Null(result); + } + + [Fact] + public async Task MakeUnknownPasskeySignalOptionsReturnsNullWhenStoreDoesNotSupportPasskeys() + { + var handler = CreateHandler(MockHelpers.TestUserManager()); + + var result = await handler.MakeUnknownPasskeySignalOptionsAsync( + CreateAssertionCredentialJson([1, 2, 3]), + CreateHttpContext()); + + Assert.Null(result); + } + + [Fact] + public async Task CanMakeUnknownPasskeySignalOptionsFromAttestationCredential() + { + var user = new PocoUser { UserName = "Foo" }; + var credentialId = (byte[])[1, 2, 3]; + var handler = CreateHandler(SetupUserManager(user)); + + var result = await handler.MakeUnknownPasskeySignalOptionsAsync( + CreateAttestationCredentialJson(credentialId), + CreateHttpContext()); + + Assert.NotNull(result); + var options = JsonSerializer.Deserialize(result.SignalOptionsJson); + Assert.Equal(Base64Url.EncodeToString(credentialId), options.GetProperty("credentialId").GetString()); + } + + [Fact] + public async Task MakeUnknownPasskeySignalOptionsUsesConfiguredServerDomain() + { + var user = new PocoUser { UserName = "Foo" }; + var handler = CreateHandler(SetupUserManager(user), new() { ServerDomain = "fabrikam.com" }); + + var result = await handler.MakeUnknownPasskeySignalOptionsAsync( + CreateAssertionCredentialJson([1, 2, 3]), + CreateHttpContext("contoso.com")); + + Assert.NotNull(result); + var options = JsonSerializer.Deserialize(result.SignalOptionsJson); + Assert.Equal("fabrikam.com", options.GetProperty("rpId").GetString()); + } + + [Fact] + public async Task MakeUnknownPasskeySignalOptionsUsesUnpaddedBase64UrlCredentialId() + { + var user = new PocoUser { UserName = "Foo" }; + var credentialId = (byte[])[251, 255]; + var handler = CreateHandler(SetupUserManager(user)); + + var result = await handler.MakeUnknownPasskeySignalOptionsAsync( + CreateAssertionCredentialJson(credentialId), + CreateHttpContext()); + + Assert.NotNull(result); + var options = JsonSerializer.Deserialize(result.SignalOptionsJson); + var encodedCredentialId = options.GetProperty("credentialId").GetString(); + Assert.Equal("-_8", encodedCredentialId); + Assert.NotNull(encodedCredentialId); + Assert.DoesNotContain('=', encodedCredentialId); + } + private static PasskeyHandler CreateHandler(UserManager userManager, IdentityPasskeyOptions? options = null) => new(userManager, Options.Create(options ?? new IdentityPasskeyOptions())); private static UserManager SetupUserManager(PocoUser user, params UserPasskeyInfo[] passkeys) + => SetupUserManagerMock(user, passkeys).Object; + + private static Mock> SetupUserManagerMock(PocoUser user, params UserPasskeyInfo[] passkeys) { var manager = MockHelpers.MockUserManager(); manager.Setup(m => m.SupportsUserPasskey).Returns(true); manager.Setup(m => m.GetUserIdAsync(user)).ReturnsAsync(user.Id); manager.Setup(m => m.GetPasskeysAsync(user)).ReturnsAsync(passkeys); - return manager.Object; + manager.Setup(m => m.FindByPasskeyIdAsync(It.IsAny())).ReturnsAsync((PocoUser?)null); + return manager; } private static HttpContext CreateHttpContext(string host = "contoso.com", int? port = null) @@ -127,4 +243,35 @@ private static PasskeyUserEntity CreateUserEntity(PocoUser user, string name = " private static UserPasskeyInfo CreatePasskey(byte[] credentialId) => new(credentialId, [], default, 0, null, false, false, false, [], []); + + private static string CreateAssertionCredentialJson(byte[] credentialId) + => CreateCredentialJson(credentialId, new + { + clientDataJSON = "", + authenticatorData = "", + signature = "", + userHandle = (string?)null, + }); + + private static string CreateAttestationCredentialJson(byte[] credentialId) + => CreateCredentialJson(credentialId, new + { + clientDataJSON = "", + attestationObject = "", + transports = Array.Empty(), + }); + + private static string CreateCredentialJson(byte[] credentialId, object response) + { + var encodedCredentialId = Base64Url.EncodeToString(credentialId); + return JsonSerializer.Serialize(new + { + id = encodedCredentialId, + rawId = encodedCredentialId, + response, + type = "public-key", + clientExtensionResults = new { }, + authenticatorAttachment = "platform", + }); + } } diff --git a/src/Identity/test/Identity.Test/SignInManagerTest.cs b/src/Identity/test/Identity.Test/SignInManagerTest.cs index 3e0215d462d4..4870bc8258c7 100644 --- a/src/Identity/test/Identity.Test/SignInManagerTest.cs +++ b/src/Identity/test/Identity.Test/SignInManagerTest.cs @@ -717,6 +717,62 @@ public async Task MakeKnownPasskeysSignalOptionsThrowsWithoutPasskeyHandler() Assert.Equal("This operation requires an IPasskeyHandler service to be registered.", ex.Message); } + [Fact] + public async Task CanMakeUnknownPasskeySignalOptions() + { + var user = new PocoUser { UserName = "Foo" }; + var expectedOptionsJson = ""; + var passkeyHandler = new Mock>(); + passkeyHandler + .Setup(h => h.MakeUnknownPasskeySignalOptionsAsync("", It.IsAny())) + .Returns(Task.FromResult(new UnknownPasskeySignalOptionsResult + { + SignalOptionsJson = expectedOptionsJson, + })) + .Verifiable(); + var manager = SetupUserManager(user, passkeyHandler: passkeyHandler.Object); + var context = new DefaultHttpContext(); + var helper = SetupSignInManager(manager.Object, context); + + var optionsJson = await helper.MakeUnknownPasskeySignalOptionsAsync(""); + + Assert.Equal(expectedOptionsJson, optionsJson); + passkeyHandler.Verify(); + } + + [Fact] + public async Task MakeUnknownPasskeySignalOptionsReturnsNullWhenHandlerReturnsNull() + { + var user = new PocoUser { UserName = "Foo" }; + var passkeyHandler = new Mock>(); + passkeyHandler + .Setup(h => h.MakeUnknownPasskeySignalOptionsAsync("", It.IsAny())) + .Returns(Task.FromResult(null)) + .Verifiable(); + var manager = SetupUserManager(user, passkeyHandler: passkeyHandler.Object); + var context = new DefaultHttpContext(); + var helper = SetupSignInManager(manager.Object, context); + + var optionsJson = await helper.MakeUnknownPasskeySignalOptionsAsync(""); + + Assert.Null(optionsJson); + passkeyHandler.Verify(); + } + + [Fact] + public async Task MakeUnknownPasskeySignalOptionsThrowsWithoutPasskeyHandler() + { + var user = new PocoUser { UserName = "Foo" }; + var manager = SetupUserManager(user); + var context = new DefaultHttpContext(); + var helper = SetupSignInManager(manager.Object, context); + + var ex = await Assert.ThrowsAsync( + () => helper.MakeUnknownPasskeySignalOptionsAsync("")); + + Assert.Equal("This operation requires an IPasskeyHandler service to be registered.", ex.Message); + } + private static void SetupPasskeyAuth(HttpContext context, Mock auth) { // Calling AuthenticateAsync will return a failure result diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Pages/Login.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Pages/Login.razor index 9582d32b04c2..0957d6a10867 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Pages/Login.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Pages/Login.razor @@ -49,7 +49,9 @@
OR - Log in with a passkey + Log in with a passkey

@@ -77,6 +79,7 @@ @code { private string? errorMessage; + private string? unknownPasskeySignalOptionsJson; private EditContext editContext = default!; [CascadingParameter] @@ -151,6 +154,11 @@ } else { + if (!string.IsNullOrEmpty(Input.Passkey?.CredentialJson)) + { + unknownPasskeySignalOptionsJson = await SignInManager.MakeUnknownPasskeySignalOptionsAsync(Input.Passkey.CredentialJson); + } + errorMessage = "Error: Invalid login attempt."; } } diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Shared/PasskeySubmit.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Shared/PasskeySubmit.razor index 3fad13f9dad0..363e83254845 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Shared/PasskeySubmit.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Shared/PasskeySubmit.razor @@ -2,7 +2,8 @@ + email-name="@EmailName" + unknown-credential-signal-options="@UnknownCredentialSignalOptionsJson"> @code { @@ -17,6 +18,9 @@ [Parameter] public string? EmailName { get; set; } + [Parameter] + public string? UnknownCredentialSignalOptionsJson { get; set; } + [Parameter] public RenderFragment? ChildContent { get; set; } diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Shared/PasskeySubmit.razor.js b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Shared/PasskeySubmit.razor.js index 13004de3563d..8f0e0940c323 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Shared/PasskeySubmit.razor.js +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Shared/PasskeySubmit.razor.js @@ -40,12 +40,13 @@ async function requestCredential(email, mediation, signal) { customElements.define('passkey-submit', class extends HTMLElement { static formAssociated = true; - connectedCallback() { + async connectedCallback() { this.internals = this.attachInternals(); this.attrs = { operation: this.getAttribute('operation'), name: this.getAttribute('name'), emailName: this.getAttribute('email-name'), + unknownCredentialSignalOptions: this.getAttribute('unknown-credential-signal-options'), }; this.internals.form.addEventListener('submit', (event) => { @@ -55,7 +56,14 @@ customElements.define('passkey-submit', class extends HTMLElement { } }); - this.tryAutofillPasskey(); + try { + if (this.attrs.unknownCredentialSignalOptions) { + const options = JSON.parse(this.attrs.unknownCredentialSignalOptions); + await PublicKeyCredential.signalUnknownCredential?.(options); + } + } finally { + this.tryAutofillPasskey(); + } } disconnectedCallback() { diff --git a/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorTemplateTest.cs b/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorTemplateTest.cs index fd15e3565f29..3177c17ac461 100644 --- a/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorTemplateTest.cs +++ b/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorTemplateTest.cs @@ -166,12 +166,33 @@ await Task.WhenAll( // the browser version bundled with Playwright. await page.AddInitScriptAsync(""" window.__passkeySignals = []; + window.__passkeyAutofillStarted = false; + window.__resolveUnknownCredentialSignal = null; + if (navigator.credentials) { + const originalGet = navigator.credentials.get.bind(navigator.credentials); + navigator.credentials.get = async function (options) { + const credential = await originalGet(options); + sessionStorage.setItem('__passkeyCredentialJson', JSON.stringify(credential)); + return credential; + }; + } if (window.PublicKeyCredential) { - for (const name of ['signalAllAcceptedCredentials', 'signalCurrentUserDetails']) { - const original = window.PublicKeyCredential[name]; + if (sessionStorage.getItem('__forcePasskeyAutofillOnce')) { + sessionStorage.removeItem('__forcePasskeyAutofillOnce'); + window.PublicKeyCredential.isConditionalMediationAvailable = () => { + window.__passkeyAutofillStarted = true; + return new Promise(() => {}); + }; + } else if (sessionStorage.getItem('__skipPasskeyAutofillOnce')) { + sessionStorage.removeItem('__skipPasskeyAutofillOnce'); + window.PublicKeyCredential.isConditionalMediationAvailable = () => Promise.resolve(false); + } + for (const name of ['signalAllAcceptedCredentials', 'signalCurrentUserDetails', 'signalUnknownCredential']) { window.PublicKeyCredential[name] = function (options) { window.__passkeySignals.push({ name, options }); - return original ? original.call(this, options) : Promise.resolve(); + return name === 'signalUnknownCredential' + ? new Promise(resolve => window.__resolveUnknownCredentialSignal = resolve) + : Promise.resolve(); }; } } @@ -270,6 +291,7 @@ await page.EvaluateAsync(""" var storedCredentials = await GetAuthenticatorCredentialsAsync(cdpSession, authenticatorId); Assert.Single(storedCredentials); Assert.Equal(storedCredentials, acceptedCredentials); + var passkeyCredentialId = storedCredentials[0]; var userDetails = await GetPasskeySignalAsync(page, "signalCurrentUserDetails"); Assert.Equal(new Uri(page.Url).Host, userDetails.GetProperty("rpId").GetString()); @@ -331,6 +353,35 @@ await Task.WhenAll( await page.WaitForSelectorAsync("text=Passkey deleted successfully"); Assert.Empty(await GetSignalledCredentialIdsAsync(page)); + + // Submit the revoked credential again. The unknown credential signal remains pending, + // so a conditional autofill request can only start if the template gets the ordering wrong. + await page.EvaluateAsync("() => sessionStorage.setItem('__skipPasskeyAutofillOnce', 'true')"); + await Task.WhenAll( + page.WaitForURLAsync("**/Account/Login**", new() { WaitUntil = WaitUntilState.NetworkIdle }), + page.ClickAsync("text=Logout")); + + await page.EvaluateAsync(""" + () => { + const credentialJson = sessionStorage.getItem('__passkeyCredentialJson'); + if (!credentialJson) { + throw new Error('The revoked passkey credential was not captured.'); + } + sessionStorage.setItem('__forcePasskeyAutofillOnce', 'true'); + navigator.credentials.get = () => Promise.resolve(JSON.parse(credentialJson)); + } + """); + + await page.FillAsync("[name=\"Input.Email\"]", userName); + await page.ClickAsync("text=Log in with a passkey"); + await page.WaitForSelectorAsync("text=Error: Invalid login attempt."); + var unknownCredential = await GetPasskeySignalAsync(page, "signalUnknownCredential"); + Assert.Equal(new Uri(page.Url).Host, unknownCredential.GetProperty("rpId").GetString()); + Assert.Equal(passkeyCredentialId, unknownCredential.GetProperty("credentialId").GetString()); + Assert.False(await page.EvaluateAsync("() => window.__passkeyAutofillStarted")); + + await page.EvaluateAsync("() => window.__resolveUnknownCredentialSignal()"); + await page.WaitForFunctionAsync("() => window.__passkeyAutofillStarted"); } } From ea48294061ddf42f8f7fbcfdaf55191556ca860a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20V=C3=ADzner?= <148648143+rolandVi@users.noreply.github.com> Date: Fri, 14 Aug 2026 15:19:40 +0200 Subject: [PATCH 7/7] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/Identity/Core/src/PasskeyHandler.cs | 2 +- src/Identity/Core/src/SignInManager.cs | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/Identity/Core/src/PasskeyHandler.cs b/src/Identity/Core/src/PasskeyHandler.cs index 659627b8b0c2..3e526d326436 100644 --- a/src/Identity/Core/src/PasskeyHandler.cs +++ b/src/Identity/Core/src/PasskeyHandler.cs @@ -209,7 +209,7 @@ public async Task MakeKnownPasskeysSignalOptio return null; } - if (credential is null) + if (credential?.Id is not { Length: > 0 }) { return null; } diff --git a/src/Identity/Core/src/SignInManager.cs b/src/Identity/Core/src/SignInManager.cs index c9dea78d8a68..14790253b99d 100644 --- a/src/Identity/Core/src/SignInManager.cs +++ b/src/Identity/Core/src/SignInManager.cs @@ -575,6 +575,9 @@ public virtual async Task MakePasskeyRequestOptionsAsync(TUser? user) /// The user whose passkeys should be signaled. /// The user entity associated with the user's passkeys. /// A JSON string representing the known passkeys signal options. + /// + /// Thrown when no is registered. + /// /// /// Thrown when the registered does not support known passkeys signal options. /// See . @@ -582,7 +585,7 @@ public virtual async Task MakePasskeyRequestOptionsAsync(TUser? user) /// /// The following example shows how the result is used from JavaScript. /// - /// const { rpId, userId, allAcceptedCredentialIds, name, displayName } = signalOptions; + /// const { rpId, userId, allAcceptedCredentialIds, name, displayName } = JSON.parse(signalOptionsJson); /// await PublicKeyCredential.signalAllAcceptedCredentials?.({ rpId, userId, allAcceptedCredentialIds }); /// await PublicKeyCredential.signalCurrentUserDetails?.({ rpId, userId, name, displayName }); ///