Render Kratos self-service flows as themed pages (todo §4); buildFlowView + views/auth.ejs + login/registration/recovery/verification/settings routes

This commit is contained in:
2026-06-17 17:55:56 +02:00
parent 2a64cfd409
commit 0928f9dd39
11 changed files with 405 additions and 4 deletions

View File

@@ -6,6 +6,7 @@ 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 { Plugin } from "./plugin.ts";
import { contentTypeFor, resolveStaticPath, routePublic } from "./static.ts";
@@ -199,6 +200,67 @@ test("plugin hooks: onRequest can short-circuit a request and onResponse observe
assert.ok(seen.includes("/hooked/ok:handler ran"));
});
// A re-rendered login flow: csrf hidden, themed fields, a submit, and a failed-attempt message.
const node = (attrs: Record<string, unknown>, label?: string): UiNode => ({ attributes: attrs, group: "default", messages: [], meta: label ? { label: { id: 1, text: label, type: "info" } } : {}, type: "input" });
const loginFlow = (id: string): Flow => ({
id,
ui: {
action: `http://127.0.0.1:4433/self-service/login?flow=${id}`,
messages: [{ id: 4000006, text: "The provided credentials are invalid.", type: "error" }],
method: "post",
nodes: [
node({ name: "csrf_token", type: "hidden", value: "tok" }),
node({ name: "identifier", required: true, type: "email" }, "E-Mail"),
node({ name: "password", required: true, type: "password" }, "Password"),
node({ name: "method", type: "submit", value: "password" }, "Sign in"),
],
},
});
function mockKratos(getFlow: KratosPublic["getFlow"]): KratosPublic {
return {
getFlow,
initBrowserFlow: async (_t: FlowType) => ({ flow: { id: "new1", ui: { action: "", method: "post", nodes: [] } }, setCookie: ["csrf_token=abc; Path=/; HttpOnly"] }),
submitFlow: async () => { throw new Error("unused"); },
whoami: async () => null,
};
}
test("themed flow init: no ?flow= initialises one, relays Kratos' CSRF cookie, and an expired flow restarts", async (t) => {
const app = createApp({ kratos: mockKratos(async (_t, id) => { if (id === "stale") throw new KratosError("gone", 410, ""); return loginFlow(id); }) });
await new Promise<void>((r) => app.listen(0, r));
t.after(() => app.close());
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
const init = await fetch(url + "/login", { redirect: "manual" });
assert.equal(init.status, 303);
assert.equal(init.headers.get("location"), "/login?flow=new1");
assert.match(init.headers.get("set-cookie") ?? "", /csrf_token=abc/);
// A stale flow id (Kratos 410) bounces back to a fresh init.
const stale = await fetch(url + "/login?flow=stale", { redirect: "manual" });
assert.equal(stale.status, 303);
assert.equal(stale.headers.get("location"), "/login");
});
test("renders a fetched flow as the themed auth page: fields post straight to Kratos, errors surface", async (t) => {
const app = createApp({ kratos: mockKratos(async (_t, id) => loginFlow(id)) });
await new Promise<void>((r) => app.listen(0, r));
t.after(() => app.close());
const html = await (await fetch(`http://localhost:${(app.address() as AddressInfo).port}/login?flow=f1`)).text();
// The form posts to flow.ui.action (Kratos owns CSRF); csrf rides as a hidden input.
assert.match(html, /<form class="auth-card" method="post" action="http:\/\/127\.0\.0\.1:4433\/self-service\/login\?flow=f1"/);
assert.match(html, /<input type="hidden" name="csrf_token" value="tok">/);
assert.match(html, /name="identifier"/);
assert.match(html, /name="password"[^>]*type="password"/);
assert.match(html, /<button type="submit"[^>]*name="method" value="password">Sign in<\/button>/);
assert.match(html, /<a href="\/registration">Create one<\/a>/); // alt link to register
// The flow-level error renders as an alert.
assert.match(html, /class="alert alert-neg"/);
assert.match(html, /The provided credentials are invalid\./);
});
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);

View File

