Add Kratos public-API fetch client (todo §4); createKratosPublic(): self-service flow init/get/submit, whoami, session→JWT tokenize
This commit is contained in:
@@ -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/<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/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-public.test.ts
Normal file
111
src/kratos-public.test.ts
Normal file
@@ -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/<type>/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);
|
||||
});
|
||||
139
src/kratos-public.ts
Normal file
139
src/kratos-public.ts
Normal file
@@ -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<string, unknown>;
|
||||
id: number;
|
||||
text: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface UiNode {
|
||||
attributes: Record<string, unknown>;
|
||||
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<string, unknown> };
|
||||
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<Flow>;
|
||||
initBrowserFlow(type: FlowType, opts?: { cookie?: string; returnTo?: string }): Promise<FlowInit>;
|
||||
submitFlow(action: string, opts: { body: string; contentType?: string; cookie?: string }): Promise<FlowSubmission>;
|
||||
whoami(opts?: { cookie?: string; tokenizeAs?: string }): Promise<Session | null>;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
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<string, string> {
|
||||
const h: Record<string, string> = { 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
2
todo.md
2
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/<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.
|
||||
- [ ] 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.
|
||||
|
||||
Reference in New Issue
Block a user