diff --git a/server/src/nest/auth/auth.dto.ts b/server/src/nest/auth/auth.dto.ts index 7a6be4ea4..d66a63849 100644 --- a/server/src/nest/auth/auth.dto.ts +++ b/server/src/nest/auth/auth.dto.ts @@ -14,6 +14,10 @@ import { settingsUpdateRequestSchema, appSettingsUpdateRequestSchema, resourceTokenRequestSchema, + passkeyRegisterOptionsRequestSchema, + passkeyRegisterVerifyRequestSchema, + passkeyLoginVerifyRequestSchema, + passkeyRenameRequestSchema, } from '@trek/shared'; /** @@ -38,3 +42,7 @@ export class ApiKeysUpdateDto extends createZodDto(apiKeysUpdateRequestSchema) { export class SettingsUpdateDto extends createZodDto(settingsUpdateRequestSchema) {} export class AppSettingsUpdateDto extends createZodDto(appSettingsUpdateRequestSchema) {} export class ResourceTokenDto extends createZodDto(resourceTokenRequestSchema) {} +export class PasskeyRegisterOptionsDto extends createZodDto(passkeyRegisterOptionsRequestSchema) {} +export class PasskeyRegisterVerifyDto extends createZodDto(passkeyRegisterVerifyRequestSchema) {} +export class PasskeyLoginVerifyDto extends createZodDto(passkeyLoginVerifyRequestSchema) {} +export class PasskeyRenameDto extends createZodDto(passkeyRenameRequestSchema) {} diff --git a/server/src/nest/auth/passkey.controller.ts b/server/src/nest/auth/passkey.controller.ts index 9ff5cd965..bd4540c32 100644 --- a/server/src/nest/auth/passkey.controller.ts +++ b/server/src/nest/auth/passkey.controller.ts @@ -8,6 +8,7 @@ import { setAuthCookie } from '../../services/cookie'; import { getClientIp } from '../audit/client-ip'; import { AuditService } from '../audit/audit.service'; import { PasskeyService } from './passkey.service'; +import { PasskeyRegisterOptionsDto, PasskeyRegisterVerifyDto, PasskeyLoginVerifyDto, PasskeyRenameDto } from './auth.dto'; import type { User } from '../../types'; const WINDOW = 15 * 60 * 1000; @@ -44,7 +45,7 @@ export class PasskeyController { @Post('register/options') @HttpCode(200) @UseGuards(PasskeyEnabledGuard, JwtAuthGuard) - async registerOptions(@CurrentUser() user: User, @Body() body: { password?: string }, @Req() req: Request) { + async registerOptions(@CurrentUser() user: User, @Body() body: PasskeyRegisterOptionsDto, @Req() req: Request) { this.limit('mfa', req, 5); const result = await this.passkeys.passkeyRegisterOptions(user.id, body?.password); if (result.error) throw new HttpException({ error: result.error }, result.status!); @@ -54,8 +55,8 @@ export class PasskeyController { @Post('register/verify') @HttpCode(200) @UseGuards(PasskeyEnabledGuard, JwtAuthGuard) - async registerVerify(@CurrentUser() user: User, @Body() body: unknown, @Req() req: Request) { - const result = await this.passkeys.passkeyRegisterVerify(user.id, body as Parameters[1]); + async registerVerify(@CurrentUser() user: User, @Body() body: PasskeyRegisterVerifyDto, @Req() req: Request) { + const result = await this.passkeys.passkeyRegisterVerify(user.id, body); if (result.error) throw new HttpException({ error: result.error }, result.status!); this.audit.writeAudit({ userId: user.id, action: 'user.passkey_register', ip: getClientIp(req) }); return { success: true, credential: result.credential }; @@ -75,10 +76,10 @@ export class PasskeyController { @Post('login/verify') @HttpCode(200) @UseGuards(PasskeyEnabledGuard) - async loginVerify(@Body() body: unknown, @Req() req: Request, @Res({ passthrough: true }) res: Response) { + async loginVerify(@Body() body: PasskeyLoginVerifyDto, @Req() req: Request, @Res({ passthrough: true }) res: Response) { this.limit('login', req, 10); const started = Date.now(); - const result = await this.passkeys.passkeyLoginVerify(body as Parameters[0]); + const result = await this.passkeys.passkeyLoginVerify(body); if (result.auditAction) { this.audit.writeAudit({ userId: result.auditUserId ?? null, action: result.auditAction, ip: getClientIp(req) }); } @@ -101,7 +102,7 @@ export class PasskeyController { @Patch('credentials/:id') @UseGuards(JwtAuthGuard) - rename(@CurrentUser() user: User, @Param('id') id: string, @Body() body: { name?: unknown }) { + rename(@CurrentUser() user: User, @Param('id') id: string, @Body() body: PasskeyRenameDto) { const result = this.passkeys.renamePasskey(user.id, id, body?.name); if (result.error) throw new HttpException({ error: result.error }, result.status!); return { success: true }; diff --git a/server/src/nest/common/body-contract-allow-list.ts b/server/src/nest/common/body-contract-allow-list.ts index a9af41be0..b78611ed7 100644 --- a/server/src/nest/common/body-contract-allow-list.ts +++ b/server/src/nest/common/body-contract-allow-list.ts @@ -61,10 +61,6 @@ export const BODY_CONTRACT_ALLOW_LIST: string[] = [ 'NotificationsController.testWebhook', 'OauthApiController.authorize', 'OauthApiController.createClient', - 'PasskeyController.loginVerify', - 'PasskeyController.registerOptions', - 'PasskeyController.registerVerify', - 'PasskeyController.rename', 'PluginRoutesController.route', 'PluginUserSettingsController.update', 'PluginsController.activate', diff --git a/shared/src/auth/auth.schema.spec.ts b/shared/src/auth/auth.schema.spec.ts index 39840eeb4..b5281dbb6 100644 --- a/shared/src/auth/auth.schema.spec.ts +++ b/shared/src/auth/auth.schema.spec.ts @@ -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 + }); +}); diff --git a/shared/src/auth/auth.schema.ts b/shared/src/auth/auth.schema.ts index 6a8779cb9..d3c53d511 100644 --- a/shared/src/auth/auth.schema.ts +++ b/shared/src/auth/auth.schema.ts @@ -108,3 +108,34 @@ export const resourceTokenRequestSchema = z.object({ purpose: z.string().optional(), }); export type ResourceTokenRequest = z.infer; + +// ── 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; + +// `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; + +export const passkeyLoginVerifyRequestSchema = z.object({ + assertionResponse: z.unknown().optional(), +}); +export type PasskeyLoginVerifyRequest = z.infer; + +export const passkeyRenameRequestSchema = z.object({ + name: z.unknown().optional(), +}); +export type PasskeyRenameRequest = z.infer;