mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
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:
1 parent
8e07cb179f
commit
566fd8c6b3
6 files changed
+372
No files matched your search
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user