From 898dc7f2cfe59bc856caf213cf44ac388c673abb Mon Sep 17 00:00:00 2001 From: lilleman Date: Wed, 17 Jun 2026 17:15:50 +0200 Subject: [PATCH] =?UTF-8?q?Add=20Kratos=20public-API=20fetch=20client=20(t?= =?UTF-8?q?odo=20=C2=A74);=20createKratosPublic():=20self-service=20flow?= =?UTF-8?q?=20init/get/submit,=20whoami,=20session=E2=86=92JWT=20tokenize?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1 + src/kratos-public.test.ts | 111 ++++++++++++++++++++++++++++++ src/kratos-public.ts | 139 ++++++++++++++++++++++++++++++++++++++ todo.md | 2 +- 4 files changed, 252 insertions(+), 1 deletion(-) create mode 100644 src/kratos-public.test.ts create mode 100644 src/kratos-public.ts diff --git a/README.md b/README.md index d838f22..3d6b2de 100644 --- a/README.md +++ b/README.md @@ -496,6 +496,7 @@ src/server.ts Entry point — starts the HTTP server (reads PORT, default src/app.ts Request routing + EJS rendering 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/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/src/kratos-public.test.ts b/src/kratos-public.test.ts new file mode 100644 index 0000000..70a1bc1 --- /dev/null +++ b/src/kratos-public.test.ts @@ -0,0 +1,111 @@ +// Kratos public-API client (§4): typed fetch wrappers over Ory Kratos' public endpoints. +// Guards the request contracts (URLs, JSON-accept, cookie relay) and the result mapping +// (200/401/4xx, validation-flow vs success, tokenized JWT). Live wiring is verified by the +// flow pages (§4); these catch contract drift with a mock fetch. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { createKratosPublic, KratosError } from "./kratos-public.ts"; + +const BASE = "http://kratos:4433"; + +function res(status: number, body?: unknown, setCookie: string[] = []): Response { + const headers = new Headers(); + if (body !== undefined) headers.set("content-type", "application/json"); + for (const c of setCookie) headers.append("set-cookie", c); + return new Response(body === undefined ? null : JSON.stringify(body), { status, headers }); +} + +// Records each call so a test can assert URL/method/headers/body. +function recorder(handler: (url: string, init: RequestInit | undefined) => Response) { + const calls: { body: string | undefined; headers: Headers; method: string; url: string }[] = []; + const fetchImpl = (async (input: unknown, init?: RequestInit) => { + calls.push({ + body: init?.body as string | undefined, + headers: new Headers(init?.headers), + method: init?.method ?? "GET", + url: String(input), + }); + return handler(String(input), init); + }) as typeof fetch; + return { calls, fetchImpl }; +} + +test("initBrowserFlow gets /self-service//browser as JSON, relays Set-Cookie, forwards return_to", async () => { + const flow = { id: "f1", ui: { action: `${BASE}/self-service/login?flow=f1`, method: "POST", nodes: [] } }; + const { calls, fetchImpl } = recorder(() => res(200, flow, ["csrf_token=abc; Path=/; HttpOnly"])); + const out = await createKratosPublic({ baseUrl: BASE, fetchImpl }).initBrowserFlow("login", { returnTo: "http://app/after" }); + assert.deepEqual(out.flow, flow); + assert.deepEqual(out.setCookie, ["csrf_token=abc; Path=/; HttpOnly"]); + assert.match(calls[0]!.url, /\/self-service\/login\/browser\?return_to=http%3A%2F%2Fapp%2Fafter$/); + assert.equal(calls[0]!.headers.get("accept"), "application/json"); +}); + +test("getFlow fetches the flow by id forwarding the browser cookie", async () => { + const flow = { id: "f2", ui: { action: "x", method: "POST", nodes: [] } }; + const { calls, fetchImpl } = recorder(() => res(200, flow)); + const out = await createKratosPublic({ baseUrl: BASE, fetchImpl }).getFlow("registration", "f2", { cookie: "csrf_token=abc" }); + assert.deepEqual(out, flow); + assert.match(calls[0]!.url, /\/self-service\/registration\/flows\?id=f2$/); + assert.equal(calls[0]!.headers.get("cookie"), "csrf_token=abc"); +}); + +test("getFlow throws a KratosError carrying the status when the flow is gone (410)", async () => { + const { fetchImpl } = recorder(() => res(410, { error: { id: "self_service_flow_expired" } })); + await assert.rejects( + createKratosPublic({ baseUrl: BASE, fetchImpl }).getFlow("login", "old"), + (e: unknown) => e instanceof KratosError && e.status === 410, + ); +}); + +test("submitFlow POSTs urlencoded to the action and reports success + relays Set-Cookie", async () => { + const { calls, fetchImpl } = recorder(() => res(200, { session: { active: true } }, ["plainpages_session=s; Path=/"])); + const out = await createKratosPublic({ baseUrl: BASE, fetchImpl }) + .submitFlow(`${BASE}/self-service/login?flow=f`, { body: "identifier=a&password=b", cookie: "csrf_token=abc" }); + assert.equal(out.ok, true); + assert.equal(out.status, 200); + assert.deepEqual(out.setCookie, ["plainpages_session=s; Path=/"]); + assert.equal(calls[0]!.method, "POST"); + assert.equal(calls[0]!.headers.get("content-type"), "application/x-www-form-urlencoded"); + assert.equal(calls[0]!.body, "identifier=a&password=b"); +}); + +test("submitFlow returns the re-rendered flow (no throw) on a 400 validation error", async () => { + const flow = { id: "f", ui: { action: "x", messages: [{ id: 4000006, text: "invalid credentials", type: "error" }], method: "POST", nodes: [] } }; + const { fetchImpl } = recorder(() => res(400, flow)); + const out = await createKratosPublic({ baseUrl: BASE, fetchImpl }).submitFlow(`${BASE}/x`, { body: "x=1" }); + assert.equal(out.ok, false); + assert.equal(out.status, 400); + assert.deepEqual(out.body, flow); +}); + +test("submitFlow surfaces the redirect target — Location header or a 422 redirect_browser_to body", async () => { + const k = (handler: () => Response) => createKratosPublic({ baseUrl: BASE, fetchImpl: recorder(handler).fetchImpl }); + const viaHeader = await k(() => new Response(null, { headers: new Headers({ location: "http://app/" }), status: 303 })) + .submitFlow(`${BASE}/x`, { body: "x=1" }); + assert.equal(viaHeader.location, "http://app/"); + const viaBody = await k(() => res(422, { redirect_browser_to: "http://app/login?flow=next" })) + .submitFlow(`${BASE}/x`, { body: "x=1" }); + assert.equal(viaBody.location, "http://app/login?flow=next"); +}); + +test("whoami returns the session on 200 (cookie forwarded) and null on 401", async () => { + const session = { active: true, identity: { id: "u1", traits: { email: "a@b" } } }; + const { calls, fetchImpl } = recorder((url) => (url.endsWith("/sessions/whoami") ? res(200, session) : res(401))); + const k = createKratosPublic({ baseUrl: BASE, fetchImpl }); + assert.deepEqual(await k.whoami({ cookie: "plainpages_session=s" }), session); + assert.equal(calls[0]!.headers.get("cookie"), "plainpages_session=s"); + assert.equal(await createKratosPublic({ baseUrl: BASE, fetchImpl: (async () => res(401)) as typeof fetch }).whoami(), null); +}); + +test("whoami?tokenize_as mints a session JWT via the tokenizer template", async () => { + const session = { active: true, identity: { id: "u1" }, tokenized: "header.payload.sig" }; + const { calls, fetchImpl } = recorder(() => res(200, session)); + const out = await createKratosPublic({ baseUrl: BASE, fetchImpl }).whoami({ cookie: "plainpages_session=s", tokenizeAs: "plainpages" }); + assert.equal(out?.tokenized, "header.payload.sig"); + assert.match(calls[0]!.url, /\/sessions\/whoami\?tokenize_as=plainpages$/); +}); + +test("whoami throws on an unexpected upstream error", async () => { + const { fetchImpl } = recorder(() => res(500, { error: "boom" })); + await assert.rejects(createKratosPublic({ baseUrl: BASE, fetchImpl }).whoami(), KratosError); +}); diff --git a/src/kratos-public.ts b/src/kratos-public.ts new file mode 100644 index 0000000..a29b9f1 --- /dev/null +++ b/src/kratos-public.ts @@ -0,0 +1,139 @@ +// Kratos public-API client (todo §4): typed `fetch` wrappers over Ory Kratos' public +// endpoints — self-service flow init/get/submit, session `whoami`, and the session→JWT +// tokenizer (`whoami?tokenize_as`). Built-in `fetch` only, no SDK dep (AGENTS.md). The +// themed flow pages and login completion (§4) build on this; rendering flow `ui.nodes` +// and mapping field errors is the renderer's job (§4), so we keep those types loose. + +export type FlowType = "login" | "recovery" | "registration" | "settings" | "verification"; + +export interface UiText { + context?: Record; + id: number; + text: string; + type: string; +} + +export interface UiNode { + attributes: Record; + group: string; + messages: UiText[]; + meta: { label?: UiText }; + type: string; +} + +export interface FlowUi { + action: string; // absolute Kratos URL the browser POSTs the form to (Kratos owns its CSRF) + messages?: UiText[]; + method: string; + nodes: UiNode[]; +} + +export interface Flow { + id: string; + type?: string; + ui: FlowUi; +} + +export interface Session { + active?: boolean; + expires_at?: string; + identity?: { id: string; metadata_admin?: unknown; traits?: Record }; + tokenized?: string; // the signed JWT — present only when `tokenize_as` was requested +} + +export interface FlowInit { + flow: Flow; + setCookie: string[]; // Kratos' CSRF cookie(s) to relay to the browser +} + +export interface FlowSubmission { + body: unknown; // parsed JSON: the re-rendered flow on 400, the success payload on 200 + location: string | null; // redirect target (Location header, or a 422 redirect_browser_to) + ok: boolean; // status === 200 + setCookie: string[]; + status: number; +} + +// Carries the HTTP status so a caller can branch — e.g. re-init on an expired flow (404/410). +export class KratosError extends Error { + body: string; + status: number; + constructor(message: string, status: number, body: string) { + super(message); + this.body = body; + this.name = "KratosError"; + this.status = status; + } +} + +export interface KratosPublic { + getFlow(type: FlowType, id: string, opts?: { cookie?: string }): Promise; + initBrowserFlow(type: FlowType, opts?: { cookie?: string; returnTo?: string }): Promise; + submitFlow(action: string, opts: { body: string; contentType?: string; cookie?: string }): Promise; + whoami(opts?: { cookie?: string; tokenizeAs?: string }): Promise; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parseBody(text: string): unknown { + if (!text) return null; + try { + return JSON.parse(text); + } catch { + return text; + } +} + +export function createKratosPublic(config: { baseUrl: string; fetchImpl?: typeof fetch }): KratosPublic { + const base = config.baseUrl.replace(/\/+$/, ""); + const http = config.fetchImpl ?? fetch; + + // Forward the browser cookie + ask for JSON, so Kratos returns the flow/session instead + // of redirecting an API caller. + function headers(cookie?: string): Record { + const h: Record = { accept: "application/json" }; + if (cookie) h["cookie"] = cookie; + return h; + } + + return { + async initBrowserFlow(type, opts = {}) { + const url = new URL(`${base}/self-service/${type}/browser`); + if (opts.returnTo) url.searchParams.set("return_to", opts.returnTo); + const res = await http(url, { headers: headers(opts.cookie), redirect: "manual" }); + if (res.status !== 200) throw new KratosError(`Kratos init ${type} flow failed (${res.status})`, res.status, await res.text()); + return { flow: (await res.json()) as Flow, setCookie: res.headers.getSetCookie() }; + }, + + async getFlow(type, id, opts = {}) { + const url = new URL(`${base}/self-service/${type}/flows`); + url.searchParams.set("id", id); + const res = await http(url, { headers: headers(opts.cookie) }); + if (res.status !== 200) throw new KratosError(`Kratos get ${type} flow failed (${res.status})`, res.status, await res.text()); + return (await res.json()) as Flow; + }, + + async submitFlow(action, opts) { + const h = headers(opts.cookie); + h["content-type"] = opts.contentType ?? "application/x-www-form-urlencoded"; + // Manual redirect so we can read a 303 Location instead of following it server-side. + const res = await http(action, { body: opts.body, headers: h, method: "POST", redirect: "manual" }); + const body = parseBody(await res.text()); + const location = + res.headers.get("location") ?? + (isRecord(body) && typeof body["redirect_browser_to"] === "string" ? body["redirect_browser_to"] : null); + return { body, location, ok: res.status === 200, setCookie: res.headers.getSetCookie(), status: res.status }; + }, + + async whoami(opts = {}) { + const url = new URL(`${base}/sessions/whoami`); + if (opts.tokenizeAs) url.searchParams.set("tokenize_as", opts.tokenizeAs); + const res = await http(url, { headers: headers(opts.cookie) }); + if (res.status === 401) return null; // no/expired session + if (res.status !== 200) throw new KratosError(`Kratos whoami failed (${res.status})`, res.status, await res.text()); + return (await res.json()) as Session; + }, + }; +} diff --git a/todo.md b/todo.md index 3d5c2eb..602e7ad 100644 --- a/todo.md +++ b/todo.md @@ -75,7 +75,7 @@ everything via Docker. - [x] Go over all tests and combine/unify ones that cover the same stuff or are very related and could be combined in a good way. Remove tests that aren't helping, we only want tests that are actually helpful to us. → Pass over the §3 Ory-stack tests. The clear overlap: the "image pinned to an exact version" AGENTS.md check was re-implemented 5× (postgres/kratos/keto/hydra + mailpit). Unified into one `compose.test.ts` scan over all three compose files (strictly stronger — auto-covers any future image) + one test asserting each Ory service & its migrate sidecar share one version (subsumes the per-service "both present + same version" halves). Dropped the now-redundant pin tests from `postgres/kratos/keto/hydra.test.ts` (each keeps its config-semantics tests; comments point pinning at `compose.test.ts`). Also trimmed `config.test.ts`'s duplicate re-validation of the committed JWKS key — `gen-jwks.test.ts` already owns key validity (round-trips a signature); the config test keeps the default-path assertion. The migrate-before-server / DSN / port / URL tests stay per-service (distinct config, distinct files — merging would hurt the per-module structure). 153 → 150 tests, zero coverage lost; typecheck + tests green. ## 4. Auth — identity, session JWT, guards -- [ ] Kratos public client (fetch): init/get/submit flows, `whoami`, `whoami?tokenize_as=plainpages`. +- [x] Kratos public client (fetch): init/get/submit flows, `whoami`, `whoami?tokenize_as=plainpages`. → `src/kratos-public.ts` (`createKratosPublic({baseUrl, fetchImpl})`): typed `fetch` wrappers over Kratos' public API, no SDK dep (built-in `fetch`), `fetchImpl`-injectable like `bootstrap.ts`. `initBrowserFlow(type, {cookie?, returnTo?})` GETs `/self-service//browser` with `Accept: json` (so Kratos returns the flow + CSRF `Set-Cookie` to relay, not a redirect); `getFlow(type, id, {cookie?})` reads `/self-service//flows?id=` forwarding the browser cookie; `submitFlow(action, {body, contentType?, cookie?})` POSTs urlencoded to the flow's `ui.action` (manual redirect) → `{ok, status, body, location, setCookie}` (200 success / 400 re-rendered flow-with-errors, no throw / 303 Location or 422 `redirect_browser_to`); `whoami({cookie?, tokenizeAs?})` reads `/sessions/whoami` → `Session|null` (401⇒null), with `?tokenize_as=plainpages` returning the session's `tokenized` JWT. Fail-loud `KratosError` carries `.status` (so §4 line 81 can re-init on an expired 404/410). Flow `ui.nodes` typed loosely — rendering/field-error mapping is §4's renderer. Tests-first (`kratos-public.test.ts`, mock fetch: URLs/JSON-accept/cookie relay/Set-Cookie/tokenize query + 410/500 errors + 400 validation + redirect targets). Building block — no route/E2E yet (the themed flow pages + login completion are the next §4 items). README **Layout** lists it. typecheck + 159 units green. - [ ] Kratos admin client (fetch): identity CRUD + `metadata_admin` update. - [ ] Keto client (fetch): `check`, list/expand relations, write/delete tuples. - [ ] Render Kratos flows: fetch flow → render fields against our themed pages → POST to `flow.ui.action` (Kratos handles its CSRF), map field errors/messages.