Skip to content

feat(auth): register a passkey for the signed-in user - #649

Merged
Tr00d merged 1 commit into
masterfrom
feat/auth.passkey.register_passkey
Nov 3, 2026
Merged

Tr00d merged 1 commit into
masterfrom
feat/auth.passkey.register_passkey

Conversation

@Tr00d

@Tr00d Tr00d commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

Summary

A signed-in user can register a new passkey. The SDK fetches the WebAuthn creation challenge from the auth server and submits the credential the caller's platform authenticator created.

Capability: auth.passkey.register_passkey, Register Passkey. No capability spec exists.

Public API

// IGotrueClient<TUser, TSession>, next to the passkey members
Task<PasskeyRegistrationChallenge> StartPasskeyRegistrationAsync(CancellationToken cancellationToken = default);
Task<Passkey> RegisterPasskeyAsync(Guid challengeId, JsonElement credential, CancellationToken cancellationToken = default);

// IGotrueApi<TUser, TSession>
Task<PasskeyRegistrationChallenge> StartPasskeyRegistrationAsync(string jwt, CancellationToken cancellationToken = default);
Task<Passkey> RegisterPasskeyAsync(string jwt, Guid challengeId, JsonElement credential, CancellationToken cancellationToken = default);

New type in Supabase.Gotrue.RegisterPasskey:

public sealed record PasskeyRegistrationChallenge { Guid Id; JsonElement Options; DateTimeOffset ExpiresAt; }

The result reuses Supabase.Gotrue.Common.Passkey.

var challenge = await client.StartPasskeyRegistrationAsync();
var credential = await js.InvokeAsync<JsonElement>("passkeys.create", challenge.Options);
var passkey = await client.RegisterPasskeyAsync(challenge.Id, credential);

Both client members carry [ImplementedCapability("auth.passkey.register_passkey")].

Behavior

  • Start: POST /passkeys/registration/options with the session bearer and no body.
    • Returns {challenge_id, options, expires_at} and maps it to PasskeyRegistrationChallenge. options (W3C PublicKeyCredentialCreationOptions, base64url binary fields, excluding the user's existing passkeys) is passed through untouched. expires_at is unix seconds, mapped to DateTimeOffset.
    • A success without a body throws GotrueException.
  • Register: POST /passkeys/registration/verify with the session bearer, body {"challenge_id":"<uuid>","credential":<RegistrationResponseJSON verbatim>}.
    • Returns {id, friendly_name, created_at}, mapped to Passkey with LastUsedAt null.
    • A credential that is not a JSON object (including default(JsonElement)) throws ArgumentException before any request.
    • A success without a body throws GotrueException.
  • Failures: all are GotrueException. No new FailureHint.Reason; server codes surface on ErrorCode.
    • SDK-side: Reason = Offline when the client is offline; Reason = NoSessionFound without a session or access token. No request is sent in either case.
    • Both steps: 403 no_authorization for anonymous users (route middleware), 422 validation_failed for SSO users, 403 insufficient_aal for MFA users below aal2, 422 too_many_passkeys, 404 passkey_disabled.
    • Register only: 400 webauthn_challenge_not_found (unknown or already used; consumed even by a failed attempt), webauthn_challenge_expired, webauthn_verification_failed, and 422 webauthn_credential_exists.
  • State: none. The session is kept. Spans gotrue.start_passkey_registration and gotrue.register_passkey parent their HTTP spans.

Contract source: supabase/auth internal/api/passkey_registration.go (PasskeyRegistrationOptions, PasskeyRegistrationVerify, PasskeyMetadataResponse) and api.go (route middleware). The auth openapi.yaml does not describe /passkeys.

Deviations from supabase-js

  • No built-in ceremony. supabase-js registerPasskey() drives navigator.credentials.create(). A netstandard2.1 library has no cross-platform WebAuthn API, so the SDK exposes the two server steps and the caller runs the ceremony: Blazor JS interop, Android Credential Manager, webauthn.dll, or a browser on a later request to an ASP.NET backend through the IGotrueApi variants. supabase-kt, supabase-flutter and supabase-swift (off Apple platforms) take the same split.
  • Returns the shared Passkey (LastUsedAt null) instead of a separate metadata-only type.
  • Options are JsonElement passthrough; no AbortSignal.
  • Guid challenge id. Typed exceptions instead of { data, error }.

Tests

Tier Class Pins
E2E RegisterPasskeyTests Covered scenarios below the table.
Contract StartPasskeyRegistrationContractTests Path, bearer and apikey. Challenge mapping with options verbatim. Empty success. Server refusals. Session checks before the wire. Cancellation, no context capture.
Contract RegisterPasskeyContractTests Path, bearer and apikey. Passkey mapping. Empty success. WebAuthn and passkey error codes. ArgumentException and session checks before the wire. Cancellation, no context capture.
Contract RegisterPasskeyApprovalTests Exact bodies: none for the challenge; challenge_id plus the credential verbatim for registration.
Contract OfflineClientContractTests, ObservabilityContractTests Offline guard on both; domain spans parent the HTTP spans.

The E2E scenarios in RegisterPasskeyTests:

  • A software-authenticator credential answering the SDK's challenge is registered and appears in ListPasskeysAsync.
  • The new passkey is named "Passkey" and has never been used.
  • A reused challenge gives webauthn_challenge_not_found.
  • An MFA user on an aal1 session gets insufficient_aal.
  • An anonymous user gets no_authorization.

Changed-line coverage: 100% (44 lines). Mutation: 14/14 tested mutants on changed lines killed, 0 survivors (4 block-removal mutants on async bodies are ignored by Stryker).

Notes

  • Gotrue.Tests/Support/SoftwareAuthenticator.CreateCredential(JsonElement) answers creation options the SDK fetched and leaves the submission to the code under test. The existing seeding helpers are unchanged.
  • No Clone() on the options, as in feat(auth): sign in with a passkey #646: System.Text.Json reads a JsonElement property through JsonElement.ParseValue, which returns an element that owns its data.
  • No config.toml change; passkeys and the WebAuthn relying party are already enabled (feat(auth): list the signed-in user's passkeys #634). Contract tests alone cover the following, because the local stack cannot produce them cheaply:
    • too_many_passkeys
    • the challenge-expiry path
    • webauthn_credential_exists
    • the SSO-user rejection

@Tr00d
Tr00d marked this pull request as ready for review November 3, 2026 09:34
@Tr00d
Tr00d merged commit a3dd4e6 into master Nov 3, 2026
6 checks passed
@Tr00d
Tr00d deleted the feat/auth.passkey.register_passkey branch November 3, 2026 09:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant