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

@@ -1,10 +1,11 @@
import { expect, test } from "@playwright/test";
// Full-stack OAuth2 login-challenge E2E (§6): another app logs in *through* plainpages. Hydra
// Full-stack OAuth2 login + consent E2E (§6): another app logs in *through* plainpages. Hydra
// starts an authorization flow and hands the browser to web's /oauth2/login; web resolves it via
// the Kratos session and accepts (Hydra then continues to consent + token issuance). We drive the
// flow over HTTP (fetch, manual cookies) because the browser hosts differ on the compose network;
// this exercises web's server-side challenge handling. The browser-UI login is owned by §8.
// the Kratos session and accepts, Hydra continues to web's /oauth2/consent, web shows the themed
// consent screen, and Allow drives Hydra to issue the authorization code. We drive the flow over
// HTTP (fetch, per-host cookie jars) because the browser hosts differ on the compose network; this
// exercises web's server-side challenge handling. The browser-UI login is owned by §8.
const WEB = process.env.BASE_URL ?? "http://web:3000";
const KRATOS = process.env.KRATOS_PUBLIC_URL ?? "http://kratos:4433";
const HYDRA_PUBLIC = process.env.HYDRA_PUBLIC_URL ?? "http://hydra:4444";
@@ -22,6 +23,28 @@ function relayCookies(res: Response): string {
return res.headers.getSetCookie().map((c) => c.split(";", 1)[0]!).filter((kv) => kv.split("=")[1] !== "").join("; ");
}
// Per-host cookie jar (the browser keeps Hydra's flow cookies separate from web's CSRF cookie).
type Jar = Map<string, string>;
function absorb(jar: Jar, res: Response): void {
for (const line of res.headers.getSetCookie()) {
const kv = line.split(";", 1)[0]!;
const name = kv.slice(0, kv.indexOf("="));
const value = kv.slice(kv.indexOf("=") + 1);
if (value === "") jar.delete(name);
else jar.set(name, value);
}
}
const jarCookie = (jar: Jar): string => [...jar].map(([k, v]) => `${k}=${v}`).join("; ");
// Hydra's resume URLs carry its issuer host (127.0.0.1:4444), unreachable from the runner —
// rebase onto the compose-network host so we can follow them.
function onHydra(url: string): string {
const u = new URL(url);
const host = new URL(HYDRA_PUBLIC);
u.protocol = host.protocol;
u.host = host.host;
return u.toString();
}
// Register a confidential OAuth2 client (admin API) so we can start an authorization flow.
async function createClient(): Promise<string> {
const res = await fetch(`${HYDRA_ADMIN}/admin/clients`, {
@@ -42,10 +65,12 @@ async function createClient(): Promise<string> {
}
// Hit Hydra's authorization endpoint; it redirects to web's login URL carrying a login_challenge.
async function startAuthFlow(clientId: string): Promise<string> {
// `jar` (when given) absorbs Hydra's flow cookies, needed to follow the login/consent verifiers.
async function startAuthFlow(clientId: string, jar?: Jar): Promise<string> {
const auth = new URL(`${HYDRA_PUBLIC}/oauth2/auth`);
auth.search = new URLSearchParams({ client_id: clientId, redirect_uri: "http://127.0.0.1:3000/callback", response_type: "code", scope: "openid", state: "0123456789abcdef0123456789abcdef" }).toString();
const res = await fetch(auth, { redirect: "manual" });
if (jar) absorb(jar, res);
expect([302, 303], `auth flow start: ${res.status}`).toContain(res.status);
const location = res.headers.get("location") ?? "";
expect(location, "Hydra redirects to our login URL").toContain("/oauth2/login");
@@ -90,3 +115,49 @@ test("Hydra login challenge: an unauthenticated user bounces to /login, an authe
expect(resume, "accepted → back to Hydra's /oauth2/auth to continue").toContain("/oauth2/auth");
expect(resume, "carries Hydra's login_verifier").toContain("login_verifier");
});
test("Hydra consent challenge: web shows the third-party consent screen; Allow → authorization code", async () => {
test.setTimeout(60_000);
const hydra: Jar = new Map(); // Hydra's flow cookies, needed to follow the verifiers
const web: Jar = new Map(); // web's CSRF cookie
// Log in and accept the login challenge → Hydra resume URL (as in the login test).
const challenge = await startAuthFlow(await createClient(), hydra);
const session = await kratosLogin();
const accepted = await fetch(`${WEB}/oauth2/login?login_challenge=${challenge}`, { headers: { cookie: `plainpages_session=${session}` }, redirect: "manual" });
expect(accepted.status).toBe(303);
// Follow the login_verifier through Hydra → web's /oauth2/consent?consent_challenge=…
const toConsent = await fetch(onHydra(accepted.headers.get("location") ?? ""), { headers: { cookie: jarCookie(hydra) }, redirect: "manual" });
absorb(hydra, toConsent);
const consentLoc = toConsent.headers.get("location") ?? "";
expect(consentLoc, `→ web consent (${toConsent.status})`).toContain("/oauth2/consent");
const consentChallenge = new URL(consentLoc).searchParams.get("consent_challenge")!;
// web shows the themed consent screen for this third-party client, listing the requested scope.
const screen = await fetch(`${WEB}/oauth2/consent?consent_challenge=${consentChallenge}`, { headers: { cookie: `plainpages_session=${session}` }, redirect: "manual" });
expect(screen.status).toBe(200);
absorb(web, screen);
const html = await screen.text();
expect(html).toContain("Authorize e2e-login");
expect(html).toContain("openid");
const csrf = html.match(/name="_csrf" value="([^"]+)"/)?.[1];
expect(csrf, "consent form carries a CSRF token").toBeTruthy();
// Allow → web accepts the consent → Hydra resume URL with a consent_verifier.
const allow = await fetch(`${WEB}/oauth2/consent`, {
body: new URLSearchParams({ _csrf: csrf!, consent_challenge: consentChallenge, decision: "allow" }).toString(),
headers: { "content-type": "application/x-www-form-urlencoded", cookie: `${jarCookie(web)}; plainpages_session=${session}` },
method: "POST",
redirect: "manual",
});
expect(allow.status, `allow consent: ${await allow.clone().text()}`).toBe(303);
const consentResume = allow.headers.get("location") ?? "";
expect(consentResume, "carries Hydra's consent_verifier").toContain("consent_verifier");
// Follow the consent_verifier through Hydra → the client callback with an authorization code.
const toCallback = await fetch(onHydra(consentResume), { headers: { cookie: jarCookie(hydra) }, redirect: "manual" });
const callback = toCallback.headers.get("location") ?? "";
expect(callback, `→ client callback (${toCallback.status})`).toContain("/callback");
expect(new URL(callback).searchParams.get("code"), "an authorization code is issued").toBeTruthy();
});