// 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 { can, type HydraAdmin, HydraError, type OAuth2Client, paginate, parseListQuery, type RequestContext, type RouteHandler, type RouteResult, type Translate, type User } from "@plainpages/plugin-api"; import { ADMIN_CLIENTS_BASE, ADMIN_EN, type AdminAction, buildConfirmModel, guardedForm, notFound, permissionName, requirePermission, 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, t: Translate = ADMIN_EN): string | null { if (!input.name) return t("admin.clients.validation.name"); if (!input.redirectUris.length) return t("admin.clients.validation.redirectUris"); 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 t("admin.clients.validation.redirectUri", { uri }); } } 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: { canWrite?: boolean; clients: OAuth2Client[]; csrfToken?: string; t?: Translate; url: URL | URLSearchParams | string; }) { const t = opts.t ?? ADMIN_EN; 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: t("admin.nav.section") }, { label: t("admin.clients.title") }], canWrite: opts.canWrite !== false, filterBar: listFilterBar(state, t), pagination: listPagination(state, page, t), table: listTable(rows, t), title: t("admin.clients.title"), }; } function listTable(rows: ClientView[], t: Translate) { return { caption: t("admin.clients.title"), columns: [{ label: t("admin.clients.column.name") }, { label: t("admin.clients.column.id") }, { label: t("admin.clients.column.type") }], rows: rows.map((c) => ({ cells: [ { rowHeader: { href: detailHref(c.id), text: c.name } }, { className: "cell-muted", text: c.id }, { badge: { label: c.public ? t("admin.clients.public") : t("admin.clients.confidential"), tone: c.public ? "warn" : "info" } }, ], name: c.name, })), }; } function listFilterBar(state: ListState, t: Translate) { const pills: { label: string; remove: string; value: string }[] = []; if (state.q) pills.push({ label: t("filter.search"), remove: listHref(state, { page: 1, q: "" }), value: state.q }); return { applyLabel: t("filter.apply"), clearHref: ADMIN_CLIENTS_BASE, label: t("admin.clients.filter"), pills, rows: [[ { label: t("admin.clients.searchLabel"), name: "q", placeholder: t("admin.clients.searchPlaceholder"), type: "search", value: state.q }, { type: "spacer" }, ]], }; } function listPagination(state: ListState, page: ReturnType, t: Translate) { const hidden: { name: string; value: string }[] = []; if (state.q) hidden.push({ name: "q", value: state.q }); return { label: t("admin.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: t("pagination.rows"), name: "pageSize", options: PAGE_SIZES, submitLabel: t("pagination.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; t?: Translate; values?: Partial; }) { const t = opts.t ?? ADMIN_EN; const v = opts.values; const nameField: FieldConfig = { autocomplete: "off", icon: "i-box", id: "name", label: t("admin.clients.field.name"), name: "name", required: true, value: v?.name ?? "", }; const scopeField: FieldConfig = { hint: t("admin.clients.field.scopesHint"), id: "scope", label: t("admin.clients.field.scopes"), name: "scope", value: v?.scope ?? DEFAULT_SCOPE, }; return { breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: t("admin.clients.title") }, { label: t("admin.clients.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: t("admin.clients.registerClient"), }, title: t("admin.clients.registerTitle"), }; } export function buildClientDetailModel(opts: { canWrite?: boolean; 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 t?: Translate; }) { const t = opts.t ?? ADMIN_EN; const base = detailHref(opts.client.id); return { breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: t("admin.clients.title") }, { label: opts.client.name }], canWrite: opts.canWrite !== false, client: opts.client, created: opts.created ?? false, csrfToken: opts.csrfToken ?? "", delete: { action: `${base}/delete` }, secret: opts.secret, title: opts.created ? t("admin.clients.created") : 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, action?: AdminAction): RouteHandler { return async (ctx) => { const user = requirePermission(ctx, "oauth2-clients", action); const hydra = ctx.system?.hydra; if (!hydra) return unavailable(ctx, ctx.t("admin.capability.hydra")); 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, action?: AdminAction): 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); }, action); } const clientFormResult = (ctx: RequestContext, extra: { error?: string; values?: Partial }): RouteResult => ({ data: { chrome: ctx.chrome, model: buildClientFormModel({ csrfToken: ctx.chrome.csrfToken, t: ctx.t, ...extra }) }, view: "client-form" }); const canWriteClients = (ctx: RequestContext): boolean => can(ctx, permissionName("oauth2-clients", "write")); const clientDetailResult = (ctx: RequestContext, client: OAuth2Client, extra: { created?: boolean; secret?: string } = {}): RouteResult => ({ data: { chrome: ctx.chrome, model: buildClientDetailModel({ canWrite: canWriteClients(ctx), client: toClientView(client), csrfToken: ctx.chrome.csrfToken, t: ctx.t, ...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({ canWrite: canWriteClients(ctx), clients, csrfToken: ctx.chrome.csrfToken, t: ctx.t, 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, ctx.t); 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: ctx.t("admin.clients.error.rejected"), 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, {})), "write"); // 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; const tt = deps.ctx.t; return Promise.resolve({ data: { chrome: deps.ctx.chrome, model: buildConfirmModel({ breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: tt("admin.clients.title") }, { href: base, label: name }, { label: tt("common.delete") }], cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: tt("admin.clients.delete"), message: tt("admin.clients.deleteMessage", { name }), title: tt("admin.clients.delete"), }) }, view: "confirm" }); }, "write"); // 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 }; });