fix(backend): Refresh-Token als httpOnly-Cookie statt Response-Body

ARCHITECTURE.md §3 legt fest: Access-Token im Memory/Context, Refresh-Token
als httpOnly-Cookie. Vorherige Implementierung gab beide Tokens im JSON-Body
zurueck - vor dem Frontend-Aufbau (das genau gegen diesen Vertrag baut)
korrigiert.

/api/v1/auth/login und /refresh setzen jetzt ein httpOnly-Cookie
(Path /api/v1/auth, SameSite=Lax, secure in production) und liefern nur
noch { accessToken } im Body. /refresh und /logout lesen den Refresh-Token
aus dem Cookie statt aus dem Request-Body.

e2e-Tests und Live-Smoke-Test (curl mit Cookie-Jar) verifizieren
Login/Rotation/Logout-Invalidierung. 12 e2e- + 26 Unit-Tests, Lint,
Typecheck und Build gruen.
This commit is contained in:
Nick Szaunig committed 2026-08-21 08:27:58 +02:00
1 parent ae8f6d4435
commit 370102387f
8 files changed
+143 -36

No files matched your search

+2
View File
@@ -5,9 +5,11 @@ import {
VersioningType,
} from '@nestjs/common';
import helmet from 'helmet';
import cookieParser from 'cookie-parser';
export function configureApp(app: INestApplication): void {
app.use(helmet());
app.use(cookieParser());
app.setGlobalPrefix('api', {
exclude: [
+65 -9
View File
@@ -1,34 +1,90 @@
import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common';
import {
Body,
Controller,
HttpCode,
HttpStatus,
Post,
Req,
Res,
UnauthorizedException,
} from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import type { Request, Response } from 'express';
import { Public } from './decorators/public.decorator';
import { LoginDto } from './dto/login.dto';
import { RefreshTokenDto } from './dto/refresh-token.dto';
import { AuthTokensDto } from './dto/auth-tokens.dto';
import { AccessTokenDto } from './dto/access-token.dto';
import { AuthService } from './auth.service';
import { parseDurationToMs } from './util/duration';
const REFRESH_TOKEN_COOKIE = 'refreshToken';
const REFRESH_TOKEN_COOKIE_PATH = '/api/v1/auth';
@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,
secure: process.env.NODE_ENV === 'production',
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<string, string> | undefined)?.[
REFRESH_TOKEN_COOKIE
];
if (!refreshToken) {
throw new UnauthorizedException('Missing refresh token');
}
return refreshToken;
}
@Public()
@Post('login')
@HttpCode(HttpStatus.OK)
login(@Body() dto: LoginDto): Promise<AuthTokensDto> {
return this.authService.login(dto.email, dto.password);
async login(
@Body() dto: LoginDto,
@Res({ passthrough: true }) res: Response,
): Promise<AccessTokenDto> {
const { accessToken, refreshToken } = await this.authService.login(
dto.email,
dto.password,
);
this.setRefreshTokenCookie(res, refreshToken);
return { accessToken };
}
@Public()
@Post('refresh')
@HttpCode(HttpStatus.OK)
refresh(@Body() dto: RefreshTokenDto): Promise<AuthTokensDto> {
return this.authService.refresh(dto.refreshToken);
async refresh(
@Req() req: Request,
@Res({ passthrough: true }) res: Response,
): Promise<AccessTokenDto> {
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(@Body() dto: RefreshTokenDto): Promise<void> {
await this.authService.logout(dto.refreshToken);
async logout(
@Req() req: Request,
@Res({ passthrough: true }) res: Response,
): Promise<void> {
await this.authService.logout(this.extractRefreshToken(req));
this.clearRefreshTokenCookie(res);
}
}
@@ -1,9 +1,6 @@
import { ApiProperty } from '@nestjs/swagger';
export class AuthTokensDto {
export class AccessTokenDto {
@ApiProperty()
accessToken!: string;
@ApiProperty()
refreshToken!: string;
}
@@ -1,8 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString } from 'class-validator';
export class RefreshTokenDto {
@ApiProperty()
@IsString()
refreshToken!: string;
}