Add Kratos admin-API fetch client (todo §4); createKratosAdmin(): identity CRUD + surgical metadata_admin update (login role projection)
This commit is contained in:
@@ -497,6 +497,7 @@ src/app.ts Request routing + EJS rendering
|
||||
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/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)
|
||||
|
||||
111
src/kratos-admin.test.ts
Normal file
111
src/kratos-admin.test.ts
Normal file
@@ -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<string, string> = {}): 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/<id> → 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 = `</admin/identities?page_size=2&page_token=NEXT>; rel="next",</admin/identities?page_size=2>; 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/<id> 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,
|
||||
);
|
||||
});
|
||||
100
src/kratos-admin.ts
Normal file
100
src/kratos-admin.ts
Normal file
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
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<Identity>;
|
||||
deleteIdentity(id: string): Promise<void>;
|
||||
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>;
|
||||
}
|
||||
|
||||
// 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<never> {
|
||||
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;
|
||||
},
|
||||
};
|
||||
}
|
||||
2
todo.md
2
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/<type>/browser` with `Accept: json` (so Kratos returns the flow + CSRF `Set-Cookie` to relay, not a redirect); `getFlow(type, id, {cookie?})` reads `/self-service/<type>/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.
|
||||
|
||||
Reference in New Issue
Block a user