Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/olive-pears-shout.md
Original file line number Diff line number Diff line change
@@ -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.
19 changes: 17 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>` (`{ data, error }`) and does not throw for HTTP or
Expand All @@ -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:
Expand Down Expand Up @@ -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<Code, true>` 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
Expand Down
50 changes: 50 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<boolean>` is a local check rather than a request, so it returns a
plain boolean.
Expand Down Expand Up @@ -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 `<BrowserRouter>`.
Expand Down
63 changes: 63 additions & 0 deletions src/client/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<PasskeyPolicyErrorCode, true> = {
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.
*
Expand Down
4 changes: 4 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,10 @@ import {
} from '@/client/createSeamlessAuthClient';
import {
getOAuthErrorCode,
getPasskeyPolicyErrorCode,
getWebAuthnErrorDetail,
OAuthErrorCode,
PasskeyPolicyErrorCode,
SeamlessAuthError,
WebAuthnErrorDetail,
} from '@/client/errors';
Expand All @@ -70,6 +72,7 @@ export {
encodePrfSalt,
extractPasskeyPrfResult,
getOAuthErrorCode,
getPasskeyPolicyErrorCode,
getWebAuthnErrorDetail,
hasNonPasskeyLoginMethod,
hasScopedRole,
Expand Down Expand Up @@ -106,6 +109,7 @@ export type {
OrganizationsResult,
PasskeyLoginData,
PasskeyMetadata,
PasskeyPolicyErrorCode,
PasskeyPrfInput,
PasskeyPrfResult,
PasskeyRegistrationData,
Expand Down
96 changes: 96 additions & 0 deletions tests/errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import {
getOAuthErrorCode,
getPasskeyPolicyErrorCode,
getWebAuthnErrorDetail,
SeamlessAuthError,
toSeamlessAuthError,
Expand Down Expand Up @@ -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.');
Expand Down
Loading