dotnet / dotnet/aspnetcore

Passkey signal options for the three WebAuthn signal methods

Open
#68,165 0 comments 0 reactions 0 assignees View on GitHub
api-proposal api-suggestion area-identity
Dominant language
C#
Stars
38.4k
Forks
10.9k
Avg merge
2d 10h
Merged PRs (30d)
281

Description

Tracked by #67299, implemented in #68129. Supersedes #68511.

## Background and Motivation

WebAuthn has [three signal methods](https://www.w3.org/TR/webauthn-3/#sctn-signal-methods) that
let a site tell the browser's passkey provider what it currently knows. Without them a passkey
deleted on the server keeps being offered at sign-in forever, and a changed username stays stale
next to the passkey.

An app cannot easily assemble these payloads. The user handle is not the user ID, it is the UTF-8
bytes of the ID base64url encoded through the internal `BufferSource`, and the RP ID may be
overridden via `IdentityPasskeyOptions.ServerDomain`. Get either wrong and the browser silently
matches nothing, so the app sees success while the passkey stays visible.

`signalUnknownCredential` is worse, because it deletes permanently. The safe question is not "why
did sign-in fail" but "does any user on this server hold this credential", which is
`UserManager.FindByPasskeyIdAsync`. Getting that judgement wrong deletes someone's working passkey.

## Proposed API

```diff
namespace Microsoft.AspNetCore.Identity;

public interface IPasskeyHandler where TUser : class
{
+ bool SupportsPasskeySignalOptions => false;
+
+ Task MakeAllAcceptedCredentialsSignalOptionsAsync(TUser user, HttpContext httpContext)
+ => throw new NotSupportedException($"'{GetType()}' does not support generating passkey signal options.");
+
+ Task MakeCurrentUserDetailsSignalOptionsAsync(TUser user, PasskeyUserEntity userEntity, HttpContext httpContext)
+ => throw new NotSupportedException($"'{GetType()}' does not support generating passkey signal options.");
+
+ Task MakeUnknownCredentialSignalOptionsAsync(string credentialJson, HttpContext httpContext)
+ => Task.FromResult(null);
}

public sealed class PasskeyHandler : IPasskeyHandler where TUser : class
{
+ public bool SupportsPasskeySignalOptions { get; }
+ public Task MakeAllAcceptedCredentialsSignalOptionsAsync(TUser user, HttpContext httpContext);
+ public Task MakeCurrentUserDetailsSignalOptionsAsync(TUser user, PasskeyUserEntity userEntity, HttpContext httpContext);
+ public Task MakeUnknownCredentialSignalOptionsAsync(string credentialJson, HttpContext httpContext);
}

+public sealed class AllAcceptedCredentialsSignalOptionsResult
+{
+ public required string SignalOptionsJson { get; init; }
+}

+public sealed class CurrentUserDetailsSignalOptionsResult
+{
+ public required string SignalOptionsJson { get; init; }
+}

+public sealed class UnknownCredentialSignalOptionsResult
+{
+ public required string SignalOptionsJson { get; init; }
+}

public class SignInManager where TUser : class
{
+ public virtual bool SupportsPasskeySignalOptions { get; }
+ public virtual Task MakeAllAcceptedCredentialsSignalOptionsAsync(TUser user);
+ public virtual Task MakeCurrentUserDetailsSignalOptionsAsync(TUser user, PasskeyUserEntity userEntity);
+ public virtual Task MakeUnknownCredentialSignalOptionsAsync(string credentialJson);
}
```

One method per spec method, each returning exactly one spec dictionary:

| Method | JSON |
| --- | --- |
| `MakeAllAcceptedCredentialsSignalOptionsAsync` | `{ rpId, userId, allAcceptedCredentialIds }` |
| `MakeCurrentUserDetailsSignalOptionsAsync` | `{ rpId, userId, name, displayName }` |
| `MakeUnknownCredentialSignalOptionsAsync` | `{ rpId, credentialId }` |

The work goes on `IPasskeyHandler` because that is where RP ID resolution already lives for
creation and assertion, so a custom handler signals the same RP ID it issued. `SignInManager`
keeps thin wrappers, matching `MakePasskeyCreationOptionsAsync` and
`MakePasskeyRequestOptionsAsync`, since apps hold a `SignInManager` and not a handler.

Only `MakeCurrentUserDetailsSignalOptionsAsync` takes a `PasskeyUserEntity`, because sending a new
name is its entire job. The other two derive everything they need from what they are given, so
there is no second source of the user handle to disagree with the first.

## Usage Examples

Server side:

```csharp
if (SignInManager.SupportsPasskeySignalOptions)
{
acceptedJson = await SignInManager.MakeAllAcceptedCredentialsSignalOptionsAsync(user);
}
```

```csharp
if (SignInManager.SupportsPasskeySignalOptions)
{
detailsJson = await SignInManager.MakeCurrentUserDetailsSignalOptionsAsync(user, new()
{
Id = userId,
Name = userName,
DisplayName = userName,
});
}
```

```csharp
var result = await SignInManager.PasskeySignInAsync(credentialJson);

if (!result.Succeeded)
{
// null when the credential is known, or when it cannot safely be told
unknownJson = await SignInManager.MakeUnknownCredentialSignalOptionsAsync(credentialJson);
}
```

Client side, each payload goes straight in:

```js
await PublicKeyCredential.signalAllAcceptedCredentials?.(JSON.parse(acceptedJson));
await PublicKeyCredential.signalCurrentUserDetails?.(JSON.parse(detailsJson));
await PublicKeyCredential.signalUnknownCredential?.(JSON.parse(unknownJson));
```

## Alternative Designs

**One method returning a superset of both user-facing payloads.** `signalAllAcceptedCredentials`
and `signalCurrentUserDetails` overlap on `rpId` and `userId`, so a single flat object covers both
and the JavaScript destructures it. This is what I proposed first, and it is worse for three
reasons. A browser can support one signal and not the other, `ClientCapability` enumerates them
separately, so JS that can only make one call still has to pick the right fields out of a flat
object. Field names could collide as the spec evolves. And a field added later to only one
dictionary would have no evident owner in the JSON. The cost of splitting is duplicating `rpId`
and `userId`.

**A separate `IPasskeySignalOptionsHandler` interface** instead of default interface methods
plus `SupportsPasskeySignalOptions`. Support would then be a type check rather than a flag the
handler reports about itself, which matches `UserManager.SupportsUserPasskey`
(`Store is IUserPasskeyStore`) and cannot be got wrong. The cost is discoverability: the
methods no longer appear on the interface an implementer is already writing, so they have to know
the second type exists.

## Risks

`SupportsPasskeySignalOptions` covers the first two only. They inherit a throwing default, so a
caller has to be able to ask first. `MakeUnknownCredentialSignalOptionsAsync` has no flag because
its default returns null and null already means "do not signal", so a flag would be a second way
to say the same thing and the two could be set inconsistently.

A handler that implements the first two but forgets to set the flag gets a silent no-op. The
separate-interface alternative above removes that.

Returning a JSON string rather than a typed object matches the existing passkey methods, but it
means the shape is documented rather than enforced. It is what makes the pass-through possible.

Contributor guide

Open the contributing guide

Research direction

The proposal names IPasskeyHandler, PasskeyHandler, and SignInManager, with MakePasskeyCreationOptionsAsync and MakePasskeyRequestOptionsAsync as existing wrapper references. Check issue #68129 first to confirm the implementation and compare its three signal payloads and support behavior with this specification. Done means the work is accounted for in that implementation rather than started here.

Written by the indexing model from the issue text.

Assessment

Tech stack
csharp
Domain
authentication, backend-api-design
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Stale
Clarity
Clearly specified
Newbie friendliness
20/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.