This commit is contained in:
@@ -0,0 +1,352 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// A revoked app session ends the service sessions the modules minted for it (joint contract §14): the
|
||||
// hook the web registers with kit-auth (`onSessionRevoked`) asks every installed module's account
|
||||
// share to revoke them server-side by the session's reference, bounded, and never fails a sign-out.
|
||||
// On the Postgres profile it is durable: the account module's revocation outbox records it, and the
|
||||
// worker's revocation job delivers what a platform that was down did not confirm, exactly once.
|
||||
|
||||
import { createSessionStore, deriveKeys, kitSessionRef, sessionRefKey, sessions } from "@conusai-kit/auth"
|
||||
import { buildRegistry, Container, contribute, defineModule } from "@conusai-kit/core"
|
||||
import { createMemoryDataSource, DATA_SOURCE, entitiesOf, SYSTEM_DATA, toInstant } from "@conusai-kit/data"
|
||||
import { SUPPORT_PLATFORM } from "@conusai-kit/support" // kit:support
|
||||
import { recordingSupportPlatform } from "@conusai-kit/support/testing" // kit:support
|
||||
import { accountData, accountDataOf } from "@conusai-kit/tenancy"
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import { revokeDerivedSessions } from "../apps/web/src/server/derived-sessions"
|
||||
import { ACCOUNT_REGISTRY, accountSessionRevocations, recordSessionRevocation } from "../modules/account/index.js"
|
||||
import { appModules } from "../modules/index.js"
|
||||
|
||||
const registry = buildRegistry([...appModules])
|
||||
const ref = { provider: "oidc", opaqueId: "0199abcd-1234-7000-8000-00000000d001" }
|
||||
|
||||
function ports(bind: (root: Container) => void, timeoutMs?: number, using = registry) {
|
||||
const root = new Container()
|
||||
bind(root)
|
||||
const warnings: string[] = []
|
||||
return {
|
||||
ports: {
|
||||
registry: using,
|
||||
root,
|
||||
log: { warn: (message: string) => warnings.push(message) },
|
||||
...(timeoutMs ? { timeoutMs } : {}),
|
||||
},
|
||||
warnings,
|
||||
}
|
||||
}
|
||||
|
||||
describe("revokeDerivedSessions", () => {
|
||||
it("answers at once when no installed module mints derived sessions", async () => {
|
||||
const without = buildRegistry(appModules.filter((module) => module.id !== "support"))
|
||||
const none = ports(() => undefined, undefined, without)
|
||||
await expect(revokeDerivedSessions(none.ports, ref)).resolves.toBeUndefined()
|
||||
expect(none.warnings).toEqual([])
|
||||
expect(sessionRefKey(ref)).toContain(ref.opaqueId)
|
||||
})
|
||||
|
||||
// kit:support:begin
|
||||
it("revokes the support widget sessions minted for exactly that app session", async () => {
|
||||
const support = recordingSupportPlatform()
|
||||
const { ports: bound, warnings } = ports((root) => root.value(SUPPORT_PLATFORM, support))
|
||||
await revokeDerivedSessions(bound, ref)
|
||||
expect(support.calls).toEqual([
|
||||
{ method: "revokeWidgetSessions", subject: { subject: "", identitySession: sessionRefKey(ref) } },
|
||||
])
|
||||
expect(warnings).toEqual([])
|
||||
})
|
||||
|
||||
it("never fails the sign-out: an unreachable or slow module is logged by name and the hook answers", async () => {
|
||||
const support = recordingSupportPlatform()
|
||||
const client = support.client as { revokeWidgetSessions: unknown }
|
||||
client.revokeWidgetSessions = async () => {
|
||||
throw new TypeError("fetch failed")
|
||||
}
|
||||
const down = ports((root) => root.value(SUPPORT_PLATFORM, support))
|
||||
await expect(revokeDerivedSessions(down.ports, ref)).resolves.toBeUndefined()
|
||||
expect(down.warnings).toEqual(["auth: support did not revoke the sessions of a revoked app session"])
|
||||
|
||||
client.revokeWidgetSessions = () => new Promise(() => undefined)
|
||||
const slow = ports((root) => root.value(SUPPORT_PLATFORM, support), 50)
|
||||
await expect(revokeDerivedSessions(slow.ports, ref)).resolves.toBeUndefined()
|
||||
expect(slow.warnings).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("asks the modules directly when the revocation could not be recorded", async () => {
|
||||
const support = recordingSupportPlatform()
|
||||
const { ports: bound, warnings } = ports((root) => root.value(SUPPORT_PLATFORM, support))
|
||||
await revokeDerivedSessions(
|
||||
{
|
||||
...bound,
|
||||
durable: async () => {
|
||||
throw new Error("the database refused the write")
|
||||
},
|
||||
},
|
||||
ref,
|
||||
)
|
||||
expect(support.calls).toEqual([
|
||||
{ method: "revokeWidgetSessions", subject: { subject: "", identitySession: sessionRefKey(ref) } },
|
||||
])
|
||||
expect(warnings).toEqual([
|
||||
"auth: the revocation of an app session could not be recorded; asking the modules directly",
|
||||
])
|
||||
})
|
||||
|
||||
it("does nothing on the fake platform, where no widget session was minted", async () => {
|
||||
const offline = ports((root) => root.value(SUPPORT_PLATFORM, { mode: "offline" }))
|
||||
await expect(revokeDerivedSessions(offline.ports, ref)).resolves.toBeUndefined()
|
||||
expect(offline.warnings).toEqual([])
|
||||
})
|
||||
|
||||
// kit:support:end
|
||||
it("asks only the modules that mint derived sessions", () => {
|
||||
const revoking = accountDataOf(registry)
|
||||
.filter((entry) => entry.revokeSessions)
|
||||
.map((entry) => entry.key)
|
||||
const expected: string[] = []
|
||||
expected.push("support") // kit:support
|
||||
expect(revoking).toEqual(expected)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* The durable path, end to end on the memory data source: kit-auth's own session store signs a
|
||||
* device out and calls the web's hook, which records the revocation in the account module's outbox;
|
||||
* the worker's revocation job (the registry's own handler) delivers it. `minting` stands for any
|
||||
* module that mints derived sessions, and its platform can be taken down and brought back.
|
||||
*/
|
||||
describe("the revocation outbox", () => {
|
||||
const platform = { down: false, delivered: [] as string[], refused: 0 }
|
||||
const minting = defineModule({
|
||||
id: "minting",
|
||||
version: "0.1.0",
|
||||
kit: "^0.1.0",
|
||||
requires: ["tenancy"],
|
||||
extends: [
|
||||
contribute(accountData, [
|
||||
{
|
||||
key: "minting",
|
||||
async revokeSessions({ session }) {
|
||||
if (platform.down) {
|
||||
platform.refused += 1
|
||||
throw new TypeError("fetch failed")
|
||||
}
|
||||
if (session) platform.delivered.push(session)
|
||||
},
|
||||
},
|
||||
]),
|
||||
],
|
||||
})
|
||||
const registry = buildRegistry([...appModules, minting])
|
||||
const job = registry.jobs["account.sessions.revoke"]
|
||||
const START = Date.parse("2026-10-25T10:00:00.000Z")
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] })
|
||||
vi.setSystemTime(START)
|
||||
platform.down = false
|
||||
platform.delivered.length = 0
|
||||
platform.refused = 0
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
async function app() {
|
||||
const data = createMemoryDataSource({ entities: entitiesOf(registry, "postgres") })
|
||||
const root = new Container()
|
||||
root.value(DATA_SOURCE, data)
|
||||
root.value(SYSTEM_DATA, data)
|
||||
root.value(ACCOUNT_REGISTRY, registry)
|
||||
root.value(SUPPORT_PLATFORM, { mode: "offline" }) // kit:support
|
||||
const warnings: string[] = []
|
||||
const log = { warn: (message: string) => warnings.push(message) }
|
||||
const store = createSessionStore({
|
||||
config: {
|
||||
idleSeconds: 3600,
|
||||
absoluteSeconds: 86400,
|
||||
cookieName: "s",
|
||||
secret: "x".repeat(32),
|
||||
secureCookies: false,
|
||||
},
|
||||
data,
|
||||
keys: await deriveKeys("x".repeat(32)),
|
||||
// What apps/web/src/server/app.ts registers on the Postgres profile.
|
||||
onRevoked: (revoked) =>
|
||||
revokeDerivedSessions(
|
||||
{ registry, root, log, durable: (session) => recordSessionRevocation(root, session, { log }) },
|
||||
revoked,
|
||||
),
|
||||
})
|
||||
const signIn = async (subject: string) =>
|
||||
(
|
||||
await store.open({
|
||||
userId: "0199abcd-1234-7000-8000-00000000e001",
|
||||
issuer: "https://id.example.test",
|
||||
subject,
|
||||
tokens: { accessToken: "a", expiresAt: toInstant(Date.now() + 3600_000) },
|
||||
})
|
||||
).row
|
||||
const runJob = () => {
|
||||
if (!job) throw new Error("the account module declares no revocation job")
|
||||
return job.def.handler(null, { container: root.child(), correlationId: "test-revoke", attempt: 1 })
|
||||
}
|
||||
const rows = async () => (await data.global(accountSessionRevocations).list({ limit: 50 })).items
|
||||
const later = (ms: number) => vi.setSystemTime(Date.now() + ms)
|
||||
return { data, store, signIn, runJob, rows, later, warnings }
|
||||
}
|
||||
|
||||
it("is scheduled by the worker every minute", () => {
|
||||
expect(job?.module).toBe("account")
|
||||
})
|
||||
|
||||
it("delivers at once when the platform is up, and the job has nothing left to do", async () => {
|
||||
const { store, signIn, runJob, rows, later } = await app()
|
||||
const device = await signIn("sub-up")
|
||||
await store.revoke(device, "sign-out")
|
||||
const key = sessionRefKey(kitSessionRef(device))
|
||||
expect(platform.delivered).toEqual([key])
|
||||
expect((await rows())[0]).toMatchObject({
|
||||
state: "delivered",
|
||||
pending: [],
|
||||
delivered: expect.arrayContaining(["minting"]),
|
||||
})
|
||||
later(10 * 60_000)
|
||||
await runJob()
|
||||
expect(platform.delivered).toEqual([key])
|
||||
})
|
||||
|
||||
it("a platform down at sign-out gets the revocation once it is back, and nothing twice", async () => {
|
||||
const { data, store, signIn, runJob, rows, later, warnings } = await app()
|
||||
const device = await signIn("sub-down")
|
||||
platform.down = true
|
||||
// The sign-out is not blocked: it answers, and the app session is revoked.
|
||||
await expect(store.revoke(device, "sign-out")).resolves.toBeUndefined()
|
||||
expect((await data.global(sessions).get(device.id))?.revokedAt).not.toBeNull()
|
||||
expect(platform.delivered).toEqual([])
|
||||
const [pending] = await rows()
|
||||
expect(pending).toMatchObject({ state: "pending", pending: ["minting"], attempts: 1, claimedUntil: null })
|
||||
expect(warnings).toEqual(["auth: minting did not revoke the sessions of a revoked app session; retrying"])
|
||||
|
||||
// Not due yet (backoff): the job asks nobody.
|
||||
await runJob()
|
||||
expect(platform.refused).toBe(1)
|
||||
// Due, platform still down: one more refusal, scheduled again with a longer backoff; the run fails.
|
||||
later(31_000)
|
||||
await expect(runJob()).rejects.toMatchObject({ code: "account_revocations_waiting" })
|
||||
expect(platform.refused).toBe(2)
|
||||
expect((await rows())[0]).toMatchObject({ state: "pending", attempts: 2 })
|
||||
|
||||
// Back: the next due run delivers it, once.
|
||||
platform.down = false
|
||||
later(61_000)
|
||||
await runJob()
|
||||
const key = sessionRefKey(kitSessionRef(device))
|
||||
expect(platform.delivered).toEqual([key])
|
||||
expect((await rows())[0]).toMatchObject({
|
||||
state: "delivered",
|
||||
pending: [],
|
||||
delivered: expect.arrayContaining(["minting"]),
|
||||
})
|
||||
later(10 * 60_000)
|
||||
await runJob()
|
||||
expect(platform.delivered).toEqual([key])
|
||||
})
|
||||
|
||||
it("two runs at once deliver one revocation once", async () => {
|
||||
const { store, signIn, runJob, later } = await app()
|
||||
const device = await signIn("sub-race")
|
||||
platform.down = true
|
||||
await store.revoke(device, "sign-out")
|
||||
platform.down = false
|
||||
later(31_000)
|
||||
await Promise.all([runJob(), runJob(), runJob()])
|
||||
expect(platform.delivered).toEqual([sessionRefKey(kitSessionRef(device))])
|
||||
})
|
||||
|
||||
it("gives up once no derived session can still be alive, and removes finished rows a day later", async () => {
|
||||
const { store, signIn, runJob, rows, later } = await app()
|
||||
const down = await signIn("sub-lapse")
|
||||
platform.down = true
|
||||
await store.revoke(down, "sign-out")
|
||||
later(66 * 60_000)
|
||||
await runJob()
|
||||
const refused = platform.refused
|
||||
expect((await rows())[0]).toMatchObject({ state: "lapsed" })
|
||||
later(10 * 60_000)
|
||||
await runJob()
|
||||
expect(platform.refused).toBe(refused)
|
||||
later(25 * 60 * 60_000)
|
||||
await runJob()
|
||||
expect(await rows()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* The Convex profile's sign-out (apps/web/src/server/convex-revocation.ts): the web records the revoked
|
||||
* app session in the backend's outbox through the internal action `account:revokeSession`, with the
|
||||
* admin key and no identity, bounded. A refusal or a backend that does not answer is thrown, so the
|
||||
* hook asks the modules directly instead.
|
||||
*/
|
||||
describe("the Convex profile's durable revocation", () => {
|
||||
async function backend(answer: (body: { path: string; args: unknown[] }) => unknown | undefined) {
|
||||
const { createServer } = await import("node:http")
|
||||
const seen: Array<{ path: string; args: unknown[]; authorization: string | undefined }> = []
|
||||
const server = createServer((request, response) => {
|
||||
let raw = ""
|
||||
request.on("data", (chunk) => {
|
||||
raw += chunk
|
||||
})
|
||||
request.on("end", () => {
|
||||
const body = JSON.parse(raw) as { path: string; args: unknown[] }
|
||||
seen.push({ ...body, authorization: request.headers.authorization })
|
||||
const value = answer(body)
|
||||
if (value === undefined) return // never answers
|
||||
response.writeHead(200, { "content-type": "application/json" })
|
||||
response.end(JSON.stringify(value))
|
||||
})
|
||||
})
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve))
|
||||
const address = server.address() as { port: number }
|
||||
return {
|
||||
convex: { url: `http://127.0.0.1:${address.port}`, adminKey: "app|admin-key" },
|
||||
seen,
|
||||
close: () =>
|
||||
new Promise<void>((resolve) => {
|
||||
server.closeAllConnections()
|
||||
server.close(() => resolve())
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
it("records the revocation in the backend's outbox with the admin key", async () => {
|
||||
const { revokeConvexSession } = await import("../apps/web/src/server/convex-revocation")
|
||||
const fake = await backend(() => ({ status: "success", value: null, logLines: [] }))
|
||||
try {
|
||||
await revokeConvexSession(fake.convex, sessionRefKey(ref))
|
||||
expect(fake.seen).toMatchObject([
|
||||
{
|
||||
path: "account:revokeSession",
|
||||
args: [{ session: sessionRefKey(ref) }],
|
||||
authorization: "Convex app|admin-key",
|
||||
},
|
||||
])
|
||||
expect(fake.seen).toHaveLength(1)
|
||||
} finally {
|
||||
await fake.close()
|
||||
}
|
||||
})
|
||||
|
||||
it("throws when nothing was recorded, so the hook asks the modules directly, and never waits long", async () => {
|
||||
const { revokeConvexSession } = await import("../apps/web/src/server/convex-revocation")
|
||||
const refused = await backend(() => ({ status: "error", errorMessage: "Server Error", logLines: [] }))
|
||||
const silent = await backend(() => undefined)
|
||||
try {
|
||||
await expect(revokeConvexSession(refused.convex, "s-1")).rejects.toBeDefined()
|
||||
const started = Date.now()
|
||||
await expect(revokeConvexSession(silent.convex, "s-2", { timeoutMs: 100 })).rejects.toBeDefined()
|
||||
expect(Date.now() - started).toBeLessThan(2000)
|
||||
} finally {
|
||||
await refused.close()
|
||||
await silent.close()
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user