From 370102387fcfb5225b601846763c9cda62df0ba8 Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Fri, 21 Aug 2026 08:27:58 +0200 Subject: [PATCH] fix(backend): Refresh-Token als httpOnly-Cookie statt Response-Body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ApiCore-Backend/package-lock.json | 31 ++++++++ ApiCore-Backend/package.json | 2 + ApiCore-Backend/src/app.config.ts | 2 + ApiCore-Backend/src/auth/auth.controller.ts | 74 ++++++++++++++++--- ...auth-tokens.dto.ts => access-token.dto.ts} | 5 +- .../src/auth/dto/refresh-token.dto.ts | 8 -- ApiCore-Backend/test/auth.e2e-spec.ts | 31 +++++--- ApiCore-Backend/test/utils/api.ts | 26 ++++++- 8 files changed, 143 insertions(+), 36 deletions(-) rename ApiCore-Backend/src/auth/dto/{auth-tokens.dto.ts => access-token.dto.ts} (55%) delete mode 100644 ApiCore-Backend/src/auth/dto/refresh-token.dto.ts diff --git a/ApiCore-Backend/package-lock.json b/ApiCore-Backend/package-lock.json index 7e946ea..e83c476 100644 --- a/ApiCore-Backend/package-lock.json +++ b/ApiCore-Backend/package-lock.json @@ -21,6 +21,7 @@ "bcryptjs": "^3.0.3", "class-transformer": "^0.5.1", "class-validator": "^0.15.1", + "cookie-parser": "^1.4.7", "helmet": "^8.3.0", "passport": "^0.7.0", "passport-jwt": "^4.0.1", @@ -34,6 +35,7 @@ "@nestjs/schematics": "^11.0.0", "@nestjs/testing": "^11.0.1", "@types/bcryptjs": "^2.4.6", + "@types/cookie-parser": "^1.4.10", "@types/express": "^5.0.0", "@types/jest": "^30.0.0", "@types/node": "^24.0.0", @@ -3268,6 +3270,16 @@ "@types/node": "*" } }, + "node_modules/@types/cookie-parser": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz", + "integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/express": "*" + } + }, "node_modules/@types/cookiejar": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", @@ -5699,6 +5711,25 @@ "node": ">= 0.6" } }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, "node_modules/cookie-signature": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", diff --git a/ApiCore-Backend/package.json b/ApiCore-Backend/package.json index e3c3e0c..140109f 100644 --- a/ApiCore-Backend/package.json +++ b/ApiCore-Backend/package.json @@ -32,6 +32,7 @@ "bcryptjs": "^3.0.3", "class-transformer": "^0.5.1", "class-validator": "^0.15.1", + "cookie-parser": "^1.4.7", "helmet": "^8.3.0", "passport": "^0.7.0", "passport-jwt": "^4.0.1", @@ -45,6 +46,7 @@ "@nestjs/schematics": "^11.0.0", "@nestjs/testing": "^11.0.1", "@types/bcryptjs": "^2.4.6", + "@types/cookie-parser": "^1.4.10", "@types/express": "^5.0.0", "@types/jest": "^30.0.0", "@types/node": "^24.0.0", diff --git a/ApiCore-Backend/src/app.config.ts b/ApiCore-Backend/src/app.config.ts index dd35f37..33bc44b 100644 --- a/ApiCore-Backend/src/app.config.ts +++ b/ApiCore-Backend/src/app.config.ts @@ -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: [ diff --git a/ApiCore-Backend/src/auth/auth.controller.ts b/ApiCore-Backend/src/auth/auth.controller.ts index f83ea85..92318ac 100644 --- a/ApiCore-Backend/src/auth/auth.controller.ts +++ b/ApiCore-Backend/src/auth/auth.controller.ts @@ -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 | 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 { - return this.authService.login(dto.email, dto.password); + 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() @Post('refresh') @HttpCode(HttpStatus.OK) - refresh(@Body() dto: RefreshTokenDto): Promise { - return this.authService.refresh(dto.refreshToken); + 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(@Body() dto: RefreshTokenDto): Promise { - await this.authService.logout(dto.refreshToken); + async logout( + @Req() req: Request, + @Res({ passthrough: true }) res: Response, + ): Promise { + await this.authService.logout(this.extractRefreshToken(req)); + this.clearRefreshTokenCookie(res); } } diff --git a/ApiCore-Backend/src/auth/dto/auth-tokens.dto.ts b/ApiCore-Backend/src/auth/dto/access-token.dto.ts similarity index 55% rename from ApiCore-Backend/src/auth/dto/auth-tokens.dto.ts rename to ApiCore-Backend/src/auth/dto/access-token.dto.ts index 5ffba97..c33ed97 100644 --- a/ApiCore-Backend/src/auth/dto/auth-tokens.dto.ts +++ b/ApiCore-Backend/src/auth/dto/access-token.dto.ts @@ -1,9 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; -export class AuthTokensDto { +export class AccessTokenDto { @ApiProperty() accessToken!: string; - - @ApiProperty() - refreshToken!: string; } diff --git a/ApiCore-Backend/src/auth/dto/refresh-token.dto.ts b/ApiCore-Backend/src/auth/dto/refresh-token.dto.ts deleted file mode 100644 index e6c64d3..0000000 --- a/ApiCore-Backend/src/auth/dto/refresh-token.dto.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsString } from 'class-validator'; - -export class RefreshTokenDto { - @ApiProperty() - @IsString() - refreshToken!: string; -} diff --git a/ApiCore-Backend/test/auth.e2e-spec.ts b/ApiCore-Backend/test/auth.e2e-spec.ts index 77cf708..434ac17 100644 --- a/ApiCore-Backend/test/auth.e2e-spec.ts +++ b/ApiCore-Backend/test/auth.e2e-spec.ts @@ -4,7 +4,7 @@ import request from 'supertest'; import { App } from 'supertest/types'; import { PrismaService } from '../src/prisma/prisma.service'; import { createTestApp } from './utils/create-test-app'; -import { AuthTokensBody, login } from './utils/api'; +import { AccessTokenBody, extractCookie, login } from './utils/api'; describe('Auth (e2e)', () => { let app: INestApplication; @@ -38,11 +38,11 @@ describe('Auth (e2e)', () => { .expect(401); }); - it('logs in and returns access + refresh tokens', async () => { + it('logs in, returns an access token, and sets an httpOnly refresh-token cookie', async () => { const tokens = await login(app, email, password); expect(tokens.accessToken).toEqual(expect.any(String)); - expect(tokens.refreshToken).toEqual(expect.any(String)); + expect(tokens.refreshTokenCookie).toMatch(/^refreshToken=/); }); it('rejects a protected route without a token', () => { @@ -58,34 +58,43 @@ describe('Auth (e2e)', () => { .expect(200); }); - it('rotates the refresh token and rejects reuse of the old one', async () => { + it('rejects refresh when no refresh-token cookie is present', () => { + return request(app.getHttpServer()) + .post('/api/v1/auth/refresh') + .expect(401); + }); + + it('rotates the refresh-token cookie and rejects reuse of the old one', async () => { const tokens = await login(app, email, password); const refreshResponse = await request(app.getHttpServer()) .post('/api/v1/auth/refresh') - .send({ refreshToken: tokens.refreshToken }) + .set('Cookie', tokens.refreshTokenCookie) .expect(200); - const refreshed = refreshResponse.body as AuthTokensBody; + const rotatedCookie = extractCookie(refreshResponse, 'refreshToken'); - expect(refreshed.refreshToken).not.toEqual(tokens.refreshToken); + expect(rotatedCookie).not.toEqual(tokens.refreshTokenCookie); + expect((refreshResponse.body as AccessTokenBody).accessToken).toEqual( + expect.any(String), + ); await request(app.getHttpServer()) .post('/api/v1/auth/refresh') - .send({ refreshToken: tokens.refreshToken }) + .set('Cookie', tokens.refreshTokenCookie) .expect(401); }); - it('logs out and invalidates the refresh token', async () => { + it('logs out and invalidates the refresh-token cookie', async () => { const tokens = await login(app, email, password); await request(app.getHttpServer()) .post('/api/v1/auth/logout') - .send({ refreshToken: tokens.refreshToken }) + .set('Cookie', tokens.refreshTokenCookie) .expect(204); await request(app.getHttpServer()) .post('/api/v1/auth/refresh') - .send({ refreshToken: tokens.refreshToken }) + .set('Cookie', tokens.refreshTokenCookie) .expect(401); }); }); diff --git a/ApiCore-Backend/test/utils/api.ts b/ApiCore-Backend/test/utils/api.ts index e837feb..392e780 100644 --- a/ApiCore-Backend/test/utils/api.ts +++ b/ApiCore-Backend/test/utils/api.ts @@ -1,10 +1,10 @@ import { INestApplication } from '@nestjs/common'; import request from 'supertest'; import { App } from 'supertest/types'; +import type { Response } from 'supertest'; -export interface AuthTokensBody { +export interface AccessTokenBody { accessToken: string; - refreshToken: string; } export interface UserBody { @@ -17,14 +17,32 @@ export interface UserBody { deactivatedAt: string | null; } +export interface LoginResult { + accessToken: string; + refreshTokenCookie: string; +} + +export function extractCookie(response: Response, name: string): string { + const setCookie = + (response.headers['set-cookie'] as unknown as string[] | undefined) ?? []; + const raw = setCookie.find((cookie) => cookie.startsWith(`${name}=`)); + if (!raw) { + throw new Error(`Expected a Set-Cookie header for "${name}"`); + } + return raw.split(';')[0]; +} + export async function login( app: INestApplication, email: string, password: string, -): Promise { +): Promise { const response = await request(app.getHttpServer()) .post('/api/v1/auth/login') .send({ email, password }) .expect(200); - return response.body as AuthTokensBody; + return { + accessToken: (response.body as AccessTokenBody).accessToken, + refreshTokenCookie: extractCookie(response, 'refreshToken'), + }; }