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;