From a390e3bf65319428ec54b52d74569863b0724d5b Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Tue, 28 Jul 2026 19:28:13 -0400 Subject: [PATCH] fix(webauthn): keep the underlying error on a failed ceremony The passkey and step-up methods caught the ceremony failure without binding it, so the DOMException name was gone before any caller saw it. A dismissed prompt, an account with no passkey, and an origin or RP ID mismatch all collapsed to one generic string, and diagnosing the real cause meant running raw WebAuthn in the browser console. Attach the thrown error to the returned SeamlessAuthError as cause, and add getWebAuthnErrorDetail() to read its name, code, and message. Result messages are unchanged, and only the error name is logged since ceremony messages can name the origin. Closes #107 --- .changeset/lucky-pandas-listen.md | 5 + AGENTS.md | 3 +- README.md | 33 ++++++ src/client/createSeamlessAuthClient.ts | 60 +++++++--- src/client/errors.ts | 53 ++++++++- src/client/result.ts | 5 +- src/index.ts | 10 +- tests/createSeamlessAuthClient.test.ts | 154 ++++++++++++++++++++++++- tests/errors.test.ts | 59 ++++++++++ 9 files changed, 362 insertions(+), 20 deletions(-) create mode 100644 .changeset/lucky-pandas-listen.md diff --git a/.changeset/lucky-pandas-listen.md b/.changeset/lucky-pandas-listen.md new file mode 100644 index 0000000..a0efff5 --- /dev/null +++ b/.changeset/lucky-pandas-listen.md @@ -0,0 +1,5 @@ +--- +'@seamless-auth/react': minor +--- + +Stop discarding the underlying WebAuthn error. The passkey login, passkey registration, and step-up verification methods now attach the thrown ceremony error to the returned `SeamlessAuthError` as `cause`, and a new `getWebAuthnErrorDetail()` export reads its `name`, `code`, and `message`. Callers can tell a dismissed prompt or missing credential (`NotAllowedError`) apart from an origin or RP ID mismatch (`SecurityError`) instead of seeing one generic string. The friendly result messages are unchanged, and only the error name is logged. diff --git a/AGENTS.md b/AGENTS.md index 3cab325..f70f3f1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -113,7 +113,7 @@ Runtime exports currently include: - `usePasskeySupport` - `hasScopedRole` and `roleGrantsAccess` - `encodePrfSalt`, `extractPasskeyPrfResult`, and `isPasskeyPrfSupported` -- `SeamlessAuthError` and `getOAuthErrorCode` +- `SeamlessAuthError`, `getOAuthErrorCode`, and `getWebAuthnErrorDetail` Every request method on the client and the provider resolves to a `SeamlessAuthResult` (`{ data, error }`) and does not throw for HTTP or @@ -132,6 +132,7 @@ domain models, for example: - OAuth types: `OAuthProvider`, `OAuthProvidersResult`, `StartOAuthLoginInput`, `StartOAuthLoginResult`, `FinishOAuthLoginInput`, `OAuthErrorCode` - Organization types: `CreateOrganizationInput`, `UpdateOrganizationInput`, `OrganizationMemberInput`, `OrganizationMemberUpdateInput`, `OrganizationsResult`, `OrganizationResult`, `OrganizationMembersResult`, `OrganizationMembershipResult`, `OrganizationSwitchResult` - Step-up types: `StepUpMethod`, `StepUpStatus`, `StepUpPrfData` +- WebAuthn failure detail: `WebAuthnErrorDetail` - `SeamlessAuthClient` and `SeamlessAuthClientOptions` Public API changes should be treated deliberately: diff --git a/README.md b/README.md index 2664921..c9aa28e 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ - `hasScopedRole()` and `roleGrantsAccess()` - `SeamlessAuthError`, the error type carried on a failed result - `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 - types including `AuthContextType`, `Credential`, `User`, `OAuthProvider`, `StepUpStatus`, the `SeamlessAuthResult` wrapper, and the headless client input/result types ## Installation @@ -505,6 +506,38 @@ a disabled provider is a value you can map straight to UI state rather than an e `SeamlessAuthError` carries the server's `message`, the HTTP `status`, and the parsed response `body`, so you can branch on a specific failure. +### WebAuthn ceremony failures + +A passkey or step-up ceremony can fail in the browser before any request is sent, so those results +carry the thrown error as `cause` with `status` `0`. Use `getWebAuthnErrorDetail()` to read it: the +`name` is the `DOMException` name that separates the cases a user can act on, and `code` is +SimpleWebAuthn's narrower reason when it identified one. + +```ts +import { getWebAuthnErrorDetail } from '@seamless-auth/react'; + +const { error } = await authClient.verifyStepUpWithPasskey(); +const detail = getWebAuthnErrorDetail(error); + +switch (detail?.name) { + case 'NotAllowedError': + // The prompt was dismissed, or the account has no passkey to assert. + break; + case 'SecurityError': + // The origin or RP ID does not match what the API is configured for. + break; + case 'InvalidStateError': + // This authenticator already holds a passkey for the account. + break; + default: + // Not a ceremony failure. Fall back to error?.message. + break; +} +``` + +`getWebAuthnErrorDetail()` returns `undefined` for any error that did not come from a ceremony, so an +HTTP failure keeps flowing through `error.message` and `error.body` as usual. + The single exception is `isPasskeySupported`-style capability checks: `isPasskeyPrfSupported(): Promise` is a local check rather than a request, so it returns a plain boolean. diff --git a/src/client/createSeamlessAuthClient.ts b/src/client/createSeamlessAuthClient.ts index b8f1cf9..5a5afbd 100644 --- a/src/client/createSeamlessAuthClient.ts +++ b/src/client/createSeamlessAuthClient.ts @@ -16,7 +16,14 @@ import { import { createFetchWithAuth } from '../fetchWithAuth'; import { Credential, Organization, OrganizationMembership, User } from '../types'; -import { requestResult, resultError, resultOf, type SeamlessAuthResult } from './result'; +import { getWebAuthnErrorDetail } from './errors'; +import { + NETWORK_ERROR_STATUS, + requestResult, + resultError, + resultOf, + type SeamlessAuthResult, +} from './result'; import { createPrfRequestBody, extractPasskeyPrfResult, @@ -377,6 +384,25 @@ function buildAssertionStartInit(input?: PasskeyPrfInput): RequestInit { }; } +/** + * A ceremony failure never reaches the server, so there is no response body to + * carry detail. Keep the friendly message and hand the thrown error to callers + * as the cause, so a dismissed prompt can be told apart from an RP ID mismatch. + * Only the error name is logged: ceremony messages can name the origin. + */ +function webAuthnFailure( + logContext: string, + message: string, + thrown: unknown +): SeamlessAuthResult { + const failure = resultError(message, NETWORK_ERROR_STATUS, undefined, thrown); + const detail = getWebAuthnErrorDetail(failure.error); + + console.error(logContext, detail?.name ?? 'Unknown error.'); + + return failure; +} + export const createSeamlessAuthClient = ( opts: SeamlessAuthClientOptions ): SeamlessAuthClient => { @@ -421,10 +447,9 @@ export const createSeamlessAuthClient = ( })) as AuthenticationResponseJSON; prf = extractPasskeyPrfResult(credential); assertionResponse = stripPrfResultsFromAssertion(credential); - } catch { + } catch (error) { // Typically a cancelled or unsupported authenticator prompt. - console.error('Passkey login error.'); - return resultError('Passkey login failed.'); + return webAuthnFailure('Passkey login error.', 'Passkey login failed.', error); } const verified = await requestResult( @@ -637,11 +662,14 @@ export const createSeamlessAuthClient = ( if (error instanceof WebAuthnError) { // The authenticator name is the useful detail here, for example // InvalidStateError when the passkey already exists. - return resultError(error.name); + return resultError(error.name, NETWORK_ERROR_STATUS, undefined, error); } - console.error('Passkey registration error.'); - return resultError('Passkey registration failed.'); + return webAuthnFailure( + 'Passkey registration error.', + 'Passkey registration failed.', + error + ); } const prfCapable = getRegistrationPrfCapable(attestationResponse); @@ -689,9 +717,12 @@ export const createSeamlessAuthClient = ( optionsJSON: preparePrfRequestOptions(started.data), })) as AuthenticationResponseJSON; assertionResponse = stripPrfResultsFromAssertion(credential); - } catch { - console.error('Step-up authentication error.'); - return resultError('Step-up authentication failed.'); + } catch (error) { + return webAuthnFailure( + 'Step-up authentication error.', + 'Step-up authentication failed.', + error + ); } const verified = await requestResult( @@ -730,9 +761,12 @@ export const createSeamlessAuthClient = ( })) as AuthenticationResponseJSON; prf = extractPasskeyPrfResult(credential); assertionResponse = stripPrfResultsFromAssertion(credential); - } catch { - console.error('Step-up authentication error.'); - return resultError('Step-up authentication failed.'); + } catch (error) { + return webAuthnFailure( + 'Step-up authentication error.', + 'Step-up authentication failed.', + error + ); } if (!prf) { diff --git a/src/client/errors.ts b/src/client/errors.ts index 7ac1ff0..f472a6a 100644 --- a/src/client/errors.ts +++ b/src/client/errors.ts @@ -11,12 +11,18 @@ export class SeamlessAuthError extends Error { readonly status: number; readonly body: unknown; + /** + * The underlying failure when the error was raised locally rather than by a + * response, for example the `DOMException` a WebAuthn ceremony throws. + */ + readonly cause: unknown; - constructor(message: string, status: number, body?: unknown) { + constructor(message: string, status: number, body?: unknown, cause?: unknown) { super(message); this.name = 'SeamlessAuthError'; this.status = status; this.body = body; + this.cause = cause; } } @@ -56,6 +62,51 @@ export function getOAuthErrorCode(error: unknown): OAuthErrorCode | undefined { : undefined; } +/** + * Detail recovered from a failed WebAuthn ceremony. + * + * `name` is the `DOMException` name, which is what distinguishes the cases a + * user can act on: `NotAllowedError` for a dismissed prompt or no usable + * credential, `SecurityError` for an origin or RP ID mismatch, + * `InvalidStateError` for an already registered passkey. `code` is + * SimpleWebAuthn's narrower reason when it identified one, for example + * `ERROR_CEREMONY_ABORTED`. + */ +export type WebAuthnErrorDetail = { + name: string; + code?: string; + message: string; +}; + +function isErrorLike( + value: unknown +): value is { name: string; message?: unknown; code?: unknown } { + return ( + typeof value === 'object' && + value !== null && + typeof (value as { name?: unknown }).name === 'string' + ); +} + +/** + * Read the WebAuthn failure behind a result error. Returns `undefined` for + * errors that did not come from a ceremony, so callers can branch on the + * specific failure and otherwise fall back to `error.message`. + */ +export function getWebAuthnErrorDetail(error: unknown): WebAuthnErrorDetail | undefined { + if (!(error instanceof SeamlessAuthError) || !isErrorLike(error.cause)) { + return undefined; + } + + const { name, code, message } = error.cause; + + return { + name, + code: typeof code === 'string' ? code : undefined, + message: typeof message === 'string' ? message : '', + }; +} + function extractMessage(body: unknown): string | undefined { if (typeof body !== 'object' || body === null) { return undefined; diff --git a/src/client/result.ts b/src/client/result.ts index 86b66a1..121ba2e 100644 --- a/src/client/result.ts +++ b/src/client/result.ts @@ -74,7 +74,8 @@ export function resultOf(data: T): SeamlessAuthResult { export function resultError( message: string, status = NETWORK_ERROR_STATUS, - body?: unknown + body?: unknown, + cause?: unknown ): SeamlessAuthResult { - return { data: null, error: new SeamlessAuthError(message, status, body) }; + return { data: null, error: new SeamlessAuthError(message, status, body, cause) }; } diff --git a/src/index.ts b/src/index.ts index 64f6227..629e95c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -41,7 +41,13 @@ import { TotpStatus, UpdateOrganizationInput, } from '@/client/createSeamlessAuthClient'; -import { getOAuthErrorCode, OAuthErrorCode, SeamlessAuthError } from '@/client/errors'; +import { + getOAuthErrorCode, + getWebAuthnErrorDetail, + OAuthErrorCode, + SeamlessAuthError, + WebAuthnErrorDetail, +} from '@/client/errors'; import type { SeamlessAuthResult } from '@/client/result'; import { encodePrfSalt, @@ -62,6 +68,7 @@ export { encodePrfSalt, extractPasskeyPrfResult, getOAuthErrorCode, + getWebAuthnErrorDetail, hasScopedRole, isPasskeyPrfSupported, roleGrantsAccess, @@ -112,4 +119,5 @@ export type { TotpStatus, UpdateOrganizationInput, User, + WebAuthnErrorDetail, }; diff --git a/tests/createSeamlessAuthClient.test.ts b/tests/createSeamlessAuthClient.test.ts index 4785f30..7d9fd80 100644 --- a/tests/createSeamlessAuthClient.test.ts +++ b/tests/createSeamlessAuthClient.test.ts @@ -6,7 +6,13 @@ import { createSeamlessAuthClient } from '../src/client/createSeamlessAuthClient'; import { createFetchWithAuth } from '../src/fetchWithAuth'; -import { startAuthentication, startRegistration } from '@simplewebauthn/browser'; +import { + startAuthentication, + startRegistration, + WebAuthnError, +} from '@simplewebauthn/browser'; + +import { getWebAuthnErrorDetail } from '../src/client/errors'; jest.mock('../src/fetchWithAuth'); jest.mock('@simplewebauthn/browser', () => ({ @@ -26,8 +32,24 @@ jest.mock('@simplewebauthn/browser', () => ({ .replace(/\//g, '_') .replace(/=+$/g, '') ), + // Mirrors the real class: the name is the DOMException name, and the code is + // SimpleWebAuthn's narrower reason. WebAuthnError: class WebAuthnError extends Error { - name = 'WebAuthnError'; + code: string; + + constructor({ + message, + code, + cause, + }: { + message: string; + code: string; + cause: Error; + }) { + super(message); + this.name = cause.name; + this.code = code; + } }, })); @@ -783,4 +805,132 @@ describe('createSeamlessAuthClient', () => { }), }); }); + + describe('WebAuthn ceremony failures', () => { + const CEREMONY_MESSAGE = 'The operation either timed out or was not allowed.'; + let consoleError: jest.SpyInstance; + + const ceremonyError = () => { + const error = new Error(CEREMONY_MESSAGE); + error.name = 'NotAllowedError'; + (error as Error & { code?: string }).code = 'ERROR_CEREMONY_ABORTED'; + + return error; + }; + + const mockStartedCeremony = () => { + mockFetchWithAuth.mockResolvedValueOnce({ + ok: true, + json: async () => ({ challenge: 'challenge' }), + }); + (startAuthentication as jest.Mock).mockRejectedValueOnce(ceremonyError()); + }; + + beforeEach(() => { + consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleError.mockRestore(); + }); + + it('carries the assertion failure detail through step-up', async () => { + mockStartedCeremony(); + + const client = createSeamlessAuthClient({ apiHost: 'https://api.example.com' }); + const { error } = await client.verifyStepUpWithPasskey(); + + expect(error?.message).toBe('Step-up authentication failed.'); + expect(getWebAuthnErrorDetail(error)).toEqual({ + name: 'NotAllowedError', + code: 'ERROR_CEREMONY_ABORTED', + message: CEREMONY_MESSAGE, + }); + }); + + it('carries the assertion failure detail through PRF step-up', async () => { + mockStartedCeremony(); + + const client = createSeamlessAuthClient({ apiHost: 'https://api.example.com' }); + const { error } = await client.verifyStepUpWithPasskeyPrf({ + salt: Uint8Array.from(Array.from({ length: 32 }, (_, index) => index + 1)), + }); + + expect(error?.message).toBe('Step-up authentication failed.'); + expect(getWebAuthnErrorDetail(error)?.name).toBe('NotAllowedError'); + }); + + it('carries the assertion failure detail through passkey login', async () => { + mockStartedCeremony(); + + const client = createSeamlessAuthClient({ apiHost: 'https://api.example.com' }); + const { error } = await client.loginWithPasskey(); + + expect(error?.message).toBe('Passkey login failed.'); + expect(getWebAuthnErrorDetail(error)?.name).toBe('NotAllowedError'); + }); + + it('carries the registration failure detail alongside the authenticator name', async () => { + mockFetchWithAuth.mockResolvedValueOnce({ + ok: true, + json: async () => ({ challenge: 'challenge' }), + }); + const cause = new Error('The authenticator was previously registered'); + cause.name = 'InvalidStateError'; + const thrown = new WebAuthnError({ + message: 'The authenticator was previously registered', + code: 'ERROR_AUTHENTICATOR_PREVIOUSLY_REGISTERED', + cause, + }); + (startRegistration as jest.Mock).mockRejectedValueOnce(thrown); + + const client = createSeamlessAuthClient({ apiHost: 'https://api.example.com' }); + const { error } = await client.registerPasskey({ + friendlyName: 'My Laptop', + platform: 'mac', + browser: 'chrome', + deviceInfo: 'mac • chrome', + }); + + expect(error?.message).toBe('InvalidStateError'); + expect(getWebAuthnErrorDetail(error)).toMatchObject({ + code: 'ERROR_AUTHENTICATOR_PREVIOUSLY_REGISTERED', + message: 'The authenticator was previously registered', + }); + }); + + it('logs the error name without the ceremony message', async () => { + mockStartedCeremony(); + + const client = createSeamlessAuthClient({ apiHost: 'https://api.example.com' }); + await client.verifyStepUpWithPasskey(); + + expect(consoleError).toHaveBeenCalledWith( + 'Step-up authentication error.', + 'NotAllowedError' + ); + expect(consoleError).not.toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining(CEREMONY_MESSAGE) + ); + }); + + it('reports an unknown error when the ceremony throws a non-error', async () => { + mockFetchWithAuth.mockResolvedValueOnce({ + ok: true, + json: async () => ({ challenge: 'challenge' }), + }); + (startAuthentication as jest.Mock).mockRejectedValueOnce('boom'); + + const client = createSeamlessAuthClient({ apiHost: 'https://api.example.com' }); + const { error } = await client.verifyStepUpWithPasskey(); + + expect(error?.message).toBe('Step-up authentication failed.'); + expect(getWebAuthnErrorDetail(error)).toBeUndefined(); + expect(consoleError).toHaveBeenCalledWith( + 'Step-up authentication error.', + 'Unknown error.' + ); + }); + }); }); diff --git a/tests/errors.test.ts b/tests/errors.test.ts index 7e838a4..f44cf8b 100644 --- a/tests/errors.test.ts +++ b/tests/errors.test.ts @@ -6,6 +6,7 @@ import { getOAuthErrorCode, + getWebAuthnErrorDetail, SeamlessAuthError, toSeamlessAuthError, } from '@/client/errors'; @@ -109,3 +110,61 @@ describe('getOAuthErrorCode', () => { expect(getOAuthErrorCode(null)).toBeUndefined(); }); }); + +describe('getWebAuthnErrorDetail', () => { + const ceremonyError = () => { + const error = new Error('The operation is insecure.'); + error.name = 'SecurityError'; + + return error; + }; + + it('reads the name and message off the underlying ceremony error', () => { + const error = new SeamlessAuthError( + 'Step-up authentication failed.', + 0, + undefined, + ceremonyError() + ); + + expect(getWebAuthnErrorDetail(error)).toEqual({ + name: 'SecurityError', + code: undefined, + message: 'The operation is insecure.', + }); + }); + + it('includes the narrower reason code when the thrown error carries one', () => { + const thrown = Object.assign(ceremonyError(), { code: 'ERROR_INVALID_RP_ID' }); + + expect( + getWebAuthnErrorDetail(new SeamlessAuthError('nope', 0, undefined, thrown))?.code + ).toBe('ERROR_INVALID_RP_ID'); + }); + + it('reads a DOMException, which is not always an Error instance across realms', () => { + const thrown = { name: 'NotAllowedError', message: 'Cancelled.' }; + + expect( + getWebAuthnErrorDetail(new SeamlessAuthError('nope', 0, undefined, thrown)) + ).toEqual({ name: 'NotAllowedError', code: undefined, message: 'Cancelled.' }); + }); + + it('returns undefined for an error with no ceremony cause', async () => { + expect(getWebAuthnErrorDetail(new SeamlessAuthError('nope', 400))).toBeUndefined(); + expect( + getWebAuthnErrorDetail(new SeamlessAuthError('nope', 0, undefined, 'boom')) + ).toBeUndefined(); + expect( + await toSeamlessAuthError( + responseWith(400, async () => ({ error: 'nope' })), + 'fallback' + ).then(getWebAuthnErrorDetail) + ).toBeUndefined(); + }); + + it('returns undefined for anything that is not a SeamlessAuthError', () => { + expect(getWebAuthnErrorDetail(ceremonyError())).toBeUndefined(); + expect(getWebAuthnErrorDetail(null)).toBeUndefined(); + }); +});