§9 optional revocation denylist (todo §9); closes the documented ~10m role/session lag for security-critical revoke, off by default (REVOCATION_DENYLIST, zero hot-path cost + zero behaviour change when off). New pure src/denylist.ts (createDenylist({ttlSec})): an in-memory, auto-evicting Map<sub, revokedAt> — revoke(sub) records now, isRevoked(sub, iat) rejects a subject's tokens minted at/before the revoke (iat <= revokedAt; missing iat fails closed), so a fresh re-login (iat after the revoke) passes while a downgrade lands immediately. Entries self-evict after REVOCATION_TTL_SEC (default 900 ≥ the 10m tokenizer TTL + skew), so it stays a bounded cache like JWKS — no database, Keto stays off the hot path. Wired: jwt-middleware.ts takes the denylist in VerifyOptions and throws TokenError(expired) on a revoked sub, so resolveSession routes it through the existing §4 re-mint (live session → fresh post-revoke JWT with current Keto roles; dead/deactivated → cleared cookie). app.ts merges it into authOptions (the same resolveSession hot-path call) and hands a bound revoke to the Users + Roles admin deps; admin-users.ts revokes on deactivate/delete, admin-roles.ts revokes a direct user: member on assign/unassign (a group:/whole-role change is transitive → left to lag, documented). server.ts builds it only when the toggle is on. Tests-first: denylist.test.ts (iat semantics, cutoff-advance, TTL eviction), jwt-middleware.test.ts (revoked→expired→re-mint, fresh passes), config.test.ts (toggle + posint TTL), app.test.ts (hot-path reject + fresh-login pass; admin deactivate/role-assign/unassign record the revoke). Stability-reviewer on the diff: APPROVE, no Critical/High/Medium (addressed its one Low). Per the §9 security-headers precedent, covered by unit + app-HTTP integration (no new browser E2E — no new user-facing page). README (Auth trade-off + new "Instant revoke" subsection, config table, Layout) updated. typecheck + 317 units green.

This commit is contained in:
2026-06-20 01:38:20 +02:00
parent 9d22c75016
commit a8a018f3e5
13 changed files with 221 additions and 17 deletions

View File

