From 566fd8c6b364131a409973845d407864f5ccbd28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 20 Oct 2026 13:17:41 +0200 Subject: [PATCH] feat(jobs): clean expired sessions, tokens and challenges nightly Sessions, refresh tokens, access tokens, trusted devices and the second-factor and passkey challenges were never removed after they expired, so the sign-in tables were mostly dead rows. auth-ledger-cleanup deletes a session, token or trusted device 30 days after it expired, which keeps every row the login throttle's 30-day known-address check and refresh reuse detection can still read; a revoked token without an expiry stays listed. Challenges go a day after expiry. Security activity reads the audit log and loses nothing. --- src/lib/jobs/auth-ledger-cleanup.ts | 163 +++++++++++++++ src/lib/jobs/job-outcome.ts | 5 + src/lib/jobs/queue-runtime.ts | 1 + src/lib/jobs/reminder/register-maintenance.ts | 17 ++ src/lib/jobs/subsystem-surface.ts | 1 + tests/integration/auth-ledger-cleanup.test.ts | 185 ++++++++++++++++++ 6 files changed, 372 insertions(+) create mode 100644 src/lib/jobs/auth-ledger-cleanup.ts create mode 100644 tests/integration/auth-ledger-cleanup.test.ts diff --git a/src/lib/jobs/auth-ledger-cleanup.ts b/src/lib/jobs/auth-ledger-cleanup.ts new file mode 100644 index 000000000..063ea6028 --- /dev/null +++ b/src/lib/jobs/auth-ledger-cleanup.ts @@ -0,0 +1,163 @@ +/** + * Nightly cleanup of the dead rows of the sign-in ledgers. + * + * Every sign-in writes a session, every native sign-in and refresh writes a + * refresh token and an access token, every second-factor prompt and passkey + * ceremony writes a challenge, and "remember this device" writes a trusted + * device. Nothing removed them once they expired, so the tables were mostly + * dead rows: on a long-running instance nine in ten sessions and access + * tokens had expired. + * + * What still reads a dead row decides how long it stays: + * + * - Sessions and refresh tokens: the login throttle recognises a source by a + * session or refresh token created from the same address in the last 30 + * days (`KNOWN_ADDRESS_WINDOW_MS`), and by any refresh token of the device. + * Reuse detection reads a consumed refresh token until it expires (an + * expired one is refused before reuse is considered). So a row goes 30 days + * after it expired: it was created before that, so the address window + * cannot reach it, and a device that has not signed in for that long is a + * new source again. A revoked token waits for its expiry like any other. + * - Access tokens (`api_tokens`): the token list shows revoked and expired + * tokens so their owner can see what ended. One goes 30 days after it + * expired; a revoked token without an expiry stays, since nothing records + * when it was revoked. Connector tokens have their own sweep + * (`mcp-token-cleanup`). + * - Second-factor and passkey challenges: nothing reads one past its expiry. + * A day of slack keeps a ceremony in flight at midnight out of the way. + * - Trusted devices: an expired one no longer skips the second factor and is + * not listed; it goes 30 days after it expired. + * + * Security activity is read from the audit log, never from these tables, so + * it loses nothing. Each table is deleted in bounded batches. + */ +import type { Job } from "pg-boss"; + +import type { PrismaClient } from "@/generated/prisma/client"; +import { jobDone, type JobOutcome } from "@/lib/jobs/job-outcome"; +import { PURGE_BATCH_SIZE, PURGE_MAX_BATCHES } from "@/lib/jobs/purge-batch"; +import { getWorkerPrisma } from "@/lib/jobs/reminder/shared"; +import { withBackgroundEvent } from "@/lib/logging/background"; + +export const AUTH_LEDGER_CLEANUP_QUEUE = "auth-ledger-cleanup"; +/** Daily at 03:35 Europe/Berlin, inside the 03:xx maintenance window. */ +export const AUTH_LEDGER_CLEANUP_CRON = "35 3 * * *"; + +const DAY_MS = 86_400_000; + +/** Days an expired session, token or trusted device is kept. */ +export const AUTH_LEDGER_EXPIRED_GRACE_DAYS = 30; +/** Days an expired challenge is kept. */ +export const AUTH_CHALLENGE_GRACE_DAYS = 1; + +export interface AuthLedgerCleanupPayload { + triggeredAt?: string; +} + +/** + * The tables and the column each one expires by. Literals, spliced into the + * delete; `api_tokens.expires_at` is nullable and a null never matches. + */ +const LEDGERS = [ + { + key: "sessions", + table: "sessions", + graceDays: AUTH_LEDGER_EXPIRED_GRACE_DAYS, + }, + { + key: "refresh_tokens", + table: "refresh_tokens", + graceDays: AUTH_LEDGER_EXPIRED_GRACE_DAYS, + }, + { + key: "api_tokens", + table: "api_tokens", + graceDays: AUTH_LEDGER_EXPIRED_GRACE_DAYS, + }, + { + key: "trusted_devices", + table: "trusted_devices", + graceDays: AUTH_LEDGER_EXPIRED_GRACE_DAYS, + }, + { + key: "mfa_challenges", + table: "mfa_challenges", + graceDays: AUTH_CHALLENGE_GRACE_DAYS, + }, + { + key: "auth_challenges", + table: "auth_challenges", + graceDays: AUTH_CHALLENGE_GRACE_DAYS, + }, +] as const; + +export type AuthLedger = (typeof LEDGERS)[number]["key"]; + +export interface AuthLedgerCleanupResult { + deleted: Record; + /** False when a table stopped at the batch cap with rows left. */ + drained: boolean; +} + +export async function cleanupAuthLedgers( + prisma: PrismaClient, + now: Date = new Date(), + { batchSize = PURGE_BATCH_SIZE, maxBatches = PURGE_MAX_BATCHES } = {}, +): Promise { + const deleted = {} as Record; + let drained = true; + for (const ledger of LEDGERS) { + const cutoff = new Date(now.getTime() - ledger.graceDays * DAY_MS); + let total = 0; + let finished = false; + for (let batch = 0; batch < maxBatches; batch++) { + // `ledger.table` is one of the literals above, never input. + const removed = await prisma.$executeRawUnsafe( + `DELETE FROM "${ledger.table}" WHERE "id" IN ( + SELECT "id" FROM "${ledger.table}" + WHERE "expires_at" < $1 LIMIT $2)`, + cutoff, + batchSize, + ); + total += removed; + if (removed < batchSize) { + finished = true; + break; + } + } + deleted[ledger.key] = total; + if (!finished) drained = false; + } + return { deleted, drained }; +} + +export async function handleAuthLedgerCleanup( + jobs: Job[], +): Promise { + void jobs; + return withBackgroundEvent("job.auth_ledger_cleanup", async (evt) => { + try { + const { deleted, drained } = await cleanupAuthLedgers(getWorkerPrisma()); + for (const [key, count] of Object.entries(deleted)) { + evt.addMeta(`auth_ledger_cleanup_${key}`, count); + } + evt.addMeta("auth_ledger_cleanup_drained", drained); + if (!drained) { + evt.addWarning( + "auth-ledger-cleanup stopped at the batch cap; a backlog remains for the next run", + ); + } + return jobDone({ + sessions_deleted: deleted.sessions, + refresh_tokens_deleted: deleted.refresh_tokens, + access_tokens_deleted: deleted.api_tokens, + trusted_devices_deleted: deleted.trusted_devices, + challenges_deleted: deleted.mfa_challenges + deleted.auth_challenges, + drained, + }); + } catch (err) { + evt.addWarning(`auth-ledger-cleanup failed: ${err}`); + throw err; + } + }); +} diff --git a/src/lib/jobs/job-outcome.ts b/src/lib/jobs/job-outcome.ts index 672cc8ba3..d75c1b29f 100644 --- a/src/lib/jobs/job-outcome.ts +++ b/src/lib/jobs/job-outcome.ts @@ -64,6 +64,8 @@ export const JOB_FACT_ALLOWLIST: ReadonlySet = new Set([ "budget_blocked", "cached", "candidates_scanned", + // The nightly sign-in ledger cleanup (`auth-ledger-cleanup`). + "challenges_deleted", "connect_tickets_deleted", "connections_deleted", "considered", @@ -208,6 +210,7 @@ export const JOB_FACT_ALLOWLIST: ReadonlySet = new Set([ "records_oversized", "records_read", "refreshed", + "refresh_tokens_deleted", "refused", "reminders_due", "removed", @@ -251,6 +254,7 @@ export const JOB_FACT_ALLOWLIST: ReadonlySet = new Set([ "scheduled", "sent", "served", + "sessions_deleted", "side_effects_migrated", // v1.42.4 — integration watchdog: sources newly flagged as silent. "silences_flagged", @@ -277,6 +281,7 @@ export const JOB_FACT_ALLOWLIST: ReadonlySet = new Set([ "tls_pin_monitor_known_count", "tls_pin_monitor_outcome", "total", + "trusted_devices_deleted", "unchanged", "updated", "users", diff --git a/src/lib/jobs/queue-runtime.ts b/src/lib/jobs/queue-runtime.ts index 56a12bda8..3c3d007ff 100644 --- a/src/lib/jobs/queue-runtime.ts +++ b/src/lib/jobs/queue-runtime.ts @@ -316,6 +316,7 @@ export const QUEUE_RUNTIME: Readonly> = { "soft-delete-purge": short( "At most 40 batches of 5 000 rows per table, over a few small tables.", ), + "auth-ledger-cleanup": short("At most 40 batches of 5 000 rows per table."), "document-summary-reaper": short("One updateMany."), "cycle-prediction-refresh": short( "Arithmetic per eligible account, no provider call.", diff --git a/src/lib/jobs/reminder/register-maintenance.ts b/src/lib/jobs/reminder/register-maintenance.ts index 3b2215244..4b32a0724 100644 --- a/src/lib/jobs/reminder/register-maintenance.ts +++ b/src/lib/jobs/reminder/register-maintenance.ts @@ -296,6 +296,12 @@ import { handleSoftDeletePurge, type SoftDeletePurgePayload, } from "@/lib/jobs/soft-delete-purge"; +import { + AUTH_LEDGER_CLEANUP_QUEUE, + AUTH_LEDGER_CLEANUP_CRON, + handleAuthLedgerCleanup, + type AuthLedgerCleanupPayload, +} from "@/lib/jobs/auth-ledger-cleanup"; import { DOCUMENT_SUMMARY_REAPER_QUEUE, DOCUMENT_SUMMARY_REAPER_CRON, @@ -589,6 +595,9 @@ const allQueues = [ // Nightly purge of expired tombstones for every soft-delete model the // retention register hands to it (`soft-delete-retention.ts`). SOFT_DELETE_PURGE_QUEUE, + // Nightly removal of expired sessions, tokens, challenges and trusted + // devices. + AUTH_LEDGER_CLEANUP_QUEUE, // Document vault — hourly reaper for summary rows stuck on PENDING (a // job dead past its retry budget). Without this entry the schedule // silently no-ops and a dead "wird generiert" claim never converges to @@ -791,6 +800,8 @@ const schedules: ScheduleEntry[] = [ // Daily 03:42 Europe/Berlin, after the sync-domain tombstone cleanup. The // next night is the retry: the purge picks up whatever a failed run left. [SOFT_DELETE_PURGE_QUEUE, SOFT_DELETE_PURGE_CRON, cronIsTheRetry], + // Daily 03:35 Europe/Berlin. Same: the next night deletes what is left. + [AUTH_LEDGER_CLEANUP_QUEUE, AUTH_LEDGER_CLEANUP_CRON, cronIsTheRetry], // Document vault — hourly heal of summary rows stuck on PENDING. The next // tick is the retry: the updateMany is idempotent and the TTL predicate // re-selects anything a failed tick left behind. @@ -1160,6 +1171,12 @@ export async function registerMaintenanceQueues( { localConcurrency: 1 }, handleSoftDeletePurge, ); + await createAndWork( + boss, + AUTH_LEDGER_CLEANUP_QUEUE, + { localConcurrency: 1 }, + handleAuthLedgerCleanup, + ); // v1.40 — background document AI runs. Each job is one run, under the lock // `lockedPass` takes per run id, so a redelivery never reads beside the // first delivery; the claim inside is conditional as well. diff --git a/src/lib/jobs/subsystem-surface.ts b/src/lib/jobs/subsystem-surface.ts index ebc49012e..39fa03847 100644 --- a/src/lib/jobs/subsystem-surface.ts +++ b/src/lib/jobs/subsystem-surface.ts @@ -128,6 +128,7 @@ const subsystemSurface = { "achievement-unlock-sweep": { audience: "system" }, "measurement-tombstone-cleanup": { audience: "system" }, "soft-delete-purge": { audience: "system" }, + "auth-ledger-cleanup": { audience: "system" }, "coach-message-cleanup": { audience: "system" }, "workout-insight-claim-cleanup": { audience: "system" }, "note-encryption-backfill": { audience: "system" }, diff --git a/tests/integration/auth-ledger-cleanup.test.ts b/tests/integration/auth-ledger-cleanup.test.ts new file mode 100644 index 000000000..8de6fe9bd --- /dev/null +++ b/tests/integration/auth-ledger-cleanup.test.ts @@ -0,0 +1,185 @@ +/** + * The nightly cleanup of the sign-in ledgers (`auth-ledger-cleanup`). + * + * A row goes once it has been expired for longer than its grace (30 days for + * sessions, tokens and trusted devices, a day for challenges). A live row, a + * recently expired one, a revoked token that has not expired, and a revoked + * token without an expiry all stay. The delete is batched and says when it + * stopped with rows left. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { cleanupAuthLedgers } from "@/lib/jobs/auth-ledger-cleanup"; + +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const USER = "auth-ledger-user"; +const NOW = new Date("2026-10-20T03:35:00.000Z"); +const DAY = 86_400_000; +const LONG_EXPIRED = new Date(NOW.getTime() - 40 * DAY); +const JUST_EXPIRED = new Date(NOW.getTime() - 10 * DAY); +const LIVE = new Date(NOW.getTime() + 10 * DAY); + +const prisma = getPrismaClient(); + +beforeEach(async () => { + await truncateAllTables(prisma); + await prisma.user.create({ + data: { id: USER, username: USER, role: "USER", timezone: "UTC" }, + }); +}); + +async function ids(rows: Promise>): Promise { + return (await rows).map((r) => r.id).sort(); +} + +describe("auth ledger cleanup", () => { + it("removes rows expired past their grace and keeps everything else", async () => { + for (const [id, expiresAt] of [ + ["session-old", LONG_EXPIRED], + ["session-recent", JUST_EXPIRED], + ["session-live", LIVE], + ] as const) { + await prisma.session.create({ data: { id, userId: USER, expiresAt } }); + } + for (const [id, expiresAt, revokedAt, usedAt] of [ + ["refresh-old", LONG_EXPIRED, null, LONG_EXPIRED], + ["refresh-recent", JUST_EXPIRED, null, null], + // Revoked but not yet expired: reuse detection may still need it. + ["refresh-revoked-live", LIVE, NOW, null], + ["refresh-used-live", LIVE, null, NOW], + ] as const) { + await prisma.refreshToken.create({ + data: { + id, + userId: USER, + tokenHash: `hash-${id}`, + deviceId: "device-1", + expiresAt, + revokedAt, + usedAt, + }, + }); + } + for (const [id, expiresAt, revoked] of [ + ["token-old", LONG_EXPIRED, false], + ["token-old-revoked", LONG_EXPIRED, true], + ["token-recent", JUST_EXPIRED, false], + ["token-live", LIVE, false], + // No expiry, revoked: nothing says when, so it stays listed. + ["token-revoked-forever", null, true], + ["token-forever", null, false], + ] as const) { + await prisma.apiToken.create({ + data: { + id, + userId: USER, + name: id, + tokenHash: `hash-${id}`, + expiresAt, + revoked, + }, + }); + } + for (const [id, expiresAt] of [ + ["trusted-old", LONG_EXPIRED], + ["trusted-recent", JUST_EXPIRED], + ] as const) { + await prisma.trustedDevice.create({ + data: { id, userId: USER, tokenHash: `hash-${id}`, expiresAt }, + }); + } + for (const [id, expiresAt] of [ + ["mfa-old", new Date(NOW.getTime() - 2 * DAY)], + ["mfa-recent", new Date(NOW.getTime() - 60 * 60_000)], + ] as const) { + await prisma.mfaChallenge.create({ + data: { + id, + userId: USER, + kind: "totp", + ticketHash: `hash-${id}`, + expiresAt, + }, + }); + } + for (const [id, expiresAt] of [ + ["challenge-old", new Date(NOW.getTime() - 2 * DAY)], + ["challenge-recent", new Date(NOW.getTime() - 60 * 60_000)], + ] as const) { + await prisma.authChallenge.create({ + data: { + id, + userId: null, + challenge: id, + type: "registration", + expiresAt, + }, + }); + } + + const result = await cleanupAuthLedgers(prisma, NOW); + + expect(result).toEqual({ + deleted: { + sessions: 1, + refresh_tokens: 1, + api_tokens: 2, + trusted_devices: 1, + mfa_challenges: 1, + auth_challenges: 1, + }, + drained: true, + }); + expect( + await ids(prisma.session.findMany({ select: { id: true } })), + ).toEqual(["session-live", "session-recent"]); + expect( + await ids(prisma.refreshToken.findMany({ select: { id: true } })), + ).toEqual(["refresh-recent", "refresh-revoked-live", "refresh-used-live"]); + expect( + await ids(prisma.apiToken.findMany({ select: { id: true } })), + ).toEqual([ + "token-forever", + "token-live", + "token-recent", + "token-revoked-forever", + ]); + expect( + await ids(prisma.trustedDevice.findMany({ select: { id: true } })), + ).toEqual(["trusted-recent"]); + expect( + await ids(prisma.mfaChallenge.findMany({ select: { id: true } })), + ).toEqual(["mfa-recent"]); + expect( + await ids(prisma.authChallenge.findMany({ select: { id: true } })), + ).toEqual(["challenge-recent"]); + }); + + it("deletes in batches and reports a table it could not finish", async () => { + for (let i = 0; i < 5; i++) { + await prisma.session.create({ + data: { id: `old-${i}`, userId: USER, expiresAt: LONG_EXPIRED }, + }); + } + + const capped = await cleanupAuthLedgers(prisma, NOW, { + batchSize: 2, + maxBatches: 2, + }); + expect(capped.deleted.sessions).toBe(4); + expect(capped.drained).toBe(false); + + const rest = await cleanupAuthLedgers(prisma, NOW, { + batchSize: 2, + maxBatches: 2, + }); + expect(rest.deleted.sessions).toBe(1); + expect(rest.drained).toBe(true); + expect(await prisma.session.count()).toBe(0); + }); +});