diff --git a/README.md b/README.md index 854787a..32e5d1e 100644 --- a/README.md +++ b/README.md @@ -493,12 +493,13 @@ mid-response, so container restarts are clean. ``` src/server.ts Entry point — starts the HTTP server (reads PORT, default 3000) -src/app.ts Request routing + EJS rendering +src/app.ts Request routing + EJS rendering (incl. the themed Kratos self-service routes, §4) 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/keto-client.ts createKetoClient(): Keto fetch client — check / list / expand relations (read API) + write / delete tuples (write API) (§4) +src/flow-view.ts buildFlowView(): Kratos self-service Flow → themed view model (fields, hidden csrf, buttons, tone-mapped messages) for views/auth.ejs (§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) @@ -514,7 +515,7 @@ src/discovery.ts discoverPlugins(): scan plugins/, import + validate each pl src/router.ts matchRoute()/allowedMethods()/isAuthorized(): map method+path → plugin route, params, permission gate (§2) src/view-resolver.ts renderPluginView(): render plugins//views/.ejs; plugin views can include() core partials (§2) src/menu-config.ts loadMenuConfig()/defineMenu(): read config/menu.ts (central override + branding), validated at boot (§2) -views/ Core EJS templates (index = the app-shell People dashboard, 403/404/500, partials/ incl. app shell, nav tree, filter bar, data table, pagination, form field, auth card, menu/popover, theme switch, icon sprite) +views/ Core EJS templates (index = the app-shell People dashboard, auth = themed Kratos self-service page, 403/404/500, partials/ incl. app shell, nav tree, filter bar, data table, pagination, form field, auth card, alert, flow body, menu/popover, theme switch, icon sprite) public/ Static assets under /public/ (css/styles.css + auth.css, favicon, robots.txt) config/menu.ts Central menu override + branding (optional; defaults apply if absent) ory/ Ory service config (kratos/: identity schema, kratos.yml, oidc/ SSO claims mapper, tokenizer/ session→JWT claims mapper + dev signing JWKS; keto/: keto.yml + namespaces.keto.ts OPL — role/group/resource; hydra/hydra.yml: OAuth2 issuer + login/consent URLs) + storage init (postgres/init/init.sql: one DB per service) diff --git a/public/css/styles.css b/public/css/styles.css index b8e3c5d..2898c79 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -592,6 +592,20 @@ th[aria-sort="descending"] .sort-ico { transform: rotate(180deg); } .badge.warn { color: var(--warn); background: var(--warn-bg); border-color: var(--warn-bd); } .badge.info { color: var(--info); background: var(--info-bg); border-color: var(--info-bd); } +/* alert / notice banner (tone tokens) — auth flows + admin screens */ +.alert { + display: flex; gap: 8px; align-items: flex-start; + padding: 10px 12px; border-radius: var(--radius); + border: 1px solid var(--border); font-size: var(--fz-sm); +} +.alert > .ico { flex: 0 0 auto; margin-top: 1px; } +.alert-body { display: flex; flex-direction: column; gap: 2px; } +.alert-body strong { font-weight: 600; } +.alert.alert-pos { color: var(--pos); background: var(--pos-bg); border-color: var(--pos-bd); } +.alert.alert-neg { color: var(--neg); background: var(--neg-bg); border-color: var(--neg-bd); } +.alert.alert-warn { color: var(--warn); background: var(--warn-bg); border-color: var(--warn-bd); } +.alert.alert-info { color: var(--info); background: var(--info-bg); border-color: var(--info-bd); } + /* row kebab */ .col-actions { width: 44px; text-align: center; } .kebab summary { width: 26px; height: 26px; border-radius: var(--radius); diff --git a/src/app.test.ts b/src/app.test.ts index 1e26154..bcd1129 100644 --- a/src/app.test.ts +++ b/src/app.test.ts @@ -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, 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((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((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, /
/); + assert.match(html, /name="identifier"/); + assert.match(html, /name="password"[^>]*type="password"/); + assert.match(html, / +<% }) -%>