From 954cf1523a1691fd909f8aebdad753ccef815353 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sun, 30 Aug 2026 17:54:29 -0400 Subject: [PATCH] feat(passkey): let a caller ask for a security key at enrollment Closes #136. GET /webAuthn/register/start accepts an attachment that narrows the browser picker to one kind of authenticator, and nothing in this SDK could ask for it: buildRegisterStartPath set only the PRF flags, so reaching the parameter meant hand building the URL and reimplementing the PRF, metadata and error handling registerPasskey already does. registerPasskey now takes an optional attachment, and the bundled enrolment view offers a "Use a security key instead" control that takes it. That is the path an organisation handing someone an issued key needs, rather than leaving them to recognise it in a browser dialog. Omitting the option sends no query parameter, so the deployment's authenticator_policy.attachment stays in charge and the default path is unchanged. PasskeyAttachment is derived from AuthenticatorAttachmentPolicy with Exclude rather than restating the two strings: `any` is a standing deployment default, not something a single request can ask for, so the request type is that policy minus that member. The API publishes no request-side union of its own. PasskeyPolicyErrorCode gains attachment_not_allowed. It was left out in #135 because it was a 400 from register/start rather than a 403 from finish, and because nothing in the SDK could provoke it. Sending the parameter is what makes it reachable, and it is a refusal by authenticator_policy like the others, so it belongs with them. prf_output_not_allowed stays out: it reports a client that failed to strip PRF output, not a deployment refusing an authenticator. The enrolment view now explains a refusal instead of showing the generic failure, so a user told to reach for a security key can act on it. Verified with npm run typecheck, npm test (315 passing), npm run lint, npm run format:check, and npm run build. --- .changeset/olive-pears-shout.md | 15 ++-- .changeset/tidy-moons-repeat.md | 19 +++++ README.md | 54 ++++++++++--- src/client/createSeamlessAuthClient.ts | 23 ++++++ src/client/errors.ts | 39 ++++++---- src/index.ts | 2 + src/styles/registerPasskey.module.css | 22 ++++++ src/views/PassKeyRegistration.tsx | 52 +++++++++++-- tests/RegisterPassKey.test.tsx | 86 ++++++++++++++++++++- tests/createSeamlessAuthClient.test.ts | 102 ++++++++++++++++++++++++- tests/errors.test.ts | 31 +++++--- 11 files changed, 395 insertions(+), 50 deletions(-) create mode 100644 .changeset/tidy-moons-repeat.md diff --git a/.changeset/olive-pears-shout.md b/.changeset/olive-pears-shout.md index 7f5c85a..ac116c4 100644 --- a/.changeset/olive-pears-shout.md +++ b/.changeset/olive-pears-shout.md @@ -2,11 +2,16 @@ '@seamless-auth/react': minor --- -Add `getPasskeyPolicyErrorCode()`, which reads the code the auth API refuses a -passkey registration with (`synced_passkey_not_allowed`, -`authenticator_not_allowed`, or `prf_required`) so an app can explain the refusal -instead of rendering the raw code from `error.message`. Unrecognized codes return -`undefined`, so a refusal from a newer API keeps your generic messaging. +Add `getPasskeyPolicyErrorCode()`, which reads the code a refused passkey +registration carries (`attachment_not_allowed`, `synced_passkey_not_allowed`, +`authenticator_not_allowed`, or `prf_required`) so an app can explain the +refusal instead of rendering the raw code from `error.message`. Unrecognized +codes return `undefined`, so a refusal from a newer API keeps your generic +messaging. + +The `PasskeyPolicyErrorCode` union is derived from `WebAuthnErrorCode` in +`@seamless-auth/types`, so the codes this recognizes cannot drift from the ones +the API sends. This matters on a default deployment: the API's `authenticator_policy.syncedPasskeys` defaults to `block`, and passkeys created diff --git a/.changeset/tidy-moons-repeat.md b/.changeset/tidy-moons-repeat.md new file mode 100644 index 0000000..5d5533b --- /dev/null +++ b/.changeset/tidy-moons-repeat.md @@ -0,0 +1,19 @@ +--- +'@seamless-auth/react': minor +--- + +`registerPasskey()` accepts an `attachment`, so a caller can ask for a roaming +authenticator (`cross-platform`, a USB or NFC security key) or the one built +into the device (`platform`) instead of leaving the choice to the browser's +picker. The bundled enrolment view offers a "Use a security key instead" control +that takes this path, and explains a policy refusal rather than showing a +generic failure. + +Omitting the option sends no query parameter, so the deployment's +`authenticator_policy.attachment` stays in charge and current behaviour is +unchanged. It is a request rather than an override: a deployment that pins the +other kind refuses the registration with `attachment_not_allowed`, which +`getPasskeyPolicyErrorCode()` reads. + +`PasskeyAttachment` is exported, and is derived from the deployment policy type +in `@seamless-auth/types` rather than restating its members. diff --git a/README.md b/README.md index 92ea83d..5121d4c 100644 --- a/README.md +++ b/README.md @@ -554,12 +554,41 @@ switch (detail?.name) { `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. +### Choosing the authenticator + +By default the browser offers every kind of authenticator the deployment enrols, which is what +`authenticator_policy.attachment: 'any'` means on the API. Pass `attachment` to narrow the picker to +one kind, for example to send someone straight to an issued security key rather than leaving them to +find it in a browser dialog: + +```ts +import { getPasskeyPolicyErrorCode } from '@seamless-auth/react'; + +const { error } = await authClient.registerPasskey({ + metadata, + attachment: 'cross-platform', +}); + +if (getPasskeyPolicyErrorCode(error) === 'attachment_not_allowed') { + // This deployment pins the other kind. Fall back to the default path. +} +``` + +`'cross-platform'` is a roaming authenticator such as a USB or NFC security key. `'platform'` is the +one built into the device, such as Touch ID or Windows Hello. Omit the option to leave the choice to +the deployment. + +This is a request, not an override. A deployment that has pinned +`authenticator_policy.attachment` to the other kind refuses the registration with +`attachment_not_allowed`, covered below. The bundled enrolment view offers a "Use a security key +instead" control that takes this path. + ### Passkey policy refusals -A credential can also be refused after a successful ceremony, by the policy the API is configured -with. `registerPasskey()` then fails with status `403` and a body whose `error` is a stable code -rather than a sentence, so rendering `error.message` would put that code in front of a user. Use -`getPasskeyPolicyErrorCode()` to branch on it: +A registration can also be refused by the policy the API is configured with. `registerPasskey()` +then fails with a body whose `error` is a stable code rather than a sentence, so rendering +`error.message` would put that code in front of a user. Use `getPasskeyPolicyErrorCode()` to branch +on it: ```ts import { getPasskeyPolicyErrorCode } from '@seamless-auth/react'; @@ -567,6 +596,9 @@ import { getPasskeyPolicyErrorCode } from '@seamless-auth/react'; const { error } = await authClient.registerPasskey({ token, metadata }); switch (getPasskeyPolicyErrorCode(error)) { + case 'attachment_not_allowed': + // The requested `attachment` is not the kind this deployment enrols. + break; case 'synced_passkey_not_allowed': // This passkey syncs to iCloud Keychain or Google Password Manager, and // this deployment requires a device-bound one such as a security key. @@ -583,11 +615,15 @@ switch (getPasskeyPolicyErrorCode(error)) { } ``` -| Code | When the API sends it | -| ---------------------------- | -------------------------------------------------------------------------------------------- | -| `synced_passkey_not_allowed` | `authenticator_policy.syncedPasskeys` is `block` and the credential is backup eligible | -| `authenticator_not_allowed` | the credential's AAGUID is on `aaguidDenyList`, or absent from a non-empty `aaguidAllowList` | -| `prf_required` | registration required PRF and the credential did not report support for it | +| Code | Stage | Status | When the API sends it | +| ---------------------------- | --------------- | ------ | -------------------------------------------------------------------------------------------- | +| `attachment_not_allowed` | register/start | 400 | the requested `attachment` is not the kind `authenticator_policy.attachment` pins | +| `synced_passkey_not_allowed` | register/finish | 403 | `authenticator_policy.syncedPasskeys` is `block` and the credential is backup eligible | +| `authenticator_not_allowed` | register/finish | 403 | the credential's AAGUID is on `aaguidDenyList`, or absent from a non-empty `aaguidAllowList` | +| `prf_required` | register/finish | 403 | registration required PRF and the credential did not report support for it | + +`attachment_not_allowed` is refused before any ceremony runs, so the browser never prompts. The rest +are refused after a credential exists and can be inspected. `syncedPasskeys` defaults to `block` on the Seamless Auth API. Passkeys created by iCloud Keychain and Google Password Manager are backup eligible, so on a default deployment the most common consumer diff --git a/src/client/createSeamlessAuthClient.ts b/src/client/createSeamlessAuthClient.ts index ebf03c8..e5c6622 100644 --- a/src/client/createSeamlessAuthClient.ts +++ b/src/client/createSeamlessAuthClient.ts @@ -16,6 +16,7 @@ import { import type { AddOrganizationMemberRequest, + AuthenticatorAttachmentPolicy, CreateOrganizationRequest, CredentialUpdateResponse, LoginMethod as LoginMethodShape, @@ -160,10 +161,28 @@ export interface PasskeyRegistrationData { /** Response body returned when credential metadata is updated. */ export type CredentialUpdateResult = CredentialUpdateResponse; +/** + * Which kind of authenticator to offer at registration. Omitting it leaves the + * choice to the deployment's `authenticator_policy.attachment`, which offers + * both kinds by default. + * + * Derived from the deployment policy type rather than restating its members: + * `any` is a standing default a deployment sets, not something a single request + * can ask for, so the request type is that policy minus that one member. + */ +export type PasskeyAttachment = Exclude; + export interface RegisterPasskeyOptions { metadata: PasskeyMetadata; requestPrf?: boolean; requirePrf?: boolean; + /** + * Narrows the browser picker to one kind of authenticator, for example + * `cross-platform` to send a user straight to an issued security key. A + * deployment that has pinned a different kind refuses this, so it is a + * request rather than an override. + */ + attachment?: PasskeyAttachment; } export type StepUpMethod = StepUpMethodShape; @@ -333,6 +352,10 @@ function buildRegisterStartPath(input: RegisterPasskeyOptions) { query.set('requestPrf', 'true'); } + if (input.attachment) { + query.set('attachment', input.attachment); + } + const queryString = query.toString(); return `/webAuthn/register/start${queryString ? `?${queryString}` : ''}`; diff --git a/src/client/errors.ts b/src/client/errors.ts index c66a411..71172d9 100644 --- a/src/client/errors.ts +++ b/src/client/errors.ts @@ -86,28 +86,36 @@ export function getOAuthErrorCode(error: unknown): OAuthErrorCode | undefined { } /** - * Machine-readable codes `POST /webAuthn/register/finish` answers `403` with - * when a deployment refuses an otherwise valid credential on policy grounds. + * Machine-readable codes registration is refused with when a deployment will not + * enrol the authenticator on policy grounds. + * + * `attachment_not_allowed` comes from register/start with a `400`, before any + * ceremony runs. The rest come from register/finish with a `403`, once the + * credential exists and can be inspected. */ export type PasskeyPolicyErrorCode = Extract< WebAuthnErrorCodeShape, - 'synced_passkey_not_allowed' | 'authenticator_not_allowed' | 'prf_required' + | 'attachment_not_allowed' + | 'synced_passkey_not_allowed' + | 'authenticator_not_allowed' + | 'prf_required' >; /* * `WebAuthnErrorCode` covers every WebAuthn code the API sends, across all of - * its operations, so it is deliberately narrowed rather than used whole. The - * two it leaves out belong to other calls and other statuses: - * `attachment_not_allowed` is a `400` from register/start, and - * `prf_output_not_allowed` a `400` from login and step-up finish. Reporting - * either as a registration policy refusal would be wrong. + * its operations, so it is narrowed rather than used whole. The one it leaves + * out, `prf_output_not_allowed`, is a `400` from login and step-up finish, and + * it reports a client that failed to strip PRF output rather than a deployment + * refusing an authenticator. Reporting it as a policy refusal would point an + * integrator at their configuration for what is a bug in the caller. * - * `Extract` still ties the three names to the upstream union: if one is renamed - * or dropped there, it resolves to `never` and the `Record` below stops - * compiling. As with the OAuth codes, the runtime list stays out of the browser - * bundle so Zod does not come with it. + * `Extract` ties these names to the upstream union: if one is renamed or dropped + * there, it resolves to `never` and the `Record` below stops compiling. As with + * the OAuth codes, the runtime list stays out of the browser bundle so Zod does + * not come with it. */ const PASSKEY_POLICY_ERROR_CODES: Record = { + attachment_not_allowed: true, synced_passkey_not_allowed: true, authenticator_not_allowed: true, prf_required: true, @@ -127,9 +135,10 @@ function readPolicyCode(body: unknown): PasskeyPolicyErrorCode | undefined { } /** - * Read the passkey policy refusal off a registration error. Returns `undefined` - * for anything unrecognized, including codes added by a newer API, so callers - * keep their generic messaging instead of showing a raw code. + * Read the passkey policy refusal off a registration error, from either stage of + * the ceremony. Returns `undefined` for anything unrecognized, including codes + * added by a newer API, so callers keep their generic messaging instead of + * showing a raw code. * * The auth API sends the code as the whole of `error`, which is also what * becomes `error.message`. A proxy in front of it may instead derive a diff --git a/src/index.ts b/src/index.ts index 611f0ee..faabc0d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -27,6 +27,7 @@ import { OrganizationSwitchResult, OrganizationsResult, PasskeyLoginData, + PasskeyAttachment, PasskeyMetadata, PasskeyRegistrationData, RegisterInput, @@ -108,6 +109,7 @@ export type { OrganizationSwitchResult, OrganizationsResult, PasskeyLoginData, + PasskeyAttachment, PasskeyMetadata, PasskeyPolicyErrorCode, PasskeyPrfInput, diff --git a/src/styles/registerPasskey.module.css b/src/styles/registerPasskey.module.css index 9108d74..93cad1a 100644 --- a/src/styles/registerPasskey.module.css +++ b/src/styles/registerPasskey.module.css @@ -107,3 +107,25 @@ opacity: 0.6; cursor: default; } + +.secondary { + margin-top: 0.75rem; + width: 100%; + padding: 0.75rem 1rem; + background: none; + color: var(--seamless-accent, #059669); + border: 1px solid var(--seamless-accent, #059669); + border-radius: 0.5rem; + font-weight: 500; + cursor: pointer; + transition: background-color 0.2s ease; +} + +.secondary:hover:not(:disabled) { + background-color: var(--seamless-accent-muted, rgba(5, 150, 105, 0.1)); +} + +.secondary:disabled { + opacity: 0.6; + cursor: default; +} diff --git a/src/views/PassKeyRegistration.tsx b/src/views/PassKeyRegistration.tsx index 580abeb..09c8138 100644 --- a/src/views/PassKeyRegistration.tsx +++ b/src/views/PassKeyRegistration.tsx @@ -5,7 +5,8 @@ */ import { useAuth } from '@/AuthProvider'; -import { PasskeyMetadata } from '@/client/createSeamlessAuthClient'; +import { PasskeyAttachment, PasskeyMetadata } from '@/client/createSeamlessAuthClient'; +import { getPasskeyPolicyErrorCode, type PasskeyPolicyErrorCode } from '@/client/errors'; import React, { useState } from 'react'; import { useAuthClient } from '@/hooks/useAuthClient'; import { hasNonPasskeyLoginMethod, useLoginMethods } from '@/hooks/useLoginMethods'; @@ -16,6 +17,22 @@ import styles from '@/styles/registerPasskey.module.css'; import { parseUserAgent } from '@/utils'; import DeviceNameModal from '@/components/DeviceNameModal'; +const POLICY_REFUSAL_MESSAGES: Record = { + attachment_not_allowed: + 'This application does not accept that kind of authenticator. Try the other option.', + synced_passkey_not_allowed: + 'This passkey syncs to a password manager, and this application requires one that stays on a single device, such as a security key.', + authenticator_not_allowed: 'This application does not accept this authenticator.', + prf_required: + 'This authenticator does not support a feature this application requires.', +}; + +function policyRefusalMessage(error: unknown): string | undefined { + const code = getPasskeyPolicyErrorCode(error); + + return code ? POLICY_REFUSAL_MESSAGES[code] : undefined; +} + const PasskeyRegistration: React.FC = () => { const { refreshSession } = useAuth(); const authClient = useAuthClient(); @@ -32,6 +49,7 @@ const PasskeyRegistration: React.FC = () => { browser: string; deviceInfo: string; } | null>(null); + const [pendingAttachment, setPendingAttachment] = useState(); // The session already exists by the time this screen renders: the OTP step // that led here established it. A passkey is an addition to that session @@ -47,10 +65,11 @@ const PasskeyRegistration: React.FC = () => { navigate('/'); }; - const openDeviceModal = () => { + const openDeviceModal = (attachment?: PasskeyAttachment) => { const { platform, browser, deviceInfo } = parseUserAgent(); setPendingMetadata({ platform, browser, deviceInfo }); + setPendingAttachment(attachment); setShowDeviceModal(true); }; @@ -65,7 +84,10 @@ const PasskeyRegistration: React.FC = () => { setStatus('loading'); try { - const { error } = await authClient.registerPasskey(metadata); + const { error } = await authClient.registerPasskey({ + metadata, + attachment: pendingAttachment, + }); if (error) { throw error; @@ -75,13 +97,16 @@ const PasskeyRegistration: React.FC = () => { setStatus('success'); setMessage('Passkey registered successfully.'); navigate('/'); - } catch { + } catch (error) { console.error('Passkey registration failed.'); setStatus('error'); - setMessage('Error registering passkey.'); + // A policy refusal names something the user can act on, for example + // reaching for a security key instead. Anything else stays generic. + setMessage(policyRefusalMessage(error) ?? 'Error registering passkey.'); } finally { setShowDeviceModal(false); setPendingMetadata(null); + setPendingAttachment(undefined); } }; @@ -124,13 +149,28 @@ const PasskeyRegistration: React.FC = () => {

