Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
}
17 changes: 17 additions & 0 deletions src/Identity/Core/src/Data/PasskeyLoginRequest.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// The request type for the "/passkeys/login" endpoint added by <see cref="IdentityApiEndpointRouteBuilderExtensions.MapIdentityApi"/>.
/// </summary>
public sealed class PasskeyLoginRequest
{
/// <summary>
/// The JSON-serialized credential returned by the browser's WebAuthn API.
/// </summary>
public required string CredentialJson { get; init; }
}
22 changes: 22 additions & 0 deletions src/Identity/Core/src/Data/PasskeyRegistrationRequest.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// The request type for the "/manage/passkeys" endpoint added by <see cref="IdentityApiEndpointRouteBuilderExtensions.MapIdentityApi"/>.
/// </summary>
public sealed class PasskeyRegistrationRequest
{
/// <summary>
/// The JSON-serialized credential returned by the browser's WebAuthn API.
/// </summary>
public required string CredentialJson { get; init; }

/// <summary>
/// The optional friendly name for the passkey.
/// </summary>
public string? Name { get; init; }
}
22 changes: 22 additions & 0 deletions src/Identity/Core/src/Data/PasskeyRegistrationResponse.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// The response type for the "/manage/passkeys" endpoint added by <see cref="IdentityApiEndpointRouteBuilderExtensions.MapIdentityApi"/>.
/// </summary>
public sealed class PasskeyRegistrationResponse
{
/// <summary>
/// The Base64Url-encoded credential ID for the registered passkey.
/// </summary>
public required string CredentialId { get; init; }

/// <summary>
/// The friendly name stored for the passkey.
/// </summary>
public string? Name { get; init; }
}
17 changes: 17 additions & 0 deletions src/Identity/Core/src/Data/PasskeyRequestOptionsRequest.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// The request type for the "/passkeys/requestOptions" endpoint added by <see cref="IdentityApiEndpointRouteBuilderExtensions.MapIdentityApi"/>.
/// </summary>
public sealed class PasskeyRequestOptionsRequest
{
/// <summary>
/// The optional email address of the user requesting passkey options.
/// </summary>
public string? Email { get; init; }
}
169 changes: 164 additions & 5 deletions src/Identity/Core/src/IdentityApiEndpointRouteBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,17 +25,23 @@ namespace Microsoft.AspNetCore.Routing;
/// </summary>
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();

/// <summary>
/// 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.
/// </summary>
/// <typeparam name="TUser">The type describing the user. This should match the generic parameter in <see cref="UserManager{TUser}"/>.</typeparam>
/// <param name="endpoints">
/// The <see cref="IEndpointRouteBuilder"/> to add the identity endpoints to.
/// Call <see cref="EndpointRouteBuilderExtensions.MapGroup(IEndpointRouteBuilder, string)"/> to add a prefix to all the endpoints.
/// </param>
/// <remarks>
/// The passkey endpoints use the <see cref="IdentityConstants.TwoFactorUserIdScheme"/> cookie to store state between
/// the options and completion requests. This scheme is registered by <c>AddIdentityApiEndpoints</c>.
/// </remarks>
/// <returns>An <see cref="IEndpointConventionBuilder"/> to further customize the added endpoints.</returns>
public static IEndpointConventionBuilder MapIdentityApi<TUser>(this IEndpointRouteBuilder endpoints)
where TUser : class, new()
Expand Down Expand Up @@ -91,10 +97,7 @@ public static IEndpointConventionBuilder MapIdentityApi<TUser>(this IEndpointRou
([FromBody] LoginRequest login, [FromQuery] bool? useCookies, [FromQuery] bool? useSessionCookies, [FromServices] IServiceProvider sp) =>
{
var signInManager = sp.GetRequiredService<SignInManager<TUser>>();

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

Expand All @@ -119,6 +122,55 @@ public static IEndpointConventionBuilder MapIdentityApi<TUser>(this IEndpointRou
return TypedResults.Empty;
});

var passkeyGroup = routeGroup.MapGroup("/passkeys");

passkeyGroup.MapPost("/requestOptions", async Task<ContentHttpResult>
([FromBody] PasskeyRequestOptionsRequest request, [FromServices] IServiceProvider sp) =>
{
var signInManager = sp.GetRequiredService<SignInManager<TUser>>();
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<Results<Ok<AccessTokenResponse>, EmptyHttpResult, ProblemHttpResult, ValidationProblem>>
([FromBody] PasskeyLoginRequest login, [FromQuery] bool? useCookies, [FromQuery] bool? useSessionCookies, [FromServices] IServiceProvider sp) =>
{
var signInManager = sp.GetRequiredService<SignInManager<TUser>>();
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<Results<Ok<AccessTokenResponse>, UnauthorizedHttpResult, SignInHttpResult, ChallengeHttpResult>>
([FromBody] RefreshRequest refreshRequest, [FromServices] IServiceProvider sp) =>
{
Expand Down Expand Up @@ -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<Results<ContentHttpResult, NotFound>>
(ClaimsPrincipal claimsPrincipal, [FromServices] IServiceProvider sp) =>
{
var signInManager = sp.GetRequiredService<SignInManager<TUser>>();
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<Results<Ok<PasskeyRegistrationResponse>, ValidationProblem, NotFound>>
(ClaimsPrincipal claimsPrincipal, [FromBody] PasskeyRegistrationRequest registration, [FromServices] IServiceProvider sp) =>
{
var signInManager = sp.GetRequiredService<SignInManager<TUser>>();
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<Results<Ok<TwoFactorResponse>, ValidationProblem, NotFound>>
(ClaimsPrincipal claimsPrincipal, [FromBody] TwoFactorRequest tfaRequest, [FromServices] IServiceProvider sp) =>
Expand Down Expand Up @@ -421,6 +558,28 @@ async Task SendConfirmationEmailAsync(TUser user, UserManager<TUser> userManager
return new IdentityEndpointsConventionBuilder(routeGroup);
}

private static bool ConfigureAuthenticationScheme<TUser>(
SignInManager<TUser> 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<TUser>(UserManager<TUser> 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<string, string[]> {
{ errorCode, [errorDescription] }
Expand Down
6 changes: 6 additions & 0 deletions src/Identity/Core/src/PasskeyAuthenticationStateException.cs
Original file line number Diff line number Diff line change
@@ -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);
21 changes: 21 additions & 0 deletions src/Identity/Core/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -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<TUser>.PasskeySignInAsync(string! credentialJson, bool isPersistent) -> System.Threading.Tasks.Task<Microsoft.AspNetCore.Identity.SignInResult!>!
Loading
Loading