import type { Request, Response } from 'express'; import { Body, Controller, Get, HttpCode, HttpStatus, Post, Req, Res, UnauthorizedException, UseGuards, } from '@nestjs/common'; import { ApiBearerAuth, ApiNoContentResponse, ApiOkResponse, ApiProperty, ApiTags, } from '@nestjs/swagger'; import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator'; import { HttpErrorCode } from '../../../common/error-code'; import { AuthService, REFRESH_TTL_MS } from '../application/auth.service'; import { AccessPayload, AuthenticationResult } from '../application/auth.types'; import { LoginDto, RegisterDto } from './auth.dto'; import { JwtAuthGuard } from './jwt-auth.guard'; const REFRESH_COOKIE = 'refresh_token'; const COOKIE_PATH = '/api/v1/auth'; class UserDto { @ApiProperty() declare id: string; @ApiProperty() declare email: string; } class AuthenticationDto { @ApiProperty() declare accessToken: string; @ApiProperty({ type: UserDto }) declare user: UserDto; } @ApiTags('auth') @ApiProblemResponses() @Controller('auth') export class AuthController { constructor(private readonly auth: AuthService) {} @Post('register') @HttpCode(HttpStatus.OK) @ApiOkResponse({ type: AuthenticationDto }) async register( @Body() credentials: RegisterDto, @Res({ passthrough: true }) response: Response, ) { return this.respond( response, await this.auth.register(credentials.email, credentials.password), ); } @Post('login') @HttpCode(HttpStatus.OK) @ApiOkResponse({ type: AuthenticationDto }) async login( @Body() credentials: LoginDto, @Res({ passthrough: true }) response: Response, ) { return this.respond( response, await this.auth.login(credentials.email, credentials.password), ); } @Post('refresh') @HttpCode(HttpStatus.OK) @ApiOkResponse({ type: AuthenticationDto }) async refresh( @Req() request: Request, @Res({ passthrough: true }) response: Response, ) { const token = this.refreshToken(request); if (!token) throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized }); return this.respond(response, await this.auth.refresh(token)); } @Post('logout') @HttpCode(HttpStatus.NO_CONTENT) @ApiNoContentResponse() async logout( @Req() request: Request, @Res({ passthrough: true }) response: Response, ): Promise { await this.auth.logout(this.refreshToken(request)); this.clearCookie(response); } @Get('me') @UseGuards(JwtAuthGuard) @ApiBearerAuth() @ApiOkResponse({ type: UserDto }) async me(@Req() request: Request & { user: AccessPayload }) { const user = await this.auth.userById(request.user.sub); return this.auth.toUser(user); } private respond( response: Response, result: AuthenticationResult, ) { response.cookie(REFRESH_COOKIE, result.refreshToken, this.cookieOptions()); return { user: result.user, accessToken: result.accessToken }; } private clearCookie(response: Response): void { response.clearCookie(REFRESH_COOKIE, this.cookieOptions()); } private refreshToken(request: Request): string | undefined { // eslint-disable-next-line security/detect-object-injection -- The cookie name is a fixed application constant; its value is narrowed below. const token: unknown = request.cookies?.[REFRESH_COOKIE]; return typeof token === 'string' ? token : undefined; } private cookieOptions() { return { httpOnly: true, sameSite: 'lax' as const, secure: process.env['NODE_ENV'] !== 'development', path: COOKIE_PATH, maxAge: REFRESH_TTL_MS, }; } }