// OAuth2 clients admin screen: register / list / delete the OAuth2 clients other // apps log in *through* us with (Ory Hydra, the 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). Below the builders are thin // per-route handlers (keyed on ctx.params) over a shared `withClients` gate — admin-only, CSRF-guarded. import { type HydraAdmin, HydraError, type OAuth2Client, paginate, parseListQuery, type RequestContext, type RouteHandler, type RouteResult, type User } from "#plugin-api"; import { ADMIN_CLIENTS_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts"; import type { FieldConfig } from "./admin-users.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; url: URL | URLSearchParams | string; }) { 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 { breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: "Admin" }, { label: "OAuth2 clients" }], filterBar: listFilterBar(state), pagination: listPagination(state, page), table: listTable(rows), title: "OAuth2 clients", }; } 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; values?: Partial; }) { 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 { breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: "OAuth2 clients" }, { label: "Register" }], 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", }, title: "Register client", }; } export function buildClientDetailModel(opts: { client: ClientView; created?: boolean; // just registered → success banner + the one-time secret (if any) csrfToken?: string; secret?: string; // one-time client_secret (confidential clients), shown once right after create }) { const base = detailHref(opts.client.id); return { breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: "OAuth2 clients" }, { label: opts.client.name }], client: opts.client, created: opts.created ?? false, csrfToken: opts.csrfToken ?? "", delete: { action: `${base}/delete` }, secret: opts.secret, title: opts.created ? "Client registered" : opts.client.name, }; } // ---- request handler (imperative shell) ---- 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(), }; } // Shared per-request deps for the OAuth2-clients screen, resolved by `withClients`: the gate + the // Hydra capability (else a themed 503). Each route below is a thin handler over these. interface ClientsDeps { ctx: RequestContext; hydra: HydraAdmin; user: User; } function withClients(inner: (deps: ClientsDeps) => Promise): RouteHandler { return async (ctx) => { const user = requireAdmin(ctx); const hydra = ctx.system?.hydra; if (!hydra) return unavailable(ctx, "Hydra OAuth2 admin"); return inner({ ctx, hydra, user }); }; } // Same, plus the target client from ctx.params.id (unknown → themed 404). function withClient(inner: (deps: ClientsDeps, client: OAuth2Client, id: string) => Promise): RouteHandler { return withClients(async (deps) => { const id = deps.ctx.params["id"] ?? ""; const client = await deps.hydra.getClient(id); if (!client) return notFound(deps.ctx); return inner(deps, client, id); }); } const clientFormResult = (ctx: RequestContext, extra: { error?: string; values?: Partial }): RouteResult => ({ data: { chrome: ctx.chrome, model: buildClientFormModel({ csrfToken: ctx.chrome.csrfToken, ...extra }) }, view: "client-form" }); const clientDetailResult = (ctx: RequestContext, client: OAuth2Client, extra: { created?: boolean; secret?: string } = {}): RouteResult => ({ data: { chrome: ctx.chrome, model: buildClientDetailModel({ client: toClientView(client), csrfToken: ctx.chrome.csrfToken, ...extra }) }, view: "client-detail" }); // GET /admin/clients — the list. export const clientsList = withClients(async ({ ctx, hydra }) => { const { clients } = await hydra.listClients({ pageSize: LIST_FETCH_SIZE }); return { data: { chrome: ctx.chrome, model: buildClientsListModel({ clients, csrfToken: ctx.chrome.csrfToken, url: ctx.url }) }, view: "clients" }; }); // POST /admin/clients — register; on success show the one-time secret directly (no PRG, Hydra never // returns it again). A Hydra 4xx (bad redirect/scope) re-renders the form (400); a 5xx rethrows → 500. export const clientsCreate = withClients(async ({ ctx, hydra, user }) => { const input = readClientInput((await guardedForm(ctx))!); const error = validateClientInput(input); if (error) return { ...clientFormResult(ctx, { error, values: input }), status: 400 }; let created: OAuth2Client; try { created = await hydra.createClient(clientPayload(input)); } catch (err) { if (err instanceof HydraError && err.status < 500) return { ...clientFormResult(ctx, { error: "Hydra rejected the client — check the redirect URIs and scopes.", values: input }), status: 400 }; throw err; } ctx.log.info("admin: oauth2 client registered", { actor: user.id, client: created.client_id ?? "" }); return clientDetailResult(ctx, created, { created: true, ...(created.client_secret ? { secret: created.client_secret } : {}) }); }); // GET /admin/clients/new — the register form. export const clientsNewForm = withClients(({ ctx }) => Promise.resolve(clientFormResult(ctx, {}))); // GET /admin/clients/:id — the detail (read-only; the secret is shown only once, at creation). export const clientsDetail = withClient((deps, client) => Promise.resolve(clientDetailResult(deps.ctx, client))); // GET /admin/clients/:id/delete — the deliberate confirm step. export const clientsDeleteConfirm = withClient((deps, client, id) => { const base = detailHref(id); const name = toClientView(client).name; return Promise.resolve({ data: { chrome: deps.ctx.chrome, model: buildConfirmModel({ breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: "OAuth2 clients" }, { href: base, label: name }, { label: "Delete" }], cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: "Delete client", message: `Delete client ${name}? Apps using it can no longer sign in through Plainpages.`, title: "Delete client", }) }, view: "confirm" }); }); // POST /admin/clients/:id/delete — perform it. export const clientsDelete = withClient(async ({ ctx, hydra, user }, _client, id) => { await guardedForm(ctx); // CSRF-verify the POST await hydra.deleteClient(id); ctx.log.info("admin: oauth2 client deleted", { actor: user.id, client: id }); return { redirect: ADMIN_CLIENTS_BASE }; });