diff --git a/test/app.e2e-spec.ts b/test/app.e2e-spec.ts index 8eb0745..4f6b5e8 100644 --- a/test/app.e2e-spec.ts +++ b/test/app.e2e-spec.ts @@ -4,7 +4,7 @@ import { JwtService } from '@nestjs/jwt'; import { Test, TestingModule } from '@nestjs/testing'; import * as bcrypt from 'bcrypt'; import { PARAMS_PROVIDER_TOKEN } from 'nestjs-pino'; -import { randomUUID } from 'node:crypto'; +import { createHash, randomUUID } from 'node:crypto'; import request from 'supertest'; import { App } from 'supertest/types'; @@ -96,6 +96,164 @@ describe('Authentication (e2e)', () => { await agent.post('/api/v1/auth/logout').expect(204); }); + it('rejects a valid refresh token as Bearer while its session stays usable', async () => { + const registration = await request(app.getHttpServer()) + .post('/api/v1/auth/register') + .send({ email: 'bearer@example.test', password: 'a secure password' }) + .expect(200); + const cookie = registration.headers['set-cookie'][0]; + const refreshToken = /^refresh_token=([^;]+)/.exec(cookie)?.[1]; + if (!refreshToken) throw new Error('Expected a refresh token cookie'); + // Signed, unexpired and backed by a live session: only its type is wrong. + await expect(app.get(JwtService).verifyAsync(refreshToken)).resolves.toMatchObject({ + type: 'refresh', + }); + + await request(app.getHttpServer()) + .get('/api/v1/auth/me') + .set('Authorization', `Bearer ${refreshToken}`) + .expect(401) + .expect(({ body }: { body: unknown }) => + expect(body).toEqual({ status: 401, code: 'UNAUTHORIZED' }), + ); + + await request(app.getHttpServer()) + .post('/api/v1/auth/refresh') + .set('Cookie', cookie) + .expect(200); + }); + + describe('refresh cookie policy', () => { + const attributes = (cookie: string) => + cookie + .split('; ') + .slice(1) + .filter((attribute) => !attribute.startsWith('Expires=')) + .sort((a, b) => a.localeCompare(b)); + const host = ['HttpOnly', 'Path=/api/v1/auth', 'SameSite=Lax']; + const login = async () => { + const registration = await request(app.getHttpServer()) + .post('/api/v1/auth/register') + .send({ email: 'cookie@example.test', password: 'a secure password' }) + .expect(200); + return registration.headers['set-cookie'][0]; + }; + + // The cookie policy reads NODE_ENV per request; the e2e runner defaults to development. + const inEnvironment = async (nodeEnv: string, run: () => Promise) => { + const previous = process.env['NODE_ENV']; + process.env['NODE_ENV'] = nodeEnv; + try { + return await run(); + } finally { + process.env['NODE_ENV'] = previous; + } + }; + + it('issues, rotates and clears it HttpOnly, Lax, path-scoped, host-only and Secure in production', async () => { + await inEnvironment('production', async () => { + const issued = await login(); + const rotated = await request(app.getHttpServer()) + .post('/api/v1/auth/refresh') + .set('Cookie', issued) + .expect(200); + const cleared = await request(app.getHttpServer()) + .post('/api/v1/auth/logout') + .set('Cookie', rotated.headers['set-cookie'][0]) + .expect(204); + + // Exact set: any added Domain/Max-Age or a dropped flag fails. + for (const cookie of [issued, rotated.headers['set-cookie'][0], cleared.headers['set-cookie'][0]]) { + expect(attributes(cookie)).toEqual([...host, 'Secure'].sort((a, b) => a.localeCompare(b))); + } + }); + }); + + it('drops only Secure in development and keeps every other attribute', async () => { + await inEnvironment('development', async () => { + expect(attributes(await login())).toEqual(host); + }); + }); + }); + + describe('expired credentials', () => { + const sha256 = (token: string) => createHash('sha256').update(token).digest('hex'); + const PAST = () => Math.floor(Date.now() / 1000) - 60; + const FUTURE = () => Math.floor(Date.now() / 1000) + 600; + const register = async () => { + const registration = await request(app.getHttpServer()) + .post('/api/v1/auth/register') + .send({ email: 'expiry@example.test', password: 'a secure password' }) + .expect(200); + const session = await app.get(PrismaService).session.findFirstOrThrow(); + return { + cookie: registration.headers['set-cookie'][0], + sub: session.userId, + sid: session.id, + }; + }; + + it('rejects a signed expired access token but admits the same claims unexpired', async () => { + const { sub, sid } = await register(); + const jwt = app.get(JwtService); + const expired = await jwt.signAsync({ sub, sid, type: 'access', exp: PAST() }); + const fresh = await jwt.signAsync({ sub, sid, type: 'access', exp: FUTURE() }); + + await request(app.getHttpServer()) + .get('/api/v1/auth/me') + .set('Authorization', `Bearer ${expired}`) + .expect(401); + await request(app.getHttpServer()) + .get('/api/v1/auth/me') + .set('Authorization', `Bearer ${fresh}`) + .expect(200) + .expect(({ body }: { body: unknown }) => + expect((body as { email: string }).email).toBe('expiry@example.test'), + ); + }); + + it('rejects a signed expired refresh token that matches the live session hash', async () => { + const { sub, sid } = await register(); + const jwt = app.get(JwtService); + const prisma = app.get(PrismaService); + const claims = { sub, sid, type: 'refresh' }; + const expired = await jwt.signAsync({ ...claims, jti: randomUUID(), exp: PAST() }); + const fresh = await jwt.signAsync({ ...claims, jti: randomUUID(), exp: FUTURE() }); + + // Hash, session and signature all valid: only `exp` differs between the two. + await prisma.session.update({ where: { id: sid }, data: { refreshTokenHash: sha256(expired) } }); + const rejected = await request(app.getHttpServer()) + .post('/api/v1/auth/refresh') + .set('Cookie', `refresh_token=${expired}`) + .expect(401); + expect(rejected.headers['set-cookie']).toBeUndefined(); + + await prisma.session.update({ where: { id: sid }, data: { refreshTokenHash: sha256(fresh) } }); + await request(app.getHttpServer()) + .post('/api/v1/auth/refresh') + .set('Cookie', `refresh_token=${fresh}`) + .expect(200); + }); + + it('rejects a valid refresh token once its session is DB-expired', async () => { + const { cookie, sid } = await register(); + const prisma = app.get(PrismaService); + await prisma.session.update({ where: { id: sid }, data: { expiresAt: new Date(Date.now() - 1000) } }); + + const rejected = await request(app.getHttpServer()) + .post('/api/v1/auth/refresh') + .set('Cookie', cookie) + .expect(401); + + expect(rejected.headers['set-cookie']).toBeUndefined(); + expect(await prisma.session.count()).toBe(0); + await request(app.getHttpServer()) + .post('/api/v1/auth/refresh') + .set('Cookie', cookie) + .expect(401); + }); + }); + it('keeps logout idempotent for an invalid refresh token', async () => { const logout = await request(app.getHttpServer()) .post('/api/v1/auth/logout')