From a5745a1ed890906d5d5e1cd8082fa8fed4f406ee Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Tue, 8 Sep 2026 10:12:25 -0400 Subject: [PATCH 1/2] feat(passkey): let a signed-in user add a passkey registerPasskey lived only on the client, so the bundled UI promised something the package did not implement: "You can continue without one and add a passkey later from a device that does". credentials could be listed and deleted but never added. It is now on useAuth() and on the framework-agnostic session actions, and the context version refreshes the session afterwards so a settings screen renders the new passkey without a reload. Enrollment requires a signed-in session as of the matching seamless-auth-api and adapter releases. The bundled flow already satisfies it: verifying the email OTP issues a session before the passkey screen is reached, so no view moves. An application that called registerPasskey() before verifying an address has to move that call after it. A 401 at enrollment now says the session expired rather than "Error registering passkey". It is the session rather than anything about the authenticator, and the generic wording invited a retry with the same expired one. isUnauthenticated(error) is exported for callers rendering their own screens. Also corrects a README example that still passed a token to registerPasskey, a field removed when the wire contract moved to @seamless-auth/types, and documents that enrollment comes after the step that establishes a session. --- .../enroll-a-passkey-while-signed-in.md | 25 ++++++++++ README.md | 40 ++++++++++++++- src/AuthProvider.tsx | 6 +++ src/client/errors.ts | 13 +++++ src/index.ts | 2 + src/session/createAuthSession.ts | 12 +++++ src/views/PassKeyRegistration.tsx | 18 +++++-- tests/RegisterPassKey.test.tsx | 20 ++++++++ tests/authSession.test.ts | 49 +++++++++++++++++++ 9 files changed, 181 insertions(+), 4 deletions(-) create mode 100644 .changeset/enroll-a-passkey-while-signed-in.md diff --git a/.changeset/enroll-a-passkey-while-signed-in.md b/.changeset/enroll-a-passkey-while-signed-in.md new file mode 100644 index 0000000..30a91f1 --- /dev/null +++ b/.changeset/enroll-a-passkey-while-signed-in.md @@ -0,0 +1,25 @@ +--- +'@seamless-auth/react': minor +--- + +Add a passkey while signed in, and say what a 401 at enrollment means. + +`registerPasskey` is now on `useAuth()` and on the framework-agnostic session actions, +not only on the client. The bundled UI already told users they could "add a passkey later +from a device that does", and nothing in the package implemented that: `credentials` could +be listed and deleted but never added. The context version refreshes the session +afterwards, so a settings screen renders the new passkey without a reload. + +Enrollment now requires a signed-in session, which is a coordinated change with +`seamless-auth-api` and the server adapters. The signup flow already satisfies it, because +verifying the email OTP issues a session before the passkey screen is reached, so nothing +in the bundled views moves. An application that called `registerPasskey()` before +verifying an address has to move that call after it. + +A 401 from enrollment now reads "Your session expired before the passkey was saved" rather +than the generic "Error registering passkey." It is the session rather than anything about +the authenticator, and the generic wording invited the user to retry with the same expired +one. `isUnauthenticated(error)` is exported for callers rendering their own screens. + +Corrects a stale README example that still passed a `token` to `registerPasskey`, a field +removed when the wire contract moved to `@seamless-auth/types`. diff --git a/README.md b/README.md index 3bb914d..2a06f95 100644 --- a/README.md +++ b/README.md @@ -612,7 +612,7 @@ on it: ```ts import { getPasskeyPolicyErrorCode } from '@seamless-auth/react'; -const { error } = await authClient.registerPasskey({ token, metadata }); +const { error } = await authClient.registerPasskey({ metadata }); switch (getPasskeyPolicyErrorCode(error)) { case 'attachment_not_allowed': @@ -760,6 +760,10 @@ function CustomRegistration() { } ``` +Enrolment takes the signed-in session, so it comes after the step that establishes one. +Verifying the email OTP signs the user in, which is why the bundled flow offers a passkey +on the screen after it rather than before. + To offer a passkey right after registering, call `registerPasskey()` before `refreshSession()`: ```ts @@ -775,6 +779,40 @@ if (!error) { } ``` +### Adding a passkey from a settings screen + +The same call adds a passkey to an account that already has one, or gives one to a user +who declined at signup. Use it from `useAuth()` rather than the client directly: that +version refreshes the session afterwards, so `credentials` includes the new passkey +without a reload. + +```tsx +function AddPasskey() { + const { registerPasskey, credentials } = useAuth(); + + const add = async () => { + const { error } = await registerPasskey({ + friendlyName: 'My laptop', + platform: 'macOS', + browser: 'Chrome', + deviceInfo: navigator.userAgent, + }); + + if (error) { + // A 401 means the session expired rather than anything about the + // authenticator. `isUnauthenticated(error)` tells the two apart. + } + }; + + return ( + <> +

