diff --git a/app/api/auth/impersonate/route.ts b/app/api/auth/impersonate/route.ts index bef1b35f4..6690be6ff 100644 --- a/app/api/auth/impersonate/route.ts +++ b/app/api/auth/impersonate/route.ts @@ -7,11 +7,8 @@ import { getCookieOptions } from '@/lib/oauth/cookie-config'; import { normalizeJmapServerUrl } from '@/lib/auth/verify-jmap-auth'; import { setStalwartAuthContextInStore } from '@/lib/stalwart/auth-context'; import { recordLogin } from '@/lib/telemetry/login-tracker'; -import { - ImpersonationJwtError, - impersonationReplayCache, - verifyImpersonationJwt, -} from '@/lib/impersonation/jwt'; +import { ImpersonationJwtError, verifyImpersonationJwt } from '@/lib/impersonation/jwt'; +import { consumeImpersonationJti } from '@/lib/impersonation/replay-store'; import { readImpersonationConfig, resolveImpersonationServerUrl, @@ -81,7 +78,7 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } - if (!impersonationReplayCache.consume(claims.jti, claims.exp)) { + if (!(await consumeImpersonationJti(claims.jti, claims.exp))) { logger.warn('Impersonation JWT replay rejected', { jti: claims.jti }); return NextResponse.json({ error: 'Token already used' }, { status: 401 }); } diff --git a/lib/__tests__/impersonation-replay-store.test.ts b/lib/__tests__/impersonation-replay-store.test.ts new file mode 100644 index 000000000..b1e3464d6 --- /dev/null +++ b/lib/__tests__/impersonation-replay-store.test.ts @@ -0,0 +1,58 @@ +// @vitest-environment node +import { mkdtempSync, readdirSync, rmSync, utimesSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@/lib/logger', () => ({ + logger: { warn: vi.fn(), info: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + +import { IMPERSONATION_REPLAY_WINDOW_SEC, impersonationReplayCache } from '@/lib/impersonation/jwt'; +import { consumeImpersonationJti } from '@/lib/impersonation/replay-store'; + +// The jti cache lived only in process memory: after a restart, or on another +// replica, a captured handoff link could be redeemed again. + +let stateDir: string; +const previous = process.env.ADMIN_STATE_DIR; + +beforeEach(() => { + stateDir = mkdtempSync(path.join(tmpdir(), 'bw-jti-')); + process.env.ADMIN_STATE_DIR = stateDir; + impersonationReplayCache.clear(); +}); + +afterEach(() => { + if (previous === undefined) delete process.env.ADMIN_STATE_DIR; + else process.env.ADMIN_STATE_DIR = previous; + rmSync(stateDir, { recursive: true, force: true }); +}); + +describe('consumeImpersonationJti', () => { + const now = Date.now(); + const exp = Math.floor(now / 1000) + 120; + + it('refuses a jti the second time', async () => { + expect(await consumeImpersonationJti('jti-1', exp, now)).toBe(true); + expect(await consumeImpersonationJti('jti-1', exp, now)).toBe(false); + }); + + it('still refuses it after the in-process cache is gone (restart, other replica)', async () => { + expect(await consumeImpersonationJti('jti-1', exp, now)).toBe(true); + impersonationReplayCache.clear(); + expect(await consumeImpersonationJti('jti-1', exp, now)).toBe(false); + }); + + it('prunes markers once their token can no longer verify', async () => { + await consumeImpersonationJti('jti-old', exp, now); + const dir = path.join(stateDir, 'impersonation-jti'); + const [marker] = readdirSync(dir); + const old = (now - (IMPERSONATION_REPLAY_WINDOW_SEC + 5) * 1000) / 1000; + utimesSync(path.join(dir, marker), old, old); + + await consumeImpersonationJti('jti-new', exp, now); + expect(readdirSync(dir)).toHaveLength(1); + expect(readdirSync(dir)).not.toContain(marker); + }); +}); diff --git a/lib/__tests__/impersonation-session.test.ts b/lib/__tests__/impersonation-session.test.ts index 1920f148d..8202b5aff 100644 --- a/lib/__tests__/impersonation-session.test.ts +++ b/lib/__tests__/impersonation-session.test.ts @@ -1,6 +1,9 @@ // @vitest-environment node import { createHmac, randomUUID } from 'node:crypto'; -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { NextRequest } from 'next/server'; vi.mock('@/lib/auth/session-secret', () => ({ @@ -78,7 +81,12 @@ async function readSession() { } describe('impersonation handoff', () => { + // Used jtis are recorded in the state directory. + const stateDir = mkdtempSync(path.join(tmpdir(), 'bw-imp-')); + afterAll(() => rmSync(stateDir, { recursive: true, force: true })); + beforeAll(() => { + process.env.ADMIN_STATE_DIR = stateDir; process.env.BULWARK_JWT_AUTH_SECRET = SECRET; process.env.BULWARK_STALWART_MASTER_USER = 'master@example.org'; process.env.BULWARK_STALWART_MASTER_PASSWORD = MASTER_PASSWORD; diff --git a/lib/impersonation/jwt.ts b/lib/impersonation/jwt.ts index 4145ce138..d7d3edeff 100644 --- a/lib/impersonation/jwt.ts +++ b/lib/impersonation/jwt.ts @@ -26,6 +26,13 @@ const MAX_TOKEN_LIFETIME_SEC = 300; const CLOCK_SKEW_SEC = 60; const MIN_SECRET_LENGTH = 32; +/** + * How long after it is first redeemed a token can still verify: `iat` may + * run CLOCK_SKEW_SEC ahead, `exp` is at most MAX_TOKEN_LIFETIME_SEC after + * `iat`, and `exp` is honoured for another CLOCK_SKEW_SEC. + */ +export const IMPERSONATION_REPLAY_WINDOW_SEC = MAX_TOKEN_LIFETIME_SEC + 2 * CLOCK_SKEW_SEC; + function base64UrlDecode(input: string): Buffer { const pad = input.length % 4 === 0 ? 0 : 4 - (input.length % 4); const b64 = input.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat(pad); @@ -141,12 +148,10 @@ export function verifyImpersonationJwt( // ─── Replay protection ────────────────────────────────────────── // In-memory LRU keyed by jti. Entries expire automatically once their -// underlying JWT could no longer be replayed (exp + skew). On a multi-pod -// deployment each pod has its own cache; that's acceptable because a token -// stolen mid-flight could only be replayed against the pod that already -// consumed it (and that pod will reject it). For stronger guarantees, -// platforms can issue per-pod-routed tokens or front Bulwark with a -// single-leader load balancer for the impersonate route. +// underlying JWT could no longer be replayed (exp + skew). This is the fast +// path; consumeImpersonationJti() in replay-store.ts backs it with marker +// files in the state directory so a restart or another replica sharing the +// state volume still refuses a used token. const REPLAY_CACHE_MAX = 4096; diff --git a/lib/impersonation/replay-store.ts b/lib/impersonation/replay-store.ts new file mode 100644 index 000000000..6f3cba8ac --- /dev/null +++ b/lib/impersonation/replay-store.ts @@ -0,0 +1,55 @@ +import { createHash } from 'node:crypto'; +import { mkdir, open, readdir, stat, unlink } from 'node:fs/promises'; +import path from 'node:path'; +import { getStatePath } from '@/lib/admin/paths'; +import { logger } from '@/lib/logger'; +import { IMPERSONATION_REPLAY_WINDOW_SEC, impersonationReplayCache } from './jwt'; + +const STORE_DIR = 'impersonation-jti'; + +async function pruneExpired(dir: string, nowMs: number): Promise { + for (const name of await readdir(dir)) { + const file = path.join(dir, name); + try { + const { mtimeMs } = await stat(file); + if (nowMs - mtimeMs > IMPERSONATION_REPLAY_WINDOW_SEC * 1000) await unlink(file); + } catch { + // Another process pruned it first. + } + } +} + +/** + * Record an impersonation token's jti as used; false when it was used before. + * + * The in-process cache alone forgot every jti on restart and knew nothing of + * other replicas, so a captured handoff link could be redeemed again within + * its lifetime. Each jti also gets a marker file in the state directory, + * created with O_EXCL, which survives restarts and is shared by every + * replica that mounts the same state volume. Markers are pruned once the + * token they stand for can no longer verify. + */ +export async function consumeImpersonationJti( + jti: string, + exp: number, + nowMs: number = Date.now(), +): Promise { + if (!impersonationReplayCache.consume(jti, exp, Math.floor(nowMs / 1000))) return false; + + const dir = getStatePath(STORE_DIR); + try { + await mkdir(dir, { recursive: true }); + await pruneExpired(dir, nowMs); + const marker = await open(path.join(dir, createHash('sha256').update(jti).digest('hex')), 'wx'); + await marker.close(); + return true; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'EEXIST') return false; + // The state directory is documented as always writable; if it is not, + // keep impersonation working on the in-process cache. + logger.warn('Impersonation replay store unavailable, using the in-process cache only', { + error: err instanceof Error ? err.message : 'Unknown', + }); + return true; + } +}