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.
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
read-only copy written onto the identity's `metadata_admin` for the tokenizer to see,
which the template maps into the JWT `roles` claim. That projection is a per-login
cache, authoritative nowhere; nothing edits it by hand, and a stale one self-heals on
the next login.
read-only copy written onto the identity's `metadata_public` for the tokenizer to see,
which the template maps into the JWT `roles` claim. (It must be `metadata_public`, not
`metadata_admin`: the session Kratos hands the tokenizer carries only *public* metadata —
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
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/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-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/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/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)

View File

@@ -36,6 +36,10 @@ selfservice:
ui_url: http://127.0.0.1:3000/error
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:
ui_url: http://127.0.0.1:3000/registration
after:
@@ -88,7 +92,7 @@ session:
# 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
# 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:
tokenizer:
templates:

View File

@@ -1,11 +1,12 @@
// 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
// can't be overridden here. roles come from metadata_admin — the per-login projection
// of Keto roles the app refreshes at login; absent on a fresh identity ⇒ empty list.
// can't be overridden here. roles come from metadata_public — the per-login projection
// 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 meta =
if std.objectHas(session.identity, 'metadata_admin') && session.identity.metadata_admin != null
then session.identity.metadata_admin
if std.objectHas(session.identity, 'metadata_public') && session.identity.metadata_public != null
then session.identity.metadata_public
else {};
{

View File

@@ -6,7 +6,9 @@ import { dirname, join } from "node:path";
import { after, before, test, type TestContext } from "node:test";
import { fileURLToPath } from "node:url";
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 { 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\./);
});
// 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", () => {
assert.equal(resolveStaticPath("/srv/public", "../app.ts"), 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 { AUTH_FLOWS, buildFlowView } from "./flow-view.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 { completeLogin, sessionCookie } from "./login.ts";
import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts";
import type { Plugin, RouteResult } from "./plugin.ts";
import { allowedMethods, isAuthorized, matchRoute } from "./router.ts";
@@ -20,7 +23,9 @@ export interface AppOptions {
// Cache compiled templates; caller decides (server passes config.cacheTemplates).
// Off by default so edits show live; the app itself never inspects the environment.
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)
kratosAdmin?: KratosAdmin; // Kratos admin client; with kratos+keto enables login completion (§4)
menu?: MenuConfig; // central override + branding (config/menu.ts); defaults to DEFAULT_MENU
plugins?: Plugin[]; // discovered manifests to mount (router); empty until §2 discovery runs
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 {
const cache = options.cache ?? false;
const keto = options.keto;
const kratos = options.kratos;
const kratosAdmin = options.kratosAdmin;
const menu = options.menu ?? DEFAULT_MENU;
const plugins = options.plugins ?? [];
const pluginIds = new Set(plugins.map((p) => p.id));
@@ -112,6 +119,20 @@ export function createApp(options: AppOptions = {}): Server {
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")) {
// 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) }));

View File

@@ -1,5 +1,5 @@
// 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
// result mapping (201/200/404/4xx). Live wiring is verified by login completion (§4).
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));
});
test("updateMetadataAdmin PATCHes a JSON-Patch `add /metadata_admin` so it never clobbers traits", async () => {
const identity = { id: ID, metadata_admin: { roles: ["admin"] } };
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 }).updateMetadataAdmin(ID, { roles: ["admin"] });
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_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 () => {

View File

@@ -1,5 +1,5 @@
// 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);
// `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.
@@ -32,7 +32,7 @@ export interface KratosAdmin {
getIdentity(id: string): Promise<Identity | null>;
listIdentities(opts?: ListOptions): Promise<IdentityList>;
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
@@ -88,12 +88,13 @@ export function createKratosAdmin(config: { baseUrl: string; fetchImpl?: typeof
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.
async updateMetadataAdmin(id, metadata) {
const patch = [{ op: "add", path: "/metadata_admin", value: metadata }];
// (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_admin", res);
if (res.status !== 200) return fail("update metadata_public", res);
return (await res.json()) as Identity;
},
};

View File

@@ -37,7 +37,7 @@ export interface Flow {
export interface Session {
active?: boolean;
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
}

View File

@@ -46,6 +46,11 @@ test("self-service flows return to our themed pages", () => {
`${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", () => {
assert.ok((kratosYml.match(/use:\s*code/g) ?? []).length >= 2,
"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");
});
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");
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", () => {

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 { discoverPlugins } from "./discovery.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 { loadMenuConfig } from "./menu-config.ts";
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 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
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
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}`);
});

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] 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.
- [ ] 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).
- [ ] JWKS fetch + cache + rotation handling.
- [ ] Guards: `requireSession` (validate JWT), `can(role)` (claim, in-process), `check(relation, object)` (live Keto).