From e7d39d29f41576cd9f561c19fce507164b7b33a5 Mon Sep 17 00:00:00 2001 From: Srejoye Date: Tue, 16 Jun 2026 23:43:49 +0530 Subject: [PATCH 1/3] fix(auth): revoke entire token family on refresh token reuse detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The POST /refresh handler checked storedToken.revokedAt and returned 401, but never invalidated the rest of the family. This allowed an attacker who stole a refresh token and used it first to fork the session: the attacker obtained a live descendant token while the legitimate client's next request got a 401, with no server-side signal that theft had occurred. The attacker's forked session could persist for up to 90 days. Fix: in the revokedAt branch, before returning 401, issue: prisma.refreshToken.updateMany({ where: { family: storedToken.family, revokedAt: null }, data: { revokedAt: new Date() }, }) This kills all live descendants in the family, forcing full re-authentication for both the attacker and the legitimate user. A WARN log is emitted with the family ID and userId for alerting. Normal (non-reuse) rotation is unchanged — updateMany is only called in the revoked-token branch. Tests added (apps/backend/src/__tests__/refresh.test.ts): - Normal rotation: revokes old token, creates new in same family, 200. - Missing / unrecognised / expired token → 401, no mutation. - Revoked token → updateMany fires with correct family + revokedAt:null filter; no new token issued. - Full A→B→C chain: step-1 rotation succeeds; step-2 stale-A reuse kills B; step-3 legitimate-B attempt gets 401 confirming family kill. - updateMany DB failure → 500, no token issued. Affected: apps/backend/src/routes/auth.ts (POST /refresh) Tests: apps/backend/src/__tests__/refresh.test.ts --- apps/backend/src/__tests__/refresh.test.ts | 309 +++++++++++++++++++++ apps/backend/src/routes/auth.ts | 10 + 2 files changed, 319 insertions(+) create mode 100644 apps/backend/src/__tests__/refresh.test.ts diff --git a/apps/backend/src/__tests__/refresh.test.ts b/apps/backend/src/__tests__/refresh.test.ts new file mode 100644 index 00000000..abe303f9 --- /dev/null +++ b/apps/backend/src/__tests__/refresh.test.ts @@ -0,0 +1,309 @@ +import cookiePlugin from '@fastify/cookie'; +import jwtPlugin from '@fastify/jwt'; +import Fastify, { type FastifyInstance } from 'fastify'; +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; + +import { authRoutes } from '../routes/auth.js'; +import { hashRefreshToken } from '../utils/refreshToken.js'; + +// ─── Constants ──────────────────────────────────────────────────────────────── + +const TEST_JWT_SECRET = 'test-secret-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'; +const USER_ID = 'user-abc'; +const USERNAME = 'testuser'; +const FAMILY_ID = 'family-uuid-1234'; + +// A raw token value that the mock will recognise via its hash. +// The route hashes the cookie value with hashRefreshToken() before querying, +// so we store the hash in the mock and present the raw value in cookies. +const RAW_TOKEN_A = 'a'.repeat(128); // 128 hex chars = 64 random bytes +const RAW_TOKEN_B = 'b'.repeat(128); +const HASH_A = hashRefreshToken(RAW_TOKEN_A); +const HASH_B = hashRefreshToken(RAW_TOKEN_B); + +const mockUser = { + id: USER_ID, + username: USERNAME, + email: 'test@example.com', +}; + +// ─── Prisma mock factory ────────────────────────────────────────────────────── + +function createMockPrisma() { + return { + user: { findUnique: vi.fn() }, + refreshToken: { + findUnique: vi.fn(), + create: vi.fn(), + update: vi.fn(), + updateMany: vi.fn(), + }, + }; +} + +type MockPrisma = ReturnType; + +// ─── App factory ───────────────────────────────────────────────────────────── + +async function buildApp(mockPrisma: MockPrisma): Promise { + const app = Fastify({ logger: false }); + + await app.register(cookiePlugin as any); + await app.register(jwtPlugin as any, { + secret: TEST_JWT_SECRET, + cookie: { cookieName: 'access_Token', signed: false }, + }); + + app.decorate('prisma', mockPrisma as any); + app.decorate('redis', { set: vi.fn(), get: vi.fn(), getdel: vi.fn() } as any); + app.decorate('authenticate', async (request: any, reply: any) => { + try { + await request.jwtVerify(); + } catch { + return reply.status(401).send({ error: 'Unauthorized' }); + } + }); + + app.register(authRoutes, { prefix: '/auth' }); + await app.ready(); + return app; +} + +// ─── Helper — build a stored-token record ──────────────────────────────────── + +function makeStoredToken(overrides: Partial<{ + tokenHash: string; + revokedAt: Date | null; + expiresAt: Date; + family: string; +}> = {}) { + return { + id: 'token-id-1', + tokenHash: HASH_A, + family: FAMILY_ID, + userId: USER_ID, + revokedAt: null, + expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000), + ip: 'hashed-ip', + userAgent: 'vitest', + user: mockUser, + ...overrides, + }; +} + +// ───────────────────────────────────────────────────────────────────────────── +// POST /auth/refresh — normal rotation +// ───────────────────────────────────────────────────────────────────────────── + +describe('POST /auth/refresh — normal token rotation', () => { + let mockPrisma: MockPrisma; + + beforeEach(() => { + vi.clearAllMocks(); + mockPrisma = createMockPrisma(); + }); + + it('rotates a valid token: revokes old, issues new, returns 200', async () => { + mockPrisma.refreshToken.findUnique.mockResolvedValue(makeStoredToken()); + mockPrisma.refreshToken.update.mockResolvedValue({}); + mockPrisma.refreshToken.create.mockResolvedValue({}); + + const app = await buildApp(mockPrisma); + const res = await app.inject({ + method: 'POST', + url: '/auth/refresh', + cookies: { refresh_token: RAW_TOKEN_A }, + }); + + expect(res.statusCode).toBe(200); + + // Old token must be revoked + expect(mockPrisma.refreshToken.update).toHaveBeenCalledWith( + expect.objectContaining({ data: { revokedAt: expect.any(Date) } }), + ); + + // New token must be created in the same family + expect(mockPrisma.refreshToken.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ family: FAMILY_ID }), + }), + ); + + // Family-wide revocation must NOT have been called on a clean rotation + expect(mockPrisma.refreshToken.updateMany).not.toHaveBeenCalled(); + }); + + it('returns 401 for a missing refresh token cookie', async () => { + const app = await buildApp(mockPrisma); + const res = await app.inject({ method: 'POST', url: '/auth/refresh' }); + + expect(res.statusCode).toBe(401); + expect(res.json().error).toBe('Refresh token missing'); + }); + + it('returns 401 for an unrecognised token hash', async () => { + mockPrisma.refreshToken.findUnique.mockResolvedValue(null); + + const app = await buildApp(mockPrisma); + const res = await app.inject({ + method: 'POST', + url: '/auth/refresh', + cookies: { refresh_token: RAW_TOKEN_A }, + }); + + expect(res.statusCode).toBe(401); + expect(res.json().error).toBe('Invalid refresh token'); + }); + + it('returns 401 for an expired token without rotating', async () => { + mockPrisma.refreshToken.findUnique.mockResolvedValue( + makeStoredToken({ expiresAt: new Date(Date.now() - 1000) }), + ); + + const app = await buildApp(mockPrisma); + const res = await app.inject({ + method: 'POST', + url: '/auth/refresh', + cookies: { refresh_token: RAW_TOKEN_A }, + }); + + expect(res.statusCode).toBe(401); + expect(res.json().error).toBe('Refresh token expired'); + expect(mockPrisma.refreshToken.update).not.toHaveBeenCalled(); + expect(mockPrisma.refreshToken.create).not.toHaveBeenCalled(); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// POST /auth/refresh — reuse-detection & family revocation +// ───────────────────────────────────────────────────────────────────────────── + +describe('POST /auth/refresh — reuse-detection triggers family-wide revocation', () => { + let mockPrisma: MockPrisma; + + beforeEach(() => { + vi.clearAllMocks(); + mockPrisma = createMockPrisma(); + }); + + it('revokes entire family when a previously-revoked token is presented', async () => { + // Token A was already rotated (revokedAt is set). + // Presenting it again is the theft signal. + mockPrisma.refreshToken.findUnique.mockResolvedValue( + makeStoredToken({ revokedAt: new Date(Date.now() - 5000) }), + ); + mockPrisma.refreshToken.updateMany.mockResolvedValue({ count: 2 }); + + const app = await buildApp(mockPrisma); + const res = await app.inject({ + method: 'POST', + url: '/auth/refresh', + cookies: { refresh_token: RAW_TOKEN_A }, + }); + + expect(res.statusCode).toBe(401); + expect(res.json().error).toBe('Refresh token revoked'); + + // Family-wide revocation must have fired + expect(mockPrisma.refreshToken.updateMany).toHaveBeenCalledOnce(); + expect(mockPrisma.refreshToken.updateMany).toHaveBeenCalledWith({ + where: { family: FAMILY_ID, revokedAt: null }, + data: { revokedAt: expect.any(Date) }, + }); + + // No new token must have been issued + expect(mockPrisma.refreshToken.create).not.toHaveBeenCalled(); + }); + + it('does not issue a new token after family revocation', async () => { + mockPrisma.refreshToken.findUnique.mockResolvedValue( + makeStoredToken({ revokedAt: new Date() }), + ); + mockPrisma.refreshToken.updateMany.mockResolvedValue({ count: 1 }); + + const app = await buildApp(mockPrisma); + await app.inject({ + method: 'POST', + url: '/auth/refresh', + cookies: { refresh_token: RAW_TOKEN_A }, + }); + + expect(mockPrisma.refreshToken.update).not.toHaveBeenCalled(); + expect(mockPrisma.refreshToken.create).not.toHaveBeenCalled(); + }); + + it('rotation chain: A→B→C succeeds normally; presenting stale A kills B and C', async () => { + // ── Step 1: legitimate client rotates A → B ── + mockPrisma.refreshToken.findUnique.mockResolvedValueOnce(makeStoredToken()); // A is live + mockPrisma.refreshToken.update.mockResolvedValue({}); + mockPrisma.refreshToken.create.mockResolvedValue({}); + + const app = await buildApp(mockPrisma); + const step1 = await app.inject({ + method: 'POST', + url: '/auth/refresh', + cookies: { refresh_token: RAW_TOKEN_A }, + }); + expect(step1.statusCode).toBe(200); + + // ── Step 2: attacker presents stale token A ── + // A is now revoked (stored with revokedAt); B is the live descendant. + mockPrisma.refreshToken.findUnique.mockResolvedValueOnce( + makeStoredToken({ revokedAt: new Date(Date.now() - 1000) }), + ); + mockPrisma.refreshToken.updateMany.mockResolvedValue({ count: 1 }); // kills B + + const step2 = await app.inject({ + method: 'POST', + url: '/auth/refresh', + cookies: { refresh_token: RAW_TOKEN_A }, + }); + expect(step2.statusCode).toBe(401); + expect(step2.json().error).toBe('Refresh token revoked'); + + // Family kill must target only the still-live tokens in the family + expect(mockPrisma.refreshToken.updateMany).toHaveBeenCalledWith({ + where: { family: FAMILY_ID, revokedAt: null }, + data: { revokedAt: expect.any(Date) }, + }); + + // ── Step 3: legitimate client now tries with B (now killed) → 401 ── + mockPrisma.refreshToken.findUnique.mockResolvedValueOnce( + makeStoredToken({ + tokenHash: HASH_B, + revokedAt: new Date(), // killed by step 2's updateMany + }), + ); + // On this third presentation the family is already all-revoked, + // so updateMany returns count: 0 — still must be called. + mockPrisma.refreshToken.updateMany.mockResolvedValue({ count: 0 }); + + const step3 = await app.inject({ + method: 'POST', + url: '/auth/refresh', + cookies: { refresh_token: RAW_TOKEN_B }, + }); + expect(step3.statusCode).toBe(401); + + // No new token issued at any point after the theft was detected + // (create was called once in step 1, never again) + expect(mockPrisma.refreshToken.create).toHaveBeenCalledTimes(1); + }); + + it('returns 500 and does not issue a token if the family-revocation updateMany throws', async () => { + mockPrisma.refreshToken.findUnique.mockResolvedValue( + makeStoredToken({ revokedAt: new Date() }), + ); + mockPrisma.refreshToken.updateMany.mockRejectedValue(new Error('DB timeout')); + + const app = await buildApp(mockPrisma); + const res = await app.inject({ + method: 'POST', + url: '/auth/refresh', + cookies: { refresh_token: RAW_TOKEN_A }, + }); + + expect(res.statusCode).toBe(500); + expect(mockPrisma.refreshToken.create).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/apps/backend/src/routes/auth.ts b/apps/backend/src/routes/auth.ts index 3bc39ad4..56263edf 100644 --- a/apps/backend/src/routes/auth.ts +++ b/apps/backend/src/routes/auth.ts @@ -522,6 +522,16 @@ export async function authRoutes(app: FastifyInstance): Promise { } if (storedToken.revokedAt) { + await app.prisma.refreshToken.updateMany({ + where: { family: storedToken.family, revokedAt: null }, + data: { revokedAt: new Date() }, + }); + + app.log.warn( + { family: storedToken.family, userId: storedToken.userId }, + 'Refresh token reuse detected — entire family revoked (possible theft)', + ); + return reply.status(401).send({ error: 'Refresh token revoked', }); From 52a9357ee6d49133118212c843e7c7f66986ad29 Mon Sep 17 00:00:00 2001 From: Srejoye Date: Wed, 17 Jun 2026 00:05:45 +0530 Subject: [PATCH 2/3] fixed --- apps/backend/src/__tests__/refresh.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/backend/src/__tests__/refresh.test.ts b/apps/backend/src/__tests__/refresh.test.ts index abe303f9..4ec2d8c2 100644 --- a/apps/backend/src/__tests__/refresh.test.ts +++ b/apps/backend/src/__tests__/refresh.test.ts @@ -1,7 +1,7 @@ import cookiePlugin from '@fastify/cookie'; import jwtPlugin from '@fastify/jwt'; import Fastify, { type FastifyInstance } from 'fastify'; -import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; import { authRoutes } from '../routes/auth.js'; import { hashRefreshToken } from '../utils/refreshToken.js'; @@ -29,7 +29,7 @@ const mockUser = { // ─── Prisma mock factory ────────────────────────────────────────────────────── -function createMockPrisma() { +function createMockPrisma(): MockPrisma { return { user: { findUnique: vi.fn() }, refreshToken: { @@ -76,7 +76,7 @@ function makeStoredToken(overrides: Partial<{ revokedAt: Date | null; expiresAt: Date; family: string; -}> = {}) { +}> = {}): ReturnType { return { id: 'token-id-1', tokenHash: HASH_A, From b3b58d0f76d83a2c25a1f533a6d16293223282c6 Mon Sep 17 00:00:00 2001 From: Srejoye Date: Wed, 17 Jun 2026 00:12:21 +0530 Subject: [PATCH 3/3] final fix --- apps/backend/src/__tests__/refresh.test.ts | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/apps/backend/src/__tests__/refresh.test.ts b/apps/backend/src/__tests__/refresh.test.ts index 4ec2d8c2..c230ad92 100644 --- a/apps/backend/src/__tests__/refresh.test.ts +++ b/apps/backend/src/__tests__/refresh.test.ts @@ -29,7 +29,15 @@ const mockUser = { // ─── Prisma mock factory ────────────────────────────────────────────────────── -function createMockPrisma(): MockPrisma { +function createMockPrisma(): { + user: { findUnique: ReturnType }; + refreshToken: { + findUnique: ReturnType; + create: ReturnType; + update: ReturnType; + updateMany: ReturnType; + }; +} { return { user: { findUnique: vi.fn() }, refreshToken: { @@ -76,7 +84,17 @@ function makeStoredToken(overrides: Partial<{ revokedAt: Date | null; expiresAt: Date; family: string; -}> = {}): ReturnType { +}> = {}): { + id: string; + tokenHash: string; + family: string; + userId: string; + revokedAt: Date | null; + expiresAt: Date; + ip: string; + userAgent: string; + user: typeof mockUser; +} { return { id: 'token-id-1', tokenHash: HASH_A,