Files
plainpages/src/admin-clients.ts
lilleman bea9a71d6f §9 trace all fetch + ENV service name + leveled logging (todo §9 follow-up); route every outbound fetch through the request logger, make the OTLP service name implementer-configurable, and add proper leveled logging throughout. An AsyncLocalStorage<Log> makes the per-request logger ambient (runWithLog/currentLog), so all outbound fetch traces with no signature churn: tracedFetch (a typeof fetch) routes through the active request log (client span + propagated W3C traceparent) for string/URL inputs, else plain fetch; server.ts wires it under the Ory timeout into every Kratos/Keto/Hydra + JWKS call (timeout still honoured — log.fetch spreads {...init,headers}). RequestContext gained ctx.log (request logger; additive/contract-stable, silent default) so a handler/plugin logs in-trace and ctx.log.fetch(url) traces upstream calls; the reference plugin's createUpstream defaults to tracedFetch and its handlers log via ctx.log; plugin-api.ts exports tracedFetch + the Log class. SERVICE_NAME (config + createLogger({serviceName})) brands the OTLP service.name. Leveled logging: who-did-what audit info lines on every admin write (user/group/role/client create·delete·assign — actor/target, no secrets), info on login (session mint) + logout, warn on missing-role 403 + CSRF rejections + Ory-unreachable, debug on a JWKS kid-miss reload. app.ts's handler body was extracted to handleRequest run inside runWithLog; end() now fires exactly once after BOTH the handler unwinds AND the response closes, so a client abort mid-handler can't end the log out from under a still-running ctx.log/tracedFetch (regression-tested) and the happy-path access line is never dropped. bootstrap.ts wraps main in runWithLog + traces the seed calls. Tests extended (logger: serviceName/runWithLog/currentLog/tracedFetch-continues-trace; config: SERVICE_NAME; context: ctx.log default+passthrough; app: ctx.log in-trace + ctx.log.fetch propagation + the abort race; plugin-api: tracedFetch+Log). Stability-reviewer: APPROVE, no Critical/High (fixed the abort-race end(); green nits addressed). docs/plugin-contract.md (ctx.log/ctx.log.fetch/tracedFetch) + README (config, Observability tracing/serviceName, plugin note, Layout) updated. typecheck + 333 units + the full scripts/ci.sh E2E gate green (326 → 333).
2026-06-20 15:46:48 +02:00

348 lines
14 KiB
TypeScript

// 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<string, unknown> {
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<ListState> = {}): 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<typeof paginate>) {
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<ClientInput>;
}) {
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<string, unknown>) => Promise<string>;
}
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<RouteResult | null> {
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<ClientInput> }): Promise<RouteResult> =>
({ html: await render("admin/client-form", { model: buildClientFormModel({ csrfToken, menu, user, ...extra }) }) });
const renderDetail = async (client: OAuth2Client, extra: { created?: boolean; secret?: string } = {}): Promise<RouteResult> =>
({ html: await render("admin/client-detail", { model: buildClientDetailModel({ client: toClientView(client), csrfToken, menu, user, ...extra }) }) });
const notFound = async (): Promise<RouteResult> => ({ 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.
ctx.log.info("admin: oauth2 client registered", { actor: user.id, client: created.client_id ?? "" });
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);
ctx.log.info("admin: oauth2 client deleted", { actor: user.id, client: id });
return { redirect: ADMIN_CLIENTS_BASE };
}
return null;
}