import { Body, Controller, Get, HttpCode, HttpStatus, Post, Req, Res, UnauthorizedException, } from '@nestjs/common'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { Throttle } from '@nestjs/throttler'; import type { Request, Response } from 'express'; import { Public } from './decorators/public.decorator'; import { CurrentUser } from './decorators/current-user.decorator'; import type { AuthenticatedUser } from './types/authenticated-user'; import { LoginDto } from './dto/login.dto'; import { AccessTokenDto } from './dto/access-token.dto'; import { AuthService, CurrentUserProfile } from './auth.service'; import { parseDurationToMs } from './util/duration'; const REFRESH_TOKEN_COOKIE = 'refreshToken'; const REFRESH_TOKEN_COOKIE_PATH = '/api/v1/auth'; // Stricter than the global default: bounds brute-force/credential-stuffing // attempts against login and refresh-token guessing specifically. const AUTH_THROTTLE = { default: { limit: 10, ttl: 60000 } }; @ApiTags('auth') @Controller('auth') export class AuthController { constructor(private readonly authService: AuthService) {} private setRefreshTokenCookie(res: Response, refreshToken: string): void { res.cookie(REFRESH_TOKEN_COOKIE, refreshToken, { httpOnly: true, // Explicit opt-out for local HTTP dev only; every real deployment must // set COOKIE_SECURE=true so the refresh-token cookie is never sent // over plaintext HTTP. secure: process.env.COOKIE_SECURE !== 'false', sameSite: 'lax', path: REFRESH_TOKEN_COOKIE_PATH, maxAge: parseDurationToMs(process.env.JWT_REFRESH_EXPIRES_IN ?? '7d'), }); } private clearRefreshTokenCookie(res: Response): void { res.clearCookie(REFRESH_TOKEN_COOKIE, { path: REFRESH_TOKEN_COOKIE_PATH }); } private extractRefreshToken(req: Request): string { const refreshToken = (req.cookies as Record | undefined)?.[ REFRESH_TOKEN_COOKIE ]; if (!refreshToken) { throw new UnauthorizedException('Missing refresh token'); } return refreshToken; } @Public() @Throttle(AUTH_THROTTLE) @Post('login') @HttpCode(HttpStatus.OK) async login( @Body() dto: LoginDto, @Res({ passthrough: true }) res: Response, ): Promise { const { accessToken, refreshToken } = await this.authService.login( dto.email, dto.password, ); this.setRefreshTokenCookie(res, refreshToken); return { accessToken }; } @Public() @Throttle(AUTH_THROTTLE) @Post('refresh') @HttpCode(HttpStatus.OK) async refresh( @Req() req: Request, @Res({ passthrough: true }) res: Response, ): Promise { const { accessToken, refreshToken } = await this.authService.refresh( this.extractRefreshToken(req), ); this.setRefreshTokenCookie(res, refreshToken); return { accessToken }; } @Public() @Post('logout') @HttpCode(HttpStatus.NO_CONTENT) async logout( @Req() req: Request, @Res({ passthrough: true }) res: Response, ): Promise { await this.authService.logout(this.extractRefreshToken(req)); this.clearRefreshTokenCookie(res); } @ApiBearerAuth() @Get('me') me(@CurrentUser() user: AuthenticatedUser): Promise { return this.authService.getCurrentUserProfile(user.id); } }