Rename the coarse gate from role to permission, matching RBAC
This commit is contained in:
+24
-24
@@ -1,11 +1,11 @@
|
||||
// One-command bootstrap: idempotent first-boot seeding. Guards the pure payload
|
||||
// builders (Kratos create-identity body + Keto role tuple), the idempotent seedAdmin
|
||||
// builders (Kratos create-identity body + Keto permission tuple), the idempotent seedAdmin
|
||||
// orchestration (fresh 201 vs existing 409 → reuse id), and the JWKS generate-if-absent
|
||||
// safety net. Live boot is verified by running the stack; these catch contract drift.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { ensureJwks, firstRunBanner, identityPayload, roleTuple, seedAdmin, seedRoles } from "./bootstrap.ts";
|
||||
import { ensureJwks, firstRunBanner, identityPayload, permissionTuple, seedAdmin, seedPermissions } from "./bootstrap.ts";
|
||||
|
||||
const json = (status: number, body?: unknown) =>
|
||||
new Response(body === undefined ? null : JSON.stringify(body), {
|
||||
@@ -20,27 +20,27 @@ test("identityPayload is a valid Kratos create-identity body with a password cre
|
||||
assert.equal(body.credentials.password.config.password, "admin");
|
||||
});
|
||||
|
||||
test("roleTuple grants a role to identity:<id> in the Role namespace", () => {
|
||||
test("permissionTuple grants a permission to identity:<id> in the Permission namespace", () => {
|
||||
const id = randomUUID();
|
||||
assert.deepEqual(roleTuple(id, "admin"), {
|
||||
namespace: "Role",
|
||||
assert.deepEqual(permissionTuple(id, "admin"), {
|
||||
namespace: "Permission",
|
||||
object: "admin",
|
||||
relation: "members",
|
||||
relation: "granted",
|
||||
subject_id: `identity:${id}`,
|
||||
});
|
||||
});
|
||||
|
||||
test("seedRoles unions ADMIN_ROLES (default 'admin') with the discovered plugins' declared roles", () => {
|
||||
// Clean clone: no ADMIN_ROLES, the scheduling plugin declares its two tokens → the demo admin
|
||||
test("seedPermissions unions ADMIN_PERMISSIONS (default 'admin') with the discovered plugins' declared permissions", () => {
|
||||
// Clean clone: no ADMIN_PERMISSIONS, the scheduling plugin declares its two tokens → the demo admin
|
||||
// gets exactly today's behaviour, but derived from discovery, not hardcoded in the host.
|
||||
assert.deepEqual(seedRoles(undefined, ["scheduling:read", "scheduling:write"]), ["admin", "scheduling:read", "scheduling:write"]);
|
||||
assert.deepEqual(seedRoles(undefined, []), ["admin"]); // no plugins → just the base admin role
|
||||
assert.deepEqual(seedRoles("admin, ops ", ["inventory:read"]), ["admin", "ops", "inventory:read"]); // env trimmed + extended
|
||||
assert.deepEqual(seedRoles("admin,scheduling:read", ["scheduling:read"]), ["admin", "scheduling:read"]); // dedup, no double grant
|
||||
assert.deepEqual(seedRoles("admin,, ", [" scheduling:read ", ""]), ["admin", "scheduling:read"]); // blanks dropped, tokens trimmed (both sides)
|
||||
assert.deepEqual(seedPermissions(undefined, ["scheduling:read", "scheduling:write"]), ["admin", "scheduling:read", "scheduling:write"]);
|
||||
assert.deepEqual(seedPermissions(undefined, []), ["admin"]); // no plugins → just the base admin permission
|
||||
assert.deepEqual(seedPermissions("admin, ops ", ["inventory:read"]), ["admin", "ops", "inventory:read"]); // env trimmed + extended
|
||||
assert.deepEqual(seedPermissions("admin,scheduling:read", ["scheduling:read"]), ["admin", "scheduling:read"]); // dedup, no double grant
|
||||
assert.deepEqual(seedPermissions("admin,, ", [" scheduling:read ", ""]), ["admin", "scheduling:read"]); // blanks dropped, tokens trimmed (both sides)
|
||||
});
|
||||
|
||||
test("seedAdmin on a fresh stack creates the identity and grants every role (one tuple each)", async () => {
|
||||
test("seedAdmin on a fresh stack creates the identity and grants every permission (one tuple each)", async () => {
|
||||
const id = randomUUID();
|
||||
const calls: { method: string; url: string; body?: unknown }[] = [];
|
||||
const fetchImpl = (async (url, init) => {
|
||||
@@ -57,20 +57,20 @@ test("seedAdmin on a fresh stack creates the identity and grants every role (one
|
||||
ketoWriteUrl: "http://keto:4467",
|
||||
kratosAdminUrl: "http://kratos:4434",
|
||||
password: "admin",
|
||||
roles: ["admin", "scheduling:read"],
|
||||
permissions: ["admin", "scheduling:read"],
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { created: true, id, roles: ["admin", "scheduling:read"] });
|
||||
assert.deepEqual(result, { created: true, id, permissions: ["admin", "scheduling:read"] });
|
||||
const puts = calls.filter((c) => c.url.includes("relation-tuples"));
|
||||
assert.equal(puts.length, 2); // one grant per role
|
||||
assert.equal(puts.length, 2); // one grant per permission
|
||||
assert.ok(puts.every((p) => p.method === "PUT"));
|
||||
assert.deepEqual(puts.map((p) => p.body), [
|
||||
{ namespace: "Role", object: "admin", relation: "members", subject_id: `identity:${id}` },
|
||||
{ namespace: "Role", object: "scheduling:read", relation: "members", subject_id: `identity:${id}` },
|
||||
{ namespace: "Permission", object: "admin", relation: "granted", subject_id: `identity:${id}` },
|
||||
{ namespace: "Permission", object: "scheduling:read", relation: "granted", subject_id: `identity:${id}` },
|
||||
]);
|
||||
});
|
||||
|
||||
test("seedAdmin is idempotent: a 409 reuses the existing identity and re-grants the role", async () => {
|
||||
test("seedAdmin is idempotent: a 409 reuses the existing identity and re-grants the permission", async () => {
|
||||
const id = randomUUID();
|
||||
let granted: unknown;
|
||||
const fetchImpl = (async (url, init) => {
|
||||
@@ -90,11 +90,11 @@ test("seedAdmin is idempotent: a 409 reuses the existing identity and re-grants
|
||||
ketoWriteUrl: "http://keto:4467",
|
||||
kratosAdminUrl: "http://kratos:4434",
|
||||
password: "admin",
|
||||
roles: ["admin"],
|
||||
permissions: ["admin"],
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { created: false, id, roles: ["admin"] });
|
||||
assert.deepEqual(granted, { namespace: "Role", object: "admin", relation: "members", subject_id: `identity:${id}` });
|
||||
assert.deepEqual(result, { created: false, id, permissions: ["admin"] });
|
||||
assert.deepEqual(granted, { namespace: "Permission", object: "admin", relation: "granted", subject_id: `identity:${id}` });
|
||||
});
|
||||
|
||||
test("seedAdmin fails loud on an unexpected Kratos error", async () => {
|
||||
@@ -106,7 +106,7 @@ test("seedAdmin fails loud on an unexpected Kratos error", async () => {
|
||||
ketoWriteUrl: "http://keto:4467",
|
||||
kratosAdminUrl: "http://kratos:4434",
|
||||
password: "admin",
|
||||
roles: ["admin"],
|
||||
permissions: ["admin"],
|
||||
}),
|
||||
/Kratos/,
|
||||
);
|
||||
|
||||
+23
-23
@@ -2,8 +2,8 @@
|
||||
// kratos+keto are healthy (web waits on it), idempotent on every `docker compose up`:
|
||||
// 1. generate the JWKS signing key if absent (committed dev key makes this a safety net);
|
||||
// 2. seed a demo admin (admin@plainpages.local / admin) in Kratos;
|
||||
// 3. grant it its roles in Keto so menu/role checks resolve out of the box — `admin` plus
|
||||
// every discovered plugin's declared role names, so a dropped-in plugin is usable by
|
||||
// 3. grant it its permissions in Keto so menu/permission checks resolve out of the box — `admin` plus
|
||||
// every discovered plugin's declared permission names, so a dropped-in plugin is usable by
|
||||
// the demo admin with no host config edit (the host stays plugin-agnostic).
|
||||
// Then prints a first-run banner; fails loud on any unexpected upstream error.
|
||||
import { existsSync, writeFileSync } from "node:fs";
|
||||
@@ -22,19 +22,19 @@ export function identityPayload(email: string, password: string) {
|
||||
};
|
||||
}
|
||||
|
||||
// Coarse-role grant: `Role:<role>#members@identity:<id>`. Subject ids are `identity:<kratos-id>`
|
||||
// (namespaces.keto.ts) — the source of truth the login flow projects into the JWT roles.
|
||||
export function roleTuple(identityId: string, role: string) {
|
||||
return { namespace: "Role", object: role, relation: "members", subject_id: `identity:${identityId}` };
|
||||
// Coarse-permission grant: `Permission:<permission>#members@identity:<id>`. Subject ids are `identity:<kratos-id>`
|
||||
// (namespaces.keto.ts) — the source of truth the login flow projects into the JWT permissions.
|
||||
export function permissionTuple(identityId: string, permission: string) {
|
||||
return { namespace: "Permission", object: permission, relation: "granted", subject_id: `identity:${identityId}` };
|
||||
}
|
||||
|
||||
// The roles to grant the demo admin = the configured base (ADMIN_ROLES, default just `admin`)
|
||||
// unioned with every discovered plugin's declared role names (a route/nav `role` is a
|
||||
// coarse role — granted as a Keto `Role:<token>#members` tuple). So the host names no plugin, yet a
|
||||
// The permissions to grant the demo admin = the configured base (ADMIN_PERMISSIONS, default just `admin`)
|
||||
// unioned with every discovered plugin's declared permission names (a route/nav `permission` is a
|
||||
// coarse permission — granted as a Keto `Permission:<token>#members` tuple). So the host names no plugin, yet a
|
||||
// dropped-in plugin's tokens are seeded out of the box. Deduped, order-stable, blanks dropped.
|
||||
export function seedRoles(adminRolesEnv: string | undefined, declaredRoles: string[]): string[] {
|
||||
export function seedPermissions(adminRolesEnv: string | undefined, declaredPermissions: string[]): string[] {
|
||||
const clean = (xs: string[]): string[] => xs.map((r) => r.trim()).filter(Boolean);
|
||||
return [...new Set([...clean((adminRolesEnv ?? "admin").split(",")), ...clean(declaredRoles)])];
|
||||
return [...new Set([...clean((adminRolesEnv ?? "admin").split(",")), ...clean(declaredPermissions)])];
|
||||
}
|
||||
|
||||
// --- JWKS safety net -----------------------------------------------------------------
|
||||
@@ -63,13 +63,13 @@ export interface SeedOptions {
|
||||
ketoWriteUrl: string;
|
||||
kratosAdminUrl: string;
|
||||
password: string;
|
||||
roles: string[];
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
export interface SeedResult {
|
||||
created: boolean;
|
||||
id: string;
|
||||
roles: string[];
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
export async function seedAdmin(opts: SeedOptions): Promise<SeedResult> {
|
||||
@@ -93,17 +93,17 @@ export async function seedAdmin(opts: SeedOptions): Promise<SeedResult> {
|
||||
throw new Error(`bootstrap: Kratos create identity failed (${res.status}): ${await res.text()}`);
|
||||
}
|
||||
|
||||
// Grant each role in Keto. PUT is idempotent — re-running just re-asserts the tuple.
|
||||
for (const role of opts.roles) {
|
||||
// Grant each permission in Keto. PUT is idempotent — re-running just re-asserts the tuple.
|
||||
for (const permission of opts.permissions) {
|
||||
const grant = await http(`${opts.ketoWriteUrl}/admin/relation-tuples`, {
|
||||
body: JSON.stringify(roleTuple(id, role)),
|
||||
body: JSON.stringify(permissionTuple(id, permission)),
|
||||
headers: { "content-type": "application/json" },
|
||||
method: "PUT",
|
||||
});
|
||||
if (!grant.ok) throw new Error(`bootstrap: Keto grant role "${role}" failed (${grant.status}): ${await grant.text()}`);
|
||||
if (!grant.ok) throw new Error(`bootstrap: Keto grant permission "${permission}" failed (${grant.status}): ${await grant.text()}`);
|
||||
}
|
||||
|
||||
return { created, id, roles: opts.roles };
|
||||
return { created, id, permissions: opts.permissions };
|
||||
}
|
||||
|
||||
async function findIdentityId(http: typeof fetch, adminUrl: string, email: string): Promise<string> {
|
||||
@@ -143,10 +143,10 @@ async function main() {
|
||||
await runWithLog(log, async () => {
|
||||
if (ensureJwks(env["JWKS_FILE"] ?? "/etc/config/kratos/tokenizer/jwks.json")) log.info("generated a JWKS signing key");
|
||||
|
||||
// Seed `admin` (or ADMIN_ROLES) + every discovered plugin's declared role names, so the
|
||||
// Seed `admin` (or ADMIN_PERMISSIONS) + every discovered plugin's declared permission names, so the
|
||||
// shipped example — and any dropped-in plugin — works for the demo admin without a host edit.
|
||||
const declared = (await discoverPlugins()).flatMap((p) => (p.roles ?? []).map((d) => d.name));
|
||||
const roles = seedRoles(env["ADMIN_ROLES"], declared);
|
||||
const declared = (await discoverPlugins()).flatMap((p) => (p.permissions ?? []).map((d) => d.name));
|
||||
const permissions = seedPermissions(env["ADMIN_PERMISSIONS"], declared);
|
||||
const email = env["ADMIN_EMAIL"] ?? "admin@plainpages.local";
|
||||
const password = env["ADMIN_PASSWORD"] ?? "admin";
|
||||
const result = await seedAdmin({
|
||||
@@ -155,9 +155,9 @@ async function main() {
|
||||
ketoWriteUrl: env["KETO_WRITE_URL"] ?? "http://keto:4467",
|
||||
kratosAdminUrl: env["KRATOS_ADMIN_URL"] ?? "http://kratos:4434",
|
||||
password,
|
||||
roles,
|
||||
permissions,
|
||||
});
|
||||
log.info("admin seeded", { created: result.created, id: result.id, roles: result.roles.join(", ") });
|
||||
log.info("admin seeded", { created: result.created, id: result.id, permissions: result.permissions.join(", ") });
|
||||
// The banner is human-facing UX (the first-run "you're ready" block), not a log event — print raw.
|
||||
console.log(firstRunBanner({ appUrl: env["APP_URL"] ?? "http://localhost:3000", email, password }));
|
||||
});
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
// Optional revocation denylist: instant role/session revoke without putting Keto
|
||||
// Optional revocation denylist: instant permission/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
|
||||
// The hot path verifies a short-lived (~10m) session JWT in-process, so a revoked permission 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).
|
||||
// re-reads permissions 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
|
||||
|
||||
@@ -48,7 +48,7 @@ test("rotateJwks --prune keeps only the newest (first) key, dropping superseded
|
||||
test("a JWS signed with a generated key verifies via our own verifier (reads what Kratos signs)", () => {
|
||||
const key = generateJwks().keys[0]!;
|
||||
const head = b64url(JSON.stringify({ alg: "ES256", kid: key.kid }));
|
||||
const body = b64url(JSON.stringify({ email: "a@b.c", roles: [], sub: key.kid }));
|
||||
const body = b64url(JSON.stringify({ email: "a@b.c", permissions: [], sub: key.kid }));
|
||||
const sig = sign("SHA256", Buffer.from(`${head}.${body}`), { dsaEncoding: "ieee-p1363", key: createPrivateKey({ key: key as unknown as JsonWebKey, format: "jwk" }) });
|
||||
const token = `${head}.${body}.${sig.toString("base64url")}`;
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ function ctxFor(user: SessionIdentity | null, url = "/"): RequestContext {
|
||||
return buildContext(req, new ServerResponse(req), { identity: user });
|
||||
}
|
||||
|
||||
const alice: SessionIdentity = { email: "a@b.c", id: "u1", roles: ["admin", "scheduling:read"] };
|
||||
const alice: SessionIdentity = { email: "a@b.c", id: "u1", permissions: ["admin", "scheduling:read"] };
|
||||
|
||||
test("requireSession returns the user, or throws GuardError(401)→/login (preserving return_to) when anonymous", () => {
|
||||
assert.equal(requireSession(ctxFor(alice)), alice);
|
||||
@@ -30,7 +30,7 @@ test("requireSession returns the user, or throws GuardError(401)→/login (prese
|
||||
err instanceof GuardError && err.location === "/login?return_to=%2Fscheduling%2Fshifts%3Fq%3D1");
|
||||
});
|
||||
|
||||
test("can reads a coarse role from the JWT claims; anonymous has none", () => {
|
||||
test("can reads a coarse permission from the JWT claims; anonymous has none", () => {
|
||||
assert.equal(can(ctxFor(alice), "admin"), true);
|
||||
assert.equal(can(ctxFor(alice), "billing:write"), false);
|
||||
assert.equal(can(ctxFor(null), "admin"), false);
|
||||
|
||||
+4
-4
@@ -1,5 +1,5 @@
|
||||
// Auth guards: in-handler authorization, the imperative counterpart to the
|
||||
// declarative route `role` gate. The middleware already verified the session JWT and put
|
||||
// declarative route `permission` gate. The middleware already verified the session JWT and put
|
||||
// the User on ctx; these read it. `requireSession` asserts (throws GuardError, which app.ts maps
|
||||
// to a response); `can`/`check` are predicates a handler branches on. `check` is the one live
|
||||
// Keto call — the fine-grained "may I?" tier (README), reserved for relationship rules.
|
||||
@@ -37,9 +37,9 @@ export function requireSession(ctx: RequestContext): SessionIdentity {
|
||||
return ctx.identity;
|
||||
}
|
||||
|
||||
// Coarse role check straight from the JWT claims — in-process, zero I/O. Anonymous ⇒ false.
|
||||
export function can(ctx: RequestContext, role: string): boolean {
|
||||
return ctx.roles.includes(role);
|
||||
// Coarse permission check straight from the JWT claims — in-process, zero I/O. Anonymous ⇒ false.
|
||||
export function can(ctx: RequestContext, permission: string): boolean {
|
||||
return ctx.permissions.includes(permission);
|
||||
}
|
||||
|
||||
// Live Keto relationship check at the point of action. The subject is the current user;
|
||||
|
||||
@@ -22,11 +22,11 @@ const jwk2: JsonWebKey = { ...(k2.publicKey.export({ format: "jwk" }) as JsonWeb
|
||||
const jwks = staticJwks([jwk1, jwk2]); // rotated set: two live keys
|
||||
|
||||
const NOW = 1_700_000_000; // fixed clock for deterministic exp/nbf checks
|
||||
const valid = { email: "a@b.c", exp: NOW + 600, roles: ["admin"], sub: "u1" };
|
||||
const valid = { email: "a@b.c", exp: NOW + 600, permissions: ["admin"], sub: "u1" };
|
||||
|
||||
test("verifyToken: a valid token → User, selecting the verify key by kid across a rotated set", async () => {
|
||||
const user = await verifyToken(mint(k2.privateKey, "k2", valid), jwks, { now: NOW });
|
||||
assert.deepEqual(user, { email: "a@b.c", id: "u1", roles: ["admin"] });
|
||||
assert.deepEqual(user, { email: "a@b.c", id: "u1", permissions: ["admin"] });
|
||||
});
|
||||
|
||||
test("verifyToken requires exp, rejects expiry and future nbf, with clock-skew leeway", async () => {
|
||||
@@ -59,18 +59,18 @@ test("verifyToken rejects a bad signature and an unknown kid", async () => {
|
||||
await assert.rejects(verifyToken(mint(k1.privateKey, "nope", valid), jwks, { now: NOW }), /no JWKS key/);
|
||||
});
|
||||
|
||||
test("claimsToIdentity requires sub + email, defaults roles to [], keeps only string roles", () => {
|
||||
test("claimsToIdentity requires sub + email, defaults permissions to [], keeps only string permissions", () => {
|
||||
assert.throws(() => claimsToIdentity({ email: "a@b.c", exp: NOW }), /sub/);
|
||||
assert.throws(() => claimsToIdentity({ email: "a@b.c", exp: NOW, sub: "" }), /sub/); // empty sub rejected too
|
||||
assert.throws(() => claimsToIdentity({ exp: NOW, sub: "u" }), /email/);
|
||||
assert.throws(() => claimsToIdentity({ email: "", exp: NOW, sub: "u" }), /email/); // empty email rejected (the shell keys signed-in vs anonymous off it)
|
||||
assert.deepEqual(claimsToIdentity({ email: "a@b.c", sub: "u" }).roles, []); // roles absent
|
||||
assert.deepEqual(claimsToIdentity({ email: "a@b.c", roles: ["a", 1, "b"], sub: "u" }).roles, ["a", "b"]);
|
||||
assert.deepEqual(claimsToIdentity({ email: "a@b.c", sub: "u" }).permissions, []); // permissions absent
|
||||
assert.deepEqual(claimsToIdentity({ email: "a@b.c", permissions: ["a", 1, "b"], sub: "u" }).permissions, ["a", "b"]);
|
||||
});
|
||||
|
||||
test("resolveSession classifies the cookie; authenticate is its fail-closed identity projection", async () => {
|
||||
const cookie = (extra: Record<string, unknown> = {}, kid = "k1") => `${SESSION_COOKIE}=${mint(k1.privateKey, kid, { ...valid, ...extra })}`;
|
||||
const identity = { email: "a@b.c", id: "u1", roles: ["admin"] };
|
||||
const identity = { email: "a@b.c", id: "u1", permissions: ["admin"] };
|
||||
|
||||
// A valid token → the user, not expired.
|
||||
assert.deepEqual(await resolveSession(cookie(), jwks, { now: NOW }), { expired: false, identity });
|
||||
@@ -96,6 +96,6 @@ test("verifyToken honours an optional denylist: a revoked subject's token reject
|
||||
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, identity: 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"] });
|
||||
assert.deepEqual(await verifyToken(mint(k1.privateKey, "k1", { ...valid, iat: NOW + 5 }), jwks, { denylist, now: NOW }), { email: "a@b.c", id: "u1", permissions: ["admin"] });
|
||||
await verifyToken(mint(k1.privateKey, "k1", { ...valid, iat: NOW - 5, sub: "u2" }), jwks, { denylist, now: NOW });
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// the hot path that never calls Ory. Select the verify key by `kid` from the cached JWKS,
|
||||
// check the signature (src/auth/jwt.ts), validate the time/issuer/audience claims, project the
|
||||
// User onto the request context. `authenticate` fails closed: any bad/expired token ⇒ null
|
||||
// (anonymous), so the route renders signed-out and the role gate denies.
|
||||
// (anonymous), so the route renders signed-out and the permission gate denies.
|
||||
import type { SessionIdentity } from "../http/context.ts";
|
||||
import { parseCookies } from "../http/cookie.ts";
|
||||
import type { Denylist } from "./denylist.ts";
|
||||
@@ -59,15 +59,15 @@ export function validateClaims(payload: Record<string, unknown>, options: Verify
|
||||
}
|
||||
|
||||
// Map verified claims → the request User. sub/email are required and non-empty (the tokenizer
|
||||
// always sets them; an empty email would read as anonymous in the shell); roles defaults to [] and
|
||||
// always sets them; an empty email would read as anonymous in the shell); permissions defaults to [] and
|
||||
// keeps only string entries (defensive).
|
||||
export function claimsToIdentity(payload: Record<string, unknown>): SessionIdentity {
|
||||
const sub = payload["sub"];
|
||||
if (typeof sub !== "string" || sub === "") throw new TokenError("token missing sub");
|
||||
const email = payload["email"];
|
||||
if (typeof email !== "string" || email === "") throw new TokenError("token missing email");
|
||||
const roles = payload["roles"];
|
||||
return { email, id: sub, roles: Array.isArray(roles) ? roles.filter((r): r is string => typeof r === "string") : [] };
|
||||
const permissions = payload["permissions"];
|
||||
return { email, id: sub, permissions: Array.isArray(permissions) ? permissions.filter((r): r is string => typeof r === "string") : [] };
|
||||
}
|
||||
|
||||
// Verify a session JWT end-to-end: select the key by `kid`, check the signature, validate
|
||||
@@ -80,7 +80,7 @@ export async function verifyToken(token: string, jwks: JwksProvider, options: Ve
|
||||
validateClaims(verified.payload, options);
|
||||
const user = claimsToIdentity(verified.payload);
|
||||
// Instant revoke: a denylisted subject's pre-revoke token is rejected as *expired* so
|
||||
// resolveSession routes it through the re-mint (fresh roles from Keto, or a cleared session).
|
||||
// resolveSession routes it through the re-mint (fresh permissions from Keto, or a cleared session).
|
||||
if (options.denylist?.isRevoked(user.id, num(verified.payload, "iat"))) throw new TokenError("token revoked", true);
|
||||
return user;
|
||||
}
|
||||
|
||||
@@ -22,10 +22,10 @@ const rsaJwk = rsa.publicKey.export({ format: "jwk" }) as JsonWebKey;
|
||||
const ecJwk = ec.publicKey.export({ format: "jwk" }) as JsonWebKey;
|
||||
|
||||
test("verifies an RS256 token, returning the decoded header + payload", () => {
|
||||
const token = makeJws("RS256", rsa.privateKey, { roles: ["admin"], sub: "u" });
|
||||
const token = makeJws("RS256", rsa.privateKey, { permissions: ["admin"], sub: "u" });
|
||||
const verified = verifyJws(token, rsaJwk);
|
||||
assert.equal(verified.header.alg, "RS256");
|
||||
assert.deepEqual(verified.payload, { roles: ["admin"], sub: "u" });
|
||||
assert.deepEqual(verified.payload, { permissions: ["admin"], sub: "u" });
|
||||
});
|
||||
|
||||
test("verifies an ES256 token (raw r‖s signature)", () => {
|
||||
@@ -35,10 +35,10 @@ test("verifies an ES256 token (raw r‖s signature)", () => {
|
||||
|
||||
// All three reach and fail the signature check itself, not an earlier structural guard.
|
||||
test("rejects a signature that fails verification (tampered payload, wrong key, empty)", () => {
|
||||
const token = makeJws("RS256", rsa.privateKey, { roles: ["user"], sub: "u" });
|
||||
const token = makeJws("RS256", rsa.privateKey, { permissions: ["user"], sub: "u" });
|
||||
const [header, payload, signature] = token.split(".");
|
||||
|
||||
const forged = `${header}.${b64url(JSON.stringify({ roles: ["admin"], sub: "u" }))}.${signature}`;
|
||||
const forged = `${header}.${b64url(JSON.stringify({ permissions: ["admin"], sub: "u" }))}.${signature}`;
|
||||
assert.throws(() => verifyJws(forged, rsaJwk), /invalid signature/);
|
||||
|
||||
const otherJwk = generateKeyPairSync("rsa", { modulusLength: 2048 }).publicKey.export({ format: "jwk" }) as JsonWebKey;
|
||||
|
||||
@@ -29,13 +29,13 @@ const keto = (fetchImpl: typeof fetch) => createKetoClient({ fetchImpl, readUrl:
|
||||
|
||||
test("check GETs the read API and returns the allowed boolean (true and false)", async () => {
|
||||
const allow = recorder(() => res(200, { allowed: true }));
|
||||
assert.equal(await keto(allow.fetchImpl).check({ namespace: "Role", object: "admin", relation: "members", subject_id: USER }), true);
|
||||
assert.equal(await keto(allow.fetchImpl).check({ namespace: "Permission", object: "admin", relation: "granted", subject_id: USER }), true);
|
||||
assert.match(allow.calls[0]!.url, /^http:\/\/keto:4466\/relation-tuples\/check\?/);
|
||||
assert.match(allow.calls[0]!.url, /namespace=Role&object=admin&relation=members/);
|
||||
assert.match(allow.calls[0]!.url, /namespace=Permission&object=admin&relation=granted/);
|
||||
assert.match(allow.calls[0]!.url, new RegExp(`subject_id=${encodeURIComponent(USER).replace(/[.]/g, "\\.")}`));
|
||||
// A denied check is 403 {allowed:false} (not a 200) — both statuses carry the verdict.
|
||||
const deny = recorder(() => res(403, { allowed: false }));
|
||||
assert.equal(await keto(deny.fetchImpl).check({ namespace: "Role", object: "admin", relation: "members", subject_id: "identity:nobody" }), false);
|
||||
assert.equal(await keto(deny.fetchImpl).check({ namespace: "Permission", object: "admin", relation: "granted", subject_id: "identity:nobody" }), false);
|
||||
});
|
||||
|
||||
test("check on a subject_set builds subject_set.* params and forwards max-depth", async () => {
|
||||
@@ -51,20 +51,20 @@ test("check on a subject_set builds subject_set.* params and forwards max-depth"
|
||||
|
||||
test("check throws a KetoError carrying the status on an unexpected response", async () => {
|
||||
await assert.rejects(
|
||||
keto((async () => res(400, { error: "bad" })) as typeof fetch).check({ namespace: "Role", object: "admin", relation: "members", subject_id: USER }),
|
||||
keto((async () => res(400, { error: "bad" })) as typeof fetch).check({ namespace: "Permission", object: "admin", relation: "granted", subject_id: USER }),
|
||||
(e: unknown) => e instanceof KetoError && e.status === 400,
|
||||
);
|
||||
});
|
||||
|
||||
test("listRelations builds the filter query + pagination and parses next_page_token", async () => {
|
||||
const tuples = [{ namespace: "Role", object: "admin", relation: "members", subject_id: USER }];
|
||||
const tuples = [{ namespace: "Permission", object: "admin", relation: "granted", subject_id: USER }];
|
||||
const { calls, fetchImpl } = recorder(() => res(200, { next_page_token: "NEXT", relation_tuples: tuples }));
|
||||
const out = await keto(fetchImpl).listRelations({ namespace: "Role", object: "admin", pageSize: 10, pageToken: "CUR", relation: "members" });
|
||||
const out = await keto(fetchImpl).listRelations({ namespace: "Permission", object: "admin", pageSize: 10, pageToken: "CUR", relation: "granted" });
|
||||
assert.deepEqual(out.tuples, tuples);
|
||||
assert.equal(out.nextPageToken, "NEXT");
|
||||
const url = calls[0]!.url;
|
||||
assert.match(url, /^http:\/\/keto:4466\/relation-tuples\?/);
|
||||
assert.match(url, /namespace=Role&object=admin&relation=members/);
|
||||
assert.match(url, /namespace=Permission&object=admin&relation=granted/);
|
||||
assert.match(url, /page_size=10&page_token=CUR/);
|
||||
// No Link header / token in the body ⇒ null, empty list ⇒ [].
|
||||
const empty = await keto((async () => res(200, {})) as typeof fetch).listRelations();
|
||||
@@ -72,16 +72,16 @@ test("listRelations builds the filter query + pagination and parses next_page_to
|
||||
});
|
||||
|
||||
test("expand GETs the read API for a subject set and returns the tree (with max-depth)", async () => {
|
||||
const tree = { children: [{ tuple: { namespace: "", object: "", relation: "", subject_id: USER }, type: "leaf" }], tuple: { namespace: "", object: "", relation: "", subject_set: { namespace: "Role", object: "admin", relation: "members" } }, type: "union" };
|
||||
const tree = { children: [{ tuple: { namespace: "", object: "", relation: "", subject_id: USER }, type: "leaf" }], tuple: { namespace: "", object: "", relation: "", subject_set: { namespace: "Permission", object: "admin", relation: "granted" } }, type: "union" };
|
||||
const { calls, fetchImpl } = recorder(() => res(200, tree));
|
||||
const out = await keto(fetchImpl).expand({ namespace: "Role", object: "admin", relation: "members" }, { maxDepth: 3 });
|
||||
const out = await keto(fetchImpl).expand({ namespace: "Permission", object: "admin", relation: "granted" }, { maxDepth: 3 });
|
||||
assert.deepEqual(out, tree);
|
||||
assert.match(calls[0]!.url, /^http:\/\/keto:4466\/relation-tuples\/expand\?/);
|
||||
assert.match(calls[0]!.url, /namespace=Role&object=admin&relation=members&max-depth=3/);
|
||||
assert.match(calls[0]!.url, /namespace=Permission&object=admin&relation=granted&max-depth=3/);
|
||||
});
|
||||
|
||||
test("writeTuple PUTs the tuple as JSON to the write API (idempotent; non-2xx throws)", async () => {
|
||||
const tuple = { namespace: "Role", object: "admin", relation: "members", subject_id: USER };
|
||||
const tuple = { namespace: "Permission", object: "admin", relation: "granted", subject_id: USER };
|
||||
const { calls, fetchImpl } = recorder(() => res(201, tuple));
|
||||
await keto(fetchImpl).writeTuple(tuple);
|
||||
assert.equal(calls[0]!.method, "PUT");
|
||||
@@ -95,12 +95,12 @@ test("writeTuple PUTs the tuple as JSON to the write API (idempotent; non-2xx th
|
||||
|
||||
test("deleteTuple DELETEs the write API by query params (204 resolves; non-204 throws)", async () => {
|
||||
const { calls, fetchImpl } = recorder(() => res(204));
|
||||
await keto(fetchImpl).deleteTuple({ namespace: "Role", object: "admin", relation: "members", subject_id: USER });
|
||||
await keto(fetchImpl).deleteTuple({ namespace: "Permission", object: "admin", relation: "granted", subject_id: USER });
|
||||
assert.equal(calls[0]!.method, "DELETE");
|
||||
assert.match(calls[0]!.url, /^http:\/\/keto:4467\/admin\/relation-tuples\?/);
|
||||
assert.match(calls[0]!.url, /namespace=Role&object=admin&relation=members/);
|
||||
assert.match(calls[0]!.url, /namespace=Permission&object=admin&relation=granted/);
|
||||
await assert.rejects(
|
||||
keto((async () => res(404)) as typeof fetch).deleteTuple({ namespace: "Role", object: "x", relation: "members", subject_id: USER }),
|
||||
keto((async () => res(404)) as typeof fetch).deleteTuple({ namespace: "Permission", object: "x", relation: "granted", subject_id: USER }),
|
||||
(e: unknown) => e instanceof KetoError && e.status === 404,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@ export interface SubjectSet {
|
||||
}
|
||||
|
||||
// A relationship tuple — the wire shape for writes and the filter shape for reads. Subject
|
||||
// is `subject_id` xor `subject_set` (never both). Mirrors bootstrap.ts's roleTuple.
|
||||
// is `subject_id` xor `subject_set` (never both). Mirrors bootstrap.ts's permissionTuple.
|
||||
export interface RelationTuple {
|
||||
namespace: string;
|
||||
object: string;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Kratos admin-API client: typed fetch wrappers over Ory Kratos' admin endpoints —
|
||||
// identity CRUD + the surgical metadata_public update the login flow projects roles into.
|
||||
// identity CRUD + the surgical metadata_public update the login flow projects permissions into.
|
||||
// Guards the request contracts (URLs, method, JSON-Patch body, query/pagination) and the
|
||||
// result mapping (201/200/404/4xx). Live wiring is verified by login completion.
|
||||
import { test } from "node:test";
|
||||
@@ -90,13 +90,13 @@ test("updateIdentity PUTs the full body to /admin/identities/<id> and returns th
|
||||
});
|
||||
|
||||
test("updateMetadataPublic PATCHes a JSON-Patch `add /metadata_public` so it never clobbers traits", async () => {
|
||||
const identity = { id: ID, metadata_public: { roles: ["admin"] } };
|
||||
const identity = { id: ID, metadata_public: { permissions: ["admin"] } };
|
||||
const { calls, fetchImpl } = recorder(() => res(200, identity));
|
||||
const out = await createKratosAdmin({ baseUrl: BASE, fetchImpl }).updateMetadataPublic(ID, { roles: ["admin"] });
|
||||
const out = await createKratosAdmin({ baseUrl: BASE, fetchImpl }).updateMetadataPublic(ID, { permissions: ["admin"] });
|
||||
assert.deepEqual(out, identity);
|
||||
assert.equal(calls[0]!.method, "PATCH");
|
||||
assert.match(calls[0]!.url, new RegExp(`/admin/identities/${ID}$`));
|
||||
assert.deepEqual(JSON.parse(calls[0]!.body!), [{ op: "add", path: "/metadata_public", value: { roles: ["admin"] } }]);
|
||||
assert.deepEqual(JSON.parse(calls[0]!.body!), [{ op: "add", path: "/metadata_public", value: { permissions: ["admin"] } }]);
|
||||
});
|
||||
|
||||
test("createRecoveryCode POSTs the identity id to /admin/recovery/code → { code, link }", async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Kratos admin-API client: typed `fetch` wrappers over Ory Kratos' admin endpoints
|
||||
// (internal-only admin port) — identity CRUD + the surgical `metadata_public` update login
|
||||
// completion projects Keto roles into (README). Built-in `fetch` only, no SDK dep (AGENTS.md);
|
||||
// completion projects Keto permissions into (README). Built-in `fetch` only, no SDK dep (AGENTS.md);
|
||||
// `fetchImpl`-injectable, reuses kratos-public.ts's `KratosError` (branch on `.status`).
|
||||
import { KratosError } from "./kratos-public.ts";
|
||||
|
||||
@@ -106,7 +106,7 @@ export function createKratosAdmin(config: { baseUrl: string; fetchImpl?: typeof
|
||||
},
|
||||
|
||||
// JSON Patch `add` sets metadata_public whether it's currently absent, null, or set, and
|
||||
// touches nothing else — so the login role projection never clobbers traits/state.
|
||||
// touches nothing else — so the login permission projection never clobbers traits/state.
|
||||
// (metadata_public, not _admin: the session the tokenizer sees carries only public metadata.)
|
||||
async updateMetadataPublic(id, metadata) {
|
||||
const patch = [{ op: "add", path: "/metadata_public", value: metadata }];
|
||||
|
||||
+20
-20
@@ -1,4 +1,4 @@
|
||||
// Login completion: turn a Kratos session into our session JWT — read roles from Keto,
|
||||
// Login completion: turn a Kratos session into our session JWT — read permissions from Keto,
|
||||
// project them onto the identity, tokenize, build the cookie. Fakes the three Ory clients;
|
||||
// the live, full-stack login is verified by the Playwright E2E.
|
||||
import { test } from "node:test";
|
||||
@@ -6,10 +6,10 @@ import assert from "node:assert/strict";
|
||||
import type { KetoClient, RelationTuple } from "./keto-client.ts";
|
||||
import type { Identity, KratosAdmin } from "./kratos-admin.ts";
|
||||
import type { KratosPublic, Session } from "./kratos-public.ts";
|
||||
import { completeLogin, readRoles, remintSession, SESSION_COOKIE, sessionCookie } from "./login.ts";
|
||||
import { completeLogin, readPermissions, remintSession, SESSION_COOKIE, sessionCookie } from "./login.ts";
|
||||
|
||||
const ID = "01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b55";
|
||||
const roleTuple = (object: string): RelationTuple => ({ namespace: "Role", object, relation: "members", subject_id: `identity:${ID}` });
|
||||
const permissionTuple = (object: string): RelationTuple => ({ namespace: "Permission", object, relation: "granted", subject_id: `identity:${ID}` });
|
||||
|
||||
const ketoStub = (over: Partial<KetoClient> = {}): KetoClient => ({
|
||||
check: async () => false,
|
||||
@@ -40,32 +40,32 @@ const publicStub = (over: Partial<KratosPublic> = {}): KratosPublic => ({
|
||||
...over,
|
||||
});
|
||||
|
||||
test("readRoles returns roles held directly OR transitively (enumerate defined roles → Keto-check each)", async () => {
|
||||
test("readPermissions returns permissions held directly OR transitively (enumerate defined permissions → Keto-check each)", async () => {
|
||||
const listQ: unknown[] = [];
|
||||
const checked: string[] = [];
|
||||
const role = (object: string, subject: Partial<RelationTuple>): RelationTuple => ({ namespace: "Role", object, relation: "members", ...subject });
|
||||
const permission = (object: string, subject: Partial<RelationTuple>): RelationTuple => ({ namespace: "Permission", object, relation: "granted", ...subject });
|
||||
const keto = ketoStub({
|
||||
// Enumerate every Role tuple (paged, no subject filter) to find the distinct role names —
|
||||
// Enumerate every Permission tuple (paged, no subject filter) to find the distinct permission names —
|
||||
// subjects vary (a direct user, a group) and a name repeats across pages → de-duped.
|
||||
listRelations: async (q) => {
|
||||
listQ.push(q);
|
||||
if (q?.pageToken === "p2") return { nextPageToken: null, tuples: [role("editor", { subject_id: "identity:other" })] };
|
||||
if (q?.pageToken === "p2") return { nextPageToken: null, tuples: [permission("editor", { subject_id: "identity:other" })] };
|
||||
return { nextPageToken: "p2", tuples: [
|
||||
role("editor", { subject_set: { namespace: "Group", object: "eng", relation: "members" } }),
|
||||
role("admin", { subject_id: `identity:${ID}` }),
|
||||
role("viewer", { subject_id: "identity:stranger" }),
|
||||
permission("editor", { subject_set: { namespace: "Group", object: "eng", relation: "members" } }),
|
||||
permission("admin", { subject_id: `identity:${ID}` }),
|
||||
permission("viewer", { subject_id: "identity:stranger" }),
|
||||
] };
|
||||
},
|
||||
// Keto resolves transitively: the user holds editor (via a group) + admin (direct), not viewer.
|
||||
check: async (t) => { checked.push(t.object); return t.object === "admin" || t.object === "editor"; },
|
||||
});
|
||||
assert.deepEqual(await readRoles(keto, ID), ["admin", "editor"]);
|
||||
assert.deepEqual(listQ[0], { namespace: "Role", relation: "members" }); // enumerate, not subject-filtered
|
||||
assert.deepEqual(await readPermissions(keto, ID), ["admin", "editor"]);
|
||||
assert.deepEqual(listQ[0], { namespace: "Permission", relation: "granted" }); // enumerate, not subject-filtered
|
||||
assert.equal((listQ[1] as { pageToken?: string }).pageToken, "p2"); // second page follows the cursor
|
||||
assert.deepEqual(checked.sort(), ["admin", "editor", "viewer"]); // every distinct role checked for the user
|
||||
assert.deepEqual(checked.sort(), ["admin", "editor", "viewer"]); // every distinct permission checked for the user
|
||||
});
|
||||
|
||||
test("completeLogin: read roles → project onto metadata_public → tokenize → JWT (in that order)", async () => {
|
||||
test("completeLogin: read permissions → project onto metadata_public → tokenize → JWT (in that order)", async () => {
|
||||
const events: string[] = [];
|
||||
let projected: unknown;
|
||||
const identity: Identity = { id: ID, traits: { email: "admin@plainpages.local" } };
|
||||
@@ -76,11 +76,11 @@ test("completeLogin: read roles → project onto metadata_public → tokenize
|
||||
},
|
||||
});
|
||||
const kratosAdmin = adminStub({ updateMetadataPublic: async (_id, meta) => { events.push("project"); projected = meta; return identity; } });
|
||||
const keto = ketoStub({ check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [roleTuple("admin")] }) });
|
||||
const keto = ketoStub({ check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [permissionTuple("admin")] }) });
|
||||
|
||||
const out = await completeLogin({ keto, kratosAdmin, kratosPublic }, "plainpages_session=s");
|
||||
assert.deepEqual(out, { email: "admin@plainpages.local", identityId: ID, jwt: "h.p.s", roles: ["admin"] });
|
||||
assert.deepEqual(projected, { roles: ["admin"] }); // Keto roles, projected for the tokenizer
|
||||
assert.deepEqual(out, { email: "admin@plainpages.local", identityId: ID, jwt: "h.p.s", permissions: ["admin"] });
|
||||
assert.deepEqual(projected, { permissions: ["admin"] }); // Keto permissions, projected for the tokenizer
|
||||
assert.deepEqual(events, ["whoami", "project", "tokenize"]); // projection MUST precede tokenize
|
||||
});
|
||||
|
||||
@@ -101,11 +101,11 @@ test("completeLogin maps a missing email trait to null and throws if the tokeniz
|
||||
test("remintSession: a live Kratos session → fresh cookie + refreshed user; a dead session → a clearing cookie + null", async () => {
|
||||
const identity: Identity = { id: ID, traits: { email: "admin@plainpages.local" } };
|
||||
const kratosPublic = publicStub({ whoami: async (o) => (o?.tokenizeAs ? { active: true, identity, tokenized: "h.p.s" } : { active: true, identity }) as Session });
|
||||
const keto = ketoStub({ check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [roleTuple("admin")] }) });
|
||||
const keto = ketoStub({ check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [permissionTuple("admin")] }) });
|
||||
|
||||
// TTL lapsed but the Kratos session lives → re-read roles from Keto, re-tokenize, fresh cookie.
|
||||
// TTL lapsed but the Kratos session lives → re-read permissions from Keto, re-tokenize, fresh cookie.
|
||||
const live = await remintSession({ keto, kratosAdmin: adminStub(), kratosPublic }, "plainpages_session=s");
|
||||
assert.deepEqual(live.identity, { email: "admin@plainpages.local", id: ID, roles: ["admin"] });
|
||||
assert.deepEqual(live.identity, { email: "admin@plainpages.local", id: ID, permissions: ["admin"] });
|
||||
assert.match(live.setCookie, /^plainpages_jwt=h\.p\.s;.*Max-Age=2592000.*HttpOnly/);
|
||||
|
||||
// Kratos session also gone → clear the stale JWT so the next request falls through to anonymous.
|
||||
|
||||
+18
-18
@@ -1,9 +1,9 @@
|
||||
// Login completion: turn a fresh Kratos session into our locally-verifiable
|
||||
// session JWT — the one moment Ory is on the path (README: Login → session JWT):
|
||||
// 1. whoami(cookie) → the identity (id, email); no active session ⇒ null
|
||||
// 2. read roles from Keto → the source of truth for the `roles` claim
|
||||
// 2. read permissions from Keto → the source of truth for the `permissions` claim
|
||||
// 3. project onto metadata_public (admin API) so the tokenizer's mapper can read them
|
||||
// 4. whoami(tokenize_as) → the signed JWT { sub, email, roles }, stored as our cookie
|
||||
// 4. whoami(tokenize_as) → the signed JWT { sub, email, permissions }, stored as our cookie
|
||||
// Order matters: the projection is written before tokenizing, because the claims mapper
|
||||
// reads only the identity, never Keto.
|
||||
import type { SessionIdentity } from "../http/context.ts";
|
||||
@@ -34,26 +34,26 @@ export interface CompletedLogin {
|
||||
email: string | null;
|
||||
identityId: string;
|
||||
jwt: string;
|
||||
roles: string[];
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
// The coarse roles a user holds — directly (`Role:<name>#members@identity:<id>`) or transitively via a
|
||||
// group that is a member of the role. Enumerates the defined roles (the distinct objects in the Role
|
||||
// namespace) and asks Keto to resolve each membership, so a role granted to a group reaches the JWT —
|
||||
// The coarse permissions a user holds — directly (`Permission:<name>#members@identity:<id>`) or transitively via a
|
||||
// group that is a member of the permission. Enumerates the defined permissions (the distinct objects in the Permission
|
||||
// namespace) and asks Keto to resolve each membership, so a permission granted to a group reaches the JWT —
|
||||
// matching the OPL model and the admin "Effective access" view. At login/refresh only, never per
|
||||
// request; role count is small, so the per-role checks are cheap and run in parallel.
|
||||
export async function readRoles(keto: KetoClient, identityId: string): Promise<string[]> {
|
||||
// request; permission count is small, so the per-permission checks are cheap and run in parallel.
|
||||
export async function readPermissions(keto: KetoClient, identityId: string): Promise<string[]> {
|
||||
const subject_id = `identity:${identityId}`;
|
||||
const names = new Set<string>();
|
||||
let pageToken: string | undefined;
|
||||
do {
|
||||
const page = await keto.listRelations({ namespace: "Role", relation: "members", ...(pageToken ? { pageToken } : {}) });
|
||||
const page = await keto.listRelations({ namespace: "Permission", relation: "granted", ...(pageToken ? { pageToken } : {}) });
|
||||
for (const t of page.tuples) names.add(t.object);
|
||||
pageToken = page.nextPageToken ?? undefined;
|
||||
} while (pageToken);
|
||||
const roles = [...names];
|
||||
const held = await Promise.all(roles.map((object) => keto.check({ namespace: "Role", object, relation: "members", subject_id })));
|
||||
return roles.filter((_, i) => held[i]).sort();
|
||||
const permissions = [...names];
|
||||
const held = await Promise.all(permissions.map((object) => keto.check({ namespace: "Permission", object, relation: "granted", subject_id })));
|
||||
return permissions.filter((_, i) => held[i]).sort();
|
||||
}
|
||||
|
||||
export async function completeLogin(deps: LoginDeps, cookie: string | undefined): Promise<CompletedLogin | null> {
|
||||
@@ -63,15 +63,15 @@ export async function completeLogin(deps: LoginDeps, cookie: string | undefined)
|
||||
const emailTrait = session.identity.traits?.["email"];
|
||||
const email = typeof emailTrait === "string" ? emailTrait : null;
|
||||
|
||||
const roles = await readRoles(deps.keto, identityId);
|
||||
await deps.kratosAdmin.updateMetadataPublic(identityId, { roles });
|
||||
const permissions = await readPermissions(deps.keto, identityId);
|
||||
await deps.kratosAdmin.updateMetadataPublic(identityId, { permissions });
|
||||
|
||||
const tokenized = await deps.kratosPublic.whoami({ ...(cookie ? { cookie } : {}), tokenizeAs: TOKENIZE_AS });
|
||||
const jwt = tokenized?.tokenized;
|
||||
if (!jwt) throw new Error("login completion: Kratos tokenizer returned no JWT");
|
||||
|
||||
currentLog()?.info("session minted", { roles: roles.join(","), sub: identityId }); // login or TTL re-mint
|
||||
return { email, identityId, jwt, roles };
|
||||
currentLog()?.info("session minted", { permissions: permissions.join(","), sub: identityId }); // login or TTL re-mint
|
||||
return { email, identityId, jwt, permissions };
|
||||
}
|
||||
|
||||
export interface Reminted {
|
||||
@@ -80,14 +80,14 @@ export interface Reminted {
|
||||
}
|
||||
|
||||
// Re-mint the session JWT on TTL expiry — "stay signed in" (README): the ~10m token lapsed but
|
||||
// the long-lived Kratos session may still be live. A live session ⇒ re-read roles from Keto,
|
||||
// the long-lived Kratos session may still be live. A live session ⇒ re-read permissions from Keto,
|
||||
// re-tokenize, fresh cookie + the refreshed user (the one moment authz recomputes). A dead
|
||||
// session ⇒ a cookie that *clears* the stale JWT, so later requests fall straight through to
|
||||
// anonymous instead of re-hitting Ory on every one.
|
||||
export async function remintSession(deps: LoginDeps, cookie: string | undefined, options: { secure?: boolean } = {}): Promise<Reminted> {
|
||||
const completed = await completeLogin(deps, cookie);
|
||||
if (!completed) return { setCookie: clearSessionCookie(options), identity: null };
|
||||
return { setCookie: sessionCookie(completed.jwt, options), identity: { email: completed.email ?? "", id: completed.identityId, roles: completed.roles } };
|
||||
return { setCookie: sessionCookie(completed.jwt, options), identity: { email: completed.email ?? "", id: completed.identityId, permissions: completed.permissions } };
|
||||
}
|
||||
|
||||
// Build the Set-Cookie for our session JWT. HttpOnly + SameSite=Lax by default; `secure` is
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// /oauth2/consent?consent_challenge=… (hydra.yml urls.consent). A first-party client (or one
|
||||
// Hydra already skipped) is auto-granted the requested scopes; a third-party client shows the
|
||||
// themed consent screen, then accept (allow) / reject (deny). id_token claims (email/name) come
|
||||
// from the Kratos identity. OAuth2-provider role only — no first-party page needs this (README).
|
||||
// from the Kratos identity. OAuth2-provider permission only — no first-party page needs this (README).
|
||||
import type { AcceptConsent, ConsentRequest, HydraAdmin, OAuth2Client } from "./hydra-admin.ts";
|
||||
import type { KratosPublic } from "./kratos-public.ts";
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// Hydra hands the browser to /oauth2/login?login_challenge=… (hydra.yml urls.login). We
|
||||
// authenticate the user with their existing Kratos session and accept the request; Hydra then
|
||||
// proceeds to consent and mints the tokens. No first-party page needs this — it's the OAuth2
|
||||
// provider role only (README).
|
||||
// provider permission only (README).
|
||||
import type { HydraAdmin } from "./hydra-admin.ts";
|
||||
import type { KratosPublic } from "./kratos-public.ts";
|
||||
|
||||
|
||||
+1
-1
@@ -192,7 +192,7 @@ function oauthLogout(hydra: HydraAdmin): BuiltinRoute["handler"] {
|
||||
}
|
||||
|
||||
// Login completion: where Kratos lands the browser after authenticating (kratos.yml). Mint our
|
||||
// session JWT — read roles from Keto, project onto the identity, tokenize — and store it as the
|
||||
// session JWT — read permissions from Keto, project onto the identity, tokenize — and store it as the
|
||||
// cookie; no active session bounces back to sign in.
|
||||
function completeAuth(deps: { keto: KetoClient; kratosAdmin: KratosAdmin; kratosPublic: KratosPublic }, secureCookies: boolean): BuiltinRoute["handler"] {
|
||||
return async (ctx: RequestContext): Promise<RouteResult> => {
|
||||
|
||||
Reference in New Issue
Block a user