Built-in OAuth2 consent-challenge handler (todo §6); /oauth2/consent grants scopes to a client logging in through us. New src/oauth-consent.ts (pure, sibling of oauth-login.ts): resolveConsentChallenge auto-accepts a first-party client (Hydra metadata.first_party===true) or a Hydra-skipped one, else returns a view to show the themed consent screen; acceptConsent re-reads the challenge so scopes/audience are never client-supplied; rejectConsent → access_denied. The grant carries an OIDC session.id_token with email/name projected from the Kratos identity (whoami traits, omitted when absent). src/hydra-admin.ts gains the consent half (get/accept/reject consent + types; login/consent URL builder folded into one reqUrl(kind,…) + shared put()). Wired in app.ts at GET|POST /oauth2/consent (gated on hydra+kratos): GET shows/auto-accepts (sets the CSRF cookie when fresh), POST is CSRF-guarded (same signed double-submit as /logout) and dispatches allow→accept / else→reject → 303 to Hydra; a stale/consumed challenge (Hydra 4xx) degrades to a recoverable 400, a real outage (5xx) → 500 (mirrors /oauth2/login). views/oauth-consent.ejs + partials/consent-body.ejs reuse the auth-card, listing the requested scopes (friendly labels for the standard OIDC ones) with Allow/Deny submit buttons. Tests-first: hydra-admin consent contracts + oauth-consent skip/first-party/third-party/audience/id_token/refetch/reject matrix + app HTTP integration (auto-accept / screen+CSRF cookie / allow+deny / forged-CSRF→403 / missing→400 / stale→400 / outage→500). Stability-reviewer run as a local PR: APPROVE, no Critical/High. Extended e2e/oauth-login.spec.ts to drive the whole authorization-code flow against real Hydra — login accept → follow login_verifier through Hydra → web's consent screen (third-party e2e-login, scopes listed) → Allow → consent_verifier → client callback with a real code (per-host cookie jars, Hydra resume URLs rebased onto the compose host). typecheck + 262 units + 8 visual + OAuth login+consent E2E green. OAuth2 client registration is the next §6 item.

This commit is contained in:
2026-06-19 10:53:21 +02:00
parent 3c8090e8e3
commit 0900bf49bd
12 changed files with 500 additions and 20 deletions

View File

