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(