// Hydra admin-API client (todo §6): typed `fetch` wrappers over Ory Hydra's OAuth2 admin // endpoints (internal admin port) — the login/consent challenge handshake other apps log in // *through* us with. Built-in `fetch` only, no SDK dep (AGENTS.md); `fetchImpl`-injectable // like the kratos/keto clients. We authenticate the user (login) and grant scopes (consent); // Hydra mints the tokens. 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; // 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 // subject (honour it, don't re-prompt); otherwise we authenticate via the Kratos session. export interface LoginRequest { challenge: string; client?: OAuth2Client; request_url?: string; requested_scope?: string[]; skip: boolean; subject: string; } export interface AcceptLogin { acr?: string; remember?: boolean; remember_for?: number; // seconds; 0 ⇒ for the browser-session lifetime subject: string; } // A consent request Hydra hands us at /oauth2/consent. `skip` ⇒ already consented (or a // skip-consent client); else we show the scope screen (or auto-accept a first-party client). export interface ConsentRequest { challenge: string; client?: OAuth2Client; request_url?: string; requested_access_token_audience?: string[]; requested_scope?: string[]; skip: boolean; subject: string; } // OIDC claims surfaced to the client: id_token (always) / access_token (introspection only). export interface ConsentSession { access_token?: Record; id_token?: Record; } export interface AcceptConsent { grant_access_token_audience?: string[]; grant_scope?: string[]; remember?: boolean; remember_for?: number; // seconds; 0 ⇒ for the browser-session lifetime session?: ConsentSession; } export interface RejectRequest { error?: string; error_description?: string; } // Hydra's answer to an accept/reject: the URL to send the browser to, to resume the flow. export interface Completed { redirect: string; } // Carries the HTTP status so a caller can branch (parallels KratosError/KetoError). export class HydraError extends Error { body: string; status: number; constructor(message: string, status: number, body: string) { super(message); this.body = body; this.name = "HydraError"; this.status = status; } } export interface HydraAdmin { acceptConsentRequest(challenge: string, body: AcceptConsent): Promise; acceptLoginRequest(challenge: string, body: AcceptLogin): Promise; acceptLogoutRequest(challenge: string): Promise; // RP-initiated logout (§6): confirm + resume createClient(client: OAuth2Client): Promise; deleteClient(id: string): Promise; getClient(id: string): Promise; getConsentRequest(challenge: string): Promise; getLoginRequest(challenge: string): Promise; listClients(opts?: { pageSize?: number; pageToken?: string }): Promise; rejectConsentRequest(challenge: string, body: RejectRequest): Promise; rejectLoginRequest(challenge: string, body: RejectRequest): Promise; } // 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; const json = { "content-type": "application/json" }; // Hydra keys each handshake off a ?_challenge= query (login/consent/logout). const reqUrl = (kind: "consent" | "login" | "logout", 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 { throw new HydraError(`Hydra admin ${action} failed (${res.status})`, res.status, await res.text()); } async function complete(action: string, res: Response): Promise { if (res.status !== 200) return fail(action, res); return { redirect: ((await res.json()) as { redirect_to: string }).redirect_to }; } const put = (action: string, url: string, body: unknown) => http(url, { body: JSON.stringify(body), headers: json, method: "PUT" }).then((r) => complete(action, r)); return { async acceptConsentRequest(challenge, body) { return put("accept consent", reqUrl("consent", challenge, "/accept"), body); }, async acceptLoginRequest(challenge, body) { return put("accept login", reqUrl("login", challenge, "/accept"), body); }, // RP-initiated logout: Hydra hands the browser to /oauth2/logout?logout_challenge=…; accept to // end its OAuth2 session and get the post-logout redirect (no body / no first-party teardown — // /logout owns the Kratos session). A stale/consumed challenge → HydraError 4xx (app degrades). async acceptLogoutRequest(challenge) { return put("accept logout", reqUrl("logout", challenge, "/accept"), {}); }, // 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); return (await res.json()) as ConsentRequest; }, async getLoginRequest(challenge) { const res = await http(reqUrl("login", challenge)); if (res.status !== 200) return fail("get login request", res); 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); }, async rejectLoginRequest(challenge, body) { return put("reject login", reqUrl("login", challenge, "/reject"), body); }, }; }