Login completion (todo §4); /auth/complete: roles from Keto → metadata_public projection → tokenize → plainpages_jwt cookie; fix tokenizer projection metadata_admin→metadata_public (whoami strips admin metadata)

This commit is contained in:
2026-06-17 23:15:28 +02:00
parent 26a7821611
commit 38157605d0
13 changed files with 288 additions and 28 deletions

View File

@@ -407,10 +407,12 @@ session cookie.
(e.g. `role:admin#members@user:alice`); the admin screens write them *only* to Keto. (e.g. `role:admin#members@user:alice`); the admin screens write them *only* to Keto.
But the tokenizer's claims mapper can read only the **identity**, not call Keto — so at But the tokenizer's claims mapper can read only the **identity**, not call Keto — so at
login the app reads the roles from Keto and refreshes a **derived projection**: a login the app reads the roles from Keto and refreshes a **derived projection**: a
read-only copy written onto the identity's `metadata_admin` for the tokenizer to see, read-only copy written onto the identity's `metadata_public` for the tokenizer to see,
which the template maps into the JWT `roles` claim. That projection is a per-login which the template maps into the JWT `roles` claim. (It must be `metadata_public`, not
cache, authoritative nowhere; nothing edits it by hand, and a stale one self-heals on `metadata_admin`: the session Kratos hands the tokenizer carries only *public* metadata —
the next login. and the user can already read these coarse roles in their own JWT, so nothing is leaked.)
That projection is a per-login cache, authoritative nowhere; nothing edits it by hand, and
a stale one self-heals on the next login.
Cost: **one Keto read + one identity refresh per login** — never per request. JWKS Cost: **one Keto read + one identity refresh per login** — never per request. JWKS
is cached, so even signature verification hits the network only on key rotation. The is cached, so even signature verification hits the network only on key rotation. The
@@ -499,9 +501,10 @@ src/app.ts Request routing + EJS rendering (incl. the themed Kratos se
src/static.ts Static file serving (path-traversal protection) + routePublic(): /public/<id>/ → a plugin's public/ src/static.ts Static file serving (path-traversal protection) + routePublic(): /public/<id>/ → a plugin's public/
src/jwt.ts JWS signature verify via node:crypto, no jose; claims+JWKS are §4 src/jwt.ts JWS signature verify via node:crypto, no jose; claims+JWKS are §4
src/kratos-public.ts createKratosPublic(): Kratos public-API fetch client — self-service flow init/get/submit, whoami, session→JWT tokenize (§4) src/kratos-public.ts createKratosPublic(): Kratos public-API fetch client — self-service flow init/get/submit, whoami, session→JWT tokenize (§4)
src/kratos-admin.ts createKratosAdmin(): Kratos admin-API fetch client — identity CRUD + surgical metadata_admin update (login role projection, §4) src/kratos-admin.ts createKratosAdmin(): Kratos admin-API fetch client — identity CRUD + surgical metadata_public update (login role projection, §4)
src/keto-client.ts createKetoClient(): Keto fetch client — check / list / expand relations (read API) + write / delete tuples (write API) (§4) src/keto-client.ts createKetoClient(): Keto fetch client — check / list / expand relations (read API) + write / delete tuples (write API) (§4)
src/flow-view.ts buildFlowView(): Kratos self-service Flow → themed view model (fields, hidden csrf, buttons, tone-mapped messages) for views/auth.ejs (§4) src/flow-view.ts buildFlowView(): Kratos self-service Flow → themed view model (fields, hidden csrf, buttons, tone-mapped messages) for views/auth.ejs (§4)
src/login.ts completeLogin(): /auth/complete login completion — roles from Keto → metadata_public projection → tokenize → session JWT cookie (§4)
src/gen-jwks.ts generateJwks() + CLI: mint the ES256 session-tokenizer signing JWKS (§3); see JWT signing key & rotation src/gen-jwks.ts generateJwks() + CLI: mint the ES256 session-tokenizer signing JWKS (§3); see JWT signing key & rotation
src/bootstrap.ts One-command bootstrap (§3): idempotent first-boot seed — JWKS-if-absent, demo admin in Kratos, admin role in Keto src/bootstrap.ts One-command bootstrap (§3): idempotent first-boot seed — JWKS-if-absent, demo admin in Kratos, admin role in Keto
src/cookie.ts Cookie parse + secure Set-Cookie build (session/CSRF cookies, §4) src/cookie.ts Cookie parse + secure Set-Cookie build (session/CSRF cookies, §4)

View File

@@ -36,6 +36,10 @@ selfservice:
ui_url: http://127.0.0.1:3000/error ui_url: http://127.0.0.1:3000/error
login: login:
ui_url: http://127.0.0.1:3000/login ui_url: http://127.0.0.1:3000/login
after:
# After authenticating, land on our completion route — it mints the session JWT
# (roles from Keto → metadata_public projection → tokenize) and sets our cookie (§4).
default_browser_return_url: http://127.0.0.1:3000/auth/complete
registration: registration:
ui_url: http://127.0.0.1:3000/registration ui_url: http://127.0.0.1:3000/registration
after: after:
@@ -88,7 +92,7 @@ session:
# Session→JWT tokenizer (§4): whoami(tokenize_as: plainpages) mints a short-lived, # Session→JWT tokenizer (§4): whoami(tokenize_as: plainpages) mints a short-lived,
# locally-verifiable JWT so the hot path never calls Ory. Claims come from the # locally-verifiable JWT so the hot path never calls Ory. Claims come from the
# committed Jsonnet mapper (sub = identity id, email from traits, roles from the # committed Jsonnet mapper (sub = identity id, email from traits, roles from the
# metadata_admin projection); signed with tokenizer/jwks.json. # metadata_public projection); signed with tokenizer/jwks.json.
whoami: whoami:
tokenizer: tokenizer:
templates: templates:

View File

@@ -1,11 +1,12 @@
// Session→JWT claims mapper for the `plainpages` tokenizer (§4). Kratos exposes the // Session→JWT claims mapper for the `plainpages` tokenizer (§4). Kratos exposes the
// session as `session`; `sub` is set from the identity id (subject_source: id) and // session as `session`; `sub` is set from the identity id (subject_source: id) and
// can't be overridden here. roles come from metadata_admin — the per-login projection // can't be overridden here. roles come from metadata_public — the per-login projection
// of Keto roles the app refreshes at login; absent on a fresh identity ⇒ empty list. // of Keto roles the app refreshes at login (metadata_admin is NOT carried in the session
// the tokenizer sees; metadata_public is). Absent on a fresh identity ⇒ empty list.
local session = std.extVar('session'); local session = std.extVar('session');
local meta = local meta =
if std.objectHas(session.identity, 'metadata_admin') && session.identity.metadata_admin != null if std.objectHas(session.identity, 'metadata_public') && session.identity.metadata_public != null
then session.identity.metadata_admin then session.identity.metadata_public
else {}; else {};
{ {

View File

@@ -6,7 +6,9 @@ import { dirname, join } from "node:path";
import { after, before, test, type TestContext } from "node:test"; import { after, before, test, type TestContext } from "node:test";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { createApp } from "./app.ts"; import { createApp } from "./app.ts";
import { KratosError, type Flow, type FlowType, type KratosPublic, type UiNode } from "./kratos-public.ts"; import type { KetoClient } from "./keto-client.ts";
import type { Identity, KratosAdmin } from "./kratos-admin.ts";
import { KratosError, type Flow, type FlowType, type KratosPublic, type Session, type UiNode } from "./kratos-public.ts";
import type { Plugin } from "./plugin.ts"; import type { Plugin } from "./plugin.ts";
import { contentTypeFor, resolveStaticPath, routePublic } from "./static.ts"; import { contentTypeFor, resolveStaticPath, routePublic } from "./static.ts";
@@ -265,6 +267,55 @@ test("renders a fetched flow as the themed auth page: fields post straight to Kr
assert.match(html, /The provided credentials are invalid\./); assert.match(html, /The provided credentials are invalid\./);
}); });
// Login completion (§4): /auth/complete is where Kratos lands the browser after login.
const stubAdmin = (over: Partial<KratosAdmin>): KratosAdmin => ({
createIdentity: async () => { throw new Error("unused"); },
deleteIdentity: async () => {},
getIdentity: async () => null,
listIdentities: async () => ({ identities: [], nextPageToken: null }),
updateIdentity: async () => { throw new Error("unused"); },
updateMetadataPublic: async () => ({ id: "x" }),
...over,
});
const stubKeto = (over: Partial<KetoClient>): KetoClient => ({
check: async () => false,
deleteTuple: async () => {},
expand: async () => ({ type: "leaf" }),
listRelations: async () => ({ nextPageToken: null, tuples: [] }),
writeTuple: async () => {},
...over,
});
const withWhoami = (whoami: KratosPublic["whoami"]): KratosPublic => ({ ...mockKratos(async () => { throw new Error("unused"); }), whoami });
test("login completion: mints the session JWT (roles from Keto → projection → tokenize) and sets the cookie", async (t) => {
const identity: Identity = { id: "01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b55", traits: { email: "admin@plainpages.local" } };
let projected: unknown;
const kratos = withWhoami(async (o) => (o?.tokenizeAs ? { active: true, identity, tokenized: "h.p.s" } : { active: true, identity }) as Session);
const kratosAdmin = stubAdmin({ updateMetadataPublic: async (_id, meta) => { projected = meta; return identity; } });
const keto = stubKeto({ listRelations: async () => ({ nextPageToken: null, tuples: [{ namespace: "Role", object: "admin", relation: "members", subject_id: `user:${identity.id}` }] }) });
const app = createApp({ keto, kratos, kratosAdmin });
await new Promise<void>((r) => app.listen(0, r));
t.after(() => app.close());
const res = await fetch(`http://localhost:${(app.address() as AddressInfo).port}/auth/complete`, { headers: { cookie: "plainpages_session=s" }, redirect: "manual" });
assert.equal(res.status, 303);
assert.equal(res.headers.get("location"), "/");
assert.match(res.headers.get("set-cookie") ?? "", /^plainpages_jwt=h\.p\.s;.*HttpOnly/);
assert.deepEqual(projected, { roles: ["admin"] }); // Keto roles projected onto the identity for the tokenizer
});
test("login completion with no Kratos session redirects to /login and sets no cookie", async (t) => {
const app = createApp({ keto: stubKeto({}), kratos: withWhoami(async () => null), kratosAdmin: stubAdmin({}) });
await new Promise<void>((r) => app.listen(0, r));
t.after(() => app.close());
const res = await fetch(`http://localhost:${(app.address() as AddressInfo).port}/auth/complete`, { redirect: "manual" });
assert.equal(res.status, 303);
assert.equal(res.headers.get("location"), "/login");
assert.equal(res.headers.get("set-cookie"), null);
});
test("resolveStaticPath blocks traversal and control chars, allows nested files", () => { test("resolveStaticPath blocks traversal and control chars, allows nested files", () => {
assert.equal(resolveStaticPath("/srv/public", "../app.ts"), null); assert.equal(resolveStaticPath("/srv/public", "../app.ts"), null);
assert.equal(resolveStaticPath("/srv/public", "a\x00b"), null); assert.equal(resolveStaticPath("/srv/public", "a\x00b"), null);

View File

@@ -7,7 +7,10 @@ import { buildDashboardModel } from "./dashboard.ts";
import { PLUGINS_DIR } from "./discovery.ts"; import { PLUGINS_DIR } from "./discovery.ts";
import { AUTH_FLOWS, buildFlowView } from "./flow-view.ts"; import { AUTH_FLOWS, buildFlowView } from "./flow-view.ts";
import { runRequestHooks, runResponseHooks } from "./hooks.ts"; import { runRequestHooks, runResponseHooks } from "./hooks.ts";
import type { KetoClient } from "./keto-client.ts";
import type { KratosAdmin } from "./kratos-admin.ts";
import { KratosError, type KratosPublic } from "./kratos-public.ts"; import { KratosError, type KratosPublic } from "./kratos-public.ts";
import { completeLogin, sessionCookie } from "./login.ts";
import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts"; import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts";
import type { Plugin, RouteResult } from "./plugin.ts"; import type { Plugin, RouteResult } from "./plugin.ts";
import { allowedMethods, isAuthorized, matchRoute } from "./router.ts"; import { allowedMethods, isAuthorized, matchRoute } from "./router.ts";
@@ -20,7 +23,9 @@ export interface AppOptions {
// Cache compiled templates; caller decides (server passes config.cacheTemplates). // Cache compiled templates; caller decides (server passes config.cacheTemplates).
// Off by default so edits show live; the app itself never inspects the environment. // Off by default so edits show live; the app itself never inspects the environment.
cache?: boolean; cache?: boolean;
keto?: KetoClient; // Keto client; with kratos+kratosAdmin enables login completion (§4)
kratos?: KratosPublic; // Kratos public client; enables the themed self-service routes (§4) kratos?: KratosPublic; // Kratos public client; enables the themed self-service routes (§4)
kratosAdmin?: KratosAdmin; // Kratos admin client; with kratos+keto enables login completion (§4)
menu?: MenuConfig; // central override + branding (config/menu.ts); defaults to DEFAULT_MENU menu?: MenuConfig; // central override + branding (config/menu.ts); defaults to DEFAULT_MENU
plugins?: Plugin[]; // discovered manifests to mount (router); empty until §2 discovery runs plugins?: Plugin[]; // discovered manifests to mount (router); empty until §2 discovery runs
pluginsDir?: string; // where plugin views/static live; defaults to the scanned plugins/ pluginsDir?: string; // where plugin views/static live; defaults to the scanned plugins/
@@ -30,7 +35,9 @@ export interface AppOptions {
export function createApp(options: AppOptions = {}): Server { export function createApp(options: AppOptions = {}): Server {
const cache = options.cache ?? false; const cache = options.cache ?? false;
const keto = options.keto;
const kratos = options.kratos; const kratos = options.kratos;
const kratosAdmin = options.kratosAdmin;
const menu = options.menu ?? DEFAULT_MENU; const menu = options.menu ?? DEFAULT_MENU;
const plugins = options.plugins ?? []; const plugins = options.plugins ?? [];
const pluginIds = new Set(plugins.map((p) => p.id)); const pluginIds = new Set(plugins.map((p) => p.id));
@@ -112,6 +119,20 @@ export function createApp(options: AppOptions = {}): Server {
return; return;
} }
// Login completion: where Kratos lands the browser after authenticating (kratos.yml).
// Mint our session JWT — read roles from Keto, project onto the identity, tokenize —
// and store it as the cookie; no active session bounces back to sign in (§4).
if (pathname === "/auth/complete" && method === "GET" && kratos && kratosAdmin && keto) {
const completed = await completeLogin({ keto, kratosAdmin, kratosPublic: kratos }, req.headers.cookie);
if (!completed) {
res.writeHead(303, { location: "/login" }).end();
return;
}
// secure: off in dev http; the §9 cookie hardening toggles it on for prod.
res.writeHead(303, { location: "/", "set-cookie": sessionCookie(completed.jwt) }).end();
return;
}
if (pathname === "/" && (method === "GET" || method === "HEAD")) { if (pathname === "/" && (method === "GET" || method === "HEAD")) {
// Mock data + no roles until auth (§4) lands; branding/override come from config/menu.ts. // Mock data + no roles until auth (§4) lands; branding/override come from config/menu.ts.
sendHtml(res, 200, await render("index", { model: buildDashboardModel(ctx.url, [], menu) })); sendHtml(res, 200, await render("index", { model: buildDashboardModel(ctx.url, [], menu) }));

View File

@@ -1,5 +1,5 @@
// Kratos admin-API client (§4): typed fetch wrappers over Ory Kratos' admin endpoints — // Kratos admin-API client (§4): typed fetch wrappers over Ory Kratos' admin endpoints —
// identity CRUD + the surgical metadata_admin update the login flow projects roles into. // 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 // 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 (§4). // result mapping (201/200/404/4xx). Live wiring is verified by login completion (§4).
import { test } from "node:test"; import { test } from "node:test";
@@ -89,14 +89,14 @@ test("updateIdentity PUTs the full body to /admin/identities/<id> and returns th
assert.equal(calls[0]!.body, JSON.stringify(body)); assert.equal(calls[0]!.body, JSON.stringify(body));
}); });
test("updateMetadataAdmin PATCHes a JSON-Patch `add /metadata_admin` so it never clobbers traits", async () => { test("updateMetadataPublic PATCHes a JSON-Patch `add /metadata_public` so it never clobbers traits", async () => {
const identity = { id: ID, metadata_admin: { roles: ["admin"] } }; const identity = { id: ID, metadata_public: { roles: ["admin"] } };
const { calls, fetchImpl } = recorder(() => res(200, identity)); const { calls, fetchImpl } = recorder(() => res(200, identity));
const out = await createKratosAdmin({ baseUrl: BASE, fetchImpl }).updateMetadataAdmin(ID, { roles: ["admin"] }); const out = await createKratosAdmin({ baseUrl: BASE, fetchImpl }).updateMetadataPublic(ID, { roles: ["admin"] });
assert.deepEqual(out, identity); assert.deepEqual(out, identity);
assert.equal(calls[0]!.method, "PATCH"); assert.equal(calls[0]!.method, "PATCH");
assert.match(calls[0]!.url, new RegExp(`/admin/identities/${ID}$`)); assert.match(calls[0]!.url, new RegExp(`/admin/identities/${ID}$`));
assert.deepEqual(JSON.parse(calls[0]!.body!), [{ op: "add", path: "/metadata_admin", value: { roles: ["admin"] } }]); assert.deepEqual(JSON.parse(calls[0]!.body!), [{ op: "add", path: "/metadata_public", value: { roles: ["admin"] } }]);
}); });
test("deleteIdentity DELETEs by id (204 resolves; non-204 throws a KratosError)", async () => { test("deleteIdentity DELETEs by id (204 resolves; non-204 throws a KratosError)", async () => {

View File

@@ -1,5 +1,5 @@
// Kratos admin-API client (todo §4): typed `fetch` wrappers over Ory Kratos' admin // Kratos admin-API client (todo §4): typed `fetch` wrappers over Ory Kratos' admin
// endpoints — identity CRUD and the surgical `metadata_admin` update login completion // endpoints — identity CRUD and the surgical `metadata_public` update login completion
// projects Keto roles into (README). Built-in `fetch` only, no SDK dep (AGENTS.md); // projects Keto roles into (README). Built-in `fetch` only, no SDK dep (AGENTS.md);
// `fetchImpl`-injectable like kratos-public.ts. Reuses that module's `KratosError` so a // `fetchImpl`-injectable like kratos-public.ts. Reuses that module's `KratosError` so a
// caller can branch on `.status`. Admin endpoints listen on the internal-only admin port. // caller can branch on `.status`. Admin endpoints listen on the internal-only admin port.
@@ -32,7 +32,7 @@ export interface KratosAdmin {
getIdentity(id: string): Promise<Identity | null>; getIdentity(id: string): Promise<Identity | null>;
listIdentities(opts?: ListOptions): Promise<IdentityList>; listIdentities(opts?: ListOptions): Promise<IdentityList>;
updateIdentity(id: string, payload: unknown): Promise<Identity>; updateIdentity(id: string, payload: unknown): Promise<Identity>;
updateMetadataAdmin(id: string, metadata: 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 // Kratos paginates with a Link header; pull the page_token of rel="next" (the href is a
@@ -88,12 +88,13 @@ export function createKratosAdmin(config: { baseUrl: string; fetchImpl?: typeof
return (await res.json()) as Identity; return (await res.json()) as Identity;
}, },
// JSON Patch `add` sets metadata_admin whether it's currently absent, null, or set, and // JSON Patch `add` sets metadata_public whether it's currently absent, null, or set, and
// touches nothing else — so the login role projection never clobbers traits/state. // touches nothing else — so the login role projection never clobbers traits/state.
async updateMetadataAdmin(id, metadata) { // (metadata_public, not _admin: the session the tokenizer sees carries only public metadata.)
const patch = [{ op: "add", path: "/metadata_admin", value: 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" }); const res = await http(identity(id), { body: JSON.stringify(patch), headers: json, method: "PATCH" });
if (res.status !== 200) return fail("update metadata_admin", res); if (res.status !== 200) return fail("update metadata_public", res);
return (await res.json()) as Identity; return (await res.json()) as Identity;
}, },
}; };

View File

@@ -37,7 +37,7 @@ export interface Flow {
export interface Session { export interface Session {
active?: boolean; active?: boolean;
expires_at?: string; expires_at?: string;
identity?: { id: string; metadata_admin?: unknown; traits?: Record<string, unknown> }; 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 tokenized?: string; // the signed JWT — present only when `tokenize_as` was requested
} }

View File

@@ -46,6 +46,11 @@ test("self-service flows return to our themed pages", () => {
`${flow} flow points at our /${flow} page`); `${flow} flow points at our /${flow} page`);
}); });
test("after a successful login Kratos returns to our /auth/complete route to mint the JWT", () => {
assert.match(kratosYml, /default_browser_return_url:\s*http:\/\/127\.0\.0\.1:3000\/auth\/complete/,
"login completion (read roles → project → tokenize → set cookie) runs at /auth/complete (§4)");
});
test("recovery + verification run on email code, delivered by a courier", () => { test("recovery + verification run on email code, delivered by a courier", () => {
assert.ok((kratosYml.match(/use:\s*code/g) ?? []).length >= 2, assert.ok((kratosYml.match(/use:\s*code/g) ?? []).length >= 2,
"recovery + verification both use the email-code method"); "recovery + verification both use the email-code method");
@@ -70,10 +75,12 @@ test("session tokenizer template 'plainpages' mints a short-lived signed JWT", (
"claims via the committed mapper"); "claims via the committed mapper");
}); });
test("the tokenizer claims mapper emits email + roles from the metadata_admin projection", () => { test("the tokenizer claims mapper emits email + roles from the metadata_public projection", () => {
// metadata_public, not _admin: the session Kratos hands the tokenizer carries only public
// metadata (admin metadata is stripped), so the roles projection must live in metadata_public.
const mapper = read("ory/kratos/tokenizer/plainpages.jsonnet"); const mapper = read("ory/kratos/tokenizer/plainpages.jsonnet");
assert.match(mapper, /email:\s*session\.identity\.traits\.email/, "email ← identity trait"); assert.match(mapper, /email:\s*session\.identity\.traits\.email/, "email ← identity trait");
assert.match(mapper, /metadata_admin/, "roles ← metadata_admin (the per-login Keto projection, §4)"); assert.match(mapper, /metadata_public/, "roles ← metadata_public (the per-login Keto projection, §4)");
}); });
test("social sign-in is off by default — a clean clone stays password-only", () => { test("social sign-in is off by default — a clean clone stays password-only", () => {

92
src/login.test.ts Normal file
View File

@@ -0,0 +1,92 @@
// Login completion (§4): 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 §8 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, 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"); },
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 => ({
getFlow: async () => { throw new Error("unused"); },
initBrowserFlow: async () => { throw new Error("unused"); },
submitFlow: async () => { throw new Error("unused"); },
whoami: async () => null,
...over,
});
test("readRoles reads direct Role memberships from Keto — paged, de-duped, sorted", async () => {
const calls: unknown[] = [];
const keto = ketoStub({
listRelations: async (q) => {
calls.push(q);
if (!q?.pageToken) return { nextPageToken: "p2", tuples: [roleTuple("editor"), roleTuple("admin")] };
return { nextPageToken: null, tuples: [roleTuple("admin")] }; // duplicate across pages
},
});
assert.deepEqual(await readRoles(keto, ID), ["admin", "editor"]);
assert.deepEqual(calls[0], { namespace: "Role", relation: "members", subject_id: `user:${ID}` });
assert.equal((calls[1] as { pageToken?: string }).pageToken, "p2"); // second page follows the cursor
});
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({ 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("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$/);
});

75
src/login.ts Normal file
View File

@@ -0,0 +1,75 @@
// Login completion (todo §4): 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 { serializeCookie, type CookieOptions } from "./cookie.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 by the §4 middleware on expiry.
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 Keto grants a subject directly: `Role:<name>#members@user:<id>`. Returns
// the de-duped, sorted role names (the tuple `object`). One logical read, paged defensively.
// Group→role inheritance lands with the Groups screen (§5); MVP grants are direct.
export async function readRoles(keto: KetoClient, identityId: string): Promise<string[]> {
const subject_id = `user:${identityId}`;
const roles = new Set<string>();
let pageToken: string | undefined;
do {
const page = await keto.listRelations({ namespace: "Role", relation: "members", subject_id, ...(pageToken ? { pageToken } : {}) });
for (const t of page.tuples) roles.add(t.object);
pageToken = page.nextPageToken ?? undefined;
} while (pageToken);
return [...roles].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");
return { email, identityId, jwt, 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 §9 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);
}

View File

@@ -2,18 +2,23 @@ import { createApp } from "./app.ts";
import { loadConfig } from "./config.ts"; import { loadConfig } from "./config.ts";
import { discoverPlugins } from "./discovery.ts"; import { discoverPlugins } from "./discovery.ts";
import { runBootHooks } from "./hooks.ts"; import { runBootHooks } from "./hooks.ts";
import { createKetoClient } from "./keto-client.ts";
import { createKratosAdmin } from "./kratos-admin.ts";
import { createKratosPublic } from "./kratos-public.ts"; import { createKratosPublic } from "./kratos-public.ts";
import { loadMenuConfig } from "./menu-config.ts"; import { loadMenuConfig } from "./menu-config.ts";
const config = loadConfig(); // validates the env (incl. enforced secrets) — fails loud at boot const config = loadConfig(); // validates the env (incl. enforced secrets) — fails loud at boot
const menu = await loadMenuConfig(); // config/menu.ts override + branding — fails loud if malformed const menu = await loadMenuConfig(); // config/menu.ts override + branding — fails loud if malformed
const kratos = createKratosPublic({ baseUrl: config.kratosPublicUrl }); // themed self-service routes (§4) // Ory clients for the themed self-service routes + login completion (§4).
const kratos = createKratosPublic({ baseUrl: config.kratosPublicUrl });
const kratosAdmin = createKratosAdmin({ baseUrl: config.kratosAdminUrl });
const keto = createKetoClient({ readUrl: config.ketoReadUrl, writeUrl: config.ketoWriteUrl });
const plugins = await discoverPlugins(); // scans plugins/, validates — fails loud on a bad plugin const plugins = await discoverPlugins(); // scans plugins/, validates — fails loud on a bad plugin
console.log(`Discovered ${plugins.length} plugin(s)${plugins.length ? `: ${plugins.map((p) => p.id).join(", ")}` : ""}`); console.log(`Discovered ${plugins.length} plugin(s)${plugins.length ? `: ${plugins.map((p) => p.id).join(", ")}` : ""}`);
await runBootHooks(plugins); // plugin onBoot — after discovery, before listen; a throw aborts boot await runBootHooks(plugins); // plugin onBoot — after discovery, before listen; a throw aborts boot
const server = createApp({ cache: config.cacheTemplates, kratos, menu, plugins }).listen(config.port, () => { const server = createApp({ cache: config.cacheTemplates, keto, kratos, kratosAdmin, menu, plugins }).listen(config.port, () => {
console.log(`Listening on http://localhost:${config.port}`); console.log(`Listening on http://localhost:${config.port}`);
}); });

View File

@@ -80,7 +80,7 @@ everything via Docker.
- [x] Keto client (fetch): `check`, list/expand relations, write/delete tuples. → `src/keto-client.ts` (`createKetoClient({readUrl, writeUrl, fetchImpl})`): typed `fetch` wrappers over Keto's relation-tuple APIs, no SDK, `fetchImpl`-injectable like the kratos clients; read (`check`/`listRelations`/`expand`) and write (`writeTuple`/`deleteTuple`) split onto the two ports config.ts targets (4466/4467). `RelationTuple` (subject_id xor subject_set; mirrors bootstrap's roleTuple) is the wire shape for writes + the filter shape for reads via `tupleParams` (subject sets → dotted `subject_set.*` keys). `check` returns a `bool` reading `allowed` from **both** 200 (allowed) and 403 (denied) — Keto answers a denial with 403, not 200 (caught in boot-verify); other statuses fail loud via `KetoError` (carries `.status`, parallels KratosError). `writeTuple` PUTs (idempotent), `deleteTuple` DELETEs by query, `listRelations` parses `next_page_token`, `expand` returns the loose tree. Building block — no route/E2E yet (login completion §4 line 83 + guards line 86 wire it). Tests-first (`keto-client.test.ts`, mock fetch: URLs/ports/method/query+body/subject forms/allowed mapping/pagination/errors). README **Layout** lists it. Boot-verified live: full round-trip against a real keto (check false → write → true → list → expand → delete → false). typecheck + 174 units green. - [x] Keto client (fetch): `check`, list/expand relations, write/delete tuples. → `src/keto-client.ts` (`createKetoClient({readUrl, writeUrl, fetchImpl})`): typed `fetch` wrappers over Keto's relation-tuple APIs, no SDK, `fetchImpl`-injectable like the kratos clients; read (`check`/`listRelations`/`expand`) and write (`writeTuple`/`deleteTuple`) split onto the two ports config.ts targets (4466/4467). `RelationTuple` (subject_id xor subject_set; mirrors bootstrap's roleTuple) is the wire shape for writes + the filter shape for reads via `tupleParams` (subject sets → dotted `subject_set.*` keys). `check` returns a `bool` reading `allowed` from **both** 200 (allowed) and 403 (denied) — Keto answers a denial with 403, not 200 (caught in boot-verify); other statuses fail loud via `KetoError` (carries `.status`, parallels KratosError). `writeTuple` PUTs (idempotent), `deleteTuple` DELETEs by query, `listRelations` parses `next_page_token`, `expand` returns the loose tree. Building block — no route/E2E yet (login completion §4 line 83 + guards line 86 wire it). Tests-first (`keto-client.test.ts`, mock fetch: URLs/ports/method/query+body/subject forms/allowed mapping/pagination/errors). README **Layout** lists it. Boot-verified live: full round-trip against a real keto (check false → write → true → list → expand → delete → false). typecheck + 174 units green.
- [x] Render Kratos flows: fetch flow → render fields against our themed pages → POST to `flow.ui.action` (Kratos handles its CSRF), map field errors/messages. → `src/flow-view.ts` (pure `buildFlowView(flow, type)`): maps a fetched self-service `Flow` → themed view model — hidden inputs (incl. `csrf_token`), themed fields (label from `meta.label`, type/required/autocomplete from attributes, an input icon by field semantics, node-level error message), submit buttons (name/value preserved), and tone-mapped flow messages (error→neg/success→pos/info→info); `oidc` nodes skipped (SSO is the next item). Per-flow chrome (title/sub/back/alt) + `AUTH_FLOWS` path→type map. `views/auth.ejs` renders it into the html-css-foundation auth layout, reusing the `auth-card` + `field` partials and capturing `partials/flow-body.ejs` (messages + hidden + fields + buttons) into the card body; new reusable `partials/alert.ejs` + an `.alert` design-system component (styles.css, tone tokens). `app.ts` serves the five routes via an injectable `kratos` client (server.ts builds it from `config.kratosPublicUrl`): no `?flow=` ⇒ init server-side + relay Kratos' CSRF `Set-Cookie` + 303 to `?flow=<id>`; `?flow=<id>``getFlow` (forwarding the browser cookie) → render; an expired/unknown flow (403/404/410) re-inits. The browser POSTs the form straight to `flow.ui.action` (Kratos owns CSRF) — no server-side `submitFlow`. Tests-first: `flow-view.test.ts` (mapping matrix: hidden/fields/buttons/icons/errors/tone/oidc-skip/chrome/AUTH_FLOWS) + `app.test.ts` integration (init 303 + CSRF relay + expired restart; rendered page posts to Kratos with the live fields + error alert) — mock `KratosPublic`. typecheck + 181 units green. Boot-verified the whole chain on the live stack: `/login` 303 → `?flow=` relaying the real `csrf_token_…` cookie, the page posts to `127.0.0.1:4433` with the live token + identifier/password + submit; registration renders the real `traits.*` fields; recovery/verification chrome correct; a stale flow id 303s back to re-init; torn down. Browser-submittable end-to-end (dev http Secure-cookie posture, login completion → our JWT cookie) is the next §4 items (lines 83/89); the full live-stack login Playwright E2E is owned by §8. - [x] Render Kratos flows: fetch flow → render fields against our themed pages → POST to `flow.ui.action` (Kratos handles its CSRF), map field errors/messages. → `src/flow-view.ts` (pure `buildFlowView(flow, type)`): maps a fetched self-service `Flow` → themed view model — hidden inputs (incl. `csrf_token`), themed fields (label from `meta.label`, type/required/autocomplete from attributes, an input icon by field semantics, node-level error message), submit buttons (name/value preserved), and tone-mapped flow messages (error→neg/success→pos/info→info); `oidc` nodes skipped (SSO is the next item). Per-flow chrome (title/sub/back/alt) + `AUTH_FLOWS` path→type map. `views/auth.ejs` renders it into the html-css-foundation auth layout, reusing the `auth-card` + `field` partials and capturing `partials/flow-body.ejs` (messages + hidden + fields + buttons) into the card body; new reusable `partials/alert.ejs` + an `.alert` design-system component (styles.css, tone tokens). `app.ts` serves the five routes via an injectable `kratos` client (server.ts builds it from `config.kratosPublicUrl`): no `?flow=` ⇒ init server-side + relay Kratos' CSRF `Set-Cookie` + 303 to `?flow=<id>`; `?flow=<id>``getFlow` (forwarding the browser cookie) → render; an expired/unknown flow (403/404/410) re-inits. The browser POSTs the form straight to `flow.ui.action` (Kratos owns CSRF) — no server-side `submitFlow`. Tests-first: `flow-view.test.ts` (mapping matrix: hidden/fields/buttons/icons/errors/tone/oidc-skip/chrome/AUTH_FLOWS) + `app.test.ts` integration (init 303 + CSRF relay + expired restart; rendered page posts to Kratos with the live fields + error alert) — mock `KratosPublic`. typecheck + 181 units green. Boot-verified the whole chain on the live stack: `/login` 303 → `?flow=` relaying the real `csrf_token_…` cookie, the page posts to `127.0.0.1:4433` with the live token + identifier/password + submit; registration renders the real `traits.*` fields; recovery/verification chrome correct; a stale flow id 303s back to re-init; torn down. Browser-submittable end-to-end (dev http Secure-cookie posture, login completion → our JWT cookie) is the next §4 items (lines 83/89); the full live-stack login Playwright E2E is owned by §8.
- [x] SSO buttons → Kratos OIDC flows. **Render per configured provider only**: derive the list from Kratos' enabled OIDC providers (no creds ⇒ no button); hide the whole SSO section when none are configured. No code change needed to add/remove a provider — config only. → `flow-view.ts` now collects the login/registration flow's `oidc`-group submit nodes into `FlowView.sso` (`{label, logo, name, value}` per provider; `logo` = provider initial, lucide ships no brand marks) instead of skipping them — so the button list *is* Kratos' live provider list (none configured ⇒ `sso: []` ⇒ no section; activate/remove a provider purely via the §3 OIDC env). `auth-card.ejs` gained a submit-provider branch: a provider with `name`/`value` renders `<button type="submit" name=… value=…>` (posts `provider=<id>` to the same Kratos form, sharing its csrf hidden input); `href` still ⇒ `<a>`, neither ⇒ inert button. `auth.ejs` forwards `sso: { providers: flow.sso }`. Removed the mockup-only `body:not(:has(#sso-toggle:checked)) .sso{display:none}` rule from `auth.css` (`#sso-toggle` is a "remove for production" preview control in `html-css-foundation/Auth.html`) — visibility is now purely server-side. Tests-first: `flow-view.test.ts` (oidc→sso matrix + `sso:[]` when none), `auth-card.test.ts` (submit-provider markup), `app.test.ts` (live `/login` renders the SSO submit button in the form). README **Social sign-in (SSO)** updated (dropped the §4 forward-ref). typecheck + 181 units green. Boot-verified end-to-end: a real Kratos with the OIDC env emitted `{group:oidc, name:provider, value:google}``buildFlowView` derived `[{label:"Sign in with google", logo:"G", name:"provider", value:"google"}]`; clean-clone `/login` renders no `.sso` section; torn down. - [x] SSO buttons → Kratos OIDC flows. **Render per configured provider only**: derive the list from Kratos' enabled OIDC providers (no creds ⇒ no button); hide the whole SSO section when none are configured. No code change needed to add/remove a provider — config only. → `flow-view.ts` now collects the login/registration flow's `oidc`-group submit nodes into `FlowView.sso` (`{label, logo, name, value}` per provider; `logo` = provider initial, lucide ships no brand marks) instead of skipping them — so the button list *is* Kratos' live provider list (none configured ⇒ `sso: []` ⇒ no section; activate/remove a provider purely via the §3 OIDC env). `auth-card.ejs` gained a submit-provider branch: a provider with `name`/`value` renders `<button type="submit" name=… value=…>` (posts `provider=<id>` to the same Kratos form, sharing its csrf hidden input); `href` still ⇒ `<a>`, neither ⇒ inert button. `auth.ejs` forwards `sso: { providers: flow.sso }`. Removed the mockup-only `body:not(:has(#sso-toggle:checked)) .sso{display:none}` rule from `auth.css` (`#sso-toggle` is a "remove for production" preview control in `html-css-foundation/Auth.html`) — visibility is now purely server-side. Tests-first: `flow-view.test.ts` (oidc→sso matrix + `sso:[]` when none), `auth-card.test.ts` (submit-provider markup), `app.test.ts` (live `/login` renders the SSO submit button in the form). README **Social sign-in (SSO)** updated (dropped the §4 forward-ref). typecheck + 181 units green. Boot-verified end-to-end: a real Kratos with the OIDC env emitted `{group:oidc, name:provider, value:google}``buildFlowView` derived `[{label:"Sign in with google", logo:"G", name:"provider", value:"google"}]`; clean-clone `/login` renders no `.sso` section; torn down.
- [ ] Login completion: read roles from Keto → write `metadata_admin` projection → tokenize → set JWT cookie. - [x] Login completion: read roles from Keto → write `metadata_public` projection → tokenize → set JWT cookie. → `src/login.ts` (`completeLogin`/`readRoles`/`sessionCookie`, `SESSION_COOKIE`), wired into `app.ts` at `GET /auth/complete` — where `kratos.yml` now lands the browser after a successful login (`login.after.default_browser_return_url`). The route: `whoami(cookie)` → identity (id/email; no session ⇒ 303 `/login`); `readRoles` lists `Role:*#members@user:<id>` from Keto (one paged read, sorted/de-duped; group→role transitivity is §5); projects `{roles}` onto the identity; then `whoami(tokenize_as: plainpages)` → the signed JWT, stored as `plainpages_jwt` (HttpOnly + SameSite=Lax + 30d, `secure` deferred to §9). `server.ts` builds the kratos-admin + keto clients and passes all three to `createApp`. **Design bug caught in live boot-verify + fixed:** the projection had to move `metadata_admin``metadata_public` — Kratos *strips admin metadata* from the session the tokenizer reads, so `metadata_admin` yielded `roles:[]`; `metadata_public` is carried (and the user already reads these coarse roles in their own JWT, so nothing leaks). Touched `kratos-admin.ts` (`updateMetadataAdmin``updateMetadataPublic`, `/metadata_public` patch), the tokenizer jsonnet, and the kratos.yml/README rationale. Tests-first: `login.test.ts` (readRoles paging/dedup; completeLogin order whoami→project→tokenize; no-session⇒null; missing email⇒null; no-JWT⇒throw; cookie flags) + `app.test.ts` integration (`/auth/complete` projects roles, sets `plainpages_jwt`, 303→`/`; no session ⇒ 303 `/login`, no cookie) + `kratos.test.ts` (after-login URL + jsonnet metadata_public). Boot-verified the whole chain live: real admin login → `/auth/complete` → JWT `{sub, email, roles:["admin"], expiat=600}`, identity re-projected `metadata_public:{roles:["admin"]}` from Keto (wiped first to prove the write); no-session ⇒ 303 `/login`; torn down. The full-stack login Playwright E2E is owned by §8. typecheck + 189 units green.
- [ ] JWT middleware: verify signature via cached JWKS, validate `exp`/`iss`/`aud` (+clock skew), build context (user, roles). - [ ] JWT middleware: verify signature via cached JWKS, validate `exp`/`iss`/`aud` (+clock skew), build context (user, roles).
- [ ] JWKS fetch + cache + rotation handling. - [ ] JWKS fetch + cache + rotation handling.
- [ ] Guards: `requireSession` (validate JWT), `can(role)` (claim, in-process), `check(relation, object)` (live Keto). - [ ] Guards: `requireSession` (validate JWT), `can(role)` (claim, in-process), `check(relation, object)` (live Keto).