fix: refuse a used impersonation link after a restart or on another replica

Used impersonation token ids were only remembered in process memory, so
a captured handoff link could be redeemed a second time within its five
minute lifetime once the process restarted, or on another replica. Each
used id now also leaves a marker file in the state directory, created
exclusively, which every replica sharing the state volume sees; markers
are pruned once the token could no longer verify.

The absolute session limit comes from the eight-hour app password that
Stalwart itself expires.
This commit is contained in:
Linus Rath
2026-09-26 16:34:58 +02:00
parent 034ee27057
commit 5622eca36b
5 changed files with 136 additions and 13 deletions
+3 -6
View File
@@ -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 });
}
@@ -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);
});
});
+9 -1
View File
@@ -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;
+11 -6
View File
@@ -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;
+55
View File
@@ -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<void> {
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<boolean> {
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;
}
}