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
42 changes: 42 additions & 0 deletions src/Identity/Core/src/IPasskeyHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,16 @@ namespace Microsoft.AspNetCore.Identity;
public interface IPasskeyHandler<TUser>
where TUser : class
{
/// <summary>
/// Gets a value indicating whether this handler supports generating known passkeys signal options.
/// </summary>
/// <remarks>
/// Returns <see langword="false"/> unless the handler implements
/// <see cref="MakeKnownPasskeysSignalOptionsAsync(TUser, PasskeyUserEntity, HttpContext)"/> and can retrieve
/// the user's passkeys.
/// </remarks>
bool SupportsKnownPasskeysSignalOptions => false;

/// <summary>
/// Generates passkey creation options for the specified user entity and HTTP context.
/// </summary>
Expand All @@ -28,6 +38,38 @@ public interface IPasskeyHandler<TUser>
/// <returns>A <see cref="PasskeyRequestOptionsResult"/> representing the result.</returns>
Task<PasskeyRequestOptionsResult> MakeRequestOptionsAsync(TUser? user, HttpContext httpContext);

/// <summary>
/// Generates the options used to signal the current state of a user's known passkeys to authenticators.
/// </summary>
/// <remarks>
/// Handlers that implement this method should also return <see langword="true"/> from
/// <see cref="SupportsKnownPasskeysSignalOptions"/>. See <see href="https://www.w3.org/TR/webauthn-3/#sctn-signal-methods"/>.
/// </remarks>
/// <param name="user">The user whose passkeys should be signaled.</param>
/// <param name="userEntity">The passkey user entity associated with the user's passkeys.</param>
/// <param name="httpContext">The HTTP context associated with the request.</param>
/// <returns>A <see cref="KnownPasskeysSignalOptionsResult"/> representing the result.</returns>
/// <exception cref="NotSupportedException">Thrown when the handler does not support generating known passkeys signal options.</exception>
Task<KnownPasskeysSignalOptionsResult> MakeKnownPasskeysSignalOptionsAsync(TUser user, PasskeyUserEntity userEntity, HttpContext httpContext)
=> throw new NotSupportedException($"'{GetType()}' does not support generating known passkeys signal options.");

/// <summary>
/// Generates options used to signal that a passkey credential is unknown to the server.
/// </summary>
/// <remarks>
/// The signal permanently deletes the passkey from the browser's passkey provider. A handler must only return
/// options when the credential is not registered to any user on the server.
/// See <see href="https://www.w3.org/TR/webauthn-3/#sctn-signal-methods"/>.
/// </remarks>
/// <param name="credentialJson">The JSON representation of the passkey credential.</param>
/// <param name="httpContext">The HTTP context associated with the request.</param>
/// <returns>
/// An <see cref="UnknownPasskeySignalOptionsResult"/> when the credential is unknown to the server,
/// otherwise <see langword="null"/>.
/// </returns>
Task<UnknownPasskeySignalOptionsResult?> MakeUnknownPasskeySignalOptionsAsync(string credentialJson, HttpContext httpContext)
=> Task.FromResult<UnknownPasskeySignalOptionsResult?>(null);

/// <summary>
/// Performs passkey attestation using the provided <see cref="PasskeyAttestationContext"/>.
/// </summary>
Expand Down
3 changes: 3 additions & 0 deletions src/Identity/Core/src/IdentityJsonSerializerContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ namespace Microsoft.AspNetCore.Identity;
[JsonSerializable(typeof(PublicKeyCredential<AuthenticatorAttestationResponse>))]
[JsonSerializable(typeof(PasskeyAttestationState))]
[JsonSerializable(typeof(PasskeyAssertionState))]
[JsonSerializable(typeof(KnownPasskeysSignalOptions))]
[JsonSerializable(typeof(PublicKeyCredentialId))]
[JsonSerializable(typeof(UnknownPasskeySignalOptions))]
[JsonSourceGenerationOptions(
JsonSerializerDefaults.Web,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Expand Down
21 changes: 21 additions & 0 deletions src/Identity/Core/src/KnownPasskeysSignalOptionsResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace Microsoft.AspNetCore.Identity;

/// <summary>
/// Represents the result of a known passkeys signal options generation.
/// </summary>
public sealed class KnownPasskeysSignalOptionsResult
{
/// <summary>
/// Gets or sets the JSON representation of the known passkeys signal options.
/// </summary>
/// <remarks>
/// The structure of this JSON is a superset of the options accepted by the
/// <c>PublicKeyCredential.signalAllAcceptedCredentials()</c> and
/// <c>PublicKeyCredential.signalCurrentUserDetails()</c> JavaScript APIs.
/// See <see href="https://www.w3.org/TR/webauthn-3/#sctn-signal-methods"/>.
/// </remarks>
public required string SignalOptionsJson { get; init; }
}
76 changes: 76 additions & 0 deletions src/Identity/Core/src/PasskeyHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ public PasskeyHandler(UserManager<TUser> userManager, IOptions<IdentityPasskeyOp
_options = options.Value;
}

/// <inheritdoc />
public bool SupportsKnownPasskeysSignalOptions => _userManager.SupportsUserPasskey;

/// <inheritdoc />
public async Task<PasskeyCreationOptionsResult> MakeCreationOptionsAsync(PasskeyUserEntity userEntity, HttpContext httpContext)
{
Expand Down Expand Up @@ -157,6 +160,79 @@ async Task<PublicKeyCredentialDescriptor[]> GetAllowCredentialsAsync()
}
}

/// <inheritdoc />
public async Task<KnownPasskeysSignalOptionsResult> MakeKnownPasskeysSignalOptionsAsync(TUser user, PasskeyUserEntity userEntity, HttpContext httpContext)
{
ArgumentNullException.ThrowIfNull(user);
ArgumentNullException.ThrowIfNull(userEntity);
ArgumentNullException.ThrowIfNull(httpContext);

var userId = await _userManager.GetUserIdAsync(user).ConfigureAwait(false);
if (!string.Equals(userId, userEntity.Id, StringComparison.Ordinal))
{
throw new InvalidOperationException(
$"The user entity ID '{userEntity.Id}' does not match the ID '{userId}' of the specified user.");
}

var passkeys = await _userManager.GetPasskeysAsync(user).ConfigureAwait(false);
var options = new KnownPasskeysSignalOptions
{
RpId = GetServerDomain(httpContext),
UserId = BufferSource.FromString(userEntity.Id),
AllAcceptedCredentialIds = [.. passkeys.Select(p => BufferSource.FromBytes(p.CredentialId))],
Name = userEntity.Name,
DisplayName = userEntity.DisplayName,
};
var optionsJson = JsonSerializer.Serialize(options, IdentityJsonSerializerContext.Default.KnownPasskeysSignalOptions);

return new KnownPasskeysSignalOptionsResult
{
SignalOptionsJson = optionsJson,
};
}

/// <inheritdoc />
public async Task<UnknownPasskeySignalOptionsResult?> MakeUnknownPasskeySignalOptionsAsync(string credentialJson, HttpContext httpContext)
{
if (!_userManager.SupportsUserPasskey)
{
return null;
}

PublicKeyCredentialId? credential;
try
{
credential = JsonSerializer.Deserialize(credentialJson, IdentityJsonSerializerContext.Default.PublicKeyCredentialId);
}
catch (JsonException)
{
return null;
}

if (credential?.Id is not { Length: > 0 })
{
return null;
}

var user = await _userManager.FindByPasskeyIdAsync(credential.Id.ToArray()).ConfigureAwait(false);
if (user is not null)
{
return null;
}

var options = new UnknownPasskeySignalOptions
{
RpId = GetServerDomain(httpContext),
CredentialId = credential.Id,
};
var optionsJson = JsonSerializer.Serialize(options, IdentityJsonSerializerContext.Default.UnknownPasskeySignalOptions);

return new UnknownPasskeySignalOptionsResult
{
SignalOptionsJson = optionsJson,
};
}

/// <inheritdoc/>
public async Task<PasskeyAttestationResult> PerformAttestationAsync(PasskeyAttestationContext context)
{
Expand Down
40 changes: 40 additions & 0 deletions src/Identity/Core/src/Passkeys/KnownPasskeysSignalOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace Microsoft.AspNetCore.Identity;

/// <summary>
/// Represents the information needed to signal the current state of a user's known passkeys to authenticators.
/// </summary>
/// <remarks>
/// This is a superset of the options accepted by the WebAuthn <c>signalAllAcceptedCredentials</c>
/// and <c>signalCurrentUserDetails</c> methods.
/// See <see href="https://www.w3.org/TR/webauthn-3/#sctn-signal-methods"/>.
/// </remarks>
internal sealed class KnownPasskeysSignalOptions
{
/// <summary>
/// Gets the relying party identifier.
/// </summary>
public required string RpId { get; init; }

/// <summary>
/// Gets the user handle of the user that owns the credentials.
/// </summary>
public required BufferSource UserId { get; init; }

/// <summary>
/// Gets the credential IDs that are currently registered for the user.
/// </summary>
public required IReadOnlyList<BufferSource> AllAcceptedCredentialIds { get; init; }

/// <summary>
/// Gets the name of the user.
/// </summary>
public required string Name { get; init; }

/// <summary>
/// Gets the display name of the user.
/// </summary>
public required string DisplayName { get; init; }
}
15 changes: 15 additions & 0 deletions src/Identity/Core/src/Passkeys/PublicKeyCredentialId.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace Microsoft.AspNetCore.Identity;

/// <summary>
/// Represents the credential ID from a public key credential.
/// </summary>
internal sealed class PublicKeyCredentialId
{
/// <summary>
/// Gets the credential ID.
/// </summary>
public required BufferSource Id { get; init; }
}
24 changes: 24 additions & 0 deletions src/Identity/Core/src/Passkeys/UnknownPasskeySignalOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace Microsoft.AspNetCore.Identity;

/// <summary>
/// Represents the information needed to signal that a passkey is unknown to the server.
/// </summary>
/// <remarks>
/// These options are accepted by the WebAuthn <c>signalUnknownCredential</c> method.
/// See <see href="https://www.w3.org/TR/webauthn-3/#sctn-signal-methods"/>.
/// </remarks>
internal sealed class UnknownPasskeySignalOptions
{
/// <summary>
/// Gets the relying party identifier.
/// </summary>
public required string RpId { get; init; }

/// <summary>
/// Gets the credential ID that is unknown to the server.
/// </summary>
public required BufferSource CredentialId { get; init; }
}
17 changes: 17 additions & 0 deletions src/Identity/Core/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,14 @@
#nullable enable
Microsoft.AspNetCore.Identity.IPasskeyHandler<TUser>.MakeKnownPasskeysSignalOptionsAsync(TUser! user, Microsoft.AspNetCore.Identity.PasskeyUserEntity! userEntity, Microsoft.AspNetCore.Http.HttpContext! httpContext) -> System.Threading.Tasks.Task<Microsoft.AspNetCore.Identity.KnownPasskeysSignalOptionsResult!>!
Comment thread
rolandVi marked this conversation as resolved.
Microsoft.AspNetCore.Identity.IPasskeyHandler<TUser>.MakeUnknownPasskeySignalOptionsAsync(string! credentialJson, Microsoft.AspNetCore.Http.HttpContext! httpContext) -> System.Threading.Tasks.Task<Microsoft.AspNetCore.Identity.UnknownPasskeySignalOptionsResult?>!
Microsoft.AspNetCore.Identity.IPasskeyHandler<TUser>.SupportsKnownPasskeysSignalOptions.get -> bool
Microsoft.AspNetCore.Identity.KnownPasskeysSignalOptionsResult
Microsoft.AspNetCore.Identity.KnownPasskeysSignalOptionsResult.KnownPasskeysSignalOptionsResult() -> void
Microsoft.AspNetCore.Identity.KnownPasskeysSignalOptionsResult.SignalOptionsJson.get -> string!
Microsoft.AspNetCore.Identity.KnownPasskeysSignalOptionsResult.SignalOptionsJson.init -> void
Microsoft.AspNetCore.Identity.PasskeyHandler<TUser>.MakeKnownPasskeysSignalOptionsAsync(TUser! user, Microsoft.AspNetCore.Identity.PasskeyUserEntity! userEntity, Microsoft.AspNetCore.Http.HttpContext! httpContext) -> System.Threading.Tasks.Task<Microsoft.AspNetCore.Identity.KnownPasskeysSignalOptionsResult!>!
Microsoft.AspNetCore.Identity.PasskeyHandler<TUser>.MakeUnknownPasskeySignalOptionsAsync(string! credentialJson, Microsoft.AspNetCore.Http.HttpContext! httpContext) -> System.Threading.Tasks.Task<Microsoft.AspNetCore.Identity.UnknownPasskeySignalOptionsResult?>!
Microsoft.AspNetCore.Identity.PasskeyHandler<TUser>.SupportsKnownPasskeysSignalOptions.get -> bool
Microsoft.AspNetCore.Identity.PasskeyEndpointsOptions
Microsoft.AspNetCore.Identity.PasskeyEndpointsOptions.Enroll.get -> string?
Microsoft.AspNetCore.Identity.PasskeyEndpointsOptions.Enroll.set -> void
Expand All @@ -9,5 +19,12 @@ Microsoft.AspNetCore.Identity.PasskeyEndpointsOptions.PrfUsageDetails.get -> str
Microsoft.AspNetCore.Identity.PasskeyEndpointsOptions.PrfUsageDetails.set -> void
Microsoft.AspNetCore.Routing.PasskeyEndpointsEndpointRouteBuilderExtensions
Microsoft.Extensions.DependencyInjection.PasskeyEndpointsServiceCollectionExtensions
Microsoft.AspNetCore.Identity.UnknownPasskeySignalOptionsResult
Microsoft.AspNetCore.Identity.UnknownPasskeySignalOptionsResult.UnknownPasskeySignalOptionsResult() -> void
Microsoft.AspNetCore.Identity.UnknownPasskeySignalOptionsResult.SignalOptionsJson.get -> string!
Microsoft.AspNetCore.Identity.UnknownPasskeySignalOptionsResult.SignalOptionsJson.init -> void
virtual Microsoft.AspNetCore.Identity.SignInManager<TUser>.MakeKnownPasskeysSignalOptionsAsync(TUser! user, Microsoft.AspNetCore.Identity.PasskeyUserEntity! userEntity) -> System.Threading.Tasks.Task<string!>!
virtual Microsoft.AspNetCore.Identity.SignInManager<TUser>.MakeUnknownPasskeySignalOptionsAsync(string! credentialJson) -> System.Threading.Tasks.Task<string?>!
virtual Microsoft.AspNetCore.Identity.SignInManager<TUser>.SupportsKnownPasskeysSignalOptions.get -> bool
static Microsoft.AspNetCore.Routing.PasskeyEndpointsEndpointRouteBuilderExtensions.MapWellKnownPasskeyEndpoints(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder! endpoints) -> Microsoft.AspNetCore.Builder.IEndpointConventionBuilder!
static Microsoft.Extensions.DependencyInjection.PasskeyEndpointsServiceCollectionExtensions.AddPasskeyEndpoints(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, System.Action<Microsoft.AspNetCore.Identity.PasskeyEndpointsOptions!>! configure) -> Microsoft.Extensions.DependencyInjection.IServiceCollection!
89 changes: 89 additions & 0 deletions src/Identity/Core/src/SignInManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -543,6 +543,95 @@ public virtual async Task<string> MakePasskeyRequestOptionsAsync(TUser? user)
return result.RequestOptionsJson;
}

/// <summary>
/// Gets a value indicating whether the registered <see cref="IPasskeyHandler{TUser}"/> supports
/// generating known passkeys signal options.
/// </summary>
/// <remarks>
/// Check this before calling <see cref="MakeKnownPasskeysSignalOptionsAsync(TUser, PasskeyUserEntity)"/>,
/// which throws when the handler does not support known passkeys signal options.
/// </remarks>
public virtual bool SupportsKnownPasskeysSignalOptions => _passkeyHandler?.SupportsKnownPasskeysSignalOptions ?? false;

/// <summary>
/// Generates the options used to signal the current state of a user's known passkeys to authenticators.
/// </summary>
/// <remarks>
/// <para>
/// The returned JSON contains the arguments for both the <c>PublicKeyCredential.signalAllAcceptedCredentials()</c>
/// and <c>PublicKeyCredential.signalCurrentUserDetails()</c> JavaScript APIs, which let an authenticator
/// stop offering passkeys that were removed from the server and keep the user's details up to date.
/// </para>
/// <para>
/// Because these APIs reveal how many passkeys a user has, only call them when the user is authenticated.
/// The <paramref name="userEntity"/> must have the same <see cref="PasskeyUserEntity.Id"/> that was passed to
/// <see cref="MakePasskeyCreationOptionsAsync(PasskeyUserEntity)"/> when the passkeys were created,
/// otherwise the authenticator will not recognize the user and the signal will have no effect.
/// </para>
/// <para>
/// See <see href="https://www.w3.org/TR/webauthn-3/#sctn-signal-methods"/>.
/// </para>
/// </remarks>
/// <param name="user">The user whose passkeys should be signaled.</param>
/// <param name="userEntity">The user entity associated with the user's passkeys.</param>
/// <returns>A JSON string representing the known passkeys signal options.</returns>
/// <exception cref="InvalidOperationException">
/// Thrown when no <see cref="IPasskeyHandler{TUser}"/> is registered.
/// </exception>
/// <exception cref="NotSupportedException">
/// Thrown when the registered <see cref="IPasskeyHandler{TUser}"/> does not support known passkeys signal options.
/// See <see cref="SupportsKnownPasskeysSignalOptions"/>.
/// </exception>
/// <example>
/// The following example shows how the result is used from JavaScript.
/// <code language="javascript">
/// const { rpId, userId, allAcceptedCredentialIds, name, displayName } = JSON.parse(signalOptionsJson);
/// await PublicKeyCredential.signalAllAcceptedCredentials?.({ rpId, userId, allAcceptedCredentialIds });
/// await PublicKeyCredential.signalCurrentUserDetails?.({ rpId, userId, name, displayName });
/// </code>
/// </example>
public virtual async Task<string> MakeKnownPasskeysSignalOptionsAsync(TUser user, PasskeyUserEntity userEntity)
{
ThrowIfNoPasskeyHandler();
ArgumentNullException.ThrowIfNull(user);
ArgumentNullException.ThrowIfNull(userEntity);

var result = await _passkeyHandler.MakeKnownPasskeysSignalOptionsAsync(user, userEntity, Context);
return result.SignalOptionsJson;
}

/// <summary>
/// Generates options used to signal that a passkey credential is unknown to the server.
/// </summary>
/// <remarks>
/// The returned JSON is accepted by the <c>PublicKeyCredential.signalUnknownCredential()</c> JavaScript API.
/// Calling that API permanently deletes the passkey from the browser's passkey provider. This method only
/// returns options when no user on the server has the credential.
/// </remarks>
/// <param name="credentialJson">The JSON representation of the passkey credential.</param>
/// <returns>
/// A JSON string representing the unknown passkey signal options when the credential is unknown to the server,
/// otherwise <see langword="null"/>.
/// </returns>
/// <exception cref="InvalidOperationException">
/// Thrown when no <see cref="IPasskeyHandler{TUser}"/> is registered.
/// </exception>
/// <exception cref="ArgumentException">Thrown when <paramref name="credentialJson"/> is <see langword="null"/> or empty.</exception>
/// <example>
/// The following example shows how the result is used from JavaScript.
/// <code language="javascript">
/// await PublicKeyCredential.signalUnknownCredential?.(signalOptions);
/// </code>
/// </example>
public virtual async Task<string?> MakeUnknownPasskeySignalOptionsAsync(string credentialJson)
{
ThrowIfNoPasskeyHandler();
ArgumentException.ThrowIfNullOrEmpty(credentialJson);

var result = await _passkeyHandler.MakeUnknownPasskeySignalOptionsAsync(credentialJson, Context);
return result?.SignalOptionsJson;
}

/// <summary>
/// Performs passkey attestation for the given <paramref name="credentialJson"/>.
/// </summary>
Expand Down
Loading
Loading