From 38157605d0b25fec5878f246bfd0c27e59fbd408 Mon Sep 17 00:00:00 2001 From: lilleman Date: Wed, 17 Jun 2026 23:15:28 +0200 Subject: [PATCH] =?UTF-8?q?Login=20completion=20(todo=20=C2=A74);=20/auth/?= =?UTF-8?q?complete:=20roles=20from=20Keto=20=E2=86=92=20metadata=5Fpublic?= =?UTF-8?q?=20projection=20=E2=86=92=20tokenize=20=E2=86=92=20plainpages?= =?UTF-8?q?=5Fjwt=20cookie;=20fix=20tokenizer=20projection=20metadata=5Fad?= =?UTF-8?q?min=E2=86=92metadata=5Fpublic=20(whoami=20strips=20admin=20meta?= =?UTF-8?q?data)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 13 ++-- ory/kratos/kratos.yml | 6 +- ory/kratos/tokenizer/plainpages.jsonnet | 9 +-- src/app.test.ts | 53 +++++++++++++- src/app.ts | 21 ++++++ src/kratos-admin.test.ts | 10 +-- src/kratos-admin.ts | 13 ++-- src/kratos-public.ts | 2 +- src/kratos.test.ts | 11 ++- src/login.test.ts | 92 +++++++++++++++++++++++++ src/login.ts | 75 ++++++++++++++++++++ src/server.ts | 9 ++- todo.md | 2 +- 13 files changed, 288 insertions(+), 28 deletions(-) create mode 100644 src/login.test.ts create mode 100644 src/login.ts diff --git a/README.md b/README.md index 0328a7d..7a51703 100644 --- a/README.md +++ b/README.md @@ -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// → 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) diff --git a/ory/kratos/kratos.yml b/ory/kratos/kratos.yml index 96465ca..6af6e69 100644 --- a/ory/kratos/kratos.yml +++ b/ory/kratos/kratos.yml @@ -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: diff --git a/ory/kratos/tokenizer/plainpages.jsonnet b/ory/kratos/tokenizer/plainpages.jsonnet index d6078b5..3a96cd0 100644 --- a/ory/kratos/tokenizer/plainpages.jsonnet +++ b/ory/kratos/tokenizer/plainpages.jsonnet @@ -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 {}; { diff --git a/src/app.test.ts b/src/app.test.ts index 7562cff..d1c10f1 100644 --- a/src/app.test.ts +++ b/src/app.test.ts @@ -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 => ({ + 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 => ({ + 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((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((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); diff --git a/src/app.ts b/src/app.ts index c85ac60..3692ac9 100644 --- a/src/app.ts +++ b/src/app.ts @@ -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) })); diff --git a/src/kratos-admin.test.ts b/src/kratos-admin.test.ts index 1f38924..3960b07 100644 --- a/src/kratos-admin.test.ts +++ b/src/kratos-admin.test.ts @@ -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/ 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 () => { diff --git a/src/kratos-admin.ts b/src/kratos-admin.ts index 389f8ba..3c8ca4d 100644 --- a/src/kratos-admin.ts +++ b/src/kratos-admin.ts @@ -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; listIdentities(opts?: ListOptions): Promise; updateIdentity(id: string, payload: unknown): Promise; - updateMetadataAdmin(id: string, metadata: unknown): Promise; + updateMetadataPublic(id: string, metadata: unknown): Promise; } // 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; }, }; diff --git a/src/kratos-public.ts b/src/kratos-public.ts index a29b9f1..7b0b18b 100644 --- a/src/kratos-public.ts +++ b/src/kratos-public.ts @@ -37,7 +37,7 @@ export interface Flow { export interface Session { active?: boolean; expires_at?: string; - identity?: { id: string; metadata_admin?: unknown; traits?: Record }; + identity?: { id: string; metadata_public?: unknown; traits?: Record }; // whoami strips metadata_admin tokenized?: string; // the signed JWT — present only when `tokenize_as` was requested } diff --git a/src/kratos.test.ts b/src/kratos.test.ts index a95c05a..cd3afd1 100644 --- a/src/kratos.test.ts +++ b/src/kratos.test.ts @@ -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", () => { diff --git a/src/login.test.ts b/src/login.test.ts new file mode 100644 index 0000000..cd3f854 --- /dev/null +++ b/src/login.test.ts @@ -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 => ({ + check: async () => false, + deleteTuple: async () => {}, + expand: async () => ({ type: "leaf" }), + listRelations: async () => ({ nextPageToken: null, tuples: [] }), + writeTuple: async () => {}, + ...over, +}); + +const adminStub = (over: Partial = {}): 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 => ({ + 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$/); +}); diff --git a/src/login.ts b/src/login.ts new file mode 100644 index 0000000..e515ace --- /dev/null +++ b/src/login.ts @@ -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:#members@user:`. 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 { + const subject_id = `user:${identityId}`; + const roles = new Set(); + 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 { + 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); +} diff --git a/src/server.ts b/src/server.ts index 80f0dc2..2dc6797 100644 --- a/src/server.ts +++ b/src/server.ts @@ -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}`); }); diff --git a/todo.md b/todo.md index 224c195..10dba53 100644 --- a/todo.md +++ b/todo.md @@ -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=`; `?flow=` ⇒ `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 `