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)]