@@ -5,7 +5,9 @@ import * as ejs from "ejs";
import { buildContext } from "./context.ts";
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 { KratosError, type KratosPublic } from "./kratos-public.ts";
import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts";
import type { Plugin, RouteResult } from "./plugin.ts";
import { allowedMethods, isAuthorized, matchRoute } from "./router.ts";
@@ -18,6 +20,7 @@ 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;
kratos?: KratosPublic; // Kratos public client; enables the themed self-service routes (§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/
@@ -27,6 +30,7 @@ export interface AppOptions {
export function createApp(options: AppOptions = {}): Server {
const cache = options.cache ?? false;
const kratos = options.kratos;
const menu = options.menu ?? DEFAULT_MENU;
const plugins = options.plugins ?? [];
const pluginIds = new Set(plugins.map((p) => p.id));
@@ -85,6 +89,29 @@ export function createApp(options: AppOptions = {}): Server {
return;
}
// Themed Kratos self-service pages (login/registration/recovery/verification/settings).
const flowType = AUTH_FLOWS[pathname];
if (kratos && flowType && (method === "GET" || method === "HEAD")) {
const cookie = req.headers.cookie;
const flowId = ctx.url.searchParams.get("flow");
if (!flowId) {
// No flow yet: init one server-side, relay Kratos' CSRF cookie, bounce to ?flow=<id>.
const { flow, setCookie } = await kratos.initBrowserFlow(flowType, cookie ? { cookie } : {});
res.writeHead(303, { location: `${pathname}?flow=${flow.id}`, ...(setCookie.length ? { "set-cookie": setCookie } : {}) }).end();
return;
}
try {
const flow = await kratos.getFlow(flowType, flowId, cookie ? { cookie } : {});
sendHtml(res, 200, await render("auth", { brand: menu.branding.name, flow: buildFlowView(flow, flowType) }));
} catch (err) {
// Expired/unknown flow → restart by re-initialising (drop the stale ?flow=).
if (err instanceof KratosError && [403, 404, 410].includes(err.status)) {
res.writeHead(303, { location: pathname }).end();
} else throw err;
}
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) }));

103
src/flow-view.test.ts Normal file
View File

