Organize src/ into concern folders (http, auth, admin, plugin-host, ui); co-locate tests, move plugin-api barrel into plugin-host, sync docs + AGENTS layout

This commit is contained in:
2026-06-24 00:23:55 +02:00
parent 6d316c4888
commit de22f51c12
113 changed files with 282 additions and 265 deletions
+134
View File
@@ -0,0 +1,134 @@
// One-command bootstrap: idempotent first-boot seeding. Guards the pure payload
// builders (Kratos create-identity body + Keto role 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";
const json = (status: number, body?: unknown) =>
new Response(body === undefined ? null : JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
test("identityPayload is a valid Kratos create-identity body with a password credential", () => {
const body = identityPayload("admin@plainpages.local", "admin");
assert.equal(body.schema_id, "default");
assert.equal(body.traits.email, "admin@plainpages.local");
assert.equal(body.credentials.password.config.password, "admin");
});
test("roleTuple grants a role to user:<id> in the Role namespace", () => {
const id = randomUUID();
assert.deepEqual(roleTuple(id, "admin"), {
namespace: "Role",
object: "admin",
relation: "members",
subject_id: `user:${id}`,
});
});
test("seedRoles unions ADMIN_ROLES (default 'admin') with the discovered plugins' declared tokens", () => {
// Clean clone: no ADMIN_ROLES, 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)
});
test("seedAdmin on a fresh stack creates the identity and grants every role (one tuple each)", async () => {
const id = randomUUID();
const calls: { method: string; url: string; body?: unknown }[] = [];
const fetchImpl = (async (url, init) => {
const u = String(url);
calls.push({ method: init?.method ?? "GET", url: u, body: init?.body && JSON.parse(String(init.body)) });
if (u.endsWith("/admin/identities")) return json(201, { id });
if (u.includes("/admin/relation-tuples")) return json(201, {});
throw new Error(`unexpected ${u}`);
}) as typeof fetch;
const result = await seedAdmin({
email: "admin@plainpages.local",
fetchImpl,
ketoWriteUrl: "http://keto:4467",
kratosAdminUrl: "http://kratos:4434",
password: "admin",
roles: ["admin", "scheduling:read"],
});
assert.deepEqual(result, { created: true, id, roles: ["admin", "scheduling:read"] });
const puts = calls.filter((c) => c.url.includes("relation-tuples"));
assert.equal(puts.length, 2); // one grant per role
assert.ok(puts.every((p) => p.method === "PUT"));
assert.deepEqual(puts.map((p) => p.body), [
{ namespace: "Role", object: "admin", relation: "members", subject_id: `user:${id}` },
{ namespace: "Role", object: "scheduling:read", relation: "members", subject_id: `user:${id}` },
]);
});
test("seedAdmin is idempotent: a 409 reuses the existing identity and re-grants the role", async () => {
const id = randomUUID();
let granted: unknown;
const fetchImpl = (async (url, init) => {
const u = String(url);
if (u.endsWith("/admin/identities") && init?.method === "POST") return json(409, { error: { code: 409 } });
if (u.includes("/admin/identities?")) return json(200, [{ id, traits: { email: "admin@plainpages.local" } }]);
if (u.includes("/admin/relation-tuples")) {
granted = JSON.parse(String(init?.body));
return json(201, {});
}
throw new Error(`unexpected ${u}`);
}) as typeof fetch;
const result = await seedAdmin({
email: "admin@plainpages.local",
fetchImpl,
ketoWriteUrl: "http://keto:4467",
kratosAdminUrl: "http://kratos:4434",
password: "admin",
roles: ["admin"],
});
assert.deepEqual(result, { created: false, id, roles: ["admin"] });
assert.deepEqual(granted, { namespace: "Role", object: "admin", relation: "members", subject_id: `user:${id}` });
});
test("seedAdmin fails loud on an unexpected Kratos error", async () => {
const fetchImpl = (async () => json(500, { error: "boom" })) as typeof fetch;
await assert.rejects(
seedAdmin({
email: "admin@plainpages.local",
fetchImpl,
ketoWriteUrl: "http://keto:4467",
kratosAdminUrl: "http://kratos:4434",
password: "admin",
roles: ["admin"],
}),
/Kratos/,
);
});
test("firstRunBanner prints the login URL, seeded creds, and a change-before-production warning", () => {
const banner = firstRunBanner({ appUrl: "http://localhost:3000", email: "admin@plainpages.local", password: "admin" });
assert.match(banner, /http:\/\/localhost:3000/);
assert.match(banner, /admin@plainpages\.local/);
assert.match(banner, /admin/); // the password
assert.match(banner, /before production/i);
});
test("ensureJwks generates a key only when the file is absent", () => {
const writes: { content: string; path: string }[] = [];
const write = (path: string, content: string) => writes.push({ content, path });
const path = "/etc/config/kratos/tokenizer/jwks.json";
assert.equal(ensureJwks(path, { exists: () => false, write }), true);
assert.equal(writes.length, 1);
assert.equal(JSON.parse(writes[0]!.content).keys.length, 1); // a real ES256 key landed
assert.equal(ensureJwks(path, { exists: () => true, write }), false);
assert.equal(writes.length, 1); // present → nothing written
});
+167
View File
@@ -0,0 +1,167 @@
// One-command bootstrap (the MVP bar). One-shot compose service: runs after
// 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/permission checks resolve out of the box — `admin` plus
// every discovered plugin's declared permission tokens, 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";
import { fileURLToPath } from "node:url";
import { discoverPlugins } from "../plugin-host/discovery.ts";
import { generateJwks, type JwkSet } from "./gen-jwks.ts";
import { createLogger, runWithLog, tracedFetch } from "../logger.ts";
// --- Pure payload builders (the Kratos/Keto request contracts) -----------------------
export function identityPayload(email: string, password: string) {
return {
credentials: { password: { config: { password } } }, // cleartext; Kratos hashes it
schema_id: "default",
traits: { email, name: { first: "Admin", last: "User" } },
};
}
// Coarse-role grant: `Role:<role>#members@user:<id>`. Subject ids are `user:<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: `user:${identityId}` };
}
// The roles to grant the demo admin = the configured base (ADMIN_ROLES, default just `admin`)
// unioned with every discovered plugin's declared permission tokens (a route/nav `permission` is a
// coarse role — granted as a Keto `Role:<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, declaredTokens: string[]): string[] {
const clean = (xs: string[]): string[] => xs.map((r) => r.trim()).filter(Boolean);
return [...new Set([...clean((adminRolesEnv ?? "admin").split(",")), ...clean(declaredTokens)])];
}
// --- JWKS safety net -----------------------------------------------------------------
export interface JwksFsHooks {
exists?: (path: string) => boolean;
generate?: () => JwkSet;
write?: (path: string, content: string) => void;
}
// Generate the signing key only when the file is missing; returns whether it wrote one.
export function ensureJwks(path: string, hooks: JwksFsHooks = {}): boolean {
const exists = hooks.exists ?? existsSync;
if (exists(path)) return false;
const generate = hooks.generate ?? generateJwks;
const write = hooks.write ?? ((p, c) => writeFileSync(p, c));
write(path, `${JSON.stringify(generate(), null, 2)}\n`);
return true;
}
// --- Admin seeding -------------------------------------------------------------------
export interface SeedOptions {
email: string;
fetchImpl?: typeof fetch;
ketoWriteUrl: string;
kratosAdminUrl: string;
password: string;
roles: string[];
}
export interface SeedResult {
created: boolean;
id: string;
roles: string[];
}
export async function seedAdmin(opts: SeedOptions): Promise<SeedResult> {
const http = opts.fetchImpl ?? fetch;
// Create the identity. A 409 means it already exists (a re-run) — look up its id.
const res = await http(`${opts.kratosAdminUrl}/admin/identities`, {
body: JSON.stringify(identityPayload(opts.email, opts.password)),
headers: { "content-type": "application/json" },
method: "POST",
});
let created: boolean;
let id: string;
if (res.status === 201) {
id = ((await res.json()) as { id: string }).id;
created = true;
} else if (res.status === 409) {
id = await findIdentityId(http, opts.kratosAdminUrl, opts.email);
created = false;
} else {
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) {
const grant = await http(`${opts.ketoWriteUrl}/admin/relation-tuples`, {
body: JSON.stringify(roleTuple(id, role)),
headers: { "content-type": "application/json" },
method: "PUT",
});
if (!grant.ok) throw new Error(`bootstrap: Keto grant role "${role}" failed (${grant.status}): ${await grant.text()}`);
}
return { created, id, roles: opts.roles };
}
async function findIdentityId(http: typeof fetch, adminUrl: string, email: string): Promise<string> {
const res = await http(`${adminUrl}/admin/identities?credentials_identifier=${encodeURIComponent(email)}`);
if (!res.ok) throw new Error(`bootstrap: Kratos lookup failed (${res.status}): ${await res.text()}`);
const found = ((await res.json()) as { id: string }[])[0];
if (!found?.id) throw new Error(`bootstrap: ${email} reported as existing but not found`);
return found.id;
}
// --- First-run banner ----------------------------------------------------------------
// Loud, scannable block in the compose logs: where to log in + the seeded demo creds +
// the "change before production" warning. Pure so it's testable; main() prints it verbatim.
export function firstRunBanner(opts: { appUrl: string; email: string; password: string }): string {
const rule = "─".repeat(58);
return [
`${rule}`,
`│ Plainpages is ready — log in at ${opts.appUrl}`,
`│ email: ${opts.email}`,
`│ password: ${opts.password}`,
`│ ⚠ Demo admin credentials — change them before production.`,
`${rule}`,
].join("\n");
}
// --- CLI (the bootstrap container entrypoint) ----------------------------------------
async function main() {
const env = process.env;
// Structured like the web app so prod logs stay uniform; honour LOG_FORMAT/SERVICE_NAME.
const log = createLogger({
format: env["LOG_FORMAT"] === "json" ? "json" : "text",
...(env["SERVICE_NAME"] ? { serviceName: env["SERVICE_NAME"] } : {}),
});
// runWithLog makes `log` ambient so seedAdmin's tracedFetch traces the Kratos/Keto seed calls.
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 permission tokens, 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.permissions ?? []).map((d) => d.token));
const roles = seedRoles(env["ADMIN_ROLES"], declared);
const email = env["ADMIN_EMAIL"] ?? "admin@plainpages.local";
const password = env["ADMIN_PASSWORD"] ?? "admin";
const result = await seedAdmin({
email,
fetchImpl: tracedFetch,
ketoWriteUrl: env["KETO_WRITE_URL"] ?? "http://keto:4467",
kratosAdminUrl: env["KRATOS_ADMIN_URL"] ?? "http://kratos:4434",
password,
roles,
});
log.info("admin seeded", { created: result.created, id: result.id, roles: result.roles.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 }));
});
await log.end(); // flush any pending OTLP spans/logs before the one-shot exits
}
if (process.argv[1] === fileURLToPath(import.meta.url)) await main();
+46
View File
@@ -0,0 +1,46 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { csrfCookie, ensureCsrfToken, issueCsrfToken, verifyCsrfRequest, verifyCsrfToken } from "./csrf.ts";
const SECRET = "test-csrf-secret";
test("issued tokens are signed: round-trip verifies; tamper/wrong-secret/garbage fail", () => {
const token = issueCsrfToken(SECRET);
assert.match(token, /^[\w-]+\.[\w-]+$/); // <nonce>.<hmac>, base64url
assert.ok(verifyCsrfToken(SECRET, token));
assert.equal(verifyCsrfToken(SECRET, token.replace(/.$/, (c) => (c === "a" ? "b" : "a"))), false); // tampered mac
assert.equal(verifyCsrfToken("other-secret", token), false);
assert.equal(verifyCsrfToken(SECRET, undefined), false);
assert.equal(verifyCsrfToken(SECRET, "nodot"), false);
assert.notEqual(issueCsrfToken(SECRET), issueCsrfToken(SECRET)); // random nonce each time
});
test("ensureCsrfToken reuses a valid cookie token, mints a fresh one when absent/invalid", () => {
const token = issueCsrfToken(SECRET);
const reused = ensureCsrfToken(`plainpages_csrf=${token}; other=x`, SECRET);
assert.deepEqual(reused, { fresh: false, token });
const minted = ensureCsrfToken(undefined, SECRET);
assert.equal(minted.fresh, true);
assert.ok(verifyCsrfToken(SECRET, minted.token));
const bad = ensureCsrfToken("plainpages_csrf=forged.value", SECRET);
assert.equal(bad.fresh, true); // a forged cookie is replaced, not trusted
});
test("csrfCookie builds the HttpOnly/Lax cookie; Secure is opt-in", () => {
assert.match(csrfCookie("tok"), /^plainpages_csrf=tok;.*HttpOnly; SameSite=Lax$/);
assert.match(csrfCookie("tok", { secure: true }), /; SameSite=Lax; Secure$/);
});
test("verifyCsrfRequest requires a genuine cookie that the submitted field echoes (double-submit)", () => {
const token = issueCsrfToken(SECRET);
const cookieHeader = `plainpages_csrf=${token}`;
assert.ok(verifyCsrfRequest({ cookieHeader, secret: SECRET, submitted: token }));
assert.equal(verifyCsrfRequest({ cookieHeader: undefined, secret: SECRET, submitted: token }), false); // no cookie
assert.equal(verifyCsrfRequest({ cookieHeader, secret: SECRET, submitted: null }), false); // no field
assert.equal(verifyCsrfRequest({ cookieHeader, secret: SECRET, submitted: issueCsrfToken(SECRET) }), false); // field ≠ cookie
assert.equal(verifyCsrfRequest({ cookieHeader: "plainpages_csrf=forged.v", secret: SECRET, submitted: "forged.v" }), false); // matching but unsigned
});
+58
View File
@@ -0,0 +1,58 @@
// CSRF protection for our own POST forms. Stateless signed double-submit token:
// the token is `<nonce>.<HMAC(secret, nonce)>`, set as a cookie *and* echoed in a hidden form
// field. A request passes iff the cookie is a genuine signature (can't be forged without the
// secret) and the submitted field equals it. SameSite=Lax already blocks the cross-site POST
// from sending the cookie; the signature + double-submit defend the rest. Kratos' own flows
// carry Kratos' CSRF token — this guards only the routes we handle.
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
import { parseCookies, serializeCookie } from "../http/cookie.ts";
export const CSRF_COOKIE = "plainpages_csrf";
export const CSRF_FIELD = "_csrf"; // hidden input name forms submit the token under
const MAX_AGE = 60 * 60 * 24 * 30; // 30d, mirrors the session cookie so the token survives restarts
const NONCE_BYTES = 18;
function sign(secret: string, nonce: string): string {
return createHmac("sha256", secret).update(nonce).digest("base64url");
}
function timingEqual(a: string, b: string): boolean {
const ab = Buffer.from(a);
const bb = Buffer.from(b);
return ab.length === bb.length && timingSafeEqual(ab, bb);
}
export function issueCsrfToken(secret: string): string {
const nonce = randomBytes(NONCE_BYTES).toString("base64url");
return `${nonce}.${sign(secret, nonce)}`;
}
// True iff `token` is a `<nonce>.<hmac>` we signed (self-validating — no server state).
export function verifyCsrfToken(secret: string, token: string | null | undefined): boolean {
if (!token) return false;
const dot = token.indexOf(".");
if (dot <= 0) return false;
return timingEqual(token.slice(dot + 1), sign(secret, token.slice(0, dot)));
}
// The token to embed in this request's forms: reuse a genuine cookie token, else mint one
// (`fresh` ⇒ the caller must Set-Cookie it). Reusing keeps every open tab/form on one token.
export function ensureCsrfToken(cookieHeader: string | undefined, secret: string): { fresh: boolean; token: string } {
const existing = parseCookies(cookieHeader)[CSRF_COOKIE];
if (existing && verifyCsrfToken(secret, existing)) return { fresh: false, token: existing };
return { fresh: true, token: issueCsrfToken(secret) };
}
export function csrfCookie(token: string, options: { secure?: boolean } = {}): string {
return serializeCookie(CSRF_COOKIE, token, { httpOnly: true, maxAge: MAX_AGE, path: "/", sameSite: "Lax", ...(options.secure ? { secure: true } : {}) });
}
// Gate a state-changing request: the cookie must be a genuine signed token and the submitted
// field must equal it. Fail-closed on any missing/forged/mismatched part.
export function verifyCsrfRequest(args: { cookieHeader: string | undefined; secret: string; submitted: string | null | undefined }): boolean {
const cookieToken = parseCookies(args.cookieHeader)[CSRF_COOKIE];
if (!cookieToken || !args.submitted) return false;
if (!verifyCsrfToken(args.secret, cookieToken)) return false;
return timingEqual(cookieToken, args.submitted);
}
+37
View File
@@ -0,0 +1,37 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { createDenylist } from "./denylist.ts";
test("createDenylist: revokes a subject's pre-revoke tokens, lets a fresh re-login through", () => {
let clock = 1000;
const dl = createDenylist({ now: () => clock, ttlSec: 600 });
// An un-revoked subject is never revoked.
assert.equal(dl.isRevoked("u1", 990), false);
// Revoke at t=1000. A token minted at/before the revoke is rejected; one minted after passes
// (a fresh re-login, whose JWT already reflects the new Keto state).
dl.revoke("u1");
assert.equal(dl.isRevoked("u1", 990), true); // before
assert.equal(dl.isRevoked("u1", 1000), true); // exactly at the revoke instant
assert.equal(dl.isRevoked("u1", 1001), false); // after → fresh token, not revoked
assert.equal(dl.isRevoked("u2", 990), false); // a different subject is unaffected
// A missing iat fails closed (better to force a re-mint than honour a maybe-revoked token).
assert.equal(dl.isRevoked("u1", undefined), true);
});
test("createDenylist: a later revoke advances the cutoff; entries self-evict after the TTL", () => {
let clock = 1000;
const dl = createDenylist({ now: () => clock, ttlSec: 600 });
dl.revoke("u1"); // cutoff = 1000
clock = 1500;
dl.revoke("u1"); // cutoff advances to 1500
assert.equal(dl.isRevoked("u1", 1400), true); // minted before the latest revoke
assert.equal(dl.isRevoked("u1", 1600), false); // minted after
// Past the TTL the entry is gone — any pre-revoke token has long since expired anyway.
clock = 1500 + 601;
assert.equal(dl.isRevoked("u1", 1400), false);
});
+56
View File
@@ -0,0 +1,56 @@
// Optional revocation denylist: 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
},
};
}
+24
View File
@@ -0,0 +1,24 @@
import assert from "node:assert/strict";
import test from "node:test";
import { withTimeout } from "./fetch-timeout.ts";
test("withTimeout injects an abort signal that fires after the deadline", async () => {
let seenSignal: AbortSignal | undefined;
const slow: typeof fetch = ((_input, init) => {
seenSignal = (init as RequestInit | undefined)?.signal ?? undefined;
return new Promise<Response>((_resolve, reject) => {
seenSignal?.addEventListener("abort", () => reject(seenSignal!.reason));
});
}) as typeof fetch;
await assert.rejects(withTimeout(slow, 20)("http://x/"), (e: unknown) => (e as Error).name === "TimeoutError");
assert.ok(seenSignal instanceof AbortSignal); // the wrapped call received a real signal
});
test("withTimeout keeps a caller-supplied signal instead of overriding it", async () => {
let seen: AbortSignal | undefined;
const fake: typeof fetch = ((_input, init) => { seen = (init as RequestInit | undefined)?.signal ?? undefined; return Promise.resolve(new Response("ok")); }) as typeof fetch;
const mine = new AbortController().signal;
await withTimeout(fake, 50)("http://x/", { signal: mine });
assert.equal(seen, mine);
});
+9
View File
@@ -0,0 +1,9 @@
// Bound every outbound Ory call: a reachable-but-silent host — a hung container, a
// black-holed socket, an LB holding the connection — would otherwise park a request handler forever
// (and exhaust the pool under load). Wrap the injected `fetch` so each call aborts after `ms` unless
// the caller already passed its own signal. server.ts wires this into the Kratos/Keto/Hydra clients.
export function withTimeout(fetchImpl: typeof fetch, ms: number): typeof fetch {
// A caller-supplied signal wins (so an explicit abort still works); otherwise inject the timeout.
return (input, init) => fetchImpl(input, { ...init, signal: init?.signal ?? AbortSignal.timeout(ms) });
}
+139
View File
@@ -0,0 +1,139 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { AUTH_FLOWS, buildFlowView } from "./flow-view.ts";
import type { Flow, UiNode } from "./kratos-public.ts";
// Concise UiNode builder mirroring Kratos' shape.
function node(attrs: Record<string, unknown>, opts: { group?: string; label?: string; error?: string } = {}): UiNode {
return {
attributes: attrs,
group: opts.group ?? "default",
messages: opts.error ? [{ id: 4000002, text: opts.error, type: "error" }] : [],
meta: opts.label ? { label: { id: 1, text: opts.label, type: "info" } } : {},
type: "input",
};
}
function flow(nodes: UiNode[], extra: Partial<Flow["ui"]> = {}): Flow {
return { id: "f1", ui: { action: "http://127.0.0.1:4433/self-service/login?flow=f1", method: "post", nodes, ...extra } };
}
test("maps a password login flow: csrf hidden, themed email/password fields, a submit button + chrome", () => {
const view = buildFlowView(
flow([
node({ name: "csrf_token", type: "hidden", value: "tok123" }),
node({ name: "identifier", type: "email", required: true, autocomplete: "username", value: "" }, { label: "E-Mail", group: "password" }),
node({ name: "password", type: "password", required: true, autocomplete: "current-password" }, { label: "Password", group: "password" }),
node({ name: "method", type: "submit", value: "password" }, { label: "Sign in", group: "password" }),
]),
"login",
);
// Form posts straight to Kratos (it owns CSRF); csrf travels as a hidden input.
assert.equal(view.action, "http://127.0.0.1:4433/self-service/login?flow=f1");
assert.equal(view.method, "post");
assert.deepEqual(view.hidden, [{ name: "csrf_token", value: "tok123" }]);
// Visible fields carry label, type, required, autocomplete + a themed input icon.
assert.equal(view.fields.length, 2);
assert.deepEqual(view.fields[0], { autocomplete: "username", icon: "i-mail", id: "field-identifier", label: "E-Mail", name: "identifier", required: true, type: "email" });
assert.equal(view.fields[1]?.icon, "i-lock");
assert.equal(view.fields[1]?.type, "password");
// One submit button carrying its method name/value.
assert.deepEqual(view.buttons, [{ label: "Sign in", name: "method", value: "password" }]);
// No OIDC providers configured ⇒ no SSO buttons.
assert.deepEqual(view.sso, []);
// Chrome derived from the flow type.
assert.equal(view.title, "Sign in");
assert.equal(view.alt?.href, "/registration");
assert.equal(view.recoverHref, "/recovery"); // login offers a path to password reset
assert.equal(view.messages.length, 0);
});
test("maps field errors and flow-level messages by tone", () => {
const view = buildFlowView(
flow(
[
node({ name: "identifier", type: "email", value: "taken@example.com" }, { label: "E-Mail", error: "This email is already in use." }),
node({ name: "method", type: "submit", value: "password" }, { label: "Sign in" }),
],
{ messages: [{ id: 4000006, text: "The provided credentials are invalid.", type: "error" }, { id: 1, text: "Check your email.", type: "info" }] },
),
"login",
);
// Submitted value is preserved; the node's error rides on the field.
assert.equal(view.fields[0]?.value, "taken@example.com");
assert.deepEqual(view.fields[0]?.error, { text: "This email is already in use." });
// Flow messages map error→neg, info→info (success→pos covered by the tone map).
assert.deepEqual(view.messages, [
{ text: "The provided credentials are invalid.", tone: "neg" },
{ text: "Check your email.", tone: "info" },
]);
});
test("collects oidc nodes as SSO providers (text logo = initial), keeping csrf and the password submit separate", () => {
const view = buildFlowView(
flow([
node({ name: "csrf_token", type: "hidden", value: "tok" }),
node({ name: "provider", type: "submit", value: "google" }, { label: "Sign in with Google", group: "oidc" }),
node({ name: "provider", type: "submit", value: "microsoft" }, { label: "Sign in with Microsoft", group: "oidc" }),
node({ name: "method", type: "submit", value: "password" }, { label: "Sign in", group: "password" }),
]),
"login",
);
// One provider button per oidc node — a submit (name/value) posting to the same Kratos form.
assert.deepEqual(view.sso, [
{ label: "Sign in with Google", logo: "G", name: "provider", value: "google" },
{ label: "Sign in with Microsoft", logo: "M", name: "provider", value: "microsoft" },
]);
// SSO nodes don't leak into hidden/buttons.
assert.deepEqual(view.hidden, [{ name: "csrf_token", value: "tok" }]);
assert.deepEqual(view.buttons, [{ label: "Sign in", name: "method", value: "password" }]);
});
test("the code field guards a pasted space: one-time-code autofill + numeric inputmode + digits-only pattern", () => {
// Verification/recovery enter a numeric OTP. Kratos doesn't trim, so a stray pasted space makes it
// reject the code as "invalid"; a digits-only pattern blocks that in the browser before submit.
const view = buildFlowView(
flow([
node({ name: "csrf_token", type: "hidden", value: "tok" }),
node({ name: "code", type: "text", required: true }, { label: "Verification code", group: "code" }),
node({ name: "method", type: "submit", value: "code" }, { label: "Continue", group: "code" }),
]),
"verification",
);
assert.deepEqual(view.fields.find((f) => f.name === "code"), {
autocomplete: "one-time-code", // Kratos sends none for the OTP node — enable OS/email autofill
icon: "i-shield",
id: "field-code",
inputmode: "numeric",
label: "Verification code",
name: "code",
pattern: "[0-9]*",
required: true,
type: "text",
});
});
test("chrome varies per flow type: registration alt, recovery back link", () => {
const reg = buildFlowView(flow([]), "registration");
assert.equal(reg.title, "Create account");
assert.equal(reg.alt?.href, "/login");
assert.equal(reg.recoverHref, undefined); // only login shows the reset link
const rec = buildFlowView(flow([]), "recovery");
assert.equal(rec.back?.href, "/login");
});
test("AUTH_FLOWS maps each themed path to its Kratos flow type", () => {
assert.equal(AUTH_FLOWS["/login"], "login");
assert.equal(AUTH_FLOWS["/registration"], "registration");
assert.equal(AUTH_FLOWS["/recovery"], "recovery");
assert.equal(AUTH_FLOWS["/verification"], "verification");
assert.equal(AUTH_FLOWS["/settings"], "settings");
});
+156
View File
@@ -0,0 +1,156 @@
// Kratos flow → themed view model. Pure: turns a fetched self-service Flow
// (src/auth/kratos-public.ts) into the data views/auth.ejs renders — hidden inputs (incl. the
// CSRF token), themed fields, submit buttons, tone-mapped messages, and one SSO button per
// configured `oidc` provider. The form posts straight back to `flow.ui.action`, so Kratos
// owns its CSRF; we only render and map errors. No providers configured ⇒ no SSO buttons.
import type { Flow, FlowType, UiNode } from "./kratos-public.ts";
export interface FlowField {
autocomplete?: string;
error?: { text: string };
icon?: string; // Lucide sprite id for the input
id: string;
inputmode?: string; // virtual-keyboard hint (e.g. "numeric" for the OTP code)
label: string;
name: string;
pattern?: string; // client-side validity regex; blocks a pasted space before it reaches Kratos
required?: boolean;
type: string;
value?: string;
}
export interface FlowButton {
label: string;
name?: string;
value?: string;
}
// An OIDC provider, rendered as a submit button (name/value) posting to the same Kratos form.
export interface SsoProvider {
label: string; // Kratos' own label, e.g. "Sign in with Google"
logo: string; // text logo (provider initial) — lucide ships no brand marks
name: string; // submit field (Kratos: "provider")
value: string; // provider id (Kratos: "google")
}
export interface FlowMessage {
text: string;
tone: "info" | "neg" | "pos" | "warn";
}
interface FlowChrome {
alt?: { href: string; label: string; text: string };
back?: { href: string; label: string };
sub?: string;
title: string;
}
export interface FlowView extends FlowChrome {
action: string;
buttons: FlowButton[];
fields: FlowField[];
hidden: { name: string; value: string }[];
messages: FlowMessage[];
method: string;
recoverHref?: string; // login only: a "Forgot password?" link to the recovery flow
sso: SsoProvider[]; // one per configured oidc provider; empty ⇒ no SSO section
}
// Themed route → Kratos flow type. The routes mirror kratos.yml's flow ui_urls.
export const AUTH_FLOWS: Record<string, FlowType> = {
"/login": "login",
"/recovery": "recovery",
"/registration": "registration",
"/settings": "settings",
"/verification": "verification",
};
const CHROME: Record<FlowType, FlowChrome> = {
login: { alt: { href: "/registration", label: "Create one", text: "Don't have an account?" }, sub: "Welcome back. Enter your details to continue.", title: "Sign in" },
recovery: { alt: { href: "/login", label: "Sign in", text: "Remembered it?" }, back: { href: "/login", label: "Back to sign in" }, sub: "Enter your email and we'll send you a recovery code.", title: "Reset password" },
registration: { alt: { href: "/login", label: "Sign in", text: "Already have an account?" }, sub: "Get started — it only takes a minute.", title: "Create account" },
settings: { sub: "Update your account details.", title: "Account settings" },
verification: { back: { href: "/login", label: "Back to sign in" }, sub: "Enter the code we sent you.", title: "Verify your email" },
};
const str = (v: unknown): string | undefined => (typeof v === "string" ? v : undefined);
// Themed input icon by field semantics; undefined ⇒ no icon.
function iconFor(name: string, type: string): string | undefined {
if (type === "email" || name === "identifier" || name.endsWith(".email")) return "i-mail";
if (type === "password") return "i-lock";
if (name.includes("name")) return "i-user";
if (name === "code") return "i-shield";
return undefined;
}
function tone(type: string): FlowMessage["tone"] {
if (type === "error") return "neg";
if (type === "success") return "pos";
return "info";
}
const ssoLogo = (value: string): string => (value.charAt(0) || "?").toUpperCase();
function toField(node: UiNode, name: string, type: string): FlowField {
const value = str(node.attributes["value"]);
// The recovery/verification one-time code: numeric, and Kratos doesn't trim it, so a stray pasted
// space makes it reject the code as "invalid". A digits-only pattern + numeric keypad block that in
// the browser; one-time-code enables OS/email autofill (Kratos sends no autocomplete for the node).
const isCode = name === "code";
const autocomplete = str(node.attributes["autocomplete"]) ?? (isCode ? "one-time-code" : undefined);
const icon = iconFor(name, type);
const errorMsg = node.messages.find((m) => m.type === "error");
return {
id: "field-" + name.replace(/[^a-z0-9]+/gi, "-"),
label: node.meta.label?.text ?? name,
name,
type,
...(autocomplete ? { autocomplete } : {}),
...(errorMsg ? { error: { text: errorMsg.text } } : {}),
...(icon ? { icon } : {}),
...(isCode ? { inputmode: "numeric", pattern: "[0-9]*" } : {}),
...(node.attributes["required"] === true ? { required: true } : {}),
...(value ? { value } : {}),
};
}
export function buildFlowView(flow: Flow, type: FlowType): FlowView {
const hidden: { name: string; value: string }[] = [];
const fields: FlowField[] = [];
const buttons: FlowButton[] = [];
const sso: SsoProvider[] = [];
for (const node of flow.ui.nodes) {
if (node.type !== "input") continue;
const name = str(node.attributes["name"]) ?? "";
const inputType = str(node.attributes["type"]) ?? "text";
if (node.group === "oidc") {
// One submit button per configured provider; posts provider=<value> to the same form.
if (inputType === "submit" || inputType === "button") {
const value = str(node.attributes["value"]) ?? "";
sso.push({ label: node.meta.label?.text ?? value, logo: ssoLogo(value), name, value });
}
} else if (inputType === "hidden") {
hidden.push({ name, value: str(node.attributes["value"]) ?? "" });
} else if (inputType === "submit" || inputType === "button") {
const value = str(node.attributes["value"]);
buttons.push({ label: node.meta.label?.text ?? "Continue", ...(name ? { name } : {}), ...(value != null ? { value } : {}) });
} else {
fields.push(toField(node, name, inputType));
}
}
return {
action: flow.ui.action,
buttons,
fields,
hidden,
messages: (flow.ui.messages ?? []).map((m) => ({ text: m.text, tone: tone(m.type) })),
method: flow.ui.method || "post",
sso,
...(type === "login" ? { recoverHref: "/recovery" } : {}),
...CHROME[type],
};
}
+59
View File
@@ -0,0 +1,59 @@
// Guards the session-tokenizer signing key: generateJwks() emits a fresh ES256
// EC private signing key, the committed dev JWKS is a valid such key, and a token signed
// with it verifies through our own verifier (src/auth/jwt.ts) — so what Kratos signs, reads.
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { createPrivateKey, sign, type JsonWebKey } from "node:crypto";
import { generateJwks, rotateJwks } from "./gen-jwks.ts";
import { verifyJws } from "./jwt.ts";
const b64url = (s: string) => Buffer.from(s).toString("base64url");
const committed = JSON.parse(readFileSync(new URL("../../ory/kratos/tokenizer/jwks.json", import.meta.url), "utf8"));
test("generateJwks emits one ES256 EC private signing key with a fresh kid", () => {
const a = generateJwks();
const b = generateJwks();
assert.equal(a.keys.length, 1);
const k = a.keys[0]!;
assert.deepEqual({ alg: k.alg, crv: k.crv, kty: k.kty, use: k.use }, { alg: "ES256", crv: "P-256", kty: "EC", use: "sig" });
assert.ok(k.d && k.x && k.y, "carries the private scalar d (a signing key) + public point");
assert.match(k.kid, /^[0-9a-f-]{36}$/, "kid is a uuid");
assert.notEqual(k.kid, b.keys[0]!.kid, "each call mints a unique kid (so rotation differs)");
});
test("the committed dev JWKS is a valid ES256 signing key importable by node:crypto", () => {
const k = committed.keys[0];
assert.equal(committed.keys.length, 1);
assert.deepEqual({ alg: k.alg, kty: k.kty, use: k.use }, { alg: "ES256", kty: "EC", use: "sig" });
assert.ok(k.kid && k.d, "has a kid and the private signing scalar");
assert.doesNotThrow(() => createPrivateKey({ key: k, format: "jwk" }), "Kratos can load it to sign");
});
test("rotateJwks prepends a fresh signing key, keeping the old ones for in-flight verification", () => {
const old = generateJwks(); // a one-key set, as Kratos signs with the first
const rotated = rotateJwks(old);
assert.equal(rotated.keys.length, old.keys.length + 1);
assert.notEqual(rotated.keys[0]!.kid, old.keys[0]!.kid, "the new key is first (Kratos signs with it) with a fresh kid");
assert.deepEqual(rotated.keys.slice(1), old.keys, "old keys are preserved in order so unexpired JWTs still verify");
assert.equal(rotated.keys[0]!.alg, "ES256");
});
test("rotateJwks --prune keeps only the newest (first) key, dropping superseded ones", () => {
const twoKeys = rotateJwks(generateJwks()); // prepend → 2 keys
const pruned = rotateJwks(twoKeys, { prune: true });
assert.deepEqual(pruned.keys, [twoKeys.keys[0]], "only the active signing key remains");
});
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 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")}`;
const { d: _d, ...pub } = key; // verify against the public half only
const decoded = verifyJws(token, pub);
assert.equal(decoded.payload.email, "a@b.c");
assert.equal(decoded.header.kid, key.kid);
});
+52
View File
@@ -0,0 +1,52 @@
import { generateKeyPairSync, randomUUID } from "node:crypto";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
// ES256 signing JWKS for the Kratos session tokenizer — Ory-recommended and the
// verifier's preferred alg (src/auth/jwt.ts). Rotation runbook: README, JWT signing key.
// CLI (prod supplies its own key; the committed one is a dev throwaway):
// gen-jwks.ts → a fresh one-key set (mint/replace; emergency rotation)
// gen-jwks.ts --prepend <jwks.json> → new key first + the old ones (zero-downtime rotation)
// gen-jwks.ts --prune <jwks.json> → keep only the newest key (drop superseded, post-TTL)
// All write to stdout; redirect into the JWKS file (use a temp file for --prepend/--prune so
// the shell's `>` can't truncate the input before it's read).
export interface SigningJwk {
kid: string;
alg: "ES256";
crv: string;
d: string; // private scalar — this is a signing key, keep it secret
kty: string;
use: "sig";
x: string;
y: string;
}
export interface JwkSet {
keys: SigningJwk[];
}
export function generateJwks(): JwkSet {
const { crv, d, kty, x, y } = generateKeyPairSync("ec", { namedCurve: "P-256" }).privateKey.export({ format: "jwk" });
if (!crv || !d || !kty || !x || !y) throw new Error("unexpected JWK shape from EC key");
return { keys: [{ kid: randomUUID(), alg: "ES256", crv, d, kty, use: "sig", x, y }] };
}
// Rotate a JWKS: prepend a fresh key (Kratos signs with the first; the old keys still verify
// in-flight JWTs) — or, with `prune`, keep only the newest key (drop superseded ones once the
// old token TTL has elapsed). Pure list math; the active signing key is always keys[0].
export function rotateJwks(current: JwkSet, opts: { prune?: boolean } = {}): JwkSet {
return opts.prune ? { keys: current.keys.slice(0, 1) } : { keys: [generateJwks().keys[0]!, ...current.keys] };
}
// CLI: print the resulting set to stdout (see the header for the redirect caveat).
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const args = process.argv.slice(2);
const rotate = args.includes("--prepend") || args.includes("--prune");
let set: JwkSet;
if (rotate) {
const path = args.find((a) => !a.startsWith("--"));
if (!path) throw new Error("usage: gen-jwks.ts [--prepend|--prune] <existing-jwks.json>");
set = rotateJwks(JSON.parse(readFileSync(path, "utf8")) as JwkSet, { prune: args.includes("--prune") });
} else set = generateJwks();
process.stdout.write(`${JSON.stringify(set, null, 2)}\n`);
}
+52
View File
@@ -0,0 +1,52 @@
import assert from "node:assert/strict";
import { IncomingMessage, ServerResponse } from "node:http";
import { Socket } from "node:net";
import { test } from "node:test";
import { buildContext, type RequestContext, type User } from "../http/context.ts";
import { can, check, GuardError, requireSession } from "./guards.ts";
import type { KetoClient, RelationTuple } from "./keto-client.ts";
function ctxFor(user: User | null, url = "/"): RequestContext {
const req = new IncomingMessage(new Socket());
req.url = url;
return buildContext(req, new ServerResponse(req), { user });
}
const alice: User = { email: "a@b.c", id: "u1", roles: ["admin", "scheduling:read"] };
test("requireSession returns the user, or throws GuardError(401)→/login (preserving return_to) when anonymous", () => {
assert.equal(requireSession(ctxFor(alice)), alice);
// On the home path there is nothing worth returning to → a bare /login.
assert.throws(() => requireSession(ctxFor(null)), (err: unknown) => {
assert.ok(err instanceof GuardError);
assert.equal(err.status, 401);
assert.equal(err.location, "/login"); // app.ts turns this into a 303 to sign in
return true;
});
// A deep link is remembered so login returns the user there (host-relative, encoded).
assert.throws(() => requireSession(ctxFor(null, "/scheduling/shifts?q=1")), (err: unknown) =>
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", () => {
assert.equal(can(ctxFor(alice), "admin"), true);
assert.equal(can(ctxFor(alice), "billing:write"), false);
assert.equal(can(ctxFor(null), "admin"), false);
});
test("check asks Keto with the current user as subject; anonymous is denied without a call", async () => {
let asked: RelationTuple | undefined;
const keto = {
check: async (tuple: RelationTuple) => { asked = tuple; return true; },
} as unknown as KetoClient;
const tuple = { namespace: "Resource", object: "doc1", relation: "view" };
assert.equal(await check(keto, ctxFor(alice), tuple), true);
assert.deepEqual(asked, { ...tuple, subject_id: "user:u1" }); // subject is the signed-in user
asked = undefined;
assert.equal(await check(keto, ctxFor(null), tuple), false); // fail-closed, no Keto call
assert.equal(asked, undefined);
});
+54
View File
@@ -0,0 +1,54 @@
// Auth guards: in-handler authorization, the imperative counterpart to the
// 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.
import type { RequestContext, User } from "../http/context.ts";
import type { KetoClient } from "./keto-client.ts";
import { localPath } from "../http/safe-url.ts";
// Build the sign-in redirect for a gated request, preserving where the user was headed as
// `return_to` so login can land them back there. Only a safe GET/HEAD navigation to a
// non-home, host-relative path is remembered (a POST or "/" ⇒ a bare /login); the target is
// validated host-relative (localPath) so it can't become an open redirect.
export function loginRedirect(ctx: RequestContext): string {
const method = (ctx.req.method ?? "GET").toUpperCase();
const target = method === "GET" || method === "HEAD" ? localPath(ctx.url.pathname + ctx.url.search) : null;
return target && target !== "/" ? `/login?return_to=${encodeURIComponent(target)}` : "/login";
}
// Thrown by an asserting guard; app.ts maps it to a response. `location` ⇒ a 303 redirect (an
// anonymous browser bounces to /login); otherwise `status` renders an error page (403 Forbidden).
// A handler may throw its own (e.g. `new GuardError(403, …)` after a failed `can`/`check`).
export class GuardError extends Error {
location?: string | undefined;
status: number;
constructor(status: number, message: string, location?: string) {
super(message);
this.location = location;
this.name = "GuardError";
this.status = status;
}
}
// Assert a signed-in session and return the user. Anonymous ⇒ GuardError → /login (return_to kept).
export function requireSession(ctx: RequestContext): User {
if (!ctx.user) throw new GuardError(401, "authentication required", loginRedirect(ctx));
return ctx.user;
}
// 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);
}
// Live Keto relationship check at the point of action. The subject is the current user;
// anonymous ⇒ false (fail-closed, no Keto call).
export async function check(
keto: KetoClient,
ctx: RequestContext,
tuple: { namespace: string; object: string; relation: string },
): Promise<boolean> {
if (!ctx.user) return false;
return keto.check({ ...tuple, subject_id: `user:${ctx.user.id}` });
}
+130
View File
@@ -0,0 +1,130 @@
// Hydra admin-API client: typed fetch wrappers over Ory Hydra's OAuth2 login/consent
// challenge handshake. Guards the request contracts (URLs, method, login_challenge query,
// JSON body) and the result mapping (200 → request/redirect, non-2xx → HydraError). Live
// wiring is verified by the OAuth login E2E.
import { test } from "node:test";
import assert from "node:assert/strict";
import { createHydraAdmin, HydraError } from "./hydra-admin.ts";
const BASE = "http://hydra:4445";
const CHALLENGE = "a1b2c3d4e5f6";
const SUBJECT = "01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b55";
function res(status: number, body?: unknown): Response {
const h = new Headers();
if (body !== undefined) h.set("content-type", "application/json");
return new Response(body === undefined ? null : JSON.stringify(body), { status, headers: h });
}
function recorder(handler: (url: string, init: RequestInit | undefined) => Response) {
const calls: { body: string | undefined; method: string; url: string }[] = [];
const fetchImpl = (async (input: unknown, init?: RequestInit) => {
calls.push({ body: init?.body as string | undefined, method: init?.method ?? "GET", url: String(input) });
return handler(String(input), init);
}) as typeof fetch;
return { calls, fetchImpl };
}
test("getLoginRequest GETs the login challenge and returns the request", async () => {
const request = { challenge: CHALLENGE, client: { client_id: "c1" }, requested_scope: ["openid"], skip: false, subject: "" };
const { calls, fetchImpl } = recorder(() => res(200, request));
const out = await createHydraAdmin({ baseUrl: BASE, fetchImpl }).getLoginRequest(CHALLENGE);
assert.deepEqual(out, request);
assert.equal(calls[0]!.method, "GET");
assert.match(calls[0]!.url, /\/admin\/oauth2\/auth\/requests\/login\?login_challenge=a1b2c3d4e5f6$/);
});
test("acceptLoginRequest PUTs the subject and returns Hydra's redirect_to", async () => {
const { calls, fetchImpl } = recorder(() => res(200, { redirect_to: "http://hydra/oauth2/auth?login_verifier=v" }));
const out = await createHydraAdmin({ baseUrl: BASE, fetchImpl }).acceptLoginRequest(CHALLENGE, { remember: true, remember_for: 0, subject: SUBJECT });
assert.equal(out.redirect, "http://hydra/oauth2/auth?login_verifier=v");
assert.equal(calls[0]!.method, "PUT");
assert.match(calls[0]!.url, /\/admin\/oauth2\/auth\/requests\/login\/accept\?login_challenge=a1b2c3d4e5f6$/);
assert.deepEqual(JSON.parse(calls[0]!.body!), { remember: true, remember_for: 0, subject: SUBJECT });
});
test("rejectLoginRequest PUTs the error and returns Hydra's redirect_to", async () => {
const { calls, fetchImpl } = recorder(() => res(200, { redirect_to: "http://client/cb?error=access_denied" }));
const out = await createHydraAdmin({ baseUrl: BASE, fetchImpl }).rejectLoginRequest(CHALLENGE, { error: "access_denied", error_description: "no" });
assert.equal(out.redirect, "http://client/cb?error=access_denied");
assert.equal(calls[0]!.method, "PUT");
assert.match(calls[0]!.url, /\/admin\/oauth2\/auth\/requests\/login\/reject\?login_challenge=a1b2c3d4e5f6$/);
assert.deepEqual(JSON.parse(calls[0]!.body!), { error: "access_denied", error_description: "no" });
});
test("acceptLogoutRequest PUTs the logout challenge and returns Hydra's redirect_to", async () => {
const { calls, fetchImpl } = recorder(() => res(200, { redirect_to: "http://client/post-logout" }));
const out = await createHydraAdmin({ baseUrl: BASE, fetchImpl }).acceptLogoutRequest(CHALLENGE);
assert.equal(out.redirect, "http://client/post-logout");
assert.equal(calls[0]!.method, "PUT");
assert.match(calls[0]!.url, /\/admin\/oauth2\/auth\/requests\/logout\/accept\?logout_challenge=a1b2c3d4e5f6$/);
});
test("getConsentRequest GETs the consent challenge and returns the request", async () => {
const request = { challenge: CHALLENGE, client: { client_name: "Acme" }, requested_scope: ["openid", "email"], skip: false, subject: SUBJECT };
const { calls, fetchImpl } = recorder(() => res(200, request));
const out = await createHydraAdmin({ baseUrl: BASE, fetchImpl }).getConsentRequest(CHALLENGE);
assert.deepEqual(out, request);
assert.equal(calls[0]!.method, "GET");
assert.match(calls[0]!.url, /\/admin\/oauth2\/auth\/requests\/consent\?consent_challenge=a1b2c3d4e5f6$/);
});
test("acceptConsentRequest PUTs the grant + id_token session and returns Hydra's redirect_to", async () => {
const { calls, fetchImpl } = recorder(() => res(200, { redirect_to: "http://hydra/oauth2/auth?consent_verifier=v" }));
const body = { grant_scope: ["openid"], remember: true, remember_for: 0, session: { id_token: { email: "a@b.c" } } };
const out = await createHydraAdmin({ baseUrl: BASE, fetchImpl }).acceptConsentRequest(CHALLENGE, body);
assert.equal(out.redirect, "http://hydra/oauth2/auth?consent_verifier=v");
assert.equal(calls[0]!.method, "PUT");
assert.match(calls[0]!.url, /\/admin\/oauth2\/auth\/requests\/consent\/accept\?consent_challenge=a1b2c3d4e5f6$/);
assert.deepEqual(JSON.parse(calls[0]!.body!), body);
});
test("rejectConsentRequest PUTs the error and returns Hydra's redirect_to", async () => {
const { calls, fetchImpl } = recorder(() => res(200, { redirect_to: "http://client/cb?error=access_denied" }));
const out = await createHydraAdmin({ baseUrl: BASE, fetchImpl }).rejectConsentRequest(CHALLENGE, { error: "access_denied" });
assert.equal(out.redirect, "http://client/cb?error=access_denied");
assert.match(calls[0]!.url, /\/admin\/oauth2\/auth\/requests\/consent\/reject\?consent_challenge=a1b2c3d4e5f6$/);
});
test("a non-2xx response throws a HydraError carrying the status", async () => {
const { fetchImpl } = recorder(() => res(404, { error: "Not Found" }));
await assert.rejects(
createHydraAdmin({ baseUrl: BASE, fetchImpl }).getLoginRequest("gone"),
(e: unknown) => e instanceof HydraError && e.status === 404,
);
});
// OAuth2 client registration: create/list/get/delete clients over Hydra's admin API.
test("createClient POSTs the client and returns it (incl. the one-time client_secret)", async () => {
const created = { client_id: "c1", client_name: "Acme", client_secret: "s3cr3t", redirect_uris: ["https://acme/cb"] };
const { calls, fetchImpl } = recorder(() => res(201, created));
const out = await createHydraAdmin({ baseUrl: BASE, fetchImpl }).createClient({ client_name: "Acme", redirect_uris: ["https://acme/cb"] });
assert.deepEqual(out, created);
assert.equal(calls[0]!.method, "POST");
assert.match(calls[0]!.url, /\/admin\/clients$/);
assert.deepEqual(JSON.parse(calls[0]!.body!), { client_name: "Acme", redirect_uris: ["https://acme/cb"] });
});
test("listClients GETs a page and parses the Link rel=next page_token", async () => {
const body = JSON.stringify([{ client_id: "c1" }, { client_id: "c2" }]);
const headers = new Headers({ "content-type": "application/json", link: '</admin/clients?page_token=tok2&page_size=2>; rel="next"' });
const { calls, fetchImpl } = recorder(() => new Response(body, { headers, status: 200 }));
const out = await createHydraAdmin({ baseUrl: BASE, fetchImpl }).listClients({ pageSize: 2 });
assert.deepEqual(out.clients.map((c) => c.client_id), ["c1", "c2"]);
assert.equal(out.nextPageToken, "tok2");
assert.equal(calls[0]!.method, "GET");
assert.match(calls[0]!.url, /\/admin\/clients\?page_size=2$/);
});
test("getClient returns the client; a 404 → null", async () => {
const found = await createHydraAdmin({ baseUrl: BASE, fetchImpl: recorder(() => res(200, { client_id: "c1" })).fetchImpl }).getClient("c1");
assert.deepEqual(found, { client_id: "c1" });
const missing = await createHydraAdmin({ baseUrl: BASE, fetchImpl: recorder(() => res(404, { error: "Not Found" })).fetchImpl }).getClient("gone");
assert.equal(missing, null);
});
test("deleteClient DELETEs the client by id (204)", async () => {
const { calls, fetchImpl } = recorder(() => res(204));
await createHydraAdmin({ baseUrl: BASE, fetchImpl }).deleteClient("c1");
assert.equal(calls[0]!.method, "DELETE");
assert.match(calls[0]!.url, /\/admin\/clients\/c1$/);
});
+197
View File
@@ -0,0 +1,197 @@
// Hydra admin-API client: typed `fetch` wrappers over Ory Hydra's OAuth2 admin
// endpoints (internal admin port) — the login/consent challenge handshake other apps log in
// *through* us with. Built-in `fetch` only, no SDK dep (AGENTS.md); `fetchImpl`-injectable
// like the kratos/keto clients. We authenticate the user (login) and grant scopes (consent);
// Hydra mints the tokens.
export interface OAuth2Client {
client_id?: string;
client_name?: string;
client_secret?: string; // write-only: Hydra returns it once, on create, for a confidential client
grant_types?: string[];
metadata?: Record<string, unknown>; // arbitrary client metadata; `first_party: true` ⇒ auto-consent
redirect_uris?: string[];
response_types?: string[];
scope?: string; // space-separated
token_endpoint_auth_method?: string; // "client_secret_basic" (confidential) | "none" (public/PKCE)
}
export interface ClientList {
clients: OAuth2Client[];
nextPageToken: string | null; // cursor for the next page; null on the last
}
// A login request Hydra hands us at /oauth2/login. `skip` ⇒ Hydra already authenticated this
// subject (honour it, don't re-prompt); otherwise we authenticate via the Kratos session.
export interface LoginRequest {
challenge: string;
client?: OAuth2Client;
request_url?: string;
requested_scope?: string[];
skip: boolean;
subject: string;
}
export interface AcceptLogin {
acr?: string;
remember?: boolean;
remember_for?: number; // seconds; 0 ⇒ for the browser-session lifetime
subject: string;
}
// A consent request Hydra hands us at /oauth2/consent. `skip` ⇒ already consented (or a
// skip-consent client); else we show the scope screen (or auto-accept a first-party client).
export interface ConsentRequest {
challenge: string;
client?: OAuth2Client;
request_url?: string;
requested_access_token_audience?: string[];
requested_scope?: string[];
skip: boolean;
subject: string;
}
// OIDC claims surfaced to the client: id_token (always) / access_token (introspection only).
export interface ConsentSession {
access_token?: Record<string, unknown>;
id_token?: Record<string, unknown>;
}
export interface AcceptConsent {
grant_access_token_audience?: string[];
grant_scope?: string[];
remember?: boolean;
remember_for?: number; // seconds; 0 ⇒ for the browser-session lifetime
session?: ConsentSession;
}
export interface RejectRequest {
error?: string;
error_description?: string;
}
// Hydra's answer to an accept/reject: the URL to send the browser to, to resume the flow.
export interface Completed {
redirect: string;
}
// Carries the HTTP status so a caller can branch (parallels KratosError/KetoError).
export class HydraError extends Error {
body: string;
status: number;
constructor(message: string, status: number, body: string) {
super(message);
this.body = body;
this.name = "HydraError";
this.status = status;
}
}
export interface HydraAdmin {
acceptConsentRequest(challenge: string, body: AcceptConsent): Promise<Completed>;
acceptLoginRequest(challenge: string, body: AcceptLogin): Promise<Completed>;
acceptLogoutRequest(challenge: string): Promise<Completed>; // RP-initiated logout: confirm + resume
createClient(client: OAuth2Client): Promise<OAuth2Client>;
deleteClient(id: string): Promise<void>;
getClient(id: string): Promise<OAuth2Client | null>;
getConsentRequest(challenge: string): Promise<ConsentRequest>;
getLoginRequest(challenge: string): Promise<LoginRequest>;
listClients(opts?: { pageSize?: number; pageToken?: string }): Promise<ClientList>;
rejectConsentRequest(challenge: string, body: RejectRequest): Promise<Completed>;
rejectLoginRequest(challenge: string, body: RejectRequest): Promise<Completed>;
}
// Hydra paginates with a Link header; pull the page_token of rel="next" (the href is relative, so
// resolve against a throwaway base to read the query param). Mirrors kratos-admin's helper.
function nextPageToken(link: string | null): string | null {
const href = link?.match(/<([^>]+)>\s*;\s*rel="next"/)?.[1];
return href ? new URL(href, "http://hydra").searchParams.get("page_token") : null;
}
export function createHydraAdmin(config: { baseUrl: string; fetchImpl?: typeof fetch }): HydraAdmin {
const base = config.baseUrl.replace(/\/+$/, "");
const http = config.fetchImpl ?? fetch;
const json = { "content-type": "application/json" };
// Hydra keys each handshake off a ?<kind>_challenge= query (login/consent/logout).
const reqUrl = (kind: "consent" | "login" | "logout", challenge: string, action = "") =>
`${base}/admin/oauth2/auth/requests/${kind}${action}?${kind}_challenge=${encodeURIComponent(challenge)}`;
const clientsUrl = `${base}/admin/clients`;
const clientUrl = (id: string) => `${clientsUrl}/${encodeURIComponent(id)}`;
async function fail(action: string, res: Response): Promise<never> {
throw new HydraError(`Hydra admin ${action} failed (${res.status})`, res.status, await res.text());
}
async function complete(action: string, res: Response): Promise<Completed> {
if (res.status !== 200) return fail(action, res);
return { redirect: ((await res.json()) as { redirect_to: string }).redirect_to };
}
const put = (action: string, url: string, body: unknown) =>
http(url, { body: JSON.stringify(body), headers: json, method: "PUT" }).then((r) => complete(action, r));
return {
async acceptConsentRequest(challenge, body) {
return put("accept consent", reqUrl("consent", challenge, "/accept"), body);
},
async acceptLoginRequest(challenge, body) {
return put("accept login", reqUrl("login", challenge, "/accept"), body);
},
// RP-initiated logout: Hydra hands the browser to /oauth2/logout?logout_challenge=…; accept to
// end its OAuth2 session and get the post-logout redirect (no body / no first-party teardown —
// /logout owns the Kratos session). A stale/consumed challenge → HydraError 4xx (app degrades).
async acceptLogoutRequest(challenge) {
return put("accept logout", reqUrl("logout", challenge, "/accept"), {});
},
// OAuth2 client registration (admin screen). Hydra generates the client_id/secret when
// omitted; the secret rides the 201 body and is never retrievable afterwards.
async createClient(client) {
const res = await http(clientsUrl, { body: JSON.stringify(client), headers: json, method: "POST" });
if (res.status !== 201) return fail("create client", res);
return (await res.json()) as OAuth2Client;
},
async deleteClient(id) {
const res = await http(clientUrl(id), { method: "DELETE" });
if (res.status !== 204) await fail("delete client", res);
},
async getClient(id) {
const res = await http(clientUrl(id));
if (res.status === 404) return null;
if (res.status !== 200) return fail("get client", res);
return (await res.json()) as OAuth2Client;
},
async getConsentRequest(challenge) {
const res = await http(reqUrl("consent", challenge));
if (res.status !== 200) return fail("get consent request", res);
return (await res.json()) as ConsentRequest;
},
async getLoginRequest(challenge) {
const res = await http(reqUrl("login", challenge));
if (res.status !== 200) return fail("get login request", res);
return (await res.json()) as LoginRequest;
},
async listClients(opts = {}) {
const url = new URL(clientsUrl);
if (opts.pageSize !== undefined) url.searchParams.set("page_size", String(opts.pageSize));
if (opts.pageToken) url.searchParams.set("page_token", opts.pageToken);
const res = await http(url);
if (res.status !== 200) return fail("list clients", res);
return { clients: (await res.json()) as OAuth2Client[], nextPageToken: nextPageToken(res.headers.get("link")) };
},
async rejectConsentRequest(challenge, body) {
return put("reject consent", reqUrl("consent", challenge, "/reject"), body);
},
async rejectLoginRequest(challenge, body) {
return put("reject login", reqUrl("login", challenge, "/reject"), body);
},
};
}
+103
View File
@@ -0,0 +1,103 @@
import assert from "node:assert/strict";
import { generateKeyPairSync, type JsonWebKey } from "node:crypto";
import { dirname, join } from "node:path";
import { test } from "node:test";
import { fileURLToPath, pathToFileURL } from "node:url";
import { cachingJwks, createJwksProvider, loadJwks, staticJwks } from "./jwks.ts";
const jwk = (kid: string): JsonWebKey => ({ ...(generateKeyPairSync("ec", { namedCurve: "P-256" }).publicKey.export({ format: "jwk" }) as JsonWebKey), alg: "ES256", kid });
const committed = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "ory/kratos/tokenizer/jwks.json");
test("staticJwks selects by kid, falls back to the sole key when none, misses cleanly", async () => {
const [a, b] = [jwk("k1"), jwk("k2")];
const set = staticJwks([a, b]);
assert.equal(await set.getKey("k2"), b);
assert.equal(await set.getKey("nope"), null);
assert.equal(await set.getKey(undefined), null); // ambiguous with >1 key
assert.equal(await staticJwks([a]).getKey(undefined), a); // single-key dev default
});
test("loadJwks reads a file:// set and a base64:// inline set, rejects http", () => {
// The committed dev tokenizer key.
const fromFile = loadJwks(pathToFileURL(committed).href);
assert.equal(fromFile[0]?.kid, "42634591-3e04-49d5-a818-284d7021a85f");
const inline = JSON.stringify({ keys: [jwk("inline")] });
assert.equal(loadJwks(`base64://${Buffer.from(inline).toString("base64")}`)[0]?.kid, "inline");
assert.throws(() => loadJwks("http://keto:4466/keys"), /unsupported/);
// Malformed sets fail loud at load, not as an opaque crypto error at verify time.
const b64 = (o: unknown) => `base64://${Buffer.from(JSON.stringify(o)).toString("base64")}`;
assert.throws(() => loadJwks(b64({})), /missing `keys`/);
assert.throws(() => loadJwks(b64({ keys: ["nope"] })), /string `kty`/); // a non-object key
assert.throws(() => loadJwks(b64({ keys: [{ kid: "x" }] })), /string `kty`/); // key missing kty
});
test("cachingJwks caches within TTL, reloads after expiry", async () => {
let clock = 0;
let calls = 0;
const k = jwk("k1");
const c = cachingJwks(async () => (calls++, [k]), { minRefetchMs: 500, now: () => clock, ttlMs: 1000 });
assert.equal(await c.getKey("k1"), k); // cold → loads
await c.getKey("k1"); // within TTL → cached
assert.equal(calls, 1);
clock = 1001; // past TTL
await c.getKey("k1");
assert.equal(calls, 2);
});
test("cachingJwks reloads on a kid miss (rotation), throttled by minRefetchMs", async () => {
let clock = 0;
let calls = 0;
const [old, fresh] = [jwk("old"), jwk("new")];
let set = [old];
const c = cachingJwks(async () => (calls++, set), { minRefetchMs: 1000, now: () => clock, ttlMs: 100_000 });
assert.equal(await c.getKey("old"), old); // cold load
set = [old, fresh]; // a new key rotates in at the source
clock = 500; // miss inside the throttle window → no reload
assert.equal(await c.getKey("new"), null);
assert.equal(calls, 1);
clock = 1001; // throttle elapsed → rotation-on-miss reload picks it up
assert.equal(await c.getKey("new"), fresh);
assert.equal(calls, 2);
});
test("cachingJwks keeps the last-good set when a reload fails, but a cold load propagates", async () => {
let clock = 0;
let fail = false;
const k = jwk("k");
const c = cachingJwks(async () => {
if (fail) throw new Error("boom");
return [k];
}, { now: () => clock, ttlMs: 1000 });
assert.equal(await c.getKey("k"), k);
fail = true;
clock = 2000; // TTL expired; the reload throws but the cached key still serves
assert.equal(await c.getKey("k"), k);
await assert.rejects(() => cachingJwks(async () => { throw new Error("down"); }).getKey("x"), /down/);
});
test("createJwksProvider routes file/base64/http, primes + caches http, fails loud on a bad source", async () => {
// file:// primed at boot from the committed dev key.
assert.ok(await (await createJwksProvider(pathToFileURL(committed).href)).getKey(undefined));
// base64:// inline set.
const inline = `base64://${Buffer.from(JSON.stringify({ keys: [jwk("inl")] })).toString("base64")}`;
assert.equal((await (await createJwksProvider(inline)).getKey("inl"))?.kid, "inl");
// http(s):// fetched once at boot, then served from cache.
let calls = 0;
const k = jwk("h1");
const fetchImpl = (async () => (calls++, new Response(JSON.stringify({ keys: [k] }), { status: 200 }))) as typeof fetch;
const http = await createJwksProvider("http://issuer/keys", { fetchImpl, ttlMs: 10_000 });
assert.equal(calls, 1); // primed at boot
assert.equal((await http.getKey("h1"))?.kid, "h1");
assert.equal(calls, 1); // cached
// Fail loud at boot: non-2xx fetch, missing file, unsupported scheme.
await assert.rejects(() => createJwksProvider("http://issuer/keys", { fetchImpl: (async () => new Response("no", { status: 500 })) as typeof fetch }), /500/);
await assert.rejects(() => createJwksProvider("file:///nope/jwks.json"), /ENOENT/);
await assert.rejects(() => createJwksProvider("ftp://x/keys"), /unsupported/);
});
+114
View File
@@ -0,0 +1,114 @@
import type { JsonWebKey } from "node:crypto";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { currentLog } from "../logger.ts";
// JWKS provider: resolve the JWT verify key by the JWS `kid`. The middleware calls
// `getKey` per request. `staticJwks` holds a fixed set; `cachingJwks` fetches over the network
// (or re-reads a mounted file), caches for a TTL, and reloads once on a `kid` miss so a rotated-
// in key is picked up without a restart (README: zero-downtime rotation). `createJwksProvider`
// picks the right one from the configured URL scheme and primes it at boot (fail loud).
export interface JwksProvider {
getKey(kid: string | undefined): Promise<JsonWebKey | null>;
}
const TTL_MS = 5 * 60_000; // serve a fetched set this long before reloading
const MIN_REFETCH_MS = 60_000; // floor between rotation-on-miss reloads — a stream of bogus kids can't hammer the source
export interface JwksCacheOptions {
fetchImpl?: typeof fetch;
minRefetchMs?: number;
now?: () => number; // unix ms; injectable for tests
ttlMs?: number;
}
function parseJwks(text: string): JsonWebKey[] {
const parsed = JSON.parse(text) as { keys?: unknown };
if (!Array.isArray(parsed.keys)) throw new Error("JWKS: missing `keys` array");
// Validate element shape here so a malformed key fails loud at load, not as an opaque crypto
// error on the first authenticated request (the verifier keys off `kty`/`kid`).
for (const k of parsed.keys) {
if (typeof k !== "object" || k === null || typeof (k as JsonWebKey).kty !== "string") {
throw new Error("JWKS: each key must be an object with a string `kty`");
}
}
return parsed.keys as JsonWebKey[];
}
// Load a JWKS synchronously from a local source: `file://` reads a mounted key, `base64://`
// decodes an inline set (README rotation). HTTP is `cachingJwks`'s job — fail loud here.
export function loadJwks(jwksUrl: string): JsonWebKey[] {
if (jwksUrl.startsWith("base64://")) return parseJwks(Buffer.from(jwksUrl.slice("base64://".length), "base64").toString("utf8"));
const url = new URL(jwksUrl);
if (url.protocol === "file:") return parseJwks(readFileSync(fileURLToPath(url), "utf8"));
throw new Error(`loadJwks: unsupported JWKS URL scheme (use cachingJwks for http): ${jwksUrl}`);
}
async function fetchJwks(jwksUrl: string, fetchImpl: typeof fetch): Promise<JsonWebKey[]> {
const res = await fetchImpl(jwksUrl, { headers: { accept: "application/json" } });
if (!res.ok) throw new Error(`JWKS fetch ${jwksUrl}: HTTP ${res.status}`);
return parseJwks(await res.text());
}
function pick(keys: JsonWebKey[], kid: string | undefined): JsonWebKey | null {
// No `kid`: fall back to the sole key (single-key dev default), else ambiguous → null.
if (kid === undefined) return keys.length === 1 ? keys[0]! : null;
return keys.find((k) => k.kid === kid) ?? null;
}
// A fixed in-memory key set — loaded once, never reloads. For immutable sources (base64 inline).
export function staticJwks(keys: JsonWebKey[]): JwksProvider {
return { getKey: async (kid) => pick(keys, kid) };
}
// A self-refreshing provider over an async loader. Holds keys for `ttlMs`, then reloads on the
// next lookup; on a `kid` miss it reloads once more (rotation-on-miss), throttled by `minRefetchMs`.
// A reload failure keeps the last-good set (transient resilience); only a cold cache propagates it
// (→ the middleware fails closed). `prime()` does the eager boot load. Concurrent loads coalesce.
export function cachingJwks(load: () => Promise<JsonWebKey[]>, opts: JwksCacheOptions = {}): JwksProvider & { prime: () => Promise<void> } {
const ttlMs = opts.ttlMs ?? TTL_MS;
const minRefetchMs = opts.minRefetchMs ?? MIN_REFETCH_MS;
const now = opts.now ?? Date.now;
let keys: JsonWebKey[] = [];
let loadedAt = -Infinity;
let inflight: Promise<void> | null = null;
const refresh = (): Promise<void> =>
(inflight ??= load().then(
(k) => { keys = k; loadedAt = now(); inflight = null; },
(e: unknown) => { inflight = null; throw e; },
));
return {
prime: refresh,
getKey: async (kid) => {
if (keys.length === 0 || now() - loadedAt > ttlMs) {
try { await refresh(); } catch (e) { if (keys.length === 0) throw e; } // else keep last-good
}
const hit = pick(keys, kid);
if (hit || kid === undefined) return hit;
if (now() - loadedAt >= minRefetchMs) {
currentLog()?.debug("jwks reload on kid miss (rotation?)", { kid }); // rare — only an unknown kid
try { await refresh(); } catch { /* keep last-good */ }
}
return pick(keys, kid);
},
};
}
// Build the verify-key provider from the configured JWKS URL and prime it at boot (fail loud):
// `base64://` → immutable inline set; `file://` → re-readable cache (rotation by remount/edit);
// `http(s)://` → fetched, cached, rotation-on-miss. The middleware sees only `getKey`.
export async function createJwksProvider(jwksUrl: string, opts: JwksCacheOptions = {}): Promise<JwksProvider> {
if (jwksUrl.startsWith("base64://")) return staticJwks(loadJwks(jwksUrl));
const { protocol } = new URL(jwksUrl);
let load: () => Promise<JsonWebKey[]>;
if (protocol === "file:") load = async () => loadJwks(jwksUrl);
else if (protocol === "http:" || protocol === "https:") {
const fetchImpl = opts.fetchImpl ?? fetch;
load = () => fetchJwks(jwksUrl, fetchImpl);
} else throw new Error(`createJwksProvider: unsupported JWKS URL scheme: ${jwksUrl}`);
const provider = cachingJwks(load, opts);
await provider.prime();
return provider;
}
+99
View File
@@ -0,0 +1,99 @@
import assert from "node:assert/strict";
import { generateKeyPairSync, sign, type JsonWebKey, type KeyObject } from "node:crypto";
import { test } from "node:test";
import { staticJwks } from "./jwks.ts";
import { authenticate, claimsToUser, resolveSession, verifyToken } from "./jwt-middleware.ts";
import { SESSION_COOKIE } from "./login.ts";
const b64url = (input: Buffer | string): string => Buffer.from(input).toString("base64url");
// Mint an ES256 session JWT the way the Kratos tokenizer would (kid in the header).
function mint(privateKey: KeyObject, kid: string, payload: Record<string, unknown>): string {
const head = b64url(JSON.stringify({ alg: "ES256", kid, typ: "JWT" }));
const body = b64url(JSON.stringify(payload));
const sig = sign("SHA256", Buffer.from(`${head}.${body}`), { key: privateKey, dsaEncoding: "ieee-p1363" });
return `${head}.${body}.${b64url(sig)}`;
}
const k1 = generateKeyPairSync("ec", { namedCurve: "P-256" });
const k2 = generateKeyPairSync("ec", { namedCurve: "P-256" });
const jwk1: JsonWebKey = { ...(k1.publicKey.export({ format: "jwk" }) as JsonWebKey), alg: "ES256", kid: "k1" };
const jwk2: JsonWebKey = { ...(k2.publicKey.export({ format: "jwk" }) as JsonWebKey), alg: "ES256", kid: "k2" };
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" };
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"] });
});
test("verifyToken rejects expiry and future nbf, with clock-skew leeway", async () => {
const opts = { clockSkewSec: 60, now: NOW };
await assert.rejects(verifyToken(mint(k1.privateKey, "k1", { ...valid, exp: NOW - 120 }), jwks, opts), /expired/);
// exp 30s in the past but inside the 60s skew → still accepted.
await verifyToken(mint(k1.privateKey, "k1", { ...valid, exp: NOW - 30 }), jwks, opts);
await assert.rejects(verifyToken(mint(k1.privateKey, "k1", { ...valid, nbf: NOW + 120 }), jwks, opts), /not yet valid/);
});
test("verifyToken checks issuer/audience only when configured", async () => {
const tok = (extra: Record<string, unknown>) => mint(k1.privateKey, "k1", { ...valid, ...extra });
// No iss/aud in the token and none expected (the dev tokenizer sets neither) → fine.
await verifyToken(tok({}), jwks, { now: NOW });
// Issuer pinned: must match; absent or wrong → reject.
await verifyToken(tok({ iss: "https://id" }), jwks, { issuer: "https://id", now: NOW });
await assert.rejects(verifyToken(tok({}), jwks, { issuer: "https://id", now: NOW }), /issuer/);
await assert.rejects(verifyToken(tok({ iss: "other" }), jwks, { issuer: "https://id", now: NOW }), /issuer/);
// Audience pinned: matches a string or an array membership; mismatch → reject.
await verifyToken(tok({ aud: "pp" }), jwks, { audience: "pp", now: NOW });
await verifyToken(tok({ aud: ["x", "pp"] }), jwks, { audience: "pp", now: NOW });
await assert.rejects(verifyToken(tok({ aud: "x" }), jwks, { audience: "pp", now: NOW }), /audience/);
});
test("verifyToken rejects a bad signature and an unknown kid", async () => {
// Signed with k1 but the header claims kid k2 → wrong verify key → bad signature.
await assert.rejects(verifyToken(mint(k1.privateKey, "k2", valid), jwks, { now: NOW }), /invalid signature/);
await assert.rejects(verifyToken(mint(k1.privateKey, "nope", valid), jwks, { now: NOW }), /no JWKS key/);
});
test("claimsToUser requires sub + email, defaults roles to [], keeps only string roles", () => {
assert.throws(() => claimsToUser({ email: "a@b.c", exp: NOW }), /sub/);
assert.throws(() => claimsToUser({ email: "a@b.c", exp: NOW, sub: "" }), /sub/); // empty sub rejected too
assert.throws(() => claimsToUser({ exp: NOW, sub: "u" }), /email/);
assert.throws(() => claimsToUser({ email: "", exp: NOW, sub: "u" }), /email/); // empty email rejected (the shell keys signed-in vs anonymous off it)
assert.deepEqual(claimsToUser({ email: "a@b.c", sub: "u" }).roles, []); // roles absent
assert.deepEqual(claimsToUser({ email: "a@b.c", roles: ["a", 1, "b"], sub: "u" }).roles, ["a", "b"]);
});
test("resolveSession classifies the cookie; authenticate is its fail-closed user projection", async () => {
const cookie = (extra: Record<string, unknown> = {}, kid = "k1") => `${SESSION_COOKIE}=${mint(k1.privateKey, kid, { ...valid, ...extra })}`;
const user = { email: "a@b.c", id: "u1", roles: ["admin"] };
// A valid token → the user, not expired.
assert.deepEqual(await resolveSession(cookie(), jwks, { now: NOW }), { expired: false, user });
// Present but past exp → the re-mint trigger (expired flagged, no user).
assert.deepEqual(await resolveSession(cookie({ exp: NOW - 999 }), jwks, { now: NOW }), { expired: true, user: null });
// No cookie / non-ours / garbage / bad-signature are NOT re-mint candidates (no Ory round-trip).
assert.deepEqual(await resolveSession(undefined, jwks, { now: NOW }), { expired: false, user: null });
assert.deepEqual(await resolveSession("other=1", jwks, { now: NOW }), { expired: false, user: null });
assert.deepEqual(await resolveSession(`${SESSION_COOKIE}=not.a.jwt`, jwks, { now: NOW }), { expired: false, user: null });
assert.deepEqual(await resolveSession(cookie({}, "nope"), jwks, { now: NOW }), { expired: false, user: null });
// authenticate() is the convenience wrapper — resolveSession(...).user, dropping the flag.
assert.deepEqual(await authenticate(cookie(), jwks, { now: NOW }), user);
assert.equal(await authenticate(cookie({ exp: NOW - 999 }), jwks, { now: NOW }), null); // expired ⇒ null
assert.equal(await authenticate(undefined, jwks, { now: NOW }), null);
});
test("verifyToken honours an optional denylist: a revoked subject's token rejects like an expiry → re-mint", async () => {
// Deny u1's tokens minted at/before NOW; a token minted after passes (a fresh re-login).
const denylist = { isRevoked: (sub: string, iat: number | undefined) => sub === "u1" && (iat === undefined || iat <= NOW) };
// Revoked: thrown as *expired* so resolveSession flags it for the 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 });
});
+110
View File
@@ -0,0 +1,110 @@
// JWT session middleware: verify our session cookie in-process on every request —
// 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 permission gate denies.
import type { User } from "../http/context.ts";
import { parseCookies } from "../http/cookie.ts";
import type { Denylist } from "./denylist.ts";
import { decodeJws, verifyJws } from "./jwt.ts";
import type { JwksProvider } from "./jwks.ts";
import { SESSION_COOKIE } from "./login.ts";
// Leeway on exp/nbf for small clock drift between Kratos and web.
const DEFAULT_CLOCK_SKEW_SEC = 60;
export interface VerifyOptions {
audience?: string | undefined; // if set, the token `aud` must include it (else skipped)
clockSkewSec?: number | undefined;
denylist?: Pick<Denylist, "isRevoked"> | undefined; // optional instant-revoke; a revoked sub is rejected like an expiry
issuer?: string | undefined; // if set, the token `iss` must equal it (else skipped)
now?: number | undefined; // unix seconds; injectable for tests
}
// A rejected token (bad signature, expired, wrong iss/aud, malformed claims). `authenticate`
// swallows it to anonymous; a caller wanting the reason can catch it. `expired` is set only for
// a lapsed-but-otherwise-intact token — the re-mint trigger (see resolveSession).
export class TokenError extends Error {
expired: boolean;
constructor(message: string, expired = false) {
super(message);
this.expired = expired;
}
}
function num(payload: Record<string, unknown>, claim: string): number | undefined {
const v = payload[claim];
return typeof v === "number" && Number.isFinite(v) ? v : undefined;
}
// Validate the time/issuer/audience claims of an already signature-verified payload.
export function validateClaims(payload: Record<string, unknown>, options: VerifyOptions = {}): void {
const skew = options.clockSkewSec ?? DEFAULT_CLOCK_SKEW_SEC;
const now = options.now ?? Math.floor(Date.now() / 1000);
const exp = num(payload, "exp");
if (exp === undefined) throw new TokenError("token missing exp");
if (now > exp + skew) throw new TokenError("token expired", true);
const nbf = num(payload, "nbf");
if (nbf !== undefined && now < nbf - skew) throw new TokenError("token not yet valid");
if (options.issuer !== undefined && payload["iss"] !== options.issuer) throw new TokenError("token issuer mismatch");
if (options.audience !== undefined) {
const aud = payload["aud"];
const ok = typeof aud === "string" ? aud === options.audience : Array.isArray(aud) && aud.includes(options.audience);
if (!ok) throw new TokenError("token audience mismatch");
}
}
// 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
// keeps only string entries (defensive).
export function claimsToUser(payload: Record<string, unknown>): User {
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") : [] };
}
// Verify a session JWT end-to-end: select the key by `kid`, check the signature, validate
// claims, project the User. Throws TokenError / the underlying verify error on any failure.
export async function verifyToken(token: string, jwks: JwksProvider, options: VerifyOptions = {}): Promise<User> {
const { header } = decodeJws(token); // unverified — only to read `kid` for key selection
const jwk = await jwks.getKey(header.kid);
if (!jwk) throw new TokenError(`no JWKS key for kid ${header.kid ?? "(none)"}`);
const verified = verifyJws(token, jwk); // throws on a bad signature / disallowed alg
validateClaims(verified.payload, options);
const user = claimsToUser(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).
if (options.denylist?.isRevoked(user.id, num(verified.payload, "iat"))) throw new TokenError("token revoked", true);
return user;
}
export interface SessionAuth {
expired: boolean; // a token was present but rejected as *expired* → a re-mint candidate
user: User | null;
}
// The request middleware: read our session cookie, verify it → the User (fail-closed: any
// bad/expired/missing token ⇒ null). `expired` distinguishes a lapsed-but-intact token from
// no-cookie / tampered ones, so app.ts only pays an Ory round-trip to re-mint a genuinely
// expired session, never for anonymous or garbage requests.
export async function resolveSession(cookieHeader: string | undefined, jwks: JwksProvider, options: VerifyOptions = {}): Promise<SessionAuth> {
const token = parseCookies(cookieHeader)[SESSION_COOKIE];
if (!token) return { expired: false, user: null };
try {
return { expired: false, user: await verifyToken(token, jwks, options) };
} catch (err) {
return { expired: err instanceof TokenError && err.expired, user: null };
}
}
// Convenience for callers that don't re-mint: just the User, or null.
export async function authenticate(cookieHeader: string | undefined, jwks: JwksProvider, options: VerifyOptions = {}): Promise<User | null> {
return (await resolveSession(cookieHeader, jwks, options)).user;
}
+94
View File
@@ -0,0 +1,94 @@
import assert from "node:assert/strict";
import { generateKeyPairSync, sign } from "node:crypto";
import type { JsonWebKey, KeyObject } from "node:crypto";
import { test } from "node:test";
import { decodeJws, verifyJws } from "./jwt.ts";
const b64url = (input: Buffer | string): string => Buffer.from(input).toString("base64url");
// Sign a compact JWS the way a JOSE signer (Kratos tokenizer) would, via node:crypto.
function makeJws(alg: "ES256" | "RS256", privateKey: KeyObject, payload: unknown): string {
const signingInput = `${b64url(JSON.stringify({ alg, typ: "JWT" }))}.${b64url(JSON.stringify(payload))}`;
const signature =
alg === "ES256"
? sign("SHA256", Buffer.from(signingInput), { key: privateKey, dsaEncoding: "ieee-p1363" })
: sign("RSA-SHA256", Buffer.from(signingInput), privateKey);
return `${signingInput}.${b64url(signature)}`;
}
const rsa = generateKeyPairSync("rsa", { modulusLength: 2048 });
const ec = generateKeyPairSync("ec", { namedCurve: "P-256" });
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 verified = verifyJws(token, rsaJwk);
assert.equal(verified.header.alg, "RS256");
assert.deepEqual(verified.payload, { roles: ["admin"], sub: "u" });
});
test("verifies an ES256 token (raw r‖s signature)", () => {
const token = makeJws("ES256", ec.privateKey, { sub: "u" });
assert.deepEqual(verifyJws(token, ecJwk).payload, { sub: "u" });
});
// 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 [header, payload, signature] = token.split(".");
const forged = `${header}.${b64url(JSON.stringify({ roles: ["admin"], sub: "u" }))}.${signature}`;
assert.throws(() => verifyJws(forged, rsaJwk), /invalid signature/);
const otherJwk = generateKeyPairSync("rsa", { modulusLength: 2048 }).publicKey.export({ format: "jwk" }) as JsonWebKey;
assert.throws(() => verifyJws(token, otherJwk), /invalid signature/);
assert.throws(() => verifyJws(`${header}.${payload}.`, rsaJwk), /invalid signature/);
});
// The algParams allowlist is the alg-confusion defense: anything outside RS*/ES* is refused
// (`HS*` symmetric and `none` would otherwise let an attacker forge tokens).
test("rejects an alg outside the allowlist (none, HS256)", () => {
const none = `${b64url(JSON.stringify({ alg: "none", typ: "JWT" }))}.${b64url(JSON.stringify({ sub: "u" }))}.`;
assert.throws(() => verifyJws(none, rsaJwk), /unsupported alg/);
const hs256 = `${b64url(JSON.stringify({ alg: "HS256" }))}.${b64url(JSON.stringify({ sub: "u" }))}.${b64url("x")}`;
assert.throws(() => verifyJws(hs256, rsaJwk), /unsupported alg/);
});
test("rejects when key type does not match the alg family", () => {
const token = makeJws("ES256", ec.privateKey, { sub: "u" });
assert.throws(() => verifyJws(token, rsaJwk), /does not match alg/);
});
test("rejects when the JWK pins a different alg", () => {
const token = makeJws("RS256", rsa.privateKey, { sub: "u" });
assert.throws(() => verifyJws(token, { ...rsaJwk, alg: "RS512" }), /alg mismatch/);
});
test("rejects a symmetric JWK (kty:oct) for an asymmetric alg — second defense after the allowlist", () => {
const token = makeJws("RS256", rsa.privateKey, { sub: "u" });
assert.throws(() => verifyJws(token, { k: b64url("secret"), kty: "oct" }), /invalid JWK/);
});
// decodeJws structural guards, all rejected before any crypto runs.
test("rejects malformed tokens before crypto (segment count, payload type, base64url, kid)", () => {
const p = b64url(JSON.stringify({ sub: "u" }));
assert.throws(() => verifyJws("only.two", rsaJwk), /expected 3 segments/);
const arrayPayload = `${b64url(JSON.stringify({ alg: "RS256" }))}.${b64url(JSON.stringify([1, 2, 3]))}.${b64url("x")}`;
assert.throws(() => verifyJws(arrayPayload, rsaJwk), /payload not an object/);
assert.throws(() => verifyJws(`ab*c.${p}.${b64url("x")}`, rsaJwk), /base64url/);
const badKid = `${b64url(JSON.stringify({ alg: "RS256", kid: 123 }))}.${p}.${b64url("x")}`;
assert.throws(() => verifyJws(badKid, rsaJwk), /kid/);
});
test("decodeJws exposes header and payload without verifying", () => {
const token = makeJws("RS256", rsa.privateKey, { sub: "u" });
const decoded = decodeJws(token);
assert.equal(decoded.header.alg, "RS256");
assert.deepEqual(decoded.payload, { sub: "u" });
});
+96
View File
@@ -0,0 +1,96 @@
import { createPublicKey, verify } from "node:crypto";
import type { JsonWebKey, KeyObject } from "node:crypto";
// JWS signature verification with the Node stdlib — no `jose`/JWT dep:
// `createPublicKey({format:"jwk"})` imports a JWK and verifies the RS*/ES* signatures
// the Kratos tokenizer produces (see AGENTS.md). Signature only — adds claim checks
// (exp/iss/aud, clock skew), JWKS-by-`kid` fetch/cache/rotation, and `token` bounds.
// JOSE `alg` → Node verify parameters. ES* signatures are raw r‖s (IEEE P1363), not DER.
// Extend this map to widen support. Security invariant: never add `HS*`/`none` — this map
// is the allowlist, and a symmetric entry lets an attacker-supplied HMAC key verify.
const algParams: Record<string, { hash: string; keyType: "ec" | "rsa"; dsaEncoding?: "ieee-p1363" }> = {
ES256: { dsaEncoding: "ieee-p1363", hash: "SHA256", keyType: "ec" },
RS256: { hash: "RSA-SHA256", keyType: "rsa" },
};
export interface JwsHeader {
alg: string;
kid?: string;
}
export interface DecodedJws {
header: JwsHeader;
payload: Record<string, unknown>;
signingInput: string;
signature: Buffer;
}
// Unpadded base64url alphabet — `Buffer.from(_,"base64url")` is lax (drops junk, tolerates
// bad padding), so reject non-canonical segments up front. reads `kid` from the still-
// unverified header, so this stops laundered bytes reaching key selection.
const base64url = /^[A-Za-z0-9_-]+$/;
function decodeSegment(segment: string): unknown {
if (!base64url.test(segment)) throw new Error("malformed JWS: invalid base64url segment");
return JSON.parse(Buffer.from(segment, "base64url").toString("utf8"));
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
// Split a compact JWS and base64url-decode its header/payload. No signature check.
export function decodeJws(token: string): DecodedJws {
const parts = token.split(".");
if (parts.length !== 3) throw new Error("malformed JWS: expected 3 segments");
const [headerB64, payloadB64, signatureB64] = parts as [string, string, string];
const rawHeader = decodeSegment(headerB64);
const payload = decodeSegment(payloadB64);
if (!isPlainObject(rawHeader)) throw new Error("malformed JWS: header not an object");
if (!isPlainObject(payload)) throw new Error("malformed JWS: payload not an object");
const { alg, kid } = rawHeader;
if (typeof alg !== "string") throw new Error("malformed JWS: header missing `alg`");
if (kid !== undefined && typeof kid !== "string") throw new Error("malformed JWS: `kid` must be a string");
return {
header: kid === undefined ? { alg } : { alg, kid },
payload,
// Verify over the original encoded strings — never re-encode the decoded JSON.
signingInput: `${headerB64}.${payloadB64}`,
signature: Buffer.from(signatureB64, "base64url"),
};
}
// Verify a compact JWS against one JWK public key; returns the decoded JWS or throws.
// Signature only — caller validates claims. Returned header is post-verification, so the caller
// can trust its `alg`/`kid` when logging.
export function verifyJws(token: string, jwk: JsonWebKey): DecodedJws {
const decoded = decodeJws(token);
const { header, signingInput, signature } = decoded;
const params = algParams[header.alg];
if (!params) throw new Error(`unsupported alg: ${header.alg}`);
// Block alg confusion: a key may pin its own `alg`, and its type must match the family.
if (typeof jwk.alg === "string" && jwk.alg !== header.alg) throw new Error("alg mismatch between JWS header and JWK");
let key: KeyObject;
try {
key = createPublicKey({ format: "jwk", key: jwk });
} catch {
throw new Error("invalid JWK");
}
if (key.asymmetricKeyType !== params.keyType) {
throw new Error(`JWK type ${key.asymmetricKeyType} does not match alg ${header.alg}`);
}
const data = Buffer.from(signingInput);
const ok = params.dsaEncoding
? verify(params.hash, data, { dsaEncoding: params.dsaEncoding, key }, signature)
: verify(params.hash, data, key, signature);
if (!ok) throw new Error("invalid signature");
return decoded;
}
+106
View File
@@ -0,0 +1,106 @@
// Keto client: typed fetch wrappers over Ory Keto's read (check/list/expand) and
// write (write/delete tuple) APIs. Guards the request contracts (URLs, ports, method,
// query/body shape, subject_id vs subject_set) and the result mapping (allowed bool, the
// next_page_token, 2xx/204/error). Live wiring is verified by login completion + guards.
import { test } from "node:test";
import assert from "node:assert/strict";
import { createKetoClient, KetoError } from "./keto-client.ts";
const READ = "http://keto:4466";
const WRITE = "http://keto:4467";
const USER = "user:01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b55";
function res(status: number, body?: unknown): Response {
const h = new Headers();
if (body !== undefined) h.set("content-type", "application/json");
return new Response(body === undefined ? null : JSON.stringify(body), { status, headers: h });
}
function recorder(handler: (url: string, init: RequestInit | undefined) => Response) {
const calls: { body: string | undefined; method: string; url: string }[] = [];
const fetchImpl = (async (input: unknown, init?: RequestInit) => {
calls.push({ body: init?.body as string | undefined, method: init?.method ?? "GET", url: String(input) });
return handler(String(input), init);
}) as typeof fetch;
return { calls, fetchImpl };
}
const keto = (fetchImpl: typeof fetch) => createKetoClient({ fetchImpl, readUrl: READ, writeUrl: WRITE });
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.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, 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: "user:nobody" }), false);
});
test("check on a subject_set builds subject_set.* params and forwards max-depth", async () => {
const { calls, fetchImpl } = recorder(() => res(200, { allowed: true }));
await keto(fetchImpl).check(
{ namespace: "Resource", object: "doc1", relation: "view", subject_set: { namespace: "Group", object: "eng", relation: "members" } },
{ maxDepth: 5 },
);
const url = calls[0]!.url;
assert.match(url, /subject_set\.namespace=Group&subject_set\.object=eng&subject_set\.relation=members/);
assert.match(url, /max-depth=5/);
});
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 }),
(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 { 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" });
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, /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();
assert.deepEqual(empty, { nextPageToken: null, tuples: [] });
});
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 { calls, fetchImpl } = recorder(() => res(200, tree));
const out = await keto(fetchImpl).expand({ namespace: "Role", object: "admin", relation: "members" }, { 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/);
});
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 { calls, fetchImpl } = recorder(() => res(201, tuple));
await keto(fetchImpl).writeTuple(tuple);
assert.equal(calls[0]!.method, "PUT");
assert.equal(calls[0]!.url, `${WRITE}/admin/relation-tuples`);
assert.deepEqual(JSON.parse(calls[0]!.body!), tuple);
await assert.rejects(
keto((async () => res(500, "boom")) as typeof fetch).writeTuple(tuple),
(e: unknown) => e instanceof KetoError && e.status === 500,
);
});
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 });
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/);
await assert.rejects(
keto((async () => res(404)) as typeof fetch).deleteTuple({ namespace: "Role", object: "x", relation: "members", subject_id: USER }),
(e: unknown) => e instanceof KetoError && e.status === 404,
);
});
+127
View File
@@ -0,0 +1,127 @@
// Keto client: typed `fetch` wrappers over Ory Keto's relation-tuple APIs —
// `check` a permission, `listRelations`/`expand` to inspect them (read API), `writeTuple`/
// `deleteTuple` to grant/revoke them (write API). Built-in `fetch` only, no SDK dep (AGENTS.md);
// `fetchImpl`-injectable like the kratos clients. Read/write split onto the two ports config.ts
// targets (ketoReadUrl 4466 / ketoWriteUrl 4467).
// A subject set: a relation on another object (e.g. Group:eng#members), resolved
// transitively. The other Keto subject form is a direct `subject_id` string.
export interface SubjectSet {
namespace: string;
object: string;
relation: string;
}
// 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.
export interface RelationTuple {
namespace: string;
object: string;
relation: string;
subject_id?: string;
subject_set?: SubjectSet;
}
// Any subset of a tuple's fields filters a list query; the rest paginate.
export type RelationQuery = Partial<RelationTuple> & { pageSize?: number; pageToken?: string };
export interface RelationList {
nextPageToken: string | null; // keyset cursor for the next page; null on the last page
tuples: RelationTuple[];
}
// Keto's expand tree: a node is a set operation (union/…) or a leaf. The resolved subject
// (subject_id xor subject_set) rides on `tuple`, not the node itself — verified against Keto
// v26.2.0. A `subject_set` node carries its members as `children` ("effective access" view).
export interface ExpandTree {
children?: ExpandTree[];
tuple?: RelationTuple;
type: string;
}
// Carries the HTTP status so a caller can branch (parallels KratosError).
export class KetoError extends Error {
body: string;
status: number;
constructor(message: string, status: number, body: string) {
super(message);
this.body = body;
this.name = "KetoError";
this.status = status;
}
}
export interface KetoClient {
check(tuple: RelationTuple, opts?: { maxDepth?: number }): Promise<boolean>;
deleteTuple(tuple: RelationTuple): Promise<void>;
expand(set: SubjectSet, opts?: { maxDepth?: number }): Promise<ExpandTree>;
listRelations(query?: RelationQuery): Promise<RelationList>;
writeTuple(tuple: RelationTuple): Promise<void>;
}
// namespace/object/relation + the chosen subject form → query params (Keto's read API and
// tuple delete both filter this way; subject sets use dotted `subject_set.*` keys).
function tupleParams(t: Partial<RelationTuple>): URLSearchParams {
const p = new URLSearchParams();
if (t.namespace !== undefined) p.set("namespace", t.namespace);
if (t.object !== undefined) p.set("object", t.object);
if (t.relation !== undefined) p.set("relation", t.relation);
if (t.subject_id !== undefined) p.set("subject_id", t.subject_id);
if (t.subject_set) {
p.set("subject_set.namespace", t.subject_set.namespace);
p.set("subject_set.object", t.subject_set.object);
p.set("subject_set.relation", t.subject_set.relation);
}
return p;
}
export function createKetoClient(config: { fetchImpl?: typeof fetch; readUrl: string; writeUrl: string }): KetoClient {
const read = config.readUrl.replace(/\/+$/, "");
const write = config.writeUrl.replace(/\/+$/, "");
const http = config.fetchImpl ?? fetch;
const tuples = `${write}/admin/relation-tuples`;
async function fail(action: string, res: Response): Promise<never> {
throw new KetoError(`Keto ${action} failed (${res.status})`, res.status, await res.text());
}
return {
async check(tuple, opts = {}) {
const params = tupleParams(tuple);
if (opts.maxDepth !== undefined) params.set("max-depth", String(opts.maxDepth));
const res = await http(`${read}/relation-tuples/check?${params}`);
// Keto answers 200 {allowed:true} or 403 {allowed:false}; both carry the verdict.
if (res.status !== 200 && res.status !== 403) return fail("check", res);
return ((await res.json()) as { allowed?: boolean }).allowed === true;
},
async deleteTuple(tuple) {
const res = await http(`${tuples}?${tupleParams(tuple)}`, { method: "DELETE" });
if (res.status !== 204) await fail("delete tuple", res);
},
async expand(set, opts = {}) {
const params = tupleParams(set);
if (opts.maxDepth !== undefined) params.set("max-depth", String(opts.maxDepth));
const res = await http(`${read}/relation-tuples/expand?${params}`);
if (res.status !== 200) return fail("expand", res);
return (await res.json()) as ExpandTree;
},
async listRelations(query = {}) {
const params = tupleParams(query);
if (query.pageSize !== undefined) params.set("page_size", String(query.pageSize));
if (query.pageToken) params.set("page_token", query.pageToken);
const res = await http(`${read}/relation-tuples?${params}`);
if (res.status !== 200) return fail("list relations", res);
const body = (await res.json()) as { next_page_token?: string; relation_tuples?: RelationTuple[] };
return { nextPageToken: body.next_page_token || null, tuples: body.relation_tuples ?? [] };
},
// PUT is idempotent — re-asserting an existing tuple is a no-op grant.
async writeTuple(tuple) {
const res = await http(tuples, { body: JSON.stringify(tuple), headers: { "content-type": "application/json" }, method: "PUT" });
if (!res.ok) await fail("write tuple", res);
},
};
}
+120
View File
@@ -0,0 +1,120 @@
// 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.
// 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";
import assert from "node:assert/strict";
import { createKratosAdmin } from "./kratos-admin.ts";
import { KratosError } from "./kratos-public.ts";
const BASE = "http://kratos:4434";
const ID = "01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b55";
function res(status: number, body?: unknown, headers: Record<string, string> = {}): Response {
const h = new Headers(headers);
if (body !== undefined) h.set("content-type", "application/json");
return new Response(body === undefined ? null : JSON.stringify(body), { status, headers: h });
}
function recorder(handler: (url: string, init: RequestInit | undefined) => Response) {
const calls: { body: string | undefined; headers: Headers; method: string; url: string }[] = [];
const fetchImpl = (async (input: unknown, init?: RequestInit) => {
calls.push({ body: init?.body as string | undefined, headers: new Headers(init?.headers), method: init?.method ?? "GET", url: String(input) });
return handler(String(input), init);
}) as typeof fetch;
return { calls, fetchImpl };
}
test("createIdentity POSTs JSON to /admin/identities and returns the created identity (201)", async () => {
const identity = { id: ID, traits: { email: "a@b" } };
const { calls, fetchImpl } = recorder(() => res(201, identity));
const payload = { schema_id: "default", traits: { email: "a@b" } };
const out = await createKratosAdmin({ baseUrl: BASE, fetchImpl }).createIdentity(payload);
assert.deepEqual(out, identity);
assert.equal(calls[0]!.method, "POST");
assert.match(calls[0]!.url, /\/admin\/identities$/);
assert.equal(calls[0]!.headers.get("content-type"), "application/json");
assert.equal(calls[0]!.body, JSON.stringify(payload));
});
test("createIdentity throws a KratosError carrying the status on conflict (409)", async () => {
const { fetchImpl } = recorder(() => res(409, { error: { id: "conflict" } }));
await assert.rejects(
createKratosAdmin({ baseUrl: BASE, fetchImpl }).createIdentity({}),
(e: unknown) => e instanceof KratosError && e.status === 409,
);
});
test("getIdentity reads /admin/identities/<id> → identity on 200, null on 404", async () => {
const identity = { id: ID, traits: { email: "a@b" } };
const { calls, fetchImpl } = recorder((url) => (url.endsWith(ID) ? res(200, identity) : res(404)));
const admin = createKratosAdmin({ baseUrl: BASE, fetchImpl });
assert.deepEqual(await admin.getIdentity(ID), identity);
assert.match(calls[0]!.url, new RegExp(`/admin/identities/${ID}$`));
assert.equal(await createKratosAdmin({ baseUrl: BASE, fetchImpl: (async () => res(404)) as typeof fetch }).getIdentity("missing"), null);
});
test("listIdentities builds the query (filter/ids/pagination) and parses next page_token from the Link header", async () => {
const identities = [{ id: ID }];
const link = `</admin/identities?page_size=2&page_token=NEXT>; rel="next",</admin/identities?page_size=2>; rel="first"`;
const { calls, fetchImpl } = recorder(() => res(200, identities, { link }));
const out = await createKratosAdmin({ baseUrl: BASE, fetchImpl }).listIdentities({
credentialsIdentifier: "a@b",
ids: ["x", "y"],
pageSize: 2,
pageToken: "CUR",
});
assert.deepEqual(out.identities, identities);
assert.equal(out.nextPageToken, "NEXT");
const url = calls[0]!.url;
assert.match(url, /credentials_identifier=a%40b/);
assert.match(url, /ids=x&ids=y/);
assert.match(url, /page_size=2/);
assert.match(url, /page_token=CUR/);
});
test("listIdentities reports a null next token when there is no Link header", async () => {
const { fetchImpl } = recorder(() => res(200, []));
assert.equal((await createKratosAdmin({ baseUrl: BASE, fetchImpl }).listIdentities()).nextPageToken, null);
});
test("updateIdentity PUTs the full body to /admin/identities/<id> and returns the updated identity", async () => {
const identity = { id: ID, state: "inactive" };
const { calls, fetchImpl } = recorder(() => res(200, identity));
const body = { schema_id: "default", state: "inactive", traits: { email: "a@b" } };
const out = await createKratosAdmin({ baseUrl: BASE, fetchImpl }).updateIdentity(ID, body);
assert.deepEqual(out, identity);
assert.equal(calls[0]!.method, "PUT");
assert.match(calls[0]!.url, new RegExp(`/admin/identities/${ID}$`));
assert.equal(calls[0]!.body, JSON.stringify(body));
});
test("updateMetadataPublic PATCHes a JSON-Patch `add /metadata_public` so it never clobbers traits", async () => {
const identity = { id: ID, metadata_public: { roles: ["admin"] } };
const { calls, fetchImpl } = recorder(() => res(200, identity));
const out = await createKratosAdmin({ baseUrl: BASE, fetchImpl }).updateMetadataPublic(ID, { roles: ["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"] } }]);
});
test("createRecoveryCode POSTs the identity id to /admin/recovery/code → { code, link }", async () => {
const { calls, fetchImpl } = recorder(() => res(200, { recovery_code: "123456", recovery_link: "http://kratos/self-service/recovery?flow=f&code=123456" }));
const out = await createKratosAdmin({ baseUrl: BASE, fetchImpl }).createRecoveryCode(ID);
assert.deepEqual(out, { code: "123456", link: "http://kratos/self-service/recovery?flow=f&code=123456" });
assert.equal(calls[0]!.method, "POST");
assert.match(calls[0]!.url, /\/admin\/recovery\/code$/);
assert.deepEqual(JSON.parse(calls[0]!.body!), { identity_id: ID });
});
test("deleteIdentity DELETEs by id (204 resolves; non-204 throws a KratosError)", async () => {
const { calls, fetchImpl } = recorder(() => res(204));
await createKratosAdmin({ baseUrl: BASE, fetchImpl }).deleteIdentity(ID);
assert.equal(calls[0]!.method, "DELETE");
assert.match(calls[0]!.url, new RegExp(`/admin/identities/${ID}$`));
await assert.rejects(
createKratosAdmin({ baseUrl: BASE, fetchImpl: (async () => res(404)) as typeof fetch }).deleteIdentity("missing"),
(e: unknown) => e instanceof KratosError && e.status === 404,
);
});
+118
View File
@@ -0,0 +1,118 @@
// 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);
// `fetchImpl`-injectable, reuses kratos-public.ts's `KratosError` (branch on `.status`).
import { KratosError } from "./kratos-public.ts";
export interface Identity {
id: string;
metadata_admin?: unknown;
metadata_public?: unknown;
schema_id?: string;
state?: string;
traits?: Record<string, unknown>;
}
export interface IdentityList {
identities: Identity[];
nextPageToken: string | null; // keyset cursor for the next page; null on the last page
}
export interface ListOptions {
credentialsIdentifier?: string; // exact-match filter on a login identifier (e.g. email)
ids?: string[];
pageSize?: number;
pageToken?: string;
}
// A one-time recovery code + the self-service link wrapping it (admin "trigger recovery").
export interface RecoveryCode {
code: string;
link: string;
}
export interface KratosAdmin {
createIdentity(payload: unknown): Promise<Identity>;
createRecoveryCode(identityId: string, opts?: { expiresIn?: string }): Promise<RecoveryCode>;
deleteIdentity(id: string): Promise<void>;
getIdentity(id: string): Promise<Identity | null>;
listIdentities(opts?: ListOptions): Promise<IdentityList>;
updateIdentity(id: string, payload: unknown): Promise<Identity>;
updateMetadataPublic(id: string, metadata: unknown): Promise<Identity>;
}
// Kratos paginates with a Link header; pull the page_token of rel="next" (the href is a
// relative path, so resolve it against a throwaway base just to read the query param).
function nextPageToken(link: string | null): string | null {
const href = link?.match(/<([^>]+)>\s*;\s*rel="next"/)?.[1];
return href ? new URL(href, "http://kratos").searchParams.get("page_token") : null;
}
export function createKratosAdmin(config: { baseUrl: string; fetchImpl?: typeof fetch }): KratosAdmin {
const base = config.baseUrl.replace(/\/+$/, "");
const http = config.fetchImpl ?? fetch;
const json = { "content-type": "application/json" };
const identity = (id: string) => `${base}/admin/identities/${encodeURIComponent(id)}`;
async function fail(action: string, res: Response): Promise<never> {
throw new KratosError(`Kratos admin ${action} failed (${res.status})`, res.status, await res.text());
}
return {
async createIdentity(payload) {
const res = await http(`${base}/admin/identities`, { body: JSON.stringify(payload), headers: json, method: "POST" });
if (res.status !== 201) return fail("create identity", res);
return (await res.json()) as Identity;
},
// Mint a recovery code for an identity (admin "trigger recovery") — the link is mailed to the
// user by Kratos; the code/link are also returned so an operator can hand them over directly.
async createRecoveryCode(identityId, opts = {}) {
const body: Record<string, unknown> = { identity_id: identityId };
if (opts.expiresIn) body.expires_in = opts.expiresIn;
const res = await http(`${base}/admin/recovery/code`, { body: JSON.stringify(body), headers: json, method: "POST" });
if (res.status !== 200 && res.status !== 201) return fail("create recovery code", res);
const data = (await res.json()) as { recovery_code: string; recovery_link: string };
return { code: data.recovery_code, link: data.recovery_link };
},
async deleteIdentity(id) {
const res = await http(identity(id), { method: "DELETE" });
if (res.status !== 204) await fail("delete identity", res);
},
async getIdentity(id) {
const res = await http(identity(id));
if (res.status === 404) return null;
if (res.status !== 200) return fail("get identity", res);
return (await res.json()) as Identity;
},
async listIdentities(opts = {}) {
const url = new URL(`${base}/admin/identities`);
if (opts.credentialsIdentifier) url.searchParams.set("credentials_identifier", opts.credentialsIdentifier);
for (const id of opts.ids ?? []) url.searchParams.append("ids", id);
if (opts.pageSize !== undefined) url.searchParams.set("page_size", String(opts.pageSize));
if (opts.pageToken) url.searchParams.set("page_token", opts.pageToken);
const res = await http(url);
if (res.status !== 200) return fail("list identities", res);
return { identities: (await res.json()) as Identity[], nextPageToken: nextPageToken(res.headers.get("link")) };
},
async updateIdentity(id, payload) {
const res = await http(identity(id), { body: JSON.stringify(payload), headers: json, method: "PUT" });
if (res.status !== 200) return fail("update identity", res);
return (await res.json()) as Identity;
},
// 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.
// (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 }];
const res = await http(identity(id), { body: JSON.stringify(patch), headers: json, method: "PATCH" });
if (res.status !== 200) return fail("update metadata_public", res);
return (await res.json()) as Identity;
},
};
}
+121
View File
@@ -0,0 +1,121 @@
// Kratos public-API client: typed fetch wrappers over Ory Kratos' public endpoints.
// Guards the request contracts (URLs, JSON-accept, cookie relay) and the result mapping
// (200/401/4xx, validation-flow vs success, tokenized JWT). Live wiring is verified by the
// flow pages; these catch contract drift with a mock fetch.
import { test } from "node:test";
import assert from "node:assert/strict";
import { createKratosPublic, KratosError } from "./kratos-public.ts";
const BASE = "http://kratos:4433";
function res(status: number, body?: unknown, setCookie: string[] = []): Response {
const headers = new Headers();
if (body !== undefined) headers.set("content-type", "application/json");
for (const c of setCookie) headers.append("set-cookie", c);
return new Response(body === undefined ? null : JSON.stringify(body), { status, headers });
}
// Records each call so a test can assert URL/method/headers/body.
function recorder(handler: (url: string, init: RequestInit | undefined) => Response) {
const calls: { body: string | undefined; headers: Headers; method: string; url: string }[] = [];
const fetchImpl = (async (input: unknown, init?: RequestInit) => {
calls.push({
body: init?.body as string | undefined,
headers: new Headers(init?.headers),
method: init?.method ?? "GET",
url: String(input),
});
return handler(String(input), init);
}) as typeof fetch;
return { calls, fetchImpl };
}
test("initBrowserFlow gets /self-service/<type>/browser as JSON, relays Set-Cookie, forwards return_to", async () => {
const flow = { id: "f1", ui: { action: `${BASE}/self-service/login?flow=f1`, method: "POST", nodes: [] } };
const { calls, fetchImpl } = recorder(() => res(200, flow, ["csrf_token=abc; Path=/; HttpOnly"]));
const out = await createKratosPublic({ baseUrl: BASE, fetchImpl }).initBrowserFlow("login", { returnTo: "http://app/after" });
assert.deepEqual(out.flow, flow);
assert.deepEqual(out.setCookie, ["csrf_token=abc; Path=/; HttpOnly"]);
assert.match(calls[0]!.url, /\/self-service\/login\/browser\?return_to=http%3A%2F%2Fapp%2Fafter$/);
assert.equal(calls[0]!.headers.get("accept"), "application/json");
});
test("getFlow fetches the flow by id forwarding the browser cookie", async () => {
const flow = { id: "f2", ui: { action: "x", method: "POST", nodes: [] } };
const { calls, fetchImpl } = recorder(() => res(200, flow));
const out = await createKratosPublic({ baseUrl: BASE, fetchImpl }).getFlow("registration", "f2", { cookie: "csrf_token=abc" });
assert.deepEqual(out, flow);
assert.match(calls[0]!.url, /\/self-service\/registration\/flows\?id=f2$/);
assert.equal(calls[0]!.headers.get("cookie"), "csrf_token=abc");
});
test("getFlow throws a KratosError carrying the status when the flow is gone (410)", async () => {
const { fetchImpl } = recorder(() => res(410, { error: { id: "self_service_flow_expired" } }));
await assert.rejects(
createKratosPublic({ baseUrl: BASE, fetchImpl }).getFlow("login", "old"),
(e: unknown) => e instanceof KratosError && e.status === 410,
);
});
test("submitFlow POSTs urlencoded to the action and reports success + relays Set-Cookie", async () => {
const { calls, fetchImpl } = recorder(() => res(200, { session: { active: true } }, ["plainpages_session=s; Path=/"]));
const out = await createKratosPublic({ baseUrl: BASE, fetchImpl })
.submitFlow(`${BASE}/self-service/login?flow=f`, { body: "identifier=a&password=b", cookie: "csrf_token=abc" });
assert.equal(out.ok, true);
assert.equal(out.status, 200);
assert.deepEqual(out.setCookie, ["plainpages_session=s; Path=/"]);
assert.equal(calls[0]!.method, "POST");
assert.equal(calls[0]!.headers.get("content-type"), "application/x-www-form-urlencoded");
assert.equal(calls[0]!.body, "identifier=a&password=b");
});
test("submitFlow returns the re-rendered flow (no throw) on a 400 validation error", async () => {
const flow = { id: "f", ui: { action: "x", messages: [{ id: 4000006, text: "invalid credentials", type: "error" }], method: "POST", nodes: [] } };
const { fetchImpl } = recorder(() => res(400, flow));
const out = await createKratosPublic({ baseUrl: BASE, fetchImpl }).submitFlow(`${BASE}/x`, { body: "x=1" });
assert.equal(out.ok, false);
assert.equal(out.status, 400);
assert.deepEqual(out.body, flow);
});
test("submitFlow surfaces the redirect target — Location header or a 422 redirect_browser_to body", async () => {
const k = (handler: () => Response) => createKratosPublic({ baseUrl: BASE, fetchImpl: recorder(handler).fetchImpl });
const viaHeader = await k(() => new Response(null, { headers: new Headers({ location: "http://app/" }), status: 303 }))
.submitFlow(`${BASE}/x`, { body: "x=1" });
assert.equal(viaHeader.location, "http://app/");
const viaBody = await k(() => res(422, { redirect_browser_to: "http://app/login?flow=next" }))
.submitFlow(`${BASE}/x`, { body: "x=1" });
assert.equal(viaBody.location, "http://app/login?flow=next");
});
test("whoami returns the session on 200 (cookie forwarded) and null on 401", async () => {
const session = { active: true, identity: { id: "u1", traits: { email: "a@b" } } };
const { calls, fetchImpl } = recorder((url) => (url.endsWith("/sessions/whoami") ? res(200, session) : res(401)));
const k = createKratosPublic({ baseUrl: BASE, fetchImpl });
assert.deepEqual(await k.whoami({ cookie: "plainpages_session=s" }), session);
assert.equal(calls[0]!.headers.get("cookie"), "plainpages_session=s");
assert.equal(await createKratosPublic({ baseUrl: BASE, fetchImpl: (async () => res(401)) as typeof fetch }).whoami(), null);
});
test("whoami?tokenize_as mints a session JWT via the tokenizer template", async () => {
const session = { active: true, identity: { id: "u1" }, tokenized: "header.payload.sig" };
const { calls, fetchImpl } = recorder(() => res(200, session));
const out = await createKratosPublic({ baseUrl: BASE, fetchImpl }).whoami({ cookie: "plainpages_session=s", tokenizeAs: "plainpages" });
assert.equal(out?.tokenized, "header.payload.sig");
assert.match(calls[0]!.url, /\/sessions\/whoami\?tokenize_as=plainpages$/);
});
test("whoami throws on an unexpected upstream error", async () => {
const { fetchImpl } = recorder(() => res(500, { error: "boom" }));
await assert.rejects(createKratosPublic({ baseUrl: BASE, fetchImpl }).whoami(), KratosError);
});
test("createLogoutFlow returns the logout URL/token on 200 (cookie forwarded) and null on 401 (no session)", async () => {
const flow = { logout_token: "lt", logout_url: `${BASE}/self-service/logout?token=lt` };
const { calls, fetchImpl } = recorder((url) => (url.endsWith("/self-service/logout/browser") ? res(200, flow) : res(401)));
const out = await createKratosPublic({ baseUrl: BASE, fetchImpl }).createLogoutFlow({ cookie: "plainpages_session=s" });
assert.deepEqual(out, { logoutToken: "lt", logoutUrl: flow.logout_url });
assert.match(calls[0]!.url, /\/self-service\/logout\/browser$/);
assert.equal(calls[0]!.headers.get("cookie"), "plainpages_session=s");
assert.equal(await createKratosPublic({ baseUrl: BASE, fetchImpl: (async () => res(401)) as typeof fetch }).createLogoutFlow(), null);
});
+153
View File
@@ -0,0 +1,153 @@
// Kratos public-API client: typed `fetch` wrappers over Ory Kratos' public
// endpoints — self-service flow init/get/submit, browser logout, session `whoami`, and the
// session→JWT tokenizer (`whoami?tokenize_as`). Built-in `fetch` only, no SDK dep (AGENTS.md).
// Flow `ui.nodes` types stay loose — rendering + field-error mapping is flow-view.ts's job.
export type FlowType = "login" | "recovery" | "registration" | "settings" | "verification";
export interface UiText {
context?: Record<string, unknown>;
id: number;
text: string;
type: string;
}
export interface UiNode {
attributes: Record<string, unknown>;
group: string;
messages: UiText[];
meta: { label?: UiText };
type: string;
}
export interface FlowUi {
action: string; // absolute Kratos URL the browser POSTs the form to (Kratos owns its CSRF)
messages?: UiText[];
method: string;
nodes: UiNode[];
}
export interface Flow {
id: string;
type?: string;
ui: FlowUi;
}
export interface Session {
active?: boolean;
expires_at?: string;
identity?: { id: string; metadata_public?: unknown; traits?: Record<string, unknown> }; // whoami strips metadata_admin
tokenized?: string; // the signed JWT — present only when `tokenize_as` was requested
}
export interface FlowInit {
flow: Flow;
setCookie: string[]; // Kratos' CSRF cookie(s) to relay to the browser
}
export interface LogoutFlow {
logoutToken: string; // CSRF token Kratos embeds in logoutUrl
logoutUrl: string; // send the browser here to revoke the session + clear Kratos' cookie
}
export interface FlowSubmission {
body: unknown; // parsed JSON: the re-rendered flow on 400, the success payload on 200
location: string | null; // redirect target (Location header, or a 422 redirect_browser_to)
ok: boolean; // status === 200
setCookie: string[];
status: number;
}
// Carries the HTTP status so a caller can branch — e.g. re-init on an expired flow (404/410).
export class KratosError extends Error {
body: string;
status: number;
constructor(message: string, status: number, body: string) {
super(message);
this.body = body;
this.name = "KratosError";
this.status = status;
}
}
export interface KratosPublic {
createLogoutFlow(opts?: { cookie?: string }): Promise<LogoutFlow | null>; // null ⇒ no active session (401)
getFlow(type: FlowType, id: string, opts?: { cookie?: string }): Promise<Flow>;
initBrowserFlow(type: FlowType, opts?: { cookie?: string; returnTo?: string }): Promise<FlowInit>;
submitFlow(action: string, opts: { body: string; contentType?: string; cookie?: string }): Promise<FlowSubmission>;
whoami(opts?: { cookie?: string; tokenizeAs?: string }): Promise<Session | null>;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function parseBody(text: string): unknown {
if (!text) return null;
try {
return JSON.parse(text);
} catch {
return text;
}
}
export function createKratosPublic(config: { baseUrl: string; fetchImpl?: typeof fetch }): KratosPublic {
const base = config.baseUrl.replace(/\/+$/, "");
const http = config.fetchImpl ?? fetch;
// Forward the browser cookie + ask for JSON, so Kratos returns the flow/session instead
// of redirecting an API caller.
function headers(cookie?: string): Record<string, string> {
const h: Record<string, string> = { accept: "application/json" };
if (cookie) h["cookie"] = cookie;
return h;
}
return {
async createLogoutFlow(opts = {}) {
// Browser logout: get the logout URL (carrying a CSRF token) to send the browser to.
const res = await http(new URL(`${base}/self-service/logout/browser`), { headers: headers(opts.cookie), redirect: "manual" });
if (res.status === 401) return null; // no active session to revoke
if (res.status !== 200) throw new KratosError(`Kratos logout flow failed (${res.status})`, res.status, await res.text());
const body = (await res.json()) as { logout_token: string; logout_url: string };
return { logoutToken: body.logout_token, logoutUrl: body.logout_url };
},
async initBrowserFlow(type, opts = {}) {
const url = new URL(`${base}/self-service/${type}/browser`);
if (opts.returnTo) url.searchParams.set("return_to", opts.returnTo);
const res = await http(url, { headers: headers(opts.cookie), redirect: "manual" });
if (res.status !== 200) throw new KratosError(`Kratos init ${type} flow failed (${res.status})`, res.status, await res.text());
return { flow: (await res.json()) as Flow, setCookie: res.headers.getSetCookie() };
},
async getFlow(type, id, opts = {}) {
const url = new URL(`${base}/self-service/${type}/flows`);
url.searchParams.set("id", id);
const res = await http(url, { headers: headers(opts.cookie) });
if (res.status !== 200) throw new KratosError(`Kratos get ${type} flow failed (${res.status})`, res.status, await res.text());
return (await res.json()) as Flow;
},
async submitFlow(action, opts) {
const h = headers(opts.cookie);
h["content-type"] = opts.contentType ?? "application/x-www-form-urlencoded";
// Manual redirect so we can read a 303 Location instead of following it server-side.
const res = await http(action, { body: opts.body, headers: h, method: "POST", redirect: "manual" });
const body = parseBody(await res.text());
const location =
res.headers.get("location") ??
(isRecord(body) && typeof body["redirect_browser_to"] === "string" ? body["redirect_browser_to"] : null);
return { body, location, ok: res.status === 200, setCookie: res.headers.getSetCookie(), status: res.status };
},
async whoami(opts = {}) {
const url = new URL(`${base}/sessions/whoami`);
if (opts.tokenizeAs) url.searchParams.set("tokenize_as", opts.tokenizeAs);
const res = await http(url, { headers: headers(opts.cookie) });
if (res.status === 401) return null; // no/expired session
if (res.status !== 200) throw new KratosError(`Kratos whoami failed (${res.status})`, res.status, await res.text());
return (await res.json()) as Session;
},
};
}
+121
View File
@@ -0,0 +1,121 @@
// Login completion: turn a Kratos session into our session JWT — read roles 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";
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";
const ID = "01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b55";
const roleTuple = (object: string): RelationTuple => ({ namespace: "Role", object, relation: "members", subject_id: `user:${ID}` });
const ketoStub = (over: Partial<KetoClient> = {}): KetoClient => ({
check: async () => false,
deleteTuple: async () => {},
expand: async () => ({ type: "leaf" }),
listRelations: async () => ({ nextPageToken: null, tuples: [] }),
writeTuple: async () => {},
...over,
});
const adminStub = (over: Partial<KratosAdmin> = {}): KratosAdmin => ({
createIdentity: async () => { throw new Error("unused"); },
createRecoveryCode: async () => ({ code: "000000", link: "http://kratos/recover" }),
deleteIdentity: async () => {},
getIdentity: async () => null,
listIdentities: async () => ({ identities: [], nextPageToken: null }),
updateIdentity: async () => { throw new Error("unused"); },
updateMetadataPublic: async () => ({ id: ID }),
...over,
});
const publicStub = (over: Partial<KratosPublic> = {}): KratosPublic => ({
createLogoutFlow: async () => null,
getFlow: async () => { throw new Error("unused"); },
initBrowserFlow: async () => { throw new Error("unused"); },
submitFlow: async () => { throw new Error("unused"); },
whoami: async () => null,
...over,
});
test("readRoles returns roles held directly OR transitively (enumerate defined roles → 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 keto = ketoStub({
// Enumerate every Role tuple (paged, no subject filter) to find the distinct role 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: "user:other" })] };
return { nextPageToken: "p2", tuples: [
role("editor", { subject_set: { namespace: "Group", object: "eng", relation: "members" } }),
role("admin", { subject_id: `user:${ID}` }),
role("viewer", { subject_id: "user: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.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
});
test("completeLogin: read roles → 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" } };
const kratosPublic = publicStub({
whoami: async (o) => {
if (o?.tokenizeAs) { events.push("tokenize"); return { active: true, identity, tokenized: "h.p.s" } as Session; }
events.push("whoami"); return { active: true, identity } as Session;
},
});
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 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(events, ["whoami", "project", "tokenize"]); // projection MUST precede tokenize
});
test("completeLogin returns null and touches nothing when there is no active session", async () => {
let touched = false;
const keto = ketoStub({ listRelations: async () => { touched = true; return { nextPageToken: null, tuples: [] }; } });
const kratosAdmin = adminStub({ updateMetadataPublic: async () => { touched = true; return { id: ID }; } });
assert.equal(await completeLogin({ keto, kratosAdmin, kratosPublic: publicStub() }, undefined), null);
assert.equal(touched, false);
});
test("completeLogin maps a missing email trait to null and throws if the tokenizer yields no JWT", async () => {
const identity: Identity = { id: ID, traits: {} };
const kratosPublic = publicStub({ whoami: async () => ({ active: true, identity }) as Session }); // never returns a tokenized JWT
await assert.rejects(completeLogin({ keto: ketoStub(), kratosAdmin: adminStub(), kratosPublic }, "c"), /tokenizer returned no JWT/);
});
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")] }) });
// TTL lapsed but the Kratos session lives → re-read roles from Keto, re-tokenize, fresh cookie.
const live = await remintSession({ keto, kratosAdmin: adminStub(), kratosPublic }, "plainpages_session=s");
assert.deepEqual(live.user, { email: "admin@plainpages.local", id: ID, roles: ["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.
const dead = await remintSession({ keto, kratosAdmin: adminStub(), kratosPublic: publicStub() }, undefined);
assert.equal(dead.user, null);
assert.match(dead.setCookie, /^plainpages_jwt=;.*Max-Age=0/);
});
test("sessionCookie builds the HttpOnly/Lax JWT cookie; secure opt-in; JWT chars stay readable", () => {
const jwt = "aaa.bbb-_.ccc";
assert.equal(sessionCookie(jwt), `${SESSION_COOKIE}=${jwt}; Max-Age=2592000; Path=/; HttpOnly; SameSite=Lax`);
assert.match(sessionCookie(jwt, { secure: true }), /; SameSite=Lax; Secure$/);
});
+105
View File
@@ -0,0 +1,105 @@
// 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
// 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
// Order matters: the projection is written before tokenizing, because the claims mapper
// reads only the identity, never Keto.
import type { User } from "../http/context.ts";
import { serializeCookie, type CookieOptions } from "../http/cookie.ts";
import { currentLog } from "../logger.ts";
import type { KetoClient } from "./keto-client.ts";
import type { KratosAdmin } from "./kratos-admin.ts";
import type { KratosPublic } from "./kratos-public.ts";
// Our session cookie — the signed JWT the hot path verifies in-process. Distinct from
// Kratos' own `plainpages_session` cookie (the long-lived login the JWT is re-minted off).
export const SESSION_COOKIE = "plainpages_jwt";
// Mirrors kratos.yml session.lifespan (30d) so the cookie survives browser restarts; the
// JWT inside is short-lived (~10m) and re-minted on expiry by the hot path (remintSession).
const COOKIE_MAX_AGE = 60 * 60 * 24 * 30;
// The tokenizer template (kratos.yml session.whoami.tokenizer.templates.plainpages).
const TOKENIZE_AS = "plainpages";
export interface LoginDeps {
keto: KetoClient;
kratosAdmin: KratosAdmin;
kratosPublic: KratosPublic;
}
export interface CompletedLogin {
email: string | null;
identityId: string;
jwt: string;
roles: string[];
}
// The coarse roles a user holds — directly (`Role:<name>#members@user:<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 —
// 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[]> {
const subject_id = `user:${identityId}`;
const names = new Set<string>();
let pageToken: string | undefined;
do {
const page = await keto.listRelations({ namespace: "Role", relation: "members", ...(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();
}
export async function completeLogin(deps: LoginDeps, cookie: string | undefined): Promise<CompletedLogin | null> {
const session = await deps.kratosPublic.whoami(cookie ? { cookie } : {});
if (!session?.identity) return null;
const identityId = session.identity.id;
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 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 };
}
export interface Reminted {
setCookie: string; // a fresh JWT cookie on success, else a cookie that clears the stale one
user: User | null;
}
// 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,
// 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), user: null };
return { setCookie: sessionCookie(completed.jwt, options), user: { email: completed.email ?? "", id: completed.identityId, roles: completed.roles } };
}
// Build the Set-Cookie for our session JWT. HttpOnly + SameSite=Lax by default; `secure` is
// supplied by the caller (off in dev http; the cookie hardening toggles it on for prod).
export function sessionCookie(jwt: string, options: { secure?: boolean } = {}): string {
const opts: CookieOptions = { httpOnly: true, maxAge: COOKIE_MAX_AGE, path: "/", sameSite: "Lax", ...(options.secure ? { secure: true } : {}) };
return serializeCookie(SESSION_COOKIE, jwt, opts);
}
// Expire our session cookie (Max-Age=0), with the same attributes sessionCookie sets so the
// browser deletes the right one.
export function clearSessionCookie(options: { secure?: boolean } = {}): string {
const opts: CookieOptions = { httpOnly: true, maxAge: 0, path: "/", sameSite: "Lax", ...(options.secure ? { secure: true } : {}) };
return serializeCookie(SESSION_COOKIE, "", opts);
}
+91
View File
@@ -0,0 +1,91 @@
// OAuth2 consent-challenge resolution: given a Hydra consent challenge, auto-accept a
// first-party (or Hydra-skipped) client granting the requested scopes, else show a consent
// screen; on submit accept (allow) or reject (deny). id_token claims come from the Kratos identity.
import { test } from "node:test";
import assert from "node:assert/strict";
import type { AcceptConsent, ConsentRequest, HydraAdmin } from "./hydra-admin.ts";
import type { KratosPublic, Session } from "./kratos-public.ts";
import { acceptConsent, rejectConsent, resolveConsentChallenge } from "./oauth-consent.ts";
const CHALLENGE = "cons-1";
const SUBJECT = "01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b55";
const REDIRECT = "http://hydra/oauth2/auth?consent_verifier=v";
const DENIED = "http://client/cb?error=access_denied";
function stubHydra(consent: ConsentRequest, capture?: (b: AcceptConsent) => void): HydraAdmin {
const unused = async () => { throw new Error("unused"); };
return {
acceptConsentRequest: async (_c, body) => { capture?.(body); return { redirect: REDIRECT }; },
acceptLoginRequest: unused,
acceptLogoutRequest: unused,
createClient: unused,
deleteClient: unused,
getClient: unused,
getConsentRequest: async () => consent,
getLoginRequest: unused,
listClients: unused,
rejectConsentRequest: async () => ({ redirect: DENIED }),
rejectLoginRequest: unused,
};
}
const stubKratos = (whoami: KratosPublic["whoami"]): KratosPublic => ({
createLogoutFlow: async () => null,
getFlow: async () => { throw new Error("unused"); },
initBrowserFlow: async () => { throw new Error("unused"); },
submitFlow: async () => { throw new Error("unused"); },
whoami,
});
const sessionWith = (traits?: Record<string, unknown>): Session => ({ active: true, identity: { id: SUBJECT, ...(traits ? { traits } : {}) } });
const consent = (over: Partial<ConsentRequest> = {}): ConsentRequest =>
({ challenge: CHALLENGE, client: { client_name: "Acme Reports" }, requested_scope: ["openid", "profile"], skip: false, subject: SUBJECT, ...over });
test("a Hydra-skipped client auto-accepts, granting the requested scopes + audience + id_token from the identity", async () => {
let granted: AcceptConsent | undefined;
const hydra = stubHydra(consent({ requested_access_token_audience: ["https://api"], requested_scope: ["openid", "email"], skip: true }), (b) => { granted = b; });
const kratos = stubKratos(async () => sessionWith({ email: "ada@x.io", name: { first: "Ada", last: "Lovelace" } }));
const out = await resolveConsentChallenge({ hydra, kratos }, CHALLENGE, "plainpages_session=s");
assert.equal(out.redirect, REDIRECT);
assert.equal(out.view, undefined);
assert.deepEqual(granted?.grant_scope, ["openid", "email"]);
assert.deepEqual(granted?.grant_access_token_audience, ["https://api"]);
assert.deepEqual(granted?.session, { id_token: { email: "ada@x.io", name: "Ada Lovelace" } });
});
test("a first-party client (metadata.first_party) auto-accepts even without skip; no identity ⇒ no id_token", async () => {
let granted: AcceptConsent | undefined;
const hydra = stubHydra(consent({ client: { client_name: "Internal", metadata: { first_party: true } }, requested_scope: ["openid"] }), (b) => { granted = b; });
const out = await resolveConsentChallenge({ hydra, kratos: stubKratos(async () => null) }, CHALLENGE, undefined);
assert.equal(out.redirect, REDIRECT);
assert.deepEqual(granted?.grant_scope, ["openid"]);
assert.equal(granted?.session, undefined);
});
test("a third-party client shows the consent screen (no auto-accept); the account is named when signed in, omitted otherwise", async () => {
let accepted = false;
const hydra = stubHydra(consent(), () => { accepted = true; });
const signedIn = await resolveConsentChallenge({ hydra, kratos: stubKratos(async () => sessionWith({ email: "ada@x.io" })) }, CHALLENGE, "plainpages_session=s");
assert.equal(signedIn.redirect, undefined);
assert.deepEqual(signedIn.view, { account: "ada@x.io", challenge: CHALLENGE, client: "Acme Reports", scopes: ["openid", "profile"] });
assert.equal(accepted, false);
// No session ⇒ the screen still renders but names no account.
const anon = await resolveConsentChallenge({ hydra: stubHydra(consent()), kratos: stubKratos(async () => null) }, CHALLENGE, undefined);
assert.equal(anon.view?.account, undefined);
});
test("acceptConsent re-reads the challenge's scopes (never client-supplied) and projects id_token only when the session subject matches", async () => {
let matched: AcceptConsent | undefined;
const redirect = await acceptConsent({ hydra: stubHydra(consent(), (b) => { matched = b; }), kratos: stubKratos(async () => sessionWith({ email: "ada@x.io" })) }, CHALLENGE, "plainpages_session=s");
assert.equal(redirect, REDIRECT);
assert.deepEqual(matched?.grant_scope, ["openid", "profile"]); // re-read from the challenge, not the form
assert.deepEqual(matched?.session, { id_token: { email: "ada@x.io" } });
// A session whose identity differs from the challenge subject must not leak its claims into the grant.
let mismatched: AcceptConsent | undefined;
const other: Session = { active: true, identity: { id: "01902d5e-0000-7e3a-9f21-3c8d1e0a4b55", traits: { email: "mallory@x.io" } } };
await acceptConsent({ hydra: stubHydra(consent(), (b) => { mismatched = b; }), kratos: stubKratos(async () => other) }, CHALLENGE, "plainpages_session=s");
assert.equal(mismatched?.session, undefined);
});
test("rejectConsent rejects with access_denied → the client's error redirect", async () => {
const redirect = await rejectConsent({ hydra: stubHydra(consent()), kratos: stubKratos(async () => null) }, CHALLENGE);
assert.equal(redirect, DENIED);
});
+85
View File
@@ -0,0 +1,85 @@
// OAuth2 consent-challenge handler: after login, Hydra hands the browser to
// /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).
import type { AcceptConsent, ConsentRequest, HydraAdmin, OAuth2Client } from "./hydra-admin.ts";
import type { KratosPublic } from "./kratos-public.ts";
// Remember the grant for the browser-session lifetime (0): a client re-authorizing while the
// Kratos session lives doesn't re-prompt on every token refresh (mirrors oauth-login).
const REMEMBER_FOR = 0;
export interface OAuthConsentDeps {
hydra: HydraAdmin;
kratos: KratosPublic;
}
// What to show on the consent screen for a third-party client.
export interface ConsentView {
account?: string; // the signed-in user's email — shown so consent is informed (whose account)
challenge: string;
client: string; // display name
scopes: string[];
}
// A consent challenge resolves to either an immediate redirect (auto-accepted) or a render
// decision (show the consent screen).
export interface ConsentResolution {
redirect?: string;
view?: ConsentView;
}
const isFirstParty = (client?: OAuth2Client): boolean => client?.metadata?.first_party === true;
const clientName = (client?: OAuth2Client): string => client?.client_name || client?.client_id || "the application";
// id_token claims from Kratos traits (email + a joined name); undefined ⇒ omit the session.
function idTokenClaims(traits?: Record<string, unknown>): Record<string, unknown> | undefined {
if (!traits) return undefined;
const claims: Record<string, unknown> = {};
if (typeof traits.email === "string") claims.email = traits.email;
const name = traits.name as { first?: string; last?: string } | undefined;
const full = [name?.first, name?.last].filter(Boolean).join(" ");
if (full) claims.name = full;
return Object.keys(claims).length ? claims : undefined;
}
// Accept a consent request, granting exactly the scopes/audience Hydra asked for (re-read from
// the challenge, never client-submitted) plus id_token claims from the current Kratos session.
async function accept(deps: OAuthConsentDeps, consent: ConsentRequest, cookie: string | undefined): Promise<string> {
const session = await deps.kratos.whoami(cookie ? { cookie } : {});
// Only project id_token claims when the session's identity matches the subject Hydra bound at
// login — never leak a mismatched session's email/name into the issued token (defensive).
const idToken = session?.identity?.id === consent.subject ? idTokenClaims(session?.identity?.traits) : undefined;
const body: AcceptConsent = {
grant_access_token_audience: consent.requested_access_token_audience ?? [],
grant_scope: consent.requested_scope ?? [],
remember: true,
remember_for: REMEMBER_FOR,
...(idToken ? { session: { id_token: idToken } } : {}),
};
return (await deps.hydra.acceptConsentRequest(consent.challenge, body)).redirect;
}
// Resolve a consent challenge: skip / first-party ⇒ auto-accept; else show the consent screen.
export async function resolveConsentChallenge(deps: OAuthConsentDeps, challenge: string, cookie: string | undefined): Promise<ConsentResolution> {
const consent = await deps.hydra.getConsentRequest(challenge);
if (consent.skip || isFirstParty(consent.client)) {
return { redirect: await accept(deps, consent, cookie) };
}
// Third party: name the signed-in account on the screen so the user sees whose access they grant.
const session = await deps.kratos.whoami(cookie ? { cookie } : {});
const email = session?.identity?.traits?.email;
const account = typeof email === "string" ? email : undefined;
return { view: { challenge, client: clientName(consent.client), scopes: consent.requested_scope ?? [], ...(account ? { account } : {}) } };
}
// The user allowed: re-fetch the challenge (don't trust the form for scopes) and accept.
export async function acceptConsent(deps: OAuthConsentDeps, challenge: string, cookie: string | undefined): Promise<string> {
return accept(deps, await deps.hydra.getConsentRequest(challenge), cookie);
}
// The user denied: reject so Hydra redirects back to the client with access_denied.
export async function rejectConsent(deps: OAuthConsentDeps, challenge: string): Promise<string> {
return (await deps.hydra.rejectConsentRequest(challenge, { error: "access_denied", error_description: "The user denied the request." })).redirect;
}
+62
View File
@@ -0,0 +1,62 @@
// OAuth2 login-challenge resolution: given a Hydra login challenge, authenticate the user
// via their Kratos session and accept — or bounce an unauthenticated user to the Kratos login UI.
import { test } from "node:test";
import assert from "node:assert/strict";
import type { AcceptLogin, HydraAdmin, LoginRequest } from "./hydra-admin.ts";
import type { KratosPublic, Session } from "./kratos-public.ts";
import { resolveLoginChallenge } from "./oauth-login.ts";
const CHALLENGE = "chal-1";
const SUBJECT = "01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b55";
const SELF = "http://127.0.0.1:3000/oauth2/login?login_challenge=chal-1";
function stubHydra(login: LoginRequest, capture?: (b: AcceptLogin) => void): HydraAdmin {
const unused = async () => { throw new Error("unused"); };
return {
acceptConsentRequest: unused,
acceptLoginRequest: async (_c, body) => { capture?.(body); return { redirect: "http://hydra/oauth2/auth?login_verifier=v" }; },
acceptLogoutRequest: unused,
createClient: unused,
deleteClient: unused,
getClient: unused,
getConsentRequest: unused,
getLoginRequest: async () => login,
listClients: unused,
rejectConsentRequest: unused,
rejectLoginRequest: unused,
};
}
const stubKratos = (whoami: KratosPublic["whoami"]): KratosPublic => ({
createLogoutFlow: async () => null,
getFlow: async () => { throw new Error("unused"); },
initBrowserFlow: async () => { throw new Error("unused"); },
submitFlow: async () => { throw new Error("unused"); },
whoami,
});
const session = (id: string): Session => ({ active: true, identity: { id } });
test("a live Kratos session accepts the login with that subject → Hydra redirect", async () => {
let accepted: AcceptLogin | undefined;
const hydra = stubHydra({ challenge: CHALLENGE, skip: false, subject: "" }, (b) => { accepted = b; });
const out = await resolveLoginChallenge({ hydra, kratos: stubKratos(async () => session(SUBJECT)) }, CHALLENGE, "plainpages_session=s", SELF);
assert.equal(out.redirect, "http://hydra/oauth2/auth?login_verifier=v");
assert.equal(accepted?.subject, SUBJECT);
assert.equal(accepted?.remember, true);
});
test("skip (Hydra already authenticated) accepts the request's subject without checking Kratos", async () => {
let accepted: AcceptLogin | undefined;
let whoamiCalled = false;
const hydra = stubHydra({ challenge: CHALLENGE, skip: true, subject: SUBJECT }, (b) => { accepted = b; });
const kratos = stubKratos(async () => { whoamiCalled = true; return null; });
const out = await resolveLoginChallenge({ hydra, kratos }, CHALLENGE, undefined, SELF);
assert.equal(out.redirect, "http://hydra/oauth2/auth?login_verifier=v");
assert.equal(accepted?.subject, SUBJECT);
assert.equal(whoamiCalled, false, "skip short-circuits the Kratos check");
});
test("no Kratos session bounces to the themed login UI, returning here once authenticated", async () => {
const hydra = stubHydra({ challenge: CHALLENGE, skip: false, subject: "" });
const out = await resolveLoginChallenge({ hydra, kratos: stubKratos(async () => null) }, CHALLENGE, undefined, SELF);
assert.equal(out.redirect, `/login?return_to=${encodeURIComponent(SELF)}`);
});
+42
View File
@@ -0,0 +1,42 @@
// OAuth2 login-challenge handler: when another app logs in *through* plainpages,
// 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).
import type { HydraAdmin } from "./hydra-admin.ts";
import type { KratosPublic } from "./kratos-public.ts";
// Remember the Hydra login for the browser-session lifetime (0), so a client re-authorizing
// doesn't re-run this on every token refresh while the Kratos session lives.
const REMEMBER_FOR = 0;
export interface OAuthLoginDeps {
hydra: HydraAdmin;
kratos: KratosPublic;
}
export interface LoginResolution {
redirect: string;
}
// Resolve a login challenge:
// - skip (Hydra already authenticated the subject) → accept it, don't re-prompt.
// - a live Kratos session → accept with that identity as the subject.
// - no session → send the browser to our themed Kratos
// login, returning to `selfUrl` (this challenge) once authenticated, where whoami succeeds.
export async function resolveLoginChallenge(
deps: OAuthLoginDeps,
challenge: string,
cookie: string | undefined,
selfUrl: string,
): Promise<LoginResolution> {
const login = await deps.hydra.getLoginRequest(challenge);
if (login.skip) {
return deps.hydra.acceptLoginRequest(challenge, { subject: login.subject });
}
const session = await deps.kratos.whoami(cookie ? { cookie } : {});
if (session?.identity) {
return deps.hydra.acceptLoginRequest(challenge, { remember: true, remember_for: REMEMBER_FOR, subject: session.identity.id });
}
return { redirect: `/login?return_to=${encodeURIComponent(selfUrl)}` };
}