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
12 changes: 6 additions & 6 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@
"typescript-eslint": "^8.46.1"
},
"dependencies": {
"@seamless-auth/types": "^0.5.0",
"@seamless-auth/types": "^0.16.0",
"@simplewebauthn/browser": "^13.1.0",
"eslint-plugin-license-header": "^0.9.0",
"libphonenumber-js": "^1.12.7",
Expand Down
29 changes: 19 additions & 10 deletions src/client/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@
* See LICENSE file in the project root for full license information
*/

import type { OAuthErrorCode as OAuthErrorCodeShape } from '@seamless-auth/types';
import type {
OAuthErrorCode as OAuthErrorCodeShape,
WebAuthnErrorCode as WebAuthnErrorCodeShape,
} from '@seamless-auth/types';

/**
* Error carrying the auth server's response detail, so callers can map known
Expand Down Expand Up @@ -86,17 +89,23 @@ 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.
*/
export type PasskeyPolicyErrorCode =
| 'synced_passkey_not_allowed'
| 'authenticator_not_allowed'
| 'prf_required';
export type PasskeyPolicyErrorCode = Extract<
WebAuthnErrorCodeShape,
'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.
* `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.
*
* `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.
*/
const PASSKEY_POLICY_ERROR_CODES: Record<PasskeyPolicyErrorCode, true> = {
synced_passkey_not_allowed: true,
Expand Down
15 changes: 14 additions & 1 deletion tests/errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
getOAuthErrorCode,
getPasskeyPolicyErrorCode,
getWebAuthnErrorDetail,
type PasskeyPolicyErrorCode,
SeamlessAuthError,
toSeamlessAuthError,
} from '@/client/errors';
Expand Down Expand Up @@ -168,7 +169,7 @@ describe('getOAuthErrorCode', () => {
});

describe('getPasskeyPolicyErrorCode', () => {
const policyCodes = [
const policyCodes: PasskeyPolicyErrorCode[] = [
'synced_passkey_not_allowed',
'authenticator_not_allowed',
'prf_required',
Expand All @@ -189,6 +190,18 @@ 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 });

expect(getPasskeyPolicyErrorCode(error)).toBeUndefined();
}
);

it('reads a real refusal built from the API response', async () => {
const error = await toSeamlessAuthError(
responseWith(403, async () => ({ error: 'synced_passkey_not_allowed' })),
Expand Down
Loading