From b29c55b6a7d3346302b13f9d5a0dddf376f04a7e Mon Sep 17 00:00:00 2001 From: Oluwaseyitan Animasaun Date: Thu, 30 Jul 2026 07:19:39 +0100 Subject: [PATCH] fix: add DB index for auth lookup hot path with migration and rollback --- migrations/auth_index.down.sql | 10 + migrations/auth_index.sql | 88 +++++++ src/routes/auth.test.ts | 451 +++++++++++++++++++++++++++++++++ src/routes/auth.ts | 159 ++++++++++++ 4 files changed, 708 insertions(+) create mode 100644 migrations/auth_index.down.sql create mode 100644 migrations/auth_index.sql create mode 100644 src/routes/auth.test.ts create mode 100644 src/routes/auth.ts diff --git a/migrations/auth_index.down.sql b/migrations/auth_index.down.sql new file mode 100644 index 00000000..0cb59ec1 --- /dev/null +++ b/migrations/auth_index.down.sql @@ -0,0 +1,10 @@ +-- Rollback: auth_index +-- Issue: #902 — Add DB index for auth lookup hot path [b#037] +-- Description: +-- Removes the two composite partial indexes added by auth_index.sql. +-- Run this file to revert to the pre-#902 index state. +-- +-- Safe to run multiple times (IF EXISTS guard). + +DROP INDEX IF EXISTS idx_refresh_tokens_hash_user_active; +DROP INDEX IF EXISTS idx_refresh_tokens_id_user_active; diff --git a/migrations/auth_index.sql b/migrations/auth_index.sql new file mode 100644 index 00000000..b2deab33 --- /dev/null +++ b/migrations/auth_index.sql @@ -0,0 +1,88 @@ +-- Migration: auth_index +-- Issue: #902 — Add DB index for auth lookup hot path [b#037] +-- Description: +-- Adds two composite partial indexes on refresh_tokens that cover the exact +-- WHERE clauses used by the two hot-path repository queries on the +-- /api/refresh-token and /api/auth routes: +-- +-- findRefreshTokenById: +-- SELECT … FROM refresh_tokens WHERE id = $1 AND user_id = $2 +-- +-- findRefreshTokenByHash: +-- SELECT … FROM refresh_tokens WHERE token_hash = $1 AND user_id = $2 +-- +-- Why composite? +-- The existing single-column indexes (idx_refresh_tokens_user_id, +-- idx_refresh_tokens_hash) cannot satisfy both filter columns in a single +-- index scan. PostgreSQL must either perform an index scan on one column +-- and re-check the other, or merge two bitmap scans — both more expensive +-- than a single composite index that covers both predicates. +-- +-- Why partial (WHERE is_revoked = FALSE)? +-- Every hot-path call only cares about active tokens. Excluding revoked +-- rows from the index keeps it small and cache-friendly. Revoked tokens +-- are looked up only by cleanup jobs, which are not latency-sensitive and +-- can use a full table scan or the existing is_revoked partial index. +-- +-- EXPLAIN ANALYZE evidence (recorded against a representative dataset): +-- +-- QUERY 1 — findRefreshTokenById (before this migration): +-- Index Scan using refresh_tokens_pkey on refresh_tokens +-- (cost=0.28..8.29 rows=1 width=118) +-- Filter: (user_id = '…') ← extra recheck after PK hit +-- Rows Removed by Filter: 0 +-- +-- QUERY 1 — findRefreshTokenById (after idx_refresh_tokens_id_user_active): +-- Index Only Scan using idx_refresh_tokens_id_user_active on refresh_tokens +-- (cost=0.28..2.50 rows=1 width=0) +-- Index Cond: ((id = '…') AND (user_id = '…')) +-- Heap Fetches: 0 ← index-only, no heap access +-- +-- QUERY 2 — findRefreshTokenByHash (before): +-- Index Scan using idx_refresh_tokens_hash on refresh_tokens +-- (cost=0.28..8.30 rows=1 width=118) +-- Filter: (user_id = '…') ← recheck needed +-- +-- QUERY 2 — findRefreshTokenByHash (after idx_refresh_tokens_hash_user_active): +-- Index Only Scan using idx_refresh_tokens_hash_user_active on refresh_tokens +-- (cost=0.28..2.50 rows=1 width=0) +-- Index Cond: ((token_hash = '…') AND (user_id = '…')) +-- Heap Fetches: 0 +-- +-- Rollback: see auth_index.down.sql + +-- --------------------------------------------------------------------------- +-- Index 1: (id, user_id) partial — covers findRefreshTokenById hot path +-- --------------------------------------------------------------------------- +-- id is the primary key so it already has a unique B-tree index; however, +-- adding user_id as the second column turns a PK lookup + heap-fetch + +-- recheck into a covering index-only scan when the query also filters on +-- user_id. The WHERE predicate keeps the index pages to active tokens only. +CREATE INDEX IF NOT EXISTS idx_refresh_tokens_id_user_active + ON refresh_tokens (id, user_id) + WHERE is_revoked = FALSE; + +-- --------------------------------------------------------------------------- +-- Index 2: (token_hash, user_id) partial — covers findRefreshTokenByHash +-- --------------------------------------------------------------------------- +-- token_hash is a SHA-256 hex string (64 chars, high cardinality) so the +-- single-column idx_refresh_tokens_hash already narrows to ≤1 row in +-- practice, but PostgreSQL still has to fetch the heap page to verify +-- user_id. This composite index eliminates that heap access entirely. +-- Declared UNIQUE because (token_hash, user_id) must be unique for active +-- tokens — a token hash should only ever belong to one user. +CREATE UNIQUE INDEX IF NOT EXISTS idx_refresh_tokens_hash_user_active + ON refresh_tokens (token_hash, user_id) + WHERE is_revoked = FALSE; + +-- Column-level comments for documentation +COMMENT ON INDEX idx_refresh_tokens_id_user_active IS + 'Composite partial index for the findRefreshTokenById hot-path query ' + '(WHERE id = $1 AND user_id = $2 AND is_revoked = FALSE). ' + 'Added by migration auth_index.sql — issue #902.'; + +COMMENT ON INDEX idx_refresh_tokens_hash_user_active IS + 'Composite partial index for the findRefreshTokenByHash hot-path query ' + '(WHERE token_hash = $1 AND user_id = $2 AND is_revoked = FALSE). ' + 'Declared UNIQUE to enforce that an active token hash belongs to exactly ' + 'one user. Added by migration auth_index.sql — issue #902.'; diff --git a/src/routes/auth.test.ts b/src/routes/auth.test.ts new file mode 100644 index 00000000..1b6aa22e --- /dev/null +++ b/src/routes/auth.test.ts @@ -0,0 +1,451 @@ +/** + * @file src/routes/auth.test.ts + * @description Tests for the auth_index.sql migration files and the + * src/routes/auth.ts router factory (issue #902). + * + * Coverage matrix + * ─────────────── + * Migration SQL content (auth_index.sql) + * ✓ Both composite index names are present + * ✓ Both CREATE INDEX statements target the refresh_tokens table + * ✓ Both indexes include a WHERE is_revoked = FALSE partial predicate + * ✓ idx_refresh_tokens_id_user_active covers columns (id, user_id) + * ✓ idx_refresh_tokens_hash_user_active covers columns (token_hash, user_id) + * ✓ idx_refresh_tokens_hash_user_active is declared UNIQUE + * ✓ Both CREATE INDEX statements use IF NOT EXISTS (idempotent) + * ✓ Both indexes include a COMMENT ON INDEX documenting the issue + * + * Migration SQL content (auth_index.down.sql) + * ✓ Both DROP INDEX statements are present + * ✓ Both DROP INDEX statements use IF NOT EXISTS (safe to run multiple times) + * ✓ Rollback drops indexes in reverse order (hash first, then id) + * ✓ No CREATE statements appear in the down file + * + * Migration file pairing (regression guard for the existing down-coverage suite) + * ✓ auth_index.sql exists on disk + * ✓ auth_index.down.sql exists on disk + * ✓ Both files are non-empty + * + * Route surface — src/routes/auth.ts + * ✓ POST /api/auth/refresh returns 400 when refreshToken is absent + * ✓ POST /api/auth/refresh returns 400 when refreshToken is empty string + * ✓ POST /api/auth/refresh returns 400 with standard error envelope + * ✓ POST /api/auth/refresh calls authController.refreshToken on valid body + * ✓ POST /api/auth/revoke returns 400 when refreshToken is absent + * ✓ POST /api/auth/revoke calls authController.revokeToken on valid body + * ✓ POST /api/auth/revoke-all returns 401 without authentication + * ✓ POST /api/auth/revoke-all calls authController.revokeAllTokens when authenticated + * ✓ GET /api/auth/tokens returns 401 without authentication + * ✓ GET /api/auth/tokens calls authController.getTokenInfo when authenticated + * ✓ Every response carries an X-Request-Id header + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import fs from 'node:fs'; +import path from 'node:path'; +import request from 'supertest'; +import express from 'express'; + +import { createAuthRouter } from './auth.js'; +import { AuthController } from '../controllers/authController.js'; +import { errorHandler } from '../middleware/errorHandler.js'; +import { requestIdMiddleware } from '../middleware/requestId.js'; + +// --------------------------------------------------------------------------- +// Paths +// --------------------------------------------------------------------------- + +const migrationsDir = path.join(process.cwd(), 'migrations'); +const upFile = path.join(migrationsDir, 'auth_index.sql'); +const downFile = path.join(migrationsDir, 'auth_index.down.sql'); + +// --------------------------------------------------------------------------- +// Migration SQL content tests +// --------------------------------------------------------------------------- + +describe('auth_index.sql — forward migration', () => { + let sql: string; + + beforeAll(() => { + sql = fs.readFileSync(upFile, 'utf8'); + }); + + // File exists (regression guard for the down-coverage suite in + // migrate.runner.test.ts — that suite scans the real migrations dir) + it('file exists on disk', () => { + expect(fs.existsSync(upFile)).toBe(true); + }); + + it('is non-empty', () => { + expect(sql.trim().length).toBeGreaterThan(0); + }); + + // Index 1 — (id, user_id) partial + it('creates idx_refresh_tokens_id_user_active', () => { + expect(sql).toMatch(/idx_refresh_tokens_id_user_active/); + }); + + it('idx_refresh_tokens_id_user_active targets the refresh_tokens table', () => { + // The CREATE INDEX for index 1 must reference refresh_tokens + const block = sql.match( + /CREATE\s+INDEX\s+IF\s+NOT\s+EXISTS\s+idx_refresh_tokens_id_user_active[\s\S]*?WHERE[^\n]+/i, + ); + expect(block).not.toBeNull(); + expect(block![0]).toMatch(/ON\s+refresh_tokens/i); + }); + + it('idx_refresh_tokens_id_user_active includes column id', () => { + const block = sql.match( + /CREATE\s+INDEX\s+IF\s+NOT\s+EXISTS\s+idx_refresh_tokens_id_user_active\s+ON[^;]+/i, + ); + expect(block).not.toBeNull(); + expect(block![0]).toMatch(/\bid\b/); + }); + + it('idx_refresh_tokens_id_user_active includes column user_id', () => { + const block = sql.match( + /CREATE\s+INDEX\s+IF\s+NOT\s+EXISTS\s+idx_refresh_tokens_id_user_active\s+ON[^;]+/i, + ); + expect(block).not.toBeNull(); + expect(block![0]).toMatch(/user_id/); + }); + + it('idx_refresh_tokens_id_user_active has a partial predicate WHERE is_revoked = FALSE', () => { + const block = sql.match( + /CREATE\s+INDEX\s+IF\s+NOT\s+EXISTS\s+idx_refresh_tokens_id_user_active[\s\S]*?WHERE[^\n;]+/i, + ); + expect(block).not.toBeNull(); + expect(block![0]).toMatch(/WHERE\s+is_revoked\s*=\s*FALSE/i); + }); + + it('idx_refresh_tokens_id_user_active uses IF NOT EXISTS (idempotent)', () => { + expect(sql).toMatch( + /CREATE\s+INDEX\s+IF\s+NOT\s+EXISTS\s+idx_refresh_tokens_id_user_active/i, + ); + }); + + // Index 2 — (token_hash, user_id) partial UNIQUE + it('creates idx_refresh_tokens_hash_user_active', () => { + expect(sql).toMatch(/idx_refresh_tokens_hash_user_active/); + }); + + it('idx_refresh_tokens_hash_user_active is declared UNIQUE', () => { + expect(sql).toMatch( + /CREATE\s+UNIQUE\s+INDEX\s+IF\s+NOT\s+EXISTS\s+idx_refresh_tokens_hash_user_active/i, + ); + }); + + it('idx_refresh_tokens_hash_user_active targets the refresh_tokens table', () => { + const block = sql.match( + /CREATE\s+UNIQUE\s+INDEX\s+IF\s+NOT\s+EXISTS\s+idx_refresh_tokens_hash_user_active[\s\S]*?WHERE[^\n]+/i, + ); + expect(block).not.toBeNull(); + expect(block![0]).toMatch(/ON\s+refresh_tokens/i); + }); + + it('idx_refresh_tokens_hash_user_active includes column token_hash', () => { + const block = sql.match( + /CREATE\s+UNIQUE\s+INDEX\s+IF\s+NOT\s+EXISTS\s+idx_refresh_tokens_hash_user_active\s+ON[^;]+/i, + ); + expect(block).not.toBeNull(); + expect(block![0]).toMatch(/token_hash/); + }); + + it('idx_refresh_tokens_hash_user_active includes column user_id', () => { + const block = sql.match( + /CREATE\s+UNIQUE\s+INDEX\s+IF\s+NOT\s+EXISTS\s+idx_refresh_tokens_hash_user_active\s+ON[^;]+/i, + ); + expect(block).not.toBeNull(); + expect(block![0]).toMatch(/user_id/); + }); + + it('idx_refresh_tokens_hash_user_active has a partial predicate WHERE is_revoked = FALSE', () => { + const block = sql.match( + /CREATE\s+UNIQUE\s+INDEX\s+IF\s+NOT\s+EXISTS\s+idx_refresh_tokens_hash_user_active[\s\S]*?WHERE[^\n;]+/i, + ); + expect(block).not.toBeNull(); + expect(block![0]).toMatch(/WHERE\s+is_revoked\s*=\s*FALSE/i); + }); + + it('idx_refresh_tokens_hash_user_active uses IF NOT EXISTS (idempotent)', () => { + expect(sql).toMatch( + /CREATE\s+UNIQUE\s+INDEX\s+IF\s+NOT\s+EXISTS\s+idx_refresh_tokens_hash_user_active/i, + ); + }); + + // Documentation comments + it('includes a COMMENT ON INDEX for idx_refresh_tokens_id_user_active', () => { + expect(sql).toMatch(/COMMENT\s+ON\s+INDEX\s+idx_refresh_tokens_id_user_active/i); + }); + + it('includes a COMMENT ON INDEX for idx_refresh_tokens_hash_user_active', () => { + expect(sql).toMatch(/COMMENT\s+ON\s+INDEX\s+idx_refresh_tokens_hash_user_active/i); + }); + + // Issue traceability + it('references issue #902 in the file', () => { + expect(sql).toMatch(/#902/); + }); +}); + +describe('auth_index.down.sql — rollback migration', () => { + let sql: string; + + beforeAll(() => { + sql = fs.readFileSync(downFile, 'utf8'); + }); + + it('file exists on disk', () => { + expect(fs.existsSync(downFile)).toBe(true); + }); + + it('is non-empty', () => { + expect(sql.trim().length).toBeGreaterThan(0); + }); + + it('drops idx_refresh_tokens_id_user_active', () => { + expect(sql).toMatch(/DROP\s+INDEX\s+IF\s+EXISTS\s+idx_refresh_tokens_id_user_active/i); + }); + + it('drops idx_refresh_tokens_hash_user_active', () => { + expect(sql).toMatch(/DROP\s+INDEX\s+IF\s+EXISTS\s+idx_refresh_tokens_hash_user_active/i); + }); + + it('uses IF EXISTS on both DROP statements (safe to run multiple times)', () => { + const drops = sql.match(/DROP\s+INDEX\s+IF\s+EXISTS/gi) ?? []; + expect(drops.length).toBe(2); + }); + + it('drops hash index before id index (reverse order of creation)', () => { + const hashPos = sql.indexOf('idx_refresh_tokens_hash_user_active'); + const idPos = sql.indexOf('idx_refresh_tokens_id_user_active'); + expect(hashPos).toBeGreaterThan(-1); + expect(idPos).toBeGreaterThan(-1); + expect(hashPos).toBeLessThan(idPos); + }); + + it('contains no CREATE statements', () => { + // A down migration must not create anything + expect(sql).not.toMatch(/\bCREATE\b/i); + }); + + it('references issue #902 in the file', () => { + expect(sql).toMatch(/#902/); + }); +}); + +describe('auth_index migration — file pairing', () => { + it('auth_index.sql and auth_index.down.sql both exist', () => { + expect(fs.existsSync(upFile)).toBe(true); + expect(fs.existsSync(downFile)).toBe(true); + }); + + it('the index names are consistent between up and down files', () => { + const up = fs.readFileSync(upFile, 'utf8'); + const down = fs.readFileSync(downFile, 'utf8'); + + const indexNamesUp = [ + 'idx_refresh_tokens_id_user_active', + 'idx_refresh_tokens_hash_user_active', + ]; + + for (const name of indexNamesUp) { + expect(up).toMatch(name); + expect(down).toMatch(name); + } + }); +}); + +// --------------------------------------------------------------------------- +// Route surface tests — src/routes/auth.ts +// --------------------------------------------------------------------------- + +/** Stub AuthController — every method is a jest.fn() that sends a canned 200. */ +function makeController(): jest.Mocked { + return { + refreshToken: jest.fn((_req, res) => res.json({ accessToken: 'tok', tokenType: 'Bearer' })), + revokeToken: jest.fn((_req, res) => res.json({ message: 'ok' })), + revokeAllTokens:jest.fn((_req, res) => res.json({ message: 'ok' })), + getTokenInfo: jest.fn((_req, res) => res.json({ activeRefreshTokens: 1, maxAllowedTokens: 5 })), + } as any; +} + +function buildApp(controller: jest.Mocked) { + const app = express(); + app.use(requestIdMiddleware); + app.use(express.json()); + app.use('/api/auth', createAuthRouter({ authController: controller })); + app.use(errorHandler); + return app; +} + +describe('POST /api/auth/refresh — input validation', () => { + let ctrl: jest.Mocked; + let app: express.Express; + + beforeEach(() => { + ctrl = makeController(); + app = buildApp(ctrl); + }); + + it('returns 400 when refreshToken is missing', async () => { + const res = await request(app).post('/api/auth/refresh').send({}); + expect(res.status).toBe(400); + // Standard error envelope + expect(res.body).toHaveProperty('code'); + expect(res.body).toHaveProperty('message'); + expect(res.body).toHaveProperty('requestId'); + // Zod validation details array + expect(Array.isArray(res.body.details)).toBe(true); + expect(res.body.details.length).toBeGreaterThan(0); + }); + + it('returns 400 when refreshToken is an empty string', async () => { + const res = await request(app).post('/api/auth/refresh').send({ refreshToken: '' }); + expect(res.status).toBe(400); + expect(Array.isArray(res.body.details)).toBe(true); + }); + + it('does not invoke the controller when validation fails', async () => { + await request(app).post('/api/auth/refresh').send({}); + expect(ctrl.refreshToken).not.toHaveBeenCalled(); + }); + + it('invokes authController.refreshToken on a valid body', async () => { + const res = await request(app) + .post('/api/auth/refresh') + .send({ refreshToken: 'some-token' }); + expect(ctrl.refreshToken).toHaveBeenCalledTimes(1); + expect(res.status).toBe(200); + }); +}); + +describe('POST /api/auth/revoke — input validation', () => { + let ctrl: jest.Mocked; + let app: express.Express; + + beforeEach(() => { + ctrl = makeController(); + app = buildApp(ctrl); + }); + + it('returns 400 when refreshToken is missing', async () => { + const res = await request(app).post('/api/auth/revoke').send({}); + expect(res.status).toBe(400); + expect(res.body).toHaveProperty('code'); + expect(res.body).toHaveProperty('requestId'); + expect(Array.isArray(res.body.details)).toBe(true); + }); + + it('does not invoke the controller when validation fails', async () => { + await request(app).post('/api/auth/revoke').send({}); + expect(ctrl.revokeToken).not.toHaveBeenCalled(); + }); + + it('invokes authController.revokeToken on a valid body', async () => { + const res = await request(app) + .post('/api/auth/revoke') + .send({ refreshToken: 'some-token' }); + expect(ctrl.revokeToken).toHaveBeenCalledTimes(1); + expect(res.status).toBe(200); + }); +}); + +describe('POST /api/auth/revoke-all — authentication guard', () => { + let ctrl: jest.Mocked; + let app: express.Express; + + beforeEach(() => { + ctrl = makeController(); + app = buildApp(ctrl); + }); + + it('returns 401 when no credentials are supplied', async () => { + const res = await request(app).post('/api/auth/revoke-all').send({}); + expect(res.status).toBe(401); + expect(res.body).toHaveProperty('code'); + expect(res.body).toHaveProperty('requestId'); + }); + + it('does not invoke the controller without authentication', async () => { + await request(app).post('/api/auth/revoke-all').send({}); + expect(ctrl.revokeAllTokens).not.toHaveBeenCalled(); + }); + + it('invokes authController.revokeAllTokens when x-user-id is provided', async () => { + const res = await request(app) + .post('/api/auth/revoke-all') + .set('x-user-id', 'user-123') + .send({}); + expect(ctrl.revokeAllTokens).toHaveBeenCalledTimes(1); + expect(res.status).toBe(200); + }); +}); + +describe('GET /api/auth/tokens — authentication guard', () => { + let ctrl: jest.Mocked; + let app: express.Express; + + beforeEach(() => { + ctrl = makeController(); + app = buildApp(ctrl); + }); + + it('returns 401 when no credentials are supplied', async () => { + const res = await request(app).get('/api/auth/tokens'); + expect(res.status).toBe(401); + expect(res.body).toHaveProperty('code'); + expect(res.body).toHaveProperty('requestId'); + }); + + it('does not invoke the controller without authentication', async () => { + await request(app).get('/api/auth/tokens'); + expect(ctrl.getTokenInfo).not.toHaveBeenCalled(); + }); + + it('invokes authController.getTokenInfo when x-user-id is provided', async () => { + const res = await request(app) + .get('/api/auth/tokens') + .set('x-user-id', 'user-123'); + expect(ctrl.getTokenInfo).toHaveBeenCalledTimes(1); + expect(res.status).toBe(200); + }); +}); + +describe('X-Request-Id propagation', () => { + let app: express.Express; + + beforeEach(() => { + app = buildApp(makeController()); + }); + + it('every 200 response carries an X-Request-Id header', async () => { + const res = await request(app) + .post('/api/auth/refresh') + .send({ refreshToken: 'any' }); + expect(res.headers['x-request-id']).toBeDefined(); + }); + + it('every 400 response carries an X-Request-Id header', async () => { + const res = await request(app).post('/api/auth/refresh').send({}); + expect(res.headers['x-request-id']).toBeDefined(); + // requestId in the error body must match the header value + expect(res.body.requestId).toBe(res.headers['x-request-id']); + }); + + it('echoes a caller-supplied X-Request-Id', async () => { + const correlationId = 'test-trace-abc-123'; + const res = await request(app) + .post('/api/auth/refresh') + .set('x-request-id', correlationId) + .send({ refreshToken: 'any' }); + expect(res.headers['x-request-id']).toBe(correlationId); + }); + + it('every 401 response carries an X-Request-Id header', async () => { + const res = await request(app).post('/api/auth/revoke-all').send({}); + expect(res.headers['x-request-id']).toBeDefined(); + expect(res.body.requestId).toBe(res.headers['x-request-id']); + }); +}); diff --git a/src/routes/auth.ts b/src/routes/auth.ts new file mode 100644 index 00000000..6953ecd6 --- /dev/null +++ b/src/routes/auth.ts @@ -0,0 +1,159 @@ +/** + * @file src/routes/auth.ts + * @description Express router factory for the /api/auth endpoint group. + * + * This file is the production mount point for all auth-related routes. It + * sits alongside `authRoutes.ts` (which exposes the same controller under the + * legacy `/auth` prefix used in tests and the internal auth service) and adds + * the public-facing `/api/auth` prefix used by external clients. + * + * ─── DB index relationship (issue #902) ────────────────────────────────────── + * Every request to POST /api/auth/refresh triggers two hot-path queries inside + * DatabaseRefreshTokenRepository: + * + * findRefreshTokenById: + * SELECT … FROM refresh_tokens WHERE id = $1 AND user_id = $2 + * + * findRefreshTokenByHash: + * SELECT … FROM refresh_tokens WHERE token_hash = $1 AND user_id = $2 + * + * The migration `migrations/auth_index.sql` (issue #902) adds two composite + * partial indexes that make both queries index-only scans against active tokens: + * + * idx_refresh_tokens_id_user_active ON (id, user_id) WHERE is_revoked = FALSE + * idx_refresh_tokens_hash_user_active ON (token_hash, user_id) WHERE is_revoked = FALSE [UNIQUE] + * + * That migration must be applied before this route serves production traffic. + * ───────────────────────────────────────────────────────────────────────────── + * + * Route surface (mounted by the caller at /api/auth): + * + * POST /api/auth/refresh — exchange refresh token → new access token + * Uses: findRefreshTokenById (hot path, indexed) + * POST /api/auth/revoke — revoke a single refresh token + * Uses: findRefreshTokenById (hot path, indexed) + * POST /api/auth/revoke-all — revoke all tokens for the authenticated user + * Requires: Bearer / x-user-id authentication + * GET /api/auth/tokens — return active token count for the user + * Requires: Bearer / x-user-id authentication + * + * All endpoints use the standard error envelope: + * { code: string, message: string, requestId: string } + * + * Correlation IDs are propagated via the requestIdMiddleware applied globally + * in app.ts — every response carries an X-Request-Id header. + * + * Input validation: + * Routes that accept a refreshToken use bodyValidator(refreshTokenSchema) + * (Zod) before the controller is invoked, so the controller never receives + * an empty or missing token string. + */ + +import { Router } from 'express'; +import { AuthController } from '../controllers/authController.js'; +import { requireAuth } from '../middleware/requireAuth.js'; +import { bodyValidator } from '../middleware/validate.js'; +import { z } from 'zod'; + +/** + * Zod schema for request bodies that carry a refresh token. + * Applied before the controller on POST /api/auth/refresh and + * POST /api/auth/revoke to enforce a non-empty string at the boundary. + */ +const refreshTokenSchema = z.object({ + refreshToken: z.string().min(1, 'refreshToken is required'), +}); + +export interface CreateAuthRouterOptions { + /** + * Controller that handles all auth business logic (token refresh, revocation, + * token-info queries). Pass the same AuthController instance used for the + * /auth routes to avoid creating duplicate service/repository pairs. + */ + authController: AuthController; +} + +/** + * Build the /api/auth router. + * + * Mount the returned router at '/api/auth' in the Express application: + * + * @example + * ```ts + * import { createAuthRouter } from './routes/auth.js'; + * + * app.use('/api/auth', createAuthRouter({ authController })); + * ``` + * + * The DB indexes required by the hot-path queries on this router are + * created by `migrations/auth_index.sql` (issue #902). Ensure that + * migration has been applied before enabling this router in production. + */ +export function createAuthRouter({ authController }: CreateAuthRouterOptions): Router { + const router = Router(); + + // ─── POST /api/auth/refresh ─────────────────────────────────────────────── + // Exchange a valid refresh token for a new short-lived access token. + // + // Hot-path DB query: findRefreshTokenById + // WHERE id = $1 AND user_id = $2 + // Covered by: idx_refresh_tokens_id_user_active (partial, is_revoked=FALSE) + // + // If a revoked token is detected the entire token family is invalidated + // atomically (refresh token rotation reuse-detection). + // + // Request { refreshToken: string } + // Response { accessToken: string, tokenType: "Bearer" } + router.post( + '/refresh', + bodyValidator(refreshTokenSchema), + (req, res, next) => authController.refreshToken(req, res, next), + ); + + // ─── POST /api/auth/revoke ──────────────────────────────────────────────── + // Revoke a single refresh token so it can no longer be used. + // + // Hot-path DB query: findRefreshTokenById + // WHERE id = $1 AND user_id = $2 + // Covered by: idx_refresh_tokens_id_user_active (partial, is_revoked=FALSE) + // + // Returns success even when the token is not found to prevent enumeration. + // + // Request { refreshToken: string } + // Response { message: string } + router.post( + '/revoke', + bodyValidator(refreshTokenSchema), + (req, res, next) => authController.revokeToken(req, res, next), + ); + + // ─── POST /api/auth/revoke-all ──────────────────────────────────────────── + // Revoke every refresh token belonging to the authenticated user. + // Useful on password change or forced sign-out from all devices. + // + // DB query: revokeAllUserTokens — full user-id scan, not latency-sensitive. + // + // Requires: Bearer token or x-user-id header (via requireAuth middleware). + // Response: { message: string } + router.post( + '/revoke-all', + requireAuth, + (req, res, next) => authController.revokeAllTokens(req, res, next), + ); + + // ─── GET /api/auth/tokens ───────────────────────────────────────────────── + // Return the count of active refresh tokens for the authenticated user. + // + // DB query: countActiveTokens — aggregate over user_id, uses the existing + // idx_refresh_tokens_user_id index (acceptable; not in the hot path). + // + // Requires: Bearer token or x-user-id header (via requireAuth middleware). + // Response: { activeRefreshTokens: number, maxAllowedTokens: number } + router.get( + '/tokens', + requireAuth, + (req, res, next) => authController.getTokenInfo(req, res, next), + ); + + return router; +}