Login completion (todo §4); /auth/complete: roles from Keto → metadata_public projection → tokenize → plainpages_jwt cookie; fix tokenizer projection metadata_admin→metadata_public (whoami strips admin metadata)
This commit is contained in:
13
README.md
13
README.md
@@ -407,10 +407,12 @@ session cookie.
|
|||||||
(e.g. `role:admin#members@user:alice`); the admin screens write them *only* to Keto.
|
(e.g. `role:admin#members@user:alice`); the admin screens write them *only* to Keto.
|
||||||
But the tokenizer's claims mapper can read only the **identity**, not call Keto — so at
|
But the tokenizer's claims mapper can read only the **identity**, not call Keto — so at
|
||||||
login the app reads the roles from Keto and refreshes a **derived projection**: a
|
login the app reads the roles from Keto and refreshes a **derived projection**: a
|
||||||
read-only copy written onto the identity's `metadata_admin` for the tokenizer to see,
|
read-only copy written onto the identity's `metadata_public` for the tokenizer to see,
|
||||||
which the template maps into the JWT `roles` claim. That projection is a per-login
|
which the template maps into the JWT `roles` claim. (It must be `metadata_public`, not
|
||||||
cache, authoritative nowhere; nothing edits it by hand, and a stale one self-heals on
|
`metadata_admin`: the session Kratos hands the tokenizer carries only *public* metadata —
|
||||||
the next login.
|
and the user can already read these coarse roles in their own JWT, so nothing is leaked.)
|
||||||
|
That projection is a per-login cache, authoritative nowhere; nothing edits it by hand, and
|
||||||
|
a stale one self-heals on the next login.
|
||||||
|
|
||||||
Cost: **one Keto read + one identity refresh per login** — never per request. JWKS
|
Cost: **one Keto read + one identity refresh per login** — never per request. JWKS
|
||||||
is cached, so even signature verification hits the network only on key rotation. The
|
is cached, so even signature verification hits the network only on key rotation. The
|
||||||
@@ -499,9 +501,10 @@ src/app.ts Request routing + EJS rendering (incl. the themed Kratos se
|
|||||||
src/static.ts Static file serving (path-traversal protection) + routePublic(): /public/<id>/ → a plugin's public/
|
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/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-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/kratos-admin.ts createKratosAdmin(): Kratos admin-API fetch client — identity CRUD + surgical metadata_public 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/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/flow-view.ts buildFlowView(): Kratos self-service Flow → themed view model (fields, hidden csrf, buttons, tone-mapped messages) for views/auth.ejs (§4)
|
||||||
|
src/login.ts completeLogin(): /auth/complete login completion — roles from Keto → metadata_public projection → tokenize → session JWT cookie (§4)
|
||||||
src/gen-jwks.ts generateJwks() + CLI: mint the ES256 session-tokenizer signing JWKS (§3); see JWT signing key & rotation
|
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/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)
|
src/cookie.ts Cookie parse + secure Set-Cookie build (session/CSRF cookies, §4)
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ selfservice:
|
|||||||
ui_url: http://127.0.0.1:3000/error
|
ui_url: http://127.0.0.1:3000/error
|
||||||
login:
|
login:
|
||||||
ui_url: http://127.0.0.1:3000/login
|
ui_url: http://127.0.0.1:3000/login
|
||||||
|
after:
|
||||||
|
# After authenticating, land on our completion route — it mints the session JWT
|
||||||
|
# (roles from Keto → metadata_public projection → tokenize) and sets our cookie (§4).
|
||||||
|
default_browser_return_url: http://127.0.0.1:3000/auth/complete
|
||||||
registration:
|
registration:
|
||||||
ui_url: http://127.0.0.1:3000/registration
|
ui_url: http://127.0.0.1:3000/registration
|
||||||
after:
|
after:
|
||||||
@@ -88,7 +92,7 @@ session:
|
|||||||
# Session→JWT tokenizer (§4): whoami(tokenize_as: plainpages) mints a short-lived,
|
# Session→JWT tokenizer (§4): whoami(tokenize_as: plainpages) mints a short-lived,
|
||||||
# locally-verifiable JWT so the hot path never calls Ory. Claims come from the
|
# locally-verifiable JWT so the hot path never calls Ory. Claims come from the
|
||||||
# committed Jsonnet mapper (sub = identity id, email from traits, roles from the
|
# committed Jsonnet mapper (sub = identity id, email from traits, roles from the
|
||||||
# metadata_admin projection); signed with tokenizer/jwks.json.
|
# metadata_public projection); signed with tokenizer/jwks.json.
|
||||||
whoami:
|
whoami:
|
||||||
tokenizer:
|
tokenizer:
|
||||||
templates:
|
templates:
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
// Session→JWT claims mapper for the `plainpages` tokenizer (§4). Kratos exposes the
|
// Session→JWT claims mapper for the `plainpages` tokenizer (§4). Kratos exposes the
|
||||||
// session as `session`; `sub` is set from the identity id (subject_source: id) and
|
// session as `session`; `sub` is set from the identity id (subject_source: id) and
|
||||||
// can't be overridden here. roles come from metadata_admin — the per-login projection
|
// can't be overridden here. roles come from metadata_public — the per-login projection
|
||||||
// of Keto roles the app refreshes at login; absent on a fresh identity ⇒ empty list.
|
// of Keto roles the app refreshes at login (metadata_admin is NOT carried in the session
|
||||||
|
// the tokenizer sees; metadata_public is). Absent on a fresh identity ⇒ empty list.
|
||||||
local session = std.extVar('session');
|
local session = std.extVar('session');
|
||||||
local meta =
|
local meta =
|
||||||
if std.objectHas(session.identity, 'metadata_admin') && session.identity.metadata_admin != null
|
if std.objectHas(session.identity, 'metadata_public') && session.identity.metadata_public != null
|
||||||
then session.identity.metadata_admin
|
then session.identity.metadata_public
|
||||||
else {};
|
else {};
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import { dirname, join } from "node:path";
|
|||||||
import { after, before, test, type TestContext } from "node:test";
|
import { after, before, test, type TestContext } from "node:test";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { createApp } from "./app.ts";
|
import { createApp } from "./app.ts";
|
||||||
import { KratosError, type Flow, type FlowType, type KratosPublic, type UiNode } from "./kratos-public.ts";
|
import type { KetoClient } from "./keto-client.ts";
|
||||||
|
import type { Identity, KratosAdmin } from "./kratos-admin.ts";
|
||||||
|
import { KratosError, type Flow, type FlowType, type KratosPublic, type Session, type UiNode } from "./kratos-public.ts";
|
||||||
import type { Plugin } from "./plugin.ts";
|
import type { Plugin } from "./plugin.ts";
|
||||||
import { contentTypeFor, resolveStaticPath, routePublic } from "./static.ts";
|
import { contentTypeFor, resolveStaticPath, routePublic } from "./static.ts";
|
||||||
|
|
||||||
@@ -265,6 +267,55 @@ test("renders a fetched flow as the themed auth page: fields post straight to Kr
|
|||||||
assert.match(html, /The provided credentials are invalid\./);
|
assert.match(html, /The provided credentials are invalid\./);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Login completion (§4): /auth/complete is where Kratos lands the browser after login.
|
||||||
|
const stubAdmin = (over: Partial<KratosAdmin>): KratosAdmin => ({
|
||||||
|
createIdentity: async () => { throw new Error("unused"); },
|
||||||
|
deleteIdentity: async () => {},
|
||||||
|
getIdentity: async () => null,
|
||||||
|
listIdentities: async () => ({ identities: [], nextPageToken: null }),
|
||||||
|
updateIdentity: async () => { throw new Error("unused"); },
|
||||||
|
updateMetadataPublic: async () => ({ id: "x" }),
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
const stubKeto = (over: Partial<KetoClient>): KetoClient => ({
|
||||||
|
check: async () => false,
|
||||||
|
deleteTuple: async () => {},
|
||||||
|
expand: async () => ({ type: "leaf" }),
|
||||||
|
listRelations: async () => ({ nextPageToken: null, tuples: [] }),
|
||||||
|
writeTuple: async () => {},
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
const withWhoami = (whoami: KratosPublic["whoami"]): KratosPublic => ({ ...mockKratos(async () => { throw new Error("unused"); }), whoami });
|
||||||
|
|
||||||
|
test("login completion: mints the session JWT (roles from Keto → projection → tokenize) and sets the cookie", async (t) => {
|
||||||
|
const identity: Identity = { id: "01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b55", traits: { email: "admin@plainpages.local" } };
|
||||||
|
let projected: unknown;
|
||||||
|
const kratos = withWhoami(async (o) => (o?.tokenizeAs ? { active: true, identity, tokenized: "h.p.s" } : { active: true, identity }) as Session);
|
||||||
|
const kratosAdmin = stubAdmin({ updateMetadataPublic: async (_id, meta) => { projected = meta; return identity; } });
|
||||||
|
const keto = stubKeto({ listRelations: async () => ({ nextPageToken: null, tuples: [{ namespace: "Role", object: "admin", relation: "members", subject_id: `user:${identity.id}` }] }) });
|
||||||
|
|
||||||
|
const app = createApp({ keto, kratos, kratosAdmin });
|
||||||
|
await new Promise<void>((r) => app.listen(0, r));
|
||||||
|
t.after(() => app.close());
|
||||||
|
const res = await fetch(`http://localhost:${(app.address() as AddressInfo).port}/auth/complete`, { headers: { cookie: "plainpages_session=s" }, redirect: "manual" });
|
||||||
|
|
||||||
|
assert.equal(res.status, 303);
|
||||||
|
assert.equal(res.headers.get("location"), "/");
|
||||||
|
assert.match(res.headers.get("set-cookie") ?? "", /^plainpages_jwt=h\.p\.s;.*HttpOnly/);
|
||||||
|
assert.deepEqual(projected, { roles: ["admin"] }); // Keto roles projected onto the identity for the tokenizer
|
||||||
|
});
|
||||||
|
|
||||||
|
test("login completion with no Kratos session redirects to /login and sets no cookie", async (t) => {
|
||||||
|
const app = createApp({ keto: stubKeto({}), kratos: withWhoami(async () => null), kratosAdmin: stubAdmin({}) });
|
||||||
|
await new Promise<void>((r) => app.listen(0, r));
|
||||||
|
t.after(() => app.close());
|
||||||
|
const res = await fetch(`http://localhost:${(app.address() as AddressInfo).port}/auth/complete`, { redirect: "manual" });
|
||||||
|
|
||||||
|
assert.equal(res.status, 303);
|
||||||
|
assert.equal(res.headers.get("location"), "/login");
|
||||||
|
assert.equal(res.headers.get("set-cookie"), null);
|
||||||
|
});
|
||||||
|
|
||||||
test("resolveStaticPath blocks traversal and control chars, allows nested files", () => {
|
test("resolveStaticPath blocks traversal and control chars, allows nested files", () => {
|
||||||
assert.equal(resolveStaticPath("/srv/public", "../app.ts"), null);
|
assert.equal(resolveStaticPath("/srv/public", "../app.ts"), null);
|
||||||
assert.equal(resolveStaticPath("/srv/public", "a\x00b"), null);
|
assert.equal(resolveStaticPath("/srv/public", "a\x00b"), null);
|
||||||
|
|||||||
21
src/app.ts
21
src/app.ts
@@ -7,7 +7,10 @@ import { buildDashboardModel } from "./dashboard.ts";
|
|||||||
import { PLUGINS_DIR } from "./discovery.ts";
|
import { PLUGINS_DIR } from "./discovery.ts";
|
||||||
import { AUTH_FLOWS, buildFlowView } from "./flow-view.ts";
|
import { AUTH_FLOWS, buildFlowView } from "./flow-view.ts";
|
||||||
import { runRequestHooks, runResponseHooks } from "./hooks.ts";
|
import { runRequestHooks, runResponseHooks } from "./hooks.ts";
|
||||||
|
import type { KetoClient } from "./keto-client.ts";
|
||||||
|
import type { KratosAdmin } from "./kratos-admin.ts";
|
||||||
import { KratosError, type KratosPublic } from "./kratos-public.ts";
|
import { KratosError, type KratosPublic } from "./kratos-public.ts";
|
||||||
|
import { completeLogin, sessionCookie } from "./login.ts";
|
||||||
import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts";
|
import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts";
|
||||||
import type { Plugin, RouteResult } from "./plugin.ts";
|
import type { Plugin, RouteResult } from "./plugin.ts";
|
||||||
import { allowedMethods, isAuthorized, matchRoute } from "./router.ts";
|
import { allowedMethods, isAuthorized, matchRoute } from "./router.ts";
|
||||||
@@ -20,7 +23,9 @@ export interface AppOptions {
|
|||||||
// Cache compiled templates; caller decides (server passes config.cacheTemplates).
|
// Cache compiled templates; caller decides (server passes config.cacheTemplates).
|
||||||
// Off by default so edits show live; the app itself never inspects the environment.
|
// Off by default so edits show live; the app itself never inspects the environment.
|
||||||
cache?: boolean;
|
cache?: boolean;
|
||||||
|
keto?: KetoClient; // Keto client; with kratos+kratosAdmin enables login completion (§4)
|
||||||
kratos?: KratosPublic; // Kratos public client; enables the themed self-service routes (§4)
|
kratos?: KratosPublic; // Kratos public client; enables the themed self-service routes (§4)
|
||||||
|
kratosAdmin?: KratosAdmin; // Kratos admin client; with kratos+keto enables login completion (§4)
|
||||||
menu?: MenuConfig; // central override + branding (config/menu.ts); defaults to DEFAULT_MENU
|
menu?: MenuConfig; // central override + branding (config/menu.ts); defaults to DEFAULT_MENU
|
||||||
plugins?: Plugin[]; // discovered manifests to mount (router); empty until §2 discovery runs
|
plugins?: Plugin[]; // discovered manifests to mount (router); empty until §2 discovery runs
|
||||||
pluginsDir?: string; // where plugin views/static live; defaults to the scanned plugins/
|
pluginsDir?: string; // where plugin views/static live; defaults to the scanned plugins/
|
||||||
@@ -30,7 +35,9 @@ export interface AppOptions {
|
|||||||
|
|
||||||
export function createApp(options: AppOptions = {}): Server {
|
export function createApp(options: AppOptions = {}): Server {
|
||||||
const cache = options.cache ?? false;
|
const cache = options.cache ?? false;
|
||||||
|
const keto = options.keto;
|
||||||
const kratos = options.kratos;
|
const kratos = options.kratos;
|
||||||
|
const kratosAdmin = options.kratosAdmin;
|
||||||
const menu = options.menu ?? DEFAULT_MENU;
|
const menu = options.menu ?? DEFAULT_MENU;
|
||||||
const plugins = options.plugins ?? [];
|
const plugins = options.plugins ?? [];
|
||||||
const pluginIds = new Set(plugins.map((p) => p.id));
|
const pluginIds = new Set(plugins.map((p) => p.id));
|
||||||
@@ -112,6 +119,20 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Login completion: where Kratos lands the browser after authenticating (kratos.yml).
|
||||||
|
// Mint our session JWT — read roles from Keto, project onto the identity, tokenize —
|
||||||
|
// and store it as the cookie; no active session bounces back to sign in (§4).
|
||||||
|
if (pathname === "/auth/complete" && method === "GET" && kratos && kratosAdmin && keto) {
|
||||||
|
const completed = await completeLogin({ keto, kratosAdmin, kratosPublic: kratos }, req.headers.cookie);
|
||||||
|
if (!completed) {
|
||||||
|
res.writeHead(303, { location: "/login" }).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// secure: off in dev http; the §9 cookie hardening toggles it on for prod.
|
||||||
|
res.writeHead(303, { location: "/", "set-cookie": sessionCookie(completed.jwt) }).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (pathname === "/" && (method === "GET" || method === "HEAD")) {
|
if (pathname === "/" && (method === "GET" || method === "HEAD")) {
|
||||||
// Mock data + no roles until auth (§4) lands; branding/override come from config/menu.ts.
|
// 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) }));
|
sendHtml(res, 200, await render("index", { model: buildDashboardModel(ctx.url, [], menu) }));
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Kratos admin-API client (§4): typed fetch wrappers over Ory Kratos' admin endpoints —
|
// 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.
|
// identity CRUD + the surgical metadata_public update the login flow projects roles into.
|
||||||
// Guards the request contracts (URLs, method, JSON-Patch body, query/pagination) and the
|
// 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).
|
// result mapping (201/200/404/4xx). Live wiring is verified by login completion (§4).
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
@@ -89,14 +89,14 @@ test("updateIdentity PUTs the full body to /admin/identities/<id> and returns th
|
|||||||
assert.equal(calls[0]!.body, JSON.stringify(body));
|
assert.equal(calls[0]!.body, JSON.stringify(body));
|
||||||
});
|
});
|
||||||
|
|
||||||
test("updateMetadataAdmin PATCHes a JSON-Patch `add /metadata_admin` so it never clobbers traits", async () => {
|
test("updateMetadataPublic PATCHes a JSON-Patch `add /metadata_public` so it never clobbers traits", async () => {
|
||||||
const identity = { id: ID, metadata_admin: { roles: ["admin"] } };
|
const identity = { id: ID, metadata_public: { roles: ["admin"] } };
|
||||||
const { calls, fetchImpl } = recorder(() => res(200, identity));
|
const { calls, fetchImpl } = recorder(() => res(200, identity));
|
||||||
const out = await createKratosAdmin({ baseUrl: BASE, fetchImpl }).updateMetadataAdmin(ID, { roles: ["admin"] });
|
const out = await createKratosAdmin({ baseUrl: BASE, fetchImpl }).updateMetadataPublic(ID, { roles: ["admin"] });
|
||||||
assert.deepEqual(out, identity);
|
assert.deepEqual(out, identity);
|
||||||
assert.equal(calls[0]!.method, "PATCH");
|
assert.equal(calls[0]!.method, "PATCH");
|
||||||
assert.match(calls[0]!.url, new RegExp(`/admin/identities/${ID}$`));
|
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"] } }]);
|
assert.deepEqual(JSON.parse(calls[0]!.body!), [{ op: "add", path: "/metadata_public", value: { roles: ["admin"] } }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("deleteIdentity DELETEs by id (204 resolves; non-204 throws a KratosError)", async () => {
|
test("deleteIdentity DELETEs by id (204 resolves; non-204 throws a KratosError)", async () => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Kratos admin-API client (todo §4): typed `fetch` wrappers over Ory Kratos' admin
|
// Kratos admin-API client (todo §4): typed `fetch` wrappers over Ory Kratos' admin
|
||||||
// endpoints — identity CRUD and the surgical `metadata_admin` update login completion
|
// endpoints — identity CRUD and the surgical `metadata_public` update login completion
|
||||||
// projects Keto roles into (README). Built-in `fetch` only, no SDK dep (AGENTS.md);
|
// 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
|
// `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.
|
// caller can branch on `.status`. Admin endpoints listen on the internal-only admin port.
|
||||||
@@ -32,7 +32,7 @@ export interface KratosAdmin {
|
|||||||
getIdentity(id: string): Promise<Identity | null>;
|
getIdentity(id: string): Promise<Identity | null>;
|
||||||
listIdentities(opts?: ListOptions): Promise<IdentityList>;
|
listIdentities(opts?: ListOptions): Promise<IdentityList>;
|
||||||
updateIdentity(id: string, payload: unknown): Promise<Identity>;
|
updateIdentity(id: string, payload: unknown): Promise<Identity>;
|
||||||
updateMetadataAdmin(id: string, metadata: unknown): Promise<Identity>;
|
updateMetadataPublic(id: string, metadata: unknown): Promise<Identity>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kratos paginates with a Link header; pull the page_token of rel="next" (the href is a
|
// Kratos paginates with a Link header; pull the page_token of rel="next" (the href is a
|
||||||
@@ -88,12 +88,13 @@ export function createKratosAdmin(config: { baseUrl: string; fetchImpl?: typeof
|
|||||||
return (await res.json()) as Identity;
|
return (await res.json()) as Identity;
|
||||||
},
|
},
|
||||||
|
|
||||||
// JSON Patch `add` sets metadata_admin whether it's currently absent, null, or set, and
|
// JSON Patch `add` sets metadata_public whether it's currently absent, null, or set, and
|
||||||
// touches nothing else — so the login role projection never clobbers traits/state.
|
// touches nothing else — so the login role projection never clobbers traits/state.
|
||||||
async updateMetadataAdmin(id, metadata) {
|
// (metadata_public, not _admin: the session the tokenizer sees carries only public metadata.)
|
||||||
const patch = [{ op: "add", path: "/metadata_admin", value: metadata }];
|
async updateMetadataPublic(id, metadata) {
|
||||||
|
const patch = [{ op: "add", path: "/metadata_public", value: metadata }];
|
||||||
const res = await http(identity(id), { body: JSON.stringify(patch), headers: json, method: "PATCH" });
|
const res = await http(identity(id), { body: JSON.stringify(patch), headers: json, method: "PATCH" });
|
||||||
if (res.status !== 200) return fail("update metadata_admin", res);
|
if (res.status !== 200) return fail("update metadata_public", res);
|
||||||
return (await res.json()) as Identity;
|
return (await res.json()) as Identity;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export interface Flow {
|
|||||||
export interface Session {
|
export interface Session {
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
expires_at?: string;
|
expires_at?: string;
|
||||||
identity?: { id: string; metadata_admin?: unknown; traits?: Record<string, unknown> };
|
identity?: { id: string; metadata_public?: unknown; traits?: Record<string, unknown> }; // whoami strips metadata_admin
|
||||||
tokenized?: string; // the signed JWT — present only when `tokenize_as` was requested
|
tokenized?: string; // the signed JWT — present only when `tokenize_as` was requested
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,11 @@ test("self-service flows return to our themed pages", () => {
|
|||||||
`${flow} flow points at our /${flow} page`);
|
`${flow} flow points at our /${flow} page`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("after a successful login Kratos returns to our /auth/complete route to mint the JWT", () => {
|
||||||
|
assert.match(kratosYml, /default_browser_return_url:\s*http:\/\/127\.0\.0\.1:3000\/auth\/complete/,
|
||||||
|
"login completion (read roles → project → tokenize → set cookie) runs at /auth/complete (§4)");
|
||||||
|
});
|
||||||
|
|
||||||
test("recovery + verification run on email code, delivered by a courier", () => {
|
test("recovery + verification run on email code, delivered by a courier", () => {
|
||||||
assert.ok((kratosYml.match(/use:\s*code/g) ?? []).length >= 2,
|
assert.ok((kratosYml.match(/use:\s*code/g) ?? []).length >= 2,
|
||||||
"recovery + verification both use the email-code method");
|
"recovery + verification both use the email-code method");
|
||||||
@@ -70,10 +75,12 @@ test("session tokenizer template 'plainpages' mints a short-lived signed JWT", (
|
|||||||
"claims via the committed mapper");
|
"claims via the committed mapper");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("the tokenizer claims mapper emits email + roles from the metadata_admin projection", () => {
|
test("the tokenizer claims mapper emits email + roles from the metadata_public projection", () => {
|
||||||
|
// metadata_public, not _admin: the session Kratos hands the tokenizer carries only public
|
||||||
|
// metadata (admin metadata is stripped), so the roles projection must live in metadata_public.
|
||||||
const mapper = read("ory/kratos/tokenizer/plainpages.jsonnet");
|
const mapper = read("ory/kratos/tokenizer/plainpages.jsonnet");
|
||||||
assert.match(mapper, /email:\s*session\.identity\.traits\.email/, "email ← identity trait");
|
assert.match(mapper, /email:\s*session\.identity\.traits\.email/, "email ← identity trait");
|
||||||
assert.match(mapper, /metadata_admin/, "roles ← metadata_admin (the per-login Keto projection, §4)");
|
assert.match(mapper, /metadata_public/, "roles ← metadata_public (the per-login Keto projection, §4)");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("social sign-in is off by default — a clean clone stays password-only", () => {
|
test("social sign-in is off by default — a clean clone stays password-only", () => {
|
||||||
|
|||||||
92
src/login.test.ts
Normal file
92
src/login.test.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
// Login completion (§4): turn a Kratos session into our session JWT — read roles from Keto,
|
||||||
|
// project them onto the identity, tokenize, build the cookie. Fakes the three Ory clients;
|
||||||
|
// the live, full-stack login is verified by the §8 Playwright E2E.
|
||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import type { KetoClient, RelationTuple } from "./keto-client.ts";
|
||||||
|
import type { Identity, KratosAdmin } from "./kratos-admin.ts";
|
||||||
|
import type { KratosPublic, Session } from "./kratos-public.ts";
|
||||||
|
import { completeLogin, readRoles, SESSION_COOKIE, sessionCookie } from "./login.ts";
|
||||||
|
|
||||||
|
const ID = "01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b55";
|
||||||
|
const roleTuple = (object: string): RelationTuple => ({ namespace: "Role", object, relation: "members", subject_id: `user:${ID}` });
|
||||||
|
|
||||||
|
const ketoStub = (over: Partial<KetoClient> = {}): KetoClient => ({
|
||||||
|
check: async () => false,
|
||||||
|
deleteTuple: async () => {},
|
||||||
|
expand: async () => ({ type: "leaf" }),
|
||||||
|
listRelations: async () => ({ nextPageToken: null, tuples: [] }),
|
||||||
|
writeTuple: async () => {},
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
const adminStub = (over: Partial<KratosAdmin> = {}): KratosAdmin => ({
|
||||||
|
createIdentity: async () => { throw new Error("unused"); },
|
||||||
|
deleteIdentity: async () => {},
|
||||||
|
getIdentity: async () => null,
|
||||||
|
listIdentities: async () => ({ identities: [], nextPageToken: null }),
|
||||||
|
updateIdentity: async () => { throw new Error("unused"); },
|
||||||
|
updateMetadataPublic: async () => ({ id: ID }),
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
const publicStub = (over: Partial<KratosPublic> = {}): KratosPublic => ({
|
||||||
|
getFlow: async () => { throw new Error("unused"); },
|
||||||
|
initBrowserFlow: async () => { throw new Error("unused"); },
|
||||||
|
submitFlow: async () => { throw new Error("unused"); },
|
||||||
|
whoami: async () => null,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
test("readRoles reads direct Role memberships from Keto — paged, de-duped, sorted", async () => {
|
||||||
|
const calls: unknown[] = [];
|
||||||
|
const keto = ketoStub({
|
||||||
|
listRelations: async (q) => {
|
||||||
|
calls.push(q);
|
||||||
|
if (!q?.pageToken) return { nextPageToken: "p2", tuples: [roleTuple("editor"), roleTuple("admin")] };
|
||||||
|
return { nextPageToken: null, tuples: [roleTuple("admin")] }; // duplicate across pages
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.deepEqual(await readRoles(keto, ID), ["admin", "editor"]);
|
||||||
|
assert.deepEqual(calls[0], { namespace: "Role", relation: "members", subject_id: `user:${ID}` });
|
||||||
|
assert.equal((calls[1] as { pageToken?: string }).pageToken, "p2"); // second page follows the cursor
|
||||||
|
});
|
||||||
|
|
||||||
|
test("completeLogin: read roles → project onto metadata_public → tokenize → JWT (in that order)", async () => {
|
||||||
|
const events: string[] = [];
|
||||||
|
let projected: unknown;
|
||||||
|
const identity: Identity = { id: ID, traits: { email: "admin@plainpages.local" } };
|
||||||
|
const kratosPublic = publicStub({
|
||||||
|
whoami: async (o) => {
|
||||||
|
if (o?.tokenizeAs) { events.push("tokenize"); return { active: true, identity, tokenized: "h.p.s" } as Session; }
|
||||||
|
events.push("whoami"); return { active: true, identity } as Session;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const kratosAdmin = adminStub({ updateMetadataPublic: async (_id, meta) => { events.push("project"); projected = meta; return identity; } });
|
||||||
|
const keto = ketoStub({ listRelations: async () => ({ nextPageToken: null, tuples: [roleTuple("admin")] }) });
|
||||||
|
|
||||||
|
const out = await completeLogin({ keto, kratosAdmin, kratosPublic }, "plainpages_session=s");
|
||||||
|
assert.deepEqual(out, { email: "admin@plainpages.local", identityId: ID, jwt: "h.p.s", roles: ["admin"] });
|
||||||
|
assert.deepEqual(projected, { roles: ["admin"] }); // Keto roles, projected for the tokenizer
|
||||||
|
assert.deepEqual(events, ["whoami", "project", "tokenize"]); // projection MUST precede tokenize
|
||||||
|
});
|
||||||
|
|
||||||
|
test("completeLogin returns null and touches nothing when there is no active session", async () => {
|
||||||
|
let touched = false;
|
||||||
|
const keto = ketoStub({ listRelations: async () => { touched = true; return { nextPageToken: null, tuples: [] }; } });
|
||||||
|
const kratosAdmin = adminStub({ updateMetadataPublic: async () => { touched = true; return { id: ID }; } });
|
||||||
|
assert.equal(await completeLogin({ keto, kratosAdmin, kratosPublic: publicStub() }, undefined), null);
|
||||||
|
assert.equal(touched, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("completeLogin maps a missing email trait to null and throws if the tokenizer yields no JWT", async () => {
|
||||||
|
const identity: Identity = { id: ID, traits: {} };
|
||||||
|
const kratosPublic = publicStub({ whoami: async () => ({ active: true, identity }) as Session }); // never returns a tokenized JWT
|
||||||
|
await assert.rejects(completeLogin({ keto: ketoStub(), kratosAdmin: adminStub(), kratosPublic }, "c"), /tokenizer returned no JWT/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sessionCookie builds the HttpOnly/Lax JWT cookie; secure opt-in; JWT chars stay readable", () => {
|
||||||
|
const jwt = "aaa.bbb-_.ccc";
|
||||||
|
assert.equal(sessionCookie(jwt), `${SESSION_COOKIE}=${jwt}; Max-Age=2592000; Path=/; HttpOnly; SameSite=Lax`);
|
||||||
|
assert.match(sessionCookie(jwt, { secure: true }), /; SameSite=Lax; Secure$/);
|
||||||
|
});
|
||||||
75
src/login.ts
Normal file
75
src/login.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
// Login completion (todo §4): turn a fresh Kratos session into our locally-verifiable
|
||||||
|
// session JWT — the one moment Ory is on the path (README: Login → session JWT):
|
||||||
|
// 1. whoami(cookie) → the identity (id, email); no active session ⇒ null
|
||||||
|
// 2. read roles from Keto → the source of truth for the `roles` claim
|
||||||
|
// 3. project onto metadata_public (admin API) so the tokenizer's mapper can read them
|
||||||
|
// 4. whoami(tokenize_as) → the signed JWT { sub, email, roles }, stored as our cookie
|
||||||
|
// Order matters: the projection is written before tokenizing, because the claims mapper
|
||||||
|
// reads only the identity, never Keto.
|
||||||
|
import { serializeCookie, type CookieOptions } from "./cookie.ts";
|
||||||
|
import type { KetoClient } from "./keto-client.ts";
|
||||||
|
import type { KratosAdmin } from "./kratos-admin.ts";
|
||||||
|
import type { KratosPublic } from "./kratos-public.ts";
|
||||||
|
|
||||||
|
// Our session cookie — the signed JWT the hot path verifies in-process. Distinct from
|
||||||
|
// Kratos' own `plainpages_session` cookie (the long-lived login the JWT is re-minted off).
|
||||||
|
export const SESSION_COOKIE = "plainpages_jwt";
|
||||||
|
|
||||||
|
// Mirrors kratos.yml session.lifespan (30d) so the cookie survives browser restarts; the
|
||||||
|
// JWT inside is short-lived (~10m) and re-minted by the §4 middleware on expiry.
|
||||||
|
const COOKIE_MAX_AGE = 60 * 60 * 24 * 30;
|
||||||
|
|
||||||
|
// The tokenizer template (kratos.yml session.whoami.tokenizer.templates.plainpages).
|
||||||
|
const TOKENIZE_AS = "plainpages";
|
||||||
|
|
||||||
|
export interface LoginDeps {
|
||||||
|
keto: KetoClient;
|
||||||
|
kratosAdmin: KratosAdmin;
|
||||||
|
kratosPublic: KratosPublic;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompletedLogin {
|
||||||
|
email: string | null;
|
||||||
|
identityId: string;
|
||||||
|
jwt: string;
|
||||||
|
roles: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// The coarse roles Keto grants a subject directly: `Role:<name>#members@user:<id>`. Returns
|
||||||
|
// the de-duped, sorted role names (the tuple `object`). One logical read, paged defensively.
|
||||||
|
// Group→role inheritance lands with the Groups screen (§5); MVP grants are direct.
|
||||||
|
export async function readRoles(keto: KetoClient, identityId: string): Promise<string[]> {
|
||||||
|
const subject_id = `user:${identityId}`;
|
||||||
|
const roles = new Set<string>();
|
||||||
|
let pageToken: string | undefined;
|
||||||
|
do {
|
||||||
|
const page = await keto.listRelations({ namespace: "Role", relation: "members", subject_id, ...(pageToken ? { pageToken } : {}) });
|
||||||
|
for (const t of page.tuples) roles.add(t.object);
|
||||||
|
pageToken = page.nextPageToken ?? undefined;
|
||||||
|
} while (pageToken);
|
||||||
|
return [...roles].sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function completeLogin(deps: LoginDeps, cookie: string | undefined): Promise<CompletedLogin | null> {
|
||||||
|
const session = await deps.kratosPublic.whoami(cookie ? { cookie } : {});
|
||||||
|
if (!session?.identity) return null;
|
||||||
|
const identityId = session.identity.id;
|
||||||
|
const emailTrait = session.identity.traits?.["email"];
|
||||||
|
const email = typeof emailTrait === "string" ? emailTrait : null;
|
||||||
|
|
||||||
|
const roles = await readRoles(deps.keto, identityId);
|
||||||
|
await deps.kratosAdmin.updateMetadataPublic(identityId, { roles });
|
||||||
|
|
||||||
|
const tokenized = await deps.kratosPublic.whoami({ ...(cookie ? { cookie } : {}), tokenizeAs: TOKENIZE_AS });
|
||||||
|
const jwt = tokenized?.tokenized;
|
||||||
|
if (!jwt) throw new Error("login completion: Kratos tokenizer returned no JWT");
|
||||||
|
|
||||||
|
return { email, identityId, jwt, roles };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the Set-Cookie for our session JWT. HttpOnly + SameSite=Lax by default; `secure` is
|
||||||
|
// supplied by the caller (off in dev http; the §9 cookie hardening toggles it on for prod).
|
||||||
|
export function sessionCookie(jwt: string, options: { secure?: boolean } = {}): string {
|
||||||
|
const opts: CookieOptions = { httpOnly: true, maxAge: COOKIE_MAX_AGE, path: "/", sameSite: "Lax", ...(options.secure ? { secure: true } : {}) };
|
||||||
|
return serializeCookie(SESSION_COOKIE, jwt, opts);
|
||||||
|
}
|
||||||
@@ -2,18 +2,23 @@ import { createApp } from "./app.ts";
|
|||||||
import { loadConfig } from "./config.ts";
|
import { loadConfig } from "./config.ts";
|
||||||
import { discoverPlugins } from "./discovery.ts";
|
import { discoverPlugins } from "./discovery.ts";
|
||||||
import { runBootHooks } from "./hooks.ts";
|
import { runBootHooks } from "./hooks.ts";
|
||||||
|
import { createKetoClient } from "./keto-client.ts";
|
||||||
|
import { createKratosAdmin } from "./kratos-admin.ts";
|
||||||
import { createKratosPublic } from "./kratos-public.ts";
|
import { createKratosPublic } from "./kratos-public.ts";
|
||||||
import { loadMenuConfig } from "./menu-config.ts";
|
import { loadMenuConfig } from "./menu-config.ts";
|
||||||
|
|
||||||
const config = loadConfig(); // validates the env (incl. enforced secrets) — fails loud at boot
|
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 menu = await loadMenuConfig(); // config/menu.ts override + branding — fails loud if malformed
|
||||||
const kratos = createKratosPublic({ baseUrl: config.kratosPublicUrl }); // themed self-service routes (§4)
|
// Ory clients for the themed self-service routes + login completion (§4).
|
||||||
|
const kratos = createKratosPublic({ baseUrl: config.kratosPublicUrl });
|
||||||
|
const kratosAdmin = createKratosAdmin({ baseUrl: config.kratosAdminUrl });
|
||||||
|
const keto = createKetoClient({ readUrl: config.ketoReadUrl, writeUrl: config.ketoWriteUrl });
|
||||||
|
|
||||||
const plugins = await discoverPlugins(); // scans plugins/, validates — fails loud on a bad plugin
|
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(", ")}` : ""}`);
|
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
|
await runBootHooks(plugins); // plugin onBoot — after discovery, before listen; a throw aborts boot
|
||||||
|
|
||||||
const server = createApp({ cache: config.cacheTemplates, kratos, menu, plugins }).listen(config.port, () => {
|
const server = createApp({ cache: config.cacheTemplates, keto, kratos, kratosAdmin, menu, plugins }).listen(config.port, () => {
|
||||||
console.log(`Listening on http://localhost:${config.port}`);
|
console.log(`Listening on http://localhost:${config.port}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
2
todo.md
2
todo.md
@@ -80,7 +80,7 @@ everything via Docker.
|
|||||||
- [x] Keto client (fetch): `check`, list/expand relations, write/delete tuples. → `src/keto-client.ts` (`createKetoClient({readUrl, writeUrl, fetchImpl})`): typed `fetch` wrappers over Keto's relation-tuple APIs, no SDK, `fetchImpl`-injectable like the kratos clients; read (`check`/`listRelations`/`expand`) and write (`writeTuple`/`deleteTuple`) split onto the two ports config.ts targets (4466/4467). `RelationTuple` (subject_id xor subject_set; mirrors bootstrap's roleTuple) is the wire shape for writes + the filter shape for reads via `tupleParams` (subject sets → dotted `subject_set.*` keys). `check` returns a `bool` reading `allowed` from **both** 200 (allowed) and 403 (denied) — Keto answers a denial with 403, not 200 (caught in boot-verify); other statuses fail loud via `KetoError` (carries `.status`, parallels KratosError). `writeTuple` PUTs (idempotent), `deleteTuple` DELETEs by query, `listRelations` parses `next_page_token`, `expand` returns the loose tree. Building block — no route/E2E yet (login completion §4 line 83 + guards line 86 wire it). Tests-first (`keto-client.test.ts`, mock fetch: URLs/ports/method/query+body/subject forms/allowed mapping/pagination/errors). README **Layout** lists it. Boot-verified live: full round-trip against a real keto (check false → write → true → list → expand → delete → false). typecheck + 174 units green.
|
- [x] Keto client (fetch): `check`, list/expand relations, write/delete tuples. → `src/keto-client.ts` (`createKetoClient({readUrl, writeUrl, fetchImpl})`): typed `fetch` wrappers over Keto's relation-tuple APIs, no SDK, `fetchImpl`-injectable like the kratos clients; read (`check`/`listRelations`/`expand`) and write (`writeTuple`/`deleteTuple`) split onto the two ports config.ts targets (4466/4467). `RelationTuple` (subject_id xor subject_set; mirrors bootstrap's roleTuple) is the wire shape for writes + the filter shape for reads via `tupleParams` (subject sets → dotted `subject_set.*` keys). `check` returns a `bool` reading `allowed` from **both** 200 (allowed) and 403 (denied) — Keto answers a denial with 403, not 200 (caught in boot-verify); other statuses fail loud via `KetoError` (carries `.status`, parallels KratosError). `writeTuple` PUTs (idempotent), `deleteTuple` DELETEs by query, `listRelations` parses `next_page_token`, `expand` returns the loose tree. Building block — no route/E2E yet (login completion §4 line 83 + guards line 86 wire it). Tests-first (`keto-client.test.ts`, mock fetch: URLs/ports/method/query+body/subject forms/allowed mapping/pagination/errors). README **Layout** lists it. Boot-verified live: full round-trip against a real keto (check false → write → true → list → expand → delete → false). typecheck + 174 units green.
|
||||||
- [x] Render Kratos flows: fetch flow → render fields against our themed pages → POST to `flow.ui.action` (Kratos handles its CSRF), map field errors/messages. → `src/flow-view.ts` (pure `buildFlowView(flow, type)`): maps a fetched self-service `Flow` → themed view model — hidden inputs (incl. `csrf_token`), themed fields (label from `meta.label`, type/required/autocomplete from attributes, an input icon by field semantics, node-level error message), submit buttons (name/value preserved), and tone-mapped flow messages (error→neg/success→pos/info→info); `oidc` nodes skipped (SSO is the next item). Per-flow chrome (title/sub/back/alt) + `AUTH_FLOWS` path→type map. `views/auth.ejs` renders it into the html-css-foundation auth layout, reusing the `auth-card` + `field` partials and capturing `partials/flow-body.ejs` (messages + hidden + fields + buttons) into the card body; new reusable `partials/alert.ejs` + an `.alert` design-system component (styles.css, tone tokens). `app.ts` serves the five routes via an injectable `kratos` client (server.ts builds it from `config.kratosPublicUrl`): no `?flow=` ⇒ init server-side + relay Kratos' CSRF `Set-Cookie` + 303 to `?flow=<id>`; `?flow=<id>` ⇒ `getFlow` (forwarding the browser cookie) → render; an expired/unknown flow (403/404/410) re-inits. The browser POSTs the form straight to `flow.ui.action` (Kratos owns CSRF) — no server-side `submitFlow`. Tests-first: `flow-view.test.ts` (mapping matrix: hidden/fields/buttons/icons/errors/tone/oidc-skip/chrome/AUTH_FLOWS) + `app.test.ts` integration (init 303 + CSRF relay + expired restart; rendered page posts to Kratos with the live fields + error alert) — mock `KratosPublic`. typecheck + 181 units green. Boot-verified the whole chain on the live stack: `/login` 303 → `?flow=` relaying the real `csrf_token_…` cookie, the page posts to `127.0.0.1:4433` with the live token + identifier/password + submit; registration renders the real `traits.*` fields; recovery/verification chrome correct; a stale flow id 303s back to re-init; torn down. Browser-submittable end-to-end (dev http Secure-cookie posture, login completion → our JWT cookie) is the next §4 items (lines 83/89); the full live-stack login Playwright E2E is owned by §8.
|
- [x] Render Kratos flows: fetch flow → render fields against our themed pages → POST to `flow.ui.action` (Kratos handles its CSRF), map field errors/messages. → `src/flow-view.ts` (pure `buildFlowView(flow, type)`): maps a fetched self-service `Flow` → themed view model — hidden inputs (incl. `csrf_token`), themed fields (label from `meta.label`, type/required/autocomplete from attributes, an input icon by field semantics, node-level error message), submit buttons (name/value preserved), and tone-mapped flow messages (error→neg/success→pos/info→info); `oidc` nodes skipped (SSO is the next item). Per-flow chrome (title/sub/back/alt) + `AUTH_FLOWS` path→type map. `views/auth.ejs` renders it into the html-css-foundation auth layout, reusing the `auth-card` + `field` partials and capturing `partials/flow-body.ejs` (messages + hidden + fields + buttons) into the card body; new reusable `partials/alert.ejs` + an `.alert` design-system component (styles.css, tone tokens). `app.ts` serves the five routes via an injectable `kratos` client (server.ts builds it from `config.kratosPublicUrl`): no `?flow=` ⇒ init server-side + relay Kratos' CSRF `Set-Cookie` + 303 to `?flow=<id>`; `?flow=<id>` ⇒ `getFlow` (forwarding the browser cookie) → render; an expired/unknown flow (403/404/410) re-inits. The browser POSTs the form straight to `flow.ui.action` (Kratos owns CSRF) — no server-side `submitFlow`. Tests-first: `flow-view.test.ts` (mapping matrix: hidden/fields/buttons/icons/errors/tone/oidc-skip/chrome/AUTH_FLOWS) + `app.test.ts` integration (init 303 + CSRF relay + expired restart; rendered page posts to Kratos with the live fields + error alert) — mock `KratosPublic`. typecheck + 181 units green. Boot-verified the whole chain on the live stack: `/login` 303 → `?flow=` relaying the real `csrf_token_…` cookie, the page posts to `127.0.0.1:4433` with the live token + identifier/password + submit; registration renders the real `traits.*` fields; recovery/verification chrome correct; a stale flow id 303s back to re-init; torn down. Browser-submittable end-to-end (dev http Secure-cookie posture, login completion → our JWT cookie) is the next §4 items (lines 83/89); the full live-stack login Playwright E2E is owned by §8.
|
||||||
- [x] 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. → `flow-view.ts` now collects the login/registration flow's `oidc`-group submit nodes into `FlowView.sso` (`{label, logo, name, value}` per provider; `logo` = provider initial, lucide ships no brand marks) instead of skipping them — so the button list *is* Kratos' live provider list (none configured ⇒ `sso: []` ⇒ no section; activate/remove a provider purely via the §3 OIDC env). `auth-card.ejs` gained a submit-provider branch: a provider with `name`/`value` renders `<button type="submit" name=… value=…>` (posts `provider=<id>` to the same Kratos form, sharing its csrf hidden input); `href` still ⇒ `<a>`, neither ⇒ inert button. `auth.ejs` forwards `sso: { providers: flow.sso }`. Removed the mockup-only `body:not(:has(#sso-toggle:checked)) .sso{display:none}` rule from `auth.css` (`#sso-toggle` is a "remove for production" preview control in `html-css-foundation/Auth.html`) — visibility is now purely server-side. Tests-first: `flow-view.test.ts` (oidc→sso matrix + `sso:[]` when none), `auth-card.test.ts` (submit-provider markup), `app.test.ts` (live `/login` renders the SSO submit button in the form). README **Social sign-in (SSO)** updated (dropped the §4 forward-ref). typecheck + 181 units green. Boot-verified end-to-end: a real Kratos with the OIDC env emitted `{group:oidc, name:provider, value:google}` → `buildFlowView` derived `[{label:"Sign in with google", logo:"G", name:"provider", value:"google"}]`; clean-clone `/login` renders no `.sso` section; torn down.
|
- [x] 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. → `flow-view.ts` now collects the login/registration flow's `oidc`-group submit nodes into `FlowView.sso` (`{label, logo, name, value}` per provider; `logo` = provider initial, lucide ships no brand marks) instead of skipping them — so the button list *is* Kratos' live provider list (none configured ⇒ `sso: []` ⇒ no section; activate/remove a provider purely via the §3 OIDC env). `auth-card.ejs` gained a submit-provider branch: a provider with `name`/`value` renders `<button type="submit" name=… value=…>` (posts `provider=<id>` to the same Kratos form, sharing its csrf hidden input); `href` still ⇒ `<a>`, neither ⇒ inert button. `auth.ejs` forwards `sso: { providers: flow.sso }`. Removed the mockup-only `body:not(:has(#sso-toggle:checked)) .sso{display:none}` rule from `auth.css` (`#sso-toggle` is a "remove for production" preview control in `html-css-foundation/Auth.html`) — visibility is now purely server-side. Tests-first: `flow-view.test.ts` (oidc→sso matrix + `sso:[]` when none), `auth-card.test.ts` (submit-provider markup), `app.test.ts` (live `/login` renders the SSO submit button in the form). README **Social sign-in (SSO)** updated (dropped the §4 forward-ref). typecheck + 181 units green. Boot-verified end-to-end: a real Kratos with the OIDC env emitted `{group:oidc, name:provider, value:google}` → `buildFlowView` derived `[{label:"Sign in with google", logo:"G", name:"provider", value:"google"}]`; clean-clone `/login` renders no `.sso` section; torn down.
|
||||||
- [ ] Login completion: read roles from Keto → write `metadata_admin` projection → tokenize → set JWT cookie.
|
- [x] Login completion: read roles from Keto → write `metadata_public` projection → tokenize → set JWT cookie. → `src/login.ts` (`completeLogin`/`readRoles`/`sessionCookie`, `SESSION_COOKIE`), wired into `app.ts` at `GET /auth/complete` — where `kratos.yml` now lands the browser after a successful login (`login.after.default_browser_return_url`). The route: `whoami(cookie)` → identity (id/email; no session ⇒ 303 `/login`); `readRoles` lists `Role:*#members@user:<id>` from Keto (one paged read, sorted/de-duped; group→role transitivity is §5); projects `{roles}` onto the identity; then `whoami(tokenize_as: plainpages)` → the signed JWT, stored as `plainpages_jwt` (HttpOnly + SameSite=Lax + 30d, `secure` deferred to §9). `server.ts` builds the kratos-admin + keto clients and passes all three to `createApp`. **Design bug caught in live boot-verify + fixed:** the projection had to move `metadata_admin` → `metadata_public` — Kratos *strips admin metadata* from the session the tokenizer reads, so `metadata_admin` yielded `roles:[]`; `metadata_public` is carried (and the user already reads these coarse roles in their own JWT, so nothing leaks). Touched `kratos-admin.ts` (`updateMetadataAdmin`→`updateMetadataPublic`, `/metadata_public` patch), the tokenizer jsonnet, and the kratos.yml/README rationale. Tests-first: `login.test.ts` (readRoles paging/dedup; completeLogin order whoami→project→tokenize; no-session⇒null; missing email⇒null; no-JWT⇒throw; cookie flags) + `app.test.ts` integration (`/auth/complete` projects roles, sets `plainpages_jwt`, 303→`/`; no session ⇒ 303 `/login`, no cookie) + `kratos.test.ts` (after-login URL + jsonnet metadata_public). Boot-verified the whole chain live: real admin login → `/auth/complete` → JWT `{sub, email, roles:["admin"], exp−iat=600}`, identity re-projected `metadata_public:{roles:["admin"]}` from Keto (wiped first to prove the write); no-session ⇒ 303 `/login`; torn down. The full-stack login Playwright E2E is owned by §8. typecheck + 189 units green.
|
||||||
- [ ] JWT middleware: verify signature via cached JWKS, validate `exp`/`iss`/`aud` (+clock skew), build context (user, roles).
|
- [ ] JWT middleware: verify signature via cached JWKS, validate `exp`/`iss`/`aud` (+clock skew), build context (user, roles).
|
||||||
- [ ] JWKS fetch + cache + rotation handling.
|
- [ ] JWKS fetch + cache + rotation handling.
|
||||||
- [ ] Guards: `requireSession` (validate JWT), `can(role)` (claim, in-process), `check(relation, object)` (live Keto).
|
- [ ] Guards: `requireSession` (validate JWT), `can(role)` (claim, in-process), `check(relation, object)` (live Keto).
|
||||||
|
|||||||
Reference in New Issue
Block a user