Built-in OAuth2 client-registration admin screen (todo §6); /admin/clients lists/registers/deletes the Hydra OAuth2 clients other apps log in through us with. New src/admin-clients.ts (pure builders + handleAdminClients, mirroring the §5 Users/Roles screens): list (search/paginate over one fetched Hydra page), register (GET form + POST), read-only detail, delete-confirm. src/hydra-admin.ts gains the client half of the admin API — createClient/listClients/getClient/deleteClient over /admin/clients (+ a nextPageToken Link parser like kratos-admin) and the registration fields on OAuth2Client. 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) and it is never re-shown (getClient carries no secret; the detail test asserts it). Writes go only to Hydra; gated admin-only (anon->/login, non-admin->403) + every mutation CSRF-guarded via requireAdmin/guardedForm 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 app.ts (/admin/clients, gated on the hydra client present) and the shared adminSection (Users.Groups.Roles.OAuth2 clients, i-globe) so it shows for admins and is 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; README Layout/§6 updated. 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.

This commit is contained in:
2026-06-19 11:23:27 +02:00
parent 0900bf49bd
commit 1c324b18e3
18 changed files with 772 additions and 21 deletions

105
src/admin-clients.test.ts Normal file
View File

@@ -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> = {}): 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");
});

345
src/admin-clients.ts Normal file
View File

@@ -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<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.
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;
}

View File

@@ -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

View File

@@ -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> = {}): 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);

View File

@@ -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];

View File

@@ -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/"));

View File

@@ -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: '</admin/clients?page_token=tok2&page_size=2>; 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$/);
});

View File

@@ -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<string, unknown>; // 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<Completed>;
acceptLoginRequest(challenge: string, body: AcceptLogin): Promise<Completed>;
createClient(client: OAuth2Client): Promise<OAuth2Client>;
deleteClient(id: string): Promise<void>;
getClient(id: string): Promise<OAuth2Client | null>;
getConsentRequest(challenge: string): Promise<ConsentRequest>;
getLoginRequest(challenge: string): Promise<LoginRequest>;
listClients(opts?: { pageSize?: number; pageToken?: string }): Promise<ClientList>;
rejectConsentRequest(challenge: string, body: RejectRequest): Promise<Completed>;
rejectLoginRequest(challenge: string, body: RejectRequest): Promise<Completed>;
}
// 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<never> {
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);
},

View File

@@ -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 => ({

View File

@@ -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,
};