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:
@@ -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);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user