test: cover auth token, cookie and expiry boundaries (WealthLedger/backend#61) (#113)
This commit was merged in pull request #113.
This commit is contained in:
+159
-1
@@ -4,7 +4,7 @@ import { JwtService } from '@nestjs/jwt';
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import * as bcrypt from 'bcrypt';
|
import * as bcrypt from 'bcrypt';
|
||||||
import { PARAMS_PROVIDER_TOKEN } from 'nestjs-pino';
|
import { PARAMS_PROVIDER_TOKEN } from 'nestjs-pino';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { createHash, randomUUID } from 'node:crypto';
|
||||||
import request from 'supertest';
|
import request from 'supertest';
|
||||||
import { App } from 'supertest/types';
|
import { App } from 'supertest/types';
|
||||||
|
|
||||||
@@ -96,6 +96,164 @@ describe('Authentication (e2e)', () => {
|
|||||||
await agent.post('/api/v1/auth/logout').expect(204);
|
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 <T>(nodeEnv: string, run: () => Promise<T>) => {
|
||||||
|
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 () => {
|
it('keeps logout idempotent for an invalid refresh token', async () => {
|
||||||
const logout = await request(app.getHttpServer())
|
const logout = await request(app.getHttpServer())
|
||||||
.post('/api/v1/auth/logout')
|
.post('/api/v1/auth/logout')
|
||||||
|
|||||||
Reference in New Issue
Block a user