From 1c324b18e3dd41351a7c5d7878d3184da5fa1f1f Mon Sep 17 00:00:00 2001 From: lilleman Date: Fri, 19 Jun 2026 11:23:27 +0200 Subject: [PATCH] =?UTF-8?q?Built-in=20OAuth2=20client-registration=20admin?= =?UTF-8?q?=20screen=20(todo=20=C2=A76);=20/admin/clients=20lists/register?= =?UTF-8?q?s/deletes=20the=20Hydra=20OAuth2=20clients=20other=20apps=20log?= =?UTF-8?q?=20in=20through=20us=20with.=20New=20src/admin-clients.ts=20(pu?= =?UTF-8?q?re=20builders=20+=20handleAdminClients,=20mirroring=20the=20?= =?UTF-8?q?=C2=A75=20Users/Roles=20screens):=20list=20(search/paginate=20o?= =?UTF-8?q?ver=20one=20fetched=20Hydra=20page),=20register=20(GET=20form?= =?UTF-8?q?=20+=20POST),=20read-only=20detail,=20delete-confirm.=20src/hyd?= =?UTF-8?q?ra-admin.ts=20gains=20the=20client=20half=20of=20the=20admin=20?= =?UTF-8?q?API=20=E2=80=94=20createClient/listClients/getClient/deleteClie?= =?UTF-8?q?nt=20over=20/admin/clients=20(+=20a=20nextPageToken=20Link=20pa?= =?UTF-8?q?rser=20like=20kratos-admin)=20and=20the=20registration=20fields?= =?UTF-8?q?=20on=20OAuth2Client.=20Register=20builds=20a=20standard=20auth?= =?UTF-8?q?orization-code=20client=20(+=20refresh=5Ftoken),=20confidential?= =?UTF-8?q?=20(client=5Fsecret=5Fbasic)=20or=20public=20(PKCE/none),=20wit?= =?UTF-8?q?h=20an=20optional=20first-party=20auto-consent=20flag;=20Hydra?= =?UTF-8?q?=20returns=20the=20client=5Fsecret=20once,=20so=20the=20registe?= =?UTF-8?q?r=20POST=20renders=20the=20new=20client's=20detail=20page=20wit?= =?UTF-8?q?h=20the=20one-time=20secret=20directly=20(no=20PRG)=20and=20it?= =?UTF-8?q?=20is=20never=20re-shown=20(getClient=20carries=20no=20secret;?= =?UTF-8?q?=20the=20detail=20test=20asserts=20it).=20Writes=20go=20only=20?= =?UTF-8?q?to=20Hydra;=20gated=20admin-only=20(anon->/login,=20non-admin->?= =?UTF-8?q?403)=20+=20every=20mutation=20CSRF-guarded=20via=20requireAdmin?= =?UTF-8?q?/guardedForm=20like=20=C2=A75;=20a=20Hydra=204xx=20(bad=20redir?= =?UTF-8?q?ect/scope)=20re-renders=20the=20form=20(400),=20a=205xx=20->=20?= =?UTF-8?q?500=20(mirrors=20oauth-login.ts);=20:id=20via=20safeDecode=20(m?= =?UTF-8?q?alformed->404).=20Wired=20into=20app.ts=20(/admin/clients,=20ga?= =?UTF-8?q?ted=20on=20the=20hydra=20client=20present)=20and=20the=20shared?= =?UTF-8?q?=20adminSection=20(Users.Groups.Roles.OAuth2=20clients,=20i-glo?= =?UTF-8?q?be)=20so=20it=20shows=20for=20admins=20and=20is=20invisible=20o?= =?UTF-8?q?therwise.=20New=20views=20(admin/clients,=20client-form,=20clie?= =?UTF-8?q?nt-detail=20+=20partials/client-{form,detail}-body)=20reuse=20t?= =?UTF-8?q?he=20shell/filter-bar/data-table/field=20blocks;=20one=20.detai?= =?UTF-8?q?l-list=20CSS=20rule;=20README=20Layout/=C2=A76=20updated.=20Tes?= =?UTF-8?q?ts-first:=20hydra-admin.test.ts=20(client=20CRUD=20contracts=20?= =?UTF-8?q?incl.=20Link=20pagination/404->null/204),=20admin-clients.test.?= =?UTF-8?q?ts=20(builder/validation/payload=20matrix),=20app.test.ts=20HTT?= =?UTF-8?q?P=20integration=20(gate/list/register-shows-secret-once/invalid?= =?UTF-8?q?+CSRF-reject/detail-hides-secret/delete=20+=20malformed-%->404)?= =?UTF-8?q?.=20Stability-reviewer=20run=20as=20a=20local=20PR:=20APPROVE,?= =?UTF-8?q?=20no=20Critical/High;=20addressed=20its=20one=20nit=20(dropped?= =?UTF-8?q?=20a=20dead=20URL.protocol=20check=20in=20validateClientInput).?= =?UTF-8?q?=20Boot-verified=20the=20client=20CRUD=20live=20against=20real?= =?UTF-8?q?=20Hydra=20v26.2.0=20(create->201=20w/=20one-time=20secret=20->?= =?UTF-8?q?=20list=20finds=20it=20->=20get=20->=20delete=20->=20get=20null?= =?UTF-8?q?);=20torn=20down.=20typecheck=20+=20274=20units=20green.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 12 +- public/css/styles.css | 3 + src/admin-clients.test.ts | 105 ++++++++ src/admin-clients.ts | 345 ++++++++++++++++++++++++++ src/admin-nav.ts | 4 +- src/app.test.ts | 63 ++++- src/app.ts | 13 +- src/dashboard.test.ts | 4 +- src/hydra-admin.test.ts | 36 +++ src/hydra-admin.ts | 53 ++++ src/oauth-consent.test.ts | 11 +- src/oauth-login.test.ts | 4 + todo.md | 20 +- views/admin/client-detail.ejs | 17 ++ views/admin/client-form.ejs | 16 ++ views/admin/clients.ejs | 21 ++ views/partials/client-detail-body.ejs | 37 +++ views/partials/client-form-body.ejs | 29 +++ 18 files changed, 772 insertions(+), 21 deletions(-) create mode 100644 src/admin-clients.test.ts create mode 100644 src/admin-clients.ts create mode 100644 views/admin/client-detail.ejs create mode 100644 views/admin/client-form.ejs create mode 100644 views/admin/clients.ejs create mode 100644 views/partials/client-detail-body.ejs create mode 100644 views/partials/client-form-body.ejs diff --git a/README.md b/README.md index efbb39c..a8a12c0 100644 --- a/README.md +++ b/README.md @@ -504,6 +504,11 @@ challenge** is wired too (`src/oauth-consent.ts` at `/oauth2/consent`): a first- requested scopes; any other client gets a themed consent screen whose CSRF-guarded Allow/Deny accepts or rejects. id_token claims (email, name) come from the Kratos identity. +Those clients are registered from the admin **OAuth2 clients** screen (`/admin/clients`, +`src/admin-clients.ts`): register (Hydra shows the generated `client_secret` **once**, on the +confirmation page — confidential clients), list, and delete. Confidential vs public (PKCE) and the +first-party auto-consent flag are set at registration; writes go only to Hydra. + ## Stateless — no application database Plainpages and its plugins hold **no state of their own**. The only database in the @@ -545,7 +550,7 @@ src/jwks.ts JwksProvider — resolve the verify key by kid; createJwksP src/kratos-public.ts createKratosPublic(): Kratos public-API fetch client — self-service flow init/get/submit, browser logout, whoami, session→JWT tokenize (§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/hydra-admin.ts createHydraAdmin(): Hydra admin-API fetch client — OAuth2 login + consent challenge get/accept/reject (§6) +src/hydra-admin.ts createHydraAdmin(): Hydra admin-API fetch client — OAuth2 login + consent challenge get/accept/reject + OAuth2 client CRUD (§6) src/oauth-login.ts resolveLoginChallenge(): authenticate a Hydra login challenge via the Kratos session → accept, or bounce to /login (§6) src/oauth-consent.ts resolveConsentChallenge()/acceptConsent()/rejectConsent(): auto-accept first-party, else show the consent screen → grant scopes (§6) src/flow-view.ts buildFlowView(): Kratos self-service Flow → themed view model (fields, hidden csrf, buttons, tone-mapped messages) for views/auth.ejs (§4) @@ -561,7 +566,8 @@ src/dashboard.ts buildDashboardModel(): the home "/" People list view model src/admin-users.ts Built-in Users admin screen (§5): list Kratos identities (filter/sort/paginate) + create/edit/deactivate/delete/recovery; gated + CSRF-guarded src/admin-groups.ts Built-in Groups admin screen (§5): list Keto subject sets + create/delete + membership (add/remove users & nested groups); writes only to Keto, gated + CSRF-guarded src/admin-roles.ts Built-in Roles admin screen (§5): list/create/delete Keto roles + assign to users/groups + "effective access" (Keto expand → transitive members); reuses the Groups membership helpers, writes only to Keto, gated + CSRF-guarded -src/admin-nav.ts adminSection(): the permission-gated "Admin" menu section (Users · Groups · Roles), wired into the global dashboard menu + the in-screen admin nav (adminNav) so they can't drift +src/admin-clients.ts Built-in OAuth2 clients admin screen (§6): list/register/delete Hydra OAuth2 clients (apps that log in through us); register shows the one-time client_secret; writes only to Hydra, gated + CSRF-guarded +src/admin-nav.ts adminSection(): the permission-gated "Admin" menu section (Users · Groups · Roles · OAuth2 clients), wired into the global dashboard menu + the in-screen admin nav (adminNav) so they can't drift src/shell-context.ts buildShellContext(): brand/theme/user view-model shared by the dashboard + admin screens (real signed-in user, no demo profile) src/icons.ts Used-icon registry + sprite builder from lucide-static (regenerates partials/icons.ejs) src/list-query.ts parseListQuery(): read a list URL → { q, filters, sort, page, pageSize } @@ -572,7 +578,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 (app-shell dashboard), admin/ (Users/Groups/Roles lists + create/edit/detail + delete-confirm), auth (themed Kratos flows), oauth-consent (OAuth2 consent screen), 403/404/500, partials/ (shell, nav tree, filter bar, data table, pagination, field, auth card, alert, flow + consent + admin bodies, menu/popover, theme switch, icon sprite) +views/ Core EJS templates: index (app-shell dashboard), admin/ (Users/Groups/Roles/Clients lists + create/edit/detail + delete-confirm), auth (themed Kratos flows), oauth-consent (OAuth2 consent screen), 403/404/500, partials/ (shell, nav tree, filter bar, data table, pagination, field, auth card, alert, flow + consent + admin bodies, 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 → /oauth2/*) + storage init (postgres/init/init.sql: one DB per service) diff --git a/public/css/styles.css b/public/css/styles.css index b05f049..c749b8e 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -678,6 +678,9 @@ th[aria-sort="descending"] .sort-ico { transform: rotate(180deg); } .admin-actions { flex-flow: row wrap; gap: 10px; align-items: center; } .admin-actions form { margin: 0; } .plain-list { display: flex; flex-direction: column; gap: 6px; } +.detail-list { display: grid; grid-template-columns: max-content 1fr; gap: 6px 18px; margin: 0; font-size: var(--fz-sm); } +.detail-list dt { color: var(--text-faint); } +.detail-list dd { margin: 0; word-break: break-word; } .btn-danger { color: var(--neg); border-color: var(--neg-bd); } .btn-danger:hover { background: var(--neg-bg); } .recovery-link { word-break: break-all; } diff --git a/src/admin-clients.test.ts b/src/admin-clients.test.ts new file mode 100644 index 0000000..282e3ff --- /dev/null +++ b/src/admin-clients.test.ts @@ -0,0 +1,105 @@ +// Built-in OAuth2 clients admin screen (§6): the pure view-model + Hydra-payload builders. A client +// is an Ory Hydra OAuth2 client (apps that log in *through* us); writes go only to Hydra. The +// HTTP routing/gate/CSRF + live Hydra calls (incl. the one-time secret) are exercised in app.test.ts. +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + buildClientDetailModel, + buildClientFormModel, + buildClientsListModel, + type ClientInput, + clientPayload, + parseRedirectUris, + toClientView, + validateClientInput, +} from "./admin-clients.ts"; + +const input = (over: Partial = {}): ClientInput => + ({ firstParty: false, name: "Acme", public: false, redirectUris: ["https://acme.example/cb"], scope: "openid offline_access", ...over }); + +test("toClientView maps Hydra fields → view (public from auth method, first-party from metadata, scopes split)", () => { + assert.deepEqual( + toClientView({ client_id: "c1", client_name: "Acme", metadata: { first_party: true }, redirect_uris: ["https://a/cb"], scope: "openid email", token_endpoint_auth_method: "client_secret_basic" }), + { firstParty: true, id: "c1", name: "Acme", public: false, redirectUris: ["https://a/cb"], scopes: ["openid", "email"] }, + ); + // A public client + no name → falls back to the id; absent metadata/scope tolerated. + assert.deepEqual( + toClientView({ client_id: "c2", token_endpoint_auth_method: "none" }), + { firstParty: false, id: "c2", name: "c2", public: true, redirectUris: [], scopes: [] }, + ); +}); + +test("parseRedirectUris splits on newlines/whitespace/commas and drops empties", () => { + assert.deepEqual(parseRedirectUris("https://a/cb\n https://b/cb \n\n, https://c/cb"), ["https://a/cb", "https://b/cb", "https://c/cb"]); + assert.deepEqual(parseRedirectUris(" "), []); +}); + +test("clientPayload maps the input to Hydra's create body (auth-code + refresh, type via auth method)", () => { + assert.deepEqual(clientPayload(input()), { + client_name: "Acme", + grant_types: ["authorization_code", "refresh_token"], + metadata: { first_party: false }, + redirect_uris: ["https://acme.example/cb"], + response_types: ["code"], + scope: "openid offline_access", + token_endpoint_auth_method: "client_secret_basic", + }); + const pub = clientPayload(input({ firstParty: true, public: true })); + assert.equal(pub.token_endpoint_auth_method, "none"); + assert.deepEqual(pub.metadata, { first_party: true }); +}); + +test("validateClientInput requires a name, ≥1 redirect URI, and absolute redirect URLs", () => { + assert.equal(validateClientInput(input()), null); + assert.match(validateClientInput(input({ name: "" }))!, /name/i); + assert.match(validateClientInput(input({ redirectUris: [] }))!, /redirect/i); + assert.match(validateClientInput(input({ redirectUris: ["not a url"] }))!, /redirect URI/i); +}); + +test("buildClientsListModel filters by search, paginates; the name links to the detail page", () => { + const clients = Array.from({ length: 30 }, (_, i) => ({ client_id: `id-${String(i).padStart(2, "0")}`, client_name: `app-${String(i).padStart(2, "0")}`, token_endpoint_auth_method: "client_secret_basic" })); + + const all = buildClientsListModel({ clients, url: "http://x/admin/clients" }); + assert.equal(all.pagination.summary.total, 30); + assert.equal(all.table.rows.length, 25); // default page size + assert.equal(all.shell.title, "OAuth2 clients"); + const first = all.table.rows[0]!.cells[0] as { rowHeader: { href: string; text: string } }; + assert.equal(first.rowHeader.text, "app-00"); + assert.equal(first.rowHeader.href, "/admin/clients/id-00"); + + const one = buildClientsListModel({ clients, url: "http://x/admin/clients?q=app-07" }); + assert.equal(one.pagination.summary.total, 1); + assert.deepEqual(one.filterBar.pills.map((p) => p.label), ["Search"]); +}); + +test("buildClientFormModel: a register form with name + scope fields; values reflected on error", () => { + const m = buildClientFormModel({ csrfToken: "tok.sig" }); + assert.equal(m.shell.title, "Register client"); + assert.equal(m.form.action, "/admin/clients"); + assert.equal(m.form.submitLabel, "Register client"); + assert.equal(m.form.csrfToken, "tok.sig"); + assert.equal(m.form.nameField.required, true); + assert.equal(m.form.scopeField.value, "openid offline_access"); // sensible default + + const err = buildClientFormModel({ error: "Add at least one redirect URI.", values: { firstParty: true, name: "Acme", public: true, redirectUris: ["https://a/cb"], scope: "openid" } }); + assert.equal(err.error, "Add at least one redirect URI."); + assert.equal(err.form.nameField.value, "Acme"); + assert.equal(err.form.redirectUris, "https://a/cb"); + assert.equal(err.form.public, true); + assert.equal(err.form.firstParty, true); +}); + +test("buildClientDetailModel: client info + delete action; the one-time secret + created banner show only right after create", () => { + const client = toClientView({ client_id: "c1", client_name: "Acme", redirect_uris: ["https://a/cb"], scope: "openid", token_endpoint_auth_method: "client_secret_basic" }); + + const plain = buildClientDetailModel({ client }); + assert.equal(plain.shell.title, "Acme"); + assert.equal(plain.delete.action, "/admin/clients/c1/delete"); + assert.equal(plain.created, false); + assert.equal(plain.secret, undefined); + + const fresh = buildClientDetailModel({ client, created: true, secret: "s3cr3t" }); + assert.equal(fresh.created, true); + assert.equal(fresh.secret, "s3cr3t"); + assert.equal(fresh.shell.title, "Client registered"); +}); diff --git a/src/admin-clients.ts b/src/admin-clients.ts new file mode 100644 index 0000000..e39cbe5 --- /dev/null +++ b/src/admin-clients.ts @@ -0,0 +1,345 @@ +// Built-in OAuth2 clients admin screen (todo §6): register / list / delete the OAuth2 clients other +// apps log in *through* us with (Ory Hydra, the §6 login+consent handlers). A client is an Ory Hydra +// OAuth2 client; writes go only to Hydra. Hydra returns the client_secret once, on create — so the +// register POST renders the new client's detail page (with the one-time secret) directly instead of a +// PRG redirect (mirrors the Users "trigger recovery" one-time code). `handleAdminClients` is the +// imperative shell app.ts dispatches to — gated admin-only, CSRF-guarded. + +import { ADMIN_CLIENTS_BASE, adminNav, buildConfirmModel, guardedForm, requireAdmin } from "./admin-nav.ts"; +import { safeDecode } from "./admin-groups.ts"; +import type { FieldConfig } from "./admin-users.ts"; +import type { RequestContext, User } from "./context.ts"; +import { HydraError, type HydraAdmin, type OAuth2Client } from "./hydra-admin.ts"; +import { parseListQuery } from "./list-query.ts"; +import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts"; +import { paginate } from "./paginate.ts"; +import type { RouteResult } from "./plugin.ts"; +import { buildShellContext } from "./shell-context.ts"; + +const DEFAULT_PAGE_SIZE = 25; +const PAGE_SIZES = [25, 50, 100]; +// One Hydra page is fetched and filtered/paged in memory — its list API has no search. Ample for an +// admin tool (the OAuth2 clients of a deployment number in the dozens); raise if one outgrows it. +const LIST_FETCH_SIZE = 250; +const DEFAULT_SCOPE = "openid offline_access"; + +export interface ClientView { + firstParty: boolean; + id: string; // client_id + name: string; + public: boolean; // public (PKCE, no secret) vs confidential + redirectUris: string[]; + scopes: string[]; +} + +export interface ClientInput { + firstParty: boolean; + name: string; + public: boolean; + redirectUris: string[]; + scope: string; +} + +export function toClientView(client: OAuth2Client): ClientView { + const id = client.client_id ?? ""; + return { + firstParty: (client.metadata as { first_party?: unknown } | undefined)?.first_party === true, + id, + name: client.client_name?.trim() || id || "(unnamed)", + public: client.token_endpoint_auth_method === "none", + redirectUris: client.redirect_uris ?? [], + scopes: (client.scope ?? "").split(/\s+/).filter(Boolean), + }; +} + +// Split a textarea value into redirect URIs (one per line / whitespace / comma), dropping empties. +export function parseRedirectUris(raw: string): string[] { + return raw.split(/[\s,]+/).map((s) => s.trim()).filter(Boolean); +} + +// Hydra's create body. We register a standard authorization-code web/native client (+ refresh); +// the type (confidential vs public/PKCE) and auto-consent ride the auth method + metadata. +export function clientPayload(input: ClientInput): Record { + return { + client_name: input.name, + grant_types: ["authorization_code", "refresh_token"], + metadata: { first_party: input.firstParty }, + redirect_uris: input.redirectUris, + response_types: ["code"], + scope: input.scope, + token_endpoint_auth_method: input.public ? "none" : "client_secret_basic", + }; +} + +export function validateClientInput(input: ClientInput): string | null { + if (!input.name) return "Enter a name for the client."; + if (!input.redirectUris.length) return "Add at least one redirect URI."; + for (const uri of input.redirectUris) { + try { + new URL(uri); // must be an absolute URL — any scheme (public/native clients use custom ones) + } catch { + return `"${uri}" is not a valid redirect URI — use an absolute URL like https://app.example.com/callback.`; + } + } + return null; +} + +// ---- list view model ---- + +interface ListState { + page: number; + pageSize: number; + q: string; +} + +function detailHref(id: string): string { + return `${ADMIN_CLIENTS_BASE}/${encodeURIComponent(id)}`; +} + +function listHref(state: ListState, overrides: Partial = {}): string { + const s = { ...state, ...overrides }; + const p = new URLSearchParams(); + if (s.q) p.set("q", s.q); + if (s.page > 1) p.set("page", String(s.page)); + if (s.pageSize !== DEFAULT_PAGE_SIZE) p.set("pageSize", String(s.pageSize)); + const qs = p.toString(); + return qs ? `${ADMIN_CLIENTS_BASE}?${qs}` : ADMIN_CLIENTS_BASE; +} + +export function buildClientsListModel(opts: { + clients: OAuth2Client[]; + csrfToken?: string; + menu?: MenuConfig; + url: URL | URLSearchParams | string; + user?: User | null; +}) { + const menu = opts.menu ?? DEFAULT_MENU; + const query = parseListQuery(opts.url, { defaultPageSize: DEFAULT_PAGE_SIZE }); + const needle = query.q.toLowerCase(); + + const all = opts.clients.map(toClientView); + const list = all.filter((c) => !needle || c.name.toLowerCase().includes(needle) || c.id.toLowerCase().includes(needle)); + + const page = paginate(list.length, query.page, query.pageSize, { boundaries: 1, siblings: 1 }); + const start = (page.page - 1) * page.pageSize; + const rows = list.slice(start, start + page.pageSize); + const state: ListState = { page: page.page, pageSize: page.pageSize, q: query.q }; + + return { + filterBar: listFilterBar(state), + nav: adminNav(opts.user?.roles ?? [], menu, "clients"), + pagination: listPagination(state, page), + shell: buildShellContext({ + breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: "Admin" }, { label: "OAuth2 clients" }], + csrfToken: opts.csrfToken ?? "", + menu, + title: "OAuth2 clients", + user: opts.user ?? null, + }), + table: listTable(rows), + }; +} + +function listTable(rows: ClientView[]) { + return { + caption: "OAuth2 clients", + columns: [{ label: "Name" }, { label: "Client ID" }, { label: "Type" }], + rows: rows.map((c) => ({ + cells: [ + { rowHeader: { href: detailHref(c.id), text: c.name } }, + { className: "cell-muted", text: c.id }, + { badge: { label: c.public ? "Public" : "Confidential", tone: c.public ? "warn" : "info" } }, + ], + name: c.name, + })), + }; +} + +function listFilterBar(state: ListState) { + const pills: { label: string; remove: string; value: string }[] = []; + if (state.q) pills.push({ label: "Search", remove: listHref(state, { page: 1, q: "" }), value: state.q }); + return { + applyLabel: "Apply", + clearHref: ADMIN_CLIENTS_BASE, + label: "Filter clients", + pills, + rows: [[ + { label: "Search clients", name: "q", placeholder: "Search name or client ID…", type: "search", value: state.q }, + { type: "spacer" }, + ]], + }; +} + +function listPagination(state: ListState, page: ReturnType) { + const hidden: { name: string; value: string }[] = []; + if (state.q) hidden.push({ name: "q", value: state.q }); + return { + label: "Clients pagination", + next: { href: page.next ? listHref(state, { page: page.next }) : undefined }, + pages: page.pages.map((p) => + p.ellipsis ? { ellipsis: true } + : p.current ? { current: true, label: String(p.page) } + : { href: listHref(state, { page: p.page as number }), label: String(p.page) }), + prev: { href: page.prev ? listHref(state, { page: page.prev }) : undefined }, + rows: { hidden, label: "Rows", name: "pageSize", options: PAGE_SIZES, submitLabel: "Go", value: state.pageSize }, + summary: { from: page.from, to: page.to, total: page.total }, + }; +} + +// ---- register form + detail view models ---- + +export function buildClientFormModel(opts: { + csrfToken?: string; + error?: string; + menu?: MenuConfig; + user?: User | null; + values?: Partial; +}) { + const menu = opts.menu ?? DEFAULT_MENU; + const v = opts.values; + const nameField: FieldConfig = { + autocomplete: "off", icon: "i-box", id: "name", label: "Name", name: "name", required: true, value: v?.name ?? "", + }; + const scopeField: FieldConfig = { + hint: "Space-separated scopes the client may request.", id: "scope", label: "Scopes", name: "scope", + value: v?.scope ?? DEFAULT_SCOPE, + }; + return { + error: opts.error, + form: { + action: ADMIN_CLIENTS_BASE, + cancelHref: ADMIN_CLIENTS_BASE, + csrfToken: opts.csrfToken ?? "", + firstParty: v?.firstParty ?? false, + nameField, + public: v?.public ?? false, + redirectUris: (v?.redirectUris ?? []).join("\n"), + scopeField, + submitLabel: "Register client", + }, + nav: adminNav(opts.user?.roles ?? [], menu, "clients"), + shell: buildShellContext({ + breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: "OAuth2 clients" }, { label: "Register" }], + csrfToken: opts.csrfToken ?? "", + menu, + title: "Register client", + user: opts.user ?? null, + }), + }; +} + +export function buildClientDetailModel(opts: { + client: ClientView; + created?: boolean; // just registered → success banner + the one-time secret (if any) + csrfToken?: string; + menu?: MenuConfig; + secret?: string; // one-time client_secret (confidential clients), shown once right after create + user?: User | null; +}) { + const menu = opts.menu ?? DEFAULT_MENU; + const base = detailHref(opts.client.id); + return { + client: opts.client, + created: opts.created ?? false, + csrfToken: opts.csrfToken ?? "", + delete: { action: `${base}/delete` }, + nav: adminNav(opts.user?.roles ?? [], menu, "clients"), + secret: opts.secret, + shell: buildShellContext({ + breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: "OAuth2 clients" }, { label: opts.client.name }], + csrfToken: opts.csrfToken ?? "", + menu, + title: opts.created ? "Client registered" : opts.client.name, + user: opts.user ?? null, + }), + }; +} + +// ---- request handler (imperative shell) ---- + +export interface AdminClientsDeps { + csrfSecret: string; + hydra: HydraAdmin; + menu: MenuConfig; + render: (view: string, data: Record) => Promise; +} + +function readClientInput(form: URLSearchParams): ClientInput { + return { + firstParty: form.get("firstParty") === "on", + name: (form.get("name") ?? "").trim(), + public: form.get("public") === "on", + redirectUris: parseRedirectUris(form.get("redirectUris") ?? ""), + scope: (form.get("scope") ?? "").trim(), + }; +} + +export async function handleAdminClients(ctx: RequestContext, csrfToken: string, deps: AdminClientsDeps): Promise { + const path = ctx.url.pathname; + if (path !== ADMIN_CLIENTS_BASE && !path.startsWith(`${ADMIN_CLIENTS_BASE}/`)) return null; + + const user = requireAdmin(ctx); // signed-in admin only (else GuardError → /login or 403) + const { hydra, menu, render } = deps; + const method = (ctx.req.method ?? "GET").toUpperCase(); + const seg = path.slice(ADMIN_CLIENTS_BASE.length).split("/").filter(Boolean); + const form = await guardedForm(ctx, deps.csrfSecret); // parsed + CSRF-verified on POST, else undefined + + const renderForm = async (extra: { error?: string; values?: Partial }): Promise => + ({ html: await render("admin/client-form", { model: buildClientFormModel({ csrfToken, menu, user, ...extra }) }) }); + const renderDetail = async (client: OAuth2Client, extra: { created?: boolean; secret?: string } = {}): Promise => + ({ html: await render("admin/client-detail", { model: buildClientDetailModel({ client: toClientView(client), csrfToken, menu, user, ...extra }) }) }); + const notFound = async (): Promise => ({ html: await render("404", { title: "Not found" }), status: 404 }); + + // /admin/clients — list (GET) · register (POST) + if (seg.length === 0) { + if (method === "GET") { + const { clients } = await hydra.listClients({ pageSize: LIST_FETCH_SIZE }); + return { html: await render("admin/clients", { model: buildClientsListModel({ clients, csrfToken, menu, url: ctx.url, user }) }) }; + } + if (method === "POST") { + const input = readClientInput(form!); + const error = validateClientInput(input); + if (error) return { ...(await renderForm({ error, values: input })), status: 400 }; + let created: OAuth2Client; + try { + created = await hydra.createClient(clientPayload(input)); + } catch (err) { + // A Hydra 4xx (bad redirect/scope it rejects) is the operator's input — re-render the form; + // a 5xx (Hydra down) rethrows → 500. Mirrors the §6 challenge-handler degrade. + if (err instanceof HydraError && err.status < 500) { + return { ...(await renderForm({ error: "Hydra rejected the client — check the redirect URIs and scopes.", values: input })), status: 400 }; + } + throw err; + } + // Show the one-time secret now (Hydra never returns it again) — render the detail directly. + return renderDetail(created, { created: true, ...(created.client_secret ? { secret: created.client_secret } : {}) }); + } + return null; + } + + // /admin/clients/new — register form + if (seg.length === 1 && seg[0] === "new" && method === "GET") return renderForm({}); + + // /admin/clients/:id … + const id = safeDecode(seg[0]!); + if (id === null) return notFound(); + const client = await hydra.getClient(id); + if (!client) return notFound(); + const base = detailHref(id); + + if (seg.length === 1 && method === "GET") return renderDetail(client); + + if (seg.length === 2 && seg[1] === "delete" && method === "GET") { + const name = toClientView(client).name; + return { html: await render("admin/confirm", { model: buildConfirmModel({ + breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: "OAuth2 clients" }, { href: base, label: name }, { label: "Delete" }], + cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: "Delete client", csrfToken, + current: "clients", menu, message: `Delete client ${name}? Apps using it can no longer sign in through Plainpages.`, title: "Delete client", user, + }) }) }; + } + if (seg.length === 2 && seg[1] === "delete" && method === "POST") { + await hydra.deleteClient(id); + return { redirect: ADMIN_CLIENTS_BASE }; + } + return null; +} diff --git a/src/admin-nav.ts b/src/admin-nav.ts index ba9cc6c..be8ad8d 100644 --- a/src/admin-nav.ts +++ b/src/admin-nav.ts @@ -16,13 +16,15 @@ export const ADMIN_PERMISSION = "admin"; // role token gating the admin section export const ADMIN_USERS_BASE = "/admin/users"; export const ADMIN_GROUPS_BASE = "/admin/groups"; export const ADMIN_ROLES_BASE = "/admin/roles"; +export const ADMIN_CLIENTS_BASE = "/admin/clients"; -export type AdminScreen = "groups" | "roles" | "users"; +export type AdminScreen = "clients" | "groups" | "roles" | "users"; const ITEMS: { href: string; icon: string; id: AdminScreen; label: string }[] = [ { href: ADMIN_USERS_BASE, icon: "i-users", id: "users", label: "Users" }, { href: ADMIN_GROUPS_BASE, icon: "i-layers", id: "groups", label: "Groups" }, { href: ADMIN_ROLES_BASE, icon: "i-shield", id: "roles", label: "Roles" }, + { href: ADMIN_CLIENTS_BASE, icon: "i-globe", id: "clients", label: "OAuth2 clients" }, ]; // The gated "Admin" header + its three screens; `current` marks the active screen and opens the diff --git a/src/app.test.ts b/src/app.test.ts index 2023bcf..f0c442d 100644 --- a/src/app.test.ts +++ b/src/app.test.ts @@ -9,7 +9,7 @@ import { fileURLToPath } from "node:url"; import { createApp, type AppOptions } from "./app.ts"; import { CSRF_COOKIE, issueCsrfToken } from "./csrf.ts"; import { can, check, GuardError, requireSession } from "./guards.ts"; -import { HydraError, type HydraAdmin } from "./hydra-admin.ts"; +import { HydraError, type HydraAdmin, type OAuth2Client } from "./hydra-admin.ts"; import { staticJwks } from "./jwks.ts"; import type { ExpandTree, KetoClient, RelationTuple, SubjectSet } from "./keto-client.ts"; import type { Identity, KratosAdmin } from "./kratos-admin.ts"; @@ -498,8 +498,12 @@ test("logout (CSRF-guarded POST): valid token revokes the Kratos session + clear const stubHydra = (over: Partial = {}): HydraAdmin => ({ acceptConsentRequest: async () => ({ redirect: "http://127.0.0.1:4444/oauth2/auth?consent_verifier=v" }), acceptLoginRequest: async () => ({ redirect: "http://127.0.0.1:4444/oauth2/auth?login_verifier=v" }), + createClient: async (c) => ({ ...c, client_id: "c1", client_secret: "s3cr3t" }), + deleteClient: async () => {}, + getClient: async () => null, getConsentRequest: async () => ({ challenge: "cons1", client: { client_name: "Acme Reports" }, requested_scope: ["openid", "profile"], skip: false, subject: OAUTH_SUBJECT }), getLoginRequest: async () => ({ challenge: "chal1", skip: false, subject: "" }), + listClients: async () => ({ clients: [], nextPageToken: null }), rejectConsentRequest: async () => ({ redirect: "http://acme.example/cb?error=access_denied" }), rejectLoginRequest: async () => { throw new Error("unused"); }, ...over, @@ -839,6 +843,63 @@ test("admin Roles screen: gate, list, create, assign user/group, effective acces assert.equal((await get("/admin/roles/%ZZ")).status, 404); }); +// Built-in OAuth2 clients admin screen (§6): gate + list/register/detail/delete over HTTP against an +// in-memory Hydra. Registration shows the one-time client_secret on the post-create page (no PRG). +test("admin OAuth2 clients screen: gate, list, register (one-time secret), detail, delete (CSRF-guarded)", async (t) => { + const store: OAuth2Client[] = [ + { client_id: "existing", client_name: "Reporting", redirect_uris: ["https://reporting.example/cb"], scope: "openid", token_endpoint_auth_method: "client_secret_basic" }, + ]; + let seq = 0; + const hydra = stubHydra({ + createClient: async (c) => { const created = { ...c, client_id: `gen-${++seq}`, client_secret: `secret-${seq}` }; store.push(created); return created; }, + deleteClient: async (id) => { const i = store.findIndex((c) => c.client_id === id); if (i >= 0) store.splice(i, 1); }, + getClient: async (id) => store.find((c) => c.client_id === id) ?? null, + listClients: async () => ({ clients: store, nextPageToken: null }), + }); + const { get, post, token, url } = await adminHarness(t, { hydra }); + + await assertAdminGate(url, get, "/admin/clients"); + + // List: the existing client shows + the "register" link. + const listHtml = await (await get("/admin/clients")).text(); + assert.match(listHtml, /href="\/admin\/clients\/existing"/); + assert.match(listHtml, /href="\/admin\/clients\/new"/); + assert.match(listHtml, /Reporting/); + + // Register: the form renders; a valid post creates the client and shows the one-time secret + id. + assert.match(await (await get("/admin/clients/new")).text(), /Register client/); + const created = await post("/admin/clients", `_csrf=${token}&name=Grafana&redirectUris=${encodeURIComponent("https://graf/cb")}&scope=openid+offline_access`); + assert.equal(created.status, 200); // not a redirect — the secret is shown once + const createdHtml = await created.text(); + assert.match(createdHtml, /Client registered/); + assert.match(createdHtml, /secret-1/); // the one-time client_secret + assert.match(createdHtml, /gen-1/); // the generated client_id + assert.ok(store.some((c) => c.client_name === "Grafana" && c.token_endpoint_auth_method === "client_secret_basic")); + + // Invalid input (missing redirect URI) and a missing CSRF token are both refused, nothing created. + const before = store.length; + assert.equal((await post("/admin/clients", `_csrf=${token}&name=NoRedirect&redirectUris=`)).status, 400); + assert.equal((await post("/admin/clients", `name=x&redirectUris=${encodeURIComponent("https://x/cb")}`)).status, 403); + assert.equal(store.length, before); + + // Detail: read-only info, never the secret again, a delete control. + const detail = await (await get("/admin/clients/existing")).text(); + assert.match(detail, /reporting\.example\/cb/); + assert.doesNotMatch(detail, /Client secret/i); // the secret is shown only once, at creation + assert.match(detail, /href="\/admin\/clients\/existing\/delete"/); + + // Delete: a confirm step (GET) then the POST removes the client, back to the list. + assert.match(await (await get("/admin/clients/existing/delete")).text(), /Cancel/); + const del = await post("/admin/clients/existing/delete", `_csrf=${token}`); + assert.equal(del.status, 303); + assert.equal(del.headers.get("location"), "/admin/clients"); + assert.ok(!store.some((c) => c.client_id === "existing")); + + // Unknown id → 404; malformed %-encoding doesn't 500. + assert.equal((await get("/admin/clients/does-not-exist")).status, 404); + assert.equal((await get("/admin/clients/%ZZ")).status, 404); +}); + 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); diff --git a/src/app.ts b/src/app.ts index 25951bd..4a0ac68 100644 --- a/src/app.ts +++ b/src/app.ts @@ -3,7 +3,8 @@ import { createServer, type Server, type ServerResponse } from "node:http"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import * as ejs from "ejs"; -import { ADMIN_GROUPS_BASE, ADMIN_ROLES_BASE, ADMIN_USERS_BASE } from "./admin-nav.ts"; +import { ADMIN_CLIENTS_BASE, ADMIN_GROUPS_BASE, ADMIN_ROLES_BASE, ADMIN_USERS_BASE } from "./admin-nav.ts"; +import { type AdminClientsDeps, handleAdminClients } from "./admin-clients.ts"; import { type AdminGroupsDeps, handleAdminGroups } from "./admin-groups.ts"; import { type AdminRolesDeps, handleAdminRoles } from "./admin-roles.ts"; import { type AdminUsersDeps, handleAdminUsers } from "./admin-users.ts"; @@ -86,6 +87,8 @@ export function createApp(options: AppOptions = {}): Server { const adminDeps: AdminUsersDeps | null = kratosAdmin ? { csrfSecret, kratosAdmin, menu, render } : null; const adminGroupsDeps: AdminGroupsDeps | null = kratosAdmin && keto ? { csrfSecret, keto, kratosAdmin, menu, render } : null; const adminRolesDeps: AdminRolesDeps | null = kratosAdmin && keto ? { csrfSecret, keto, kratosAdmin, menu, render } : null; + // OAuth2 clients (§6) write to Hydra; wired only when the Hydra admin client is present. + const adminClientsDeps: AdminClientsDeps | null = hydra ? { csrfSecret, hydra, menu, render } : null; const sendHtml = (res: ServerResponse, status: number, html: string): void => { res.writeHead(status, { "content-type": "text/html; charset=utf-8" }); @@ -181,6 +184,14 @@ export function createApp(options: AppOptions = {}): Server { return; } } + if (adminClientsDeps && pathname.startsWith(ADMIN_CLIENTS_BASE)) { + const result = await handleAdminClients(ctx, csrf.token, adminClientsDeps); + if (result) { + if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies })); + await sendResult(res, result, () => Promise.reject(new Error("admin screens return html, not view"))); + return; + } + } // Themed Kratos self-service pages (login/registration/recovery/verification/settings). const flowType = AUTH_FLOWS[pathname]; diff --git a/src/dashboard.test.ts b/src/dashboard.test.ts index d0bf1ce..fd2e850 100644 --- a/src/dashboard.test.ts +++ b/src/dashboard.test.ts @@ -73,11 +73,11 @@ test("dashboard applies the central menu config: branding + nav override (rename }); test("dashboard menu wires in the permission-gated Admin section (only for admins)", () => { - // An admin sees the Admin section with the three built-in screens. + // An admin sees the Admin section with the four built-in screens. const admin = buildDashboardModel(new URL("http://x/"), ["admin"]); const adminNode = admin.nav.find((n) => n.label === "Admin"); assert.ok(adminNode, "admin role → Admin section present"); - assert.deepEqual(adminNode!.children?.map((c) => c.href), ["/admin/users", "/admin/groups", "/admin/roles"]); + assert.deepEqual(adminNode!.children?.map((c) => c.href), ["/admin/users", "/admin/groups", "/admin/roles", "/admin/clients"]); // A non-admin (default []) never sees it — composeNav drops the gated header + its subtree. const plain = buildDashboardModel(new URL("http://x/")); diff --git a/src/hydra-admin.test.ts b/src/hydra-admin.test.ts index 76697c0..6a83dc9 100644 --- a/src/hydra-admin.test.ts +++ b/src/hydra-admin.test.ts @@ -84,3 +84,39 @@ test("a non-2xx response throws a HydraError carrying the status", async () => { (e: unknown) => e instanceof HydraError && e.status === 404, ); }); + +// OAuth2 client registration (§6): create/list/get/delete clients over Hydra's admin API. +test("createClient POSTs the client and returns it (incl. the one-time client_secret)", async () => { + const created = { client_id: "c1", client_name: "Acme", client_secret: "s3cr3t", redirect_uris: ["https://acme/cb"] }; + const { calls, fetchImpl } = recorder(() => res(201, created)); + const out = await createHydraAdmin({ baseUrl: BASE, fetchImpl }).createClient({ client_name: "Acme", redirect_uris: ["https://acme/cb"] }); + assert.deepEqual(out, created); + assert.equal(calls[0]!.method, "POST"); + assert.match(calls[0]!.url, /\/admin\/clients$/); + assert.deepEqual(JSON.parse(calls[0]!.body!), { client_name: "Acme", redirect_uris: ["https://acme/cb"] }); +}); + +test("listClients GETs a page and parses the Link rel=next page_token", async () => { + const body = JSON.stringify([{ client_id: "c1" }, { client_id: "c2" }]); + const headers = new Headers({ "content-type": "application/json", link: '; rel="next"' }); + const { calls, fetchImpl } = recorder(() => new Response(body, { headers, status: 200 })); + const out = await createHydraAdmin({ baseUrl: BASE, fetchImpl }).listClients({ pageSize: 2 }); + assert.deepEqual(out.clients.map((c) => c.client_id), ["c1", "c2"]); + assert.equal(out.nextPageToken, "tok2"); + assert.equal(calls[0]!.method, "GET"); + assert.match(calls[0]!.url, /\/admin\/clients\?page_size=2$/); +}); + +test("getClient returns the client; a 404 → null", async () => { + const found = await createHydraAdmin({ baseUrl: BASE, fetchImpl: recorder(() => res(200, { client_id: "c1" })).fetchImpl }).getClient("c1"); + assert.deepEqual(found, { client_id: "c1" }); + const missing = await createHydraAdmin({ baseUrl: BASE, fetchImpl: recorder(() => res(404, { error: "Not Found" })).fetchImpl }).getClient("gone"); + assert.equal(missing, null); +}); + +test("deleteClient DELETEs the client by id (204)", async () => { + const { calls, fetchImpl } = recorder(() => res(204)); + await createHydraAdmin({ baseUrl: BASE, fetchImpl }).deleteClient("c1"); + assert.equal(calls[0]!.method, "DELETE"); + assert.match(calls[0]!.url, /\/admin\/clients\/c1$/); +}); diff --git a/src/hydra-admin.ts b/src/hydra-admin.ts index c818129..14572c7 100644 --- a/src/hydra-admin.ts +++ b/src/hydra-admin.ts @@ -7,7 +7,18 @@ export interface OAuth2Client { client_id?: string; client_name?: string; + client_secret?: string; // write-only: Hydra returns it once, on create, for a confidential client + grant_types?: string[]; metadata?: Record; // arbitrary client metadata; `first_party: true` ⇒ auto-consent (§6) + redirect_uris?: string[]; + response_types?: string[]; + scope?: string; // space-separated + token_endpoint_auth_method?: string; // "client_secret_basic" (confidential) | "none" (public/PKCE) +} + +export interface ClientList { + clients: OAuth2Client[]; + nextPageToken: string | null; // cursor for the next page; null on the last } // A login request Hydra hands us at /oauth2/login. `skip` ⇒ Hydra already authenticated this @@ -79,12 +90,23 @@ export class HydraError extends Error { export interface HydraAdmin { acceptConsentRequest(challenge: string, body: AcceptConsent): Promise; acceptLoginRequest(challenge: string, body: AcceptLogin): Promise; + createClient(client: OAuth2Client): Promise; + deleteClient(id: string): Promise; + getClient(id: string): Promise; getConsentRequest(challenge: string): Promise; getLoginRequest(challenge: string): Promise; + listClients(opts?: { pageSize?: number; pageToken?: string }): Promise; rejectConsentRequest(challenge: string, body: RejectRequest): Promise; rejectLoginRequest(challenge: string, body: RejectRequest): Promise; } +// Hydra paginates with a Link header; pull the page_token of rel="next" (the href is relative, so +// resolve against a throwaway base to read the query param). Mirrors kratos-admin's helper. +function nextPageToken(link: string | null): string | null { + const href = link?.match(/<([^>]+)>\s*;\s*rel="next"/)?.[1]; + return href ? new URL(href, "http://hydra").searchParams.get("page_token") : null; +} + export function createHydraAdmin(config: { baseUrl: string; fetchImpl?: typeof fetch }): HydraAdmin { const base = config.baseUrl.replace(/\/+$/, ""); const http = config.fetchImpl ?? fetch; @@ -92,6 +114,8 @@ export function createHydraAdmin(config: { baseUrl: string; fetchImpl?: typeof f // Hydra keys the login/consent handshake off a ?login_challenge=/?consent_challenge= query. const reqUrl = (kind: "consent" | "login", challenge: string, action = "") => `${base}/admin/oauth2/auth/requests/${kind}${action}?${kind}_challenge=${encodeURIComponent(challenge)}`; + const clientsUrl = `${base}/admin/clients`; + const clientUrl = (id: string) => `${clientsUrl}/${encodeURIComponent(id)}`; async function fail(action: string, res: Response): Promise { throw new HydraError(`Hydra admin ${action} failed (${res.status})`, res.status, await res.text()); @@ -113,6 +137,26 @@ export function createHydraAdmin(config: { baseUrl: string; fetchImpl?: typeof f return put("accept login", reqUrl("login", challenge, "/accept"), body); }, + // OAuth2 client registration (§6, admin screen). Hydra generates the client_id/secret when + // omitted; the secret rides the 201 body and is never retrievable afterwards. + async createClient(client) { + const res = await http(clientsUrl, { body: JSON.stringify(client), headers: json, method: "POST" }); + if (res.status !== 201) return fail("create client", res); + return (await res.json()) as OAuth2Client; + }, + + async deleteClient(id) { + const res = await http(clientUrl(id), { method: "DELETE" }); + if (res.status !== 204) await fail("delete client", res); + }, + + async getClient(id) { + const res = await http(clientUrl(id)); + if (res.status === 404) return null; + if (res.status !== 200) return fail("get client", res); + return (await res.json()) as OAuth2Client; + }, + async getConsentRequest(challenge) { const res = await http(reqUrl("consent", challenge)); if (res.status !== 200) return fail("get consent request", res); @@ -125,6 +169,15 @@ export function createHydraAdmin(config: { baseUrl: string; fetchImpl?: typeof f return (await res.json()) as LoginRequest; }, + async listClients(opts = {}) { + const url = new URL(clientsUrl); + if (opts.pageSize !== undefined) url.searchParams.set("page_size", String(opts.pageSize)); + if (opts.pageToken) url.searchParams.set("page_token", opts.pageToken); + const res = await http(url); + if (res.status !== 200) return fail("list clients", res); + return { clients: (await res.json()) as OAuth2Client[], nextPageToken: nextPageToken(res.headers.get("link")) }; + }, + async rejectConsentRequest(challenge, body) { return put("reject consent", reqUrl("consent", challenge, "/reject"), body); }, diff --git a/src/oauth-consent.test.ts b/src/oauth-consent.test.ts index e139f58..80df9e8 100644 --- a/src/oauth-consent.test.ts +++ b/src/oauth-consent.test.ts @@ -13,13 +13,18 @@ const REDIRECT = "http://hydra/oauth2/auth?consent_verifier=v"; const DENIED = "http://client/cb?error=access_denied"; function stubHydra(consent: ConsentRequest, capture?: (b: AcceptConsent) => void): HydraAdmin { + const unused = async () => { throw new Error("unused"); }; return { acceptConsentRequest: async (_c, body) => { capture?.(body); return { redirect: REDIRECT }; }, - acceptLoginRequest: async () => { throw new Error("unused"); }, + acceptLoginRequest: unused, + createClient: unused, + deleteClient: unused, + getClient: unused, getConsentRequest: async () => consent, - getLoginRequest: async () => { throw new Error("unused"); }, + getLoginRequest: unused, + listClients: unused, rejectConsentRequest: async () => ({ redirect: DENIED }), - rejectLoginRequest: async () => { throw new Error("unused"); }, + rejectLoginRequest: unused, }; } const stubKratos = (whoami: KratosPublic["whoami"]): KratosPublic => ({ diff --git a/src/oauth-login.test.ts b/src/oauth-login.test.ts index 62b87f4..782fb0d 100644 --- a/src/oauth-login.test.ts +++ b/src/oauth-login.test.ts @@ -15,8 +15,12 @@ function stubHydra(login: LoginRequest, capture?: (b: AcceptLogin) => void): Hyd return { acceptConsentRequest: unused, acceptLoginRequest: async (_c, body) => { capture?.(body); return { redirect: "http://hydra/oauth2/auth?login_verifier=v" }; }, + createClient: unused, + deleteClient: unused, + getClient: unused, getConsentRequest: unused, getLoginRequest: async () => login, + listClients: unused, rejectConsentRequest: unused, rejectLoginRequest: unused, }; diff --git a/todo.md b/todo.md index b82f646..4d05c9b 100644 --- a/todo.md +++ b/todo.md @@ -17,7 +17,7 @@ everything via Docker. - [x] Request context type threaded to handlers: `{ req, res, url, params, query, user|null, roles }`. → `src/context.ts` (`RequestContext` + `buildContext`); `roles` mirror `user.roles`, the §2 router/§4 JWT middleware supply `params`/`user`. - [x] Error templates: add 403 + 500 (404 exists). → `views/403.ejs` + `views/500.ejs`; 500 wired into `app.ts` error handler (HTML, plain-text fallback). - [x] Config/env loader: Ory endpoints, cookie/CSRF secret, JWKS location, ports. → `src/config.ts` (`loadConfig`); validated at boot, dev defaults for clean-clone, prod requires real secrets; wired into `server.ts`. -- [x] Run the architecture and the product reviewer agents on the _whole_ project, not just the latest changes, and address their issues. → Both: no bugs/security issues. Addressed: wired `buildContext` into `app.ts`; graceful SIGTERM/SIGINT shutdown; EJS template caching in prod. Deferred `core/`/`shell/` split (premature for an 8-file scaffold; revisit at §2/§4). +- [x] Run the architecture and the product reviewer agents on the _whole_ project, not just the latest changes, and address their issues. → Both: no bugs/security issues. Addressed: wired `buildContext` into `app.ts`; graceful SIGTERM/SIGINT shutdown; EJS template caching in prod. Deferred `core/`/`shell/` split (premature for an 8-file scaffold; revisit at §2/§4). - [x] Go over all comments in the code and the README and try to make it shorter and more information dense. Remove not strictly needed stuff. → Tightened comments across `src/*.ts`, Dockerfile, and trimmed verbose/duplicated prose in README; tests + typecheck green. - [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. → Merged related cases across jwt/cookie/app/context/config tests (59 → 42), every assertion preserved; typecheck + tests green. @@ -52,7 +52,7 @@ everything via Docker. - [x] Per-plugin static serving: `plugins//public/` → `/public//`. → `routePublic` (pure, in `src/static.ts`), wired into `app.ts`'s existing `/public/` branch. A request `/public/` whose leading segment names a discovered plugin serves from `plugins//public/`; anything else (e.g. `css/styles.css`) stays on the core `public/`. Disambiguates by the discovered plugin-id set, so only mounted plugins expose assets and core paths are unaffected; plugin ids are URL-safe so the raw segment compares directly (no decode needed). Reuses `serveStatic` unchanged, so the sub-path keeps its decode + traversal/control-char guard (encoded `..` ⇒ 403) and HEAD support; a missing `public/` or file ⇒ 404. Tests-first: a `routePublic` unit (plugin/core split, nested asset, bare `/public/`) + the `app.test.ts` plugin integration now serves a real `demo/public/app.css` (200 + `text/css`) and still 403s a traversal; typecheck + 103 units green. `config/menu.ts` central override is the next §2 item. - [x] `config/menu.ts` central override: reorder/rename/hide/group + branding (app name, logo, default theme). → `src/menu-config.ts` (`MenuConfig`/`Branding`/`MenuConfigInput`, `defineMenu()` identity helper, `DEFAULT_MENU`, `loadMenuConfig()`) + the operator file `config/menu.ts`. The override is `composeNav`'s existing `NavOverride` (reorder/rename/group/hide by node id, applied before the per-user filter); branding = `{ name, logo?, sub?, theme? }`. `loadMenuConfig` (imperative shell) dynamically imports `config/menu.ts` if present, validates the authored shape fail-loud (branding field types + `theme` enum, override `hide`/`order` string-arrays / `groups` array / `rename` object), merges branding over defaults; **absent file ⇒ `DEFAULT_MENU`** (clean clone). Wired: `server.ts` loads it at boot → `createApp({ menu })` → `buildDashboardModel(url, roles, menu)` feeds `menu.override` into `composeNav` and `menu.branding` (name/sub) into the shell brand. `config/menu.ts` ships defaults matching prior behaviour (name "Plainpages"/sub "Console", empty override), so a clean clone is unchanged. Added `config` to tsconfig `include` so the authored file is type-checked (Dockerfile `COPY . .` already bakes it). Tests-first: `menu-config.test.ts` (absent⇒defaults / read+merge / malformed⇒throws) + a `dashboard.test.ts` case asserting rename+hide+branding take effect; typecheck (incl. `config/`) + 107 units green; smoke-loaded the real file at boot. **Rendering branding (logo, default theme) into the app shell is the next §2 item.** - [x] Wire branding into the app shell. → Completes the §2 branding chain (name/sub already flowed). `shell.ejs` now renders `brand.logo` as `` when set, else the default `#i-box` brand-mark; the `theme` local (already forwarded to the theme-switch) is now supplied. `buildDashboardModel` puts `menu.branding.logo` into `shell.brand` and `menu.branding.theme` into `shell.theme` (both omitted when unset, so a clean clone is unchanged → brand-mark + auto theme); `views/index.ejs` forwards `theme` to the shell. Added a `.brand-logo` CSS rule (22px, matches `.brand-mark` sizing). Tests-first: `shell.test.ts` (logo replaces the mark + default theme checked; no-logo ⇒ mark + auto) + extended `dashboard.test.ts` (logo→brand, theme→shell.theme) + an `app.test.ts` integration rendering `createApp({ menu })` end-to-end (logo `` + `theme-dark` checked on `/`). Default-app shell rendering is byte-equivalent, so the visual E2E is unaffected; typecheck + 109 units green. The §2 plugin host is feature-complete (remaining §2 items are the project-wide review + comment/test cleanup). -- [x] Run the architecture and the product reviewer agents on the _whole_ project, not just the latest changes, and address their issues. → Ran both on all of `src/`, `views/`, `config/`, Docker/tsconfig. Verdict: architecture sound + disciplined, no crash/security defect in the current path (fail-loud, traversal guards, JWT/cookie defenses all confirmed). **Fixed now:** (1) HIGH — `PluginHooks` was typed+documented but never invoked; wired it (`src/hooks.ts`: `runBootHooks`/`runRequestHooks`/`runResponseHooks`) — `server.ts` runs `onBoot` after discovery before listen, `app.ts` runs `onRequest` (before routing, first non-void short-circuits, renders against its plugin) + `onResponse` (after handler, observer, throw→500); skipped entirely when no plugin declares a hook (hot path free); `hooks.test.ts` + an `app.test.ts` integration. (2) `discovery.ts` `fail` helper retyped `: void`. (3) Documented the template trust boundary in `docs/plugin-contract.md` (raw `html`/`*.html` fields; URL sinks escaped but not scheme-checked) + tightened the Hooks prose to the wired semantics. **Deferred (reviewer-scoped, not §2):** extract a shared `buildShellContext` out of `dashboard.ts` and route the built-in screens through `matchRoute`/`isAuthorized` → §5 (premature at one call site); a `safeUrl()` helper for href sinks → §4 (no untrusted URLs until upstream data flows); doc/type-duplication + non-local `§N` refs → the §2 comment-cleanup item; HEAD-render cost + dev empty-secret fallback → negligible. typecheck + 113 units green; boot smoke-tested. +- [x] Run the architecture and the product reviewer agents on the _whole_ project, not just the latest changes, and address their issues. → Ran both on all of `src/`, `views/`, `config/`, Docker/tsconfig. Verdict: architecture sound + disciplined, no crash/security defect in the current path (fail-loud, traversal guards, JWT/cookie defenses all confirmed). **Fixed now:** (1) HIGH — `PluginHooks` was typed+documented but never invoked; wired it (`src/hooks.ts`: `runBootHooks`/`runRequestHooks`/`runResponseHooks`) — `server.ts` runs `onBoot` after discovery before listen, `app.ts` runs `onRequest` (before routing, first non-void short-circuits, renders against its plugin) + `onResponse` (after handler, observer, throw→500); skipped entirely when no plugin declares a hook (hot path free); `hooks.test.ts` + an `app.test.ts` integration. (2) `discovery.ts` `fail` helper retyped `: void`. (3) Documented the template trust boundary in `docs/plugin-contract.md` (raw `html`/`*.html` fields; URL sinks escaped but not scheme-checked) + tightened the Hooks prose to the wired semantics. **Deferred (reviewer-scoped, not §2):** extract a shared `buildShellContext` out of `dashboard.ts` and route the built-in screens through `matchRoute`/`isAuthorized` → §5 (premature at one call site); a `safeUrl()` helper for href sinks → §4 (no untrusted URLs until upstream data flows); doc/type-duplication + non-local `§N` refs → the §2 comment-cleanup item; HEAD-render cost + dev empty-secret fallback → negligible. typecheck + 113 units green; boot smoke-tested. - [x] Go over all comments in the code and the README and try to make it shorter and more information dense. Remove not strictly needed stuff. → Pass over the §2 accretion (the §0/§1 cleanup at line 21 stands). Tightened the verbose module-header blocks (`plugin.ts`, `discovery.ts`, `router.ts`, `dashboard.ts`) and collapsed the `checkApiVersion` rule comment to a one-liner that points at the contract doc (the if-chain + messages already document it). Removed now-stale forward-refs ("router wiring is the next §2 item", "rendered in the shell — next §2 item"). README: corrected the **Status** note (it undersold — §1 design system + the whole §2 plugin host are built, not just a scaffold), dropped the stale `_(planned)_`/"planned to extract" markers on **Building a plugin** and **Building blocks** (both shipped; auth guards still flagged §4), and named the real helpers. Left the security-rationale comments (jwt/cookie/static/paginate) and the EJS partials' config-doc headers intact — they carry vital info / are the only schema for untyped locals. No anchor links broke; typecheck + 113 units green. - [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. → Reviewed all 24 test files. The suite already follows the deliberate per-module "matrix + edge" pattern from the §0/§1 merge (line 22), so most files carry no fat and force-merging distinct concerns would only hurt readability. Removed the genuine §2-era overlaps, all in `app.test.ts`: merged the two HTTP static tests into one (GET/HEAD + traversal/NUL→403), and dropped the standalone "renders the 403 error page" `ejs.renderFile` stopgap (its comment even said "403 has no first-party route yet") — the gated plugin route now exercises 403 over HTTP, so the template assertions (status + 403.ejs body + stylesheet link) moved there; also dropped the now-unused `ejs` import. Unified `view-resolver.test.ts`'s two `resolveViewPath` cases (resolve + reject) into one. 113 → 110 tests, zero coverage lost; typecheck + tests green. @@ -70,7 +70,7 @@ everything via Docker. - [x] **One-command bootstrap** (the MVP bar): `docker compose up` brings up web + all Ory services + Postgres with *zero* manual prep. Commit working default Ory configs; auto-run migrations on first boot; auto-generate the JWKS signing key if absent; seed an admin identity + its Keto roles + a demo password (`admin`/`admin`) idempotently. Land an `OPL`/namespace bootstrap so Keto answers checks out of the box. → `src/bootstrap.ts` + a one-shot `bootstrap` compose service: runs after kratos+keto are healthy (web gates on its `service_completed_successfully`), idempotent so every `up` re-runs cleanly. (1) `ensureJwks` generates the ES256 signing key (reuses `gen-jwks.ts`) only when the committed dev key is absent — tokenizer dir mounted rw so it can land. (2) `seedAdmin` creates `admin@plainpages.local`/`admin` via the Kratos admin API (a re-run's 409 → look up + reuse the id). (3) grants `Role:admin#members@user:` via the Keto write API (PUT, idempotent) — the source of truth the §4 login flow projects into the JWT. Migrations + default Ory configs already auto-run/committed (§3); OPL/namespaces load from `keto.yml` (§3). The password policy is bypassed by the admin API, so `admin`/`admin` is accepted. Tests-first: `bootstrap.test.ts` (payload builders, seed idempotency via mock fetch, generate-if-absent) + `compose.test.ts` (service wiring). Boot-verified the whole chain on the live stack: `docker compose up --wait` seeds with zero prep, Keto `check` → `allowed:true`, login with `admin@plainpages.local`/`admin` issues a session + tokenizes a JWT; re-run → "already present"; moving the committed key → "generated a JWKS signing key". JWT `roles` stays `[]` until §4 wires the Keto→`metadata_admin` projection. typecheck + 151 units green. The first-run banner (login URL + creds) and the prod-secret/SSO exception docs are the next §3 items. - [x] First-run banner / log line printing the login URL + seeded admin creds, with a clear "change these before production" warning. → `firstRunBanner()` in `src/bootstrap.ts` (pure, testable) renders a boxed banner — login URL · seeded email/password · "⚠ change before production" — that `main()` prints after seeding. Login URL from `APP_URL` (compose default `http://localhost:3000`, overridable per deployment); creds reuse the seeded `ADMIN_EMAIL`/`ADMIN_PASSWORD`. Tests-first (`bootstrap.test.ts`: asserts URL + creds + warning present); README **Development** notes the banner. Live-verified: rebuilt bootstrap prints the banner after the admin line; typecheck + 152 units green; stack torn down. - [x] Document the *only* things that can't be auto-generated: third-party **SSO provider** client id/secret (optional — password login works without them) and **production secrets** (real cookie/CSRF secret + signing key, supplied via env, replacing the dev throwaways). Everything else must work from a clean clone. → New README **What you must supply (the only manual prep)** subsection (under Configuration) consolidates the previously-scattered facts into one authoritative list: a clean clone needs nothing; exactly two production-only things can't be auto-generated — (1) production secrets (`COOKIE_SECRET`/`CSRF_SECRET` + the JWT signing key, with `REQUIRE_SECURE_SECRETS=true` refusing throwaways) and (2) optional SSO provider creds (no creds ⇒ no button). States everything else (Ory migrations, dev signing key, demo admin + Keto roles, OPL model) is generated/seeded on first boot. Cross-links the existing SSO + JWT-rotation subsections (no duplication) and adds a pointer from **Production / deployment**. All four anchors verified; docs-only — typecheck + 152 units green. -- [x] Run the architecture and the product reviewer agents on the _whole_ project, not just the latest changes, and address their issues. → Ran both on the whole project (weighted to the §3 Ory stack). Verdict: architecture sound + disciplined, no Critical; both independently flagged the *same* top issue. **Fixed now:** (1) HIGH (both agents) — `JWKS_URL` default was `http://kratos:4433/.well-known/jwks.json`, but Kratos does **not** republish the session-tokenizer key there (no OIDC discovery on Kratos — that's Hydra), so the §4 verifier would have fetched the wrong/empty set and *no one* could be authorized. Repointed the default to `file:///etc/config/kratos/tokenizer/jwks.json` — the exact key Kratos signs with (`kratos.yml` `jwks_url`) — and mounted that tokenizer dir **read-only into `web`** (`compose.yml`) so the verifier resolves the live key in dev *and* prod (same file bootstrap regenerates). `config.test.ts` now locks the default to the tokenizer file + asserts the committed key is a real ES256 JWKS carrying a `kid` (the regression the old `/jwks/` match missed). (2) MEDIUM (stability) — `bootstrap` had uncapped `restart: on-failure`; a *permanent* seed error would loop forever and silently hang `web` (gates on `service_completed_successfully`). Capped to `on-failure:5` (seed is idempotent — 409-create + idempotent PUT — so transient Ory blips still recover, permanent ones give up loud). (3) §3's new `web` `depends_on` made the documented `docker compose run --rm web …` typecheck/test/gen-jwks commands drag up the whole Ory stack — added `--no-deps` (README + AGENTS.md). **Deferred (reviewer-scoped, not §3):** extract `buildShellContext` out of `dashboard.ts` + route built-in screens through `matchRoute`/`isAuthorized` → §5 (forcing function arrives with the 2nd/3rd screen); seed the demo admin's `metadata_admin.roles` projection so first login is non-empty → §4 (the login-completion projection owns it); enforce Ory `*.yml` prod secrets + self-service return-URLs via env → §9 (ops). typecheck + 153 units green; both compose files validated. +- [x] Run the architecture and the product reviewer agents on the _whole_ project, not just the latest changes, and address their issues. → Ran both on the whole project (weighted to the §3 Ory stack). Verdict: architecture sound + disciplined, no Critical; both independently flagged the *same* top issue. **Fixed now:** (1) HIGH (both agents) — `JWKS_URL` default was `http://kratos:4433/.well-known/jwks.json`, but Kratos does **not** republish the session-tokenizer key there (no OIDC discovery on Kratos — that's Hydra), so the §4 verifier would have fetched the wrong/empty set and *no one* could be authorized. Repointed the default to `file:///etc/config/kratos/tokenizer/jwks.json` — the exact key Kratos signs with (`kratos.yml` `jwks_url`) — and mounted that tokenizer dir **read-only into `web`** (`compose.yml`) so the verifier resolves the live key in dev *and* prod (same file bootstrap regenerates). `config.test.ts` now locks the default to the tokenizer file + asserts the committed key is a real ES256 JWKS carrying a `kid` (the regression the old `/jwks/` match missed). (2) MEDIUM (stability) — `bootstrap` had uncapped `restart: on-failure`; a *permanent* seed error would loop forever and silently hang `web` (gates on `service_completed_successfully`). Capped to `on-failure:5` (seed is idempotent — 409-create + idempotent PUT — so transient Ory blips still recover, permanent ones give up loud). (3) §3's new `web` `depends_on` made the documented `docker compose run --rm web …` typecheck/test/gen-jwks commands drag up the whole Ory stack — added `--no-deps` (README + AGENTS.md). **Deferred (reviewer-scoped, not §3):** extract `buildShellContext` out of `dashboard.ts` + route built-in screens through `matchRoute`/`isAuthorized` → §5 (forcing function arrives with the 2nd/3rd screen); seed the demo admin's `metadata_admin.roles` projection so first login is non-empty → §4 (the login-completion projection owns it); enforce Ory `*.yml` prod secrets + self-service return-URLs via env → §9 (ops). typecheck + 153 units green; both compose files validated. - [x] Go over all comments in the code and the README and try to make it shorter and more information dense. Remove not strictly needed stuff. → Pass over the §3 Ory accretion. Killed the now-stale "the next §3 item generates/mounts" forward-refs (the JWKS shipped) in `kratos.yml` (×2) + `kratos.test.ts`. Tightened the verbose service/header blocks in `compose.yml` (web depends_on/JWKS-mount, the three Ory headers, the bootstrap block) and the `bootstrap.ts`/`gen-jwks.ts` module headers — dropping prose the README/`src/bootstrap.ts` already carry, keeping the security/stability rationale (read-only mount, bounded retry). Trimmed `config.ts`'s JWKS comment and the `kratos.yml` SSO block (kept the concrete env example), and aligned the `gen-jwks.ts` command with the README's `--no-deps`. Net −12 lines; typecheck + 153 units green. The §3 README sections (Development / What you must supply / SSO / JWT rotation) were already authored concise in §3 (todo lines 70–72) and left intact. - [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. @@ -88,7 +88,7 @@ everything via Docker. - [x] Logout: revoke Kratos session + clear cookie. → `GET /logout` (`app.ts`): clears our local `plainpages_jwt` (`clearSessionCookie`, Max-Age=0) **and** revokes the Kratos session. Kratos' own cookie lives on its origin, so we can't expire it from here — instead `kratos.createLogoutFlow(cookie)` (new `KratosPublic` method, `GET /self-service/logout/browser` → `{logoutToken, logoutUrl}`, 401⇒null) and 303 the browser to `logoutUrl`; Kratos revokes the session, clears `plainpages_session`, and lands on `/login` (`kratos.yml` `logout.after`, already configured). No active session ⇒ just clear our cookie + 303 `/login`. Wired the inert shell "Sign out" button → `` (zero-JS, matches the menu's existing link items). Tests-first: `kratos-public.test.ts` (logout flow 200→urls / 401→null + cookie forwarded), `app.test.ts` integration (active session → Kratos logout URL + cleared JWT; no session → `/login` + cleared JWT), `shell.test.ts` (sign-out link wired). typecheck + 212 units green. Boot-verified live: admin login → `/logout` 303s to the real `…/self-service/logout?token=ory_lo_…` with `plainpages_jwt` cleared, following it revokes the session (`whoami` 200→401) and redirects to `/login`; no-session `/logout` → `/login`; torn down. - [x] Secure cookie flags; CSRF for our own POST forms. → **Secure flag:** new explicit `SECURE_COOKIES` toggle (`config.ts`, default off — dev is http; `compose.yml` sets it `true`, `compose.override.yml`/`compose.e2e.yml` `false`), threaded through every first-party Set-Cookie (session JWT, clear, re-mint, CSRF). **CSRF:** `src/csrf.ts` — stateless **signed double-submit** token `.` (node:crypto, no dep): `issueCsrfToken`/`verifyCsrfToken` (self-validating, timing-safe), `ensureCsrfToken` (reuse a genuine `plainpages_csrf` cookie, else mint — one token across tabs), `csrfCookie` (HttpOnly+Lax, secure opt-in), `verifyCsrfRequest` (cookie genuine **and** field echoes it). `src/body.ts` `readFormBody` (size-capped urlencoded reader; §5 forms reuse it). Applied to our one first-party form: **logout is now a CSRF-guarded `POST`** — `shell.ejs`'s Sign-out is a `
` with a hidden `_csrf` (semantic win: a state change is a form, not a GET link), `app.ts` issues the token cookie on `GET /` and verifies it on `POST /logout` (bad/missing → 403, before any Kratos call); `dashboard.ts`→`index.ejs`→shell thread the token. Kratos' own flows keep Kratos' CSRF; the host does **not** auto-gate plugin routes (they own their body/safety per the contract). Switched the cookie-setting sites to `appendHeader` so the CSRF cookie coexists with others. Tests-first: `csrf.test.ts`/`body.test.ts` + extended `config`/`dashboard`/`shell`/`app` tests (logout POST: valid→Kratos logout + cleared JWT, no-session→/login, missing/forged→403) + an Ory-free E2E (GET / issues the cookie + matching form token; tokenless POST→403). typecheck + 217 units + 8 E2E green. Boot-verified live on the full stack: GET / double-submit token matches; admin login → `POST /logout` 303s to the real `…/self-service/logout?token=ory_lo_…` with the JWT cleared; no-session→/login; forged/missing→403; torn down. - [x] Make sure we have E2E tests for token timeouts and refresh (maybe by shorten the token lifetime to very low or something). → New full-stack Playwright suite `e2e/auth-refresh.spec.ts` (run via `compose.e2e-auth.yml`): boots the **real** Ory stack (Postgres + Kratos + Keto + bootstrap + web), logs in the seeded admin, completes login on web → session JWT, then proves the §4 "stay signed in" hot path end-to-end — once the token lapses the next request is silently **re-minted** from the live Kratos session (fresh JWT, later `exp`, roles re-read from Keto = `["admin"]`); revoking the Kratos session (admin API) then makes the next lapsed request **clear** the stale cookie (→ anonymous). To make timeout/refresh observable in seconds not ~10m: `ory/kratos/e2e.yml` (merged via a second `-c`) shortens the tokenizer `ttl` to **8s** and points `serve.public.base_url` at `kratos:4433` (so the runner drives self-service over the compose network), and a new explicit **`JWT_CLOCK_SKEW_SEC`** config (default 60, the E2E sets `0`) makes web treat the JWT as expired the instant its ttl lapses instead of +60s. The flow is driven over HTTP (fetch + manual cookie relay) because Kratos/web sit on different hosts here — it exercises web's own server-side relay; the browser-UI login stays §8. Scoped the existing visual suite to `visual.spec.ts` (stays Ory-free/fast) so the two suites don't cross-run. Tests-first for the config knob (`config.test.ts`). Verified live: auth suite green (re-mint + clear), visual suite still 8/8 green; typecheck + 218 units green; both stacks torn down. -- [x] Run the architecture and the product reviewer agents on the _whole_ project, not just the latest changes, and address their issues. → Ran both on the whole project (weighted to the §4 auth hot path). Verdict: no Critical/High; both confirmed the auth core (alg-allowlist JWS verify, fail-closed `resolveSession`, key-by-`kid` cache, timing-safe CSRF, traversal guards) is sound, and that a tampered/garbage cookie **can't** drive the Ory re-mint round-trip (only a validly-signed, time-expired token sets `expired`). **Fixed now (tests-first):** (1) MEDIUM (stability) — the re-mint hot path turned an Ory *outage* into a 500 on **every** lapsed request (a dead Kratos session returns `null` and clears cleanly, but a 5xx/refused/timeout *throws* and escaped to the 500 handler). Wrapped the `remintSession` call in `app.ts` in try/catch → degrade to **anonymous** (route renders signed-out / guard bounces to `/login`), and leave the cookie untouched so it re-mints once Ory recovers; `app.test.ts` re-mint test now also asserts outage→403-not-500 + no cleared cookie. (2) MEDIUM (architecture) — a plugin folder named after a host route (`login`/`logout`/`auth`/`public`/`recovery`/`registration`/`settings`/`verification`) would **silently shadow** it (plugin routes resolve first), the one collision `findConflicts` didn't catch. Added `RESERVED_PLUGIN_IDS` (`plugin.ts`) checked in `discovery.ts` → fails boot loud, like every other conflict; documented in `docs/plugin-contract.md` Identity; `discovery.test.ts` covers it. **Deferred (reviewer-scoped, not §4):** extract `buildShellContext` out of `dashboard.ts` + thread the real `ctx.user` into the shell (kills the hardcoded "Sam Rivers" demo profile) **and** give the host its own internal route table via `matchRoute`/`isAuthorized` → **§5** (the 2nd/3rd built-in screen is the forcing function; the hardcoded user is the one user-visible §4 gap, so §5 opens with it); `/auth/complete` login-CSRF hardening + the `POST /logout` oversized-body→500 papercut → **§9** (security headers/CSRF/cookies); retarget the stale `safeUrl()` §4 reference in the contract doc → the next §4 comment-cleanup item (line 92), helper itself deferred to §5/§7 when untrusted URL data first flows. No action: forwarding the full cookie header to Kratos on re-mint (works, mild over-coupling), the deliberately-opt-in `iss`/`aud` claim checks, the `serializeCookie` length bound. typecheck + 219 units green. +- [x] Run the architecture and the product reviewer agents on the _whole_ project, not just the latest changes, and address their issues. → Ran both on the whole project (weighted to the §4 auth hot path). Verdict: no Critical/High; both confirmed the auth core (alg-allowlist JWS verify, fail-closed `resolveSession`, key-by-`kid` cache, timing-safe CSRF, traversal guards) is sound, and that a tampered/garbage cookie **can't** drive the Ory re-mint round-trip (only a validly-signed, time-expired token sets `expired`). **Fixed now (tests-first):** (1) MEDIUM (stability) — the re-mint hot path turned an Ory *outage* into a 500 on **every** lapsed request (a dead Kratos session returns `null` and clears cleanly, but a 5xx/refused/timeout *throws* and escaped to the 500 handler). Wrapped the `remintSession` call in `app.ts` in try/catch → degrade to **anonymous** (route renders signed-out / guard bounces to `/login`), and leave the cookie untouched so it re-mints once Ory recovers; `app.test.ts` re-mint test now also asserts outage→403-not-500 + no cleared cookie. (2) MEDIUM (architecture) — a plugin folder named after a host route (`login`/`logout`/`auth`/`public`/`recovery`/`registration`/`settings`/`verification`) would **silently shadow** it (plugin routes resolve first), the one collision `findConflicts` didn't catch. Added `RESERVED_PLUGIN_IDS` (`plugin.ts`) checked in `discovery.ts` → fails boot loud, like every other conflict; documented in `docs/plugin-contract.md` Identity; `discovery.test.ts` covers it. **Deferred (reviewer-scoped, not §4):** extract `buildShellContext` out of `dashboard.ts` + thread the real `ctx.user` into the shell (kills the hardcoded "Sam Rivers" demo profile) **and** give the host its own internal route table via `matchRoute`/`isAuthorized` → **§5** (the 2nd/3rd built-in screen is the forcing function; the hardcoded user is the one user-visible §4 gap, so §5 opens with it); `/auth/complete` login-CSRF hardening + the `POST /logout` oversized-body→500 papercut → **§9** (security headers/CSRF/cookies); retarget the stale `safeUrl()` §4 reference in the contract doc → the next §4 comment-cleanup item (line 92), helper itself deferred to §5/§7 when untrusted URL data first flows. No action: forwarding the full cookie header to Kratos on re-mint (works, mild over-coupling), the deliberately-opt-in `iss`/`aud` claim checks, the `serializeCookie` length bound. typecheck + 219 units green. - [x] Go over all comments in the code and the README and try to make it shorter and more information dense. Remove not strictly needed stuff. → Pass over the §4 auth accretion (the §3 cleanup at line 74 stands). The §4 comments were authored dense, so the wins are targeted: tightened the verbose client module-headers — `kratos-public.ts` (dropped the "themed flow pages build on this" forward-ref, kept the loose-`ui.nodes`-types rationale), `kratos-admin.ts` (folded the admin-port note up, trimmed the `KratosError` restatement), `keto-client.ts` (dropped the caller-listing tail). Retargeted the stale `safeUrl()` ref in `docs/plugin-contract.md` (the §4 reviewer flag at line 91): the helper was deferred to §5/§7, not §4. Left intact: app.ts's per-branch *why* comments (right altitude for scanning the request flow), config.ts's dense field notes, and the §4 README **Auth, sessions & permissions** sections (the canonical design rationale, authored concise in §4). `_(planned)_` markers stay for §9 (line 133 owns dropping them). typecheck + 219 units green. - [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 §4 auth tests. The clients (`kratos-public`/`kratos-admin`/`keto-client`) and the focused units (`jwks`/`flow-view`/`guards`/`csrf`/`body`/`login`) already follow the per-module "matrix + edge" pattern, no fat to cut. Removed the two genuine §4-era overlaps: (1) `jwt-middleware.test.ts` re-ran `resolveSession`'s whole classification matrix again under `authenticate` — but `authenticate` is just `resolveSession(...).user`, so merged into one test where `resolveSession` owns the matrix and `authenticate` is asserted as its fail-closed user-projection (kept `authenticate` itself — a documented convenience export, just not double-tested). (2) `app.test.ts` had two `/auth/complete` HTTP tests (live-session vs no-session) for one route → merged into one (happy path + edge), mirroring the project's style. 219 → 217 tests, zero coverage lost; typecheck + tests green. @@ -97,22 +97,22 @@ everything via Docker. - [x] Groups: Keto subject sets — list/create/delete + membership management. → `src/admin-groups.ts`: pure view-model + Keto-tuple builders (`groupsFromTuples`, `parseSubject`/`memberTuple`, `memberView`, `isValidGroupName`, `buildGroups{List,Detail,Form}Model`) + `handleAdminGroups` (the imperative shell app.ts dispatches `/admin/groups*` to). A group is a Keto subject set `Group:#members`; a member is a user (`subject_id=user:`) or a nested group (`subject_set=Group:#members`). Keto has no create-object, so a group exists while it has ≥1 member: **create** writes the first-member tuple (requires a member, rejects a duplicate/invalid name), **delete** removes every member tuple (one delete-by-partial-filter), **add/remove member** write/delete one tuple. Routes: `GET /admin/groups` (list — search/sort/paginate over one Keto namespace scan), `GET|POST /admin/groups/new`+`/` (create), `GET /admin/groups/:name` (membership detail — members by email, add a user/nested group, remove, delete-group), `POST …/members` · `…/members/delete` · `…/delete`. Writes go **only to Keto** (README "stateless"); Kratos is read only to label the member pickers by email. Gated **admin-only** (anon→/login, non-admin→403) and every mutation **CSRF-guarded**, same as Users; reuses the §1 building blocks around the shell. Extracted `src/admin-nav.ts` (shared Dashboard·Users·Groups sidebar nav) so the two screens can't drift; added a generic `rowHeader` `` data-table cell (the group name links to its detail). Tests-first: `admin-groups.test.ts` (builder/validation/subject matrix), `app.test.ts` HTTP integration (gate/list/create/dup-reject/detail/add/remove/delete + CSRF + invalid-name & malformed-`%`→404), `data-table.test.ts` (rowHeader). Stability-reviewer (treated as a local PR): APPROVE; fixed its nits — symmetric subject validation (UUID-check the user id), "already exists" feedback on create, malformed-`%`→404 (`safeDecode`). typecheck + 237 units green. Boot-verified the core Keto interactions live (namespace listing, group-collapse counts, delete-group-by-filter, single-member removal). The full-stack groups-CRUD Playwright E2E is §8's scope (line 123), as with the Users screen. Roles/permissions + global-menu wiring are the next §5 items. - [x] Roles & permissions: Keto relations — assign roles to users/groups; "effective access" view via Keto expand. → `src/admin-roles.ts`: a role is a Keto subject set `Role:#members` (OPL: members are users or groups, resolved transitively — the source of truth the §4 login projects into the JWT). Same shape as the Groups screen, so the pure membership helpers are reused from `admin-groups.ts` (`parseSubject`, `isValidGroupName`, `memberView`, `groupsFromTuples`, and now-exported `pagedTuples`/`memberCandidates`/`safeDecode`). Routes (`handleAdminRoles`, dispatched by app.ts): `GET /admin/roles` (list — search/sort/paginate over one Keto scan), `GET|POST /admin/roles/new`+`/` (create = assign first member; rejects invalid/duplicate name), `GET /admin/roles/:name` (detail), `POST …/members` (assign a user/group) · `…/members/delete` (revoke) · `…/delete` (remove all member tuples). The one role-specific piece is **effective access**: `keto.expand(Role:#members, {maxDepth:50})` → `expandToEffectiveUsers` flattens the tree to the distinct users who hold the role directly *or transitively via a group* (the coarse JWT projection stays direct-only per the README's one-read-per-login design; this view is where group→role inheritance is surfaced). Writes go **only to Keto**; Kratos is read only to label members. Gated admin-only (anon→/login, non-admin→403) + CSRF-guarded, like Users/Groups. Added a "Roles" entry (`i-shield`) to the shared `admin-nav.ts`; new `.plain-list` CSS rule. Tests-first: `admin-roles.test.ts` (builders + expand-flatten matrix) + `app.test.ts` HTTP integration (gate/list/create/dup-reject/assign user&group/effective-access-via-expand/revoke/delete + CSRF + malformed-name→404). Stability-reviewer run as a local PR: APPROVE, no Critical/High; addressed its expand-depth nit (explicit `maxDepth`). 237→243 units + typecheck green. **Live boot-verify caught a real bug the tests missed:** Keto v26.2.0's expand nests the subject under `tuple` (`{type:"leaf",tuple:{subject_id}}`), not at the node top-level as the §4 `ExpandTree` type had guessed — fixed the type + walker + the (wrongly-shaped) fixtures, then re-verified live that a user reachable only through a group surfaces in effective access; torn down. Global-menu wiring is the next §5 item. - [x] Wire into the menu (admin section, permission-gated). → Extracted `adminSection(current?)` in `admin-nav.ts` as the single source of truth for the built-in screens' menu links: a permission-gated (`admin`) "Admin" header whose children are Users/Groups/Roles. Wired into the **global** dashboard menu (`dashboard.ts` appends `adminSection()`) so an admin sees the section on `/`; `composeNav`'s `filterByRoles` drops the whole gated header + subtree for a non-admin/anonymous (cosmetic — the routes themselves stay independently `GuardError(403)`-gated). The in-screen `adminNav()` now reuses the same `adminSection(current)` (Dashboard link + the active-marked section) so the two navs can't drift; narrowed `AdminScreen` to `groups|roles|users` (the home link was never `current`). Reuses existing sprite icons (no icon-guard change). Tests-first: `dashboard.test.ts` (admin→section present with the three hrefs; non-admin→absent) + `app.test.ts` HTTP integration (admin JWT→`/admin/users` link rendered, anonymous→absent). Default anonymous `/` render is byte-equivalent (section filtered out) so the visual E2E is unaffected. README Layout line updated. Stability-reviewer run as a local PR: APPROVE, no Critical/High/Medium. 242→244 units + typecheck green. -- [x] Run the architecture and the product reviewer agents on the _whole_ project, not just the latest changes, and address their issues. → Ran both on all of `src/`/`views/`/`config/`/docs (weighted to the §5 admin screens). Architecture: **no Critical/High** (functional-core/imperative-shell genuinely honored, security primitives sound). Product: **2 Critical + 1 High**. **Fixed now (tests-first):** (1) Critical (product) — the Roles "Effective access" view showed group→role membership *transitively* but `login.ts` `readRoles` granted only **direct** memberships into the JWT, so a user holding a role *only via a group* was listed as having it yet gated as if not (two screens contradicting). Per the user's call, made `readRoles` transitive: enumerate the defined roles + Keto-`check` each (resolves group membership), so the JWT now matches the Effective-access view + the OPL model — at login/refresh only, never per request (README login section + `admin-roles.ts` header updated). (2) Critical (product) — no confirmation on destructive actions: added a server-rendered (zero-JS) confirm step (`views/admin/confirm.ejs` + `partials/confirm-body.ejs`, shared `buildConfirmModel`) — `GET /admin/{users,groups,roles}/:id/delete` renders an interstitial (Cancel + the real POST); each detail/edit Delete control is now a link to it. (3) High (product) — self-lockout: an admin can no longer delete or deactivate **their own** account, revoke **their own** (direct) admin grant, or delete the **admin role** outright (each → 400 + inline error). Covers the direct-grant paths (incl. the bootstrap-seeded admin, which holds a direct grant); admin held *only* via a group can still be self-revoked, so the robust "last effective admin won't drop" check is deferred to **§9** (stability-reviewer Medium). (4) MEDIUM (arch M1 pt.1) — extracted the gate+CSRF preamble copied verbatim across the 3 admin handlers into `admin-nav.ts` `requireAdmin`/`guardedForm` (one security-critical copy, can't drift). (5) MEDIUM (arch M4) — `shellUser` no longer blanks the email: name = email local part, full email beneath (matches `toUserView`). Tests-first throughout (extended the 3 admin HTTP tests + login/shell-context units); typecheck + 244 units + 8 visual E2E + the full-stack auth-refresh E2E green (the latter re-verifies live login→transitive `readRoles`→`roles:["admin"]`). **Deferred (reviewer-scoped, not the §5 checkpoint):** the host internal route-table (fold the admin if-ladder + Hydra into `matchRoute`/`isAuthorized`, arch M1 pt.2) → **§6** (the 2nd/3rd Hydra screen is the forcing function); admin list-model/template near-duplication across Users/Groups/Roles (arch M3) → the §5 comment/test-cleanup items below (lines 101–102); success-flash after writes + welcoming empty-list states + warn-on-dangling-group-references + >250-row truncation notice (product Medium) → §5 polish / §8 E2E; `safeUrl()` href helper (arch L1 — the recovery link is server-built, not exploitable today) → **§7** (first untrusted-URL flow); oversized-body→500 should be 413 (arch M2) + prod Ory-URL `https` enforcement (arch L3) + `§N`-in-comments / README Layout drift (arch L4) → **§9** (ops/security). +- [x] Run the architecture and the product reviewer agents on the _whole_ project, not just the latest changes, and address their issues. → Ran both on all of `src/`/`views/`/`config/`/docs (weighted to the §5 admin screens). Architecture: **no Critical/High** (functional-core/imperative-shell genuinely honored, security primitives sound). Product: **2 Critical + 1 High**. **Fixed now (tests-first):** (1) Critical (product) — the Roles "Effective access" view showed group→role membership *transitively* but `login.ts` `readRoles` granted only **direct** memberships into the JWT, so a user holding a role *only via a group* was listed as having it yet gated as if not (two screens contradicting). Per the user's call, made `readRoles` transitive: enumerate the defined roles + Keto-`check` each (resolves group membership), so the JWT now matches the Effective-access view + the OPL model — at login/refresh only, never per request (README login section + `admin-roles.ts` header updated). (2) Critical (product) — no confirmation on destructive actions: added a server-rendered (zero-JS) confirm step (`views/admin/confirm.ejs` + `partials/confirm-body.ejs`, shared `buildConfirmModel`) — `GET /admin/{users,groups,roles}/:id/delete` renders an interstitial (Cancel + the real POST); each detail/edit Delete control is now a link to it. (3) High (product) — self-lockout: an admin can no longer delete or deactivate **their own** account, revoke **their own** (direct) admin grant, or delete the **admin role** outright (each → 400 + inline error). Covers the direct-grant paths (incl. the bootstrap-seeded admin, which holds a direct grant); admin held *only* via a group can still be self-revoked, so the robust "last effective admin won't drop" check is deferred to **§9** (stability-reviewer Medium). (4) MEDIUM (arch M1 pt.1) — extracted the gate+CSRF preamble copied verbatim across the 3 admin handlers into `admin-nav.ts` `requireAdmin`/`guardedForm` (one security-critical copy, can't drift). (5) MEDIUM (arch M4) — `shellUser` no longer blanks the email: name = email local part, full email beneath (matches `toUserView`). Tests-first throughout (extended the 3 admin HTTP tests + login/shell-context units); typecheck + 244 units + 8 visual E2E + the full-stack auth-refresh E2E green (the latter re-verifies live login→transitive `readRoles`→`roles:["admin"]`). **Deferred (reviewer-scoped, not the §5 checkpoint):** the host internal route-table (fold the admin if-ladder + Hydra into `matchRoute`/`isAuthorized`, arch M1 pt.2) → **§6** (the 2nd/3rd Hydra screen is the forcing function); admin list-model/template near-duplication across Users/Groups/Roles (arch M3) → the §5 comment/test-cleanup items below (lines 101–102); success-flash after writes + welcoming empty-list states + warn-on-dangling-group-references + >250-row truncation notice (product Medium) → §5 polish / §8 E2E; `safeUrl()` href helper (arch L1 — the recovery link is server-built, not exploitable today) → **§7** (first untrusted-URL flow); oversized-body→500 should be 413 (arch M2) + prod Ory-URL `https` enforcement (arch L3) + `§N`-in-comments / README Layout drift (arch L4) → **§9** (ops/security). - [x] Go over all comments in the code and the README and try to make it shorter and more information dense. Remove not strictly needed stuff. → Pass over the §5 admin accretion. The §5 code was authored dense, so the wins are targeted: tightened the three near-identical module-header blocks (`admin-users`/`admin-groups`/`admin-roles`) — dropped per-file restatement the README/code already carry (subject-form detail → "see parseSubject", "no user/group store" → covered by README "stateless", the verbatim "it gates… CSRF-guards… maps each action to a RouteResult" boilerplate → "gated admin-only, CSRF-guarded"). README **Layout**: compressed the `views/` run-on (long admin/ + per-body-partial enumeration → grouped) and fixed an accuracy gap — it now lists the §5 delete-confirm view. Left intact: the EJS view config-doc headers (the only schema for untyped locals), the security-rationale comments, and the legitimate §9 forward-ref in `admin-roles.ts` (the deferred last-effective-admin check). Docs/comments-only (per AGENTS.md, no stability-reviewer needed); typecheck + 244 units green. - [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 §5 admin tests. The genuine §5-era duplication was all in `app.test.ts`: the three admin-screen HTTP tests (Users/Groups/Roles) each repeated an identical ~13-line harness preamble (createApp + listen + url + CSRF token + admin cookie + get/post), an identical 5-line gate block, and a stateful in-memory `KetoClient` defined 3× (the trivial `stubKeto` + two byte-identical inline fakes). Unified into shared helpers — `adminHarness(t, opts)` → `{url, token, get, post}`, `assertAdminGate(url, get, path)`, and one `fakeKeto(tuples?, over?)` that subsumes `stubKeto` (the login tests now use `fakeKeto([], …)`) and both inline admin fakes (`fakeKeto(tuples)` / `fakeKeto(tuples, { expand })`); hoisted the shared `sameSet`/`matchesTuple` up next to it. The per-module unit files (admin-users/groups/roles + the focused units) already follow the deliberate matrix pattern and the §3/§4 "don't force-merge across distinct modules" rule, so the near-identical `build*ListModel` tests stay per-file (each guards its own function; the source-side list-model dedup is the deferred arch-M3 item, not the test side). −30 net lines, zero coverage lost; typecheck + 244 units green. ## 6. Hydra — OAuth2/OIDC provider (can ship after the rest) - [x] Login-challenge handler: authenticate via Kratos session, accept/reject. → `src/hydra-admin.ts` (`createHydraAdmin`): typed `fetch` wrappers over Hydra's OAuth2 admin API (port 4445, no SDK, `fetchImpl`-injectable like the kratos/keto clients) — `getLoginRequest`/`acceptLoginRequest`/`rejectLoginRequest` + a `HydraError` carrying `.status`. `src/oauth-login.ts` (`resolveLoginChallenge`, pure): `getLoginRequest` → **skip** (Hydra already authenticated the subject) ⇒ accept it without touching Kratos; a live **Kratos session** (`whoami`) ⇒ accept with that identity as the subject (`remember`, browser-session lifetime); **no session** ⇒ bounce to our themed `/login?return_to=`, so Kratos lands back on the challenge once signed in. Wired into `app.ts` at `GET /oauth2/login` (gated on `hydra`+`kratos` present; missing `login_challenge`→400; the absolute return target derives from the request Host + the SECURE_COOKIES scheme — a spoofed Host can't escape, Kratos validates `return_to` against its allow-list); `/login` now bakes a `return_to` into the Kratos flow init so the round-trip works. `config.ts` gains `hydraAdminUrl` (default `http://hydra:4445`); `server.ts` builds the client; `compose.yml` `web` now gates on `hydra` healthy (the app consumes it). Tests-first: `hydra-admin.test.ts` (request contracts + error mapping), `oauth-login.test.ts` (skip/session/no-session matrix), `app.test.ts` (HTTP: accept→Hydra redirect / no-session→/login bounce / missing-challenge→400 / `/login` return_to forwarding), `config.test.ts` + `compose.test.ts` (web↦hydra dep). Full-stack E2E `e2e/oauth-login.spec.ts` (`compose.e2e-oauth.yml`): boots the real stack incl. Hydra, registers an OAuth2 client, starts an authorization flow, asserts the unauthenticated bounce **and** the authenticated accept (→ Hydra `/oauth2/auth?…login_verifier=…`) — green, then torn down. Stability-reviewer run as a local PR: APPROVE, no Critical/High; addressed its one stability warning — a stale/invalid/consumed challenge (Hydra 4xx, user-reachable via back button/slow login) now degrades to a recoverable 400 instead of a 500, while a genuine Hydra 5xx outage still surfaces as 500 (mirrors the themed-flow + §4 re-mint hardening). Deferred (reviewer-scoped, §9): document that prod `allowed_return_urls` entries must be exact origins with a trailing `/` (the return_to safety leans on Kratos' allow-list). typecheck + 253 units + 8 visual E2E green. Consent handler + client registration are the next §6 items. - [x] Consent-challenge handler: show / auto-accept first-party, grant scopes, accept/reject. → `src/hydra-admin.ts` gains the consent half of the handshake (`getConsentRequest`/`acceptConsentRequest`/`rejectConsentRequest` + `ConsentRequest`/`AcceptConsent`/`ConsentSession` types; the login/consent URL builder folded into one `reqUrl(kind,…)` + a shared `put()`). `src/oauth-consent.ts` (pure, sibling of `oauth-login.ts`): `resolveConsentChallenge` → **skip** (Hydra already consented / a skip-consent client) or **first-party** (the client's Hydra `metadata.first_party === true`) ⇒ auto-accept, else return a `view` to show the themed consent screen; `acceptConsent` (re-reads the challenge so scopes/audience are **never** client-supplied) + `rejectConsent` (access_denied). The grant carries an OIDC `session.id_token` with `email`/`name` projected from the Kratos identity (`whoami` traits; absent ⇒ omitted). Wired in `app.ts` at `GET|POST /oauth2/consent` (gated on `hydra`+`kratos`): GET shows/auto-accepts (sets the page CSRF cookie when fresh), POST is **CSRF-guarded** (same signed double-submit as `/logout`) and dispatches `decision=allow`→accept / else→reject → 303 to Hydra; a stale/consumed challenge (Hydra 4xx) degrades to a recoverable 400, a genuine outage (5xx) → 500 (mirrors `/oauth2/login`). `views/oauth-consent.ejs` + `partials/consent-body.ejs` reuse the auth-card: the consent screen lists the requested scopes (friendly labels for the standard OIDC ones) with Allow/Deny submit buttons. Tests-first: `hydra-admin.test.ts` (consent request contracts), `oauth-consent.test.ts` (skip/first-party/third-party/audience/id_token/accept-refetch/reject matrix), `app.test.ts` HTTP integration (auto-accept / screen render+CSRF cookie / allow+deny / forged-CSRF→403 / missing→400 / stale→400 / outage→500). Stability-reviewer run as a local PR: APPROVE, no Critical/High. Extended the full-stack E2E `e2e/oauth-login.spec.ts` to drive the **whole** authorization-code flow against real Hydra — login accept → follow the login_verifier through Hydra → web's consent screen (third-party client `e2e-login`, scopes listed) → Allow → consent_verifier → the client callback with a real `code` (per-host cookie jars; Hydra resume URLs rebased onto the compose host). typecheck + 262 units + 8 visual + OAuth login+consent E2E green; stack torn down. OAuth2 client registration is the next §6 item. -- [ ] OAuth2 client registration (admin UI or CLI). -- [ ] Run the architecture and the product reviewer agents on the _whole_ project, not just the latest changes, and address their issues. +- [x] OAuth2 client registration (admin UI or CLI). → Built-in **OAuth2 clients** admin screen (`src/admin-clients.ts`, `/admin/clients`) — the §6 client side of Hydra (apps that log in *through* us). `src/hydra-admin.ts` gains the client half of the admin API: `createClient`/`listClients`/`getClient`/`deleteClient` over `/admin/clients` (+ a `nextPageToken` Link parser, mirrors kratos-admin) and the registration fields on `OAuth2Client`. The screen mirrors the §5 Users/Roles pattern — pure builders (`toClientView`, `clientPayload`, `validateClientInput`, `parseRedirectUris`, `buildClients{List,Form,Detail}Model`) + `handleAdminClients` (the imperative shell app.ts dispatches `/admin/clients*` to). Routes: `GET /admin/clients` (list — search/paginate over one Hydra page), `GET|POST /admin/clients/new`+`/` (register), `GET /admin/clients/:id` (read-only detail), `GET|POST …/:id/delete` (confirm + delete). Register builds a standard authorization-code client (+ refresh_token), confidential (`client_secret_basic`) or public (PKCE, `none`), with an optional first-party (auto-consent) flag; **Hydra returns the `client_secret` once**, so the register POST renders the new client's detail page with the one-time secret directly (no PRG) — never re-shown (`getClient` carries no secret; detail asserts it). Writes go **only to Hydra**; gated admin-only (anon→/login, non-admin→403) + every mutation CSRF-guarded, like §5; a Hydra 4xx (bad redirect/scope) re-renders the form (400), a 5xx → 500 (mirrors `oauth-login.ts`); `:id` via `safeDecode` (malformed→404). Wired into the shared `adminSection` (Users·Groups·Roles·**OAuth2 clients**, `i-globe`) so it shows for admins, invisible otherwise. New views (`admin/clients`,`client-form`,`client-detail` + `partials/client-{form,detail}-body`) reuse the shell/filter-bar/data-table/field blocks; one `.detail-list` CSS rule. Tests-first: `hydra-admin.test.ts` (client CRUD contracts incl. Link pagination/404→null/204), `admin-clients.test.ts` (builder/validation/payload matrix), `app.test.ts` HTTP integration (gate/list/register-shows-secret-once/invalid+CSRF-reject/detail-hides-secret/delete + malformed-`%`→404). Stability-reviewer run as a local PR: APPROVE, no Critical/High; addressed its one nit (dropped a dead `URL.protocol` check in `validateClientInput`). Boot-verified the client CRUD live against real Hydra v26.2.0 (create→201 w/ one-time secret → list finds it → get → delete → get null); torn down. typecheck + 274 units green. Review/comment/test-cleanup are the next §6 items. +- [ ] Run the architecture and the product reviewer agents on the _whole_ project, not just the latest changes, and address their issues. - [ ] Go over all comments in the code and the README and try to make it shorter and more information dense. Remove not strictly needed stuff. - [ ] 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. ## 7. Example plugin (reference) - [ ] Reference plugin (e.g. people directory or scheduling): list page fetching upstream data, a form that forwards writes upstream, permission-gated nav. - [ ] Verify the full plugin contract end-to-end against the README. -- [ ] Run the architecture and the product reviewer agents on the _whole_ project, not just the latest changes, and address their issues. +- [ ] Run the architecture and the product reviewer agents on the _whole_ project, not just the latest changes, and address their issues. - [ ] Go over all comments in the code and the README and try to make it shorter and more information dense. Remove not strictly needed stuff. - [ ] 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. @@ -120,7 +120,7 @@ everything via Docker. - [ ] node --test units across helpers / router / nav / auth (tests-first throughout). - [ ] **Playwright full E2E**: login (password + mocked SSO), menu filtering by role, users/groups/permissions CRUD, a plugin page, logout. - [ ] E2E harness: bring up the full compose stack, seed Keto roles + a test identity, **tear down after**. -- [ ] Run the architecture and the product reviewer agents on the _whole_ project, not just the latest changes, and address their issues. +- [ ] Run the architecture and the product reviewer agents on the _whole_ project, not just the latest changes, and address their issues. - [ ] Go over all comments in the code and the README and try to make it shorter and more information dense. Remove not strictly needed stuff. - [ ] 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. @@ -131,7 +131,7 @@ everything via Docker. - [ ] Structured logging / basic observability. use @larvit/log for OTLP compability - but add subtasks and stuff for supporting incoming trace id etc from a reverse-proxy etc. - [ ] JWT signing-key rotation runbook. - [ ] Refresh README `Layout` + drop `_(planned)_` markers as pieces land. -- [ ] Run the architecture and the product reviewer agents on the _whole_ project, not just the latest changes, and address their issues. +- [ ] Run the architecture and the product reviewer agents on the _whole_ project, not just the latest changes, and address their issues. - [ ] Go over all comments in the code and the README and try to make it shorter and more information dense. Remove not strictly needed stuff. - [ ] 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. diff --git a/views/admin/client-detail.ejs b/views/admin/client-detail.ejs new file mode 100644 index 0000000..0f9a682 --- /dev/null +++ b/views/admin/client-detail.ejs @@ -0,0 +1,17 @@ +<%# + OAuth2 client detail page (todo §6): the client-detail body (info · one-time secret · delete) in the + shell. Doubles as the post-register page when `created`/`secret` are set. +%><% + const nav = include("partials/nav-tree", { nodes: model.nav }); + const body = include("partials/client-detail-body", { client: model.client, created: model.created, csrfToken: model.shell.csrfToken, del: model.delete, secret: model.secret }); +-%> +<%- include("partials/shell", { + body, + brand: model.shell.brand, + breadcrumbs: model.shell.breadcrumbs, + csrfToken: model.shell.csrfToken, + nav, + theme: model.shell.theme, + title: model.shell.title, + user: model.shell.user, +}) %> diff --git a/views/admin/client-form.ejs b/views/admin/client-form.ejs new file mode 100644 index 0000000..a6f1eae --- /dev/null +++ b/views/admin/client-form.ejs @@ -0,0 +1,16 @@ +<%# + OAuth2 client register page (todo §6): the client-form body captured into the app shell. +%><% + const nav = include("partials/nav-tree", { nodes: model.nav }); + const body = include("partials/client-form-body", { error: model.error, form: model.form }); +-%> +<%- include("partials/shell", { + body, + brand: model.shell.brand, + breadcrumbs: model.shell.breadcrumbs, + csrfToken: model.shell.csrfToken, + nav, + theme: model.shell.theme, + title: model.shell.title, + user: model.shell.user, +}) %> diff --git a/views/admin/clients.ejs b/views/admin/clients.ejs new file mode 100644 index 0000000..6c0065b --- /dev/null +++ b/views/admin/clients.ejs @@ -0,0 +1,21 @@ +<%# + OAuth2 clients admin list (todo §6): apps that log in *through* us (Hydra). Same building blocks as + the Roles screen, around the shell, backed by live Hydra OAuth2 clients (src/admin-clients.ts). +%><% + const nav = include("partials/nav-tree", { nodes: model.nav }); + const filters = include("partials/filter-bar", model.filterBar); + const table = include("partials/data-table", model.table); + const pager = include("partials/pagination", model.pagination); + const actions = 'Register client'; +-%> +<%- include("partials/shell", { + actions, + body: filters + table + pager, + brand: model.shell.brand, + breadcrumbs: model.shell.breadcrumbs, + csrfToken: model.shell.csrfToken, + nav, + theme: model.shell.theme, + title: model.shell.title, + user: model.shell.user, +}) %> diff --git a/views/partials/client-detail-body.ejs b/views/partials/client-detail-body.ejs new file mode 100644 index 0000000..0123c17 --- /dev/null +++ b/views/partials/client-detail-body.ejs @@ -0,0 +1,37 @@ +<%# + Admin OAuth2 client detail body (todo §6), captured into the shell content slot. Config: + client { firstParty, id, name, public, redirectUris[], scopes[] } + created bool just registered → success banner + secret? string one-time client secret (confidential clients), shown once right after create + del { action } delete the client + csrfToken +%><% + const c = locals.client; + const del = locals.del; +-%> +
+<% if (locals.created) { -%> +<%- include("alert", { text: "Client registered.", tone: "pos" }) %> +<% } -%> +<% if (locals.secret) { -%> +
+

Client secret

+

Copy these now — the secret can't be shown again. Store them where the app reads its credentials.

+
+
+
+<% } -%> +
+

