Files
plainpages/src/hydra-admin.ts
lilleman 521c09fa2d §6 review checkpoint (todo §6); ran the architecture + product reviewers on the whole project (weighted to the Hydra OAuth2 surfaces) and addressed their findings — no Critical from either. Fixed tests-first: (HIGH, arch) /oauth2/logout was published to Hydra (hydra.yml urls.logout) and asserted in hydra.test.ts but had no handler — a dead/published contract; added hydra-admin.acceptLogoutRequest (PUT logout/accept via the shared reqUrl(kind…)) + a GET /oauth2/logout branch that accepts the RP-initiated logout_challenge → 303 to Hydra's post-logout redirect (missing→400, stale 4xx→recoverable 400, 5xx→500, byte-identical degrade to the login/consent siblings; GET-accept is safe since the challenge is Hydra-minted+single-use; the first-party POST /logout still owns ending the Kratos session + JWT cookie). (HIGH, arch) added oauth2 to RESERVED_PLUGIN_IDS so a plugins/oauth2/ folder can't silently shadow the provider routes (the route surface the §4 reserved-id fix missed; discovery now refuses it loud). (Product Blocker) the third-party consent screen now names the signed-in account — "Signed in as <email>" (ConsentView.account from whoami) — plus a CSRF-guarded "Not you? Sign out" form, so consent is informed on shared devices. (MEDIUM, arch) consent accept() now projects id_token claims only when the live Kratos session subject === the challenge subject Hydra bound at login, never leaking a mismatched session's email/name into the issued token (guards the auto-accept path too). (Product nits) register-form confidential-vs-public guidance + a client-detail "delete and re-register / secret shown once" note (no-edit friction + lost-secret). New tests across discovery (reserved oauth2), hydra-admin (acceptLogoutRequest contract), oauth-consent (subject-match + account-in-view), app.test (logout 303/400/500 matrix, consent identity+sign-out, client form/detail copy); e2e/oauth-login.spec asserts the consent screen names the account. Stability-reviewer run as a local PR: APPROVE, no Critical/High — addressed its doc/comment follow-ups (README §6 documents the logout handler + consent identity line; a comment notes the GET-accept is Hydra-validated). Deferred (reviewer-scoped): the host internal route-table (arch M1, now a pure dedup once H1/H2 are point-fixed) → §9; the RP-initiated-logout browser/live E2E → §8; redirect-URI scheme allowlist + safeUrl() → §7; full client edit / empty-list state / success-flash → §8/polish. typecheck + 279 units green; full-stack OAuth2 login+consent E2E verified live against real Hydra v26.2.0 then torn down.
2026-06-19 11:47:06 +02:00

198 lines
8.0 KiB
TypeScript

// 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<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
// 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<string, unknown>;
id_token?: Record<string, unknown>;
}
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<Completed>;
acceptLoginRequest(challenge: string, body: AcceptLogin): Promise<Completed>;
acceptLogoutRequest(challenge: string): Promise<Completed>; // RP-initiated logout (§6): confirm + resume
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;
const json = { "content-type": "application/json" };
// Hydra keys each handshake off a ?<kind>_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<never> {
throw new HydraError(`Hydra admin ${action} failed (${res.status})`, res.status, await res.text());
}
async function complete(action: string, res: Response): Promise<Completed> {
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);
},
};
}