import { Controller, Post, Get, Delete, Param, Body, UseGuards, Request, Res, BadRequestException, } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { Throttle } from '@nestjs/throttler'; import { Response } from 'express'; import { PasskeyService } from './passkey.service'; import { AuthService } from './auth.service'; import { UsersService } from '../users/users.service'; import { JwtAuthGuard } from './guards/jwt-auth.guard'; import { AllowViewer } from '../../common/decorators/allow-viewer.decorator'; const COOKIE_NAME = 'ledgeriq_rt'; const isProduction = process.env.NODE_ENV === 'production'; @ApiTags('auth') @Controller('auth/passkeys') export class PasskeyController { constructor( private passkeyService: PasskeyService, private authService: AuthService, private usersService: UsersService, ) {} @Post('register-options') @ApiOperation({ summary: 'Get passkey registration options' }) @ApiBearerAuth() @UseGuards(JwtAuthGuard) async getRegistrationOptions(@Request() req: any) { return this.passkeyService.generateRegistrationOptions(req.user.sub); } @Post('register') @ApiOperation({ summary: 'Register a new passkey' }) @ApiBearerAuth() @UseGuards(JwtAuthGuard) async register( @Request() req: any, @Body() body: { response: any; deviceName?: string }, ) { if (!body.response) throw new BadRequestException('Attestation response required'); return this.passkeyService.verifyRegistration(req.user.sub, body.response, body.deviceName); } @Post('login-options') @ApiOperation({ summary: 'Get passkey login options' }) @Throttle({ default: { limit: 10, ttl: 60000 } }) async getLoginOptions(@Body() body: { email?: string }) { return this.passkeyService.generateAuthenticationOptions(body.email); } @Post('login') @ApiOperation({ summary: 'Authenticate with passkey' }) @Throttle({ default: { limit: 5, ttl: 60000 } }) async login( @Body() body: { response: any; challenge: string }, @Res({ passthrough: true }) res: Response, ) { if (!body.response || !body.challenge) { throw new BadRequestException('Assertion response and challenge required'); } const { userId } = await this.passkeyService.verifyAuthentication(body.response, body.challenge); // Get user with orgs and generate session const user = await this.usersService.findByIdWithOrgs(userId); if (!user) throw new BadRequestException('User not found'); await this.usersService.updateLastLogin(userId); const result = await this.authService.generateTokenResponse(user); if (result.refreshToken) { res.cookie(COOKIE_NAME, result.refreshToken, { httpOnly: true, secure: isProduction, sameSite: 'strict', path: '/api/auth', maxAge: 30 * 24 * 60 * 60 * 1000, }); } const { refreshToken: _rt, ...response } = result; return response; } @Get() @ApiOperation({ summary: 'List registered passkeys' }) @ApiBearerAuth() @UseGuards(JwtAuthGuard) @AllowViewer() async list(@Request() req: any) { return this.passkeyService.listPasskeys(req.user.sub); } @Delete(':id') @ApiOperation({ summary: 'Remove a passkey' }) @ApiBearerAuth() @UseGuards(JwtAuthGuard) async remove(@Request() req: any, @Param('id') passkeyId: string) { await this.passkeyService.removePasskey(req.user.sub, passkeyId); return { success: true }; } }