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 =>