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

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