From 78bf8738518cb5e4963313bf14cdb2d28a5569c1 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sat, 24 Oct 2026 22:28:51 -0400 Subject: [PATCH 1/8] fix(deps): require @seamless-auth/types 0.31.0 for account recovery --- .changeset/types-0-31.md | 7 +++++++ package-lock.json | 22 +++++++++++----------- packages/client/package.json | 2 +- packages/react-native/package.json | 2 +- packages/react/package.json | 2 +- 5 files changed, 21 insertions(+), 14 deletions(-) create mode 100644 .changeset/types-0-31.md diff --git a/.changeset/types-0-31.md b/.changeset/types-0-31.md new file mode 100644 index 0000000..7e015bc --- /dev/null +++ b/.changeset/types-0-31.md @@ -0,0 +1,7 @@ +--- +'@seamless-auth/client': patch +'@seamless-auth/react': patch +'@seamless-auth/react-native': patch +--- + +Require `@seamless-auth/types` 0.31.0, which carries the recovery code, account recovery and recovery session shapes of auth API v0.20. diff --git a/package-lock.json b/package-lock.json index d684d0d..b605ba6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4642,9 +4642,9 @@ "link": true }, "node_modules/@seamless-auth/types": { - "version": "0.29.0", - "resolved": "https://registry.npmjs.org/@seamless-auth/types/-/types-0.29.0.tgz", - "integrity": "sha512-Moiuw17RNZaORrrb5Wypjvl1iBj5K1PqDQkkK8ACYh9NSHD7khMyHaTsx9AzK6zOqROXgRRrIAurk9S1w2CZhA==", + "version": "0.31.0", + "resolved": "https://registry.npmjs.org/@seamless-auth/types/-/types-0.31.0.tgz", + "integrity": "sha512-3XcwiseZWXaus7QxDMkgHvHPRF+4qwYVl78KGLVTf+ktAN9cKhBp2zbW6jJ1/aEN7Zd08Gy+4C/CRS57MYb2TQ==", "license": "Apache-2.0", "dependencies": { "zod": "^4.5.0" @@ -19030,10 +19030,10 @@ }, "packages/client": { "name": "@seamless-auth/client", - "version": "0.7.0", + "version": "0.7.1", "license": "Apache-2.0", "dependencies": { - "@seamless-auth/types": "^0.29.0", + "@seamless-auth/types": "^0.31.0", "@simplewebauthn/browser": "^13.1.0", "libphonenumber-js": "^1.12.7" }, @@ -19044,11 +19044,11 @@ }, "packages/react": { "name": "@seamless-auth/react", - "version": "0.18.0", + "version": "0.18.1", "license": "Apache-2.0", "dependencies": { - "@seamless-auth/client": "^0.7.0", - "@seamless-auth/types": "^0.29.0", + "@seamless-auth/client": "^0.7.1", + "@seamless-auth/types": "^0.31.0", "@simplewebauthn/browser": "^13.1.0", "libphonenumber-js": "^1.12.7" }, @@ -19069,11 +19069,11 @@ }, "packages/react-native": { "name": "@seamless-auth/react-native", - "version": "0.3.2", + "version": "0.3.3", "license": "Apache-2.0", "dependencies": { - "@seamless-auth/client": "^0.7.0", - "@seamless-auth/types": "^0.29.0" + "@seamless-auth/client": "^0.7.1", + "@seamless-auth/types": "^0.31.0" }, "engines": { "node": ">=22", diff --git a/packages/client/package.json b/packages/client/package.json index bed198c..9ec3de9 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -42,7 +42,7 @@ "provenance": true }, "dependencies": { - "@seamless-auth/types": "^0.29.0", + "@seamless-auth/types": "^0.31.0", "@simplewebauthn/browser": "^13.1.0", "libphonenumber-js": "^1.12.7" }, diff --git a/packages/react-native/package.json b/packages/react-native/package.json index 49b90b9..7abcbb4 100644 --- a/packages/react-native/package.json +++ b/packages/react-native/package.json @@ -46,7 +46,7 @@ }, "dependencies": { "@seamless-auth/client": "^0.7.1", - "@seamless-auth/types": "^0.29.0" + "@seamless-auth/types": "^0.31.0" }, "sideEffects": false } diff --git a/packages/react/package.json b/packages/react/package.json index 5cb6538..1ff27a1 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -57,7 +57,7 @@ }, "dependencies": { "@seamless-auth/client": "^0.7.1", - "@seamless-auth/types": "^0.29.0", + "@seamless-auth/types": "^0.31.0", "@simplewebauthn/browser": "^13.1.0", "libphonenumber-js": "^1.12.7" }, From d977b503b5d94daf225749590bd1d7b121ddd053 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sat, 24 Oct 2026 22:37:15 -0400 Subject: [PATCH 2/8] feat(client): recovery codes, account recovery and recovery sessions Add client methods for the auth API v0.20 recovery routes, the bearer route rules that mirror their adapter manifest credentials, and the shared flows the bundled screens of every binding build on. A session issued by a recovery code or a completed account recovery is tracked by the client and mirrored into the session state. After a passkey or TOTP enrollment made from it, the client refreshes the session so the new access token no longer carries the recovery claim. --- .changeset/recovery-client-primitives.md | 14 + .../src/client/createSeamlessAuthClient.ts | 251 ++++++++- packages/client/src/client/errors.ts | 86 +++ packages/client/src/flows/index.ts | 1 + packages/client/src/flows/recovery.ts | 203 ++++++++ .../client/src/session/createAuthSession.ts | 24 +- packages/client/src/transport.ts | 20 + packages/client/tests/authSession.test.ts | 3 +- packages/client/tests/oauthBinding.test.ts | 1 + packages/client/tests/recovery.test.ts | 490 ++++++++++++++++++ .../tests/recoveryTransport.node.test.ts | 158 ++++++ 11 files changed, 1240 insertions(+), 11 deletions(-) create mode 100644 .changeset/recovery-client-primitives.md create mode 100644 packages/client/src/flows/recovery.ts create mode 100644 packages/client/tests/recovery.test.ts create mode 100644 packages/client/tests/recoveryTransport.node.test.ts diff --git a/.changeset/recovery-client-primitives.md b/.changeset/recovery-client-primitives.md new file mode 100644 index 0000000..5605797 --- /dev/null +++ b/.changeset/recovery-client-primitives.md @@ -0,0 +1,14 @@ +--- +'@seamless-auth/client': minor +'@seamless-auth/react-native': minor +--- + +Add the auth API v0.20 recovery primitives to the client and the session store. + +- Recovery codes: `getRecoveryCodeStatus()`, `generateRecoveryCodes()` and `redeemRecoveryCode(code)`. Replacing an existing set needs a fresh step-up; the `403` it answers without one is recognized by `isStepUpRequired()`, and `generateRecoveryCodesWithStepUp()` runs the step-up and tries again. +- Self-service account recovery: `startAccountRecovery(channel)`, `verifyAccountRecovery(code)`, `getAccountRecoveryStatus()`, `completeAccountRecovery()`, `cancelAccountRecovery(cancelToken)`, and for a signed-in owner `listAccountRecoveryRequests()` and `cancelAccountRecoveryRequest(id)`. +- Recovery sessions: redeeming a code or completing a recovery issues a session that can only enroll a factor. `isRecoverySession()` on the client and `recoverySession` in the session state report it, and the session actions `redeemRecoveryCode` and `completeAccountRecovery` sign in with it. After a passkey registration or TOTP enrollment made from a recovery session the client refreshes the session (`POST /refresh`), so the new access token drops the recovery claim the adapters' guards refuse. +- Flows for every binding: `loadRecoveryOptions()`, `recoveryCancelTokenFromHash()`, `cancelRecoveryFromLink()`, `recoveryErrorMessage()`, `recoveryCodesText()`, `passkeyKind()` and `passkeyDisplayName()`, plus `getRecoveryErrorCode()` and `isEnrollmentRequired()`. +- Bearer transport routes each recovery route with the credential the auth API's adapter manifest gives it. + +Needs an auth API with account recovery (v0.20) and a server adapter that proxies its manifest routes. diff --git a/packages/client/src/client/createSeamlessAuthClient.ts b/packages/client/src/client/createSeamlessAuthClient.ts index baf9fcd..6a8fb4e 100644 --- a/packages/client/src/client/createSeamlessAuthClient.ts +++ b/packages/client/src/client/createSeamlessAuthClient.ts @@ -12,6 +12,10 @@ import type { } from '@simplewebauthn/browser'; import type { + AccountRecoveryChannel as AccountRecoveryChannelShape, + AccountRecoveryStartResponse, + AccountRecoveryState as AccountRecoveryStateShape, + AccountRecoveryStatus, AddOrganizationMemberRequest, AuthenticatorAttachmentPolicy, CreateOrganizationRequest, @@ -30,6 +34,9 @@ import type { OrganizationSwitchResponse, PublicOAuthProvider, PublicSystemConfigResponse, + RecoveryCodesGenerated, + RecoveryCodesStatus, + RecoverySessionResponse, RegistrationRequest, StartOAuthLoginResponse, StepUpMethod as StepUpMethodShape, @@ -237,6 +244,46 @@ export interface StepUpPrfData extends StepUpStatus { prf: PasskeyPrfResult; } +/** `GET /recovery-codes`: how many of the current set are unused. */ +export type RecoveryCodeStatus = RecoveryCodesStatus; + +/** A new set of recovery codes. The only time they are ever returned. */ +export type RecoveryCodesResult = RecoveryCodesGenerated; + +/** + * The session a recovery code or a completed account recovery issues, minus its + * session material. `recovery` is always true and `nextStep` says what lifts the + * restriction: until a passkey or authenticator app is enrolled, the session can + * only enroll one, manage recovery codes, read the user and sign out. + */ +export type RecoverySessionResult = Omit< + RecoverySessionResponse, + 'token' | 'refreshToken' | 'sub' +>; + +export type AccountRecoveryChannel = AccountRecoveryChannelShape; + +export type AccountRecoveryState = AccountRecoveryStateShape; + +export type AccountRecoveryStartResult = AccountRecoveryStartResponse; + +/** Where a recovery request stands: `pending` until `completesAt`, then `ready`. */ +export type AccountRecoveryStatusResult = AccountRecoveryStatus; + +/** + * An open recovery request on the signed-in account. Composed from the upstream + * status and channel shapes because `@seamless-auth/types` 0.31.0 does not publish + * the list response of `GET /recovery/requests`. + */ +export type AccountRecoveryRequest = AccountRecoveryStatus & { + id: string; + channel: AccountRecoveryChannel; +}; + +export interface AccountRecoveryRequestsResult { + requests: AccountRecoveryRequest[]; +} + export type LogoutScope = LogoutScopeShape; export interface LogoutOptions { @@ -326,6 +373,57 @@ export interface SeamlessAuthClient { verifyTotpEnrollment: (code: string) => Promise>; disableTotp: (code: string) => Promise>; verifyStepUpWithTotp: (code: string) => Promise>; + /** + * Whether this client holds a recovery session: one a recovery code or a + * completed account recovery issued, which has not enrolled a factor since. + * Kept in memory, so it does not survive a reload. + */ + isRecoverySession: () => boolean; + getRecoveryCodeStatus: () => Promise>; + /** + * Creates a new set and invalidates the old one. Replacing an existing set + * needs a fresh step-up: without one this fails with a `403` that + * `isStepUpRequired()` recognizes. + */ + generateRecoveryCodes: () => Promise>; + /** + * Signs in with a recovery code, on the sign-in `login()` started. Issues a + * recovery session, so the next step is enrolling a passkey. + */ + redeemRecoveryCode: ( + code: string + ) => Promise>; + /** Sends a recovery code to the verified address on file for the channel. */ + startAccountRecovery: ( + channel: AccountRecoveryChannel + ) => Promise>; + /** Opens (or resumes) the recovery request with the code `startAccountRecovery()` sent. */ + verifyAccountRecovery: ( + code: string + ) => Promise>; + getAccountRecoveryStatus: () => Promise< + SeamlessAuthResult + >; + /** + * Completes a `ready` request: signs out every session on the account and + * issues a recovery session. + */ + completeAccountRecovery: () => Promise>; + /** + * Cancels a recovery request with the token from the notice's link. Needs no + * session, and answers the same whether or not the token matched. + */ + cancelAccountRecovery: ( + cancelToken: string + ) => Promise>; + /** The signed-in account's open recovery requests. */ + listAccountRecoveryRequests: () => Promise< + SeamlessAuthResult + >; + /** Cancels one of the signed-in account's open recovery requests. */ + cancelAccountRecoveryRequest: ( + id: string + ) => Promise>; updateCredential: (input: { id: string; friendlyName: string | null; @@ -459,6 +557,44 @@ export const createSeamlessAuthClient = ( const fetchWithAuth = transport.fetch; const passkeys = opts.passkeys ?? createBrowserPasskeyPort(); + let recoverySession = false; + + // The API lifts a recovery session's restriction as soon as a factor is enrolled, + // but the access token keeps its recovery claim until it is rotated, and the + // adapters' guards refuse that claim. A failed refresh leaves the enrollment + // standing, so it is not reported as the enrollment failing. + async function afterEnrollment() { + if (!recoverySession) return; + + try { + if (await transport.refresh()) { + recoverySession = false; + } + } catch { + // The next refresh, silent or explicit, rotates the token instead. + } + } + + async function issuedRecoverySession( + request: Promise, + fallbackMessage: string + ): Promise> { + const result = await requestResult(request, fallbackMessage); + + if (result.error) { + return result as SeamlessAuthResult; + } + + // Bearer transport has already stored the pair; nothing else needs it. + const session: Partial = { ...result.data }; + delete session.token; + delete session.refreshToken; + delete session.sub; + recoverySession = result.data.recovery === true; + + return resultOf(session as RecoverySessionResult); + } + // An empty apiHost is the same-origin setup, so a path goes to this page's // origin. Without a page (server rendering, React Native) it stays relative, // and authorizedFetch refuses it rather than guess. @@ -529,19 +665,25 @@ export const createSeamlessAuthClient = ( return resultOf(prf ? { prf } : {}); }, - logout: (options = {}) => - requestResult( + logout: (options = {}) => { + recoverySession = false; + + return requestResult( fetchWithAuth(options.scope === 'all_sessions' ? `/logout/all` : `/logout`, { method: 'DELETE', }), 'Failed to sign out.' - ), + ); + }, - logoutAllSessions: () => - requestResult( + logoutAllSessions: () => { + recoverySession = false; + + return requestResult( fetchWithAuth(`/logout/all`, { method: 'DELETE' }), 'Failed to sign out of all sessions.' - ), + ); + }, deleteUser: () => requestResult( @@ -769,6 +911,8 @@ export const createSeamlessAuthClient = ( return verified as SeamlessAuthResult; } + await afterEnrollment(); + return resultOf({ credentialId: attestationResponse.id, prfCapable }); }, @@ -882,14 +1026,21 @@ export const createSeamlessAuthClient = ( 'Failed to start authenticator enrollment.' ), - verifyTotpEnrollment: code => - requestResult( + verifyTotpEnrollment: async code => { + const result = await requestResult( fetchWithAuth(`/totp/enroll/verify`, { method: 'POST', body: JSON.stringify({ code }), }), 'Failed to verify the authenticator code.' - ), + ); + + if (!result.error) { + await afterEnrollment(); + } + + return result; + }, disableTotp: code => requestResult( @@ -916,6 +1067,88 @@ export const createSeamlessAuthClient = ( return toStepUpStatus(verified.data ?? {}, 'totp'); }, + isRecoverySession: () => recoverySession, + + getRecoveryCodeStatus: () => + requestResult( + fetchWithAuth(`/recovery-codes`, { method: 'GET' }), + 'Failed to load recovery code status.' + ), + + generateRecoveryCodes: () => + requestResult( + fetchWithAuth(`/recovery-codes`, { + method: 'POST', + body: JSON.stringify({}), + }), + 'Failed to generate recovery codes.' + ), + + redeemRecoveryCode: code => + issuedRecoverySession( + fetchWithAuth(`/recovery-codes/redeem`, { + method: 'POST', + body: JSON.stringify({ code }), + }), + 'Failed to sign in with the recovery code.' + ), + + startAccountRecovery: channel => + requestResult( + fetchWithAuth(`/recovery/start`, { + method: 'POST', + body: JSON.stringify({ channel }), + }), + 'Failed to start account recovery.' + ), + + verifyAccountRecovery: code => + requestResult( + fetchWithAuth(`/recovery/verify`, { + method: 'POST', + body: JSON.stringify({ code }), + }), + 'Failed to verify the recovery code.' + ), + + getAccountRecoveryStatus: () => + requestResult( + fetchWithAuth(`/recovery/status`, { method: 'GET' }), + 'Failed to load the account recovery status.' + ), + + completeAccountRecovery: () => + issuedRecoverySession( + fetchWithAuth(`/recovery/complete`, { + method: 'POST', + body: JSON.stringify({}), + }), + 'Failed to complete account recovery.' + ), + + cancelAccountRecovery: cancelToken => + requestResult( + fetchWithAuth(`/recovery/cancel`, { + method: 'POST', + body: JSON.stringify({ cancelToken }), + }), + 'Failed to cancel account recovery.' + ), + + listAccountRecoveryRequests: () => + requestResult( + fetchWithAuth(`/recovery/requests`, { method: 'GET' }), + 'Failed to load account recovery requests.' + ), + + cancelAccountRecoveryRequest: id => + requestResult( + fetchWithAuth(`/recovery/requests/${encodeURIComponent(id)}`, { + method: 'DELETE', + }), + 'Failed to cancel the account recovery request.' + ), + updateCredential: input => requestResult( fetchWithAuth(`users/credentials`, { diff --git a/packages/client/src/client/errors.ts b/packages/client/src/client/errors.ts index 835cd3d..4b90ebd 100644 --- a/packages/client/src/client/errors.ts +++ b/packages/client/src/client/errors.ts @@ -177,6 +177,92 @@ export function isUnauthenticated(error: unknown): boolean { return error instanceof SeamlessAuthError && error.status === 401; } +function topLevelError(body: unknown): string | undefined { + if (typeof body !== 'object' || body === null) { + return undefined; + } + + const code = (body as { error?: unknown }).error; + return typeof code === 'string' ? code : undefined; +} + +/** + * The machine codes on an error body: the API's own `error`, then the one a proxy + * that rewrites `error` into a sentence keeps under `details`. + */ +function errorCodesOf(body: unknown): string[] { + const nested = + typeof body === 'object' && body !== null + ? topLevelError((body as { details?: unknown }).details) + : undefined; + + return [topLevelError(body), nested].filter( + (code): code is string => code !== undefined + ); +} + +function hasErrorCode(error: unknown, status: number, code: string): boolean { + return ( + error instanceof SeamlessAuthError && + error.status === status && + errorCodesOf(error.body).includes(code) + ); +} + +/** + * Whether a request was refused for want of a fresh step-up, for example replacing + * an existing set of recovery codes. Verify with `verifyStepUpWithPasskey()` (or + * TOTP) and send the request again. + */ +export function isStepUpRequired(error: unknown): boolean { + return hasErrorCode(error, 403, 'step_up_required'); +} + +/** + * Whether a request was refused because the session is a recovery session that + * has not enrolled a new passkey or authenticator app yet. + */ +export function isEnrollmentRequired(error: unknown): boolean { + return hasErrorCode(error, 403, 'enrollment_required'); +} + +/** + * Machine codes the recovery code and account recovery routes answer with. Local + * until `@seamless-auth/types` publishes them. + */ +export type RecoveryErrorCode = + | 'invalid_recovery_code' + | 'recovery_codes_disabled' + | 'account_recovery_disabled' + | 'recovery_notice_failed' + | 'recovery_not_found' + | 'recovery_not_ready' + | 'cancel_token_required'; + +const RECOVERY_ERROR_CODES: Record = { + invalid_recovery_code: true, + recovery_codes_disabled: true, + account_recovery_disabled: true, + recovery_notice_failed: true, + recovery_not_found: true, + recovery_not_ready: true, + cancel_token_required: true, +}; + +/** + * Read the recovery failure code off a result error. Returns `undefined` for + * anything unrecognized, so callers keep their generic messaging. + */ +export function getRecoveryErrorCode(error: unknown): RecoveryErrorCode | undefined { + if (!(error instanceof SeamlessAuthError)) { + return undefined; + } + + return errorCodesOf(error.body).find(code => + Object.prototype.hasOwnProperty.call(RECOVERY_ERROR_CODES, code) + ) as RecoveryErrorCode | undefined; +} + /** * Detail recovered from a failed WebAuthn ceremony. * diff --git a/packages/client/src/flows/index.ts b/packages/client/src/flows/index.ts index d23d35e..732cce3 100644 --- a/packages/client/src/flows/index.ts +++ b/packages/client/src/flows/index.ts @@ -11,6 +11,7 @@ export * from './messages'; export * from './oauth'; export * from './otp'; export * from './passkeySupport'; +export * from './recovery'; export * from './redirects'; export * from './signIn'; export * from './validation'; diff --git a/packages/client/src/flows/recovery.ts b/packages/client/src/flows/recovery.ts new file mode 100644 index 0000000..9620db6 --- /dev/null +++ b/packages/client/src/flows/recovery.ts @@ -0,0 +1,203 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in the project root for full license information + */ + +import type { + RecoveryCodesResult, + SeamlessAuthClient, + StepUpStatus, +} from '../client/createSeamlessAuthClient'; +import { + getRecoveryErrorCode, + isEnrollmentRequired, + isStepUpRequired, + type RecoveryErrorCode, +} from '../client/errors'; +import type { SeamlessAuthResult } from '../client/result'; +import type { Credential } from '../types'; + +/** Which recovery paths this instance offers on its sign-in screen. */ +export interface RecoveryOptions { + recoveryCodes: boolean; + accountRecovery: boolean; +} + +/** + * Read from the public configuration, so a sign-in screen offers a path only where + * it works. Both stay off when the answer is missing or the request fails. + */ +export async function loadRecoveryOptions( + client: Pick +): Promise { + try { + const { data, error } = await client.getPublicSystemConfig(); + + return { + recoveryCodes: !error && data?.recoveryCodes === true, + accountRecovery: !error && data?.accountRecovery === true, + }; + } catch { + return { recoveryCodes: false, accountRecovery: false }; + } +} + +export const RECOVERY_ERROR_MESSAGES: Record = { + invalid_recovery_code: + 'That code is not valid. Check it and try again. Each recovery code works once.', + recovery_codes_disabled: 'Recovery codes are turned off for this application.', + account_recovery_disabled: 'Account recovery is turned off for this application.', + recovery_notice_failed: + 'Recovery was not started because we could not notify you at the addresses on your account. Try again later.', + recovery_not_found: + 'There is no open recovery request for this sign-in. Start recovery again.', + recovery_not_ready: + 'This recovery request is still waiting. Come back once the waiting period ends.', + cancel_token_required: + 'This cancel link is incomplete. Open the link from the notice again.', +}; + +export const RECOVERY_GENERIC_ERROR = 'Something went wrong. Please try again.'; + +/** What to tell someone whose recovery step failed. */ +export function recoveryErrorMessage( + error: unknown, + fallback: string = RECOVERY_GENERIC_ERROR +): string { + const code = getRecoveryErrorCode(error); + + if (code) return RECOVERY_ERROR_MESSAGES[code]; + + if (isEnrollmentRequired(error)) { + return 'Add a new passkey to finish recovering your account.'; + } + + return fallback; +} + +/** + * The cancel token from a recovery notice's link, which carries it in the fragment + * (`/recovery/cancel#`) so it never reaches a server log. + */ +export function recoveryCancelTokenFromHash(hash: string): string | null { + const raw = hash.startsWith('#') ? hash.slice(1) : hash; + + let token: string; + try { + token = decodeURIComponent(raw).trim(); + } catch { + return null; + } + + return token === '' ? null : token; +} + +export type RecoveryCancelOutcome = + | { kind: 'cancelled' } + | { kind: 'error'; message: string }; + +/** + * Posts the token from a notice. The API answers the same whether or not it + * matched an open request, so a success says only that nothing is left to cancel. + */ +export async function cancelRecoveryFromLink( + client: Pick, + token: string | null +): Promise { + if (!token) { + return { kind: 'error', message: RECOVERY_ERROR_MESSAGES.cancel_token_required }; + } + + const { error } = await client.cancelAccountRecovery(token); + + if (error) { + return { + kind: 'error', + message: recoveryErrorMessage( + error, + 'We could not cancel the recovery request. Try the link again.' + ), + }; + } + + return { kind: 'cancelled' }; +} + +export type RecoveryCodesOutcome = + | { kind: 'generated'; codes: RecoveryCodesResult } + | { kind: 'error'; message: string }; + +/** + * Generates a new set, stepping up first when the API asks for it. A first set + * needs only the session; replacing one needs a fresh step-up. + */ +export async function generateRecoveryCodesWithStepUp(deps: { + client: Pick; + verifyStepUp: () => Promise>; +}): Promise { + const first = await deps.client.generateRecoveryCodes(); + + if (!first.error) { + return { kind: 'generated', codes: first.data }; + } + + if (!isStepUpRequired(first.error)) { + return { + kind: 'error', + message: recoveryErrorMessage(first.error, 'Failed to generate recovery codes.'), + }; + } + + const stepUp = await deps.verifyStepUp(); + + if (stepUp.error) { + return { + kind: 'error', + message: 'Confirm it is you with your passkey to replace your recovery codes.', + }; + } + + const retried = await deps.client.generateRecoveryCodes(); + + return retried.error + ? { + kind: 'error', + message: recoveryErrorMessage( + retried.error, + 'Failed to generate recovery codes.' + ), + } + : { kind: 'generated', codes: retried.data }; +} + +/** The codes as a plain text file's contents, one per line. */ +export function recoveryCodesText(codes: string[], appName = 'your account'): string { + return [`Recovery codes for ${appName}. Each code works once.`, '', ...codes, ''].join( + '\n' + ); +} + +/** The kind of authenticator a passkey is, for a management screen. */ +export type PasskeyKind = 'security_key' | 'synced' | 'device_bound'; + +export function passkeyKind(credential: Credential): PasskeyKind { + if (credential.transports?.some(t => t === 'usb' || t === 'nfc' || t === 'ble')) { + return 'security_key'; + } + + return credential.deviceType === 'multiDevice' || credential.backedUp + ? 'synced' + : 'device_bound'; +} + +export const PASSKEY_KIND_LABELS: Record = { + security_key: 'Security key', + synced: 'Synced passkey', + device_bound: 'Device passkey', +}; + +/** The name to show for a passkey, falling back to where it was enrolled. */ +export function passkeyDisplayName(credential: Credential): string { + return credential.friendlyName || credential.deviceInfo || 'Passkey'; +} diff --git a/packages/client/src/session/createAuthSession.ts b/packages/client/src/session/createAuthSession.ts index 9db8267..949d4cc 100644 --- a/packages/client/src/session/createAuthSession.ts +++ b/packages/client/src/session/createAuthSession.ts @@ -16,6 +16,7 @@ import { PasskeyLoginData, PasskeyMetadata, PasskeyRegistrationData, + RecoverySessionResult, RegisterPasskeyOptions, SeamlessAuthClient, SeamlessAuthClientOptions, @@ -57,6 +58,12 @@ export interface AuthSessionState { activeOrganization: Organization | null; stepUpStatus: StepUpStatus | null; isAuthenticated: boolean; + /** + * The session is a recovery session: it can only enroll a passkey or + * authenticator app until one is enrolled, so a UI sends the user there next. + * Known only to the client that redeemed the code or completed the recovery. + */ + recoverySession: boolean; loading: boolean; hasSignedInBefore: boolean; } @@ -67,6 +74,12 @@ export interface AuthSessionActions { passkeyAvailable: boolean ) => Promise>; handlePasskeyLogin: () => Promise>; + /** Signs in with a recovery code, on the sign-in `login` started. */ + redeemRecoveryCode: ( + code: string + ) => Promise>; + /** Completes an account recovery whose delay has passed. */ + completeAccountRecovery: () => Promise>; registerPasskey: ( input: PasskeyMetadata | RegisterPasskeyOptions ) => Promise>; @@ -152,6 +165,7 @@ const SIGNED_OUT = { activeOrganization: null, stepUpStatus: null, isAuthenticated: false, + recoverySession: false, } satisfies Partial; function signedIn(session: InitialSession) { @@ -271,7 +285,11 @@ export function createAuthSession(options: AuthSessionOptions): AuthSession { return result; } - setState({ ...signedIn(result.data), loading: false }); + setState({ + ...signedIn(result.data), + recoverySession: client.isRecoverySession(), + loading: false, + }); if (!state.hasSignedInBefore) { markSignedIn(); @@ -298,6 +316,10 @@ export function createAuthSession(options: AuthSessionOptions): AuthSession { handlePasskeyLogin: () => refreshAfter(() => client.loginWithPasskey()), + redeemRecoveryCode: code => refreshAfter(() => client.redeemRecoveryCode(code)), + + completeAccountRecovery: () => refreshAfter(() => client.completeAccountRecovery()), + // 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 diff --git a/packages/client/src/transport.ts b/packages/client/src/transport.ts index 4a5f03a..042c15c 100644 --- a/packages/client/src/transport.ts +++ b/packages/client/src/transport.ts @@ -168,6 +168,11 @@ export interface Transport { */ authorizedFetch: (input: string | URL, init?: RequestInit) => Promise; mode: AuthTransportMode; + /** + * Rotates the session through `POST /refresh`, so the next access token + * carries the session's current claims. Resolves to whether it succeeded. + */ + refresh(): Promise; /** Forgets the held session without calling the server. Bearer transport only; a no-op otherwise. */ clearTokens(): Promise; } @@ -218,6 +223,13 @@ const ROUTE_RULES: readonly RouteRule[] = [ { match: /^\/logout(\/all)?$/, identity: 'access', effect: 'end' }, { match: /^\/users\/delete$/, identity: 'access', effect: 'end' }, { match: /^\/organizations\/[^/]+\/switch$/, identity: 'access', effect: 'issue' }, + { match: /^\/recovery-codes$/, identity: 'access' }, + { match: /^\/recovery-codes\/redeem$/, identity: 'preAuth', effect: 'issue' }, + { match: /^\/recovery\/(start|verify|status)$/, identity: 'preAuth' }, + { match: /^\/recovery\/complete$/, identity: 'preAuth', effect: 'issue' }, + // The owner may be signed out everywhere when they follow the notice's link. + { match: /^\/recovery\/cancel$/, identity: 'none' }, + { match: /^\/recovery\/requests(\/[^/]+)?$/, identity: 'access' }, ]; const DEFAULT_RULE: RouteRule = { match: /.*/, identity: 'access' }; @@ -343,6 +355,13 @@ export function createTransport(options: TransportOptions): Transport { return fetchImpl(url, withHeaders({ ...init, credentials: 'include' }, {})); }, + refresh: async () => { + const response = await fetchImpl( + buildUrl(options.apiHost, basePath, '/refresh'), + withHeaders({ method: 'POST', credentials: 'include' }, {}) + ); + return response.ok; + }, clearTokens: async () => undefined, }; } @@ -576,6 +595,7 @@ export function createTransport(options: TransportOptions): Transport { return { mode, clearTokens, + refresh: refreshOnce, fetch: fetchUnderMount, // The transport header is the adapter's; an application's own API only // needs the bearer token, which requireAuth reads. A URL under the diff --git a/packages/client/tests/authSession.test.ts b/packages/client/tests/authSession.test.ts index a6b750f..60ac59a 100644 --- a/packages/client/tests/authSession.test.ts +++ b/packages/client/tests/authSession.test.ts @@ -68,6 +68,7 @@ describe('createAuthSession', () => { activeOrganization: null, stepUpStatus: null, isAuthenticated: false, + recoverySession: false, loading: true, hasSignedInBefore: false, }); @@ -487,7 +488,7 @@ describe('createAuthSession', () => { data: { user, credentials: [] }, error: null, }); - const client = { getCurrentUser } as never; + const client = { getCurrentUser, isRecoverySession: () => false } as never; const session = createAuthSession({ apiHost, diff --git a/packages/client/tests/oauthBinding.test.ts b/packages/client/tests/oauthBinding.test.ts index f2f96ae..9019cb7 100644 --- a/packages/client/tests/oauthBinding.test.ts +++ b/packages/client/tests/oauthBinding.test.ts @@ -38,6 +38,7 @@ function buildSession() { data: { user, credentials: [] }, error: null, }), + isRecoverySession: () => false, }; const session = createAuthSession({ apiHost: 'https://api.example.com', diff --git a/packages/client/tests/recovery.test.ts b/packages/client/tests/recovery.test.ts new file mode 100644 index 0000000..be74908 --- /dev/null +++ b/packages/client/tests/recovery.test.ts @@ -0,0 +1,490 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in the project root for full license information + */ + +import { startRegistration } from '@simplewebauthn/browser'; + +import { createSeamlessAuthClient } from '../src/client/createSeamlessAuthClient'; +import { + getRecoveryErrorCode, + isEnrollmentRequired, + isStepUpRequired, + SeamlessAuthError, +} from '../src/client/errors'; +import { createFetchTransport } from '../src/fetchWithAuth'; +import { + cancelRecoveryFromLink, + generateRecoveryCodesWithStepUp, + loadRecoveryOptions, + passkeyDisplayName, + passkeyKind, + recoveryCancelTokenFromHash, + recoveryCodesText, + recoveryErrorMessage, +} from '../src/flows/recovery'; +import { createAuthSession } from '../src/session/createAuthSession'; +import { createMemoryStorage } from '../src/session/storage'; +import type { Credential } from '../src/types'; + +jest.mock('../src/fetchWithAuth'); +jest.mock('@simplewebauthn/browser', () => ({ + ...jest.requireActual('@simplewebauthn/browser'), + startRegistration: jest.fn(), +})); + +const mockFetch = jest.fn(); +const mockRefresh = jest.fn(); + +const ok = (body: unknown) => ({ ok: true, status: 200, json: async () => body }); +const fail = (status: number, body: unknown) => ({ + ok: false, + status, + json: async () => body, +}); + +const recoverySession = { + message: 'Success', + token: 'access', + refreshToken: 'refresh', + sub: 'user-1', + email: 'a@example.com', + recovery: true, + nextStep: 'enroll_passkey', +}; + +const buildClient = () => + createSeamlessAuthClient({ apiHost: 'https://api.example.com' }); + +beforeEach(() => { + jest.clearAllMocks(); + mockFetch.mockReset(); + mockRefresh.mockReset().mockResolvedValue(true); + (createFetchTransport as jest.Mock).mockImplementation(() => ({ + fetch: mockFetch, + authorizedFetch: jest.fn(), + refresh: mockRefresh, + mode: 'cookie', + clearTokens: jest.fn(), + })); +}); + +describe('recovery code requests', () => { + it('reads the status and generates a set', async () => { + const client = buildClient(); + mockFetch + .mockResolvedValueOnce( + ok({ enabled: true, remaining: 10, total: 10, createdAt: null }) + ) + .mockResolvedValueOnce(ok({ message: 'Success', codes: ['A'], createdAt: 'now' })); + + await expect(client.getRecoveryCodeStatus()).resolves.toMatchObject({ + data: { remaining: 10 }, + }); + await expect(client.generateRecoveryCodes()).resolves.toMatchObject({ + data: { codes: ['A'] }, + }); + + expect(mockFetch).toHaveBeenNthCalledWith(1, '/recovery-codes', { method: 'GET' }); + expect(mockFetch).toHaveBeenNthCalledWith(2, '/recovery-codes', { + method: 'POST', + body: '{}', + }); + }); + + it('surfaces the step-up refusal for replacing a set', async () => { + mockFetch.mockResolvedValueOnce( + fail(403, { error: 'step_up_required', fresh: false, method: null }) + ); + + const { error } = await buildClient().generateRecoveryCodes(); + + expect(isStepUpRequired(error)).toBe(true); + expect(error?.status).toBe(403); + }); + + it('redeems a code into a recovery session without exposing its tokens', async () => { + const client = buildClient(); + mockFetch.mockResolvedValueOnce(ok(recoverySession)); + + const result = await client.redeemRecoveryCode('abcde-fghij'); + + expect(mockFetch).toHaveBeenCalledWith('/recovery-codes/redeem', { + method: 'POST', + body: JSON.stringify({ code: 'abcde-fghij' }), + }); + expect(result).toEqual({ + data: { + message: 'Success', + email: 'a@example.com', + recovery: true, + nextStep: 'enroll_passkey', + }, + error: null, + }); + expect(client.isRecoverySession()).toBe(true); + }); + + it('stays a normal session when the code is refused', async () => { + const client = buildClient(); + mockFetch.mockResolvedValueOnce(fail(401, { error: 'invalid_recovery_code' })); + + const { error } = await client.redeemRecoveryCode('nope'); + + expect(getRecoveryErrorCode(error)).toBe('invalid_recovery_code'); + expect(client.isRecoverySession()).toBe(false); + }); +}); + +describe('account recovery requests', () => { + it('sends each step to its route', async () => { + const client = buildClient(); + mockFetch.mockResolvedValue(ok({ message: 'ok' })); + + await client.startAccountRecovery('phone'); + await client.verifyAccountRecovery('123456'); + await client.getAccountRecoveryStatus(); + await client.cancelAccountRecovery('cancel-token'); + await client.listAccountRecoveryRequests(); + await client.cancelAccountRecoveryRequest('req/1'); + + expect(mockFetch.mock.calls).toEqual([ + ['/recovery/start', { method: 'POST', body: JSON.stringify({ channel: 'phone' }) }], + ['/recovery/verify', { method: 'POST', body: JSON.stringify({ code: '123456' }) }], + ['/recovery/status', { method: 'GET' }], + [ + '/recovery/cancel', + { method: 'POST', body: JSON.stringify({ cancelToken: 'cancel-token' }) }, + ], + ['/recovery/requests', { method: 'GET' }], + ['/recovery/requests/req%2F1', { method: 'DELETE' }], + ]); + }); + + it('completes into a recovery session', async () => { + const client = buildClient(); + mockFetch.mockResolvedValueOnce(ok(recoverySession)); + + const { data } = await client.completeAccountRecovery(); + + expect(mockFetch).toHaveBeenCalledWith('/recovery/complete', { + method: 'POST', + body: '{}', + }); + expect(data).not.toHaveProperty('token'); + expect(client.isRecoverySession()).toBe(true); + }); +}); + +describe('enrollment from a recovery session', () => { + const registerOnce = () => { + mockFetch + .mockResolvedValueOnce(ok({ challenge: 'c' })) + .mockResolvedValueOnce(ok({ message: 'Success' })); + (startRegistration as jest.Mock).mockResolvedValueOnce({ id: 'cred' }); + }; + const metadata = { + friendlyName: 'mac', + platform: 'mac', + browser: 'chrome', + deviceInfo: 'mac', + }; + + it('refreshes after a passkey is registered so the token drops the claim', async () => { + const client = buildClient(); + mockFetch.mockResolvedValueOnce(ok(recoverySession)); + await client.redeemRecoveryCode('code'); + registerOnce(); + + const { error } = await client.registerPasskey(metadata); + + expect(error).toBeNull(); + expect(mockRefresh).toHaveBeenCalledTimes(1); + expect(client.isRecoverySession()).toBe(false); + }); + + it('does not refresh after a registration in a normal session', async () => { + const client = buildClient(); + registerOnce(); + + await client.registerPasskey(metadata); + + expect(mockRefresh).not.toHaveBeenCalled(); + }); + + it('refreshes after a TOTP enrollment', async () => { + const client = buildClient(); + mockFetch.mockResolvedValueOnce(ok(recoverySession)); + await client.completeAccountRecovery(); + mockFetch.mockResolvedValueOnce(ok({ message: 'Success' })); + + await client.verifyTotpEnrollment('123456'); + + expect(mockRefresh).toHaveBeenCalledTimes(1); + expect(client.isRecoverySession()).toBe(false); + }); + + it('keeps the enrollment when the refresh fails, and tries again next time', async () => { + const client = buildClient(); + mockFetch.mockResolvedValueOnce(ok(recoverySession)); + await client.redeemRecoveryCode('code'); + mockRefresh.mockRejectedValueOnce(new Error('offline')).mockResolvedValueOnce(false); + mockFetch.mockResolvedValue(ok({ message: 'Success' })); + + await expect(client.verifyTotpEnrollment('1')).resolves.toMatchObject({ + error: null, + }); + await client.verifyTotpEnrollment('2'); + + expect(mockRefresh).toHaveBeenCalledTimes(2); + expect(client.isRecoverySession()).toBe(true); + }); + + it('forgets the recovery session on sign out', async () => { + const client = buildClient(); + mockFetch.mockResolvedValueOnce(ok(recoverySession)); + await client.redeemRecoveryCode('code'); + mockFetch.mockResolvedValue(ok({ message: 'ok' })); + + await client.logout(); + expect(client.isRecoverySession()).toBe(false); + + mockFetch.mockResolvedValueOnce(ok(recoverySession)); + await client.redeemRecoveryCode('code'); + mockFetch.mockResolvedValue(ok({ message: 'ok' })); + await client.logoutAllSessions(); + expect(client.isRecoverySession()).toBe(false); + }); +}); + +describe('session store', () => { + it('reports a recovery session after a code is redeemed', async () => { + const session = createAuthSession({ + apiHost: 'https://api.example.com', + storage: createMemoryStorage(), + }); + mockFetch + .mockResolvedValueOnce(ok(recoverySession)) + .mockResolvedValueOnce(ok({ user: { id: 'user-1' }, credentials: [] })); + + await session.actions.redeemRecoveryCode('code'); + + expect(session.getState()).toMatchObject({ + isAuthenticated: true, + recoverySession: true, + }); + }); + + it('reports a recovery session after account recovery completes', async () => { + const session = createAuthSession({ + apiHost: 'https://api.example.com', + storage: createMemoryStorage(), + }); + mockFetch + .mockResolvedValueOnce(ok(recoverySession)) + .mockResolvedValueOnce(ok({ user: { id: 'user-1' }, credentials: [] })); + + await session.actions.completeAccountRecovery(); + + expect(session.getState().recoverySession).toBe(true); + }); +}); + +describe('error helpers', () => { + it('recognizes codes at the top level and under details', () => { + const top = new SeamlessAuthError('x', 403, { error: 'enrollment_required' }); + const nested = new SeamlessAuthError('x', 503, { + error: 'Owner could not be notified', + details: { error: 'recovery_notice_failed' }, + }); + + expect(isEnrollmentRequired(top)).toBe(true); + expect(isStepUpRequired(top)).toBe(false); + expect(getRecoveryErrorCode(nested)).toBe('recovery_notice_failed'); + expect(getRecoveryErrorCode(new SeamlessAuthError('x', 400, 'text'))).toBeUndefined(); + expect(getRecoveryErrorCode(new SeamlessAuthError('x', 400, { details: 1 }))).toBe( + undefined + ); + expect(getRecoveryErrorCode(new Error('x'))).toBeUndefined(); + expect(isStepUpRequired(new Error('x'))).toBe(false); + }); +}); + +describe('recovery flows', () => { + it('reads which recovery paths the instance offers', async () => { + const config = (data: unknown, error: unknown = null) => ({ + getPublicSystemConfig: jest.fn().mockResolvedValue({ data, error }), + }); + + await expect( + loadRecoveryOptions( + config({ loginMethods: [], recoveryCodes: true, accountRecovery: false }) + ) + ).resolves.toEqual({ recoveryCodes: true, accountRecovery: false }); + await expect(loadRecoveryOptions(config(null, new Error('x')))).resolves.toEqual({ + recoveryCodes: false, + accountRecovery: false, + }); + await expect( + loadRecoveryOptions({ + getPublicSystemConfig: jest.fn().mockRejectedValue(new Error('x')), + }) + ).resolves.toEqual({ recoveryCodes: false, accountRecovery: false }); + }); + + it('maps failures to copy', () => { + expect( + recoveryErrorMessage( + new SeamlessAuthError('x', 403, { error: 'account_recovery_disabled' }) + ) + ).toMatch(/turned off/); + expect( + recoveryErrorMessage( + new SeamlessAuthError('x', 403, { error: 'enrollment_required' }) + ) + ).toMatch(/new passkey/); + expect(recoveryErrorMessage(new Error('x'), 'Fallback')).toBe('Fallback'); + expect(recoveryErrorMessage(undefined)).toMatch(/try again/); + }); + + it('reads the cancel token from the fragment', () => { + expect(recoveryCancelTokenFromHash('#abc.def')).toBe('abc.def'); + expect(recoveryCancelTokenFromHash('abc%20')).toBe('abc'); + expect(recoveryCancelTokenFromHash('#')).toBeNull(); + expect(recoveryCancelTokenFromHash('#%E0%A4%A')).toBeNull(); + }); + + it('cancels from a link', async () => { + const client = { + cancelAccountRecovery: jest + .fn() + .mockResolvedValueOnce({ data: { message: 'ok' }, error: null }) + .mockResolvedValueOnce({ data: null, error: new SeamlessAuthError('x', 429) }), + }; + + await expect(cancelRecoveryFromLink(client, 't')).resolves.toEqual({ + kind: 'cancelled', + }); + await expect(cancelRecoveryFromLink(client, 't')).resolves.toMatchObject({ + kind: 'error', + message: expect.stringMatching(/could not cancel/), + }); + await expect(cancelRecoveryFromLink(client, null)).resolves.toMatchObject({ + kind: 'error', + }); + expect(client.cancelAccountRecovery).toHaveBeenCalledTimes(2); + }); + + describe('generateRecoveryCodesWithStepUp', () => { + const codes = { message: 'Success', codes: ['A'], createdAt: 'now' }; + const stepUpRequired = { + data: null, + error: new SeamlessAuthError('x', 403, { error: 'step_up_required' }), + }; + + it('generates a first set without a step-up', async () => { + const verifyStepUp = jest.fn(); + const client = { + generateRecoveryCodes: jest.fn().mockResolvedValue({ data: codes, error: null }), + }; + + await expect( + generateRecoveryCodesWithStepUp({ client, verifyStepUp }) + ).resolves.toEqual({ kind: 'generated', codes }); + expect(verifyStepUp).not.toHaveBeenCalled(); + }); + + it('steps up and tries again when the API asks for it', async () => { + const verifyStepUp = jest.fn().mockResolvedValue({ data: {}, error: null }); + const client = { + generateRecoveryCodes: jest + .fn() + .mockResolvedValueOnce(stepUpRequired) + .mockResolvedValueOnce({ data: codes, error: null }), + }; + + await expect( + generateRecoveryCodesWithStepUp({ client, verifyStepUp }) + ).resolves.toEqual({ kind: 'generated', codes }); + expect(verifyStepUp).toHaveBeenCalledTimes(1); + }); + + it('stops when the step-up fails', async () => { + const verifyStepUp = jest + .fn() + .mockResolvedValue({ data: null, error: new SeamlessAuthError('x', 0) }); + const client = { + generateRecoveryCodes: jest.fn().mockResolvedValue(stepUpRequired), + }; + + await expect( + generateRecoveryCodesWithStepUp({ client, verifyStepUp }) + ).resolves.toMatchObject({ + kind: 'error', + message: expect.stringMatching(/passkey/), + }); + expect(client.generateRecoveryCodes).toHaveBeenCalledTimes(1); + }); + + it('reports other failures, before and after a step-up', async () => { + const disabled = { + data: null, + error: new SeamlessAuthError('x', 403, { error: 'recovery_codes_disabled' }), + }; + const verifyStepUp = jest.fn().mockResolvedValue({ data: {}, error: null }); + + await expect( + generateRecoveryCodesWithStepUp({ + client: { generateRecoveryCodes: jest.fn().mockResolvedValue(disabled) }, + verifyStepUp, + }) + ).resolves.toMatchObject({ kind: 'error', message: expect.stringMatching(/off/) }); + + await expect( + generateRecoveryCodesWithStepUp({ + client: { + generateRecoveryCodes: jest + .fn() + .mockResolvedValueOnce(stepUpRequired) + .mockResolvedValueOnce({ + data: null, + error: new SeamlessAuthError('x', 500), + }), + }, + verifyStepUp, + }) + ).resolves.toEqual({ + kind: 'error', + message: 'Failed to generate recovery codes.', + }); + }); + }); + + it('formats codes for saving', () => { + expect(recoveryCodesText(['A', 'B'], 'Acme')).toBe( + 'Recovery codes for Acme. Each code works once.\n\nA\nB\n' + ); + }); + + it('describes passkeys', () => { + const base = { + id: '1', + createdAt: 'now', + counter: 0, + backedup: false, + backedUp: false, + } as Credential; + + expect(passkeyKind({ ...base, transports: ['usb'] })).toBe('security_key'); + expect(passkeyKind({ ...base, deviceType: 'multiDevice' })).toBe('synced'); + expect(passkeyKind({ ...base, transports: ['internal'] })).toBe('device_bound'); + expect(passkeyDisplayName({ ...base, friendlyName: 'Work laptop' })).toBe( + 'Work laptop' + ); + expect(passkeyDisplayName({ ...base, deviceInfo: 'mac • chrome' })).toBe( + 'mac • chrome' + ); + expect(passkeyDisplayName(base)).toBe('Passkey'); + }); +}); diff --git a/packages/client/tests/recoveryTransport.node.test.ts b/packages/client/tests/recoveryTransport.node.test.ts new file mode 100644 index 0000000..c4b53a8 --- /dev/null +++ b/packages/client/tests/recoveryTransport.node.test.ts @@ -0,0 +1,158 @@ +/** + * @jest-environment node + * + * Copyright © 2026 Fells Code, LLC + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in the project root for full license information + */ +import { createMemoryTokenStorage } from '../src/ports/tokenStorage'; +import { createTransport, resolveRouteRule } from '../src/transport'; + +const API = 'https://api.example.com'; + +type Call = { url: string; init: RequestInit }; + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +function scriptedFetch(script: (call: Call) => Response) { + const calls: Call[] = []; + const fetchImpl = jest.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const call = { url: String(input), init: init ?? {} }; + calls.push(call); + return script(call); + }); + return { calls, fetchImpl: fetchImpl as unknown as typeof fetch }; +} + +const authorizationOf = (call: Call) => + ((call.init.headers ?? {}) as Record).Authorization; + +describe('recovery route rules', () => { + // Mirrors the credential the auth API's adapter manifest gives each route. + it.each([ + ['/recovery-codes', 'access', undefined], + ['/recovery-codes/redeem', 'preAuth', 'issue'], + ['/recovery/start', 'preAuth', undefined], + ['/recovery/verify', 'preAuth', undefined], + ['/recovery/status', 'preAuth', undefined], + ['/recovery/complete', 'preAuth', 'issue'], + ['/recovery/cancel', 'none', undefined], + ['/recovery/requests', 'access', undefined], + ['/recovery/requests/8a6e0804-2bd0-4672-b79d-d97027f4071a', 'access', undefined], + ])('%s takes the %s identity', (path, identity, effect) => { + const rule = resolveRouteRule(path); + expect(rule.identity).toBe(identity); + expect(rule.effect).toBe(effect); + }); +}); + +describe('bearer transport through recovery', () => { + it('redeems a code on the pre-auth token and stores the recovery session', async () => { + const tokenStorage = createMemoryTokenStorage(); + const { calls, fetchImpl } = scriptedFetch(call => { + if (call.url.endsWith('/login')) { + return jsonResponse(200, { message: 'Login continued', token: 'ephemeral-1' }); + } + return jsonResponse(200, { + message: 'Success', + token: 'access-recovery', + refreshToken: 'refresh-1', + recovery: true, + nextStep: 'enroll_passkey', + }); + }); + const transport = createTransport({ + apiHost: API, + mode: 'bearer', + fetch: fetchImpl, + tokenStorage, + }); + + await transport.fetch('/login', { method: 'POST', body: '{}' }); + await transport.fetch('/recovery-codes/redeem', { + method: 'POST', + body: JSON.stringify({ code: 'ABCDE-FGHIJ' }), + }); + + expect(authorizationOf(calls[1])).toBe('Bearer ephemeral-1'); + await expect(tokenStorage.get()).resolves.toEqual({ + accessToken: 'access-recovery', + refreshToken: 'refresh-1', + }); + }); + + it('cancels from a notice link without any token', async () => { + const tokenStorage = createMemoryTokenStorage(); + await tokenStorage.set({ accessToken: 'access-1', refreshToken: 'refresh-1' }); + const { calls, fetchImpl } = scriptedFetch(() => + jsonResponse(200, { message: 'ok' }) + ); + const transport = createTransport({ + apiHost: API, + mode: 'bearer', + fetch: fetchImpl, + tokenStorage, + }); + + await transport.fetch('/recovery/cancel', { + method: 'POST', + body: JSON.stringify({ cancelToken: 't' }), + }); + + expect(authorizationOf(calls[0])).toBeUndefined(); + }); + + it('refreshes on request with the refresh token and stores the rotated pair', async () => { + const tokenStorage = createMemoryTokenStorage(); + await tokenStorage.set({ accessToken: 'access-recovery', refreshToken: 'refresh-1' }); + const { calls, fetchImpl } = scriptedFetch(() => + jsonResponse(200, { + message: 'Success', + token: 'access-2', + refreshToken: 'refresh-2', + }) + ); + const transport = createTransport({ + apiHost: API, + mode: 'bearer', + fetch: fetchImpl, + tokenStorage, + }); + + await expect(transport.refresh()).resolves.toBe(true); + + expect(calls[0].url).toBe(`${API}/auth/refresh`); + expect(authorizationOf(calls[0])).toBe('Bearer refresh-1'); + await expect(tokenStorage.get()).resolves.toEqual({ + accessToken: 'access-2', + refreshToken: 'refresh-2', + }); + }); +}); + +describe('cookie transport refresh', () => { + it('posts to the adapter refresh route with credentials', async () => { + const { calls, fetchImpl } = scriptedFetch(() => jsonResponse(200, {})); + const transport = createTransport({ apiHost: API, fetch: fetchImpl }); + + await expect(transport.refresh()).resolves.toBe(true); + + expect(calls[0].url).toBe(`${API}/auth/refresh`); + expect(calls[0].init.method).toBe('POST'); + expect(calls[0].init.credentials).toBe('include'); + }); + + it('reports a refused refresh', async () => { + const { fetchImpl } = scriptedFetch(() => + jsonResponse(401, { error: 'unauthorized' }) + ); + const transport = createTransport({ apiHost: API, fetch: fetchImpl }); + + await expect(transport.refresh()).resolves.toBe(false); + }); +}); From d39462e6f3b6241fb5415a3c1ef6a90399e4a2e4 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sat, 24 Oct 2026 22:44:50 -0400 Subject: [PATCH 3/8] chore: drop the types bump changeset, which ships with #210 --- .changeset/types-0-31.md | 7 ------- 1 file changed, 7 deletions(-) delete mode 100644 .changeset/types-0-31.md diff --git a/.changeset/types-0-31.md b/.changeset/types-0-31.md deleted file mode 100644 index 7e015bc..0000000 --- a/.changeset/types-0-31.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@seamless-auth/client': patch -'@seamless-auth/react': patch -'@seamless-auth/react-native': patch ---- - -Require `@seamless-auth/types` 0.31.0, which carries the recovery code, account recovery and recovery session shapes of auth API v0.20. From 51c67657561eeff59904de198cb91858d63a26fb Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sat, 24 Oct 2026 22:48:02 -0400 Subject: [PATCH 4/8] feat(react): recovery screens in AuthRoutes Add the sign-in side of account recovery to the prebuilt UI: - /recovery-code signs in with a recovery code - /recovery-codes generates and shows a first set after a passkey is enrolled, and asks the user to confirm they saved it - /account-recovery runs self-service recovery: channel, code, the waiting period, then completion - /recovery/cancel takes the token from the notice link's fragment, drops it from the address bar and cancels the request The sign-in screen offers recovery only where the public configuration reports it, and a recovery session goes straight to passkey enrollment, which it cannot skip. Refs #185, #208 --- packages/react/src/AuthProvider.tsx | 12 + packages/react/src/AuthRoutes.tsx | 8 + packages/react/src/authRoutePaths.ts | 6 + .../react/src/components/RecoveryCodeList.tsx | 94 +++++ .../react/src/hooks/useRecoveryOptions.ts | 41 ++ packages/react/src/styles/login.module.css | 7 + packages/react/src/styles/recovery.module.css | 155 +++++++ packages/react/src/views/AccountRecovery.tsx | 260 ++++++++++++ packages/react/src/views/Login.tsx | 35 +- .../react/src/views/PassKeyRegistration.tsx | 20 +- packages/react/src/views/RecoveryCancel.tsx | 90 ++++ .../react/src/views/RecoveryCodeLogin.tsx | 100 +++++ packages/react/src/views/RecoveryCodes.tsx | 132 ++++++ packages/react/tests/AuthRoutes.test.tsx | 16 + .../react/tests/RecoveryCodeList.test.tsx | 99 +++++ packages/react/tests/RecoveryViews.test.tsx | 397 ++++++++++++++++++ packages/react/tests/RegisterPassKey.test.tsx | 27 +- packages/react/tests/login.test.tsx | 60 +++ 18 files changed, 1551 insertions(+), 8 deletions(-) create mode 100644 packages/react/src/components/RecoveryCodeList.tsx create mode 100644 packages/react/src/hooks/useRecoveryOptions.ts create mode 100644 packages/react/src/styles/recovery.module.css create mode 100644 packages/react/src/views/AccountRecovery.tsx create mode 100644 packages/react/src/views/RecoveryCancel.tsx create mode 100644 packages/react/src/views/RecoveryCodeLogin.tsx create mode 100644 packages/react/src/views/RecoveryCodes.tsx create mode 100644 packages/react/tests/RecoveryCodeList.test.tsx create mode 100644 packages/react/tests/RecoveryViews.test.tsx diff --git a/packages/react/src/AuthProvider.tsx b/packages/react/src/AuthProvider.tsx index 19947cb..0979a1e 100644 --- a/packages/react/src/AuthProvider.tsx +++ b/packages/react/src/AuthProvider.tsx @@ -15,6 +15,7 @@ import { PasskeyLoginData, PasskeyMetadata, PasskeyRegistrationData, + RecoverySessionResult, RegisterPasskeyOptions, StartOAuthLoginInput, StartOAuthLoginResult, @@ -81,6 +82,17 @@ export interface AuthContextType { passkeyAvailable: boolean ) => Promise>; handlePasskeyLogin: () => Promise>; + /** + * True while the session is a recovery session, which can only enroll a passkey + * or authenticator app. Enrolling one through `registerPasskey` lifts it. + */ + recoverySession: boolean; + /** Signs in with a recovery code, on the sign-in `login` started. */ + redeemRecoveryCode: ( + code: string + ) => Promise>; + /** Completes an account recovery whose waiting period has passed. */ + completeAccountRecovery: () => Promise>; registerPasskey: ( input: PasskeyMetadata | RegisterPasskeyOptions ) => Promise>; diff --git a/packages/react/src/AuthRoutes.tsx b/packages/react/src/AuthRoutes.tsx index 0fa538a..77d8b13 100644 --- a/packages/react/src/AuthRoutes.tsx +++ b/packages/react/src/AuthRoutes.tsx @@ -13,6 +13,10 @@ import PhoneRegistration from '@/views/PhoneRegistration'; import EmailRegistration from '@/views/EmailRegistration'; import VerifyMagicLink from '@/views/VerifyMagicLink'; import OAuthCallback from '@/views/OAuthCallback'; +import RecoveryCodeLogin from '@/views/RecoveryCodeLogin'; +import RecoveryCodes from '@/views/RecoveryCodes'; +import AccountRecovery from '@/views/AccountRecovery'; +import RecoveryCancel from '@/views/RecoveryCancel'; import MagicLinkSent from './components/MagicLinkSent'; import { authRoutePaths } from './authRoutePaths'; @@ -26,6 +30,10 @@ export const AuthRoutes = () => ( } /> } /> } /> + } /> + } /> + } /> + } /> } /> ); diff --git a/packages/react/src/authRoutePaths.ts b/packages/react/src/authRoutePaths.ts index b4282f2..9d8f100 100644 --- a/packages/react/src/authRoutePaths.ts +++ b/packages/react/src/authRoutePaths.ts @@ -14,6 +14,8 @@ * link, so it has to match that value exactly. * - `oauthCallback` is registered with OAuth providers as an allowed redirect * URI, so renaming it would break configured integrations. + * - `recoveryCancel` is the link the auth API puts in every account recovery + * notice (`${FRONTEND_URL}/recovery/cancel#`). */ export const authRoutePaths = { login: '/login', @@ -24,4 +26,8 @@ export const authRoutePaths = { oauthCallback: '/oauth/callback', registerPasskey: '/register-passkey', magicLinkSent: '/magic-link-sent', + recoveryCode: '/recovery-code', + recoveryCodes: '/recovery-codes', + accountRecovery: '/account-recovery', + recoveryCancel: '/recovery/cancel', } as const; diff --git a/packages/react/src/components/RecoveryCodeList.tsx b/packages/react/src/components/RecoveryCodeList.tsx new file mode 100644 index 0000000..08f39a6 --- /dev/null +++ b/packages/react/src/components/RecoveryCodeList.tsx @@ -0,0 +1,94 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in the project root for full license information + */ + +import { recoveryCodesText } from '@seamless-auth/client'; +import React, { useState } from 'react'; + +import styles from '@/styles/recovery.module.css'; + +interface RecoveryCodeListProps { + codes: string[]; + /** Called once the user confirms they saved the codes. */ + onDone: () => void; + doneLabel?: string; +} + +/** + * A freshly generated set, shown once. The server keeps only hashes, so the user + * has to confirm they saved the codes before moving on. + */ +const RecoveryCodeList: React.FC = ({ + codes, + onDone, + doneLabel = 'Continue', +}) => { + const [saved, setSaved] = useState(false); + const [copyMessage, setCopyMessage] = useState(''); + + const copy = async () => { + try { + await navigator.clipboard.writeText(codes.join('\n')); + setCopyMessage('Copied to the clipboard.'); + } catch { + setCopyMessage('Copying is not available here. Write the codes down instead.'); + } + }; + + const download = () => { + const url = URL.createObjectURL( + new Blob([recoveryCodesText(codes)], { type: 'text/plain' }) + ); + const link = document.createElement('a'); + link.href = url; + link.download = 'recovery-codes.txt'; + link.click(); + URL.revokeObjectURL(url); + }; + + return ( +
+

+ Save these codes somewhere safe, such as a password manager. Each one signs you in + once if you lose your passkeys. You will not see them again. +

+ +
    + {codes.map(code => ( +
  • {code}
  • + ))} +
+ +
+ + +
+ {copyMessage && ( +

+ {copyMessage} +

+ )} + + + + +
+ ); +}; + +export default RecoveryCodeList; diff --git a/packages/react/src/hooks/useRecoveryOptions.ts b/packages/react/src/hooks/useRecoveryOptions.ts new file mode 100644 index 0000000..d0de317 --- /dev/null +++ b/packages/react/src/hooks/useRecoveryOptions.ts @@ -0,0 +1,41 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in the project root for full license information + */ + +import { loadRecoveryOptions, type RecoveryOptions } from '@seamless-auth/client'; +import { useEffect, useState } from 'react'; + +import { useAuthClient } from '@/hooks/useAuthClient'; + +/** + * Which recovery paths this instance offers, read from its public configuration. + * Both stay false until the answer arrives, so a sign-in screen never offers a + * path the instance has turned off. + */ +export const useRecoveryOptions = () => { + const authClient = useAuthClient(); + const [options, setOptions] = useState({ + recoveryCodes: false, + accountRecovery: false, + }); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let active = true; + + void loadRecoveryOptions(authClient).then(next => { + if (!active) return; + + setOptions(next); + setLoading(false); + }); + + return () => { + active = false; + }; + }, [authClient]); + + return { ...options, loading }; +}; diff --git a/packages/react/src/styles/login.module.css b/packages/react/src/styles/login.module.css index 184b18e..6600744 100644 --- a/packages/react/src/styles/login.module.css +++ b/packages/react/src/styles/login.module.css @@ -212,3 +212,10 @@ .linkButton:hover { text-decoration: underline; } + +.recoveryOptions { + display: flex; + flex-direction: column; + align-items: flex-start; + margin-top: 0.75rem; +} diff --git a/packages/react/src/styles/recovery.module.css b/packages/react/src/styles/recovery.module.css new file mode 100644 index 0000000..1e5dba2 --- /dev/null +++ b/packages/react/src/styles/recovery.module.css @@ -0,0 +1,155 @@ +.container { + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; + color: var(--seamless-text, white); +} + +.card { + background-color: var(--seamless-surface, #1f2937); + padding: 2rem; + border-radius: 0.5rem; + box-shadow: 0 4px 6px var(--seamless-shadow, rgba(0, 0, 0, 0.1)); + width: 100%; + max-width: 28rem; +} + +.title { + font-size: 1.5rem; + font-weight: bold; + margin-bottom: 1rem; + text-align: center; +} + +.description { + color: var(--seamless-text-muted, #d1d5db); + margin-bottom: 1rem; +} + +.form { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.label { + display: block; + color: var(--seamless-text-muted, #d1d5db); +} + +.input { + width: 100%; + padding: 0.5rem; + background-color: var(--seamless-surface-raised, #4b5563); + border: 1px solid var(--seamless-border, #d1d5db); + border-radius: 0.375rem; + margin-top: 0.25rem; + color: inherit; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + letter-spacing: 0.05em; +} + +.input:focus { + outline: none; + box-shadow: 0 0 0 2px var(--seamless-accent, #3b82f6); +} + +.button { + width: 100%; + padding: 0.75rem 1rem; + background-color: var(--seamless-accent, #3b82f6); + color: var(--seamless-accent-contrast, white); + border: none; + border-radius: 0.5rem; + font-weight: 500; + cursor: pointer; +} + +.button:hover:not(:disabled) { + background-color: var(--seamless-accent-hover, #2563eb); +} + +.button:disabled { + opacity: 0.6; + cursor: default; +} + +.secondary { + width: 100%; + padding: 0.75rem 1rem; + background: none; + color: var(--seamless-accent, #3b82f6); + border: 1px solid var(--seamless-accent, #3b82f6); + border-radius: 0.5rem; + font-weight: 500; + cursor: pointer; +} + +.secondary:disabled { + opacity: 0.6; + cursor: default; +} + +.link { + background: none; + border: none; + color: var(--seamless-accent-soft, #60a5fa); + cursor: pointer; + font-size: 0.875rem; + text-align: center; + width: 100%; +} + +.choices { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.error { + color: var(--seamless-danger, #f87171); + font-size: 0.875rem; +} + +.success { + color: var(--seamless-success, #6ee7b7); + font-size: 0.875rem; +} + +.codes { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.5rem; + padding: 1rem; + margin: 0 0 1rem; + list-style: none; + background-color: var(--seamless-surface-raised, #374151); + border-radius: 0.5rem; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 1rem; + text-align: center; +} + +.actions { + display: flex; + gap: 0.5rem; + margin-bottom: 1rem; +} + +.actions > button { + flex: 1; +} + +.confirm { + display: flex; + gap: 0.5rem; + align-items: flex-start; + margin-bottom: 1rem; + font-size: 0.875rem; +} + +.loading { + text-align: center; + color: var(--seamless-text-muted, #d1d5db); +} diff --git a/packages/react/src/views/AccountRecovery.tsx b/packages/react/src/views/AccountRecovery.tsx new file mode 100644 index 0000000..08910d9 --- /dev/null +++ b/packages/react/src/views/AccountRecovery.tsx @@ -0,0 +1,260 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in the project root for full license information + */ + +import { + type AccountRecoveryChannel, + type AccountRecoveryStatusResult, + getRecoveryErrorCode, + isUnauthenticated, + recoveryErrorMessage, +} from '@seamless-auth/client'; +import React, { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; + +import { useAuth } from '@/AuthProvider'; +import { authRoutePaths } from '@/authRoutePaths'; +import OtpInput from '@/components/OtpInput'; +import { useAuthClient } from '@/hooks/useAuthClient'; +import styles from '@/styles/recovery.module.css'; + +type Step = 'channel' | 'code' | 'status'; + +const CHANNEL_LABELS: Record = { + email: 'email', + phone: 'text message', +}; + +// The pre-auth token from the login screen lasts minutes, and the waiting period +// hours, so a step after it lapses has to start again from sign-in. +const SIGN_IN_AGAIN = + 'Your sign-in timed out. Sign in again and choose the same recovery method to continue.'; + +const formatTime = (value: string) => new Date(value).toLocaleString(); + +/** + * Self-service recovery for a user with no passkey and no recovery codes. It + * cannot finish quietly: it waits out a delay, and every verified address on the + * account is told when it opens. + */ +const AccountRecovery: React.FC = () => { + const navigate = useNavigate(); + const authClient = useAuthClient(); + const { completeAccountRecovery } = useAuth(); + + const [step, setStep] = useState('channel'); + const [channel, setChannel] = useState('email'); + const [code, setCode] = useState(''); + const [status, setStatus] = useState(null); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + const failWith = (failure: unknown, fallback: string) => { + // A wrong code is a 401 too, so the code is read before the status. + setError( + !getRecoveryErrorCode(failure) && isUnauthenticated(failure) + ? SIGN_IN_AGAIN + : recoveryErrorMessage(failure, fallback) + ); + }; + + const run = async (task: () => Promise) => { + setError(''); + setLoading(true); + try { + await task(); + } finally { + setLoading(false); + } + }; + + const start = (chosen: AccountRecoveryChannel) => + run(async () => { + setChannel(chosen); + const { error: startError } = await authClient.startAccountRecovery(chosen); + + if (startError) { + failWith(startError, 'We could not send a recovery code. Please try again.'); + return; + } + + setCode(''); + setStep('code'); + }); + + const verify = (event: React.FormEvent) => { + event.preventDefault(); + + return run(async () => { + const { data, error: verifyError } = await authClient.verifyAccountRecovery(code); + + if (verifyError) { + failWith(verifyError, 'That code did not work. Check it and try again.'); + return; + } + + setStatus(data); + setStep('status'); + }); + }; + + const checkStatus = () => + run(async () => { + const { data, error: statusError } = await authClient.getAccountRecoveryStatus(); + + if (statusError) { + failWith(statusError, 'We could not check your recovery request.'); + return; + } + + setStatus(data); + }); + + const complete = () => + run(async () => { + const { error: completeError } = await completeAccountRecovery(); + + if (completeError) { + failWith(completeError, 'We could not finish recovering your account.'); + return; + } + + navigate(authRoutePaths.registerPasskey, { replace: true }); + }); + + return ( +
+
+

Recover your account

+ + {step === 'channel' && ( +
+

+ We will send a code to the email address or phone number on your account. + For your security, recovery finishes only after a waiting period, and + everyone on the account is told so they can stop it. If you started recovery + before, choose the same method to pick it back up. +

+
+ + +
+
+ )} + + {step === 'code' && ( +
+

+ If your account has a verified {CHANNEL_LABELS[channel]} on file, we sent it + a six-digit code. It expires in ten minutes. +

+ + + + + + )} + + {step === 'status' && status && ( +
+ {status.state === 'pending' && ( + <> +

+ Your recovery request is open. You can finish it after{' '} + {formatTime(status.completesAt)}. We told every address + on your account, and anyone with access to them can cancel it. +

+

+ Come back then, sign in again, and choose the same method to finish. +

+ + + )} + + {status.state === 'ready' && ( + <> +

+ The waiting period is over. Finishing signs out every session on your + account, then you will add a new passkey. +

+ + + )} + + {status.state === 'cancelled' && ( +

+ This recovery request was cancelled. If that was not you, start again. +

+ )} + + {status.state === 'completed' && ( +

+ This recovery request has already been completed. +

+ )} +
+ )} + + {error && ( +

+ {error} +

+ )} + + +
+
+ ); +}; + +export default AccountRecovery; diff --git a/packages/react/src/views/Login.tsx b/packages/react/src/views/Login.tsx index 8804a2f..10957ed 100644 --- a/packages/react/src/views/Login.tsx +++ b/packages/react/src/views/Login.tsx @@ -8,6 +8,7 @@ import { useAuth } from '@/AuthProvider'; import React, { useEffect, useState } from 'react'; import { useAuthClient } from '@/hooks/useAuthClient'; import { useLoginMethods } from '@/hooks/useLoginMethods'; +import { useRecoveryOptions } from '@/hooks/useRecoveryOptions'; import { usePasskeySupport } from '@/hooks/usePasskeySupport'; import { useNavigate } from 'react-router-dom'; import { authRoutePaths } from '@/authRoutePaths'; @@ -24,10 +25,11 @@ import { const Login: React.FC = () => { const navigate = useNavigate(); - const { hasSignedInBefore, login, handlePasskeyLogin } = useAuth(); + const { hasSignedInBefore, login, handlePasskeyLogin, recoverySession } = useAuth(); const authClient = useAuthClient(); const { passkeySupported } = usePasskeySupport(); const { loginMethods: configuredMethods } = useLoginMethods(); + const recoveryOptions = useRecoveryOptions(); const [identifier, setIdentifier] = useState(''); const [email, setEmail] = useState(''); const [mode, setMode] = useState<'login' | 'register'>('register'); @@ -43,6 +45,13 @@ const Login: React.FC = () => { } }, [hasSignedInBefore]); + // A recovery session can do nothing but enroll a factor, so it goes straight there. + useEffect(() => { + if (recoverySession) { + navigate(authRoutePaths.registerPasskey, { replace: true }); + } + }, [recoverySession, navigate]); + const handleEmailChange = (e: React.ChangeEvent) => { const email = e.target.value; setEmail(email); @@ -215,6 +224,30 @@ const Login: React.FC = () => { /> )} + {showFallbackOptions && + (recoveryOptions.recoveryCodes || recoveryOptions.accountRecovery) && ( +
+ {recoveryOptions.recoveryCodes && ( + + )} + {recoveryOptions.accountRecovery && ( + + )} +
+ )} + {identifierError &&

{identifierError}

} )} diff --git a/packages/react/src/views/PassKeyRegistration.tsx b/packages/react/src/views/PassKeyRegistration.tsx index 2713b3e..f7c0599 100644 --- a/packages/react/src/views/PassKeyRegistration.tsx +++ b/packages/react/src/views/PassKeyRegistration.tsx @@ -16,11 +16,12 @@ import { useAuthClient } from '@/hooks/useAuthClient'; import { useLoginMethods } from '@/hooks/useLoginMethods'; import { usePasskeySupport } from '@/hooks/usePasskeySupport'; import { useLocation, useNavigate } from 'react-router-dom'; +import { authRoutePaths } from '@/authRoutePaths'; import styles from '@/styles/registerPasskey.module.css'; const PasskeyRegistration: React.FC = () => { - const { refreshSession } = useAuth(); + const { refreshSession, recoverySession } = useAuth(); const authClient = useAuthClient(); const { passkeySupported, loading: passkeySupportLoading } = usePasskeySupport(); const { loginMethods, loading: loginMethodsLoading } = useLoginMethods(); @@ -41,7 +42,10 @@ const PasskeyRegistration: React.FC = () => { // // Gated on another method being enabled. With passkey as the only one, a user // who skipped would have no way back into the account they just made. - const canSkip = hasNonPasskeyLoginMethod(loginMethods); + // + // A recovery session cannot skip either: it can do nothing else until a factor + // is enrolled. + const canSkip = !recoverySession && hasNonPasskeyLoginMethod(loginMethods); const finishWithoutPasskey = async () => { await refreshSession(); @@ -64,7 +68,7 @@ const PasskeyRegistration: React.FC = () => { setStatus('success'); setMessage('Passkey registered successfully.'); - navigate(destination); + navigate(authRoutePaths.recoveryCodes, { state: { returnTo: destination } }); }; return ( @@ -99,9 +103,15 @@ const PasskeyRegistration: React.FC = () => { ) : (
-

Secure Your Account with a Passkey

+

+ {recoverySession + ? 'Add a New Passkey' + : 'Secure Your Account with a Passkey'} +

- Your device supports passkeys! Register one to skip passwords forever. + {recoverySession + ? 'You are signed in to recover your account. Add a new passkey to finish, and you will be able to use it to sign in from now on.' + : 'Your device supports passkeys! Register one to skip passwords forever.'}

+
+ + ); +}; + +export default RecoveryCancel; diff --git a/packages/react/src/views/RecoveryCodeLogin.tsx b/packages/react/src/views/RecoveryCodeLogin.tsx new file mode 100644 index 0000000..0614b7c --- /dev/null +++ b/packages/react/src/views/RecoveryCodeLogin.tsx @@ -0,0 +1,100 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in the project root for full license information + */ + +import { recoveryErrorMessage } from '@seamless-auth/client'; +import React, { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; + +import { useAuth } from '@/AuthProvider'; +import { authRoutePaths } from '@/authRoutePaths'; +import styles from '@/styles/recovery.module.css'; + +/** + * Signs in with a recovery code, on the sign-in the login screen started. The + * session it issues can only enroll a passkey, so that is where it goes next. + */ +const RecoveryCodeLogin: React.FC = () => { + const navigate = useNavigate(); + const { redeemRecoveryCode } = useAuth(); + const [code, setCode] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + setError(''); + setLoading(true); + + const { error: redeemError } = await redeemRecoveryCode(code.trim()); + + setLoading(false); + + if (redeemError) { + setError( + recoveryErrorMessage( + redeemError, + 'We could not sign you in with that code. Go back and sign in again, then try the code once more.' + ) + ); + return; + } + + navigate(authRoutePaths.registerPasskey, { replace: true }); + }; + + return ( +
+
+

Use a recovery code

+

+ Enter one of the recovery codes you saved. Once you are in, you will add a new + passkey. +

+ +
+ + + {error && ( +

+ {error} +

+ )} + + + + +
+
+
+ ); +}; + +export default RecoveryCodeLogin; diff --git a/packages/react/src/views/RecoveryCodes.tsx b/packages/react/src/views/RecoveryCodes.tsx new file mode 100644 index 0000000..1a3b21f --- /dev/null +++ b/packages/react/src/views/RecoveryCodes.tsx @@ -0,0 +1,132 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in the project root for full license information + */ + +import { generateRecoveryCodesWithStepUp, safeReturnPath } from '@seamless-auth/client'; +import React, { useEffect, useRef, useState } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; + +import { useAuth } from '@/AuthProvider'; +import RecoveryCodeList from '@/components/RecoveryCodeList'; +import { useAuthClient } from '@/hooks/useAuthClient'; +import styles from '@/styles/recovery.module.css'; + +type Step = 'loading' | 'show' | 'offer'; + +/** + * Offered straight after a passkey is enrolled. A user with no set gets one + * generated and shown here; a user whose codes are all spent is offered a new + * set. Anyone else, or an instance with recovery codes off, moves straight on. + */ +const RecoveryCodes: React.FC = () => { + const navigate = useNavigate(); + const authClient = useAuthClient(); + const { verifyStepUpWithPasskey } = useAuth(); + const destination = safeReturnPath( + (useLocation().state as { returnTo?: unknown } | null)?.returnTo + ); + + const [step, setStep] = useState('loading'); + const [codes, setCodes] = useState([]); + const [error, setError] = useState(''); + const [busy, setBusy] = useState(false); + const [usedUp, setUsedUp] = useState(false); + // Generating twice would void the first set, and the second request would need a + // step-up the user has not been asked for. StrictMode runs effects twice. + const started = useRef(false); + + const finish = () => navigate(destination, { replace: true }); + + const generate = async () => { + setBusy(true); + setError(''); + + const outcome = await generateRecoveryCodesWithStepUp({ + client: authClient, + verifyStepUp: verifyStepUpWithPasskey, + }); + + setBusy(false); + + if (outcome.kind === 'error') { + setError(outcome.message); + return false; + } + + setCodes(outcome.codes.codes); + setStep('show'); + return true; + }; + + useEffect(() => { + if (started.current) return; + started.current = true; + + void (async () => { + const { data, error: statusError } = await authClient.getRecoveryCodeStatus(); + + if (statusError || !data.enabled || data.remaining > 0) { + finish(); + return; + } + + if (data.total > 0) { + setUsedUp(true); + setStep('offer'); + return; + } + + if (!(await generate())) { + setStep('offer'); + } + })(); + // eslint-disable-next-line react-hooks/exhaustive-deps -- runs once per visit + }, []); + + return ( +
+
+

Save your recovery codes

+ + {step === 'loading' && ( +

+ Preparing your recovery codes... +

+ )} + + {step === 'show' && } + + {step === 'offer' && ( +
+

+ {usedUp + ? 'You have used all of your recovery codes. Generate a new set so you can still get in if you lose your passkeys.' + : 'Recovery codes let you sign in if you lose your passkeys.'} +

+ + +
+ )} + + {error && ( +

+ {error} +

+ )} +
+
+ ); +}; + +export default RecoveryCodes; diff --git a/packages/react/tests/AuthRoutes.test.tsx b/packages/react/tests/AuthRoutes.test.tsx index 908158a..d95918a 100644 --- a/packages/react/tests/AuthRoutes.test.tsx +++ b/packages/react/tests/AuthRoutes.test.tsx @@ -16,6 +16,10 @@ jest.mock('@/views/PhoneRegistration', () => () =>
Verify Phone Page
) jest.mock('@/views/EmailRegistration', () => () =>
Verify Email Page
); jest.mock('@/views/OAuthCallback', () => () =>
OAuth Callback Page
); jest.mock('@/components/MagicLinkSent', () => () =>
Magic Link Sent Page
); +jest.mock('@/views/RecoveryCodeLogin', () => () =>
Recovery Code Page
); +jest.mock('@/views/RecoveryCodes', () => () =>
Recovery Codes Page
); +jest.mock('@/views/AccountRecovery', () => () =>
Account Recovery Page
); +jest.mock('@/views/RecoveryCancel', () => () =>
Recovery Cancel Page
); // Reports where the router ended up, so alias redirects can be asserted on the // resulting location rather than only on the rendered screen. @@ -70,6 +74,10 @@ describe('AuthRoutes canonical paths', () => { [authRoutePaths.oauthCallback, 'OAuth Callback Page'], [authRoutePaths.registerPasskey, 'Register Passkey Page'], [authRoutePaths.magicLinkSent, 'Magic Link Sent Page'], + [authRoutePaths.recoveryCode, 'Recovery Code Page'], + [authRoutePaths.recoveryCodes, 'Recovery Codes Page'], + [authRoutePaths.accountRecovery, 'Account Recovery Page'], + [authRoutePaths.recoveryCancel, 'Recovery Cancel Page'], ]; it.each(cases)('renders %s', (path, expected) => { @@ -97,6 +105,14 @@ describe('externally owned paths', () => { expect(screen.getByTestId('search')).toHaveTextContent('?token=abc123'); }); + // The auth API links here from every account recovery notice + // (`${FRONTEND_URL}/recovery/cancel#`, docs/account-recovery.md). + it('serves the cancel link in account recovery notices', () => { + renderAt('/recovery/cancel'); + + expect(screen.getByText('Recovery Cancel Page')).toBeInTheDocument(); + }); + // Registered with OAuth providers as an allowed redirect URI. it('serves the OAuth callback path registered with providers', () => { renderAt('/oauth/callback?code=abc&state=xyz'); diff --git a/packages/react/tests/RecoveryCodeList.test.tsx b/packages/react/tests/RecoveryCodeList.test.tsx new file mode 100644 index 0000000..d7111b0 --- /dev/null +++ b/packages/react/tests/RecoveryCodeList.test.tsx @@ -0,0 +1,99 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in the project root for full license information + */ + +import { fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react'; + +import RecoveryCodeList from '@/components/RecoveryCodeList'; +import { useAuthClient } from '@/hooks/useAuthClient'; +import { useRecoveryOptions } from '@/hooks/useRecoveryOptions'; + +jest.mock('@/hooks/useAuthClient'); + +describe('RecoveryCodeList', () => { + const writeText = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + Object.defineProperty(navigator, 'clipboard', { + value: { writeText }, + configurable: true, + }); + URL.createObjectURL = jest.fn(() => 'blob:codes'); + URL.revokeObjectURL = jest.fn(); + }); + + it('copies the codes', async () => { + writeText.mockResolvedValueOnce(undefined); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Copy' })); + + expect(await screen.findByRole('status')).toHaveTextContent('Copied'); + expect(writeText).toHaveBeenCalledWith('A\nB'); + }); + + it('says when copying is not available', async () => { + writeText.mockRejectedValueOnce(new Error('denied')); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Copy' })); + + expect(await screen.findByRole('status')).toHaveTextContent(/Write the codes down/); + }); + + it('downloads the codes as a text file', () => { + const click = jest + .spyOn(HTMLAnchorElement.prototype, 'click') + .mockImplementation(() => undefined); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Download' })); + + expect(click).toHaveBeenCalled(); + expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:codes'); + click.mockRestore(); + }); +}); + +describe('useRecoveryOptions', () => { + it('reads the recovery paths from the public configuration', async () => { + (useAuthClient as jest.Mock).mockReturnValue({ + getPublicSystemConfig: jest.fn().mockResolvedValue({ + data: { loginMethods: ['passkey'], recoveryCodes: true }, + error: null, + }), + }); + + const { result, unmount } = renderHook(() => useRecoveryOptions()); + + expect(result.current).toEqual({ + recoveryCodes: false, + accountRecovery: false, + loading: true, + }); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current).toEqual({ + recoveryCodes: true, + accountRecovery: false, + loading: false, + }); + unmount(); + }); + + it('ignores an answer that arrives after unmounting', async () => { + let resolve: (value: unknown) => void = () => undefined; + (useAuthClient as jest.Mock).mockReturnValue({ + getPublicSystemConfig: jest.fn(() => new Promise(r => (resolve = r))), + }); + + const { result, unmount } = renderHook(() => useRecoveryOptions()); + unmount(); + resolve({ data: { loginMethods: [], accountRecovery: true }, error: null }); + await Promise.resolve(); + + expect(result.current.accountRecovery).toBe(false); + }); +}); diff --git a/packages/react/tests/RecoveryViews.test.tsx b/packages/react/tests/RecoveryViews.test.tsx new file mode 100644 index 0000000..a6c9da4 --- /dev/null +++ b/packages/react/tests/RecoveryViews.test.tsx @@ -0,0 +1,397 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in the project root for full license information + */ + +import { SeamlessAuthError } from '@seamless-auth/client'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { StrictMode } from 'react'; +import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; + +import { useAuth } from '@/AuthProvider'; +import { useAuthClient } from '@/hooks/useAuthClient'; +import AccountRecovery from '@/views/AccountRecovery'; +import RecoveryCancel from '@/views/RecoveryCancel'; +import RecoveryCodeLogin from '@/views/RecoveryCodeLogin'; +import RecoveryCodes from '@/views/RecoveryCodes'; + +jest.mock('@/AuthProvider'); +jest.mock('@/hooks/useAuthClient'); + +const failure = (status: number, body: unknown) => ({ + data: null, + error: new SeamlessAuthError('failed', status, body), +}); +const success = (data: T) => ({ data, error: null }); + +const LocationProbe = () => { + const location = useLocation(); + return ( + + {location.pathname} + {location.search} + {location.hash} + + ); +}; + +const renderAt = ( + element: React.ReactElement, + path: string, + entry: { pathname: string; hash?: string; state?: unknown } = { pathname: path } +) => + render( + + + + + Elsewhere} /> + + + + + ); + +const client = { + getRecoveryCodeStatus: jest.fn(), + generateRecoveryCodes: jest.fn(), + startAccountRecovery: jest.fn(), + verifyAccountRecovery: jest.fn(), + getAccountRecoveryStatus: jest.fn(), + cancelAccountRecovery: jest.fn(), +}; +const auth = { + redeemRecoveryCode: jest.fn(), + completeAccountRecovery: jest.fn(), + verifyStepUpWithPasskey: jest.fn(), +}; + +beforeEach(() => { + jest.clearAllMocks(); + (useAuthClient as jest.Mock).mockReturnValue(client); + (useAuth as jest.Mock).mockReturnValue(auth); +}); + +describe('RecoveryCodeLogin', () => { + const submit = (code: string) => { + fireEvent.change(screen.getByLabelText('Recovery code'), { + target: { value: code }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Sign in' })); + }; + + it('redeems the code and continues to passkey enrollment', async () => { + auth.redeemRecoveryCode.mockResolvedValue( + success({ message: 'Success', recovery: true, nextStep: 'enroll_passkey' }) + ); + renderAt(, '/recovery-code'); + + submit(' abcde-fghij '); + + await waitFor(() => + expect(screen.getByTestId('location')).toHaveTextContent('/register-passkey') + ); + expect(auth.redeemRecoveryCode).toHaveBeenCalledWith('abcde-fghij'); + }); + + it('explains a wrong code', async () => { + auth.redeemRecoveryCode.mockResolvedValue( + failure(401, { error: 'invalid_recovery_code' }) + ); + renderAt(, '/recovery-code'); + + submit('wrong'); + + expect(await screen.findByRole('alert')).toHaveTextContent(/not valid/); + }); + + it('asks to sign in again for any other failure', async () => { + auth.redeemRecoveryCode.mockResolvedValue(failure(401, { error: 'unauthorized' })); + renderAt(, '/recovery-code'); + + submit('code'); + + expect(await screen.findByRole('alert')).toHaveTextContent(/sign in again/); + }); + + it('goes back to sign in', () => { + renderAt(, '/recovery-code'); + + fireEvent.click(screen.getByText('Back to sign in')); + + expect(screen.getByTestId('location')).toHaveTextContent('/login'); + }); +}); + +describe('RecoveryCancel', () => { + it('drops the token from the address bar, then cancels with it', async () => { + client.cancelAccountRecovery.mockResolvedValue(success({ message: 'ok' })); + + renderAt(, '/recovery/cancel', { + pathname: '/recovery/cancel', + hash: '#cancel-token-1', + }); + + expect(await screen.findByText(/is cancelled/)).toBeInTheDocument(); + expect(screen.getByTestId('location')).toHaveTextContent(/^\/recovery\/cancel$/); + expect(client.cancelAccountRecovery).toHaveBeenCalledTimes(1); + expect(client.cancelAccountRecovery).toHaveBeenCalledWith('cancel-token-1'); + }); + + it('says the link is incomplete without a token', async () => { + renderAt(, '/recovery/cancel'); + + expect(await screen.findByRole('alert')).toHaveTextContent(/incomplete/); + expect(client.cancelAccountRecovery).not.toHaveBeenCalled(); + }); + + it('reports a failed cancel', async () => { + client.cancelAccountRecovery.mockResolvedValue(failure(429, {})); + renderAt(, '/recovery/cancel', { + pathname: '/recovery/cancel', + hash: '#t', + }); + + expect(await screen.findByRole('alert')).toHaveTextContent(/could not cancel/); + fireEvent.click(screen.getByText('Go to sign in')); + expect(screen.getByTestId('location')).toHaveTextContent('/login'); + }); +}); + +describe('RecoveryCodes', () => { + const renderCodes = () => + renderAt(, '/recovery-codes', { + pathname: '/recovery-codes', + state: { returnTo: '/dashboard' }, + }); + + it('generates a first set once, and continues after the user confirms saving it', async () => { + client.getRecoveryCodeStatus.mockResolvedValue( + success({ enabled: true, remaining: 0, total: 0, createdAt: null }) + ); + client.generateRecoveryCodes.mockResolvedValue( + success({ + message: 'Success', + codes: ['AAAAA-BBBBB', 'CCCCC-DDDDD'], + createdAt: 'x', + }) + ); + + renderCodes(); + + expect(await screen.findByText('AAAAA-BBBBB')).toBeInTheDocument(); + expect(client.generateRecoveryCodes).toHaveBeenCalledTimes(1); + + const continueButton = screen.getByRole('button', { name: 'Continue' }); + expect(continueButton).toBeDisabled(); + fireEvent.click(screen.getByLabelText(/I have saved/)); + fireEvent.click(continueButton); + + expect(screen.getByTestId('location')).toHaveTextContent('/dashboard'); + }); + + it('moves straight on when the user already has unused codes', async () => { + client.getRecoveryCodeStatus.mockResolvedValue( + success({ enabled: true, remaining: 9, total: 10, createdAt: 'x' }) + ); + + renderCodes(); + + await waitFor(() => + expect(screen.getByTestId('location')).toHaveTextContent('/dashboard') + ); + expect(client.generateRecoveryCodes).not.toHaveBeenCalled(); + }); + + it('moves straight on when the instance has recovery codes off or cannot answer', async () => { + client.getRecoveryCodeStatus.mockResolvedValue(failure(404, {})); + + renderCodes(); + + await waitFor(() => + expect(screen.getByTestId('location')).toHaveTextContent('/dashboard') + ); + }); + + it('offers a new set when every code is spent, stepping up first', async () => { + client.getRecoveryCodeStatus.mockResolvedValue( + success({ enabled: true, remaining: 0, total: 10, createdAt: 'x' }) + ); + client.generateRecoveryCodes + .mockResolvedValueOnce(failure(403, { error: 'step_up_required' })) + .mockResolvedValueOnce( + success({ message: 'Success', codes: ['NEW'], createdAt: 'x' }) + ); + auth.verifyStepUpWithPasskey.mockResolvedValue(success({ fresh: true })); + + renderCodes(); + + expect( + await screen.findByText(/used all of your recovery codes/) + ).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Generate recovery codes' })); + + expect(await screen.findByText('NEW')).toBeInTheDocument(); + expect(auth.verifyStepUpWithPasskey).toHaveBeenCalledTimes(1); + }); + + it('lets the user skip when generating fails', async () => { + client.getRecoveryCodeStatus.mockResolvedValue( + success({ enabled: true, remaining: 0, total: 0, createdAt: null }) + ); + client.generateRecoveryCodes.mockResolvedValue(failure(500, {})); + + renderCodes(); + + expect(await screen.findByRole('alert')).toHaveTextContent(/Failed to generate/); + fireEvent.click(screen.getByText('Skip for now')); + expect(screen.getByTestId('location')).toHaveTextContent('/dashboard'); + }); +}); + +describe('AccountRecovery', () => { + const pending = { + state: 'pending', + requestedAt: '2026-10-24T10:00:00.000Z', + completesAt: '2026-10-25T10:00:00.000Z', + }; + + const enterCode = (code: string) => { + const inputs = screen.getAllByRole('textbox'); + fireEvent.change(inputs[0], { target: { value: code } }); + }; + + it('starts, verifies and waits out the delay, then finishes into enrollment', async () => { + client.startAccountRecovery.mockResolvedValue(success({ message: 'sent' })); + client.verifyAccountRecovery.mockResolvedValue(success(pending)); + client.getAccountRecoveryStatus.mockResolvedValue( + success({ ...pending, state: 'ready' }) + ); + auth.completeAccountRecovery.mockResolvedValue( + success({ message: 'Success', recovery: true, nextStep: 'enroll_passkey' }) + ); + + renderAt(, '/account-recovery'); + + fireEvent.click(screen.getByText('Send a code by text message')); + expect(await screen.findByText(/verified text message/)).toBeInTheDocument(); + expect(client.startAccountRecovery).toHaveBeenCalledWith('phone'); + + enterCode('123456'); + fireEvent.click(screen.getByRole('button', { name: 'Verify' })); + + expect(await screen.findByText(/request is open/)).toBeInTheDocument(); + expect(client.verifyAccountRecovery).toHaveBeenCalledWith('123456'); + + fireEvent.click(screen.getByText('Check again')); + fireEvent.click(await screen.findByRole('button', { name: 'Finish recovery' })); + + await waitFor(() => + expect(screen.getByTestId('location')).toHaveTextContent('/register-passkey') + ); + }); + + it('explains a refused start', async () => { + client.startAccountRecovery.mockResolvedValue( + failure(403, { error: 'account_recovery_disabled' }) + ); + renderAt(, '/account-recovery'); + + fireEvent.click(screen.getByText('Send a code by email')); + + expect(await screen.findByRole('alert')).toHaveTextContent(/turned off/); + }); + + it('explains that the owner could not be notified', async () => { + client.startAccountRecovery.mockResolvedValue(success({ message: 'sent' })); + client.verifyAccountRecovery.mockResolvedValue( + failure(503, { error: 'recovery_notice_failed' }) + ); + renderAt(, '/account-recovery'); + + fireEvent.click(screen.getByText('Send a code by email')); + await screen.findByText(/verified email/); + enterCode('123456'); + fireEvent.click(screen.getByRole('button', { name: 'Verify' })); + + expect(await screen.findByRole('alert')).toHaveTextContent(/could not notify you/); + }); + + it('tells a wrong code apart from a lapsed sign-in', async () => { + client.startAccountRecovery.mockResolvedValue(success({ message: 'sent' })); + client.verifyAccountRecovery + .mockResolvedValueOnce(failure(401, { error: 'invalid_recovery_code' })) + .mockResolvedValueOnce(failure(401, { error: 'unauthorized' })); + renderAt(, '/account-recovery'); + + fireEvent.click(screen.getByText('Send a code by email')); + await screen.findByText(/verified email/); + enterCode('123456'); + fireEvent.click(screen.getByRole('button', { name: 'Verify' })); + expect(await screen.findByRole('alert')).toHaveTextContent(/not valid/); + + fireEvent.click(screen.getByRole('button', { name: 'Verify' })); + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent(/timed out/)); + + fireEvent.click(screen.getByText('Use a different method')); + expect(screen.getByText('Send a code by email')).toBeInTheDocument(); + }); + + it('reports a request that was cancelled or already completed', async () => { + client.startAccountRecovery.mockResolvedValue(success({ message: 'sent' })); + client.verifyAccountRecovery.mockResolvedValue( + success({ ...pending, state: 'cancelled' }) + ); + client.getAccountRecoveryStatus.mockResolvedValue(failure(500, {})); + renderAt(, '/account-recovery'); + + fireEvent.click(screen.getByText('Send a code by email')); + await screen.findByText(/verified email/); + enterCode('123456'); + fireEvent.click(screen.getByRole('button', { name: 'Verify' })); + + expect(await screen.findByText(/was cancelled/)).toBeInTheDocument(); + }); + + it('shows a completed request and a failed finish', async () => { + client.startAccountRecovery.mockResolvedValue(success({ message: 'sent' })); + client.verifyAccountRecovery.mockResolvedValue( + success({ ...pending, state: 'ready' }) + ); + auth.completeAccountRecovery.mockResolvedValue( + failure(409, { error: 'recovery_not_ready' }) + ); + client.getAccountRecoveryStatus.mockResolvedValue( + success({ ...pending, state: 'completed' }) + ); + renderAt(, '/account-recovery'); + + fireEvent.click(screen.getByText('Send a code by email')); + await screen.findByText(/verified email/); + enterCode('123456'); + fireEvent.click(screen.getByRole('button', { name: 'Verify' })); + fireEvent.click(await screen.findByRole('button', { name: 'Finish recovery' })); + + expect(await screen.findByRole('alert')).toHaveTextContent(/still waiting/); + }); + + it('reports a status check that fails', async () => { + client.startAccountRecovery.mockResolvedValue(success({ message: 'sent' })); + client.verifyAccountRecovery.mockResolvedValue(success(pending)); + client.getAccountRecoveryStatus.mockResolvedValue(failure(500, {})); + renderAt(, '/account-recovery'); + + fireEvent.click(screen.getByText('Send a code by email')); + await screen.findByText(/verified email/); + enterCode('123456'); + fireEvent.click(screen.getByRole('button', { name: 'Verify' })); + fireEvent.click(await screen.findByText('Check again')); + + expect(await screen.findByRole('alert')).toHaveTextContent(/could not check/); + await act(async () => { + fireEvent.click(screen.getByText('Back to sign in')); + }); + expect(screen.getByTestId('location')).toHaveTextContent('/login'); + }); +}); diff --git a/packages/react/tests/RegisterPassKey.test.tsx b/packages/react/tests/RegisterPassKey.test.tsx index a8e224b..882c38e 100644 --- a/packages/react/tests/RegisterPassKey.test.tsx +++ b/packages/react/tests/RegisterPassKey.test.tsx @@ -22,9 +22,12 @@ jest.mock('react-router-dom', () => ({ useLocation: () => ({ state: mockLocationState }), })); +let mockRecoverySession = false; + jest.mock('@/AuthProvider', () => ({ useAuth: () => ({ refreshSession: mockRefreshSession, + recoverySession: mockRecoverySession, }), })); @@ -51,6 +54,7 @@ beforeAll(() => { beforeEach(() => { jest.clearAllMocks(); mockLocationState = null; + mockRecoverySession = false; (useAuthClient as jest.Mock).mockReturnValue({ registerPasskey: mockRegisterPasskey, }); @@ -93,7 +97,10 @@ describe('RegisterPasskey', () => { }); expect(mockRefreshSession).toHaveBeenCalled(); - expect(mockNavigate).toHaveBeenCalledWith('/'); + // Recovery codes are offered next, then the user continues where they were going. + expect(mockNavigate).toHaveBeenCalledWith('/recovery-codes', { + state: { returnTo: '/' }, + }); }); // The button used to open a form asking for a name. Registration now goes @@ -349,7 +356,9 @@ describe('RegisterPasskey destination', () => { fireEvent.click(await screen.findByText(/Register Passkey/i)); await waitFor(() => - expect(mockNavigate).toHaveBeenCalledWith('/dashboard?tab=billing') + expect(mockNavigate).toHaveBeenCalledWith('/recovery-codes', { + state: { returnTo: '/dashboard?tab=billing' }, + }) ); }); @@ -374,3 +383,17 @@ describe('RegisterPasskey destination', () => { } ); }); + +describe('RegisterPasskey from a recovery session', () => { + it('asks for a new passkey and offers no way to skip it', async () => { + mockRecoverySession = true; + + render(); + + expect( + await screen.findByRole('heading', { name: 'Add a New Passkey' }) + ).toBeInTheDocument(); + expect(screen.getByText(/recover your account/i)).toBeInTheDocument(); + expect(screen.queryByText(/Skip for now/i)).not.toBeInTheDocument(); + }); +}); diff --git a/packages/react/tests/login.test.tsx b/packages/react/tests/login.test.tsx index 8028717..9c8bd79 100644 --- a/packages/react/tests/login.test.tsx +++ b/packages/react/tests/login.test.tsx @@ -381,4 +381,64 @@ describe('Login', () => { expect(mockAuthClient.register.mock.calls[0][0]).not.toHaveProperty('phone'); expect(navigate).toHaveBeenCalledWith('/verify-email-otp'); }); + + describe('recovery', () => { + const signInToFallback = async () => { + render(); + + fireEvent.change(await screen.findByLabelText(/email address \/ phone number/i), { + target: { value: 'user@example.com' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Login' })); + + await screen.findByTestId('fallback-options'); + }; + + test('offers the recovery paths the instance reports', async () => { + mockAuthClient.getPublicSystemConfig.mockResolvedValue({ + data: { + loginMethods: ['magic_link'], + recoveryCodes: true, + accountRecovery: true, + }, + error: null, + }); + + await signInToFallback(); + + fireEvent.click(await screen.findByText('Use a recovery code')); + expect(navigate).toHaveBeenCalledWith('/recovery-code'); + + fireEvent.click(screen.getByText(/Recover your account/)); + expect(navigate).toHaveBeenCalledWith('/account-recovery'); + }); + + test('offers none where the instance has them off', async () => { + mockAuthClient.getPublicSystemConfig.mockResolvedValue({ + data: { loginMethods: ['magic_link'] }, + error: null, + }); + + await signInToFallback(); + + expect(screen.queryByText('Use a recovery code')).not.toBeInTheDocument(); + expect(screen.queryByText(/Recover your account/)).not.toBeInTheDocument(); + }); + + test('sends a recovery session straight to enrollment', async () => { + (useAuth as jest.Mock).mockReturnValue({ + hasSignedInBefore: true, + login: jest.fn(), + handlePasskeyLogin: jest.fn(), + listOAuthProviders: jest.fn().mockResolvedValue({ providers: [] }), + recoverySession: true, + }); + + render(); + + await waitFor(() => + expect(navigate).toHaveBeenCalledWith('/register-passkey', { replace: true }) + ); + }); + }); }); From edef66bfc359710c7b78247dae282541fd166d85 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sat, 24 Oct 2026 22:48:35 -0400 Subject: [PATCH 5/8] docs: recovery codes, account recovery and passkey management --- packages/client/README.md | 48 +++++++++++------ packages/react-native/README.md | 6 +++ packages/react/README.md | 96 +++++++++++++++++++++++++++++++++ 3 files changed, 133 insertions(+), 17 deletions(-) diff --git a/packages/client/README.md b/packages/client/README.md index dfb9e5e..75741cb 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -23,8 +23,8 @@ npm install @seamless-auth/client ## What is here - `createSeamlessAuthClient({ apiHost })`: the request choreography for login, - registration, OTP, magic link, passkey, OAuth, step-up, TOTP, credential and - organization flows against a Seamless Auth server adapter mounted at `/auth`. + registration, OTP, magic link, passkey, OAuth, step-up, TOTP, credential, + recovery code, account recovery and organization flows against a Seamless Auth server adapter mounted at `/auth`. Every method returns a `SeamlessAuthResult`. - `createAuthSession({ apiHost, storage?, detectPreviousSignIn? })`: the session state machine (`getState`, `subscribe`, `actions`, `destroy`), designed to be @@ -46,27 +46,41 @@ than redeclaring shapes. Each bundled screen in a binding is a thin view over one of these. A custom UI in any framework can call them directly. -| Flow | What it does | -| --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | -| `beginSignIn(actions, { identifier, passkeySupported, configuredMethods })` | Starts a login and runs the passkey ceremony when it can. Resolves to `signed_in`, `choose_method` or `error` | -| `registerWithEmail(client, email)` | Starts registration. The next step is the email code | -| `requestOtp` / `verifyOtp` | Sends and checks a code for a channel (`email`, `phone`) and flow (`login`, `register`), and says where to go next | -| `watchMagicLink({ client, refreshSession, onSignedIn })` | In the tab that asked for a link, waits for it to be used (cross-tab message or poll). Returns a stop function | -| `finishMagicLinkSignIn({ client, refreshSession })` | On the page the link lands on, after `client.verifyMagicLink(token)` succeeds | -| `getMagicLinkConfirmation(error)`, `magicLinkConfirmationPrompt(held)` | When verifying fails because the link was opened on another device: who asked for it, and when | -| `confirmMagicLinkSignIn({ client, token })` | Approves that link once the reader agrees. Signs in the device that asked for it, not this one | -| `startOAuthSignIn` / `completeOAuthCallback` | Starts a provider sign-in and finishes it on the redirect URI | -| `enrollPasskey({ client, refreshSession }, attachment?)` | Enrols a passkey for the signed-in user, labelled with this device | -| `loadLoginMethods`, `fallbackSignInOptions`, `hasNonPasskeyLoginMethod` | What the instance allows, and what to offer a user | -| `detectPasskeySupport(port)` | Whether this device can use a passkey | -| `safeReturnPath`, `inAppPath` | Keep a destination inside the application | -| `isValidEmail`, `isValidPhoneNumber`, `parseUserAgent`, `formatCountdown` | Small shared helpers | +| Flow | What it does | +| ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| `beginSignIn(actions, { identifier, passkeySupported, configuredMethods })` | Starts a login and runs the passkey ceremony when it can. Resolves to `signed_in`, `choose_method` or `error` | +| `registerWithEmail(client, email)` | Starts registration. The next step is the email code | +| `requestOtp` / `verifyOtp` | Sends and checks a code for a channel (`email`, `phone`) and flow (`login`, `register`), and says where to go next | +| `watchMagicLink({ client, refreshSession, onSignedIn })` | In the tab that asked for a link, waits for it to be used (cross-tab message or poll). Returns a stop function | +| `finishMagicLinkSignIn({ client, refreshSession })` | On the page the link lands on, after `client.verifyMagicLink(token)` succeeds | +| `getMagicLinkConfirmation(error)`, `magicLinkConfirmationPrompt(held)` | When verifying fails because the link was opened on another device: who asked for it, and when | +| `confirmMagicLinkSignIn({ client, token })` | Approves that link once the reader agrees. Signs in the device that asked for it, not this one | +| `startOAuthSignIn` / `completeOAuthCallback` | Starts a provider sign-in and finishes it on the redirect URI | +| `enrollPasskey({ client, refreshSession }, attachment?)` | Enrols a passkey for the signed-in user, labelled with this device | +| `loadLoginMethods`, `fallbackSignInOptions`, `hasNonPasskeyLoginMethod` | What the instance allows, and what to offer a user | +| `detectPasskeySupport(port)` | Whether this device can use a passkey | +| `safeReturnPath`, `inAppPath` | Keep a destination inside the application | +| `loadRecoveryOptions(client)` | Whether the instance offers recovery codes and self-service account recovery | +| `generateRecoveryCodesWithStepUp({ client, verifyStepUp })` | Generates a set of recovery codes, stepping up first when the API asks for it | +| `recoveryCancelTokenFromHash(hash)`, `cancelRecoveryFromLink(client, token)` | The cancel page a recovery notice links to | +| `recoveryErrorMessage(error)`, `recoveryCodesText(codes)` | Copy for recovery failures, and a set of codes as a text file | +| `passkeyKind`, `passkeyDisplayName` | How a passkey management screen labels a credential | +| `isValidEmail`, `isValidPhoneNumber`, `parseUserAgent`, `formatCountdown` | Small shared helpers | None of them store a token. In a browser the session lives in HttpOnly cookies the server adapter sets; the only things written to browser storage are the id of the OAuth provider in flight and that sign-in's state binding (`sessionStorage`), and a flag that this browser has signed in before (`localStorage`). +### Recovery sessions + +`redeemRecoveryCode(code)` and `completeAccountRecovery()` issue a recovery session, which can only +enroll a passkey or authenticator app until one is enrolled (other routes answer `403` +`enrollment_required`, see `isEnrollmentRequired()`). `client.isRecoverySession()` and the session +state's `recoverySession` report it. After `registerPasskey()` or `verifyTotpEnrollment()` succeeds +from one, the client rotates the session through `POST /refresh` so the next access token no longer +carries the recovery claim. The flag is held in memory, so it does not survive a reload. + ### OAuth state binding Auth servers that bind the OAuth state to the client that started sign-in return a diff --git a/packages/react-native/README.md b/packages/react-native/README.md index 1a93a63..df358c1 100644 --- a/packages/react-native/README.md +++ b/packages/react-native/README.md @@ -117,6 +117,12 @@ passkey step. Every method returns a `SeamlessAuthResult`: `{ data, error: null }` or `{ data: null, error }`. Nothing throws for a failed request. +Account recovery comes from `@seamless-auth/client` as well: `useAuth()` carries +`recoverySession`, `redeemRecoveryCode(code)` and `completeAccountRecovery()`, and the +client has the recovery code and account recovery methods. After a passkey is enrolled +from a recovery session the client refreshes the stored tokens, so the new access token +no longer carries the recovery claim. See the client README for the full list. + ### Passkeys Native passkeys need the relying party to be an associated domain of the app: diff --git a/packages/react/README.md b/packages/react/README.md index ad772d6..8883f13 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -25,6 +25,10 @@ New to Seamless Auth? The [self-hosted quickstart](https://docs.seamlessauth.com - `getOAuthErrorCode()`, which reads the known OAuth callback failure codes off that error - `getWebAuthnErrorDetail()`, which reads the underlying failure of a passkey or step-up ceremony - `getPasskeyPolicyErrorCode()`, which reads the code the API refused a passkey registration with +- `isStepUpRequired()`, `isEnrollmentRequired()` and `getRecoveryErrorCode()` for the recovery routes +- `useRecoveryOptions()`, which reads whether the instance offers recovery codes and account recovery +- `AccountSecurity`, `PasskeyManagement`, `RecoveryCodeSettings` and `RecoveryRequests`, prebuilt + account settings sections (see [Account recovery](#account-recovery)) - types including `AuthContextType`, `Credential`, `User`, `OAuthProvider`, `StepUpStatus`, the `SeamlessAuthResult` wrapper, and the headless client input/result types ## Installation @@ -250,6 +254,9 @@ the session. handlePasskeyLogin(): Promise>; updateCredential(credential: Credential): Promise>; deleteCredential(credentialId: string): Promise>; + recoverySession: boolean; + redeemRecoveryCode(code: string): Promise>; + completeAccountRecovery(): Promise>; } ``` @@ -1147,6 +1154,78 @@ const lastUsed = credential.lastUsedAt ? new Date(credential.lastUsedAt) : null; Removing a passkey is a sensitive change. Gate it behind a fresh step-up when the account has other factors, using `refreshStepUpStatus()` and `verifyStepUpWithPasskey()` from the step-up section. +### Account recovery + +Auth API v0.20 adds two ways back into an account that lost its passkeys, and the client exposes +both. Every method returns a `SeamlessAuthResult`. + +Recovery codes are single-use codes a signed-in user generates and saves: + +```ts +const client = useAuthClient(); + +await client.getRecoveryCodeStatus(); // { enabled, remaining, total, createdAt } +const { data, error } = await client.generateRecoveryCodes(); // { codes, createdAt }, shown once +``` + +Replacing an existing set needs a fresh step-up. Without one the call fails with a `403` that +`isStepUpRequired(error)` recognizes. `generateRecoveryCodesWithStepUp({ client, verifyStepUp })` +runs `verifyStepUp` (for example `useAuth().verifyStepUpWithPasskey`) and tries again. + +Signing in with a code, or completing a self-service recovery, continues the sign-in `login()` +started and issues a **recovery session**: + +```ts +const { login, redeemRecoveryCode, recoverySession } = useAuth(); + +await login(identifier, false); +const { error } = await redeemRecoveryCode('ABCDE-FGHIJ'); +// recoverySession is now true +``` + +A recovery session can only enroll a passkey or authenticator app, manage recovery codes, read the +user and sign out; everything else answers `403` with `enrollment_required` +(`isEnrollmentRequired(error)`), and the server adapter's `requireAuth` refuses it by default. Send +the user to enrollment next. After `registerPasskey()` or `verifyTotpEnrollment()` succeeds from a +recovery session the client calls `POST /auth/refresh`, so the new access token no longer carries +the recovery claim, and `recoverySession` goes back to false on the next `refreshSession()`. The +flag lives in memory: after a reload the client cannot tell a recovery session from a normal one. + +Self-service recovery is off unless the deployment turns it on. `useRecoveryOptions()` reports +`recoveryCodes` and `accountRecovery` from the public configuration, so offer each only where it +works: + +```ts +await client.startAccountRecovery('email'); // sends a six-digit code +const { data } = await client.verifyAccountRecovery(code); // { state: 'pending', completesAt } +await client.getAccountRecoveryStatus(); // 'ready' once completesAt has passed +await completeAccountRecovery(); // from useAuth(): signs out every session, issues a recovery session +``` + +The waiting period outlasts the pre-auth token, so a user returns later, signs in again, and starts +on the **same channel**: verifying the new code resumes the open request. `getRecoveryErrorCode()` +reads `account_recovery_disabled`, `recovery_notice_failed` (the owner could not be told, so nothing +was opened), `recovery_not_ready`, `invalid_recovery_code` and the rest, and `recoveryErrorMessage()` +turns them into copy. + +Every notice links to `${FRONTEND_URL}/recovery/cancel#`. `client.cancelAccountRecovery(token)` +needs no session and answers the same whether or not the token matched. A signed-in owner can list +and cancel open requests with `listAccountRecoveryRequests()` and `cancelAccountRecoveryRequest(id)`. + +For settings screens the package ships router-free sections built on the same primitives: + +```tsx +import { AccountSecurity } from '@seamless-auth/react'; + +// Or individually: PasskeyManagement, RecoveryCodeSettings, RecoveryRequests +; +``` + +`PasskeyManagement` lists passkeys with their name, kind and last use, renames and deletes them, adds +another, and encourages a second passkey when there is only one. `RecoveryCodeSettings` shows how many +codes are left and generates or replaces a set (asking for the passkey when the API wants a step-up). +`RecoveryRequests` lists open recovery requests with a cancel button. + ## Built-In Routes `AuthRoutes` serves these canonical paths: @@ -1159,6 +1238,15 @@ factors, using `refreshStepUpStatus()` and `verifyStepUpWithPasskey()` from the - `/oauth/callback` - `/register-passkey` - `/magic-link-sent` +- `/recovery-code`, sign in with a recovery code +- `/recovery-codes`, shown after a passkey is enrolled: generates and shows a first set (or a new one + when every code is spent), and continues once the user confirms they saved it +- `/account-recovery`, self-service recovery: choose a channel, verify the code, wait, then finish +- `/recovery/cancel`, where the link in a recovery notice lands + +The sign-in screen offers "Use a recovery code" and "Recover your account" beside the other methods, +only where the public configuration reports `recoveryCodes` or `accountRecovery`. A recovery session +goes straight to `/register-passkey`, which then cannot be skipped. These are optional UI wrappers over the same SDK primitives the package now exports for custom flows. @@ -1182,6 +1270,9 @@ Two paths are unchanged because they are owned by contracts outside this package discarded. - `/oauth/callback` is registered with OAuth providers as an allowed redirect URI, so renaming it would break configured integrations. +- `/recovery/cancel` is the link the auth API puts in every account recovery notice. The page reads + the token from the fragment, drops it from the address bar, then posts it. Mount `AuthRoutes` at the + root of `FRONTEND_URL` so the link resolves. ## Theming The Built-In UI @@ -1296,6 +1387,11 @@ The built-in flows assume compatible endpoints for: - `/users/me` - `/users/credentials` - `/users/delete` +- `POST /refresh` (after enrolling a factor from a recovery session) +- `GET /recovery-codes`, `POST /recovery-codes` and `POST /recovery-codes/redeem` +- `POST /recovery/start`, `POST /recovery/verify`, `GET /recovery/status`, `POST /recovery/complete` + and `POST /recovery/cancel` +- `GET /recovery/requests` and `DELETE /recovery/requests/:id` - `/organizations` - `/organizations/:organizationId` - `/organizations/:organizationId/switch` From bfa67725900cb9830c175a4b145d6d77b557839f Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sat, 24 Oct 2026 22:49:06 -0400 Subject: [PATCH 6/8] feat(react): passkey and recovery sections for account settings Export router-free settings sections built on the public primitives: - PasskeyManagement lists passkeys with name, kind and last use, renames and deletes them, adds another, and asks for a second passkey when there is only one - RecoveryCodeSettings shows the codes left and generates or replaces a set, stepping up with the passkey when the API asks for it - RecoveryRequests lists open account recovery requests with cancel - AccountSecurity renders all three Refs #185, #186, #208 --- .changeset/recovery-react-ui.md | 12 + .../react/src/components/AccountSecurity.tsx | 25 ++ .../src/components/PasskeyManagement.tsx | 253 ++++++++++++++ .../src/components/RecoveryCodeSettings.tsx | 138 ++++++++ .../react/src/components/RecoveryRequests.tsx | 124 +++++++ packages/react/src/index.ts | 36 ++ .../src/styles/accountSecurity.module.css | 122 +++++++ packages/react/tests/AccountSecurity.test.tsx | 320 ++++++++++++++++++ 8 files changed, 1030 insertions(+) create mode 100644 .changeset/recovery-react-ui.md create mode 100644 packages/react/src/components/AccountSecurity.tsx create mode 100644 packages/react/src/components/PasskeyManagement.tsx create mode 100644 packages/react/src/components/RecoveryCodeSettings.tsx create mode 100644 packages/react/src/components/RecoveryRequests.tsx create mode 100644 packages/react/src/styles/accountSecurity.module.css create mode 100644 packages/react/tests/AccountSecurity.test.tsx diff --git a/.changeset/recovery-react-ui.md b/.changeset/recovery-react-ui.md new file mode 100644 index 0000000..59b635c --- /dev/null +++ b/.changeset/recovery-react-ui.md @@ -0,0 +1,12 @@ +--- +'@seamless-auth/react': minor +--- + +The prebuilt UI covers account recovery from auth API v0.20. + +- `AuthRoutes` serves `/recovery-code` (sign in with a recovery code), `/recovery-codes` (shown after a passkey is enrolled, to generate and save a first set, or a new one once every code is spent), `/account-recovery` (self-service recovery through a verified email or phone, with its waiting period) and `/recovery/cancel`, where the link in every recovery notice lands. That page takes the token from the URL fragment, drops it from the address bar and cancels the request. Mount `AuthRoutes` at the root of the deployment's `FRONTEND_URL` so the link resolves. +- The sign-in screen offers "Use a recovery code" and "Recover your account" only where `GET /system-config/public` reports `recoveryCodes` or `accountRecovery`. A recovery session goes straight to passkey enrollment, which it cannot skip. +- New router-free settings sections: `PasskeyManagement` (list with name, kind and last use, rename, delete, add another, and a nudge towards a second passkey), `RecoveryCodeSettings` (codes left, generate or replace with a step-up), `RecoveryRequests` (open recovery requests with cancel), and `AccountSecurity`, which renders all three. +- `useAuth()` adds `recoverySession`, `redeemRecoveryCode` and `completeAccountRecovery`, and the package re-exports `useRecoveryOptions`, `isStepUpRequired`, `isEnrollmentRequired`, `getRecoveryErrorCode` and the recovery types. + +Needs an auth API with account recovery (v0.20) and a server adapter that proxies its manifest routes, including `POST /auth/refresh`. diff --git a/packages/react/src/components/AccountSecurity.tsx b/packages/react/src/components/AccountSecurity.tsx new file mode 100644 index 0000000..4485ce3 --- /dev/null +++ b/packages/react/src/components/AccountSecurity.tsx @@ -0,0 +1,25 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in the project root for full license information + */ + +import React from 'react'; + +import PasskeyManagement from '@/components/PasskeyManagement'; +import RecoveryCodeSettings from '@/components/RecoveryCodeSettings'; +import RecoveryRequests from '@/components/RecoveryRequests'; + +/** + * A signed-in account settings panel: passkeys, recovery codes and open recovery + * requests. Each section is also exported on its own. + */ +export const AccountSecurity: React.FC = () => ( +
+ + + +
+); + +export default AccountSecurity; diff --git a/packages/react/src/components/PasskeyManagement.tsx b/packages/react/src/components/PasskeyManagement.tsx new file mode 100644 index 0000000..6605db1 --- /dev/null +++ b/packages/react/src/components/PasskeyManagement.tsx @@ -0,0 +1,253 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in the project root for full license information + */ + +import { + type Credential, + PASSKEY_KIND_LABELS, + passkeyDisplayName, + passkeyKind, + passkeyMetadataForThisDevice, + passkeyRegistrationErrorMessage, +} from '@seamless-auth/client'; +import React, { useState } from 'react'; + +import { useAuth } from '@/AuthProvider'; +import { usePasskeySupport } from '@/hooks/usePasskeySupport'; +import styles from '@/styles/accountSecurity.module.css'; + +const formatDate = (value: string | null | undefined) => + value ? new Date(value).toLocaleDateString() : null; + +interface PasskeyRowProps { + credential: Credential; + onlyOne: boolean; +} + +const PasskeyRow: React.FC = ({ credential, onlyOne }) => { + const { updateCredential, deleteCredential } = useAuth(); + const [renaming, setRenaming] = useState(false); + const [confirmingDelete, setConfirmingDelete] = useState(false); + const [name, setName] = useState(passkeyDisplayName(credential)); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + + const displayName = passkeyDisplayName(credential); + const lastUsed = formatDate(credential.lastUsedAt); + const added = formatDate(credential.createdAt); + + const rename = async (event: React.FormEvent) => { + event.preventDefault(); + setBusy(true); + setError(''); + + const trimmed = name.trim(); + const { error: renameError } = await updateCredential({ + ...credential, + friendlyName: trimmed === '' ? null : trimmed, + }); + + setBusy(false); + + if (renameError) { + setError('Could not rename this passkey. Please try again.'); + return; + } + + setRenaming(false); + }; + + const remove = async () => { + setBusy(true); + setError(''); + + const { error: deleteError } = await deleteCredential(credential.id); + + setBusy(false); + + if (deleteError) { + setError('Could not delete this passkey. Please try again.'); + setConfirmingDelete(false); + } + }; + + return ( +
  • +
    + {renaming ? ( +
    + + + +
    + ) : ( + {displayName} + )} +

    + {PASSKEY_KIND_LABELS[passkeyKind(credential)]} + {added ? ` · Added ${added}` : ''} + {` · ${lastUsed ? `Last used ${lastUsed}` : 'Never used'}`} +

    + {error && ( +

    + {error} +

    + )} +
    + + {!renaming && ( +
    + {confirmingDelete ? ( + <> + + {onlyOne + ? 'This is your only passkey. Delete it anyway?' + : 'Delete this passkey?'} + + + + + ) : ( + <> + + + + )} +
    + )} +
  • + ); +}; + +/** + * The signed-in user's passkeys: rename, delete, and add another. Reads + * `credentials` from the provider, so a change shows without a reload. + */ +export const PasskeyManagement: React.FC = () => { + const { credentials, registerPasskey } = useAuth(); + const { passkeySupported, loading: supportLoading } = usePasskeySupport(); + const [adding, setAdding] = useState(false); + const [message, setMessage] = useState<{ kind: 'success' | 'error'; text: string }>(); + + const add = async () => { + setAdding(true); + setMessage(undefined); + + const { error } = await registerPasskey({ metadata: passkeyMetadataForThisDevice() }); + + setAdding(false); + setMessage( + error + ? { kind: 'error', text: passkeyRegistrationErrorMessage(error) } + : { kind: 'success', text: 'Passkey added.' } + ); + }; + + return ( +
    +

    + Passkeys +

    +

    + Passkeys sign you in with your device's screen lock or a security key. +

    + + {credentials.length === 0 && ( +

    + You have no passkeys. Add one so you can sign in without a code. +

    + )} + + {credentials.length === 1 && ( +

    + You have one passkey. Add a second, on another device or a security key, so + losing this one does not lock you out. +

    + )} + + {credentials.length > 0 && ( +
      + {credentials.map(credential => ( + + ))} +
    + )} + + + {!supportLoading && !passkeySupported && ( +

    This browser cannot create passkeys.

    + )} + + {message && ( +

    + {message.text} +

    + )} +
    + ); +}; + +export default PasskeyManagement; diff --git a/packages/react/src/components/RecoveryCodeSettings.tsx b/packages/react/src/components/RecoveryCodeSettings.tsx new file mode 100644 index 0000000..4ab6452 --- /dev/null +++ b/packages/react/src/components/RecoveryCodeSettings.tsx @@ -0,0 +1,138 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in the project root for full license information + */ + +import { + generateRecoveryCodesWithStepUp, + type RecoveryCodeStatus, + recoveryErrorMessage, +} from '@seamless-auth/client'; +import React, { useCallback, useEffect, useState } from 'react'; + +import { useAuth } from '@/AuthProvider'; +import RecoveryCodeList from '@/components/RecoveryCodeList'; +import { useAuthClient } from '@/hooks/useAuthClient'; +import styles from '@/styles/accountSecurity.module.css'; + +/** + * How many recovery codes are left, and a way to generate a new set. Replacing a + * set asks for the user's passkey first, which the API requires. + */ +export const RecoveryCodeSettings: React.FC = () => { + const authClient = useAuthClient(); + const { verifyStepUpWithPasskey } = useAuth(); + const [status, setStatus] = useState(null); + const [codes, setCodes] = useState(null); + const [error, setError] = useState(''); + const [busy, setBusy] = useState(false); + + const load = useCallback(async () => { + const { data, error: statusError } = await authClient.getRecoveryCodeStatus(); + + if (statusError) { + setError(recoveryErrorMessage(statusError, 'Could not load your recovery codes.')); + return; + } + + setStatus(data); + }, [authClient]); + + useEffect(() => { + void load(); + }, [load]); + + const generate = async () => { + setBusy(true); + setError(''); + + const outcome = await generateRecoveryCodesWithStepUp({ + client: authClient, + verifyStepUp: verifyStepUpWithPasskey, + }); + + setBusy(false); + + if (outcome.kind === 'error') { + setError(outcome.message); + return; + } + + setCodes(outcome.codes.codes); + }; + + const done = () => { + setCodes(null); + void load(); + }; + + const hasSet = (status?.total ?? 0) > 0; + + return ( +
    +

    + Recovery codes +

    +

    + Single-use codes that sign you in if you lose your passkeys. +

    + + {codes ? ( + + ) : ( + <> + {status && !status.enabled && ( +

    + Recovery codes are turned off for this application. +

    + )} + + {status?.enabled && ( + <> +

    + {hasSet + ? `${status.remaining} of ${status.total} codes left${ + status.createdAt + ? `, created ${new Date(status.createdAt).toLocaleDateString()}` + : '' + }.` + : 'You have no recovery codes yet.'} +

    + + {hasSet && ( +

    + Replacing your codes stops the old ones working. You will be asked for + your passkey first. +

    + )} + + )} + + )} + + {error && ( +

    + {error} +

    + )} +
    + ); +}; + +export default RecoveryCodeSettings; diff --git a/packages/react/src/components/RecoveryRequests.tsx b/packages/react/src/components/RecoveryRequests.tsx new file mode 100644 index 0000000..1876549 --- /dev/null +++ b/packages/react/src/components/RecoveryRequests.tsx @@ -0,0 +1,124 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in the project root for full license information + */ + +import { type AccountRecoveryRequest, recoveryErrorMessage } from '@seamless-auth/client'; +import React, { useEffect, useState } from 'react'; + +import { useAuthClient } from '@/hooks/useAuthClient'; +import styles from '@/styles/accountSecurity.module.css'; + +const CHANNEL_LABELS: Record = { + email: 'email', + phone: 'text message', +}; + +const formatTime = (value: string) => new Date(value).toLocaleString(); + +/** + * Open account recovery requests on the signed-in account, so the owner can stop + * one they did not start without waiting for the notice. + */ +export const RecoveryRequests: React.FC = () => { + const authClient = useAuthClient(); + const [requests, setRequests] = useState(null); + const [error, setError] = useState(''); + const [cancelling, setCancelling] = useState(null); + + useEffect(() => { + let active = true; + + void authClient.listAccountRecoveryRequests().then(({ data, error: listError }) => { + if (!active) return; + + if (listError) { + setError(recoveryErrorMessage(listError, 'Could not load recovery requests.')); + return; + } + + setRequests(data.requests); + }); + + return () => { + active = false; + }; + }, [authClient]); + + const cancel = async (id: string) => { + setCancelling(id); + setError(''); + + const { error: cancelError } = await authClient.cancelAccountRecoveryRequest(id); + + setCancelling(null); + + if (cancelError) { + setError( + recoveryErrorMessage(cancelError, 'Could not cancel the recovery request.') + ); + return; + } + + setRequests(current => current?.filter(request => request.id !== id) ?? null); + }; + + return ( +
    +

    + Account recovery requests +

    + + {requests?.length === 0 && ( +

    No one is trying to recover this account.

    + )} + + {requests && requests.length > 0 && ( + <> +

    + Someone asked to recover this account. If it was not you, cancel the request. +

    +
      + {requests.map(request => ( +
    • +
      + + Recovery by {CHANNEL_LABELS[request.channel]} + +

      + Requested {formatTime(request.requestedAt)} + {request.state === 'ready' + ? ' · Can be completed now' + : ` · Completes ${formatTime(request.completesAt)}`} +

      +
      +
      + +
      +
    • + ))} +
    + + )} + + {error && ( +

    + {error} +

    + )} +
    + ); +}; + +export default RecoveryRequests; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 8b5eb93..105e80f 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -5,7 +5,17 @@ */ import { AuthContextType, AuthPorts, AuthProvider, useAuth } from '@/AuthProvider'; +import { AccountSecurity } from '@/components/AccountSecurity'; +import { PasskeyManagement } from '@/components/PasskeyManagement'; +import { RecoveryCodeSettings } from '@/components/RecoveryCodeSettings'; +import { RecoveryRequests } from '@/components/RecoveryRequests'; import { + AccountRecoveryChannel, + AccountRecoveryRequest, + AccountRecoveryRequestsResult, + AccountRecoveryStartResult, + AccountRecoveryState, + AccountRecoveryStatusResult, createSeamlessAuthClient, CredentialUpdateResult, CreateOrganizationInput, @@ -30,6 +40,9 @@ import { PasskeyAttachment, PasskeyMetadata, PasskeyRegistrationData, + RecoveryCodesResult, + RecoveryCodeStatus, + RecoverySessionResult, RegisterInput, RegisterPasskeyOptions, SeamlessAuthClient, @@ -46,10 +59,14 @@ import { import { getOAuthErrorCode, getPasskeyPolicyErrorCode, + getRecoveryErrorCode, getWebAuthnErrorDetail, + isEnrollmentRequired, + isStepUpRequired, isUnauthenticated, OAuthErrorCode, PasskeyPolicyErrorCode, + RecoveryErrorCode, SeamlessAuthError, UntrustedOriginError, UntrustedRedirectError, @@ -67,6 +84,7 @@ import { useAuthClient } from '@/hooks/useAuthClient'; import { useAuthorizedFetch } from '@/hooks/useAuthorizedFetch'; import { hasNonPasskeyLoginMethod, useLoginMethods } from '@/hooks/useLoginMethods'; import { usePasskeySupport } from '@/hooks/usePasskeySupport'; +import { useRecoveryOptions } from '@/hooks/useRecoveryOptions'; import { hasScopedRole, roleGrantsAccess } from '@seamless-auth/client'; import { Credential, @@ -86,17 +104,24 @@ import type { } from '@seamless-auth/client'; export { + AccountSecurity, AuthProvider, createSeamlessAuthClient, encodePrfSalt, extractPasskeyPrfResult, getOAuthErrorCode, getPasskeyPolicyErrorCode, + getRecoveryErrorCode, getWebAuthnErrorDetail, hasNonPasskeyLoginMethod, hasScopedRole, + isEnrollmentRequired, isPasskeyPrfSupported, + isStepUpRequired, isUnauthenticated, + PasskeyManagement, + RecoveryCodeSettings, + RecoveryRequests, roleGrantsAccess, SeamlessAuthError, UntrustedOriginError, @@ -106,8 +131,15 @@ export { useAuthorizedFetch, useLoginMethods, usePasskeySupport, + useRecoveryOptions, }; export type { + AccountRecoveryChannel, + AccountRecoveryRequest, + AccountRecoveryRequestsResult, + AccountRecoveryStartResult, + AccountRecoveryState, + AccountRecoveryStatusResult, CredentialUpdateResult, AuthContextType, AuthPorts, @@ -144,6 +176,10 @@ export type { PasskeyPrfResult, PasskeyRegistrationData, PublicSystemConfigResult, + RecoveryCodesResult, + RecoveryCodeStatus, + RecoveryErrorCode, + RecoverySessionResult, RefreshSessionOptions, RegisterInput, RegisterPasskeyOptions, diff --git a/packages/react/src/styles/accountSecurity.module.css b/packages/react/src/styles/accountSecurity.module.css new file mode 100644 index 0000000..f2d60da --- /dev/null +++ b/packages/react/src/styles/accountSecurity.module.css @@ -0,0 +1,122 @@ +.section { + color: var(--seamless-text, white); + background-color: var(--seamless-surface, #1f2937); + border-radius: 0.5rem; + padding: 1.5rem; + margin-bottom: 1rem; +} + +.heading { + font-size: 1.125rem; + font-weight: 600; + margin: 0 0 0.5rem; +} + +.description { + color: var(--seamless-text-muted, #d1d5db); + font-size: 0.875rem; + margin: 0 0 1rem; +} + +.notice { + border-left: 3px solid var(--seamless-accent, #3b82f6); + padding: 0.5rem 0.75rem; + margin: 0 0 1rem; + font-size: 0.875rem; +} + +.warning { + border-left: 3px solid var(--seamless-danger, #f87171); + padding: 0.5rem 0.75rem; + margin: 0 0 1rem; + font-size: 0.875rem; +} + +.list { + list-style: none; + margin: 0 0 1rem; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.item { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 0.75rem; + padding: 0.75rem; + border: 1px solid var(--seamless-border, #4b5563); + border-radius: 0.5rem; +} + +.itemName { + font-weight: 600; +} + +.meta { + color: var(--seamless-text-muted, #9ca3af); + font-size: 0.8125rem; + margin: 0.25rem 0 0; +} + +.itemActions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; +} + +.input { + padding: 0.375rem 0.5rem; + background-color: var(--seamless-surface-raised, #4b5563); + border: 1px solid var(--seamless-border, #d1d5db); + border-radius: 0.375rem; + color: inherit; +} + +.button { + padding: 0.5rem 0.875rem; + background-color: var(--seamless-accent, #3b82f6); + color: var(--seamless-accent-contrast, white); + border: none; + border-radius: 0.375rem; + font-weight: 500; + cursor: pointer; +} + +.button:disabled, +.secondary:disabled, +.danger:disabled { + opacity: 0.6; + cursor: default; +} + +.secondary { + padding: 0.5rem 0.875rem; + background: none; + color: var(--seamless-accent, #3b82f6); + border: 1px solid var(--seamless-accent, #3b82f6); + border-radius: 0.375rem; + cursor: pointer; +} + +.danger { + padding: 0.5rem 0.875rem; + background: none; + color: var(--seamless-danger, #f87171); + border: 1px solid var(--seamless-danger, #f87171); + border-radius: 0.375rem; + cursor: pointer; +} + +.error { + color: var(--seamless-danger, #f87171); + font-size: 0.875rem; +} + +.success { + color: var(--seamless-success, #6ee7b7); + font-size: 0.875rem; +} diff --git a/packages/react/tests/AccountSecurity.test.tsx b/packages/react/tests/AccountSecurity.test.tsx new file mode 100644 index 0000000..47fbe1a --- /dev/null +++ b/packages/react/tests/AccountSecurity.test.tsx @@ -0,0 +1,320 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in the project root for full license information + */ + +import { SeamlessAuthError } from '@seamless-auth/client'; +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; + +import { useAuth } from '@/AuthProvider'; +import { useAuthClient } from '@/hooks/useAuthClient'; +import { usePasskeySupport } from '@/hooks/usePasskeySupport'; +import { AccountSecurity } from '@/components/AccountSecurity'; +import { PasskeyManagement } from '@/components/PasskeyManagement'; +import { RecoveryCodeSettings } from '@/components/RecoveryCodeSettings'; +import { RecoveryRequests } from '@/components/RecoveryRequests'; + +jest.mock('@/AuthProvider'); +jest.mock('@/hooks/useAuthClient'); +jest.mock('@/hooks/usePasskeySupport'); + +const failure = (status: number, body: unknown = {}) => ({ + data: null, + error: new SeamlessAuthError('failed', status, body), +}); +const success = (data: T) => ({ data, error: null }); + +const passkey = (id: string, extra: Record = {}) => ({ + id, + createdAt: '2026-10-01T00:00:00.000Z', + counter: 0, + backedup: false, + backedUp: false, + friendlyName: `Key ${id}`, + lastUsedAt: null, + ...extra, +}); + +const client = { + getRecoveryCodeStatus: jest.fn(), + generateRecoveryCodes: jest.fn(), + listAccountRecoveryRequests: jest.fn(), + cancelAccountRecoveryRequest: jest.fn(), +}; +let auth: Record; + +beforeEach(() => { + jest.clearAllMocks(); + auth = { + credentials: [], + updateCredential: jest.fn().mockResolvedValue(success({})), + deleteCredential: jest.fn().mockResolvedValue(success({ message: 'ok' })), + registerPasskey: jest.fn().mockResolvedValue(success({ credentialId: 'n' })), + verifyStepUpWithPasskey: jest.fn().mockResolvedValue(success({ fresh: true })), + }; + (useAuth as jest.Mock).mockImplementation(() => auth); + (useAuthClient as jest.Mock).mockReturnValue(client); + (usePasskeySupport as jest.Mock).mockReturnValue({ + passkeySupported: true, + loading: false, + }); +}); + +describe('PasskeyManagement', () => { + it('lists passkeys with their type and last use, and asks for a second one', () => { + auth.credentials = [ + passkey('1', { + deviceType: 'multiDevice', + lastUsedAt: '2026-10-20T00:00:00.000Z', + }), + ]; + + render(); + + expect(screen.getByText('Key 1')).toBeInTheDocument(); + expect(screen.getByText(/Synced passkey/)).toHaveTextContent(/Last used/); + expect(screen.getByText(/Add a second/)).toBeInTheDocument(); + }); + + it('warns when there are none and says so when the browser cannot add one', () => { + (usePasskeySupport as jest.Mock).mockReturnValue({ + passkeySupported: false, + loading: false, + }); + + render(); + + expect(screen.getByText(/You have no passkeys/)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Add a passkey' })).toBeDisabled(); + expect(screen.getByText(/cannot create passkeys/)).toBeInTheDocument(); + }); + + it('renames a passkey, and clears the name when left empty', async () => { + auth.credentials = [passkey('1'), passkey('2', { transports: ['usb'] })]; + render(); + + expect(screen.getByText(/Security key/)).toHaveTextContent(/Never used/); + expect(screen.queryByText(/Add a second/)).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Rename Key 1' })); + fireEvent.change(screen.getByLabelText('Passkey name'), { + target: { value: ' Work laptop ' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + + await waitFor(() => + expect(auth.updateCredential).toHaveBeenCalledWith( + expect.objectContaining({ id: '1', friendlyName: 'Work laptop' }) + ) + ); + + fireEvent.click(await screen.findByRole('button', { name: 'Rename Key 2' })); + fireEvent.change(screen.getByLabelText('Passkey name'), { target: { value: ' ' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + + await waitFor(() => + expect(auth.updateCredential).toHaveBeenLastCalledWith( + expect.objectContaining({ id: '2', friendlyName: null }) + ) + ); + }); + + it('reports a failed rename and can cancel it', async () => { + auth.credentials = [passkey('1')]; + (auth.updateCredential as jest.Mock).mockResolvedValue(failure(500)); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Rename Key 1' })); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + + expect(await screen.findByRole('alert')).toHaveTextContent(/Could not rename/); + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + expect(screen.getByRole('button', { name: 'Rename Key 1' })).toBeInTheDocument(); + }); + + it('deletes only after confirming, with a stronger warning for the last one', async () => { + auth.credentials = [passkey('1')]; + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Delete Key 1' })); + expect(screen.getByText(/only passkey/)).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Keep' })); + expect(auth.deleteCredential).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole('button', { name: 'Delete Key 1' })); + fireEvent.click(screen.getByRole('button', { name: 'Delete' })); + + await waitFor(() => expect(auth.deleteCredential).toHaveBeenCalledWith('1')); + }); + + it('reports a failed delete', async () => { + auth.credentials = [passkey('1'), passkey('2')]; + (auth.deleteCredential as jest.Mock).mockResolvedValue(failure(500)); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Delete Key 2' })); + expect(screen.getByText('Delete this passkey?')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Delete' })); + + expect(await screen.findByRole('alert')).toHaveTextContent(/Could not delete/); + }); + + it('adds another passkey on this device', async () => { + auth.credentials = [passkey('1')]; + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Add a passkey' })); + + expect(await screen.findByRole('status')).toHaveTextContent('Passkey added.'); + expect(auth.registerPasskey).toHaveBeenCalledWith({ + metadata: expect.objectContaining({ friendlyName: expect.any(String) }), + }); + }); + + it('explains a refused enrollment', async () => { + (auth.registerPasskey as jest.Mock).mockResolvedValue( + failure(403, { error: 'synced_passkey_not_allowed' }) + ); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Add a passkey' })); + + expect(await screen.findByRole('alert')).toHaveTextContent(/password manager/); + }); +}); + +describe('RecoveryCodeSettings', () => { + it('shows what is left and replaces the set after a step-up', async () => { + client.getRecoveryCodeStatus.mockResolvedValue( + success({ + enabled: true, + remaining: 2, + total: 10, + createdAt: '2026-10-01T00:00:00.000Z', + }) + ); + client.generateRecoveryCodes + .mockResolvedValueOnce(failure(403, { error: 'step_up_required' })) + .mockResolvedValueOnce( + success({ message: 'Success', codes: ['NEW-1'], createdAt: 'x' }) + ); + + render(); + + expect(await screen.findByText(/2 of 10 codes left, created/)).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Replace recovery codes' })); + + expect(await screen.findByText('NEW-1')).toBeInTheDocument(); + expect(auth.verifyStepUpWithPasskey).toHaveBeenCalledTimes(1); + + fireEvent.click(screen.getByLabelText(/I have saved/)); + fireEvent.click(screen.getByRole('button', { name: 'Done' })); + + await waitFor(() => expect(client.getRecoveryCodeStatus).toHaveBeenCalledTimes(2)); + }); + + it('generates a first set and reports a failed step-up', async () => { + client.getRecoveryCodeStatus.mockResolvedValue( + success({ enabled: true, remaining: 0, total: 0, createdAt: null }) + ); + client.generateRecoveryCodes.mockResolvedValue( + failure(403, { error: 'step_up_required' }) + ); + (auth.verifyStepUpWithPasskey as jest.Mock).mockResolvedValue(failure(0)); + + render(); + + expect(await screen.findByText(/no recovery codes yet/)).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Generate recovery codes' })); + + expect(await screen.findByRole('alert')).toHaveTextContent(/Confirm it is you/); + }); + + it('says when recovery codes are off, or cannot be read', async () => { + client.getRecoveryCodeStatus.mockResolvedValueOnce( + success({ enabled: false, remaining: 0, total: 0, createdAt: null }) + ); + const { unmount } = render(); + expect(await screen.findByText(/turned off/)).toBeInTheDocument(); + unmount(); + + client.getRecoveryCodeStatus.mockResolvedValueOnce(failure(500)); + render(); + expect(await screen.findByRole('alert')).toHaveTextContent(/Could not load/); + }); +}); + +describe('RecoveryRequests', () => { + const request = (id: string, state = 'pending') => ({ + id, + channel: 'email', + state, + requestedAt: '2026-10-24T10:00:00.000Z', + completesAt: '2026-10-25T10:00:00.000Z', + }); + + it('lists open requests and cancels one', async () => { + client.listAccountRecoveryRequests.mockResolvedValue( + success({ requests: [request('a'), request('b', 'ready')] }) + ); + client.cancelAccountRecoveryRequest.mockResolvedValue(success({ message: 'ok' })); + + render(); + + expect(await screen.findByText(/If it was not you/)).toBeInTheDocument(); + expect(screen.getByText(/Can be completed now/)).toBeInTheDocument(); + + const items = screen.getAllByRole('listitem'); + fireEvent.click(within(items[0]).getByRole('button', { name: 'Cancel request' })); + + await waitFor(() => expect(screen.getAllByRole('listitem')).toHaveLength(1)); + expect(client.cancelAccountRecoveryRequest).toHaveBeenCalledWith('a'); + }); + + it('reports a failed cancel and an empty list', async () => { + client.listAccountRecoveryRequests.mockResolvedValueOnce( + success({ requests: [request('a')] }) + ); + client.cancelAccountRecoveryRequest.mockResolvedValue( + failure(404, { error: 'recovery_not_found' }) + ); + const { unmount } = render(); + + fireEvent.click(await screen.findByRole('button', { name: 'Cancel request' })); + expect(await screen.findByRole('alert')).toHaveTextContent( + /no open recovery request/ + ); + unmount(); + + client.listAccountRecoveryRequests.mockResolvedValueOnce(success({ requests: [] })); + render(); + expect(await screen.findByText(/No one is trying/)).toBeInTheDocument(); + }); + + it('reports a list that cannot be read', async () => { + client.listAccountRecoveryRequests.mockResolvedValue(failure(500)); + + render(); + + expect(await screen.findByRole('alert')).toHaveTextContent(/Could not load/); + }); +}); + +describe('AccountSecurity', () => { + it('renders every section', async () => { + client.getRecoveryCodeStatus.mockResolvedValue( + success({ enabled: true, remaining: 10, total: 10, createdAt: null }) + ); + client.listAccountRecoveryRequests.mockResolvedValue(success({ requests: [] })); + + render(); + + expect(screen.getByRole('heading', { name: 'Passkeys' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Recovery codes' })).toBeInTheDocument(); + expect( + screen.getByRole('heading', { name: 'Account recovery requests' }) + ).toBeInTheDocument(); + expect(await screen.findByText(/10 of 10 codes left\./)).toBeInTheDocument(); + }); +}); From 4e77c1bb2ae8c7437af5b7b7be871c7260a3b05b Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sat, 24 Oct 2026 22:51:15 -0400 Subject: [PATCH 7/8] feat(svelte): expose the recovery session actions --- .changeset/recovery-svelte.md | 5 +++++ packages/svelte/src/auth.svelte.ts | 7 +++++++ packages/svelte/tests/auth.test.ts | 19 +++++++++++++++++++ 3 files changed, 31 insertions(+) create mode 100644 .changeset/recovery-svelte.md diff --git a/.changeset/recovery-svelte.md b/.changeset/recovery-svelte.md new file mode 100644 index 0000000..983044c --- /dev/null +++ b/.changeset/recovery-svelte.md @@ -0,0 +1,5 @@ +--- +'@seamless-auth/svelte': minor +--- + +The session exposes `recoverySession`, `redeemRecoveryCode(code)` and `completeAccountRecovery()` from `@seamless-auth/client`, for a custom account recovery screen. diff --git a/packages/svelte/src/auth.svelte.ts b/packages/svelte/src/auth.svelte.ts index 11354d9..bd63564 100644 --- a/packages/svelte/src/auth.svelte.ts +++ b/packages/svelte/src/auth.svelte.ts @@ -127,6 +127,9 @@ export class SeamlessAuth implements AuthSessionActions { get stepUpStatus() { return this.#state.stepUpStatus; } + get recoverySession() { + return this.#state.recoverySession; + } get hasSignedInBefore() { return this.#state.hasSignedInBefore; } @@ -209,6 +212,10 @@ export class SeamlessAuth implements AuthSessionActions { login: AuthSessionActions['login'] = (...args) => this.#session.actions.login(...args); handlePasskeyLogin: AuthSessionActions['handlePasskeyLogin'] = () => this.#session.actions.handlePasskeyLogin(); + redeemRecoveryCode: AuthSessionActions['redeemRecoveryCode'] = code => + this.#session.actions.redeemRecoveryCode(code); + completeAccountRecovery: AuthSessionActions['completeAccountRecovery'] = () => + this.#session.actions.completeAccountRecovery(); registerPasskey: AuthSessionActions['registerPasskey'] = input => this.#session.actions.registerPasskey(input); refreshSession: AuthSessionActions['refreshSession'] = options => diff --git a/packages/svelte/tests/auth.test.ts b/packages/svelte/tests/auth.test.ts index b088969..b85c357 100644 --- a/packages/svelte/tests/auth.test.ts +++ b/packages/svelte/tests/auth.test.ts @@ -68,6 +68,25 @@ describe('createSeamlessAuth', () => { expect(auth.user).toBeNull(); }); + it('reports a recovery session from a recovery code or a completed recovery', async () => { + const recovery = { + body: { message: 'Success', recovery: true, nextStep: 'enroll_passkey' }, + }; + const { auth } = setup({ + 'GET /users/me': signedIn, + 'POST /recovery-codes/redeem': recovery, + 'POST /recovery/complete': recovery, + }); + await auth.whenSettled(); + expect(auth.recoverySession).toBe(false); + + await auth.redeemRecoveryCode('ABCDE-FGHIJ'); + flushSync(); + expect(auth.recoverySession).toBe(true); + + await expect(auth.completeAccountRecovery()).resolves.toMatchObject({ error: null }); + }); + it('starts settled from a session the server resolved', async () => { const { auth } = setup({ 'GET /users/me': signedIn }, { initialSession: { user } }); expect(auth.loading).toBe(false); From 728de253662926a631dc839aa2c5649ec2b0bf02 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sun, 25 Oct 2026 11:01:23 -0400 Subject: [PATCH 8/8] style: format the types 0.31.0 changeset --- .changeset/types-0-31-0.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.changeset/types-0-31-0.md b/.changeset/types-0-31-0.md index 0bfb7d9..9d6381e 100644 --- a/.changeset/types-0-31-0.md +++ b/.changeset/types-0-31-0.md @@ -1,7 +1,7 @@ --- -"@seamless-auth/client": patch -"@seamless-auth/react": patch -"@seamless-auth/react-native": patch +'@seamless-auth/client': patch +'@seamless-auth/react': patch +'@seamless-auth/react-native': patch --- Update `@seamless-auth/types` to 0.31.0, the contract for auth server v0.20.