184 lines
7.9 KiB
TypeScript
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 }
|
|
}
|