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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-20 13:17:41 +02:00
1 parent 8e07cb179f
commit 566fd8c6b3
6 files changed
+372

No files matched your search

+163
View File
@@ -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<AuthLedger, number>;
/** 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<AuthLedgerCleanupResult> {
const deleted = {} as Record<AuthLedger, number>;
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<AuthLedgerCleanupPayload>[],
): Promise<JobOutcome> {
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;
}
});
}
+5
View File
@@ -64,6 +64,8 @@ export const JOB_FACT_ALLOWLIST: ReadonlySet<string> = 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<string> = new Set([
"records_oversized",
"records_read",
"refreshed",
"refresh_tokens_deleted",
"refused",
"reminders_due",
"removed",
@@ -251,6 +254,7 @@ export const JOB_FACT_ALLOWLIST: ReadonlySet<string> = 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<string> = new Set([
"tls_pin_monitor_known_count",
"tls_pin_monitor_outcome",
"total",
"trusted_devices_deleted",
"unchanged",
"updated",
"users",
+1
View File
@@ -316,6 +316,7 @@ export const QUEUE_RUNTIME: Readonly<Record<string, QueueRuntime>> = {
"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.",
@@ -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<AuthLedgerCleanupPayload>(
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.
+1
View File
@@ -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" },
@@ -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<Array<{ id: string }>>): Promise<string[]> {
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);
});
});