Files
template-app/modules/account/core/revocation.ts
T

184 lines
7.9 KiB
TypeScript

// The revocation outbox (joint contract §14 "Lifecycle, CSP and revocation"): when kit-auth revokes an
// app session -- a sign-out, a refused refresh, an expiry -- every installed module that mints derived
// service sessions (a Support widget session records its parent app session) ends them on its
// platform. A platform that is down at that moment must not leave them alive until their own expiry,
// and must not hold the sign-out up either:
//
// 1. the revocation is written as a row (the modules still to tell), claimed by this process;
// 2. each module is asked at once, bounded (REVOCATION_CALL_MS); the ones that confirmed leave
// `pending`, and a row with nothing pending is `delivered`;
// 3. a row left pending is retried by the worker's revocation job (REVOCATION_JOB), with backoff,
// until every module confirmed -- or until no session it minted can still be alive (`lapsed`);
// 4. a delivery pass claims its row with a versioned write first, so two processes (the web's
// first attempt and the worker, or two workers) never deliver one row at once, and a module that
// confirmed is never asked again.
//
// Nothing here throws into a sign-out: `revokeOnSignOut` answers once the row is written and the first
// attempt has settled or timed out. Only a refused write of the row itself is reported (thrown), so
// the caller can fall back to asking the modules directly.
import type { Container } from "@conusai-kit/core"
import type { AccountDataContribution } from "@conusai-kit/tenancy"
import {
DERIVED_SESSION_LIFETIME_MS,
REVOCATION_BACKOFF_MAX_MS,
REVOCATION_BACKOFF_MS,
REVOCATION_CALL_MS,
REVOCATION_CLAIM_MS,
REVOCATION_RETENTION_MS,
} from "../contract/constants.js"
import { refusalOf } from "./export.js"
import type { RevocationRecord, RevocationStore } from "./ports.js"
export interface RevocationDeps {
readonly store: RevocationStore
readonly contributions: readonly AccountDataContribution[]
/** The container each module resolves its platform port from. */
readonly container: Container
readonly now: () => number
readonly log: { warn(message: string): void }
/** How long one module's call may take; REVOCATION_CALL_MS by default. */
readonly callMs?: number
}
const iso = (ms: number): string => new Date(ms).toISOString()
/** The modules that mint derived service sessions, in module order. */
export const revokingModules = (contributions: readonly AccountDataContribution[]): string[] =>
contributions
.filter((contribution) => contribution.revokeSessions !== undefined)
.map((contribution) => contribution.key)
/** When the retry after `attempts` failed passes is due: REVOCATION_BACKOFF_MS doubling, capped. */
export function revocationBackoffMs(attempts: number): number {
return Math.min(REVOCATION_BACKOFF_MAX_MS, REVOCATION_BACKOFF_MS * 2 ** Math.max(0, Math.min(attempts - 1, 20)))
}
async function bounded(run: () => Promise<void> | undefined, ms: number): Promise<void> {
let timer: ReturnType<typeof setTimeout> | undefined
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error("timed out")), ms)
})
try {
await Promise.race([run(), timeout])
} finally {
clearTimeout(timer)
}
}
/**
* One delivery pass over a row this process has claimed: every pending module is asked, bounded; the
* row is then written against the claimed version -- the confirmed modules leave `pending`, a row with
* none left is `delivered`, otherwise the next attempt is scheduled with backoff and the claim released.
*/
async function deliver(deps: RevocationDeps, claimed: RevocationRecord): Promise<RevocationRecord | null> {
const callMs = deps.callMs ?? REVOCATION_CALL_MS
const outcomes = await Promise.all(
claimed.pending.map(async (key): Promise<{ key: string; failure: string | null }> => {
const contribution = deps.contributions.find((candidate) => candidate.key === key)
// Removed from the app since the revocation was recorded: nothing here can reach its platform,
// and its sessions end at their own expiry.
if (!contribution?.revokeSessions) return { key, failure: null }
try {
await bounded(
() => contribution.revokeSessions?.({ session: claimed.session, container: deps.container.child() }),
callMs,
)
return { key, failure: null }
} catch (error) {
return { key, failure: `${key}:${refusalOf(error).code}` }
}
}),
)
const failed = outcomes.filter((outcome) => outcome.failure !== null)
const confirmed = outcomes.filter((outcome) => outcome.failure === null).map((outcome) => outcome.key)
const now = deps.now()
if (failed.length === 0) {
return deps.store.change(claimed, {
state: "delivered",
pending: [],
delivered: [...claimed.delivered, ...confirmed],
claimedUntil: null,
lastFailure: null,
completedAt: iso(now),
})
}
for (const { key } of failed)
deps.log.warn(`auth: ${key} did not revoke the sessions of a revoked app session; retrying`)
const attempts = claimed.attempts + 1
return deps.store.change(claimed, {
pending: failed.map((outcome) => outcome.key),
delivered: [...claimed.delivered, ...confirmed],
attempts,
nextAttemptAt: iso(now + revocationBackoffMs(attempts)),
claimedUntil: null,
lastFailure: failed[0]?.failure ?? null,
})
}
/**
* kit-auth's revocation hook, durable: records the revocation, then delivers it at once. Answers
* within about one bounded call; a module that could not be reached is retried by the worker. Throws
* only when the row could not be written (nothing was recorded).
*/
export async function revokeOnSignOut(deps: RevocationDeps, session: string): Promise<void> {
const modules = revokingModules(deps.contributions)
if (modules.length === 0) return
const now = deps.now()
const recorded = await deps.store.record({
session,
modules,
at: iso(now),
claimedUntil: iso(now + REVOCATION_CLAIM_MS),
})
// Already recorded (kit-auth told twice about one session): the row that exists is delivered by
// whoever holds it, or by the worker.
if (recorded === null) return
try {
await deliver(deps, recorded)
} catch {
// The row stays claimed until its claim expires; the worker then delivers it.
deps.log.warn("auth: a revocation was recorded but not yet delivered; the worker retries it")
}
}
export interface RevocationSweep {
/** Rows this pass claimed. */
readonly claimed: number
/** Rows every module has now confirmed. */
readonly delivered: number
/** Rows still pending, scheduled again. */
readonly waiting: number
/** Rows given up: no session they name can still be alive. */
readonly lapsed: number
/** Finished rows removed. */
readonly purged: number
}
/** The worker's revocation job: every due row, claimed one by one and delivered. */
export async function sweepSessionRevocations(deps: RevocationDeps, limit = 100): Promise<RevocationSweep> {
const start = deps.now()
const due = await deps.store.due(iso(start), limit)
let claimed = 0
let delivered = 0
let waiting = 0
let lapsed = 0
for (const row of due) {
const now = deps.now()
if (now - Date.parse(row.requestedAt) >= DERIVED_SESSION_LIFETIME_MS) {
// Every service session minted for that app session has expired by now: nothing is left to end.
if (await deps.store.change(row, { state: "lapsed", claimedUntil: null, completedAt: iso(now) })) lapsed += 1
continue
}
const mine = await deps.store.change(row, { claimedUntil: iso(now + REVOCATION_CLAIM_MS) })
// Another process claimed or settled it first: that one delivers it.
if (mine === null) continue
claimed += 1
const settled = await deliver(deps, mine).catch(() => null)
if (settled?.state === "delivered") delivered += 1
else waiting += 1
}
const purged = await deps.store.purge(iso(start - REVOCATION_RETENTION_MS))
return { claimed, delivered, waiting, lapsed, purged }
}