{credentials.length} passkeys

+ + + ); +} +``` + ### OTP and magic-link continuation > **The request helpers take no identifier.** `requestMagicLink()`, `requestLoginEmailOtp()`, and diff --git a/src/AuthProvider.tsx b/src/AuthProvider.tsx index a98bda9..75357c2 100644 --- a/src/AuthProvider.tsx +++ b/src/AuthProvider.tsx @@ -13,6 +13,9 @@ import { OAuthProvidersResult, OrganizationSwitchResult, PasskeyLoginData, + PasskeyMetadata, + PasskeyRegistrationData, + RegisterPasskeyOptions, StartOAuthLoginInput, StartOAuthLoginResult, StepUpPrfData, @@ -65,6 +68,9 @@ export interface AuthContextType { passkeyAvailable: boolean ) => Promise>; handlePasskeyLogin: () => Promise>; + registerPasskey: ( + input: PasskeyMetadata | RegisterPasskeyOptions + ) => Promise>; refreshStepUpStatus: () => Promise>; verifyStepUpWithPasskey: () => Promise>; verifyStepUpWithPasskeyPrf: ( diff --git a/src/client/errors.ts b/src/client/errors.ts index 4398856..1a48058 100644 --- a/src/client/errors.ts +++ b/src/client/errors.ts @@ -162,6 +162,19 @@ export function getPasskeyPolicyErrorCode( ); } +/** + * Whether a failure means nobody is signed in. + * + * Enrollment takes the signed-in session, so a `401` there is an expired or missing + * one rather than anything about the authenticator, and the two need different + * messages: one asks the user to sign in again, the other to reach for a different + * key. Raised locally, a ceremony failure carries `NETWORK_ERROR_STATUS`, so this + * matches on the response status alone. + */ +export function isUnauthenticated(error: unknown): boolean { + return error instanceof SeamlessAuthError && error.status === 401; +} + /** * Detail recovered from a failed WebAuthn ceremony. * diff --git a/src/index.ts b/src/index.ts index 0c02e75..35c6bdd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -48,6 +48,7 @@ import { getOAuthErrorCode, getPasskeyPolicyErrorCode, getWebAuthnErrorDetail, + isUnauthenticated, OAuthErrorCode, PasskeyPolicyErrorCode, SeamlessAuthError, @@ -79,6 +80,7 @@ export { hasNonPasskeyLoginMethod, hasScopedRole, isPasskeyPrfSupported, + isUnauthenticated, roleGrantsAccess, SeamlessAuthError, useAuth, diff --git a/src/session/createAuthSession.ts b/src/session/createAuthSession.ts index c2c0cf8..8f0a1e0 100644 --- a/src/session/createAuthSession.ts +++ b/src/session/createAuthSession.ts @@ -14,6 +14,9 @@ import { OAuthProvidersResult, OrganizationSwitchResult, PasskeyLoginData, + PasskeyMetadata, + PasskeyRegistrationData, + RegisterPasskeyOptions, StartOAuthLoginInput, StartOAuthLoginResult, StepUpPrfData, @@ -45,6 +48,9 @@ export interface AuthSessionActions { passkeyAvailable: boolean ) => Promise>; handlePasskeyLogin: () => Promise>; + registerPasskey: ( + input: PasskeyMetadata | RegisterPasskeyOptions + ) => Promise>; refreshSession: () => Promise>; logout: () => Promise>; logoutAllSessions: () => Promise>; @@ -229,6 +235,12 @@ export function createAuthSession(options: AuthSessionOptions): AuthSession { handlePasskeyLogin: () => refreshAfter(() => client.loginWithPasskey()), + // Enrollment takes the signed-in session, so this belongs beside the other + // credential actions rather than only on the client. Refreshing afterwards is what + // puts the new passkey in `credentials`, which is how a settings screen renders it + // without a reload. + registerPasskey: input => refreshAfter(() => client.registerPasskey(input)), + refreshSession, logout, logoutAllSessions, diff --git a/src/views/PassKeyRegistration.tsx b/src/views/PassKeyRegistration.tsx index f828708..1541fba 100644 --- a/src/views/PassKeyRegistration.tsx +++ b/src/views/PassKeyRegistration.tsx @@ -6,7 +6,11 @@ import { useAuth } from '@/AuthProvider'; import { PasskeyAttachment, PasskeyMetadata } from '@/client/createSeamlessAuthClient'; -import { getPasskeyPolicyErrorCode, type PasskeyPolicyErrorCode } from '@/client/errors'; +import { + getPasskeyPolicyErrorCode, + isUnauthenticated, + type PasskeyPolicyErrorCode, +} from '@/client/errors'; import React, { useState } from 'react'; import { useAuthClient } from '@/hooks/useAuthClient'; import { hasNonPasskeyLoginMethod, useLoginMethods } from '@/hooks/useLoginMethods'; @@ -86,8 +90,16 @@ const PasskeyRegistration: React.FC = () => { console.error('Passkey registration failed.'); setStatus('error'); // A policy refusal names something the user can act on, for example - // reaching for a security key instead. Anything else stays generic. - setMessage(policyRefusalMessage(error) ?? 'Error registering passkey.'); + // reaching for a security key instead. A 401 is the session, not the + // authenticator: enrollment takes the signed-in one, so the answer is to + // sign in again rather than to try a different key. Anything else stays + // generic. + setMessage( + policyRefusalMessage(error) ?? + (isUnauthenticated(error) + ? 'Your session expired before the passkey was saved. Sign in again to add one.' + : 'Error registering passkey.') + ); } }; diff --git a/tests/RegisterPassKey.test.tsx b/tests/RegisterPassKey.test.tsx index 0a1a843..4c3e970 100644 --- a/tests/RegisterPassKey.test.tsx +++ b/tests/RegisterPassKey.test.tsx @@ -121,6 +121,26 @@ describe('RegisterPasskey', () => { }); }); + // Enrollment takes the signed-in session, so a 401 here is the session rather + // than anything about the authenticator, and it used to read as the generic + // failure that tells the user to try again with the same expired session. + it('tells the user to sign in again on a 401', async () => { + mockRegisterPasskey.mockResolvedValueOnce({ + data: null, + error: new SeamlessAuthError('unauthorized', 401, { error: 'unauthorized' }), + }); + + render(); + + fireEvent.click(await screen.findByText(/Register Passkey/i)); + + await waitFor(() => { + expect(screen.getByText(/Your session expired/i)).toBeInTheDocument(); + }); + + expect(screen.queryByText(/Error registering passkey/i)).not.toBeInTheDocument(); + }); + it('handles WebAuthnError', async () => { mockRegisterPasskey.mockResolvedValueOnce({ data: null, diff --git a/tests/authSession.test.ts b/tests/authSession.test.ts index a077d57..f9c7134 100644 --- a/tests/authSession.test.ts +++ b/tests/authSession.test.ts @@ -7,9 +7,15 @@ import { createAuthSession } from '../src/session/createAuthSession'; import { createMemoryStorage, SessionStoragePort } from '../src/session/storage'; import { createFetchWithAuth } from '../src/fetchWithAuth'; +import { startRegistration } from '@simplewebauthn/browser'; jest.mock('../src/fetchWithAuth'); +jest.mock('@simplewebauthn/browser', () => ({ + ...jest.requireActual('@simplewebauthn/browser'), + startRegistration: jest.fn(), +})); + const mockFetchWithAuth = jest.fn(); (createFetchWithAuth as jest.Mock).mockReturnValue(mockFetchWithAuth); @@ -18,6 +24,13 @@ const apiHost = 'https://api.example.com'; const user = { id: '1', email: 'test@example.com', phone: '', roles: ['admin'] }; +const metadata = { + friendlyName: 'Second device', + platform: 'macOS', + browser: 'Chrome', + deviceInfo: 'MacBook Pro', +}; + const okResponse = (body: unknown = {}) => ({ ok: true, json: async () => body }) as unknown as Response; @@ -284,6 +297,42 @@ describe('createAuthSession', () => { expect(session.getState().credentials).toEqual([]); }); + + // Enrollment takes the signed-in session, so a settings screen can add a passkey + // without leaving the account. Refreshing is what puts it in `credentials`. + it('adds an enrolled passkey to state', async () => { + const session = await loadWithCredential(); + + (startRegistration as jest.Mock).mockResolvedValueOnce({ id: 'cred-2' }); + mockFetchWithAuth + .mockResolvedValueOnce(okResponse({ challenge: 'challenge' })) + .mockResolvedValueOnce(okResponse({ message: 'Credential registered' })) + .mockResolvedValueOnce( + okResponse({ + user, + credentials: [{ id: 'cred-1' }, { id: 'cred-2' }], + }) + ); + + const { data, error } = await session.actions.registerPasskey(metadata); + + expect(error).toBeNull(); + expect(data).toMatchObject({ credentialId: 'cred-2' }); + expect(session.getState().credentials).toHaveLength(2); + }); + + it('leaves state alone when enrollment fails', async () => { + const session = await loadWithCredential(); + + mockFetchWithAuth.mockResolvedValueOnce( + failedResponse(401, { error: 'unauthorized' }) + ); + + const { error } = await session.actions.registerPasskey(metadata); + + expect(error).toMatchObject({ status: 401 }); + expect(session.getState().credentials).toHaveLength(1); + }); }); describe('role checks', () => { From 206a25bb5ffdf4a30883f492c88d5d03152142ad Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Tue, 8 Sep 2026 10:15:44 -0400 Subject: [PATCH 2/2] docs(changeset): say that all three packages upgrade in lockstep The auth API and the server adapter have no safe release order between them, so enrollment answers 401 until both land. An adopter upgrading one package at a time needs to read that here. --- .changeset/enroll-a-passkey-while-signed-in.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.changeset/enroll-a-passkey-while-signed-in.md b/.changeset/enroll-a-passkey-while-signed-in.md index 30a91f1..955f46e 100644 --- a/.changeset/enroll-a-passkey-while-signed-in.md +++ b/.changeset/enroll-a-passkey-while-signed-in.md @@ -11,7 +11,9 @@ be listed and deleted but never added. The context version refreshes the session afterwards, so a settings screen renders the new passkey without a reload. Enrollment now requires a signed-in session, which is a coordinated change with -`seamless-auth-api` and the server adapters. The signup flow already satisfies it, because +`seamless-auth-api` and the server adapters. Upgrade all three together: the auth API and +the adapter have no safe release order between them, and enrollment answers `401` until +both land. The signup flow already satisfies it, because verifying the email OTP issues a session before the passkey screen is reached, so nothing in the bundled views moves. An application that called `registerPasskey()` before verifying an address has to move that call after it.