diff --git a/README.md b/README.md index 3d6b2de..c14b3b2 100644 --- a/README.md +++ b/README.md @@ -497,6 +497,7 @@ 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/kratos-admin.ts createKratosAdmin(): Kratos admin-API fetch client — identity CRUD + surgical metadata_admin update (login role projection, §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-admin.test.ts b/src/kratos-admin.test.ts new file mode 100644 index 0000000..1f38924 --- /dev/null +++ b/src/kratos-admin.test.ts @@ -0,0 +1,111 @@ +// 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. +// 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"; +import assert from "node:assert/strict"; +import { createKratosAdmin } from "./kratos-admin.ts"; +import { KratosError } from "./kratos-public.ts"; + +const BASE = "http://kratos:4434"; +const ID = "01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b55"; + +function res(status: number, body?: unknown, headers: Record = {}): Response { + const h = new Headers(headers); + if (body !== undefined) h.set("content-type", "application/json"); + return new Response(body === undefined ? null : JSON.stringify(body), { status, headers: h }); +} + +function recorder(handler: (url: string, init: RequestInit | undefined) => Response) { + const calls: { body: string | undefined; headers: Headers; method: string; url: string }[] = []; + const fetchImpl = (async (input: unknown, init?: RequestInit) => { + calls.push({ body: init?.body as string | undefined, headers: new Headers(init?.headers), method: init?.method ?? "GET", url: String(input) }); + return handler(String(input), init); + }) as typeof fetch; + return { calls, fetchImpl }; +} + +test("createIdentity POSTs JSON to /admin/identities and returns the created identity (201)", async () => { + const identity = { id: ID, traits: { email: "a@b" } }; + const { calls, fetchImpl } = recorder(() => res(201, identity)); + const payload = { schema_id: "default", traits: { email: "a@b" } }; + const out = await createKratosAdmin({ baseUrl: BASE, fetchImpl }).createIdentity(payload); + assert.deepEqual(out, identity); + assert.equal(calls[0]!.method, "POST"); + assert.match(calls[0]!.url, /\/admin\/identities$/); + assert.equal(calls[0]!.headers.get("content-type"), "application/json"); + assert.equal(calls[0]!.body, JSON.stringify(payload)); +}); + +test("createIdentity throws a KratosError carrying the status on conflict (409)", async () => { + const { fetchImpl } = recorder(() => res(409, { error: { id: "conflict" } })); + await assert.rejects( + createKratosAdmin({ baseUrl: BASE, fetchImpl }).createIdentity({}), + (e: unknown) => e instanceof KratosError && e.status === 409, + ); +}); + +test("getIdentity reads /admin/identities/ → identity on 200, null on 404", async () => { + const identity = { id: ID, traits: { email: "a@b" } }; + const { calls, fetchImpl } = recorder((url) => (url.endsWith(ID) ? res(200, identity) : res(404))); + const admin = createKratosAdmin({ baseUrl: BASE, fetchImpl }); + assert.deepEqual(await admin.getIdentity(ID), identity); + assert.match(calls[0]!.url, new RegExp(`/admin/identities/${ID}$`)); + assert.equal(await createKratosAdmin({ baseUrl: BASE, fetchImpl: (async () => res(404)) as typeof fetch }).getIdentity("missing"), null); +}); + +test("listIdentities builds the query (filter/ids/pagination) and parses next page_token from the Link header", async () => { + const identities = [{ id: ID }]; + const link = `; rel="next",; rel="first"`; + const { calls, fetchImpl } = recorder(() => res(200, identities, { link })); + const out = await createKratosAdmin({ baseUrl: BASE, fetchImpl }).listIdentities({ + credentialsIdentifier: "a@b", + ids: ["x", "y"], + pageSize: 2, + pageToken: "CUR", + }); + assert.deepEqual(out.identities, identities); + assert.equal(out.nextPageToken, "NEXT"); + const url = calls[0]!.url; + assert.match(url, /credentials_identifier=a%40b/); + assert.match(url, /ids=x&ids=y/); + assert.match(url, /page_size=2/); + assert.match(url, /page_token=CUR/); +}); + +test("listIdentities reports a null next token when there is no Link header", async () => { + const { fetchImpl } = recorder(() => res(200, [])); + assert.equal((await createKratosAdmin({ baseUrl: BASE, fetchImpl }).listIdentities()).nextPageToken, null); +}); + +test("updateIdentity PUTs the full body to /admin/identities/ and returns the updated identity", async () => { + const identity = { id: ID, state: "inactive" }; + const { calls, fetchImpl } = recorder(() => res(200, identity)); + const body = { schema_id: "default", state: "inactive", traits: { email: "a@b" } }; + const out = await createKratosAdmin({ baseUrl: BASE, fetchImpl }).updateIdentity(ID, body); + assert.deepEqual(out, identity); + assert.equal(calls[0]!.method, "PUT"); + assert.match(calls[0]!.url, new RegExp(`/admin/identities/${ID}$`)); + assert.equal(calls[0]!.body, JSON.stringify(body)); +}); + +test("updateMetadataAdmin PATCHes a JSON-Patch `add /metadata_admin` so it never clobbers traits", async () => { + const identity = { id: ID, metadata_admin: { roles: ["admin"] } }; + const { calls, fetchImpl } = recorder(() => res(200, identity)); + const out = await createKratosAdmin({ baseUrl: BASE, fetchImpl }).updateMetadataAdmin(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"] } }]); +}); + +test("deleteIdentity DELETEs by id (204 resolves; non-204 throws a KratosError)", async () => { + const { calls, fetchImpl } = recorder(() => res(204)); + await createKratosAdmin({ baseUrl: BASE, fetchImpl }).deleteIdentity(ID); + assert.equal(calls[0]!.method, "DELETE"); + assert.match(calls[0]!.url, new RegExp(`/admin/identities/${ID}$`)); + await assert.rejects( + createKratosAdmin({ baseUrl: BASE, fetchImpl: (async () => res(404)) as typeof fetch }).deleteIdentity("missing"), + (e: unknown) => e instanceof KratosError && e.status === 404, + ); +}); diff --git a/src/kratos-admin.ts b/src/kratos-admin.ts new file mode 100644 index 0000000..389f8ba --- /dev/null +++ b/src/kratos-admin.ts @@ -0,0 +1,100 @@ +// Kratos admin-API client (todo §4): typed `fetch` wrappers over Ory Kratos' admin +// endpoints — identity CRUD and the surgical `metadata_admin` 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. +import { KratosError } from "./kratos-public.ts"; + +export interface Identity { + id: string; + metadata_admin?: unknown; + metadata_public?: unknown; + schema_id?: string; + state?: string; + traits?: Record; +} + +export interface IdentityList { + identities: Identity[]; + nextPageToken: string | null; // keyset cursor for the next page; null on the last page +} + +export interface ListOptions { + credentialsIdentifier?: string; // exact-match filter on a login identifier (e.g. email) + ids?: string[]; + pageSize?: number; + pageToken?: string; +} + +export interface KratosAdmin { + createIdentity(payload: unknown): Promise; + deleteIdentity(id: string): Promise; + getIdentity(id: string): Promise; + listIdentities(opts?: ListOptions): Promise; + updateIdentity(id: string, payload: unknown): Promise; + updateMetadataAdmin(id: string, metadata: unknown): Promise; +} + +// Kratos paginates with a Link header; pull the page_token of rel="next" (the href is a +// relative path, so resolve it against a throwaway base just to read the query param). +function nextPageToken(link: string | null): string | null { + const href = link?.match(/<([^>]+)>\s*;\s*rel="next"/)?.[1]; + return href ? new URL(href, "http://kratos").searchParams.get("page_token") : null; +} + +export function createKratosAdmin(config: { baseUrl: string; fetchImpl?: typeof fetch }): KratosAdmin { + const base = config.baseUrl.replace(/\/+$/, ""); + const http = config.fetchImpl ?? fetch; + const json = { "content-type": "application/json" }; + const identity = (id: string) => `${base}/admin/identities/${encodeURIComponent(id)}`; + + async function fail(action: string, res: Response): Promise { + throw new KratosError(`Kratos admin ${action} failed (${res.status})`, res.status, await res.text()); + } + + return { + async createIdentity(payload) { + const res = await http(`${base}/admin/identities`, { body: JSON.stringify(payload), headers: json, method: "POST" }); + if (res.status !== 201) return fail("create identity", res); + return (await res.json()) as Identity; + }, + + async deleteIdentity(id) { + const res = await http(identity(id), { method: "DELETE" }); + if (res.status !== 204) await fail("delete identity", res); + }, + + async getIdentity(id) { + const res = await http(identity(id)); + if (res.status === 404) return null; + if (res.status !== 200) return fail("get identity", res); + return (await res.json()) as Identity; + }, + + async listIdentities(opts = {}) { + const url = new URL(`${base}/admin/identities`); + if (opts.credentialsIdentifier) url.searchParams.set("credentials_identifier", opts.credentialsIdentifier); + for (const id of opts.ids ?? []) url.searchParams.append("ids", id); + if (opts.pageSize !== undefined) url.searchParams.set("page_size", String(opts.pageSize)); + if (opts.pageToken) url.searchParams.set("page_token", opts.pageToken); + const res = await http(url); + if (res.status !== 200) return fail("list identities", res); + return { identities: (await res.json()) as Identity[], nextPageToken: nextPageToken(res.headers.get("link")) }; + }, + + async updateIdentity(id, payload) { + const res = await http(identity(id), { body: JSON.stringify(payload), headers: json, method: "PUT" }); + if (res.status !== 200) return fail("update identity", res); + return (await res.json()) as Identity; + }, + + // JSON Patch `add` sets metadata_admin 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 }]; + const res = await http(identity(id), { body: JSON.stringify(patch), headers: json, method: "PATCH" }); + if (res.status !== 200) return fail("update metadata_admin", res); + return (await res.json()) as Identity; + }, + }; +} diff --git a/todo.md b/todo.md index 602e7ad..be5b8ab 100644 --- a/todo.md +++ b/todo.md @@ -76,7 +76,7 @@ everything via Docker. ## 4. Auth — identity, session JWT, guards - [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. +- [x] Kratos admin client (fetch): identity CRUD + `metadata_admin` update. → `src/kratos-admin.ts` (`createKratosAdmin({baseUrl, fetchImpl})`): typed `fetch` wrappers over Kratos' admin API (admin port), no SDK, `fetchImpl`-injectable like `kratos-public.ts`; reuses that module's `KratosError` (carries `.status`). `createIdentity` (POST, 201), `getIdentity` (GET, 404⇒`null`), `listIdentities({credentialsIdentifier?, ids?, pageSize?, pageToken?})` → `{identities, nextPageToken}` (parses the keyset cursor from the `Link` rel="next" header for the §5 users list), `updateIdentity` (full PUT), `deleteIdentity` (DELETE, 204), and `updateMetadataAdmin` — the key login-completion method: `PATCH` JSON-Patch `add /metadata_admin` so it sets the roles projection whether the field is absent/null/set and never clobbers traits/state. Building block — no route/E2E yet (login completion §4 line 83 wires it; the projection feeds the tokenizer's `metadata_admin` mapper, §3). Tests-first (`kratos-admin.test.ts`, mock fetch: URLs/method/JSON-Patch body/query+pagination/Link parsing + 201/200/404/409 mapping). README **Layout** lists it. typecheck + 167 units green. - [ ] 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. - [ ] 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.