§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:
28
README.md
28
README.md
@@ -156,6 +156,8 @@ auto-merged by `docker compose up`) turns them back off for live editing.
|
|||||||
| `JWT_ISSUER` / `JWT_AUDIENCE` | _unset_ | optional: when set, the session JWT's `iss` / `aud` must match (the dev tokenizer sets neither) |
|
| `JWT_ISSUER` / `JWT_AUDIENCE` | _unset_ | optional: when set, the session JWT's `iss` / `aud` must match (the dev tokenizer sets neither) |
|
||||||
| `JWT_CLOCK_SKEW_SEC` | `60` | exp/nbf leeway (s) for Kratos↔web clock drift (the auth E2E sets `0`) |
|
| `JWT_CLOCK_SKEW_SEC` | `60` | exp/nbf leeway (s) for Kratos↔web clock drift (the auth E2E sets `0`) |
|
||||||
| `ORY_TIMEOUT_SEC` | `5` | per-call timeout for outbound Kratos/Keto/Hydra (and http JWKS) fetches, so a hung Ory can't park a request |
|
| `ORY_TIMEOUT_SEC` | `5` | per-call timeout for outbound Kratos/Keto/Hydra (and http JWKS) fetches, so a hung Ory can't park a request |
|
||||||
|
| `REVOCATION_DENYLIST` | `false` | when `true`, enable the optional [instant role/session revoke denylist](#instant-revoke-the-optional-denylist) |
|
||||||
|
| `REVOCATION_TTL_SEC` | `900` | how long a revoke entry lives; keep ≥ tokenizer TTL (10m) + clock skew |
|
||||||
| `CSRF_SECRET` | dev throwaway | signs our double-submit CSRF token; enforced by `REQUIRE_SECURE_SECRETS` |
|
| `CSRF_SECRET` | dev throwaway | signs our double-submit CSRF token; enforced by `REQUIRE_SECURE_SECRETS` |
|
||||||
|
|
||||||
### What you must supply (the only manual prep)
|
### What you must supply (the only manual prep)
|
||||||
@@ -496,14 +498,33 @@ users** on modest hardware. In return:
|
|||||||
- **Role changes lag by up to one TTL (~10m).** Gating reads the JWT, not Keto, so a
|
- **Role changes lag by up to one TTL (~10m).** Gating reads the JWT, not Keto, so a
|
||||||
granted or revoked role only takes effect when the token is next minted (re-login or
|
granted or revoked role only takes effect when the token is next minted (re-login or
|
||||||
TTL refresh). For an admin tool this is intentional — the alternative is a Keto call
|
TTL refresh). For an admin tool this is intentional — the alternative is a Keto call
|
||||||
per request, which we traded away. For instant revoke, the optional revocation
|
per request, which we traded away. For instant revoke, turn on the optional
|
||||||
denylist (roadmap) closes the gap for security-critical cases without putting Keto
|
[revocation denylist](#instant-revoke-the-optional-denylist) — it closes the gap for
|
||||||
back on the hot path.
|
security-critical cases without putting Keto back on the hot path.
|
||||||
- **Ory is on the critical path for sign-in.** If Kratos is down no one can log in; if
|
- **Ory is on the critical path for sign-in.** If Kratos is down no one can log in; if
|
||||||
it stays down past the TTL, existing sessions can't refresh and the UI goes dark.
|
it stays down past the TTL, existing sessions can't refresh and the UI goes dark.
|
||||||
That's the direct consequence of being stateless and delegating identity — no local
|
That's the direct consequence of being stateless and delegating identity — no local
|
||||||
fallback, by design. Run Ory with the availability you'd give any auth provider.
|
fallback, by design. Run Ory with the availability you'd give any auth provider.
|
||||||
|
|
||||||
|
### Instant revoke — the optional denylist
|
||||||
|
|
||||||
|
Off by default; turn it on with `REVOCATION_DENYLIST=true` (`src/denylist.ts`). For
|
||||||
|
security-critical revoke (offboarding, a compromised account) the ~10m role/session lag
|
||||||
|
above is too long. When enabled, an admin **deactivating** or **deleting** a user, or
|
||||||
|
**granting/revoking** a role to a *user*, records that subject as revoked-now; the hot path
|
||||||
|
then rejects every token for it minted **before** the revoke and forces a re-mint — which
|
||||||
|
re-reads roles from Keto, or clears a now-dead session. A fresh re-login (its JWT issued
|
||||||
|
*after* the revoke) passes, so a role downgrade lands immediately without locking the account.
|
||||||
|
|
||||||
|
It's an in-memory, auto-evicting map — no database, like the JWKS cache, so it stays inside the
|
||||||
|
stateless model. Entries self-evict after `REVOCATION_TTL_SEC` (default 900s ≥ the 10m token TTL
|
||||||
|
+ skew), by which point any pre-revoke token has expired anyway. The check is pure CPU — **Keto
|
||||||
|
stays off the hot path**. Two deliberate bounds: it's instant on the **single instance** that
|
||||||
|
handled the revoke (across replicas/restarts the guarantee falls back to the token TTL — back the
|
||||||
|
denylist with a shared store for hard multi-instance instant-revoke), and a **group** membership
|
||||||
|
change is transitive across many users, so it's left to lag — deactivate the user, or use a direct
|
||||||
|
user-role change, for an instant effect.
|
||||||
|
|
||||||
### Three tiers of "may I?"
|
### Three tiers of "may I?"
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -610,6 +631,7 @@ src/gen-jwks.ts generateJwks() + CLI: mint the ES256 session-tokenizer sign
|
|||||||
src/bootstrap.ts One-command bootstrap (§3): idempotent first-boot seed — JWKS-if-absent, demo admin in Kratos, admin role in Keto
|
src/bootstrap.ts One-command bootstrap (§3): idempotent first-boot seed — JWKS-if-absent, demo admin in Kratos, admin role in Keto
|
||||||
src/cookie.ts Cookie parse + secure Set-Cookie build (session/CSRF cookies, §4)
|
src/cookie.ts Cookie parse + secure Set-Cookie build (session/CSRF cookies, §4)
|
||||||
src/csrf.ts CSRF for our own POST forms (§4): signed double-submit token — issue/verify, cookie, request gate
|
src/csrf.ts CSRF for our own POST forms (§4): signed double-submit token — issue/verify, cookie, request gate
|
||||||
|
src/denylist.ts Optional instant-revoke denylist (§9): in-memory, auto-evicting; hot path rejects a revoked subject's pre-revoke tokens (REVOCATION_DENYLIST)
|
||||||
src/security-headers.ts Response security headers set on every reply (§9): strict CSP (zero-JS), nosniff, X-Frame-Options/frame-ancestors, Referrer-Policy, HSTS over https
|
src/security-headers.ts Response security headers set on every reply (§9): strict CSP (zero-JS), nosniff, X-Frame-Options/frame-ancestors, Referrer-Policy, HSTS over https
|
||||||
src/body.ts readFormBody(): read + size-cap an x-www-form-urlencoded request body (CSRF gate + §5 forms)
|
src/body.ts readFormBody(): read + size-cap an x-www-form-urlencoded request body (CSRF gate + §5 forms)
|
||||||
src/context.ts RequestContext handed to handlers + buildContext()
|
src/context.ts RequestContext handed to handlers + buildContext()
|
||||||
|
|||||||
@@ -272,6 +272,14 @@ export interface AdminRolesDeps {
|
|||||||
kratosAdmin: KratosAdmin;
|
kratosAdmin: KratosAdmin;
|
||||||
menu: MenuConfig;
|
menu: MenuConfig;
|
||||||
render: (view: string, data: Record<string, unknown>) => Promise<string>;
|
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).
|
// 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 === "GET") return renderList();
|
||||||
if (method === "POST") {
|
if (method === "POST") {
|
||||||
const name = (form!.get("name") ?? "").trim();
|
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> =>
|
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 (!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 (!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.");
|
if (await roleExists(keto, name)) return reject("A role with that name already exists.");
|
||||||
await keto.writeTuple(tuple);
|
await keto.writeTuple(tuple);
|
||||||
|
revokeUserMember(deps, member);
|
||||||
return { redirect: detailHref(name) };
|
return { redirect: detailHref(name) };
|
||||||
}
|
}
|
||||||
return null;
|
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 === 1 && method === "GET") return renderDetail(name);
|
||||||
|
|
||||||
if (seg.length === 2 && seg[1] === "members" && method === "POST") {
|
if (seg.length === 2 && seg[1] === "members" && method === "POST") {
|
||||||
const tuple = roleMemberTuple(name, (form!.get("member") ?? "").trim());
|
const member = (form!.get("member") ?? "").trim();
|
||||||
if (tuple) await keto.writeTuple(tuple); // the picker only offers real users/groups
|
const tuple = roleMemberTuple(name, member);
|
||||||
|
if (tuple) { await keto.writeTuple(tuple); revokeUserMember(deps, member); } // the picker only offers real users/groups
|
||||||
return { redirect: base };
|
return { redirect: base };
|
||||||
}
|
}
|
||||||
if (seg.length === 2 && seg[1] === "delete" && method === "GET") {
|
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 (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.");
|
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
|
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 };
|
return { redirect: ADMIN_ROLES_BASE };
|
||||||
}
|
}
|
||||||
if (seg.length === 3 && seg[1] === "members" && seg[2] === "delete" && method === "POST") {
|
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.
|
// 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.");
|
if (name === ADMIN_PERMISSION && member === `user:${user.id}`) return renderDetail(name, "You can't revoke your own admin access.");
|
||||||
const tuple = roleMemberTuple(name, member);
|
const tuple = roleMemberTuple(name, member);
|
||||||
if (tuple) await keto.deleteTuple(tuple);
|
if (tuple) { await keto.deleteTuple(tuple); revokeUserMember(deps, member); }
|
||||||
return { redirect: base };
|
return { redirect: base };
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -286,6 +286,7 @@ export interface AdminUsersDeps {
|
|||||||
kratosAdmin: KratosAdmin;
|
kratosAdmin: KratosAdmin;
|
||||||
menu: MenuConfig;
|
menu: MenuConfig;
|
||||||
render: (view: string, data: Record<string, unknown>) => Promise<string>;
|
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 {
|
function readUserInput(form: URLSearchParams): UserInput {
|
||||||
@@ -370,12 +371,15 @@ export async function handleAdminUsers(ctx: RequestContext, csrfToken: string, d
|
|||||||
if (method === "POST") {
|
if (method === "POST") {
|
||||||
if (seg[1] === "state") {
|
if (seg[1] === "state") {
|
||||||
if (isSelf) return { ...(await renderForm({ error: "You can't deactivate your own account.", identity })), status: 400 };
|
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 };
|
return { redirect: back };
|
||||||
}
|
}
|
||||||
if (seg[1] === "delete") {
|
if (seg[1] === "delete") {
|
||||||
if (isSelf) return { ...(await renderForm({ error: "You can't delete your own account.", identity })), status: 400 };
|
if (isSelf) return { ...(await renderForm({ error: "You can't delete your own account.", identity })), status: 400 };
|
||||||
await kratosAdmin.deleteIdentity(targetId);
|
await kratosAdmin.deleteIdentity(targetId);
|
||||||
|
deps.revoke?.(targetId); // §9: the account is gone — reject its live tokens immediately
|
||||||
return { redirect: ADMIN_USERS_BASE };
|
return { redirect: ADMIN_USERS_BASE };
|
||||||
}
|
}
|
||||||
if (seg[1] === "recovery") {
|
if (seg[1] === "recovery") {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { after, before, test, type TestContext } from "node:test";
|
|||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { createApp, type AppOptions } from "./app.ts";
|
import { createApp, type AppOptions } from "./app.ts";
|
||||||
import { readFormBody } from "./body.ts";
|
import { readFormBody } from "./body.ts";
|
||||||
|
import { createDenylist } from "./denylist.ts";
|
||||||
import { CSRF_COOKIE, issueCsrfToken } from "./csrf.ts";
|
import { CSRF_COOKIE, issueCsrfToken } from "./csrf.ts";
|
||||||
import { can, check, GuardError, requireSession } from "./guards.ts";
|
import { can, check, GuardError, requireSession } from "./guards.ts";
|
||||||
import { HydraError, type HydraAdmin, type OAuth2Client } from "./hydra-admin.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
|
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) => {
|
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 identity: Identity = { id: "u1", traits: { email: "a@b.c" } };
|
||||||
const nowSec = Math.floor(Date.now() / 1000);
|
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 }),
|
listIdentities: async () => ({ identities: store, nextPageToken: null }),
|
||||||
updateIdentity: async (id, payload) => { const it = store.find((x) => x.id === id)!; Object.assign(it, payload); return it; },
|
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");
|
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.equal(updated.status, 303);
|
||||||
assert.deepEqual((target.traits as { name: unknown }).name, { first: "Ada", last: "King" });
|
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}`);
|
await post(`/admin/users/${target.id}/state`, `_csrf=${token}`);
|
||||||
assert.equal(target.state, "inactive");
|
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).
|
// 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}`);
|
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 keto = fakeKeto(tuples, { expand: async (set) => expandSet(set) });
|
||||||
const kratosAdmin = stubAdmin({ listIdentities: async () => ({ identities, nextPageToken: null }) });
|
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");
|
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.status, 303);
|
||||||
assert.equal(created.headers.get("location"), "/admin/roles/viewer");
|
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.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.
|
// An invalid name, a duplicate name, or a missing CSRF token are all refused, nothing written.
|
||||||
const before = tuples.length;
|
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`);
|
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"));
|
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.
|
// 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/);
|
assert.match(await (await get("/admin/roles/editor/delete")).text(), /Cancel/);
|
||||||
const del = await post("/admin/roles/editor/delete", `_csrf=${token}`);
|
const del = await post("/admin/roles/editor/delete", `_csrf=${token}`);
|
||||||
|
|||||||
13
src/app.ts
13
src/app.ts
@@ -12,6 +12,7 @@ import { readFormBody } from "./body.ts";
|
|||||||
import { buildPluginChrome, type PageChrome } from "./chrome.ts";
|
import { buildPluginChrome, type PageChrome } from "./chrome.ts";
|
||||||
import { buildContext, type User } from "./context.ts";
|
import { buildContext, type User } from "./context.ts";
|
||||||
import { CSRF_FIELD, csrfCookie, ensureCsrfToken, verifyCsrfRequest } from "./csrf.ts";
|
import { CSRF_FIELD, csrfCookie, ensureCsrfToken, verifyCsrfRequest } from "./csrf.ts";
|
||||||
|
import type { Denylist } from "./denylist.ts";
|
||||||
import { buildDashboardModel } from "./dashboard.ts";
|
import { buildDashboardModel } from "./dashboard.ts";
|
||||||
import { PLUGINS_DIR } from "./discovery.ts";
|
import { PLUGINS_DIR } from "./discovery.ts";
|
||||||
import { GuardError } from "./guards.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.
|
// Off by default so edits show live; the app itself never inspects the environment.
|
||||||
cache?: boolean;
|
cache?: boolean;
|
||||||
csrfSecret?: string; // HMAC key for the double-submit CSRF token (config.csrfSecret); random if omitted
|
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)
|
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
|
jwks?: JwksProvider; // verify the session JWT → ctx.user/roles (§4); absent ⇒ always anonymous
|
||||||
keto?: KetoClient; // Keto client; with kratos+kratosAdmin enables login completion (§4)
|
keto?: KetoClient; // Keto client; with kratos+kratosAdmin enables login completion (§4)
|
||||||
@@ -55,7 +57,12 @@ export interface AppOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createApp(options: AppOptions = {}): Server {
|
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 cache = options.cache ?? false;
|
||||||
const csrfSecret = options.csrfSecret ?? randomBytes(32).toString("hex"); // server passes config; tests pass their own
|
const csrfSecret = options.csrfSecret ?? randomBytes(32).toString("hex"); // server passes config; tests pass their own
|
||||||
const secureCookies = options.secureCookies ?? false;
|
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
|
// 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.
|
// 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.
|
// 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 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.
|
// 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;
|
const adminClientsDeps: AdminClientsDeps | null = hydra ? { csrfSecret, hydra, menu, render } : null;
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,15 @@ test("loads dev defaults when the environment is empty", () => {
|
|||||||
assert.equal(c.hydraAdminUrl, "http://hydra:4445");
|
assert.equal(c.hydraAdminUrl, "http://hydra:4445");
|
||||||
assert.match(c.csrfSecret, /dev-insecure/);
|
assert.match(c.csrfSecret, /dev-insecure/);
|
||||||
assert.equal(c.jwtClockSkewSec, 60); // default exp/nbf leeway for Kratos↔web clock drift
|
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", () => {
|
test("JWKS_URL defaults to the committed Kratos tokenizer signing key, not an http endpoint", () => {
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ export interface Config {
|
|||||||
kratosPublicUrl: string;
|
kratosPublicUrl: string;
|
||||||
oryTimeoutSec: number; // per-call timeout for outbound Kratos/Keto/Hydra fetches (bounds a hung Ory)
|
oryTimeoutSec: number; // per-call timeout for outbound Kratos/Keto/Hydra fetches (bounds a hung Ory)
|
||||||
port: number;
|
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;
|
secureCookies: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,6 +114,11 @@ export function loadConfig(env: Env = process.env): Config {
|
|||||||
kratosPublicUrl: readUrl(env, "KRATOS_PUBLIC_URL", "http://kratos:4433"),
|
kratosPublicUrl: readUrl(env, "KRATOS_PUBLIC_URL", "http://kratos:4433"),
|
||||||
oryTimeoutSec: readPosInt(env, "ORY_TIMEOUT_SEC", 5),
|
oryTimeoutSec: readPosInt(env, "ORY_TIMEOUT_SEC", 5),
|
||||||
port: readPort(env),
|
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.
|
// Set Secure on our session/CSRF cookies. Off by default (dev runs http); prod (https) sets it.
|
||||||
secureCookies: readBool(env, "SECURE_COOKIES", false),
|
secureCookies: readBool(env, "SECURE_COOKIES", false),
|
||||||
};
|
};
|
||||||
|
|||||||
37
src/denylist.test.ts
Normal file
37
src/denylist.test.ts
Normal 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
56
src/denylist.ts
Normal 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
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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(cookie({ exp: NOW - 999 }), jwks, { now: NOW }), null); // expired ⇒ null
|
||||||
assert.equal(await authenticate(undefined, jwks, { now: NOW }), 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 });
|
||||||
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
// (anonymous), so the route renders signed-out and the permission gate denies.
|
// (anonymous), so the route renders signed-out and the permission gate denies.
|
||||||
import type { User } from "./context.ts";
|
import type { User } from "./context.ts";
|
||||||
import { parseCookies } from "./cookie.ts";
|
import { parseCookies } from "./cookie.ts";
|
||||||
|
import type { Denylist } from "./denylist.ts";
|
||||||
import { decodeJws, verifyJws } from "./jwt.ts";
|
import { decodeJws, verifyJws } from "./jwt.ts";
|
||||||
import type { JwksProvider } from "./jwks.ts";
|
import type { JwksProvider } from "./jwks.ts";
|
||||||
import { SESSION_COOKIE } from "./login.ts";
|
import { SESSION_COOKIE } from "./login.ts";
|
||||||
@@ -15,6 +16,7 @@ const DEFAULT_CLOCK_SKEW_SEC = 60;
|
|||||||
export interface VerifyOptions {
|
export interface VerifyOptions {
|
||||||
audience?: string | undefined; // if set, the token `aud` must include it (else skipped)
|
audience?: string | undefined; // if set, the token `aud` must include it (else skipped)
|
||||||
clockSkewSec?: number | undefined;
|
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)
|
issuer?: string | undefined; // if set, the token `iss` must equal it (else skipped)
|
||||||
now?: number | undefined; // unix seconds; injectable for tests
|
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)"}`);
|
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
|
const verified = verifyJws(token, jwk); // throws on a bad signature / disallowed alg
|
||||||
validateClaims(verified.payload, options);
|
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 {
|
export interface SessionAuth {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createApp } from "./app.ts";
|
import { createApp } from "./app.ts";
|
||||||
import { loadConfig } from "./config.ts";
|
import { loadConfig } from "./config.ts";
|
||||||
|
import { createDenylist } from "./denylist.ts";
|
||||||
import { discoverPlugins } from "./discovery.ts";
|
import { discoverPlugins } from "./discovery.ts";
|
||||||
import { withTimeout } from "./fetch-timeout.ts";
|
import { withTimeout } from "./fetch-timeout.ts";
|
||||||
import { runBootHooks } from "./hooks.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,
|
// 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).
|
// 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
|
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
|
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(", ")}` : ""}`);
|
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 },
|
auth: { audience: config.jwtAudience, clockSkewSec: config.jwtClockSkewSec, issuer: config.jwtIssuer },
|
||||||
cache: config.cacheTemplates,
|
cache: config.cacheTemplates,
|
||||||
csrfSecret: config.csrfSecret,
|
csrfSecret: config.csrfSecret,
|
||||||
|
...(denylist ? { denylist } : {}),
|
||||||
hydra,
|
hydra,
|
||||||
jwks,
|
jwks,
|
||||||
keto,
|
keto,
|
||||||
|
|||||||
2
todo.md
2
todo.md
@@ -127,7 +127,7 @@ everything via Docker.
|
|||||||
## 9. Production, security, ops
|
## 9. Production, security, ops
|
||||||
- [x] `compose.yml` prod: Ory + Postgres, secrets via env, no source mount. → The base file was already the full prod stack (web + Postgres + Kratos/Keto/Hydra + migrations + the one-shot bootstrap; `.:/app` lives only in the dev override), built during §3. **The real gap, now closed:** it set `REQUIRE_SECURE_SECRETS=true` but never wired `CSRF_SECRET` into `web`, so `docker compose -f compose.yml up` couldn't boot. Added `CSRF_SECRET: ${CSRF_SECRET:-dev-insecure-csrf-secret}` — env-supplied with the throwaway as the only fallback; `config.ts`'s existing `REQUIRE_SECURE_SECRETS` logic rejects that throwaway, so a forgotten prod secret **fails loud** (verified all three paths: prod-unset→reject, prod-set→real secret, dev→throwaway + toggle off → boots). Used `:-` not `:?` because compose interpolates the base file per-file *before* merging the override (confirmed empirically), so a `:?` in the base would also break the zero-config dev `docker compose up`. Tests-first: extended `compose.test.ts` (secret-via-env + no-source-mount + the prod/dev toggle split + postgres-creds-via-env). README prod section corrected (dropped the stale "_(… Ory + Postgres — planned)_"). typecheck + 310 units green.
|
- [x] `compose.yml` prod: Ory + Postgres, secrets via env, no source mount. → The base file was already the full prod stack (web + Postgres + Kratos/Keto/Hydra + migrations + the one-shot bootstrap; `.:/app` lives only in the dev override), built during §3. **The real gap, now closed:** it set `REQUIRE_SECURE_SECRETS=true` but never wired `CSRF_SECRET` into `web`, so `docker compose -f compose.yml up` couldn't boot. Added `CSRF_SECRET: ${CSRF_SECRET:-dev-insecure-csrf-secret}` — env-supplied with the throwaway as the only fallback; `config.ts`'s existing `REQUIRE_SECURE_SECRETS` logic rejects that throwaway, so a forgotten prod secret **fails loud** (verified all three paths: prod-unset→reject, prod-set→real secret, dev→throwaway + toggle off → boots). Used `:-` not `:?` because compose interpolates the base file per-file *before* merging the override (confirmed empirically), so a `:?` in the base would also break the zero-config dev `docker compose up`. Tests-first: extended `compose.test.ts` (secret-via-env + no-source-mount + the prod/dev toggle split + postgres-creds-via-env). README prod section corrected (dropped the stale "_(… Ory + Postgres — planned)_"). typecheck + 310 units green.
|
||||||
- [x] Security headers; secure/HttpOnly/SameSite cookies; CSRF; clock-skew tolerance. → Cookies (HttpOnly · SameSite=Lax · Secure-when-`SECURE_COOKIES`, `src/cookie.ts`), the signed double-submit CSRF (`src/csrf.ts`), and JWT clock-skew leeway (`JWT_CLOCK_SKEW_SEC`, applied to exp+nbf in `validateClaims`) all landed in §4 — the open gap was **response security headers**, now closed. New pure `src/security-headers.ts` (`securityHeaders({secure})`): a strict CSP for the zero-JS core — `default-src 'self'`, `script-src 'self'` with **no** `'unsafe-inline'` (an injected `<script>` can't run; core ships none, a plugin may still serve its own `/public/<id>/*.js`), `style-src` adds `'unsafe-inline'` for the partials' inline `style=`, `img-src 'self' data:`, `frame-ancestors 'none'`, `object-src 'none'`; **`form-action` deliberately omitted** (the themed login POSTs to Kratos' often-cross-origin action URL) — plus `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: strict-origin-when-cross-origin`, `Cross-Origin-Opener-Policy: same-origin`, and HSTS only when `secureCookies` (https; ignored on dev http). Wired in `app.ts`: precomputed once at boot, `res.setHeader`'d at the very top of the handler before any branch, so **every** response (page/json/redirect/static/error/plugin) inherits them via `writeHead`'s merge; a plugin overrides per-route via `RouteResult.headers`. Verified no view/CSS loads cross-origin (no `<script>` anywhere, no external fonts/CDNs), so `default-src 'self'` breaks nothing. Tests-first: `security-headers.test.ts` (strict defaults, `script-src` has no `'unsafe-inline'`, HSTS-only-on-secure) + an `app.test.ts` integration (the page **and** a static asset both carry the headers; HSTS toggles with `SECURE_COOKIES`). Stability-reviewer on the diff: **APPROVE, no Critical/High** (Low: a CDN/absolute branding logo would be CSP-blocked → documented the same-origin-logo constraint). README Status + Production + Layout updated. typecheck + 312 units green.
|
- [x] Security headers; secure/HttpOnly/SameSite cookies; CSRF; clock-skew tolerance. → Cookies (HttpOnly · SameSite=Lax · Secure-when-`SECURE_COOKIES`, `src/cookie.ts`), the signed double-submit CSRF (`src/csrf.ts`), and JWT clock-skew leeway (`JWT_CLOCK_SKEW_SEC`, applied to exp+nbf in `validateClaims`) all landed in §4 — the open gap was **response security headers**, now closed. New pure `src/security-headers.ts` (`securityHeaders({secure})`): a strict CSP for the zero-JS core — `default-src 'self'`, `script-src 'self'` with **no** `'unsafe-inline'` (an injected `<script>` can't run; core ships none, a plugin may still serve its own `/public/<id>/*.js`), `style-src` adds `'unsafe-inline'` for the partials' inline `style=`, `img-src 'self' data:`, `frame-ancestors 'none'`, `object-src 'none'`; **`form-action` deliberately omitted** (the themed login POSTs to Kratos' often-cross-origin action URL) — plus `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: strict-origin-when-cross-origin`, `Cross-Origin-Opener-Policy: same-origin`, and HSTS only when `secureCookies` (https; ignored on dev http). Wired in `app.ts`: precomputed once at boot, `res.setHeader`'d at the very top of the handler before any branch, so **every** response (page/json/redirect/static/error/plugin) inherits them via `writeHead`'s merge; a plugin overrides per-route via `RouteResult.headers`. Verified no view/CSS loads cross-origin (no `<script>` anywhere, no external fonts/CDNs), so `default-src 'self'` breaks nothing. Tests-first: `security-headers.test.ts` (strict defaults, `script-src` has no `'unsafe-inline'`, HSTS-only-on-secure) + an `app.test.ts` integration (the page **and** a static asset both carry the headers; HSTS toggles with `SECURE_COOKIES`). Stability-reviewer on the diff: **APPROVE, no Critical/High** (Low: a CDN/absolute branding logo would be CSP-blocked → documented the same-origin-logo constraint). README Status + Production + Layout updated. typecheck + 312 units green.
|
||||||
- [ ] Optional revocation denylist for instant role/session revoke.
|
- [x] Optional revocation denylist for instant role/session revoke. → 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: a comment noting whole-role delete lags like a group change). Per the §9 security-headers precedent, covered by unit + app-HTTP integration (no new browser E2E — no new user-facing page; the operator toggle + handler paths are exercised directly). README (Auth trade-off + a new "Instant revoke" subsection, config table, Layout) updated. typecheck + 317 units green.
|
||||||
- [ ] Structured logging / basic observability. use @larvit/log for OTLP compability dig down in how to use it properly.
|
- [ ] Structured logging / basic observability. use @larvit/log for OTLP compability dig down in how to use it properly.
|
||||||
- [ ] JWT signing-key rotation runbook.
|
- [ ] JWT signing-key rotation runbook.
|
||||||
- [ ] Refresh README `Layout` + drop `_(planned)_` markers as pieces land.
|
- [ ] Refresh README `Layout` + drop `_(planned)_` markers as pieces land.
|
||||||
|
|||||||
Reference in New Issue
Block a user