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(