+ {/* + The default above leaves the choice to the deployment policy, + which offers both kinds. This is the deliberate path for someone + who has been handed an issued key and should not have to find it + in the browser's picker. + */} + + {message && (

{ await waitFor(() => { expect(mockRegisterPasskey).toHaveBeenCalledWith({ - friendlyName: 'My Device', - platform: 'macOS', - browser: 'Chrome', - deviceInfo: 'MacBook Pro', + metadata: { + friendlyName: 'My Device', + platform: 'macOS', + browser: 'Chrome', + deviceInfo: 'MacBook Pro', + }, + attachment: undefined, }); }); @@ -252,4 +256,78 @@ describe('RegisterPasskey skip control', () => { expect(await screen.findByText(/requires one to sign in/i)).toBeInTheDocument(); expect(screen.queryByText(/^Continue$/i)).not.toBeInTheDocument(); }); + + it('requests a cross-platform authenticator from the security key path', async () => { + mockRegisterPasskey.mockResolvedValueOnce({ data: {}, error: null }); + + render(); + + fireEvent.click(await screen.findByText(/Use a security key instead/i)); + fireEvent.click(await screen.findByText('Confirm')); + + await waitFor(() => { + expect(mockRegisterPasskey).toHaveBeenCalledWith({ + metadata: { + friendlyName: 'My Device', + platform: 'macOS', + browser: 'Chrome', + deviceInfo: 'MacBook Pro', + }, + attachment: 'cross-platform', + }); + }); + }); + + // The refusal names something the user can act on, so it has to reach the + // screen instead of the generic failure the catch would otherwise show. + it('explains a policy refusal instead of showing the raw code', async () => { + mockRegisterPasskey.mockResolvedValueOnce({ + data: null, + error: new SeamlessAuthError('synced_passkey_not_allowed', 403, { + error: 'synced_passkey_not_allowed', + }), + }); + + render(); + + fireEvent.click(await screen.findByText(/Register Passkey/i)); + fireEvent.click(await screen.findByText('Confirm')); + + expect(await screen.findByText(/stays on a single device/i)).toBeInTheDocument(); + expect(screen.queryByText(/synced_passkey_not_allowed/)).not.toBeInTheDocument(); + }); + + it('falls back to the generic message when a failure carries no policy code', async () => { + mockRegisterPasskey.mockResolvedValueOnce({ + data: null, + error: new SeamlessAuthError('Verification failed.', 500), + }); + + render(); + + fireEvent.click(await screen.findByText(/Register Passkey/i)); + fireEvent.click(await screen.findByText('Confirm')); + + expect(await screen.findByText('Error registering passkey.')).toBeInTheDocument(); + }); + + // The attachment the user asked for is refused at register/start, before any + // ceremony, so the screen has to explain it rather than appear to hang. + it('explains a refused attachment from the security key path', async () => { + mockRegisterPasskey.mockResolvedValueOnce({ + data: null, + error: new SeamlessAuthError('attachment_not_allowed', 400, { + error: 'attachment_not_allowed', + }), + }); + + render(); + + fireEvent.click(await screen.findByText(/Use a security key instead/i)); + fireEvent.click(await screen.findByText('Confirm')); + + expect( + await screen.findByText(/does not accept that kind of authenticator/i) + ).toBeInTheDocument(); + }); }); diff --git a/tests/createSeamlessAuthClient.test.ts b/tests/createSeamlessAuthClient.test.ts index 7d9fd80..1786fd3 100644 --- a/tests/createSeamlessAuthClient.test.ts +++ b/tests/createSeamlessAuthClient.test.ts @@ -12,7 +12,7 @@ import { WebAuthnError, } from '@simplewebauthn/browser'; -import { getWebAuthnErrorDetail } from '../src/client/errors'; +import { getPasskeyPolicyErrorCode, getWebAuthnErrorDetail } from '../src/client/errors'; jest.mock('../src/fetchWithAuth'); jest.mock('@simplewebauthn/browser', () => ({ @@ -508,6 +508,106 @@ describe('createSeamlessAuthClient', () => { }); }); + it('asks register/start for the requested attachment', async () => { + mockFetchWithAuth + .mockResolvedValueOnce({ ok: true, json: async () => ({ challenge: 'challenge' }) }) + .mockResolvedValueOnce({ ok: true }); + (startRegistration as jest.Mock).mockResolvedValueOnce({ id: 'cred-key' }); + + const client = createSeamlessAuthClient({ apiHost: 'https://api.example.com' }); + + await client.registerPasskey({ + metadata: { + friendlyName: 'Security Key', + platform: 'mac', + browser: 'chrome', + deviceInfo: 'mac chrome', + }, + attachment: 'cross-platform', + }); + + expect(mockFetchWithAuth).toHaveBeenNthCalledWith( + 1, + '/webAuthn/register/start?attachment=cross-platform', + expect.objectContaining({ method: 'GET' }) + ); + }); + + // Omitting it has to send nothing rather than a default, so the deployment's + // own `authenticator_policy.attachment` stays in charge of the picker. + it('sends no attachment parameter when none is requested', async () => { + mockFetchWithAuth + .mockResolvedValueOnce({ ok: true, json: async () => ({ challenge: 'challenge' }) }) + .mockResolvedValueOnce({ ok: true }); + (startRegistration as jest.Mock).mockResolvedValueOnce({ id: 'cred' }); + + const client = createSeamlessAuthClient({ apiHost: 'https://api.example.com' }); + + await client.registerPasskey({ + friendlyName: 'My Laptop', + platform: 'mac', + browser: 'chrome', + deviceInfo: 'mac chrome', + }); + + expect(mockFetchWithAuth).toHaveBeenNthCalledWith( + 1, + '/webAuthn/register/start', + expect.objectContaining({ method: 'GET' }) + ); + }); + + it('combines the attachment with a PRF flag', async () => { + mockFetchWithAuth + .mockResolvedValueOnce({ ok: true, json: async () => ({ challenge: 'challenge' }) }) + .mockResolvedValueOnce({ ok: true }); + (startRegistration as jest.Mock).mockResolvedValueOnce({ id: 'cred' }); + + const client = createSeamlessAuthClient({ apiHost: 'https://api.example.com' }); + + await client.registerPasskey({ + metadata: { + friendlyName: 'Security Key', + platform: 'mac', + browser: 'chrome', + deviceInfo: 'mac chrome', + }, + requirePrf: true, + attachment: 'cross-platform', + }); + + expect(mockFetchWithAuth).toHaveBeenNthCalledWith( + 1, + '/webAuthn/register/start?requirePrf=true&attachment=cross-platform', + expect.objectContaining({ method: 'GET' }) + ); + }); + + // The refusal happens at register/start, so it must surface as the registration + // result rather than being lost before the ceremony is reached. + it('surfaces a register/start attachment refusal to the caller', async () => { + mockFetchWithAuth.mockResolvedValueOnce({ + ok: false, + status: 400, + json: async () => ({ error: 'attachment_not_allowed' }), + }); + + const client = createSeamlessAuthClient({ apiHost: 'https://api.example.com' }); + + const { error } = await client.registerPasskey({ + metadata: { + friendlyName: 'Security Key', + platform: 'mac', + browser: 'chrome', + deviceInfo: 'mac chrome', + }, + attachment: 'platform', + }); + + expect(getPasskeyPolicyErrorCode(error)).toBe('attachment_not_allowed'); + expect(startRegistration).not.toHaveBeenCalled(); + }); + it('requests PRF-capable registration and reports capability', async () => { mockFetchWithAuth .mockResolvedValueOnce({ diff --git a/tests/errors.test.ts b/tests/errors.test.ts index e25f8c8..4b2c074 100644 --- a/tests/errors.test.ts +++ b/tests/errors.test.ts @@ -170,6 +170,7 @@ describe('getOAuthErrorCode', () => { describe('getPasskeyPolicyErrorCode', () => { const policyCodes: PasskeyPolicyErrorCode[] = [ + 'attachment_not_allowed', 'synced_passkey_not_allowed', 'authenticator_not_allowed', 'prf_required', @@ -190,17 +191,27 @@ describe('getPasskeyPolicyErrorCode', () => { expect(getPasskeyPolicyErrorCode(error)).toBe(code); }); - // These are WebAuthn codes from other operations: `attachment_not_allowed` is - // a 400 from register/start, `prf_output_not_allowed` a 400 from login and - // step-up finish. Neither is a registration policy refusal. - it.each(['attachment_not_allowed', 'prf_output_not_allowed'])( - 'ignores %s, which is not a registration policy refusal', - code => { - const error = new SeamlessAuthError(code, 400, { error: code }); + // A WebAuthn code from another operation: a 400 from login and step-up finish, + // reporting a client that failed to strip PRF output rather than a deployment + // refusing an authenticator. + it('ignores prf_output_not_allowed, which is not a registration refusal', () => { + const error = new SeamlessAuthError('prf_output_not_allowed', 400, { + error: 'prf_output_not_allowed', + }); - expect(getPasskeyPolicyErrorCode(error)).toBeUndefined(); - } - ); + expect(getPasskeyPolicyErrorCode(error)).toBeUndefined(); + }); + + // register/start refuses before any ceremony runs, so this arrives as a 400 + // rather than the 403 the finish-stage refusals use. + it('reads attachment_not_allowed from a register/start refusal', async () => { + const error = await toSeamlessAuthError( + responseWith(400, async () => ({ error: 'attachment_not_allowed' })), + 'Failed to fetch passkey registration challenge.' + ); + + expect(getPasskeyPolicyErrorCode(error)).toBe('attachment_not_allowed'); + }); it('reads a real refusal built from the API response', async () => { const error = await toSeamlessAuthError(