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:
1 parent
ae8f6d4435
commit
370102387f
8 files changed
+143
-36
No files matched your search
Generated
+31
@@ -21,6 +21,7 @@
|
|||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.15.1",
|
"class-validator": "^0.15.1",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
"helmet": "^8.3.0",
|
"helmet": "^8.3.0",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
@@ -34,6 +35,7 @@
|
|||||||
"@nestjs/schematics": "^11.0.0",
|
"@nestjs/schematics": "^11.0.0",
|
||||||
"@nestjs/testing": "^11.0.1",
|
"@nestjs/testing": "^11.0.1",
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"@types/cookie-parser": "^1.4.10",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
@@ -3268,6 +3270,16 @@
|
|||||||
"@types/node": "*"
|
"@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": {
|
"node_modules/@types/cookiejar": {
|
||||||
"version": "2.1.5",
|
"version": "2.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz",
|
||||||
@@ -5699,6 +5711,25 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/cookie-signature": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.15.1",
|
"class-validator": "^0.15.1",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
"helmet": "^8.3.0",
|
"helmet": "^8.3.0",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
@@ -45,6 +46,7 @@
|
|||||||
"@nestjs/schematics": "^11.0.0",
|
"@nestjs/schematics": "^11.0.0",
|
||||||
"@nestjs/testing": "^11.0.1",
|
"@nestjs/testing": "^11.0.1",
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"@types/cookie-parser": "^1.4.10",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import {
|
|||||||
VersioningType,
|
VersioningType,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import helmet from 'helmet';
|
import helmet from 'helmet';
|
||||||
|
import cookieParser from 'cookie-parser';
|
||||||
|
|
||||||
export function configureApp(app: INestApplication): void {
|
export function configureApp(app: INestApplication): void {
|
||||||
app.use(helmet());
|
app.use(helmet());
|
||||||
|
app.use(cookieParser());
|
||||||
|
|
||||||
app.setGlobalPrefix('api', {
|
app.setGlobalPrefix('api', {
|
||||||
exclude: [
|
exclude: [
|
||||||
|
|||||||
@@ -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 { ApiTags } from '@nestjs/swagger';
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
import { Public } from './decorators/public.decorator';
|
import { Public } from './decorators/public.decorator';
|
||||||
import { LoginDto } from './dto/login.dto';
|
import { LoginDto } from './dto/login.dto';
|
||||||
import { RefreshTokenDto } from './dto/refresh-token.dto';
|
import { AccessTokenDto } from './dto/access-token.dto';
|
||||||
import { AuthTokensDto } from './dto/auth-tokens.dto';
|
|
||||||
import { AuthService } from './auth.service';
|
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')
|
@ApiTags('auth')
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
constructor(private readonly authService: AuthService) {}
|
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()
|
@Public()
|
||||||
@Post('login')
|
@Post('login')
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
login(@Body() dto: LoginDto): Promise<AuthTokensDto> {
|
async login(
|
||||||
return this.authService.login(dto.email, dto.password);
|
@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()
|
@Public()
|
||||||
@Post('refresh')
|
@Post('refresh')
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
refresh(@Body() dto: RefreshTokenDto): Promise<AuthTokensDto> {
|
async refresh(
|
||||||
return this.authService.refresh(dto.refreshToken);
|
@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()
|
@Public()
|
||||||
@Post('logout')
|
@Post('logout')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
async logout(@Body() dto: RefreshTokenDto): Promise<void> {
|
async logout(
|
||||||
await this.authService.logout(dto.refreshToken);
|
@Req() req: Request,
|
||||||
|
@Res({ passthrough: true }) res: Response,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.authService.logout(this.extractRefreshToken(req));
|
||||||
|
this.clearRefreshTokenCookie(res);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+1
-4
@@ -1,9 +1,6 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
export class AuthTokensDto {
|
export class AccessTokenDto {
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
accessToken!: string;
|
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;
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,7 @@ import request from 'supertest';
|
|||||||
import { App } from 'supertest/types';
|
import { App } from 'supertest/types';
|
||||||
import { PrismaService } from '../src/prisma/prisma.service';
|
import { PrismaService } from '../src/prisma/prisma.service';
|
||||||
import { createTestApp } from './utils/create-test-app';
|
import { createTestApp } from './utils/create-test-app';
|
||||||
import { AuthTokensBody, login } from './utils/api';
|
import { AccessTokenBody, extractCookie, login } from './utils/api';
|
||||||
|
|
||||||
describe('Auth (e2e)', () => {
|
describe('Auth (e2e)', () => {
|
||||||
let app: INestApplication<App>;
|
let app: INestApplication<App>;
|
||||||
@@ -38,11 +38,11 @@ describe('Auth (e2e)', () => {
|
|||||||
.expect(401);
|
.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);
|
const tokens = await login(app, email, password);
|
||||||
|
|
||||||
expect(tokens.accessToken).toEqual(expect.any(String));
|
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', () => {
|
it('rejects a protected route without a token', () => {
|
||||||
@@ -58,34 +58,43 @@ describe('Auth (e2e)', () => {
|
|||||||
.expect(200);
|
.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 tokens = await login(app, email, password);
|
||||||
|
|
||||||
const refreshResponse = await request(app.getHttpServer())
|
const refreshResponse = await request(app.getHttpServer())
|
||||||
.post('/api/v1/auth/refresh')
|
.post('/api/v1/auth/refresh')
|
||||||
.send({ refreshToken: tokens.refreshToken })
|
.set('Cookie', tokens.refreshTokenCookie)
|
||||||
.expect(200);
|
.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())
|
await request(app.getHttpServer())
|
||||||
.post('/api/v1/auth/refresh')
|
.post('/api/v1/auth/refresh')
|
||||||
.send({ refreshToken: tokens.refreshToken })
|
.set('Cookie', tokens.refreshTokenCookie)
|
||||||
.expect(401);
|
.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);
|
const tokens = await login(app, email, password);
|
||||||
|
|
||||||
await request(app.getHttpServer())
|
await request(app.getHttpServer())
|
||||||
.post('/api/v1/auth/logout')
|
.post('/api/v1/auth/logout')
|
||||||
.send({ refreshToken: tokens.refreshToken })
|
.set('Cookie', tokens.refreshTokenCookie)
|
||||||
.expect(204);
|
.expect(204);
|
||||||
|
|
||||||
await request(app.getHttpServer())
|
await request(app.getHttpServer())
|
||||||
.post('/api/v1/auth/refresh')
|
.post('/api/v1/auth/refresh')
|
||||||
.send({ refreshToken: tokens.refreshToken })
|
.set('Cookie', tokens.refreshTokenCookie)
|
||||||
.expect(401);
|
.expect(401);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import { INestApplication } from '@nestjs/common';
|
import { INestApplication } from '@nestjs/common';
|
||||||
import request from 'supertest';
|
import request from 'supertest';
|
||||||
import { App } from 'supertest/types';
|
import { App } from 'supertest/types';
|
||||||
|
import type { Response } from 'supertest';
|
||||||
|
|
||||||
export interface AuthTokensBody {
|
export interface AccessTokenBody {
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
refreshToken: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserBody {
|
export interface UserBody {
|
||||||
@@ -17,14 +17,32 @@ export interface UserBody {
|
|||||||
deactivatedAt: string | null;
|
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(
|
export async function login(
|
||||||
app: INestApplication<App>,
|
app: INestApplication<App>,
|
||||||
email: string,
|
email: string,
|
||||||
password: string,
|
password: string,
|
||||||
): Promise<AuthTokensBody> {
|
): Promise<LoginResult> {
|
||||||
const response = await request(app.getHttpServer())
|
const response = await request(app.getHttpServer())
|
||||||
.post('/api/v1/auth/login')
|
.post('/api/v1/auth/login')
|
||||||
.send({ email, password })
|
.send({ email, password })
|
||||||
.expect(200);
|
.expect(200);
|
||||||
return response.body as AuthTokensBody;
|
return {
|
||||||
|
accessToken: (response.body as AccessTokenBody).accessToken,
|
||||||
|
refreshTokenCookie: extractCookie(response, 'refreshToken'),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user