@@ -272,6 +272,14 @@ export interface AdminRolesDeps {
kratosAdmin: KratosAdmin;
menu: MenuConfig;
render: (view: string, data: Record<string, unknown>) => Promise<string>;
revoke?: (sub: string) => void; // optional instant-revoke (§9): assigning/unassigning a *user* kills their live tokens
}
// §9 instant-revoke: a role change for a `user:<id>` member must take effect now, so revoke that
// user's live tokens (a re-mint then re-reads roles from Keto). A `group:<name>` change is
// transitive across many users — left to lag (documented), so only direct user members revoke.
function revokeUserMember(deps: AdminRolesDeps, member: string): void {
if (deps.revoke && member.startsWith("user:")) deps.revoke(member.slice("user:".length));
}
// A role exists exactly while it has ≥1 member (Keto has no create-object).
@@ -322,13 +330,15 @@ export async function handleAdminRoles(ctx: RequestContext, csrfToken: string, d
if (method === "GET") return renderList();
if (method === "POST") {
const name = (form!.get("name") ?? "").trim();
const tuple = roleMemberTuple(name, (form!.get("member") ?? "").trim());
const member = (form!.get("member") ?? "").trim();
const tuple = roleMemberTuple(name, member);
const reject = (error: string): Promise<RouteResult> =>
renderForm({ error, values: { member: form!.get("member") ?? "", name } }).then((r) => ({ ...r, status: 400 }));
renderForm({ error, values: { member, name } }).then((r) => ({ ...r, status: 400 }));
if (!isValidRoleName(name)) return reject("Role names use lowercase letters, digits, dashes and underscores.");
if (!tuple) return reject("Pick a user or group to assign the role to.");
if (await roleExists(keto, name)) return reject("A role with that name already exists.");
await keto.writeTuple(tuple);
revokeUserMember(deps, member);
return { redirect: detailHref(name) };
}
return null;
@@ -345,8 +355,9 @@ export async function handleAdminRoles(ctx: RequestContext, csrfToken: string, d
if (seg.length === 1 && method === "GET") return renderDetail(name);
if (seg.length === 2 && seg[1] === "members" && method === "POST") {
const tuple = roleMemberTuple(name, (form!.get("member") ?? "").trim());
if (tuple) await keto.writeTuple(tuple); // the picker only offers real users/groups
const member = (form!.get("member") ?? "").trim();
const tuple = roleMemberTuple(name, member);
if (tuple) { await keto.writeTuple(tuple); revokeUserMember(deps, member); } // the picker only offers real users/groups
return { redirect: base };
}
if (seg.length === 2 && seg[1] === "delete" && method === "GET") {
@@ -361,6 +372,8 @@ export async function handleAdminRoles(ctx: RequestContext, csrfToken: string, d
if (seg.length === 2 && seg[1] === "delete" && method === "POST") {
if (name === ADMIN_PERMISSION) return renderDetail(name, "The admin role can't be deleted — it would remove all admin access.");
await keto.deleteTuple({ namespace: ROLE_NS, object: name, relation: MEMBERS }); // removes every member tuple
// §9: a whole-role delete drops many members at once — left to lag like a group change; the
// per-member unassign above is the instant-revoke path.
return { redirect: ADMIN_ROLES_BASE };
}
if (seg.length === 3 && seg[1] === "members" && seg[2] === "delete" && method === "POST") {
@@ -369,7 +382,7 @@ export async function handleAdminRoles(ctx: RequestContext, csrfToken: string, d
// Admin held only via a group isn't covered here — the robust "last effective admin" check is §9.
if (name === ADMIN_PERMISSION && member === `user:${user.id}`) return renderDetail(name, "You can't revoke your own admin access.");
const tuple = roleMemberTuple(name, member);
if (tuple) await keto.deleteTuple(tuple);
if (tuple) { await keto.deleteTuple(tuple); revokeUserMember(deps, member); }
return { redirect: base };
}
return null;

View File

@@ -286,6 +286,7 @@ export interface AdminUsersDeps {
kratosAdmin: KratosAdmin;
menu: MenuConfig;
render: (view: string, data: Record<string, unknown>) => Promise<string>;
revoke?: (sub: string) => void; // optional instant-revoke (§9): kill the target's live tokens on deactivate/delete
}
function readUserInput(form: URLSearchParams): UserInput {
@@ -370,12 +371,15 @@ export async function handleAdminUsers(ctx: RequestContext, csrfToken: string, d
if (method === "POST") {
if (seg[1] === "state") {
if (isSelf) return { ...(await renderForm({ error: "You can't deactivate your own account.", identity })), status: 400 };
await kratosAdmin.updateIdentity(targetId, setStatePayload(identity, identity.state === "inactive" ? "active" : "inactive"));
const nextState = identity.state === "inactive" ? "active" : "inactive";
await kratosAdmin.updateIdentity(targetId, setStatePayload(identity, nextState));
if (nextState === "inactive") deps.revoke?.(targetId); // §9: a deactivation takes effect now, not after the JWT TTL
return { redirect: back };
}
if (seg[1] === "delete") {
if (isSelf) return { ...(await renderForm({ error: "You can't delete your own account.", identity })), status: 400 };
await kratosAdmin.deleteIdentity(targetId);
deps.revoke?.(targetId); // §9: the account is gone — reject its live tokens immediately
return { redirect: ADMIN_USERS_BASE };
}
if (seg[1] === "recovery") {

View File

@@ -8,6 +8,7 @@ import { after, before, test, type TestContext } from "node:test";
import { fileURLToPath } from "node:url";
import { createApp, type AppOptions } from "./app.ts";
import { readFormBody } from "./body.ts";
import { createDenylist } from "./denylist.ts";
import { CSRF_COOKIE, issueCsrfToken } from "./csrf.ts";
import { can, check, GuardError, requireSession } from "./guards.ts";
import { HydraError, type HydraAdmin, type OAuth2Client } from "./hydra-admin.ts";
@@ -297,6 +298,22 @@ test("a verified session JWT authorizes a role-gated route; no cookie / expired
assert.doesNotMatch(await (await home()).text(), /href="\/admin\/users"/); // anonymous → no admin section
});
test("revocation denylist (§9): a revoked subject's token stops authorizing on the hot path; a fresh re-login passes", async (t) => {
const denylist = createDenylist(); // no Ory clients ⇒ a revoked token drops straight to anonymous (no re-mint)
const app = createApp({ denylist, jwks: staticJwks([ecJwk]), plugins: [demoPlugin] });
await new Promise<void>((r) => app.listen(0, r));
t.after(() => app.close());
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
const nowSec = Math.floor(Date.now() / 1000);
const secret = (iat: number) => fetch(url + "/demo/secret", { redirect: "manual", headers: { cookie: `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec + 600, iat, roles: ["demo:read"], sub: "u1" })}` } });
assert.equal((await secret(nowSec)).status, 200); // before any revoke, the token authorizes
denylist.revoke("u1");
assert.equal((await secret(nowSec - 5)).status, 303); // the pre-revoke token now bounces to /login
assert.equal((await secret(nowSec + 5)).status, 200); // a fresh re-login (iat after the revoke) still works
});
test("session re-mint: an expired JWT backed by a live Kratos session is silently re-minted; a dead session clears it", async (t) => {
const identity: Identity = { id: "u1", traits: { email: "a@b.c" } };
const nowSec = Math.floor(Date.now() / 1000);
@@ -759,7 +776,8 @@ test("admin Users screen: gate, list/filter, create, edit, deactivate, delete, r
listIdentities: async () => ({ identities: store, nextPageToken: null }),
updateIdentity: async (id, payload) => { const it = store.find((x) => x.id === id)!; Object.assign(it, payload); return it; },
});
const { get, post, token, url } = await adminHarness(t, { kratosAdmin });
const denylist = createDenylist(); // §9: a deactivate/delete should revoke the target's live tokens instantly
const { get, post, token, url } = await adminHarness(t, { denylist, kratosAdmin });
await assertAdminGate(url, get, "/admin/users");
@@ -790,9 +808,10 @@ test("admin Users screen: gate, list/filter, create, edit, deactivate, delete, r
assert.equal(updated.status, 303);
assert.deepEqual((target.traits as { name: unknown }).name, { first: "Ada", last: "King" });
// Deactivate (state toggle): active → inactive.
// Deactivate (state toggle): active → inactive, and the target's live tokens are revoked at once (§9).
await post(`/admin/users/${target.id}/state`, `_csrf=${token}`);
assert.equal(target.state, "inactive");
assert.equal(denylist.isRevoked(target.id, 0), true);
// Recovery: renders the edit page (200) showing the generated code (code-based; no admin-host link).
const rec = await post(`/admin/users/${target.id}/recovery`, `_csrf=${token}`);
@@ -902,7 +921,8 @@ test("admin Roles screen: gate, list, create, assign user/group, effective acces
});
const keto = fakeKeto(tuples, { expand: async (set) => expandSet(set) });
const kratosAdmin = stubAdmin({ listIdentities: async () => ({ identities, nextPageToken: null }) });
const { get, post, token, url } = await adminHarness(t, { keto, kratosAdmin });
const denylist = createDenylist(); // §9: granting/revoking a *user's* role revokes their live tokens (a group change is transitive → left to lag)
const { get, post, token, url } = await adminHarness(t, { denylist, keto, kratosAdmin });
await assertAdminGate(url, get, "/admin/roles");
@@ -917,6 +937,7 @@ test("admin Roles screen: gate, list, create, assign user/group, effective acces
assert.equal(created.status, 303);
assert.equal(created.headers.get("location"), "/admin/roles/viewer");
assert.ok(tuples.some((tp) => tp.namespace === "Role" && tp.object === "viewer" && tp.subject_id === `user:${ada}`));
assert.equal(denylist.isRevoked(ada, 0), true); // assigning a role to a user revokes their stale token so the grant lands now
// An invalid name, a duplicate name, or a missing CSRF token are all refused, nothing written.
const before = tuples.length;
@@ -942,6 +963,11 @@ test("admin Roles screen: gate, list, create, assign user/group, effective acces
await post("/admin/roles/editor/members/delete", `_csrf=${token}&member=group:eng`);
assert.ok(!tuples.some((tp) => tp.namespace === "Role" && tp.object === "editor" && tp.subject_set?.object === "eng"));
// Unassigning a *user* membership likewise revokes that user's live token (§9), so the loss of access is immediate.
await post("/admin/roles/editor/members", `_csrf=${token}&member=user:${grace}`);
await post("/admin/roles/editor/members/delete", `_csrf=${token}&member=user:${grace}`);
assert.equal(denylist.isRevoked(grace, 0), true);
// Delete the role: a confirm step (GET) then the POST removes every member tuple, back to the list.
assert.match(await (await get("/admin/roles/editor/delete")).text(), /Cancel/);
const del = await post("/admin/roles/editor/delete", `_csrf=${token}`);

View File

@@ -12,6 +12,7 @@ import { readFormBody } from "./body.ts";
import { buildPluginChrome, type PageChrome } from "./chrome.ts";
import { buildContext, type User } from "./context.ts";
import { CSRF_FIELD, csrfCookie, ensureCsrfToken, verifyCsrfRequest } from "./csrf.ts";
import type { Denylist } from "./denylist.ts";
import { buildDashboardModel } from "./dashboard.ts";
import { PLUGINS_DIR } from "./discovery.ts";
import { GuardError } from "./guards.ts";
@@ -41,6 +42,7 @@ export interface AppOptions {
// Off by default so edits show live; the app itself never inspects the environment.
cache?: boolean;
csrfSecret?: string; // HMAC key for the double-submit CSRF token (config.csrfSecret); random if omitted
denylist?: Denylist; // optional instant-revoke (§9); the hot path rejects revoked subjects, admin writes record revokes
hydra?: HydraAdmin; // Hydra admin client; with kratos enables the OAuth2 login challenge (§6)
jwks?: JwksProvider; // verify the session JWT → ctx.user/roles (§4); absent ⇒ always anonymous
keto?: KetoClient; // Keto client; with kratos+kratosAdmin enables login completion (§4)
@@ -55,7 +57,12 @@ export interface AppOptions {
}
export function createApp(options: AppOptions = {}): Server {
const authOptions = options.auth ?? {};
// The denylist (when enabled) rides in the verify options so resolveSession rejects a revoked
// subject on the hot path; the bound `revoke` is handed to the admin handlers that should
// revoke instantly. Both absent ⇒ the feature is fully off (no cost, no behaviour change).
const denylist = options.denylist;
const authOptions: VerifyOptions = denylist ? { ...(options.auth ?? {}), denylist } : (options.auth ?? {});
const revoke = denylist ? (sub: string): void => denylist.revoke(sub) : undefined;
const cache = options.cache ?? false;
const csrfSecret = options.csrfSecret ?? randomBytes(32).toString("hex"); // server passes config; tests pass their own
const secureCookies = options.secureCookies ?? false;
@@ -88,9 +95,9 @@ export function createApp(options: AppOptions = {}): Server {
// Built-in admin screens (§5) — wired only when their Ory clients are present (the writes go
// there). They render core views via `render` and are gated/CSRF-guarded inside the handler.
// Users writes to Kratos; Groups writes to Keto and reads users from Kratos for the pickers.
const adminDeps: AdminUsersDeps | null = kratosAdmin ? { csrfSecret, kratosAdmin, menu, render } : null;
const adminDeps: AdminUsersDeps | null = kratosAdmin ? { csrfSecret, kratosAdmin, menu, render, ...(revoke ? { revoke } : {}) } : null;
const adminGroupsDeps: AdminGroupsDeps | null = kratosAdmin && keto ? { csrfSecret, keto, kratosAdmin, menu, render } : null;
const adminRolesDeps: AdminRolesDeps | null = kratosAdmin && keto ? { csrfSecret, keto, kratosAdmin, menu, render } : null;
const adminRolesDeps: AdminRolesDeps | null = kratosAdmin && keto ? { csrfSecret, keto, kratosAdmin, menu, render, ...(revoke ? { revoke } : {}) } : null;
// OAuth2 clients (§6) write to Hydra; wired only when the Hydra admin client is present.
const adminClientsDeps: AdminClientsDeps | null = hydra ? { csrfSecret, hydra, menu, render } : null;

View File

@@ -21,6 +21,15 @@ test("loads dev defaults when the environment is empty", () => {
assert.equal(c.hydraAdminUrl, "http://hydra:4445");
assert.match(c.csrfSecret, /dev-insecure/);
assert.equal(c.jwtClockSkewSec, 60); // default exp/nbf leeway for Kratos↔web clock drift
assert.equal(c.revocationDenylist, false); // instant-revoke is opt-in (§9)
assert.equal(c.revocationTtlSec, 900); // ≥ tokenizer TTL (10m) + skew
});
test("REVOCATION_DENYLIST: opt-in toggle (off by default) + REVOCATION_TTL_SEC must be a positive integer", () => {
assert.equal(loadConfig({ REVOCATION_DENYLIST: "true" }).revocationDenylist, true);
assert.throws(() => loadConfig({ REVOCATION_DENYLIST: "on" }), /REVOCATION_DENYLIST/);
assert.equal(loadConfig({ REVOCATION_TTL_SEC: "1200" }).revocationTtlSec, 1200);
for (const v of ["0", "-1", "1.5", "abc"]) assert.throws(() => loadConfig({ REVOCATION_TTL_SEC: v }), /REVOCATION_TTL_SEC/);
});
test("JWKS_URL defaults to the committed Kratos tokenizer signing key, not an http endpoint", () => {

View File

@@ -22,6 +22,8 @@ export interface Config {
kratosPublicUrl: string;
oryTimeoutSec: number; // per-call timeout for outbound Kratos/Keto/Hydra fetches (bounds a hung Ory)
port: number;
revocationDenylist: boolean; // §9: enable the optional instant role/session revoke denylist
revocationTtlSec: number; // how long a revoke entry lives; keep ≥ tokenizer TTL + clock skew
secureCookies: boolean;
}
@@ -112,6 +114,11 @@ export function loadConfig(env: Env = process.env): Config {
kratosPublicUrl: readUrl(env, "KRATOS_PUBLIC_URL", "http://kratos:4433"),
oryTimeoutSec: readPosInt(env, "ORY_TIMEOUT_SEC", 5),
port: readPort(env),
// Optional instant-revoke (§9), off by default. When on, an admin deactivate/delete or role
// change revokes the subject's live tokens at once; the entry lives ttl seconds (≥ the 10m
// tokenizer TTL + skew, so it outlasts any pre-revoke token).
revocationDenylist: readBool(env, "REVOCATION_DENYLIST", false),
revocationTtlSec: readPosInt(env, "REVOCATION_TTL_SEC", 900),
// Set Secure on our session/CSRF cookies. Off by default (dev runs http); prod (https) sets it.
secureCookies: readBool(env, "SECURE_COOKIES", false),
};

37
src/denylist.test.ts Normal file
View File

@@ -0,0 +1,37 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { createDenylist } from "./denylist.ts";
test("createDenylist: revokes a subject's pre-revoke tokens, lets a fresh re-login through", () => {
let clock = 1000;
const dl = createDenylist({ now: () => clock, ttlSec: 600 });
// An un-revoked subject is never revoked.
assert.equal(dl.isRevoked("u1", 990), false);
// Revoke at t=1000. A token minted at/before the revoke is rejected; one minted after passes
// (a fresh re-login, whose JWT already reflects the new Keto state).
dl.revoke("u1");
assert.equal(dl.isRevoked("u1", 990), true); // before
assert.equal(dl.isRevoked("u1", 1000), true); // exactly at the revoke instant
assert.equal(dl.isRevoked("u1", 1001), false); // after → fresh token, not revoked
assert.equal(dl.isRevoked("u2", 990), false); // a different subject is unaffected
// A missing iat fails closed (better to force a re-mint than honour a maybe-revoked token).
assert.equal(dl.isRevoked("u1", undefined), true);
});
test("createDenylist: a later revoke advances the cutoff; entries self-evict after the TTL", () => {
let clock = 1000;
const dl = createDenylist({ now: () => clock, ttlSec: 600 });
dl.revoke("u1"); // cutoff = 1000
clock = 1500;
dl.revoke("u1"); // cutoff advances to 1500
assert.equal(dl.isRevoked("u1", 1400), true); // minted before the latest revoke
assert.equal(dl.isRevoked("u1", 1600), false); // minted after
// Past the TTL the entry is gone — any pre-revoke token has long since expired anyway.
clock = 1500 + 601;
assert.equal(dl.isRevoked("u1", 1400), false);
});

56
src/denylist.ts Normal file
View File

@@ -0,0 +1,56 @@
// Optional revocation denylist (todo §9): instant role/session revoke without putting Keto
// back on the hot path. Off by default — enable with REVOCATION_DENYLIST=true.
//
// The hot path verifies a short-lived (~10m) session JWT in-process, so a revoked role or a
// killed session only takes effect when the token is next minted (re-login / TTL refresh) —
// up to one token TTL of lag. For security-critical revoke (offboarding, a compromised
// account) that lag is too long. An admin action records the subject as revoked-now and the
// hot path then rejects that subject's pre-revoke tokens at once, forcing a re-mint (which
// re-reads roles from Keto, or clears a now-dead session).
//
// Cost & scope: an in-memory, auto-evicting Map — no database, like the JWKS cache, so it
// stays inside the stateless model. A token carries `iat`, so a *fresh* re-login (iat after
// the revoke) passes while every token minted before the revoke is rejected. Entries self-evict
// after one token TTL, by which point any pre-revoke token has expired anyway. Single-process:
// instant on the instance that handled the revoke; across replicas/restarts the guarantee
// falls back to the token TTL (the gap is just no longer closed early). Back it with a shared
// store for hard multi-instance instant-revoke.
export interface Denylist {
// Hot-path check: is a token for `sub`, issued at `iat` (unix sec), revoked? A token minted
// after the latest revoke passes (a fresh re-login); a missing `iat` fails closed.
isRevoked(sub: string, iat: number | undefined): boolean;
// Record `sub` (a Kratos identity id) as revoked as of now: every token for it minted at or
// before this instant is rejected until it would have expired anyway.
revoke(sub: string): void;
}
export interface DenylistOptions {
now?: () => number; // unix seconds; injectable for tests
ttlSec?: number; // entry lifetime; keep ≥ tokenizer TTL + clock skew (default 900 ≥ 10m + 60s)
}
export function createDenylist(options: DenylistOptions = {}): Denylist {
const ttl = options.ttlSec ?? 900;
const clock = options.now ?? (() => Math.floor(Date.now() / 1000));
const revokedAt = new Map<string, number>(); // sub → unix sec of its latest revoke
return {
isRevoked(sub, iat) {
const at = revokedAt.get(sub);
if (at === undefined) return false;
if (clock() - at > ttl) {
revokedAt.delete(sub); // expired entry — any token it could match is long gone
return false;
}
return iat === undefined || iat <= at; // pre-revoke token (or unknown iat) ⇒ revoked
},
revoke(sub) {
const now = clock();
// Full-scan prune (cheap, and only on a revoke — never the hot path) keeps the map bounded
// to recently-revoked subjects.
for (const [s, at] of revokedAt) if (now - at > ttl) revokedAt.delete(s);
revokedAt.set(sub, now); // latest revoke wins; advances the cutoff
},
};
}

View File

@@ -83,3 +83,15 @@ test("resolveSession classifies the cookie; authenticate is its fail-closed user
assert.equal(await authenticate(cookie({ exp: NOW - 999 }), jwks, { now: NOW }), null); // expired ⇒ null
assert.equal(await authenticate(undefined, jwks, { now: NOW }), null);
});
test("verifyToken honours an optional denylist: a revoked subject's token rejects like an expiry → re-mint", async () => {
// Deny u1's tokens minted at/before NOW; a token minted after passes (a fresh re-login).
const denylist = { isRevoked: (sub: string, iat: number | undefined) => sub === "u1" && (iat === undefined || iat <= NOW) };
// Revoked: thrown as *expired* so resolveSession flags it for the §4 re-mint (re-read Keto / clear).
await assert.rejects(verifyToken(mint(k1.privateKey, "k1", { ...valid, iat: NOW - 5 }), jwks, { denylist, now: NOW }), /revoked/);
assert.deepEqual(await resolveSession(`${SESSION_COOKIE}=${mint(k1.privateKey, "k1", { ...valid, iat: NOW - 5 })}`, jwks, { denylist, now: NOW }), { expired: true, user: null });
// A token minted after the revoke (fresh login) is accepted; a different subject is untouched.
assert.deepEqual(await verifyToken(mint(k1.privateKey, "k1", { ...valid, iat: NOW + 5 }), jwks, { denylist, now: NOW }), { email: "a@b.c", id: "u1", roles: ["admin"] });
await verifyToken(mint(k1.privateKey, "k1", { ...valid, iat: NOW - 5, sub: "u2" }), jwks, { denylist, now: NOW });
});

View File

@@ -5,6 +5,7 @@
// (anonymous), so the route renders signed-out and the permission gate denies.
import type { User } from "./context.ts";
import { parseCookies } from "./cookie.ts";
import type { Denylist } from "./denylist.ts";
import { decodeJws, verifyJws } from "./jwt.ts";
import type { JwksProvider } from "./jwks.ts";
import { SESSION_COOKIE } from "./login.ts";
@@ -15,6 +16,7 @@ const DEFAULT_CLOCK_SKEW_SEC = 60;
export interface VerifyOptions {
audience?: string | undefined; // if set, the token `aud` must include it (else skipped)
clockSkewSec?: number | undefined;
denylist?: Pick<Denylist, "isRevoked"> | undefined; // optional instant-revoke (§9); a revoked sub is rejected like an expiry
issuer?: string | undefined; // if set, the token `iss` must equal it (else skipped)
now?: number | undefined; // unix seconds; injectable for tests
}
@@ -75,7 +77,11 @@ export async function verifyToken(token: string, jwks: JwksProvider, options: Ve
if (!jwk) throw new TokenError(`no JWKS key for kid ${header.kid ?? "(none)"}`);
const verified = verifyJws(token, jwk); // throws on a bad signature / disallowed alg
validateClaims(verified.payload, options);
return claimsToUser(verified.payload);
const user = claimsToUser(verified.payload);
// Instant revoke (§9): a denylisted subject's pre-revoke token is rejected as *expired* so
// resolveSession routes it through the §4 re-mint (fresh roles from Keto, or a cleared session).
if (options.denylist?.isRevoked(user.id, num(verified.payload, "iat"))) throw new TokenError("token revoked", true);
return user;
}
export interface SessionAuth {

View File

@@ -1,5 +1,6 @@
import { createApp } from "./app.ts";
import { loadConfig } from "./config.ts";
import { createDenylist } from "./denylist.ts";
import { discoverPlugins } from "./discovery.ts";
import { withTimeout } from "./fetch-timeout.ts";
import { runBootHooks } from "./hooks.ts";
@@ -23,6 +24,9 @@ const hydra = createHydraAdmin({ baseUrl: config.hydraAdminUrl, fetchImpl: oryFe
// Session-JWT verify key: primed at boot from the configured JWKS (file mount, base64 inline,
// or fetched http), then served from cache with TTL refresh + rotation-on-miss (§4).
const jwks = await createJwksProvider(config.jwksUrl, { fetchImpl: oryFetch }); // bound an http JWKS fetch too
// Optional instant-revoke (§9), off unless REVOCATION_DENYLIST=true: an in-memory denylist the
// hot path consults and the admin screens populate on deactivate/delete/role-change.
const denylist = config.revocationDenylist ? createDenylist({ ttlSec: config.revocationTtlSec }) : undefined;
const plugins = await discoverPlugins(); // scans plugins/, validates — fails loud on a bad plugin
console.log(`Discovered ${plugins.length} plugin(s)${plugins.length ? `: ${plugins.map((p) => p.id).join(", ")}` : ""}`);
@@ -32,6 +36,7 @@ const server = createApp({
auth: { audience: config.jwtAudience, clockSkewSec: config.jwtClockSkewSec, issuer: config.jwtIssuer },
cache: config.cacheTemplates,
csrfSecret: config.csrfSecret,
...(denylist ? { denylist } : {}),
hydra,
jwks,
keto,