@@ -496,11 +496,16 @@ test("logout (CSRF-guarded POST): valid token revokes the Kratos session + clear
// OAuth2 login challenge (§6): another app logs in *through* us; Hydra hands the browser here.
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" }),
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: "" }),
rejectConsentRequest: async () => ({ redirect: "http://acme.example/cb?error=access_denied" }),
rejectLoginRequest: async () => { throw new Error("unused"); },
...over,
});
const OAUTH_SUBJECT = "01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b55";
const oauthSession = (): Session => ({ active: true, identity: { id: OAUTH_SUBJECT, traits: { email: "ada@x.io" } } });
test("OAuth2 login challenge (/oauth2/login): a Kratos session accepts via Hydra; no session bounces to /login; missing challenge → 400", async (t) => {
const identity = { id: "01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b55" };
@@ -560,6 +565,68 @@ test("/login?return_to=… bakes the return target into the Kratos flow init (§
assert.equal(seenReturnTo, returnTo);
});
test("OAuth2 consent challenge (/oauth2/consent): skip auto-accepts; a third-party shows the screen; allow/deny POST; CSRF-guarded; missing/stale challenge", async (t) => {
const csrfSecret = "consent-secret";
let granted: { grant_scope?: string[]; session?: unknown } | undefined;
const hydra = stubHydra({
acceptConsentRequest: async (_c, b) => { granted = b; return { redirect: "http://127.0.0.1:4444/oauth2/auth?consent_verifier=v" }; },
rejectConsentRequest: async () => ({ redirect: "http://acme.example/cb?error=access_denied" }),
});
const app = createApp({ csrfSecret, hydra, kratos: withWhoami(async () => oauthSession()) });
await new Promise<void>((r) => app.listen(0, r));
t.after(() => app.close());
const base = `http://localhost:${(app.address() as AddressInfo).port}`;
const token = issueCsrfToken(csrfSecret);
const post = (body: string) =>
fetch(base + "/oauth2/consent", { body, headers: { "content-type": "application/x-www-form-urlencoded", cookie: `${CSRF_COOKIE}=${token}` }, method: "POST", redirect: "manual" });
// Third-party (default stub: not first-party, not skipped) → 200 consent screen listing the
// client + scopes, with a CSRF cookie its form echoes back; posts to our own /oauth2/consent.
const page = await fetch(base + "/oauth2/consent?consent_challenge=cons1", { redirect: "manual" });
assert.equal(page.status, 200);
const html = await page.text();
assert.match(html, /Authorize Acme Reports/);
assert.match(html, /openid/);
assert.match(html, /profile/);
assert.match(html, /action="\/oauth2\/consent"/);
assert.match(page.headers.get("set-cookie") ?? "", /plainpages_csrf=/);
// Allow → 303 to Hydra, granting the scopes re-read from the challenge (never form-supplied) +
// id_token claims from the Kratos identity.
const allow = await post(`_csrf=${token}&consent_challenge=cons1&decision=allow`);
assert.equal(allow.status, 303);
assert.match(allow.headers.get("location") ?? "", /\/oauth2\/auth\?consent_verifier=v/);
assert.deepEqual(granted?.grant_scope, ["openid", "profile"]);
assert.deepEqual(granted?.session, { id_token: { email: "ada@x.io" } });
// Deny → 303 back to the client with access_denied.
const deny = await post(`_csrf=${token}&consent_challenge=cons1&decision=deny`);
assert.equal(deny.status, 303);
assert.equal(deny.headers.get("location"), "http://acme.example/cb?error=access_denied");
// Forged/missing CSRF → 403 (no Hydra call); missing challenge → 400.
assert.equal((await post("decision=allow")).status, 403);
assert.equal((await fetch(base + "/oauth2/consent", { redirect: "manual" })).status, 400);
// A Hydra-skipped client auto-accepts on GET (no screen) → 303 to Hydra.
const skip = createApp({ hydra: stubHydra({ getConsentRequest: async () => ({ challenge: "cons1", requested_scope: ["openid"], skip: true, subject: OAUTH_SUBJECT }) }), kratos: withWhoami(async () => oauthSession()) });
await new Promise<void>((r) => skip.listen(0, r));
t.after(() => skip.close());
const auto = await fetch(`http://localhost:${(skip.address() as AddressInfo).port}/oauth2/consent?consent_challenge=cons1`, { redirect: "manual" });
assert.equal(auto.status, 303);
assert.match(auto.headers.get("location") ?? "", /consent_verifier=v/);
// A stale challenge (Hydra 4xx) degrades to 400; a genuine outage (5xx) surfaces as 500.
const stale = createApp({ hydra: stubHydra({ getConsentRequest: async () => { throw new HydraError("gone", 410, ""); } }), kratos: withWhoami(async () => null) });
await new Promise<void>((r) => stale.listen(0, r));
t.after(() => stale.close());
assert.equal((await fetch(`http://localhost:${(stale.address() as AddressInfo).port}/oauth2/consent?consent_challenge=gone`, { redirect: "manual" })).status, 400);
const down = createApp({ hydra: stubHydra({ getConsentRequest: async () => { throw new HydraError("down", 503, ""); } }), kratos: withWhoami(async () => null) });
await new Promise<void>((r) => down.listen(0, r));
t.after(() => down.close());
assert.equal((await fetch(`http://localhost:${(down.address() as AddressInfo).port}/oauth2/consent?consent_challenge=x`, { redirect: "manual" })).status, 500);
});
// Built-in Users admin screen (§5): gate + every CRUD action over HTTP against a mock Kratos admin.
test("admin Users screen: gate, list/filter, create, edit, deactivate, delete, recovery (CSRF-guarded)", async (t) => {
const mk = (email: string, over: Partial<Identity> = {}): Identity =>

View File

@@ -23,6 +23,7 @@ import type { KratosAdmin } from "./kratos-admin.ts";
import { KratosError, type KratosPublic } from "./kratos-public.ts";
import { clearSessionCookie, completeLogin, remintSession, sessionCookie } from "./login.ts";
import { resolveLoginChallenge } from "./oauth-login.ts";
import { acceptConsent, rejectConsent, resolveConsentChallenge } from "./oauth-consent.ts";
import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts";
import type { Plugin, RouteResult } from "./plugin.ts";
import { allowedMethods, isAuthorized, matchRoute } from "./router.ts";
@@ -236,6 +237,56 @@ export function createApp(options: AppOptions = {}): Server {
return;
}
// OAuth2 consent challenge (§6): after login Hydra hands the browser here. A first-party
// (or Hydra-skipped) client is auto-granted its scopes; a third-party client gets the themed
// consent screen, whose CSRF-guarded POST accepts (Allow) or rejects (Deny). Provider-only.
if (hydra && kratos && pathname === "/oauth2/consent") {
const consentDeps = { hydra, kratos };
try {
if (method === "GET" || method === "HEAD") {
const challenge = ctx.url.searchParams.get("consent_challenge");
if (!challenge) {
res.writeHead(400, { "content-type": "text/plain; charset=utf-8" }).end("Missing consent_challenge");
return;
}
const { redirect, view } = await resolveConsentChallenge(consentDeps, challenge, req.headers.cookie);
if (redirect) {
res.writeHead(303, { location: redirect }).end();
return;
}
// Third-party: show the consent screen, carrying a CSRF token its form echoes back.
if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies }));
sendHtml(res, 200, await render("oauth-consent", { brand: menu.branding.name, consent: view, csrfField: CSRF_FIELD, csrfToken: csrf.token }));
return;
}
if (method === "POST") {
const form = await readFormBody(req);
if (!verifyCsrfRequest({ cookieHeader: req.headers.cookie, secret: csrfSecret, submitted: form.get(CSRF_FIELD) })) {
sendHtml(res, 403, await render("403", { title: "Forbidden" }));
return;
}
const challenge = form.get("consent_challenge");
if (!challenge) {
res.writeHead(400, { "content-type": "text/plain; charset=utf-8" }).end("Missing consent_challenge");
return;
}
const redirect = form.get("decision") === "allow"
? await acceptConsent(consentDeps, challenge, req.headers.cookie)
: await rejectConsent(consentDeps, challenge);
res.writeHead(303, { location: redirect }).end();
return;
}
} catch (err) {
// Stale/invalid/consumed challenge (Hydra 4xx — back button, slow login, re-used URL):
// recoverable 400, not a 500. A genuine Hydra outage (5xx) rethrows → 500.
if (err instanceof HydraError && err.status < 500) {
res.writeHead(400, { "content-type": "text/plain; charset=utf-8" }).end("This authorization request has expired. Please start again from the application you were signing in to.");
return;
}
throw err;
}
}
// Login completion: where Kratos lands the browser after authenticating (kratos.yml).
// Mint our session JWT — read roles from Keto, project onto the identity, tokenize —
// and store it as the cookie; no active session bounces back to sign in (§4).