@@ -0,0 +1,103 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { AUTH_FLOWS, buildFlowView } from "./flow-view.ts";
import type { Flow, UiNode } from "./kratos-public.ts";
// Concise UiNode builder mirroring Kratos' shape.
function node(attrs: Record<string, unknown>, opts: { group?: string; label?: string; error?: string } = {}): UiNode {
return {
attributes: attrs,
group: opts.group ?? "default",
messages: opts.error ? [{ id: 4000002, text: opts.error, type: "error" }] : [],
meta: opts.label ? { label: { id: 1, text: opts.label, type: "info" } } : {},
type: "input",
};
}
function flow(nodes: UiNode[], extra: Partial<Flow["ui"]> = {}): Flow {
return { id: "f1", ui: { action: "http://127.0.0.1:4433/self-service/login?flow=f1", method: "post", nodes, ...extra } };
}
test("maps a password login flow: csrf hidden, themed email/password fields, a submit button + chrome", () => {
const view = buildFlowView(
flow([
node({ name: "csrf_token", type: "hidden", value: "tok123" }),
node({ name: "identifier", type: "email", required: true, autocomplete: "username", value: "" }, { label: "E-Mail", group: "password" }),
node({ name: "password", type: "password", required: true, autocomplete: "current-password" }, { label: "Password", group: "password" }),
node({ name: "method", type: "submit", value: "password" }, { label: "Sign in", group: "password" }),
]),
"login",
);
// Form posts straight to Kratos (it owns CSRF); csrf travels as a hidden input.
assert.equal(view.action, "http://127.0.0.1:4433/self-service/login?flow=f1");
assert.equal(view.method, "post");
assert.deepEqual(view.hidden, [{ name: "csrf_token", value: "tok123" }]);
// Visible fields carry label, type, required, autocomplete + a themed input icon.
assert.equal(view.fields.length, 2);
assert.deepEqual(view.fields[0], { autocomplete: "username", icon: "i-mail", id: "field-identifier", label: "E-Mail", name: "identifier", required: true, type: "email" });
assert.equal(view.fields[1]?.icon, "i-lock");
assert.equal(view.fields[1]?.type, "password");
// One submit button carrying its method name/value.
assert.deepEqual(view.buttons, [{ label: "Sign in", name: "method", value: "password" }]);
// Chrome derived from the flow type.
assert.equal(view.title, "Sign in");
assert.equal(view.alt?.href, "/registration");
assert.equal(view.messages.length, 0);
});
test("maps field errors and flow-level messages by tone", () => {
const view = buildFlowView(
flow(
[
node({ name: "identifier", type: "email", value: "taken@example.com" }, { label: "E-Mail", error: "This email is already in use." }),
node({ name: "method", type: "submit", value: "password" }, { label: "Sign in" }),
],
{ messages: [{ id: 4000006, text: "The provided credentials are invalid.", type: "error" }, { id: 1, text: "Check your email.", type: "info" }] },
),
"login",
);
// Submitted value is preserved; the node's error rides on the field.
assert.equal(view.fields[0]?.value, "taken@example.com");
assert.deepEqual(view.fields[0]?.error, { text: "This email is already in use." });
// Flow messages map error→neg, info→info (success→pos covered by the tone map).
assert.deepEqual(view.messages, [
{ text: "The provided credentials are invalid.", tone: "neg" },
{ text: "Check your email.", tone: "info" },
]);
});
test("skips oidc (SSO) nodes but keeps the default-group csrf — SSO buttons are a later item", () => {
const view = buildFlowView(
flow([
node({ name: "csrf_token", type: "hidden", value: "tok" }),
node({ name: "provider", type: "submit", value: "google" }, { label: "Sign in with Google", group: "oidc" }),
node({ name: "method", type: "submit", value: "password" }, { label: "Sign in", group: "password" }),
]),
"login",
);
assert.deepEqual(view.hidden, [{ name: "csrf_token", value: "tok" }]);
assert.deepEqual(view.buttons, [{ label: "Sign in", name: "method", value: "password" }]);
});
test("chrome varies per flow type: registration alt, recovery back link", () => {
const reg = buildFlowView(flow([]), "registration");
assert.equal(reg.title, "Create account");
assert.equal(reg.alt?.href, "/login");
const rec = buildFlowView(flow([]), "recovery");
assert.equal(rec.back?.href, "/login");
});
test("AUTH_FLOWS maps each themed path to its Kratos flow type", () => {
assert.equal(AUTH_FLOWS["/login"], "login");
assert.equal(AUTH_FLOWS["/registration"], "registration");
assert.equal(AUTH_FLOWS["/recovery"], "recovery");
assert.equal(AUTH_FLOWS["/verification"], "verification");
assert.equal(AUTH_FLOWS["/settings"], "settings");
});

128
src/flow-view.ts Normal file
View File

