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

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