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..955f46e --- /dev/null +++ b/.changeset/enroll-a-passkey-while-signed-in.md @@ -0,0 +1,27 @@ +--- +'@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. 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. + +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', () => {