diff --git a/apps/backend/src/__tests__/app.test.ts b/apps/backend/src/__tests__/app.test.ts index 92b5ce48..b238961b 100644 --- a/apps/backend/src/__tests__/app.test.ts +++ b/apps/backend/src/__tests__/app.test.ts @@ -3,8 +3,10 @@ import { describe, it, expect, vi } from 'vitest'; import { buildApp } from '../app'; process.env.NODE_ENV = 'test'; -process.env.JWT_SECRET ||= 'test-jwt-secret'; -process.env.ENCRYPTION_KEY ||= 'test-encryption-key'; +// validateEnv() runs inside buildApp() and exits if these are absent. +// Provide safe test-only fallbacks so CI doesn't need real secrets here. +process.env.JWT_SECRET ??= 'test-jwt-secret-not-for-production-xxxxxxxxxxxxxxxxxxxxxxx'; +process.env.ENCRYPTION_KEY ??= 'a'.repeat(64); describe('GET /health', () => { it('should return status ok', async () => { diff --git a/apps/backend/src/__tests__/logout.test.ts b/apps/backend/src/__tests__/logout.test.ts new file mode 100644 index 00000000..15fc7d1f --- /dev/null +++ b/apps/backend/src/__tests__/logout.test.ts @@ -0,0 +1,673 @@ +import cookiePlugin from '@fastify/cookie'; +import jwtPlugin from '@fastify/jwt'; +import Fastify, { type FastifyInstance, type FastifyRequest } from 'fastify'; +import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest'; + +import { authRoutes } from '../routes/auth.js'; +import { extractRawJwt, blocklistKey } from '../utils/jwt.js'; + +// ─── Constants ──────────────────────────────────────────────────────────────── + +const TEST_JWT_SECRET = 'test-secret-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'; // ≥ 32 chars +const USER_ID = 'user-test-001'; +const USERNAME = 'testuser'; + +// ─── Mock Redis factory ─────────────────────────────────────────────────────── + +function createMockRedis(): { exists: Mock; set: Mock; del: Mock } { + return { + exists: vi.fn().mockResolvedValue(0), + set: vi.fn().mockResolvedValue('OK'), + del: vi.fn().mockResolvedValue(1), + }; +} + +type MockRedis = ReturnType; + +// ─── App factory ───────────────────────────────────────────────────────────── +// +// Builds an isolated Fastify instance that mirrors the production authenticate +// decorator (blocklist check → jwtVerify) without needing a real database or +// Redis server. All dependencies are replaced with vitest mocks. + +async function buildTestApp(mockRedis: MockRedis): Promise { + const app = Fastify({ logger: false }); + + // cookie must be registered before jwt (required by @fastify/jwt when the + // cookie option is used) so that request.cookies is populated before + // jwtVerify() runs. + // + // Both plugins use `export =` (CJS-style) declarations. TypeScript resolves + // the overloaded type as the namespace object rather than the callable + // function when moduleResolution is "bundler", so `as any` narrows to the + // call signature Fastify's register() actually expects at runtime. + await app.register(cookiePlugin as any); + // Real JWT plugin with cookie support — mirrors the production configuration + // in app.ts so that both Authorization header and token cookie are accepted. + await app.register(jwtPlugin as any, { + secret: TEST_JWT_SECRET, + cookie: { cookieName: 'token', signed: false }, + }); + + // Minimal Prisma stub. The logout route does not touch the database, but + // authRoutes also registers /dev-login and /auth/me which reference + // app.prisma at request time (never reached by these tests). + app.decorate('prisma', { + user: { findUnique: vi.fn().mockResolvedValue(null) }, + } as any); + + // Mock Redis — injected so the authenticate decorator and logout handler + // can interact with it without a real Redis server. + app.decorate('redis', mockRedis as any); + + // Authenticate decorator — mirrors production logic in app.ts: + // 1. Extract raw JWT. + // 2. Check blocklist in Redis (inner try/catch — Redis failure is non-fatal). + // 3. Call jwtVerify() (outer try/catch — invalid JWT → 401). + app.decorate('authenticate', async function (request: any, reply: any) { + try { + const raw = extractRawJwt(request); + if (raw) { + try { + const revoked = await mockRedis.exists(blocklistKey(raw)); + if (revoked) { + return reply.status(401).send({ error: 'Token has been revoked' }); + } + } catch (redisErr) { + app.log.warn({ err: redisErr }, 'Redis blocklist check failed — proceeding with JWT verification'); + } + } + await request.jwtVerify(); + } catch { + return reply.status(401).send({ error: 'Unauthorized' }); + } + }); + + await app.register(authRoutes, { prefix: '/auth' }); + + // Generic protected route — used to test the authenticate middleware + // independently of the logout handler. + app.get('/protected', { + preHandler: [(app as any).authenticate], + }, async () => ({ ok: true })); + + await app.ready(); + return app; +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function bearerHeader(token: string): { Authorization: string } { + return { Authorization: `Bearer ${token}` }; +} + +// app.jwt is added by @fastify/jwt's module augmentation. The augmentation +// is not picked up by VS Code's language server under moduleResolution:"bundler" +// for `export =` packages, so all sign() calls go through this helper to keep +// the single cast in one place rather than scattering `(app as any)` everywhere. +function signToken(app: FastifyInstance, payload: object, options?: Record): string { + return (app as any).jwt.sign(payload, options); +} + +// ─── DELETE /auth/logout ────────────────────────────────────────────────────── + +describe('DELETE /auth/logout', () => { + let app: FastifyInstance; + let mockRedis: MockRedis; + + beforeEach(async () => { + vi.clearAllMocks(); + mockRedis = createMockRedis(); + app = await buildTestApp(mockRedis); + }); + + afterEach(async () => { + await app.close(); + }); + + it('200 — returns logged-out message and clears the token cookie', async () => { + const token = signToken(app, { id: USER_ID, username: USERNAME }, { expiresIn: '30d' }); + + const res = await app.inject({ + method: 'DELETE', + url: '/auth/logout', + headers: bearerHeader(token), + }); + + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ message: 'Logged out' }); + + // Cookie must be cleared — Set-Cookie header should zero the token value. + const setCookie = res.headers['set-cookie'] as string | string[]; + const cookieStr = Array.isArray(setCookie) ? setCookie.join('; ') : setCookie; + expect(cookieStr).toMatch(/token=;/); + }); + + it('blocks the token in Redis with a positive TTL', async () => { + const token = signToken(app, { id: USER_ID, username: USERNAME }, { expiresIn: '30d' }); + + await app.inject({ + method: 'DELETE', + url: '/auth/logout', + headers: bearerHeader(token), + }); + + expect(mockRedis.set).toHaveBeenCalledOnce(); + + const [key, value, exFlag, ttl] = mockRedis.set.mock.calls[0] as unknown as [string, string, string, number]; + expect(key).toBe(blocklistKey(token)); + expect(value).toBe('1'); + expect(exFlag).toBe('EX'); + // TTL should be close to 30 days in seconds (allow 60s of test execution slack). + expect(ttl).toBeGreaterThan(30 * 24 * 60 * 60 - 60); + expect(ttl).toBeLessThanOrEqual(30 * 24 * 60 * 60); + }); + + it('uses the correct blocklist key derived from the token signature', async () => { + const token = signToken(app, { id: USER_ID, username: USERNAME }, { expiresIn: '30d' }); + + await app.inject({ + method: 'DELETE', + url: '/auth/logout', + headers: bearerHeader(token), + }); + + const [key] = mockRedis.set.mock.calls[0] as unknown as [string]; + expect(key).toBe(blocklistKey(token)); + // Key must be a deterministic sha256 hash, never the raw JWT. + expect(key).toMatch(/^blocklist:[0-9a-f]{64}$/); + expect(key).not.toContain(token); + }); + + it('401 — rejects request with no token (unauthenticated)', async () => { + const res = await app.inject({ + method: 'DELETE', + url: '/auth/logout', + }); + + expect(res.statusCode).toBe(401); + expect(mockRedis.set).not.toHaveBeenCalled(); + }); + + it('401 — rejects request with a malformed token', async () => { + const res = await app.inject({ + method: 'DELETE', + url: '/auth/logout', + headers: bearerHeader('not.a.valid.jwt'), + }); + + expect(res.statusCode).toBe(401); + expect(mockRedis.set).not.toHaveBeenCalled(); + }); + + it('still returns 200 if Redis write fails (non-fatal)', async () => { + mockRedis.set.mockRejectedValueOnce(new Error('Redis connection lost')); + + const token = signToken(app, { id: USER_ID, username: USERNAME }, { expiresIn: '30d' }); + + const res = await app.inject({ + method: 'DELETE', + url: '/auth/logout', + headers: bearerHeader(token), + }); + + // Logout must succeed even when Redis is down — cookie is still cleared. + expect(res.statusCode).toBe(200); + }); + + it('401 — rejects a second logout attempt with an already-revoked token', async () => { + // After the first logout the token is in the blocklist (exists returns 1). + mockRedis.exists.mockResolvedValue(1); + + const token = signToken(app, { id: USER_ID, username: USERNAME }, { expiresIn: '30d' }); + + const res = await app.inject({ + method: 'DELETE', + url: '/auth/logout', + headers: bearerHeader(token), + }); + + // The authenticate preHandler catches the revoked token before the handler runs. + expect(res.statusCode).toBe(401); + expect(res.json().error).toBe('Token has been revoked'); + // Redis write must NOT be called — handler never ran. + expect(mockRedis.set).not.toHaveBeenCalled(); + }); + + it('401 — expired token is rejected and does not write to Redis', async () => { + const realNow = Date.now(); + // Sign with 1-second expiry so we can advance the clock past it. + const token = signToken(app, { id: USER_ID, username: USERNAME }, { expiresIn: 1 }); + + // Fake only the Date object (not timers) so jwtVerify sees the token as + // expired without blocking the async inject pipeline. + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(realNow + 2000); + + try { + const res = await app.inject({ + method: 'DELETE', + url: '/auth/logout', + headers: bearerHeader(token), + }); + // Authenticate preHandler rejects the expired token; handler never runs. + expect(res.statusCode).toBe(401); + expect(mockRedis.set).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it('200 — works when JWT is sent via cookie (web browser flow)', async () => { + const token = signToken(app, { id: USER_ID, username: USERNAME }, { expiresIn: '30d' }); + + const res = await app.inject({ + method: 'DELETE', + url: '/auth/logout', + headers: { Cookie: `token=${token}` }, + }); + + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ message: 'Logged out' }); + // Token extracted from cookie must still be blocklisted in Redis. + expect(mockRedis.set).toHaveBeenCalledOnce(); + const [key] = mockRedis.set.mock.calls[0] as unknown as [string]; + expect(key).toBe(blocklistKey(token)); + }); + + it('200 — Authorization header takes precedence over cookie when both are present', async () => { + const headerToken = signToken(app, { id: USER_ID, username: USERNAME }, { expiresIn: '30d' }); + const cookieToken = signToken(app, { id: 'other-user', username: 'other' }, { expiresIn: '30d' }); + + const res = await app.inject({ + method: 'DELETE', + url: '/auth/logout', + headers: { + Authorization: `Bearer ${headerToken}`, + Cookie: `token=${cookieToken}`, + }, + }); + + expect(res.statusCode).toBe(200); + // The header token must be blocklisted — not the cookie token. + expect(mockRedis.set).toHaveBeenCalledOnce(); + const [key] = mockRedis.set.mock.calls[0] as unknown as [string]; + expect(key).toBe(blocklistKey(headerToken)); + expect(key).not.toBe(blocklistKey(cookieToken)); + }); + + it('200 — Set-Cookie response clears token with Path=/ and a past Expires date', async () => { + const token = signToken(app, { id: USER_ID, username: USERNAME }, { expiresIn: '30d' }); + + const res = await app.inject({ + method: 'DELETE', + url: '/auth/logout', + headers: bearerHeader(token), + }); + + expect(res.statusCode).toBe(200); + const raw = res.headers['set-cookie'] as string | string[]; + const cookieStr = Array.isArray(raw) ? raw.join('; ') : (raw ?? ''); + // Value must be emptied. + expect(cookieStr).toMatch(/token=;/); + // Path must be explicit so the browser clears the cookie on all routes. + expect(cookieStr).toMatch(/Path=\//i); + // Browser must be told to delete the cookie immediately. + expect(cookieStr).toMatch(/Expires=|Max-Age=0/i); + }); + + it('200 — near-expiry token gets a short positive TTL in Redis', async () => { + // Token that expires in 5 seconds — the blocklist TTL must still be positive. + const token = signToken(app, { id: USER_ID, username: USERNAME }, { expiresIn: 5 }); + + const res = await app.inject({ + method: 'DELETE', + url: '/auth/logout', + headers: bearerHeader(token), + }); + + expect(res.statusCode).toBe(200); + expect(mockRedis.set).toHaveBeenCalledOnce(); + const [, , , ttl] = mockRedis.set.mock.calls[0] as unknown as [string, string, string, number]; + expect(ttl).toBeGreaterThan(0); + expect(ttl).toBeLessThanOrEqual(5); + }); + + it('200 — logs warning and skips Redis write when JWT has no exp claim', async () => { + // Signing without expiresIn produces a token with no exp field. + const token = signToken(app, { id: USER_ID, username: USERNAME }); + const warnMock = vi.fn(); + // Replace the logger's warn method so we can assert it was called. + (app.log as any).warn = warnMock; + + const res = await app.inject({ + method: 'DELETE', + url: '/auth/logout', + headers: bearerHeader(token), + }); + + expect(res.statusCode).toBe(200); + expect(mockRedis.set).not.toHaveBeenCalled(); + expect(warnMock).toHaveBeenCalledOnce(); + // Verify the message identifies the root cause clearly. + const [, message] = warnMock.mock.calls[0] as [unknown, string]; + expect(message).toMatch(/missing exp/i); + }); + + it('401 — rejects "Authorization: Bearer " with no token value after the prefix', async () => { + const res = await app.inject({ + method: 'DELETE', + url: '/auth/logout', + headers: { Authorization: 'Bearer ' }, + }); + + expect(res.statusCode).toBe(401); + expect(mockRedis.set).not.toHaveBeenCalled(); + }); +}); + +// ─── authenticate middleware — blocklist behaviour ──────────────────────────── + +describe('authenticate middleware', () => { + let app: FastifyInstance; + let mockRedis: MockRedis; + + beforeEach(async () => { + vi.clearAllMocks(); + mockRedis = createMockRedis(); + app = await buildTestApp(mockRedis); + }); + + afterEach(async () => { + await app.close(); + }); + + it('200 — allows a valid non-revoked token', async () => { + mockRedis.exists.mockResolvedValue(0); + const token = signToken(app, { id: USER_ID, username: USERNAME }, { expiresIn: '30d' }); + + const res = await app.inject({ + method: 'GET', + url: '/protected', + headers: bearerHeader(token), + }); + + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ ok: true }); + expect(mockRedis.exists).toHaveBeenCalledOnce(); + expect(mockRedis.exists.mock.calls[0][0]).toBe(blocklistKey(token)); + }); + + it('401 — rejects a revoked token with "Token has been revoked"', async () => { + mockRedis.exists.mockResolvedValue(1); // token is in the blocklist + const token = signToken(app, { id: USER_ID, username: USERNAME }, { expiresIn: '30d' }); + + const res = await app.inject({ + method: 'GET', + url: '/protected', + headers: bearerHeader(token), + }); + + expect(res.statusCode).toBe(401); + expect(res.json().error).toBe('Token has been revoked'); + }); + + it('200 — continues to allow access when Redis check throws (fail-open)', async () => { + mockRedis.exists.mockRejectedValueOnce(new Error('Redis timeout')); + const token = signToken(app, { id: USER_ID, username: USERNAME }, { expiresIn: '30d' }); + + const res = await app.inject({ + method: 'GET', + url: '/protected', + headers: bearerHeader(token), + }); + + // Redis failure must not cause a false 401 — JWT expiry is still the guard. + expect(res.statusCode).toBe(200); + }); + + it('401 — rejects a malformed token with "Unauthorized"', async () => { + const res = await app.inject({ + method: 'GET', + url: '/protected', + headers: bearerHeader('not-a-jwt'), + }); + + expect(res.statusCode).toBe(401); + expect(res.json().error).toBe('Unauthorized'); + }); + + it('401 — rejects a request with no token', async () => { + const res = await app.inject({ + method: 'GET', + url: '/protected', + }); + + expect(res.statusCode).toBe(401); + expect(mockRedis.exists).not.toHaveBeenCalled(); + }); + + it('401 — rejects a token signed with the wrong secret', async () => { + // Sign with a different secret — jwtVerify will fail. + const wrongApp = Fastify({ logger: false }); + await wrongApp.register(jwtPlugin as any, { secret: 'totally-different-secret-xxxxx' }); + const badToken = signToken(wrongApp, { id: USER_ID }); + await wrongApp.close(); + + const res = await app.inject({ + method: 'GET', + url: '/protected', + headers: bearerHeader(badToken), + }); + + expect(res.statusCode).toBe(401); + expect(res.json().error).toBe('Unauthorized'); + }); + + it('200 — allows authenticated request when JWT is sent via cookie', async () => { + mockRedis.exists.mockResolvedValue(0); + const token = signToken(app, { id: USER_ID, username: USERNAME }, { expiresIn: '30d' }); + + const res = await app.inject({ + method: 'GET', + url: '/protected', + headers: { Cookie: `token=${token}` }, + }); + + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ ok: true }); + // Blocklist check must still run — the key is derived from the cookie token. + expect(mockRedis.exists).toHaveBeenCalledOnce(); + expect(mockRedis.exists.mock.calls[0][0]).toBe(blocklistKey(token)); + }); + + it('logs a warning when the Redis check throws and still allows valid JWT through', async () => { + const warnMock = vi.fn(); + (app.log as any).warn = warnMock; + mockRedis.exists.mockRejectedValueOnce(new Error('ECONNREFUSED')); + const token = signToken(app, { id: USER_ID, username: USERNAME }, { expiresIn: '30d' }); + + const res = await app.inject({ + method: 'GET', + url: '/protected', + headers: bearerHeader(token), + }); + + expect(res.statusCode).toBe(200); + expect(warnMock).toHaveBeenCalledOnce(); + const [obj, message] = warnMock.mock.calls[0] as [{ err: Error }, string]; + expect(message).toMatch(/blocklist check failed/i); + expect(obj.err).toBeInstanceOf(Error); + }); + + it('401 — rejects "Authorization: Bearer " with no token value after the prefix', async () => { + const res = await app.inject({ + method: 'GET', + url: '/protected', + headers: { Authorization: 'Bearer ' }, + }); + + // extractRawJwt returns '' (falsy) — blocklist check is skipped, + // jwtVerify receives an empty token and throws. + expect(res.statusCode).toBe(401); + expect(mockRedis.exists).not.toHaveBeenCalled(); + }); +}); + +// ─── Revocation flow — end-to-end ──────────────────────────────────────────── +// +// Verifies the full lifecycle: token works → logout blocklists it → +// authenticate rejects it. This is the critical security invariant. + +describe('revocation flow — end-to-end', () => { + let app: FastifyInstance; + let mockRedis: MockRedis; + + beforeEach(async () => { + vi.clearAllMocks(); + mockRedis = createMockRedis(); + app = await buildTestApp(mockRedis); + }); + + afterEach(async () => { + await app.close(); + }); + + it('token is usable before logout and rejected after blocklisting', async () => { + const token = signToken(app, { id: USER_ID, username: USERNAME }, { expiresIn: '30d' }); + + // Step 1: token is valid — protected route responds 200. + const before = await app.inject({ + method: 'GET', + url: '/protected', + headers: bearerHeader(token), + }); + expect(before.statusCode).toBe(200); + + // Step 2: logout succeeds and writes the key to the blocklist. + const logout = await app.inject({ + method: 'DELETE', + url: '/auth/logout', + headers: bearerHeader(token), + }); + expect(logout.statusCode).toBe(200); + expect(mockRedis.set).toHaveBeenCalledOnce(); + + // Step 3: simulate Redis now returning 1 for this token's blocklist key. + // (In production this is automatic — the SET from step 2 persists in Redis.) + mockRedis.exists.mockResolvedValueOnce(1); + + // Step 4: same token is now rejected by the authenticate middleware. + const after = await app.inject({ + method: 'GET', + url: '/protected', + headers: bearerHeader(token), + }); + expect(after.statusCode).toBe(401); + expect(after.json().error).toBe('Token has been revoked'); + }); + + it('cookie-delivered token is also rejected after logout', async () => { + const token = signToken(app, { id: USER_ID, username: USERNAME }, { expiresIn: '30d' }); + + // Logout via cookie — browser clients never send an Authorization header. + const logout = await app.inject({ + method: 'DELETE', + url: '/auth/logout', + headers: { Cookie: `token=${token}` }, + }); + expect(logout.statusCode).toBe(200); + expect(mockRedis.set).toHaveBeenCalledOnce(); + // The blocklist key must match the token delivered via cookie. + const [writtenKey] = mockRedis.set.mock.calls[0] as unknown as [string]; + expect(writtenKey).toBe(blocklistKey(token)); + + // Simulate blocklist hit on next request. + mockRedis.exists.mockResolvedValueOnce(1); + + const after = await app.inject({ + method: 'GET', + url: '/protected', + headers: { Cookie: `token=${token}` }, + }); + expect(after.statusCode).toBe(401); + expect(after.json().error).toBe('Token has been revoked'); + }); +}); + +// ─── blocklistKey utility ───────────────────────────────────────────────────── + +describe('blocklistKey', () => { + it('produces a consistent key for the same token', () => { + const token = 'header.payload.signature'; + expect(blocklistKey(token)).toBe(blocklistKey(token)); + }); + + it('produces different keys for different signatures', () => { + expect(blocklistKey('h.p.sig1')).not.toBe(blocklistKey('h.p.sig2')); + }); + + it('always starts with "blocklist:" followed by 64 hex chars', () => { + const key = blocklistKey('h.p.anysignature'); + expect(key).toMatch(/^blocklist:[0-9a-f]{64}$/); + }); + + it('produces the same key regardless of header or payload content', () => { + // Two tokens with different claims but the same signature produce the same key. + // (Unlikely in practice, but documents the hash-of-signature contract.) + const key1 = blocklistKey('differentHeader.differentPayload.SAME_SIG'); + const key2 = blocklistKey('anotherHeader.anotherPayload.SAME_SIG'); + expect(key1).toBe(key2); + }); +}); + +// ─── extractRawJwt utility ──────────────────────────────────────────────────── + +describe('extractRawJwt', () => { + function makeRequest(overrides: Partial<{ authorization: string; cookies: Record }>): FastifyRequest { + return { + headers: { authorization: overrides.authorization }, + cookies: overrides.cookies ?? {}, + } as any; + } + + it('returns token from Authorization: Bearer header', () => { + const req = makeRequest({ authorization: 'Bearer my.jwt.token' }); + expect(extractRawJwt(req)).toBe('my.jwt.token'); + }); + + it('returns token from cookie when no Authorization header', () => { + const req = makeRequest({ cookies: { token: 'cookie.jwt.token' } }); + expect(extractRawJwt(req)).toBe('cookie.jwt.token'); + }); + + it('prefers Authorization header over cookie', () => { + const req = makeRequest({ + authorization: 'Bearer header.jwt.token', + cookies: { token: 'cookie.jwt.token' }, + }); + expect(extractRawJwt(req)).toBe('header.jwt.token'); + }); + + it('returns null when neither header nor cookie is present', () => { + const req = makeRequest({}); + expect(extractRawJwt(req)).toBeNull(); + }); + + it('returns null when Authorization header is not Bearer', () => { + const req = makeRequest({ authorization: 'Basic dXNlcjpwYXNz' }); + expect(extractRawJwt(req)).toBeNull(); + }); + + it('returns null when Authorization is "Bearer " with no token after the space', () => { + const req = makeRequest({ authorization: 'Bearer ' }); + // slice(7) || null normalises the empty string to null. + expect(extractRawJwt(req)).toBeNull(); + }); + + it('returns null when the token cookie value is empty', () => { + const req = makeRequest({ cookies: { token: '' } }); + // || null normalises the empty string to null, matching the return type. + expect(extractRawJwt(req)).toBeNull(); + }); +}); diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index be0b27e9..6116b91b 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -21,6 +21,7 @@ import { nfcRoutes } from './routes/nfc.js'; import { profileRoutes } from './routes/profiles.js'; import { publicRoutes } from './routes/public.js'; import { teamRoutes } from './routes/team.js'; +import { extractRawJwt, blocklistKey } from './utils/jwt.js'; import { validateEnv } from './utils/validateEnv.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -70,12 +71,19 @@ export async function buildApp():Promise { }, }); + // cookie must be registered before jwt so that @fastify/jwt can read the + // `token` cookie during jwtVerify() for browser-based clients. + await app.register(cookie); + await app.register(jwt, { // validateEnv() above guarantees JWT_SECRET is present and safe. secret: process.env.JWT_SECRET!, + cookie: { + // Matches the cookie name set in the OAuth callback handlers. + cookieName: 'token', + signed: false, + }, }); - - await app.register(cookie); await app.register(multipart, { limits: { fileSize: 5 * 1024 * 1024 } }); // 5MB await app.register(rateLimit, { max: 100, @@ -93,13 +101,31 @@ export async function buildApp():Promise { await app.register(redisPlugin); } // ─── Auth Decorator ─── + // Checks the Redis blocklist before calling jwtVerify so that a logged-out + // token is rejected immediately even if it has not yet expired. + // The blocklist check is skipped when Redis is not registered (test env). app.decorate('authenticate', async function (request: any, reply: any) { try { - // Ensure the verified payload is assigned to `request.user` like the original plugin. + if (app.hasDecorator('redis')) { + const raw = extractRawJwt(request); + if (raw) { + try { + const revoked = await app.redis.exists(blocklistKey(raw)); + if (revoked) { + return reply.status(401).send({ error: 'Token has been revoked' }); + } + } catch (redisErr) { + // Redis is unavailable — fail open to avoid an outage on every + // authenticated request. The JWT expiry is still the safety net. + app.log.warn({ err: redisErr }, 'Redis blocklist check failed — proceeding with JWT verification'); + } + } + } + // Assign verified payload to request.user (upstream addition). const payload = await request.jwtVerify(); if (payload) { request.user = payload; } - } catch (_error) { - reply.status(401).send({ error: 'Unauthorized' }); + } catch (_err) { + return reply.status(401).send({ error: 'Unauthorized' }); } }); diff --git a/apps/backend/src/routes/auth.ts b/apps/backend/src/routes/auth.ts index c14949e1..cffebea7 100644 --- a/apps/backend/src/routes/auth.ts +++ b/apps/backend/src/routes/auth.ts @@ -1,6 +1,8 @@ -import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; import { encrypt } from '../utils/encryption.js'; -import { buildOAuthState, getMobileRedirectUri } from '../services/authService.js'; +import { extractRawJwt, blocklistKey } from '../utils/jwt.js'; +import { buildOAuthState, getMobileRedirectUri } from '../utils/oauth.js'; + +import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; const GITHUB_AUTH_URL = 'https://github.com/login/oauth/authorize'; const GITHUB_TOKEN_URL = 'https://github.com/login/oauth/access_token'; @@ -14,7 +16,7 @@ interface OAuthCallbackQuery { state?: string; } -export async function authRoutes(app: FastifyInstance) { +export async function authRoutes(app: FastifyInstance): Promise { // Developer login bypass (development only) if (process.env.NODE_ENV !== 'production') { app.post('/dev-login', async (request: FastifyRequest, reply: FastifyReply) => { @@ -253,12 +255,10 @@ export async function authRoutes(app: FastifyInstance) { }); // Current user - app.get('/me', { preHandler: [async (request, reply) => { - const server = request.server as any; - if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } - if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } - try { await request.jwtVerify() } catch (e) { reply.status(401).send({ error: 'Unauthorized' }) } - }] }, async (request: FastifyRequest, reply: FastifyReply) => { + app.get('/me', { + // eslint-disable-next-line @typescript-eslint/unbound-method + preHandler: [app.authenticate], + }, async (request: FastifyRequest, reply: FastifyReply) => { const userId = (request.user as any).id; const user = await app.prisma.user.findUnique({ where: { id: userId }, @@ -286,8 +286,59 @@ export async function authRoutes(app: FastifyInstance) { return { ...userData, connectedPlatforms: oauthTokens }; }); - app.post('/logout', async (request: FastifyRequest, reply: FastifyReply) => { + // Legacy endpoint kept for backward compatibility with existing clients. + // Cookie-only logout — use DELETE /auth/logout for token revocation. + app.post('/logout', async (_request: FastifyRequest, reply: FastifyReply) => { + app.log.info('Legacy cookie-only logout called — token not blocklisted'); + reply.clearCookie('token', { path: '/' }); + return { message: 'Logged out' }; + }); + + // ─── Secure Logout — blocklists the token in Redis ─── + // + // Requires a valid JWT so that only the token's owner can revoke it. + // The token signature is hashed and stored in Redis with a TTL equal to the + // token's remaining lifetime, so the entry self-cleans when the JWT expires. + // + // Tradeoff: if Redis is down the block write is skipped (non-fatal), but the + // token will still expire naturally based on its exp claim. + + app.delete('/logout', { + // eslint-disable-next-line @typescript-eslint/unbound-method + preHandler: [app.authenticate], + }, async (request: FastifyRequest, reply: FastifyReply) => { + const raw = extractRawJwt(request); + + if (raw && app.hasDecorator('redis')) { + // jwt.decode() skips signature verification — safe here because the + // authenticate preHandler above already called jwtVerify() successfully. + const payload = app.jwt.decode<{ exp?: number }>(raw); + const exp = payload?.exp; + + if (exp) { + const ttl = exp - Math.floor(Date.now() / 1000); + if (ttl > 0) { + try { + await app.redis.set(blocklistKey(raw), '1', 'EX', ttl); + } catch (err) { + // Non-fatal: log and continue. The token will expire on its own. + app.log.warn({ err, userId: (request.user as any)?.id }, 'Redis blocklist write failed during logout — token will expire naturally'); + } + } + } else { + // A JWT without exp cannot be given a finite Redis TTL, so it cannot be + // actively revoked. This should never happen with tokens signed by this + // server (we always pass expiresIn), but log a warning so it is + // visible if a custom or third-party token ever reaches this path. + app.log.warn( + { userId: (request.user as any)?.id }, + 'JWT missing exp claim — skipping Redis blocklist; token cannot be actively revoked', + ); + } + } + reply.clearCookie('token', { path: '/' }); return { message: 'Logged out' }; }); } + diff --git a/apps/backend/src/utils/jwt.ts b/apps/backend/src/utils/jwt.ts new file mode 100644 index 00000000..40386962 --- /dev/null +++ b/apps/backend/src/utils/jwt.ts @@ -0,0 +1,27 @@ +import { createHash } from 'node:crypto'; + +import type { FastifyRequest } from 'fastify'; + +/** + * Extract the raw JWT string from a Fastify request. + * Precedence: Authorization: Bearer header → `token` cookie. + * Returns null if neither is present. + */ +export function extractRawJwt(request: FastifyRequest): string | null { + const auth = request.headers.authorization; + if (auth?.startsWith('Bearer ')) { return auth.slice(7) || null; } + return request.cookies?.token || null; +} + +/** + * Compute the Redis blocklist key for a raw JWT. + * + * Only the signature segment (third JWT segment) is hashed. The signature is + * unique per token because it is an HMAC over the header + payload, so it + * identifies the token without storing any claims in Redis. SHA-256 of the + * signature also means the Redis key leaks nothing if Redis is compromised. + */ +export function blocklistKey(rawJwt: string): string { + const sig = rawJwt.split('.')[2] ?? rawJwt; + return `blocklist:${createHash('sha256').update(sig).digest('hex')}`; +} diff --git a/apps/backend/src/utils/oauth.ts b/apps/backend/src/utils/oauth.ts new file mode 100644 index 00000000..9dff87fe --- /dev/null +++ b/apps/backend/src/utils/oauth.ts @@ -0,0 +1,31 @@ +import { randomBytes } from 'node:crypto'; + +export function generateState(): string { + return randomBytes(32).toString('hex'); +} + +export function buildOAuthState(clientState: string, mobileRedirectUri: string): string { + if (!clientState) { + return generateState(); + } + if (clientState.startsWith('mobile_') && mobileRedirectUri) { + const encodedRedirect = Buffer.from(mobileRedirectUri, 'utf8').toString('base64url'); + return `${clientState}.${encodedRedirect}.${generateState()}`; + } + return `${clientState}.${generateState()}`; +} + +export function getMobileRedirectUri(state?: string): string | null { + if (!state?.startsWith('mobile_')) { + return null; + } + const encodedRedirect = state.split('.')[1]; + if (!encodedRedirect) { + return null; + } + try { + return Buffer.from(encodedRedirect, 'base64url').toString('utf8'); + } catch { + return null; + } +}