View File

@@ -51,6 +51,32 @@ test("rejectLoginRequest PUTs the error and returns Hydra's redirect_to", async
assert.deepEqual(JSON.parse(calls[0]!.body!), { error: "access_denied", error_description: "no" });
});
test("getConsentRequest GETs the consent challenge and returns the request", async () => {
const request = { challenge: CHALLENGE, client: { client_name: "Acme" }, requested_scope: ["openid", "email"], skip: false, subject: SUBJECT };
const { calls, fetchImpl } = recorder(() => res(200, request));
const out = await createHydraAdmin({ baseUrl: BASE, fetchImpl }).getConsentRequest(CHALLENGE);
assert.deepEqual(out, request);
assert.equal(calls[0]!.method, "GET");
assert.match(calls[0]!.url, /\/admin\/oauth2\/auth\/requests\/consent\?consent_challenge=a1b2c3d4e5f6$/);
});
test("acceptConsentRequest PUTs the grant + id_token session and returns Hydra's redirect_to", async () => {
const { calls, fetchImpl } = recorder(() => res(200, { redirect_to: "http://hydra/oauth2/auth?consent_verifier=v" }));
const body = { grant_scope: ["openid"], remember: true, remember_for: 0, session: { id_token: { email: "a@b.c" } } };
const out = await createHydraAdmin({ baseUrl: BASE, fetchImpl }).acceptConsentRequest(CHALLENGE, body);
assert.equal(out.redirect, "http://hydra/oauth2/auth?consent_verifier=v");
assert.equal(calls[0]!.method, "PUT");
assert.match(calls[0]!.url, /\/admin\/oauth2\/auth\/requests\/consent\/accept\?consent_challenge=a1b2c3d4e5f6$/);
assert.deepEqual(JSON.parse(calls[0]!.body!), body);
});
test("rejectConsentRequest PUTs the error and returns Hydra's redirect_to", async () => {
const { calls, fetchImpl } = recorder(() => res(200, { redirect_to: "http://client/cb?error=access_denied" }));
const out = await createHydraAdmin({ baseUrl: BASE, fetchImpl }).rejectConsentRequest(CHALLENGE, { error: "access_denied" });
assert.equal(out.redirect, "http://client/cb?error=access_denied");
assert.match(calls[0]!.url, /\/admin\/oauth2\/auth\/requests\/consent\/reject\?consent_challenge=a1b2c3d4e5f6$/);
});
test("a non-2xx response throws a HydraError carrying the status", async () => {
const { fetchImpl } = recorder(() => res(404, { error: "Not Found" }));
await assert.rejects(

View File

@@ -7,6 +7,7 @@
export interface OAuth2Client {
client_id?: string;
client_name?: string;
metadata?: Record<string, unknown>; // arbitrary client metadata; `first_party: true` ⇒ auto-consent (§6)
}
// A login request Hydra hands us at /oauth2/login. `skip` ⇒ Hydra already authenticated this
@@ -27,6 +28,32 @@ export interface AcceptLogin {
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;
@@ -50,8 +77,11 @@ export class HydraError extends Error {
}
export interface HydraAdmin {
acceptConsentRequest(challenge: string, body: AcceptConsent): Promise<Completed>;
acceptLoginRequest(challenge: string, body: AcceptLogin): Promise<Completed>;
getConsentRequest(challenge: string): Promise<ConsentRequest>;
getLoginRequest(challenge: string): Promise<LoginRequest>;
rejectConsentRequest(challenge: string, body: RejectRequest): Promise<Completed>;
rejectLoginRequest(challenge: string, body: RejectRequest): Promise<Completed>;
}
@@ -60,8 +90,8 @@ export function createHydraAdmin(config: { baseUrl: string; fetchImpl?: typeof f
const http = config.fetchImpl ?? fetch;
const json = { "content-type": "application/json" };
// Hydra keys the login/consent handshake off a ?login_challenge=/?consent_challenge= query.
const loginUrl = (challenge: string, action = "") =>
`${base}/admin/oauth2/auth/requests/login${action}?login_challenge=${encodeURIComponent(challenge)}`;
const reqUrl = (kind: "consent" | "login", challenge: string, action = "") =>
`${base}/admin/oauth2/auth/requests/${kind}${action}?${kind}_challenge=${encodeURIComponent(challenge)}`;
async function fail(action: string, res: Response): Promise<never> {
throw new HydraError(`Hydra admin ${action} failed (${res.status})`, res.status, await res.text());
@@ -71,19 +101,36 @@ export function createHydraAdmin(config: { baseUrl: string; fetchImpl?: typeof f
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 complete("accept login", await http(loginUrl(challenge, "/accept"), { body: JSON.stringify(body), headers: json, method: "PUT" }));
return put("accept login", reqUrl("login", challenge, "/accept"), body);
},
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(loginUrl(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 rejectConsentRequest(challenge, body) {
return put("reject consent", reqUrl("consent", challenge, "/reject"), body);
},
async rejectLoginRequest(challenge, body) {
return complete("reject login", await http(loginUrl(challenge, "/reject"), { body: JSON.stringify(body), headers: json, method: "PUT" }));
return put("reject login", reqUrl("login", challenge, "/reject"), body);
},
};
}

78
src/oauth-consent.test.ts Normal file
View File

@@ -0,0 +1,78 @@
// OAuth2 consent-challenge resolution (§6): given a Hydra consent challenge, auto-accept a
// first-party (or Hydra-skipped) client granting the requested scopes, else show a consent
// screen; on submit accept (allow) or reject (deny). id_token claims come from the Kratos identity.
import { test } from "node:test";
import assert from "node:assert/strict";
import type { AcceptConsent, ConsentRequest, HydraAdmin } from "./hydra-admin.ts";
import type { KratosPublic, Session } from "./kratos-public.ts";
import { acceptConsent, rejectConsent, resolveConsentChallenge } from "./oauth-consent.ts";
const CHALLENGE = "cons-1";
const SUBJECT = "01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b55";
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 {
return {
acceptConsentRequest: async (_c, body) => { capture?.(body); return { redirect: REDIRECT }; },
acceptLoginRequest: async () => { throw new Error("unused"); },
getConsentRequest: async () => consent,
getLoginRequest: async () => { throw new Error("unused"); },
rejectConsentRequest: async () => ({ redirect: DENIED }),
rejectLoginRequest: async () => { throw new Error("unused"); },
};
}
const stubKratos = (whoami: KratosPublic["whoami"]): KratosPublic => ({
createLogoutFlow: async () => null,
getFlow: async () => { throw new Error("unused"); },
initBrowserFlow: async () => { throw new Error("unused"); },
submitFlow: async () => { throw new Error("unused"); },
whoami,
});
const sessionWith = (traits?: Record<string, unknown>): Session => ({ active: true, identity: { id: SUBJECT, ...(traits ? { traits } : {}) } });
const consent = (over: Partial<ConsentRequest> = {}): ConsentRequest =>
({ challenge: CHALLENGE, client: { client_name: "Acme Reports" }, requested_scope: ["openid", "profile"], skip: false, subject: SUBJECT, ...over });
test("a Hydra-skipped client auto-accepts, granting the requested scopes + audience + id_token from the identity", async () => {
let granted: AcceptConsent | undefined;
const hydra = stubHydra(consent({ requested_access_token_audience: ["https://api"], requested_scope: ["openid", "email"], skip: true }), (b) => { granted = b; });
const kratos = stubKratos(async () => sessionWith({ email: "ada@x.io", name: { first: "Ada", last: "Lovelace" } }));
const out = await resolveConsentChallenge({ hydra, kratos }, CHALLENGE, "plainpages_session=s");
assert.equal(out.redirect, REDIRECT);
assert.equal(out.view, undefined);
assert.deepEqual(granted?.grant_scope, ["openid", "email"]);
assert.deepEqual(granted?.grant_access_token_audience, ["https://api"]);
assert.deepEqual(granted?.session, { id_token: { email: "ada@x.io", name: "Ada Lovelace" } });
});
test("a first-party client (metadata.first_party) auto-accepts even without skip; no identity ⇒ no id_token", async () => {
let granted: AcceptConsent | undefined;
const hydra = stubHydra(consent({ client: { client_name: "Internal", metadata: { first_party: true } }, requested_scope: ["openid"] }), (b) => { granted = b; });
const out = await resolveConsentChallenge({ hydra, kratos: stubKratos(async () => null) }, CHALLENGE, undefined);
assert.equal(out.redirect, REDIRECT);
assert.deepEqual(granted?.grant_scope, ["openid"]);
assert.equal(granted?.session, undefined);
});
test("a third-party client shows the consent screen (no auto-accept)", async () => {
let accepted = false;
const hydra = stubHydra(consent(), () => { accepted = true; });
const out = await resolveConsentChallenge({ hydra, kratos: stubKratos(async () => null) }, CHALLENGE, undefined);
assert.equal(out.redirect, undefined);
assert.deepEqual(out.view, { challenge: CHALLENGE, client: "Acme Reports", scopes: ["openid", "profile"] });
assert.equal(accepted, false);
});
test("acceptConsent re-fetches the challenge and grants its scopes (never client-supplied)", async () => {
let granted: AcceptConsent | undefined;
const hydra = stubHydra(consent(), (b) => { granted = b; });
const redirect = await acceptConsent({ hydra, kratos: stubKratos(async () => sessionWith({ email: "ada@x.io" })) }, CHALLENGE, "plainpages_session=s");
assert.equal(redirect, REDIRECT);
assert.deepEqual(granted?.grant_scope, ["openid", "profile"]);
assert.deepEqual(granted?.session, { id_token: { email: "ada@x.io" } });
});
test("rejectConsent rejects with access_denied → the client's error redirect", async () => {
const redirect = await rejectConsent({ hydra: stubHydra(consent()), kratos: stubKratos(async () => null) }, CHALLENGE);
assert.equal(redirect, DENIED);
});

78
src/oauth-consent.ts Normal file
View File

@@ -0,0 +1,78 @@
// OAuth2 consent-challenge handler (todo §6): after login, Hydra hands the browser to
// /oauth2/consent?consent_challenge=… (hydra.yml urls.consent). A first-party client (or one
// Hydra already skipped) is auto-granted the requested scopes; a third-party client shows the
// themed consent screen, then accept (allow) / reject (deny). id_token claims (email/name) come
// from the Kratos identity. OAuth2-provider role only — no first-party page needs this (README).
import type { AcceptConsent, ConsentRequest, HydraAdmin, OAuth2Client } from "./hydra-admin.ts";
import type { KratosPublic } from "./kratos-public.ts";
// Remember the grant for the browser-session lifetime (0): a client re-authorizing while the
// Kratos session lives doesn't re-prompt on every token refresh (mirrors oauth-login).
const REMEMBER_FOR = 0;
export interface OAuthConsentDeps {
hydra: HydraAdmin;
kratos: KratosPublic;
}
// What to show on the consent screen for a third-party client.
export interface ConsentView {
challenge: string;
client: string; // display name
scopes: string[];
}
// A consent challenge resolves to either an immediate redirect (auto-accepted) or a render
// decision (show the consent screen).
export interface ConsentResolution {
redirect?: string;
view?: ConsentView;
}
const isFirstParty = (client?: OAuth2Client): boolean => client?.metadata?.first_party === true;
const clientName = (client?: OAuth2Client): string => client?.client_name || client?.client_id || "the application";
// id_token claims from Kratos traits (email + a joined name); undefined ⇒ omit the session.
function idTokenClaims(traits?: Record<string, unknown>): Record<string, unknown> | undefined {
if (!traits) return undefined;
const claims: Record<string, unknown> = {};
if (typeof traits.email === "string") claims.email = traits.email;
const name = traits.name as { first?: string; last?: string } | undefined;
const full = [name?.first, name?.last].filter(Boolean).join(" ");
if (full) claims.name = full;
return Object.keys(claims).length ? claims : undefined;
}
// Accept a consent request, granting exactly the scopes/audience Hydra asked for (re-read from
// the challenge, never client-submitted) plus id_token claims from the current Kratos session.
async function accept(deps: OAuthConsentDeps, consent: ConsentRequest, cookie: string | undefined): Promise<string> {
const session = await deps.kratos.whoami(cookie ? { cookie } : {});
const idToken = idTokenClaims(session?.identity?.traits);
const body: AcceptConsent = {
grant_access_token_audience: consent.requested_access_token_audience ?? [],
grant_scope: consent.requested_scope ?? [],
remember: true,
remember_for: REMEMBER_FOR,
...(idToken ? { session: { id_token: idToken } } : {}),
};
return (await deps.hydra.acceptConsentRequest(consent.challenge, body)).redirect;
}
// Resolve a consent challenge: skip / first-party ⇒ auto-accept; else show the consent screen.
export async function resolveConsentChallenge(deps: OAuthConsentDeps, challenge: string, cookie: string | undefined): Promise<ConsentResolution> {
const consent = await deps.hydra.getConsentRequest(challenge);
if (consent.skip || isFirstParty(consent.client)) {
return { redirect: await accept(deps, consent, cookie) };
}
return { view: { challenge, client: clientName(consent.client), scopes: consent.requested_scope ?? [] } };
}
// The user allowed: re-fetch the challenge (don't trust the form for scopes) and accept.
export async function acceptConsent(deps: OAuthConsentDeps, challenge: string, cookie: string | undefined): Promise<string> {
return accept(deps, await deps.hydra.getConsentRequest(challenge), cookie);
}
// The user denied: reject so Hydra redirects back to the client with access_denied.
export async function rejectConsent(deps: OAuthConsentDeps, challenge: string): Promise<string> {
return (await deps.hydra.rejectConsentRequest(challenge, { error: "access_denied", error_description: "The user denied the request." })).redirect;
}

View File

@@ -11,10 +11,14 @@ const SUBJECT = "01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b55";
const SELF = "http://127.0.0.1:3000/oauth2/login?login_challenge=chal-1";
function stubHydra(login: LoginRequest, capture?: (b: AcceptLogin) => void): HydraAdmin {
const unused = async () => { throw new Error("unused"); };
return {
acceptConsentRequest: unused,
acceptLoginRequest: async (_c, body) => { capture?.(body); return { redirect: "http://hydra/oauth2/auth?login_verifier=v" }; },
getConsentRequest: unused,
getLoginRequest: async () => login,
rejectLoginRequest: async () => { throw new Error("unused"); },
rejectConsentRequest: unused,
rejectLoginRequest: unused,
};
}
const stubKratos = (whoami: KratosPublic["whoami"]): KratosPublic => ({