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
21 changes: 21 additions & 0 deletions .changeset/brave-pandas-report.md
Original file line number Diff line number Diff line change
@@ -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<WebAuthnErrorCode, true>` 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.
2 changes: 2 additions & 0 deletions .changeset/major-chairs-knock.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
30 changes: 30 additions & 0 deletions src/schemas/webauthn/schema.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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<WebAuthnErrorCode, true> = {
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());
});
});
36 changes: 36 additions & 0 deletions src/schemas/webauthn/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,3 +80,39 @@ export const WebAuthnTokenSuccessSchema = z.object({
});

export type WebAuthnTokenSuccessResponse = z.infer<typeof WebAuthnTokenSuccessSchema>;

/**
* 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<WebAuthnErrorCode, true>` 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<typeof WebAuthnErrorCodeSchema>;

export const WebAuthnErrorResponseSchema = z.object({
error: z.string(),
});

export type WebAuthnErrorResponse = z.infer<typeof WebAuthnErrorResponseSchema>;