From 3c50032781ba9caf3940e6c3edac49c29e5b79c7 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sun, 30 Aug 2026 13:24:14 -0400 Subject: [PATCH] feat(passkey): expose the policy code behind a refused passkey registration The auth API can refuse a verified credential on policy grounds, answering 403 with a body whose `error` is a machine code rather than a sentence. The adapter forwards that body verbatim and `extractMessage` turns the code into `error.message`, so an app rendering the message shows a user `synced_passkey_not_allowed`, and a caller has nothing to branch on except the literal. `getPasskeyPolicyErrorCode()` reads that code, following the existing `getOAuthErrorCode()` shape: unrecognized values return undefined so a refusal from a newer API keeps generic messaging, and a code nested under `details` is found too, for a proxy that derives its own top-level `error` string. `prf_required` comes from the same endpoint and had the same problem, so it is covered as well. This is the default path rather than an edge case: `syncedPasskeys` defaults to `block` and every iCloud Keychain or Google Password Manager passkey is backup eligible, so README and AGENTS.md now name the codes and the default. Refs #132 --- .changeset/olive-pears-shout.md | 14 +++++ AGENTS.md | 19 ++++++- README.md | 50 +++++++++++++++++ src/client/errors.ts | 63 ++++++++++++++++++++++ src/index.ts | 4 ++ tests/errors.test.ts | 96 +++++++++++++++++++++++++++++++++ 6 files changed, 244 insertions(+), 2 deletions(-) create mode 100644 .changeset/olive-pears-shout.md diff --git a/.changeset/olive-pears-shout.md b/.changeset/olive-pears-shout.md new file mode 100644 index 0000000..7f5c85a --- /dev/null +++ b/.changeset/olive-pears-shout.md @@ -0,0 +1,14 @@ +--- +'@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. + +This matters on a default deployment: the API's +`authenticator_policy.syncedPasskeys` defaults to `block`, and passkeys created +by iCloud Keychain or Google Password Manager are backup eligible, so the most +common consumer passkey is refused at registration. diff --git a/AGENTS.md b/AGENTS.md index abed167..b84c833 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -140,7 +140,7 @@ Runtime exports currently include: - `usePasskeySupport` - `hasScopedRole` and `roleGrantsAccess` - `encodePrfSalt`, `extractPasskeyPrfResult`, and `isPasskeyPrfSupported` -- `SeamlessAuthError`, `getOAuthErrorCode`, and `getWebAuthnErrorDetail` +- `SeamlessAuthError`, `getOAuthErrorCode`, `getPasskeyPolicyErrorCode`, and `getWebAuthnErrorDetail` Every request method on the client and the provider resolves to a `SeamlessAuthResult` (`{ data, error }`) and does not throw for HTTP or @@ -159,7 +159,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` +- WebAuthn failure detail: `WebAuthnErrorDetail`, `PasskeyPolicyErrorCode` - `SeamlessAuthClient` and `SeamlessAuthClientOptions` Public API changes should be treated deliberately: @@ -246,6 +246,21 @@ or email sends), so both the client and the adapter serve them over `POST` with JSON body to force a CORS preflight. This depends on a matching adapter version; do not revert them to `GET` in isolation. +`/webAuthn/register/finish` can refuse a credential that passed verification, +answering `403` with a body whose `error` is a machine code rather than a +sentence: `synced_passkey_not_allowed`, `authenticator_not_allowed`, or +`prf_required`. The adapter forwards that body verbatim, and `extractMessage` +turns the code into `error.message`, so callers must branch with +`getPasskeyPolicyErrorCode()` rather than render the message. The API's +`authenticator_policy.syncedPasskeys` defaults to `block` and every iCloud +Keychain or Google Password Manager passkey is backup eligible, so this is the +default path, not an edge case. + +`@seamless-auth/types` publishes no union for those codes as of 0.15.0, so +`PasskeyPolicyErrorCode` in `src/client/errors.ts` is a local copy of the API's +list. If the types package starts exporting one, switch to it so the +`Record` check catches upstream drift the way the OAuth one does. + Before documenting new flow behavior, verify the route contract in `seamless-auth-server` or `seamless-auth-api`. ## Migration Status diff --git a/README.md b/README.md index d5a510b..92ea83d 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ - `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 +- `getPasskeyPolicyErrorCode()`, which reads the code the API refused a passkey registration with - types including `AuthContextType`, `Credential`, `User`, `OAuthProvider`, `StepUpStatus`, the `SeamlessAuthResult` wrapper, and the headless client input/result types ## Installation @@ -553,6 +554,50 @@ 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. +### 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: + +```ts +import { getPasskeyPolicyErrorCode } from '@seamless-auth/react'; + +const { error } = await authClient.registerPasskey({ token, metadata }); + +switch (getPasskeyPolicyErrorCode(error)) { + 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. + break; + case 'authenticator_not_allowed': + // This authenticator model is not permitted here. + break; + case 'prf_required': + // Registration asked for PRF and the authenticator does not support it. + break; + default: + // No error, or one without a recognized code. Fall back to error?.message. + break; +} +``` + +| 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 | + +`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 +passkey is refused at registration. If that is not what you want, set +`authenticator_policy.syncedPasskeys` to `allow` in the API's system config; the SDK cannot relax it +from the client. + +Like `getOAuthErrorCode()`, this returns `undefined` for anything it does not recognize, including +codes added by a newer API, so an unexpected refusal keeps your generic messaging. + The single exception is `isPasskeySupported`-style capability checks: `isPasskeyPrfSupported(): Promise` is a local check rather than a request, so it returns a plain boolean. @@ -967,6 +1012,11 @@ The state-changing OTP and magic-link request routes are `POST` (marked above). email sends to a signed-in user. Using `@seamless-auth/react` with an older adapter that only serves the `GET` forms returns a 404 for those requests. See the changelog for the minimum adapter version. +`/webAuthn/register/finish` can refuse a verified credential on policy grounds with a `403` whose +body is a stable code. `syncedPasskeys` defaults to `block`, which refuses every backup-eligible +passkey, so this is reachable on a default deployment. See +[Passkey policy refusals](#passkey-policy-refusals). + ## Notes - This package does not create its own ``. diff --git a/src/client/errors.ts b/src/client/errors.ts index 9f5ccdf..6e6510c 100644 --- a/src/client/errors.ts +++ b/src/client/errors.ts @@ -82,6 +82,69 @@ export function getOAuthErrorCode(error: unknown): OAuthErrorCode | undefined { : undefined; } +/** + * Machine-readable codes `POST /webAuthn/register/finish` answers `403` with + * when a deployment refuses an otherwise valid credential on policy grounds. + */ +export type PasskeyPolicyErrorCode = + | 'synced_passkey_not_allowed' + | 'authenticator_not_allowed' + | 'prf_required'; + +/* + * Unlike the OAuth codes, `@seamless-auth/types` publishes no union for these + * (checked against 0.15.0), so this list is a copy of the API's rather than a + * check against it and will not fail to compile if the API adds a code. Drift + * therefore degrades to generic messaging instead of breaking; the `Record` + * still keeps the list and the union in step with each other. + */ +const PASSKEY_POLICY_ERROR_CODES: Record = { + synced_passkey_not_allowed: true, + authenticator_not_allowed: true, + prf_required: true, +}; + +function readPolicyCode(body: unknown): PasskeyPolicyErrorCode | undefined { + if (typeof body !== 'object' || body === null) { + return undefined; + } + + const code = (body as { error?: unknown }).error; + + return typeof code === 'string' && + Object.prototype.hasOwnProperty.call(PASSKEY_POLICY_ERROR_CODES, code) + ? (code as 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. + * + * 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 + * human-readable `error` and keep the upstream body under `details`, so an + * unrecognized top-level value falls through to the nested one rather than + * ending the lookup. + */ +export function getPasskeyPolicyErrorCode( + error: unknown +): PasskeyPolicyErrorCode | undefined { + if (!(error instanceof SeamlessAuthError)) { + return undefined; + } + + if (typeof error.body !== 'object' || error.body === null) { + return undefined; + } + + return ( + readPolicyCode(error.body) ?? + readPolicyCode((error.body as { details?: unknown }).details) + ); +} + /** * Detail recovered from a failed WebAuthn ceremony. * diff --git a/src/index.ts b/src/index.ts index 5dc286f..611f0ee 100644 --- a/src/index.ts +++ b/src/index.ts @@ -44,8 +44,10 @@ import { } from '@/client/createSeamlessAuthClient'; import { getOAuthErrorCode, + getPasskeyPolicyErrorCode, getWebAuthnErrorDetail, OAuthErrorCode, + PasskeyPolicyErrorCode, SeamlessAuthError, WebAuthnErrorDetail, } from '@/client/errors'; @@ -70,6 +72,7 @@ export { encodePrfSalt, extractPasskeyPrfResult, getOAuthErrorCode, + getPasskeyPolicyErrorCode, getWebAuthnErrorDetail, hasNonPasskeyLoginMethod, hasScopedRole, @@ -106,6 +109,7 @@ export type { OrganizationsResult, PasskeyLoginData, PasskeyMetadata, + PasskeyPolicyErrorCode, PasskeyPrfInput, PasskeyPrfResult, PasskeyRegistrationData, diff --git a/tests/errors.test.ts b/tests/errors.test.ts index ff90389..42f67f6 100644 --- a/tests/errors.test.ts +++ b/tests/errors.test.ts @@ -6,6 +6,7 @@ import { getOAuthErrorCode, + getPasskeyPolicyErrorCode, getWebAuthnErrorDetail, SeamlessAuthError, toSeamlessAuthError, @@ -166,6 +167,101 @@ describe('getOAuthErrorCode', () => { }); }); +describe('getPasskeyPolicyErrorCode', () => { + const policyCodes = [ + 'synced_passkey_not_allowed', + 'authenticator_not_allowed', + 'prf_required', + ]; + + it.each(policyCodes)('returns the known code %s', code => { + const error = new SeamlessAuthError(code, 403, { error: code }); + + expect(getPasskeyPolicyErrorCode(error)).toBe(code); + }); + + it.each(policyCodes)('returns the known code %s nested under details', code => { + const error = new SeamlessAuthError('Registration refused', 403, { + error: 'Registration refused', + details: { error: code }, + }); + + expect(getPasskeyPolicyErrorCode(error)).toBe(code); + }); + + it('reads a real refusal built from the API response', async () => { + const error = await toSeamlessAuthError( + responseWith(403, async () => ({ error: 'synced_passkey_not_allowed' })), + 'Failed to register passkey' + ); + + expect(getPasskeyPolicyErrorCode(error)).toBe('synced_passkey_not_allowed'); + }); + + it('prefers the top-level code over the nested one', () => { + const error = new SeamlessAuthError('nope', 403, { + error: 'prf_required', + details: { error: 'synced_passkey_not_allowed' }, + }); + + expect(getPasskeyPolicyErrorCode(error)).toBe('prf_required'); + }); + + it('ignores a code the SDK does not know', () => { + expect( + getPasskeyPolicyErrorCode( + new SeamlessAuthError('nope', 403, { error: 'passkey_something_new' }) + ) + ).toBeUndefined(); + expect( + getPasskeyPolicyErrorCode( + new SeamlessAuthError('nope', 403, { + details: { error: 'passkey_something_new' }, + }) + ) + ).toBeUndefined(); + }); + + it('returns undefined for a generic verification failure', () => { + expect( + getPasskeyPolicyErrorCode( + new SeamlessAuthError('Registration failed verification', 403, { + error: 'Registration failed verification', + }) + ) + ).toBeUndefined(); + }); + + it('returns undefined for a missing or non-object body', () => { + expect(getPasskeyPolicyErrorCode(new SeamlessAuthError('nope', 403))).toBeUndefined(); + expect( + getPasskeyPolicyErrorCode(new SeamlessAuthError('nope', 403, null)) + ).toBeUndefined(); + expect( + getPasskeyPolicyErrorCode(new SeamlessAuthError('nope', 403, 'prf_required')) + ).toBeUndefined(); + }); + + it('returns undefined for a non-object details', () => { + expect( + getPasskeyPolicyErrorCode( + new SeamlessAuthError('nope', 403, { details: 'prf_required' }) + ) + ).toBeUndefined(); + expect( + getPasskeyPolicyErrorCode(new SeamlessAuthError('nope', 403, { details: null })) + ).toBeUndefined(); + }); + + it('returns undefined for anything that is not a SeamlessAuthError', () => { + expect(getPasskeyPolicyErrorCode(new Error('boom'))).toBeUndefined(); + expect( + getPasskeyPolicyErrorCode({ body: { error: 'prf_required' } }) + ).toBeUndefined(); + expect(getPasskeyPolicyErrorCode(null)).toBeUndefined(); + }); +}); + describe('getWebAuthnErrorDetail', () => { const ceremonyError = () => { const error = new Error('The operation is insecure.');