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(); + }); +});