From 0be12d4da8c3cebabb5c74e7d2cfc2c2e93600a4 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sun, 30 Aug 2026 16:07:27 -0400 Subject: [PATCH] feat(webauthn): publish a union for the WebAuthn error codes Closes #51 The auth API returns machine-readable codes as the whole of an error body's error field on the WebAuthn endpoints, and this package did not publish them, so every consumer redeclared the list and no consumer found out when the API added one. OAUTH_ERROR_CODES already solves this for the OAuth surface, and the React SDK leans on it: a local Record stops compiling when the upstream union changes. The WebAuthn codes had no such union, so the equivalent SDK helper declared its own and its membership map only checked itself. That is the same silent drift that let the API's authenticator_policy default fall behind this package's schema twice. One union covers all five rather than a narrower one for the registration policy refusals. A consumer asks a single question, is this a code I know, and the grouping does not have to be revisited when a code is added on a different endpoint. It does put a client bug, prf_output_not_allowed, next to deployment policy refusals, which are different in kind, and a consumer that cares about that distinction can still narrow further. The prose failures are deliberately absent rather than forgotten, and the code says so. --- .changeset/brave-pandas-report.md | 21 +++++++++++++++++ .changeset/major-chairs-knock.md | 2 ++ src/schemas/webauthn/schema.test.ts | 30 ++++++++++++++++++++++++ src/schemas/webauthn/schema.ts | 36 +++++++++++++++++++++++++++++ 4 files changed, 89 insertions(+) create mode 100644 .changeset/brave-pandas-report.md create mode 100644 .changeset/major-chairs-knock.md diff --git a/.changeset/brave-pandas-report.md b/.changeset/brave-pandas-report.md new file mode 100644 index 0000000..af56263 --- /dev/null +++ b/.changeset/brave-pandas-report.md @@ -0,0 +1,21 @@ +--- +'@seamless-auth/types': minor +--- + +Publish a union for the WebAuthn error codes. + +`WEBAUTHN_ERROR_CODES`, `WebAuthnErrorCodeSchema` and `WebAuthnErrorCode` cover +the machine-readable codes the auth API returns as the whole of an error body's +`error` field: `attachment_not_allowed`, `synced_passkey_not_allowed`, +`authenticator_not_allowed`, `prf_required` and `prf_output_not_allowed`. + +This mirrors `OAUTH_ERROR_CODES` and exists for the same reason. A consumer that +declares its own copy of the list has no way to find out when the API adds a +code: it degrades to generic messaging and nothing fails anywhere. Checking a +local map against this union with `Record` turns that +silent drift into a compile error. + +The remaining WebAuthn failures answer with prose rather than a code, so they are +deliberately absent. + +Additive. Nothing changes for a consumer that does not import it. diff --git a/.changeset/major-chairs-knock.md b/.changeset/major-chairs-knock.md new file mode 100644 index 0000000..a845151 --- /dev/null +++ b/.changeset/major-chairs-knock.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/src/schemas/webauthn/schema.test.ts b/src/schemas/webauthn/schema.test.ts index da51a33..1a35810 100644 --- a/src/schemas/webauthn/schema.test.ts +++ b/src/schemas/webauthn/schema.test.ts @@ -1,9 +1,12 @@ import { describe, it, expect } from 'vitest'; import { + WEBAUTHN_ERROR_CODES, WebAuthnAssertionStartSchema, + WebAuthnErrorCodeSchema, WebAuthnRegisterFinishSchema, WebAuthnRegisterStartQuerySchema, } from './schema.js'; +import type { WebAuthnErrorCode } from './schema.js'; describe('WebAuthnRegisterStartQuerySchema', () => { it('coerces the string form query values', () => { @@ -54,3 +57,30 @@ describe('WebAuthnRegisterFinishSchema', () => { expect(() => WebAuthnRegisterFinishSchema.parse({})).toThrow(); }); }); + +describe('WebAuthnErrorCodeSchema', () => { + it('accepts every published code', () => { + for (const code of WEBAUTHN_ERROR_CODES) { + expect(WebAuthnErrorCodeSchema.parse(code)).toBe(code); + } + }); + + it('rejects a code it does not publish', () => { + expect(() => WebAuthnErrorCodeSchema.parse('not_a_real_code')).toThrow(); + }); + + // The point of publishing the union: a consumer's own map is checked against it + // and stops compiling when the API adds a code. This asserts the union and the + // list stay in step, which is what makes that check meaningful. + it('keeps the union and the list in step', () => { + const everyCode: Record = { + attachment_not_allowed: true, + synced_passkey_not_allowed: true, + authenticator_not_allowed: true, + prf_required: true, + prf_output_not_allowed: true, + }; + + expect(Object.keys(everyCode).sort()).toEqual([...WEBAUTHN_ERROR_CODES].sort()); + }); +}); diff --git a/src/schemas/webauthn/schema.ts b/src/schemas/webauthn/schema.ts index 6d84468..1c9bf6a 100644 --- a/src/schemas/webauthn/schema.ts +++ b/src/schemas/webauthn/schema.ts @@ -80,3 +80,39 @@ export const WebAuthnTokenSuccessSchema = z.object({ }); export type WebAuthnTokenSuccessResponse = z.infer; + +/** + * Machine-readable codes the auth API returns as the whole of an error body's + * `error` field, for WebAuthn failures a client can act on. + * + * Published here for the same reason as `OAUTH_ERROR_CODES`: a consumer that + * declares its own copy has no way to find out when the API adds a code, and + * degrades to generic messaging with nothing failing anywhere. Checking a local + * map against this union with `Record` turns that into + * a compile error. + * + * The remaining WebAuthn failures answer with prose rather than a code, so they + * are deliberately absent rather than forgotten. + */ +export const WEBAUTHN_ERROR_CODES = [ + /** Registration asked for an attachment the deployment's policy does not allow. */ + 'attachment_not_allowed', + /** The credential is backup eligible and the deployment blocks synced passkeys. */ + 'synced_passkey_not_allowed', + /** The authenticator model is denied, or absent from a non-empty allow list. */ + 'authenticator_not_allowed', + /** Registration required a PRF-capable credential and the one offered was not. */ + 'prf_required', + /** An assertion carried PRF output, which must never leave the client. */ + 'prf_output_not_allowed', +] as const; + +export const WebAuthnErrorCodeSchema = z.enum(WEBAUTHN_ERROR_CODES); + +export type WebAuthnErrorCode = z.infer; + +export const WebAuthnErrorResponseSchema = z.object({ + error: z.string(), +}); + +export type WebAuthnErrorResponse = z.infer;