@@ -0,0 +1,128 @@
// Kratos flow → themed view model (todo §4). Pure: turns a fetched self-service Flow
// (src/kratos-public.ts) into the data views/auth.ejs renders — hidden inputs (incl. the
// CSRF token), themed fields, submit buttons, and tone-mapped messages. The form posts
// straight back to `flow.ui.action`, so Kratos owns its CSRF; we only render and map errors.
// SSO/oidc buttons are skipped here — they're derived per provider in the next §4 item.
import type { Flow, FlowType, UiNode } from "./kratos-public.ts";
export interface FlowField {
autocomplete?: string;
error?: { text: string };
icon?: string; // Lucide sprite id for the input
id: string;
label: string;
name: string;
required?: boolean;
type: string;
value?: string;
}
export interface FlowButton {
label: string;
name?: string;
value?: string;
}
export interface FlowMessage {
text: string;
tone: "info" | "neg" | "pos" | "warn";
}
interface FlowChrome {
alt?: { href: string; label: string; text: string };
back?: { href: string; label: string };
sub?: string;
title: string;
}
export interface FlowView extends FlowChrome {
action: string;
buttons: FlowButton[];
fields: FlowField[];
hidden: { name: string; value: string }[];
messages: FlowMessage[];
method: string;
}
// Themed route → Kratos flow type. The routes mirror kratos.yml's flow ui_urls.
export const AUTH_FLOWS: Record<string, FlowType> = {
"/login": "login",
"/recovery": "recovery",
"/registration": "registration",
"/settings": "settings",
"/verification": "verification",
};
const CHROME: Record<FlowType, FlowChrome> = {
login: { alt: { href: "/registration", label: "Create one", text: "Don't have an account?" }, sub: "Welcome back. Enter your details to continue.", title: "Sign in" },
recovery: { alt: { href: "/login", label: "Sign in", text: "Remembered it?" }, back: { href: "/login", label: "Back to sign in" }, sub: "Enter your email and we'll send you a recovery code.", title: "Reset password" },
registration: { alt: { href: "/login", label: "Sign in", text: "Already have an account?" }, sub: "Get started — it only takes a minute.", title: "Create account" },
settings: { sub: "Update your account details.", title: "Account settings" },
verification: { back: { href: "/login", label: "Back to sign in" }, sub: "Enter the code we sent you.", title: "Verify your email" },
};
const str = (v: unknown): string | undefined => (typeof v === "string" ? v : undefined);
// Themed input icon by field semantics; undefined ⇒ no icon.
function iconFor(name: string, type: string): string | undefined {
if (type === "email" || name === "identifier" || name.endsWith(".email")) return "i-mail";
if (type === "password") return "i-lock";
if (name.includes("name")) return "i-user";
if (name === "code") return "i-shield";
return undefined;
}
function tone(type: string): FlowMessage["tone"] {
if (type === "error") return "neg";
if (type === "success") return "pos";
return "info";
}
function toField(node: UiNode, name: string, type: string): FlowField {
const value = str(node.attributes["value"]);
const autocomplete = str(node.attributes["autocomplete"]);
const icon = iconFor(name, type);
const errorMsg = node.messages.find((m) => m.type === "error");
return {
id: "field-" + name.replace(/[^a-z0-9]+/gi, "-"),
label: node.meta.label?.text ?? name,
name,
type,
...(autocomplete ? { autocomplete } : {}),
...(errorMsg ? { error: { text: errorMsg.text } } : {}),
...(icon ? { icon } : {}),
...(node.attributes["required"] === true ? { required: true } : {}),
...(value ? { value } : {}),
};
}
export function buildFlowView(flow: Flow, type: FlowType): FlowView {
const hidden: { name: string; value: string }[] = [];
const fields: FlowField[] = [];
const buttons: FlowButton[] = [];
for (const node of flow.ui.nodes) {
if (node.type !== "input" || node.group === "oidc") continue; // SSO buttons: next §4 item
const name = str(node.attributes["name"]) ?? "";
const inputType = str(node.attributes["type"]) ?? "text";
if (inputType === "hidden") {
hidden.push({ name, value: str(node.attributes["value"]) ?? "" });
} else if (inputType === "submit" || inputType === "button") {
const value = str(node.attributes["value"]);
buttons.push({ label: node.meta.label?.text ?? "Continue", ...(name ? { name } : {}), ...(value != null ? { value } : {}) });
} else {
fields.push(toField(node, name, inputType));
}
}
return {
action: flow.ui.action,
buttons,
fields,
hidden,
messages: (flow.ui.messages ?? []).map((m) => ({ text: m.text, tone: tone(m.type) })),
method: flow.ui.method || "post",
...CHROME[type],
};
}

View File

@@ -2,16 +2,18 @@ import { createApp } from "./app.ts";
import { loadConfig } from "./config.ts";
import { discoverPlugins } from "./discovery.ts";
import { runBootHooks } from "./hooks.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)
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, menu, plugins }).listen(config.port, () => {
const server = createApp({ cache: config.cacheTemplates, kratos, menu, plugins }).listen(config.port, () => {
console.log(`Listening on http://localhost:${config.port}`);
});