feat(server): validate passkey request bodies

This commit is contained in:
jubnl
2026-08-02 10:16:10 +02:00
parent e65f851446
commit 5d4b2abd26
5 changed files with 73 additions and 10 deletions
+27
View File
@@ -13,6 +13,10 @@ import {
settingsUpdateRequestSchema,
appSettingsUpdateRequestSchema,
resourceTokenRequestSchema,
passkeyRegisterOptionsRequestSchema,
passkeyRegisterVerifyRequestSchema,
passkeyLoginVerifyRequestSchema,
passkeyRenameRequestSchema,
} from './auth.schema';
import { describe, it, expect } from 'vitest';
@@ -104,3 +108,26 @@ describe('api-key / settings schemas', () => {
expect(resourceTokenRequestSchema.safeParse({ purpose: 5 }).success).toBe(false);
});
});
describe('passkey schemas', () => {
it('registerOptions keeps password optional (the 401 is a service rule)', () => {
expect(passkeyRegisterOptionsRequestSchema.safeParse({ password: 'pw' }).success).toBe(true);
expect(passkeyRegisterOptionsRequestSchema.safeParse({}).success).toBe(true);
expect(passkeyRegisterOptionsRequestSchema.safeParse({ password: 5 }).success).toBe(false);
});
it('ceremony payloads stay permissive — the WebAuthn verifier owns validation', () => {
expect(
passkeyRegisterVerifyRequestSchema.safeParse({ attestationResponse: { id: 'x' }, name: 'Key' }).success,
).toBe(true);
expect(passkeyRegisterVerifyRequestSchema.safeParse({}).success).toBe(true);
expect(passkeyLoginVerifyRequestSchema.safeParse({ assertionResponse: { id: 'x' } }).success).toBe(true);
expect(passkeyLoginVerifyRequestSchema.safeParse({}).success).toBe(true);
});
it("rename keeps name unknown (the 'Name is required' 400 is a service rule)", () => {
expect(passkeyRenameRequestSchema.safeParse({ name: 'My Key' }).success).toBe(true);
expect(passkeyRenameRequestSchema.safeParse({}).success).toBe(true);
expect(passkeyRenameRequestSchema.safeParse({ name: 42 }).success).toBe(true); // service-side sanitize owns the 400
});
});
+31
View File
@@ -108,3 +108,34 @@ export const resourceTokenRequestSchema = z.object({
purpose: z.string().optional(),
});
export type ResourceTokenRequest = z.infer<typeof resourceTokenRequestSchema>;
// ── Passkeys (/api/auth/passkey) ─────────────────────────────────────────────
// The WebAuthn ceremony payloads are raw @simplewebauthn/browser outputs whose
// real validation is the server-side ceremony verifier, and the bespoke 4xx
// strings ('Incorrect password' 401, 'Invalid registration response',
// 'Name is required', the uniform 'Authentication failed' 401) are service
// rules — so every field stays permissive and the service owns the semantics.
// Password re-auth: a missing password must reach the service (401), not 400.
export const passkeyRegisterOptionsRequestSchema = z.object({
password: z.string().optional(),
});
export type PasskeyRegisterOptionsRequest = z.infer<typeof passkeyRegisterOptionsRequestSchema>;
// `name` is omitted entirely when the user leaves it blank; the service
// sanitizes non-strings itself.
export const passkeyRegisterVerifyRequestSchema = z.object({
attestationResponse: z.unknown().optional(),
name: z.unknown().optional(),
});
export type PasskeyRegisterVerifyRequest = z.infer<typeof passkeyRegisterVerifyRequestSchema>;
export const passkeyLoginVerifyRequestSchema = z.object({
assertionResponse: z.unknown().optional(),
});
export type PasskeyLoginVerifyRequest = z.infer<typeof passkeyLoginVerifyRequestSchema>;
export const passkeyRenameRequestSchema = z.object({
name: z.unknown().optional(),
});
export type PasskeyRenameRequest = z.infer<typeof passkeyRenameRequestSchema>;