This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
// 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 }
|
||||
}
|
||||
Reference in New Issue
Block a user