diff --git a/src/Identity/Core/src/Data/IdentityEndpointsJsonSerializerContext.cs b/src/Identity/Core/src/Data/IdentityEndpointsJsonSerializerContext.cs index e459042e9ce4..f15fd0989452 100644 --- a/src/Identity/Core/src/Data/IdentityEndpointsJsonSerializerContext.cs +++ b/src/Identity/Core/src/Data/IdentityEndpointsJsonSerializerContext.cs @@ -15,6 +15,10 @@ namespace Microsoft.AspNetCore.Identity.Data; [JsonSerializable(typeof(InfoResponse))] [JsonSerializable(typeof(TwoFactorRequest))] [JsonSerializable(typeof(TwoFactorResponse))] +[JsonSerializable(typeof(PasskeyLoginRequest))] +[JsonSerializable(typeof(PasskeyRegistrationRequest))] +[JsonSerializable(typeof(PasskeyRegistrationResponse))] +[JsonSerializable(typeof(PasskeyRequestOptionsRequest))] internal sealed partial class IdentityEndpointsJsonSerializerContext : JsonSerializerContext { } diff --git a/src/Identity/Core/src/Data/PasskeyLoginRequest.cs b/src/Identity/Core/src/Data/PasskeyLoginRequest.cs new file mode 100644 index 000000000000..fc9d2946089c --- /dev/null +++ b/src/Identity/Core/src/Data/PasskeyLoginRequest.cs @@ -0,0 +1,17 @@ +// 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.Routing; + +namespace Microsoft.AspNetCore.Identity.Data; + +/// +/// The request type for the "/passkeys/login" endpoint added by . +/// +public sealed class PasskeyLoginRequest +{ + /// + /// The JSON-serialized credential returned by the browser's WebAuthn API. + /// + public required string CredentialJson { get; init; } +} diff --git a/src/Identity/Core/src/Data/PasskeyRegistrationRequest.cs b/src/Identity/Core/src/Data/PasskeyRegistrationRequest.cs new file mode 100644 index 000000000000..48e74d7b8cd1 --- /dev/null +++ b/src/Identity/Core/src/Data/PasskeyRegistrationRequest.cs @@ -0,0 +1,22 @@ +// 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.Routing; + +namespace Microsoft.AspNetCore.Identity.Data; + +/// +/// The request type for the "/manage/passkeys" endpoint added by . +/// +public sealed class PasskeyRegistrationRequest +{ + /// + /// The JSON-serialized credential returned by the browser's WebAuthn API. + /// + public required string CredentialJson { get; init; } + + /// + /// The optional friendly name for the passkey. + /// + public string? Name { get; init; } +} diff --git a/src/Identity/Core/src/Data/PasskeyRegistrationResponse.cs b/src/Identity/Core/src/Data/PasskeyRegistrationResponse.cs new file mode 100644 index 000000000000..006d6b4a311b --- /dev/null +++ b/src/Identity/Core/src/Data/PasskeyRegistrationResponse.cs @@ -0,0 +1,22 @@ +// 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.Routing; + +namespace Microsoft.AspNetCore.Identity.Data; + +/// +/// The response type for the "/manage/passkeys" endpoint added by . +/// +public sealed class PasskeyRegistrationResponse +{ + /// + /// The Base64Url-encoded credential ID for the registered passkey. + /// + public required string CredentialId { get; init; } + + /// + /// The friendly name stored for the passkey. + /// + public string? Name { get; init; } +} diff --git a/src/Identity/Core/src/Data/PasskeyRequestOptionsRequest.cs b/src/Identity/Core/src/Data/PasskeyRequestOptionsRequest.cs new file mode 100644 index 000000000000..5d8564c48d23 --- /dev/null +++ b/src/Identity/Core/src/Data/PasskeyRequestOptionsRequest.cs @@ -0,0 +1,17 @@ +// 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.Routing; + +namespace Microsoft.AspNetCore.Identity.Data; + +/// +/// The request type for the "/passkeys/requestOptions" endpoint added by . +/// +public sealed class PasskeyRequestOptionsRequest +{ + /// + /// The optional email address of the user requesting passkey options. + /// + public string? Email { get; init; } +} diff --git a/src/Identity/Core/src/IdentityApiEndpointRouteBuilderExtensions.cs b/src/Identity/Core/src/IdentityApiEndpointRouteBuilderExtensions.cs index 4ccf2048e40e..c2cd04952b37 100644 --- a/src/Identity/Core/src/IdentityApiEndpointRouteBuilderExtensions.cs +++ b/src/Identity/Core/src/IdentityApiEndpointRouteBuilderExtensions.cs @@ -25,17 +25,23 @@ namespace Microsoft.AspNetCore.Routing; /// public static class IdentityApiEndpointRouteBuilderExtensions { + private const int MaxPasskeyNameLength = 200; + // Validate the email address using DataAnnotations like the UserValidator does when RequireUniqueEmail = true. private static readonly EmailAddressAttribute _emailAddressAttribute = new(); /// - /// Add endpoints for registering, logging in, and logging out using ASP.NET Core Identity. + /// Add endpoints for registering, logging in, managing passkeys, and logging out using ASP.NET Core Identity. /// /// The type describing the user. This should match the generic parameter in . /// /// The to add the identity endpoints to. /// Call to add a prefix to all the endpoints. /// + /// + /// The passkey endpoints use the cookie to store state between + /// the options and completion requests. This scheme is registered by AddIdentityApiEndpoints. + /// /// An to further customize the added endpoints. public static IEndpointConventionBuilder MapIdentityApi(this IEndpointRouteBuilder endpoints) where TUser : class, new() @@ -91,10 +97,7 @@ public static IEndpointConventionBuilder MapIdentityApi(this IEndpointRou ([FromBody] LoginRequest login, [FromQuery] bool? useCookies, [FromQuery] bool? useSessionCookies, [FromServices] IServiceProvider sp) => { var signInManager = sp.GetRequiredService>(); - - var useCookieScheme = (useCookies == true) || (useSessionCookies == true); - var isPersistent = (useCookies == true) && (useSessionCookies != true); - signInManager.AuthenticationScheme = useCookieScheme ? IdentityConstants.ApplicationScheme : IdentityConstants.BearerScheme; + var isPersistent = ConfigureAuthenticationScheme(signInManager, useCookies, useSessionCookies); var result = await signInManager.PasswordSignInAsync(login.Email, login.Password, isPersistent, lockoutOnFailure: true); @@ -119,6 +122,55 @@ public static IEndpointConventionBuilder MapIdentityApi(this IEndpointRou return TypedResults.Empty; }); + var passkeyGroup = routeGroup.MapGroup("/passkeys"); + + passkeyGroup.MapPost("/requestOptions", async Task + ([FromBody] PasskeyRequestOptionsRequest request, [FromServices] IServiceProvider sp) => + { + var signInManager = sp.GetRequiredService>(); + var userManager = signInManager.UserManager; + EnsurePasskeySupport(userManager); + + var user = string.IsNullOrEmpty(request.Email) + ? null + : await userManager.FindByEmailAsync(request.Email); + var optionsJson = await signInManager.MakePasskeyRequestOptionsAsync(user); + + return TypedResults.Content(optionsJson, contentType: "application/json"); + }); + + passkeyGroup.MapPost("/login", async Task, EmptyHttpResult, ProblemHttpResult, ValidationProblem>> + ([FromBody] PasskeyLoginRequest login, [FromQuery] bool? useCookies, [FromQuery] bool? useSessionCookies, [FromServices] IServiceProvider sp) => + { + var signInManager = sp.GetRequiredService>(); + EnsurePasskeySupport(signInManager.UserManager); + + if (string.IsNullOrEmpty(login.CredentialJson)) + { + return CreateValidationProblem("InvalidPasskey", "The passkey could not be validated."); + } + + var isPersistent = ConfigureAuthenticationScheme(signInManager, useCookies, useSessionCookies); + + SignInResult result; + try + { + result = await signInManager.PasskeySignInAsync(login.CredentialJson, isPersistent); + } + catch (PasskeyAuthenticationStateException) + { + return CreateValidationProblem("InvalidPasskeyState", "The passkey operation is invalid or has expired."); + } + + if (!result.Succeeded) + { + return TypedResults.Problem(result.ToString(), statusCode: StatusCodes.Status401Unauthorized); + } + + // The signInManager already produced the needed response in the form of a cookie or bearer token. + return TypedResults.Empty; + }); + routeGroup.MapPost("/refresh", async Task, UnauthorizedHttpResult, SignInHttpResult, ChallengeHttpResult>> ([FromBody] RefreshRequest refreshRequest, [FromServices] IServiceProvider sp) => { @@ -256,6 +308,91 @@ await signInManager.ValidateSecurityStampAsync(refreshTicket.Principal) is not T }); var accountGroup = routeGroup.MapGroup("/manage").RequireAuthorization(); + var passkeyAccountGroup = accountGroup.MapGroup("/passkeys"); + + passkeyAccountGroup.MapPost("/creationOptions", async Task> + (ClaimsPrincipal claimsPrincipal, [FromServices] IServiceProvider sp) => + { + var signInManager = sp.GetRequiredService>(); + var userManager = signInManager.UserManager; + EnsurePasskeySupport(userManager); + + if (await userManager.GetUserAsync(claimsPrincipal) is not { } user) + { + return TypedResults.NotFound(); + } + + var userId = await userManager.GetUserIdAsync(user); + var userName = await userManager.GetUserNameAsync(user) + ?? throw new NotSupportedException("Users must have a user name."); + var optionsJson = await signInManager.MakePasskeyCreationOptionsAsync(new() + { + Id = userId, + Name = userName, + DisplayName = userName, + }); + + return TypedResults.Content(optionsJson, contentType: "application/json"); + }); + + passkeyAccountGroup.MapPost("", async Task, ValidationProblem, NotFound>> + (ClaimsPrincipal claimsPrincipal, [FromBody] PasskeyRegistrationRequest registration, [FromServices] IServiceProvider sp) => + { + var signInManager = sp.GetRequiredService>(); + var userManager = signInManager.UserManager; + EnsurePasskeySupport(userManager); + + if (await userManager.GetUserAsync(claimsPrincipal) is not { } user) + { + return TypedResults.NotFound(); + } + + if (registration.Name is { Length: > MaxPasskeyNameLength }) + { + return CreateValidationProblem( + "InvalidPasskeyName", + $"Passkey names must be no longer than {MaxPasskeyNameLength} characters."); + } + + if (string.IsNullOrEmpty(registration.CredentialJson)) + { + return CreateValidationProblem("InvalidPasskey", "The passkey could not be validated."); + } + + PasskeyAttestationResult attestationResult; + try + { + attestationResult = await signInManager.PerformPasskeyAttestationAsync(registration.CredentialJson); + } + catch (PasskeyAuthenticationStateException) + { + return CreateValidationProblem("InvalidPasskeyState", "The passkey operation is invalid or has expired."); + } + + if (!attestationResult.Succeeded) + { + return CreateValidationProblem("InvalidPasskey", "The passkey could not be validated."); + } + + var userId = await userManager.GetUserIdAsync(user); + if (!string.Equals(userId, attestationResult.UserEntity.Id, StringComparison.Ordinal)) + { + return CreateValidationProblem("InvalidPasskey", "The passkey could not be validated."); + } + + attestationResult.Passkey.Name = string.IsNullOrEmpty(registration.Name) ? null : registration.Name; + var result = await userManager.AddOrUpdatePasskeyAsync(user, attestationResult.Passkey); + if (!result.Succeeded) + { + return CreateValidationProblem(result); + } + + return TypedResults.Ok(new PasskeyRegistrationResponse + { + CredentialId = WebEncoders.Base64UrlEncode(attestationResult.Passkey.CredentialId), + Name = attestationResult.Passkey.Name, + }); + }); accountGroup.MapPost("/2fa", async Task, ValidationProblem, NotFound>> (ClaimsPrincipal claimsPrincipal, [FromBody] TwoFactorRequest tfaRequest, [FromServices] IServiceProvider sp) => @@ -421,6 +558,28 @@ async Task SendConfirmationEmailAsync(TUser user, UserManager userManager return new IdentityEndpointsConventionBuilder(routeGroup); } + private static bool ConfigureAuthenticationScheme( + SignInManager signInManager, + bool? useCookies, + bool? useSessionCookies) + where TUser : class + { + var useCookieScheme = (useCookies == true) || (useSessionCookies == true); + var isPersistent = (useCookies == true) && (useSessionCookies != true); + signInManager.AuthenticationScheme = useCookieScheme ? IdentityConstants.ApplicationScheme : IdentityConstants.BearerScheme; + + return isPersistent; + } + + private static void EnsurePasskeySupport(UserManager userManager) + where TUser : class + { + if (!userManager.SupportsUserPasskey) + { + throw new NotSupportedException($"{nameof(MapIdentityApi)} requires a user store with passkey support."); + } + } + private static ValidationProblem CreateValidationProblem(string errorCode, string errorDescription) => TypedResults.ValidationProblem(new Dictionary { { errorCode, [errorDescription] } diff --git a/src/Identity/Core/src/PasskeyAuthenticationStateException.cs b/src/Identity/Core/src/PasskeyAuthenticationStateException.cs new file mode 100644 index 000000000000..0749e4ae0ff8 --- /dev/null +++ b/src/Identity/Core/src/PasskeyAuthenticationStateException.cs @@ -0,0 +1,6 @@ +// 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; + +internal sealed class PasskeyAuthenticationStateException(string message) : InvalidOperationException(message); diff --git a/src/Identity/Core/src/PublicAPI.Unshipped.txt b/src/Identity/Core/src/PublicAPI.Unshipped.txt index 7dc5c58110bf..5cb3078c403e 100644 --- a/src/Identity/Core/src/PublicAPI.Unshipped.txt +++ b/src/Identity/Core/src/PublicAPI.Unshipped.txt @@ -1 +1,22 @@ #nullable enable +Microsoft.AspNetCore.Identity.Data.PasskeyLoginRequest +Microsoft.AspNetCore.Identity.Data.PasskeyLoginRequest.CredentialJson.get -> string! +Microsoft.AspNetCore.Identity.Data.PasskeyLoginRequest.CredentialJson.init -> void +Microsoft.AspNetCore.Identity.Data.PasskeyLoginRequest.PasskeyLoginRequest() -> void +Microsoft.AspNetCore.Identity.Data.PasskeyRegistrationRequest +Microsoft.AspNetCore.Identity.Data.PasskeyRegistrationRequest.CredentialJson.get -> string! +Microsoft.AspNetCore.Identity.Data.PasskeyRegistrationRequest.CredentialJson.init -> void +Microsoft.AspNetCore.Identity.Data.PasskeyRegistrationRequest.Name.get -> string? +Microsoft.AspNetCore.Identity.Data.PasskeyRegistrationRequest.Name.init -> void +Microsoft.AspNetCore.Identity.Data.PasskeyRegistrationRequest.PasskeyRegistrationRequest() -> void +Microsoft.AspNetCore.Identity.Data.PasskeyRegistrationResponse +Microsoft.AspNetCore.Identity.Data.PasskeyRegistrationResponse.CredentialId.get -> string! +Microsoft.AspNetCore.Identity.Data.PasskeyRegistrationResponse.CredentialId.init -> void +Microsoft.AspNetCore.Identity.Data.PasskeyRegistrationResponse.Name.get -> string? +Microsoft.AspNetCore.Identity.Data.PasskeyRegistrationResponse.Name.init -> void +Microsoft.AspNetCore.Identity.Data.PasskeyRegistrationResponse.PasskeyRegistrationResponse() -> void +Microsoft.AspNetCore.Identity.Data.PasskeyRequestOptionsRequest +Microsoft.AspNetCore.Identity.Data.PasskeyRequestOptionsRequest.Email.get -> string? +Microsoft.AspNetCore.Identity.Data.PasskeyRequestOptionsRequest.Email.init -> void +Microsoft.AspNetCore.Identity.Data.PasskeyRequestOptionsRequest.PasskeyRequestOptionsRequest() -> void +virtual Microsoft.AspNetCore.Identity.SignInManager.PasskeySignInAsync(string! credentialJson, bool isPersistent) -> System.Threading.Tasks.Task! diff --git a/src/Identity/Core/src/SignInManager.cs b/src/Identity/Core/src/SignInManager.cs index e303012f9849..3246dab37737 100644 --- a/src/Identity/Core/src/SignInManager.cs +++ b/src/Identity/Core/src/SignInManager.cs @@ -561,12 +561,12 @@ public virtual async Task PerformPasskeyAttestationAsy ArgumentException.ThrowIfNullOrEmpty(credentialJson); var passkeyInfo = await RetrievePasskeyAuthenticationInfoAsync() - ?? throw new InvalidOperationException( + ?? throw new PasskeyAuthenticationStateException( "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( + throw new PasskeyAuthenticationStateException( $"Expected passkey operation '{PasskeyOperations.Attestation}', but got '{passkeyInfo.Operation}'. " + $"This may indicate that you have not previously called '{nameof(SignInManager<>)}.{nameof(MakePasskeyCreationOptionsAsync)}()'."); } @@ -605,12 +605,12 @@ public virtual async Task> PerformPasskeyAssertion ArgumentException.ThrowIfNullOrEmpty(credentialJson); var passkeyInfo = await RetrievePasskeyAuthenticationInfoAsync() - ?? throw new InvalidOperationException( + ?? throw new PasskeyAuthenticationStateException( "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( + throw new PasskeyAuthenticationStateException( $"Expected passkey operation '{PasskeyOperations.Assertion}', but got '{passkeyInfo.Operation}'. " + $"This may indicate that you have not previously called '{nameof(SignInManager<>)}.{nameof(MakePasskeyRequestOptionsAsync)}()'."); } @@ -642,24 +642,43 @@ public virtual async Task> PerformPasskeyAssertion /// The task object representing the asynchronous operation containing the /// for the sign-in attempt. /// - public virtual async Task PasskeySignInAsync([StringSyntax(StringSyntaxAttribute.Json)] string credentialJson) + public virtual Task PasskeySignInAsync([StringSyntax(StringSyntaxAttribute.Json)] string credentialJson) + => PasskeySignInAsync(credentialJson, isPersistent: false); + + /// + /// 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. + /// 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 async Task PasskeySignInAsync( + [StringSyntax(StringSyntaxAttribute.Json)] string credentialJson, + bool isPersistent) { var startTimestamp = Stopwatch.GetTimestamp(); try { - var result = await PasskeySignInCoreAsync(credentialJson); - _metrics?.AuthenticateSignIn(typeof(TUser).FullName!, AuthenticationScheme, result, SignInType.Passkey, isPersistent: false, startTimestamp); + var result = await PasskeySignInCoreAsync(credentialJson, isPersistent); + _metrics?.AuthenticateSignIn(typeof(TUser).FullName!, AuthenticationScheme, result, SignInType.Passkey, isPersistent, startTimestamp); return result; } catch (Exception ex) { - _metrics?.AuthenticateSignIn(typeof(TUser).FullName!, AuthenticationScheme, result: null, SignInType.Passkey, isPersistent: false, startTimestamp, ex); + _metrics?.AuthenticateSignIn(typeof(TUser).FullName!, AuthenticationScheme, result: null, SignInType.Passkey, isPersistent, startTimestamp, ex); throw; } } - private async Task PasskeySignInCoreAsync(string credentialJson) + private async Task PasskeySignInCoreAsync(string credentialJson, bool isPersistent) { ArgumentException.ThrowIfNullOrEmpty(credentialJson); @@ -683,7 +702,7 @@ private async Task PasskeySignInCoreAsync(string credentialJson) return SignInResult.Failed; } - return await SignInOrTwoFactorAsync(assertionResult.User, isPersistent: false, bypassTwoFactor: true); + return await SignInOrTwoFactorAsync(assertionResult.User, isPersistent, bypassTwoFactor: true); } [MemberNotNull(nameof(_passkeyHandler))] diff --git a/src/Identity/test/Identity.FunctionalTests/MapIdentityApiTests.cs b/src/Identity/test/Identity.FunctionalTests/MapIdentityApiTests.cs index a41af4addef4..65d1885ca2ab 100644 --- a/src/Identity/test/Identity.FunctionalTests/MapIdentityApiTests.cs +++ b/src/Identity/test/Identity.FunctionalTests/MapIdentityApiTests.cs @@ -239,6 +239,314 @@ await Assert.ThrowsAsync(() => client.PostAsJsonAsync("/identity/login?useCookies=true", new { Email, Password })); } + [Theory] + [InlineData(null)] + [InlineData("unknown@example.com")] + public async Task PasskeyRequestOptionsReturnOkWithoutKnownEmail(string? email) + { + await using var app = await CreatePasskeyAppAsync(); + using var client = app.GetTestClient(); + + var response = await client.PostAsJsonAsync("/identity/passkeys/requestOptions", new { email }); + + AssertOk(response); + Assert.Equal("application/json", response.Content.Headers.ContentType?.MediaType); + var content = await response.Content.ReadFromJsonAsync(); + Assert.False(content.GetProperty("hasUser").GetBoolean()); + } + + [Fact] + public async Task PasskeyRequestOptionsReturnOkForKnownEmail() + { + await using var app = await CreatePasskeyAppAsync(); + using var client = app.GetTestClient(); + + await RegisterAsync(client); + + var response = await client.PostAsJsonAsync("/identity/passkeys/requestOptions", new { Email }); + + AssertOk(response); + var content = await response.Content.ReadFromJsonAsync(); + Assert.True(content.GetProperty("hasUser").GetBoolean()); + } + + [Fact] + public async Task PasskeyCreationOptionsRequireAuthorizationAndDescribeUser() + { + await using var app = await CreatePasskeyAppAsync(); + using var client = app.GetTestClient(); + + AssertUnauthorizedAndEmpty(await client.PostAsync("/identity/manage/passkeys/creationOptions", content: null)); + + await RegisterAsync(client); + await LoginAsync(client); + + var response = await client.PostAsync("/identity/manage/passkeys/creationOptions", content: null); + + AssertOk(response); + var content = await response.Content.ReadFromJsonAsync(); + Assert.NotEmpty(Assert.IsType(content.GetProperty("Id").GetString())); + Assert.Equal(Email, content.GetProperty("Name").GetString()); + Assert.Equal(Email, content.GetProperty("DisplayName").GetString()); + } + + [Fact] + public async Task CanRegisterPasskey() + { + await using var app = await CreatePasskeyAppAsync(); + using var client = app.GetTestClient(); + + await RegisterAsync(client); + await LoginAsync(client); + + var optionsResponse = await client.PostAsync("/identity/manage/passkeys/creationOptions", content: null); + ApplyCookies(client, optionsResponse); + + var registrationResponse = await client.PostAsJsonAsync("/identity/manage/passkeys", new + { + CredentialJson = "valid", + Name = "Laptop", + }); + + AssertOk(registrationResponse); + var registration = await registrationResponse.Content.ReadFromJsonAsync(); + Assert.Equal("AQID", registration.GetProperty("credentialId").GetString()); + Assert.Equal("Laptop", registration.GetProperty("name").GetString()); + + await using var scope = app.Services.CreateAsyncScope(); + var userManager = scope.ServiceProvider.GetRequiredService>(); + var user = Assert.IsType(await userManager.FindByEmailAsync(Email)); + var passkey = Assert.Single(await userManager.GetPasskeysAsync(user)); + Assert.Equal([1, 2, 3], passkey.CredentialId); + Assert.Equal("Laptop", passkey.Name); + } + + [Fact] + public async Task PasskeyRegistrationRejectsLongName() + { + await using var app = await CreatePasskeyAppAsync(); + using var client = app.GetTestClient(); + + await RegisterAsync(client); + await LoginAsync(client); + + var optionsResponse = await client.PostAsync("/identity/manage/passkeys/creationOptions", content: null); + ApplyCookies(client, optionsResponse); + + await AssertValidationProblemAsync( + await client.PostAsJsonAsync("/identity/manage/passkeys", new + { + CredentialJson = "valid", + Name = new string('a', 201), + }), + "InvalidPasskeyName"); + } + + [Fact] + public async Task PasskeyRegistrationRejectsEmptyCredential() + { + await using var app = await CreatePasskeyAppAsync(); + using var client = app.GetTestClient(); + + await RegisterAsync(client); + await LoginAsync(client); + + await AssertValidationProblemAsync( + await client.PostAsJsonAsync("/identity/manage/passkeys", new { CredentialJson = "" }), + "InvalidPasskey"); + } + + [Fact] + public async Task PasskeyRegistrationRejectsInvalidCredential() + { + await using var app = await CreatePasskeyAppAsync(); + using var client = app.GetTestClient(); + + await RegisterAsync(client); + await LoginAsync(client); + + var optionsResponse = await client.PostAsync("/identity/manage/passkeys/creationOptions", content: null); + ApplyCookies(client, optionsResponse); + + await AssertValidationProblemAsync( + await client.PostAsJsonAsync("/identity/manage/passkeys", new { CredentialJson = "invalid" }), + "InvalidPasskey"); + } + + [Fact] + public async Task PasskeyRegistrationRejectsMissingState() + { + await using var app = await CreatePasskeyAppAsync(); + using var client = app.GetTestClient(); + + await RegisterAsync(client); + await LoginAsync(client); + + await AssertValidationProblemAsync( + await client.PostAsJsonAsync("/identity/manage/passkeys", new { CredentialJson = "valid" }), + "InvalidPasskeyState"); + } + + [Fact] + public async Task PasskeyRegistrationRejectsUserMismatch() + { + await using var app = await CreatePasskeyAppAsync(); + using var client = app.GetTestClient(); + + await RegisterAsync(client); + await LoginAsync(client); + + var optionsResponse = await client.PostAsync("/identity/manage/passkeys/creationOptions", content: null); + ApplyCookies(client, optionsResponse); + + await AssertValidationProblemAsync( + await client.PostAsJsonAsync("/identity/manage/passkeys", new { CredentialJson = "mismatch" }), + "InvalidPasskey"); + } + + [Fact] + public async Task CanLoginWithPasskeyBearerToken() + { + await using var app = await CreatePasskeyAppAsync(); + using var client = app.GetTestClient(); + + await RegisterAsync(client); + + var optionsResponse = await client.PostAsJsonAsync("/identity/passkeys/requestOptions", new { Email }); + ApplyCookies(client, optionsResponse); + + var loginResponse = await client.PostAsJsonAsync("/identity/passkeys/login", new { CredentialJson = Email }); + + AssertOk(loginResponse); + var login = await loginResponse.Content.ReadFromJsonAsync(); + var accessToken = login.GetProperty("accessToken").GetString(); + Assert.NotNull(accessToken); + Assert.True(login.TryGetProperty("refreshToken", out _)); + + client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken); + Assert.Equal($"Hello, {Email}!", await client.GetStringAsync("/auth/hello")); + } + + [Theory] + [InlineData("?useCookies=true")] + [InlineData("?useSessionCookies=true")] + public async Task CanLoginWithPasskeyCookie(string query) + { + await using var app = await CreatePasskeyAppAsync(); + using var client = app.GetTestClient(); + + await RegisterAsync(client); + + var optionsResponse = await client.PostAsJsonAsync("/identity/passkeys/requestOptions", new { Email }); + ApplyCookies(client, optionsResponse); + + var loginResponse = await client.PostAsJsonAsync($"/identity/passkeys/login{query}", new { CredentialJson = Email }); + ApplyCookies(client, loginResponse); + + Assert.Equal($"Hello, {Email}!", await client.GetStringAsync("/auth/hello")); + } + + [Fact] + public async Task PasskeyLoginRejectsMissingState() + { + await using var app = await CreatePasskeyAppAsync(); + using var client = app.GetTestClient(); + + await RegisterAsync(client); + + await AssertValidationProblemAsync( + await client.PostAsJsonAsync("/identity/passkeys/login", new { CredentialJson = Email }), + "InvalidPasskeyState"); + } + + [Fact] + public async Task PasskeyLoginRejectsEmptyCredential() + { + await using var app = await CreatePasskeyAppAsync(); + using var client = app.GetTestClient(); + + await AssertValidationProblemAsync( + await client.PostAsJsonAsync("/identity/passkeys/login", new { CredentialJson = "" }), + "InvalidPasskey"); + } + + [Fact] + public async Task PasskeyLoginRejectsInvalidCredential() + { + await using var app = await CreatePasskeyAppAsync(); + using var client = app.GetTestClient(); + + await RegisterAsync(client); + + var optionsResponse = await client.PostAsJsonAsync("/identity/passkeys/requestOptions", new { Email }); + ApplyCookies(client, optionsResponse); + + await AssertProblemAsync( + await client.PostAsJsonAsync("/identity/passkeys/login", new { CredentialJson = "invalid" }), + "Failed"); + } + + [Fact] + public async Task PasskeyLoginRejectsCreationState() + { + await using var app = await CreatePasskeyAppAsync(); + using var client = app.GetTestClient(); + + await RegisterAsync(client); + await LoginAsync(client); + + var optionsResponse = await client.PostAsync("/identity/manage/passkeys/creationOptions", content: null); + ApplyCookies(client, optionsResponse); + + await AssertValidationProblemAsync( + await client.PostAsJsonAsync("/identity/passkeys/login", new { CredentialJson = Email }), + "InvalidPasskeyState"); + } + + [Fact] + public async Task PasskeyOptionsRequireTemporaryCookieScheme() + { + await using var app = await CreatePasskeyAppAsync(bearerOnly: true); + using var client = app.GetTestClient(); + + await RegisterAsync(client); + + await Assert.ThrowsAsync(() + => client.PostAsJsonAsync("/identity/passkeys/requestOptions", new { Email })); + } + + [Fact] + public async Task CanChangePasskeyResponseJsonOptions() + { + await using var app = await CreatePasskeyAppAsync(configureServices: services => + { + services.ConfigureHttpJsonOptions(options => + { + options.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower; + }); + }); + using var client = app.GetTestClient(); + + await RegisterAsync(client); + ApplyCookies( + client, + await client.PostAsJsonAsync("/identity/login?useCookies=true", new { Email, Password })); + + var optionsResponse = await client.PostAsync("/identity/manage/passkeys/creationOptions", content: null); + ApplyCookies(client, optionsResponse); + + var registrationResponse = await client.PostAsJsonAsync("/identity/manage/passkeys", new + { + credential_json = "valid", + name = "Laptop", + }); + + AssertOk(registrationResponse); + var registration = await registrationResponse.Content.ReadFromJsonAsync(); + Assert.Equal("AQID", registration.GetProperty("credential_id").GetString()); + Assert.Equal("Laptop", registration.GetProperty("name").GetString()); + } + [Fact] public async Task CanReadBearerTokenFromQueryString() { @@ -1368,22 +1676,54 @@ private static IdentityBuilder AddIdentityApiEndpoints(IService private static IdentityBuilder AddIdentityApiEndpoints(IServiceCollection services) => AddIdentityApiEndpoints(services); - private static IdentityBuilder AddIdentityApiEndpointsBearerOnly(IServiceCollection services) + private static IdentityBuilder AddIdentityApiEndpointsBearerOnly(IServiceCollection services) + where TUser : class, new() + where TContext : DbContext { services .AddAuthentication() .AddBearerToken(IdentityConstants.BearerScheme); return services - .AddDbContext((sp, options) => options.UseSqlite(sp.GetRequiredService())) - .AddIdentityCore() - .AddEntityFrameworkStores() + .AddDbContext((sp, options) => options.UseSqlite(sp.GetRequiredService())) + .AddIdentityCore() + .AddEntityFrameworkStores() .AddApiEndpoints(); } + private static IdentityBuilder AddIdentityApiEndpointsBearerOnly(IServiceCollection services) + => AddIdentityApiEndpointsBearerOnly(services); + private Task CreateAppAsync(Action? configureServices = null) => CreateAppAsync(configureServices); + private Task CreatePasskeyAppAsync( + bool bearerOnly = false, + Action? configureServices = null) + { + return CreateAppAsync(services => + { + if (bearerOnly) + { + AddIdentityApiEndpointsBearerOnly(services); + } + else + { + AddIdentityApiEndpoints(services); + } + + services.Configure(options => + { + options.Stores.SchemaVersion = IdentitySchemaVersions.Version3; + }); + services.AddScoped, TestPasskeyHandler>(); + configureServices?.Invoke(services); + }); + } + + private sealed class PasskeyDbContext(DbContextOptions options) + : IdentityDbContext(options); + private static Dictionary> AddIdentityActions { get; } = new() { [nameof(AddIdentityApiEndpoints)] = services => AddIdentityApiEndpoints(services), @@ -1547,6 +1887,91 @@ private static string MakeToken(string purpose, string userId) } } + private sealed class TestPasskeyHandler : IPasskeyHandler + { + private const string UnknownUserState = "unknown-user"; + private readonly UserManager _userManager; + + public TestPasskeyHandler(UserManager userManager) + { + _userManager = userManager; + } + + public Task MakeCreationOptionsAsync( + PasskeyUserEntity userEntity, + HttpContext httpContext) + { + return Task.FromResult(new PasskeyCreationOptionsResult + { + CreationOptionsJson = JsonSerializer.Serialize(new { userEntity.Id, userEntity.Name, userEntity.DisplayName }), + AttestationState = JsonSerializer.Serialize(userEntity), + }); + } + + public async Task MakeRequestOptionsAsync( + ApplicationUser? user, + HttpContext httpContext) + { + return new() + { + RequestOptionsJson = JsonSerializer.Serialize(new { hasUser = user is not null }), + AssertionState = user is null ? UnknownUserState : await _userManager.GetUserIdAsync(user), + }; + } + + public Task PerformAttestationAsync(PasskeyAttestationContext context) + { + if (context.CredentialJson == "invalid") + { + return Task.FromResult(PasskeyAttestationResult.Fail(new PasskeyException("Invalid passkey."))); + } + + var userEntity = JsonSerializer.Deserialize(context.AttestationState!); + Assert.NotNull(userEntity); + + if (context.CredentialJson == "mismatch") + { + userEntity = new() + { + Id = "different-user", + Name = userEntity.Name, + DisplayName = userEntity.DisplayName, + }; + } + + return Task.FromResult(PasskeyAttestationResult.Success(CreatePasskey(), userEntity)); + } + + public async Task> PerformAssertionAsync(PasskeyAssertionContext context) + { + if (context.CredentialJson == "invalid") + { + return PasskeyAssertionResult.Fail(new PasskeyException("Invalid passkey.")); + } + + var user = context.AssertionState == UnknownUserState + ? await _userManager.FindByEmailAsync(context.CredentialJson) + : await _userManager.FindByIdAsync(context.AssertionState!); + + return user is null + ? PasskeyAssertionResult.Fail(new PasskeyException("Invalid passkey.")) + : PasskeyAssertionResult.Success(CreatePasskey(), user); + } + + private static UserPasskeyInfo CreatePasskey() + => new( + credentialId: [1, 2, 3], + publicKey: [4, 5, 6], + createdAt: DateTimeOffset.UtcNow, + signCount: 0, + transports: [], + isUserVerified: true, + isBackupEligible: true, + isBackedUp: true, + attestationObject: [7, 8, 9], + clientDataJson: [10, 11, 12]); + } + private sealed class TestEmailSender : IEmailSender { public List Emails { get; set; } = new(); diff --git a/src/Identity/test/Identity.Test/SignInManagerTest.cs b/src/Identity/test/Identity.Test/SignInManagerTest.cs index 2dcba35b6fd0..4a4872074e50 100644 --- a/src/Identity/test/Identity.Test/SignInManagerTest.cs +++ b/src/Identity/test/Identity.Test/SignInManagerTest.cs @@ -423,8 +423,10 @@ public async Task ExternalSignInRequiresVerificationIfNotBypassed(bool bypass) } } - [Fact] - public async Task CanPasskeySignIn() + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task CanPasskeySignIn(bool isPersistent) { // Setup var testMeterFactory = new TestMeterFactory(); @@ -453,13 +455,13 @@ public async Task CanPasskeySignIn() .Verifiable(); var context = new DefaultHttpContext(); var auth = MockAuth(context); - SetupSignIn(context, auth, user.Id, isPersistent: false, loginProvider: null); + SetupSignIn(context, auth, user.Id, isPersistent, loginProvider: null); SetupPasskeyAuth(context, auth); var helper = SetupSignInManager(manager.Object, context); // Act var optionsJson = await helper.MakePasskeyRequestOptionsAsync(user); - var signInResult = await helper.PasskeySignInAsync(credentialJson: ""); + var signInResult = await helper.PasskeySignInAsync(credentialJson: "", isPersistent); // Assert Assert.Equal(expectedOptionsJson, optionsJson); @@ -474,7 +476,7 @@ public async Task CanPasskeySignIn() KeyValuePair.Create("aspnetcore.identity.user_type", "Microsoft.AspNetCore.Identity.Test.PocoUser"), KeyValuePair.Create("aspnetcore.authentication.scheme", "Identity.Application"), KeyValuePair.Create("aspnetcore.identity.sign_in.type", "passkey"), - KeyValuePair.Create("aspnetcore.authentication.is_persistent", false), + KeyValuePair.Create("aspnetcore.authentication.is_persistent", isPersistent), KeyValuePair.Create("aspnetcore.identity.sign_in.result", "success"), ])); Assert.Collection(signInUserPrincipal.GetMeasurementSnapshot(), @@ -482,10 +484,66 @@ public async Task CanPasskeySignIn() [ KeyValuePair.Create("aspnetcore.identity.user_type", "Microsoft.AspNetCore.Identity.Test.PocoUser"), KeyValuePair.Create("aspnetcore.authentication.scheme", "Identity.Application"), - KeyValuePair.Create("aspnetcore.authentication.is_persistent", false), + KeyValuePair.Create("aspnetcore.authentication.is_persistent", isPersistent), ])); } + [Fact] + public async Task PasskeyAssertionThrowsForMissingState() + { + var user = new PocoUser { UserName = "Foo" }; + var passkeyHandler = new Mock>(); + var manager = SetupUserManager(user, passkeyHandler: passkeyHandler.Object); + var context = new DefaultHttpContext(); + var auth = MockAuth(context); + auth.Setup(a => a.AuthenticateAsync(context, IdentityConstants.TwoFactorUserIdScheme)) + .ReturnsAsync(AuthenticateResult.Fail("Not currently signed in.")) + .Verifiable(); + auth.Setup(a => a.SignOutAsync( + context, + IdentityConstants.TwoFactorUserIdScheme, + It.IsAny())) + .Returns(Task.CompletedTask) + .Verifiable(); + var helper = SetupSignInManager(manager.Object, context); + + await Assert.ThrowsAsync( + () => helper.PerformPasskeyAssertionAsync("")); + + auth.Verify(); + } + + [Fact] + public async Task PasskeyAssertionThrowsForMismatchedState() + { + var user = new PocoUser { UserName = "Foo" }; + var passkeyHandler = new Mock>(); + passkeyHandler + .Setup(h => h.MakeCreationOptionsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new PasskeyCreationOptionsResult + { + AttestationState = "", + CreationOptionsJson = "", + }); + var manager = SetupUserManager(user, passkeyHandler: passkeyHandler.Object); + var context = new DefaultHttpContext(); + var auth = MockAuth(context); + SetupPasskeyAuth(context, auth); + var helper = SetupSignInManager(manager.Object, context); + + await helper.MakePasskeyCreationOptionsAsync(new() + { + Id = user.Id, + Name = user.UserName, + DisplayName = user.UserName, + }); + + await Assert.ThrowsAsync( + () => helper.PerformPasskeyAssertionAsync("")); + + auth.Verify(); + } + [Theory] [InlineData(true)] [InlineData(false)]