<%= c.name %>

+
+
Client ID
<%= c.id %>
+
Type
<%= c.public ? "Public (PKCE)" : "Confidential" %>
+
Consent
<%= c.firstParty ? "First-party (auto-granted)" : "Shows the consent screen" %>
+
Scopes
<%= c.scopes.length ? c.scopes.join(" ") : "—" %>
+
Redirect URIs
<% if (c.redirectUris.length) { %>
    <% c.redirectUris.forEach((u) => { %>
  • <%= u %>
  • <% }) %>
<% } else { %>—<% } %>
+
+
+
+ Delete client +
+
diff --git a/views/partials/client-form-body.ejs b/views/partials/client-form-body.ejs new file mode 100644 index 0000000..73a71f2 --- /dev/null +++ b/views/partials/client-form-body.ejs @@ -0,0 +1,29 @@ +<%# + Admin OAuth2 client register form body (todo §6), captured into the shell content slot. Config: + form { action, csrfToken, submitLabel, cancelHref, nameField, scopeField (field.ejs configs), + redirectUris: string (newline-separated), public: bool, firstParty: bool } + error? string shown when a write was rejected +%><% + const form = locals.form; +-%> +
+<% if (locals.error) { -%> +<%- include("alert", { text: locals.error, tone: "neg" }) %> +<% } -%> + + + <%- include("field", form.nameField) %> +
+ + + One per line — where the app is sent back after sign-in. +
+ <%- include("field", form.scopeField) %> + + +
+ Cancel + +
+ +