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:
@@ -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
|
||||
});
|
||||
@@ -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();
|
||||
@@ -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
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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) });
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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],
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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}` });
|
||||
}
|
||||
@@ -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$/);
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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" });
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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$/);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)}`);
|
||||
});
|
||||
@@ -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)}` };
|
||||
}
|
||||
Reference in New Issue
Block a user