mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(server): validate passkey request bodies
This commit is contained in:
@@ -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) {}
|
||||
|
||||
@@ -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<PasskeyService['passkeyRegisterVerify']>[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<PasskeyService['passkeyLoginVerify']>[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 };
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user