From 0900bf49bd84f2e6b4c6f4528d82d9c661166c6a Mon Sep 17 00:00:00 2001 From: lilleman Date: Fri, 19 Jun 2026 10:53:21 +0200 Subject: [PATCH] =?UTF-8?q?Built-in=20OAuth2=20consent-challenge=20handler?= =?UTF-8?q?=20(todo=20=C2=A76);=20/oauth2/consent=20grants=20scopes=20to?= =?UTF-8?q?=20a=20client=20logging=20in=20through=20us.=20New=20src/oauth-?= =?UTF-8?q?consent.ts=20(pure,=20sibling=20of=20oauth-login.ts):=20resolve?= =?UTF-8?q?ConsentChallenge=20auto-accepts=20a=20first-party=20client=20(H?= =?UTF-8?q?ydra=20metadata.first=5Fparty=3D=3D=3Dtrue)=20or=20a=20Hydra-sk?= =?UTF-8?q?ipped=20one,=20else=20returns=20a=20view=20to=20show=20the=20th?= =?UTF-8?q?emed=20consent=20screen;=20acceptConsent=20re-reads=20the=20cha?= =?UTF-8?q?llenge=20so=20scopes/audience=20are=20never=20client-supplied;?= =?UTF-8?q?=20rejectConsent=20=E2=86=92=20access=5Fdenied.=20The=20grant?= =?UTF-8?q?=20carries=20an=20OIDC=20session.id=5Ftoken=20with=20email/name?= =?UTF-8?q?=20projected=20from=20the=20Kratos=20identity=20(whoami=20trait?= =?UTF-8?q?s,=20omitted=20when=20absent).=20src/hydra-admin.ts=20gains=20t?= =?UTF-8?q?he=20consent=20half=20(get/accept/reject=20consent=20+=20types;?= =?UTF-8?q?=20login/consent=20URL=20builder=20folded=20into=20one=20reqUrl?= =?UTF-8?q?(kind,=E2=80=A6)=20+=20shared=20put()).=20Wired=20in=20app.ts?= =?UTF-8?q?=20at=20GET|POST=20/oauth2/consent=20(gated=20on=20hydra+kratos?= =?UTF-8?q?):=20GET=20shows/auto-accepts=20(sets=20the=20CSRF=20cookie=20w?= =?UTF-8?q?hen=20fresh),=20POST=20is=20CSRF-guarded=20(same=20signed=20dou?= =?UTF-8?q?ble-submit=20as=20/logout)=20and=20dispatches=20allow=E2=86=92a?= =?UTF-8?q?ccept=20/=20else=E2=86=92reject=20=E2=86=92=20303=20to=20Hydra;?= =?UTF-8?q?=20a=20stale/consumed=20challenge=20(Hydra=204xx)=20degrades=20?= =?UTF-8?q?to=20a=20recoverable=20400,=20a=20real=20outage=20(5xx)=20?= =?UTF-8?q?=E2=86=92=20500=20(mirrors=20/oauth2/login).=20views/oauth-cons?= =?UTF-8?q?ent.ejs=20+=20partials/consent-body.ejs=20reuse=20the=20auth-ca?= =?UTF-8?q?rd,=20listing=20the=20requested=20scopes=20(friendly=20labels?= =?UTF-8?q?=20for=20the=20standard=20OIDC=20ones)=20with=20Allow/Deny=20su?= =?UTF-8?q?bmit=20buttons.=20Tests-first:=20hydra-admin=20consent=20contra?= =?UTF-8?q?cts=20+=20oauth-consent=20skip/first-party/third-party/audience?= =?UTF-8?q?/id=5Ftoken/refetch/reject=20matrix=20+=20app=20HTTP=20integrat?= =?UTF-8?q?ion=20(auto-accept=20/=20screen+CSRF=20cookie=20/=20allow+deny?= =?UTF-8?q?=20/=20forged-CSRF=E2=86=92403=20/=20missing=E2=86=92400=20/=20?= =?UTF-8?q?stale=E2=86=92400=20/=20outage=E2=86=92500).=20Stability-review?= =?UTF-8?q?er=20run=20as=20a=20local=20PR:=20APPROVE,=20no=20Critical/High?= =?UTF-8?q?.=20Extended=20e2e/oauth-login.spec.ts=20to=20drive=20the=20who?= =?UTF-8?q?le=20authorization-code=20flow=20against=20real=20Hydra=20?= =?UTF-8?q?=E2=80=94=20login=20accept=20=E2=86=92=20follow=20login=5Fverif?= =?UTF-8?q?ier=20through=20Hydra=20=E2=86=92=20web's=20consent=20screen=20?= =?UTF-8?q?(third-party=20e2e-login,=20scopes=20listed)=20=E2=86=92=20Allo?= =?UTF-8?q?w=20=E2=86=92=20consent=5Fverifier=20=E2=86=92=20client=20callb?= =?UTF-8?q?ack=20with=20a=20real=20code=20(per-host=20cookie=20jars,=20Hyd?= =?UTF-8?q?ra=20resume=20URLs=20rebased=20onto=20the=20compose=20host).=20?= =?UTF-8?q?typecheck=20+=20262=20units=20+=208=20visual=20+=20OAuth=20logi?= =?UTF-8?q?n+consent=20E2E=20green.=20OAuth2=20client=20registration=20is?= =?UTF-8?q?=20the=20next=20=C2=A76=20item.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 21 +++++---- e2e/oauth-login.spec.ts | 81 +++++++++++++++++++++++++++++++-- src/app.test.ts | 67 +++++++++++++++++++++++++++ src/app.ts | 51 +++++++++++++++++++++ src/hydra-admin.test.ts | 26 +++++++++++ src/hydra-admin.ts | 57 +++++++++++++++++++++-- src/oauth-consent.test.ts | 78 +++++++++++++++++++++++++++++++ src/oauth-consent.ts | 78 +++++++++++++++++++++++++++++++ src/oauth-login.test.ts | 6 ++- todo.md | 2 +- views/oauth-consent.ejs | 36 +++++++++++++++ views/partials/consent-body.ejs | 17 +++++++ 12 files changed, 500 insertions(+), 20 deletions(-) create mode 100644 src/oauth-consent.test.ts create mode 100644 src/oauth-consent.ts create mode 100644 views/oauth-consent.ejs create mode 100644 views/partials/consent-body.ejs diff --git a/README.md b/README.md index a547d58..efbb39c 100644 --- a/README.md +++ b/README.md @@ -241,10 +241,11 @@ docker compose -f compose.yml -f compose.e2e-auth.yml run --build --rm e2e # r docker compose -f compose.yml -f compose.e2e-auth.yml down -v # tear down after ``` -**OAuth2 login challenge** (`oauth-login.spec.ts`) — another app logs in *through* us: it boots the -real stack (incl. Hydra), registers an OAuth2 client, starts an authorization flow, and proves the -§6 `/oauth2/login` handler bounces an unauthenticated user to the themed login and **accepts** the -challenge once a Kratos session exists. +**OAuth2 login + consent** (`oauth-login.spec.ts`) — another app logs in *through* us: it boots the +real stack (incl. Hydra), registers an OAuth2 client, starts an authorization flow, and drives the +§6 handlers end-to-end — `/oauth2/login` bounces an unauthenticated user to the themed login and +**accepts** the challenge once a Kratos session exists; `/oauth2/consent` then shows the consent +screen for the third-party client and **Allow** drives Hydra to issue the authorization code. ```bash docker compose -f compose.yml -f compose.e2e-oauth.yml run --build --rm e2e # run the suite @@ -498,7 +499,10 @@ in the menu or first-party pages needs Hydra. The **login challenge** is wired (`src/oauth-login.ts` at `/oauth2/login`): Hydra hands the browser here, the app resolves it against the Kratos session and accepts (or bounces an unauthenticated user to the themed login, returning here once signed in). The **consent -challenge** is next. +challenge** is wired too (`src/oauth-consent.ts` at `/oauth2/consent`): a first-party client +(its Hydra `metadata.first_party: true`) — or one Hydra already skipped — is auto-granted the +requested scopes; any other client gets a themed consent screen whose CSRF-guarded Allow/Deny +accepts or rejects. id_token claims (email, name) come from the Kratos identity. ## Stateless — no application database @@ -541,8 +545,9 @@ src/jwks.ts JwksProvider — resolve the verify key by kid; createJwksP src/kratos-public.ts createKratosPublic(): Kratos public-API fetch client — self-service flow init/get/submit, browser logout, whoami, session→JWT tokenize (§4) src/kratos-admin.ts createKratosAdmin(): Kratos admin-API fetch client — identity CRUD + surgical metadata_public update (login role projection, §4) src/keto-client.ts createKetoClient(): Keto fetch client — check / list / expand relations (read API) + write / delete tuples (write API) (§4) -src/hydra-admin.ts createHydraAdmin(): Hydra admin-API fetch client — OAuth2 login challenge get/accept/reject (§6) +src/hydra-admin.ts createHydraAdmin(): Hydra admin-API fetch client — OAuth2 login + consent challenge get/accept/reject (§6) src/oauth-login.ts resolveLoginChallenge(): authenticate a Hydra login challenge via the Kratos session → accept, or bounce to /login (§6) +src/oauth-consent.ts resolveConsentChallenge()/acceptConsent()/rejectConsent(): auto-accept first-party, else show the consent screen → grant scopes (§6) src/flow-view.ts buildFlowView(): Kratos self-service Flow → themed view model (fields, hidden csrf, buttons, tone-mapped messages) for views/auth.ejs (§4) src/login.ts completeLogin()/remintSession(): login completion + TTL re-mint — roles from Keto → metadata_public projection → tokenize → session JWT cookie (§4) src/gen-jwks.ts generateJwks() + CLI: mint the ES256 session-tokenizer signing JWKS (§3); see JWT signing key & rotation @@ -567,13 +572,13 @@ src/discovery.ts discoverPlugins(): scan plugins/, import + validate each pl src/router.ts matchRoute()/allowedMethods()/isAuthorized(): map method+path → plugin route, params, permission gate (§2) src/view-resolver.ts renderPluginView(): render plugins//views/.ejs; plugin views can include() core partials (§2) src/menu-config.ts loadMenuConfig()/defineMenu(): read config/menu.ts (central override + branding), validated at boot (§2) -views/ Core EJS templates: index (app-shell dashboard), admin/ (Users/Groups/Roles lists + create/edit/detail + delete-confirm), auth (themed Kratos flows), 403/404/500, partials/ (shell, nav tree, filter bar, data table, pagination, field, auth card, alert, flow + admin bodies, menu/popover, theme switch, icon sprite) +views/ Core EJS templates: index (app-shell dashboard), admin/ (Users/Groups/Roles lists + create/edit/detail + delete-confirm), auth (themed Kratos flows), oauth-consent (OAuth2 consent screen), 403/404/500, partials/ (shell, nav tree, filter bar, data table, pagination, field, auth card, alert, flow + consent + admin bodies, menu/popover, theme switch, icon sprite) public/ Static assets under /public/ (css/styles.css + auth.css, favicon, robots.txt) config/menu.ts Central menu override + branding (optional; defaults apply if absent) ory/ Ory service config (kratos/: identity schema, kratos.yml, oidc/ SSO claims mapper, tokenizer/ session→JWT claims mapper + dev signing JWKS; keto/: keto.yml + namespaces.keto.ts OPL — role/group/resource; hydra/hydra.yml: OAuth2 issuer + login/consent URLs → /oauth2/*) + storage init (postgres/init/init.sql: one DB per service) plugins/ Drop-in plugin folders (scanned at /app/plugins; bind-mount or bake in) (planned) docs/ Reference docs (plugin-contract.md — the authoritative plugin API) -e2e/ Playwright E2E: visual.spec (design system, Ory-free) + auth-refresh.spec (token timeout/re-mint) + oauth-login.spec (OAuth2 login challenge), full Ory stack; Dockerfile.e2e + compose.e2e[-auth|-oauth].yml run them +e2e/ Playwright E2E: visual.spec (design system, Ory-free) + auth-refresh.spec (token timeout/re-mint) + oauth-login.spec (OAuth2 login + consent → authorization code), full Ory stack; Dockerfile.e2e + compose.e2e[-auth|-oauth].yml run them html-css-foundation/ HTML design mockups — the source for the building-block partials; reference the stylesheets in public/css/. ``` diff --git a/e2e/oauth-login.spec.ts b/e2e/oauth-login.spec.ts index e1fdaee..7f17f4e 100644 --- a/e2e/oauth-login.spec.ts +++ b/e2e/oauth-login.spec.ts @@ -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; +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 { const res = await fetch(`${HYDRA_ADMIN}/admin/clients`, { @@ -42,10 +65,12 @@ async function createClient(): Promise { } // Hit Hydra's authorization endpoint; it redirects to web's login URL carrying a login_challenge. -async function startAuthFlow(clientId: string): Promise { +// `jar` (when given) absorbs Hydra's flow cookies, needed to follow the login/consent verifiers. +async function startAuthFlow(clientId: string, jar?: Jar): Promise { 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(); +}); diff --git a/src/app.test.ts b/src/app.test.ts index 5a5cfa9..2023bcf 100644 --- a/src/app.test.ts +++ b/src/app.test.ts @@ -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 => ({ + 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((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((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((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((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 => diff --git a/src/app.ts b/src/app.ts index 47c75ca..25951bd 100644 --- a/src/app.ts +++ b/src/app.ts @@ -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). diff --git a/src/hydra-admin.test.ts b/src/hydra-admin.test.ts index f46dea5..76697c0 100644 --- a/src/hydra-admin.test.ts +++ b/src/hydra-admin.test.ts @@ -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( diff --git a/src/hydra-admin.ts b/src/hydra-admin.ts index 038c626..c818129 100644 --- a/src/hydra-admin.ts +++ b/src/hydra-admin.ts @@ -7,6 +7,7 @@ export interface OAuth2Client { client_id?: string; client_name?: string; + metadata?: Record; // 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; + id_token?: Record; +} + +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; acceptLoginRequest(challenge: string, body: AcceptLogin): Promise; + getConsentRequest(challenge: string): Promise; getLoginRequest(challenge: string): Promise; + rejectConsentRequest(challenge: string, body: RejectRequest): Promise; rejectLoginRequest(challenge: string, body: RejectRequest): Promise; } @@ -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 { 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); }, }; } diff --git a/src/oauth-consent.test.ts b/src/oauth-consent.test.ts new file mode 100644 index 0000000..e139f58 --- /dev/null +++ b/src/oauth-consent.test.ts @@ -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): Session => ({ active: true, identity: { id: SUBJECT, ...(traits ? { traits } : {}) } }); +const consent = (over: Partial = {}): 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); +}); diff --git a/src/oauth-consent.ts b/src/oauth-consent.ts new file mode 100644 index 0000000..71c29c4 --- /dev/null +++ b/src/oauth-consent.ts @@ -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): Record | undefined { + if (!traits) return undefined; + const claims: Record = {}; + 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 { + 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 { + 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 { + 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 { + return (await deps.hydra.rejectConsentRequest(challenge, { error: "access_denied", error_description: "The user denied the request." })).redirect; +} diff --git a/src/oauth-login.test.ts b/src/oauth-login.test.ts index 18bb4e8..62b87f4 100644 --- a/src/oauth-login.test.ts +++ b/src/oauth-login.test.ts @@ -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 => ({ diff --git a/todo.md b/todo.md index cb65f03..b82f646 100644 --- a/todo.md +++ b/todo.md @@ -103,7 +103,7 @@ everything via Docker. ## 6. Hydra — OAuth2/OIDC provider (can ship after the rest) - [x] Login-challenge handler: authenticate via Kratos session, accept/reject. → `src/hydra-admin.ts` (`createHydraAdmin`): typed `fetch` wrappers over Hydra's OAuth2 admin API (port 4445, no SDK, `fetchImpl`-injectable like the kratos/keto clients) — `getLoginRequest`/`acceptLoginRequest`/`rejectLoginRequest` + a `HydraError` carrying `.status`. `src/oauth-login.ts` (`resolveLoginChallenge`, pure): `getLoginRequest` → **skip** (Hydra already authenticated the subject) ⇒ accept it without touching Kratos; a live **Kratos session** (`whoami`) ⇒ accept with that identity as the subject (`remember`, browser-session lifetime); **no session** ⇒ bounce to our themed `/login?return_to=`, so Kratos lands back on the challenge once signed in. Wired into `app.ts` at `GET /oauth2/login` (gated on `hydra`+`kratos` present; missing `login_challenge`→400; the absolute return target derives from the request Host + the SECURE_COOKIES scheme — a spoofed Host can't escape, Kratos validates `return_to` against its allow-list); `/login` now bakes a `return_to` into the Kratos flow init so the round-trip works. `config.ts` gains `hydraAdminUrl` (default `http://hydra:4445`); `server.ts` builds the client; `compose.yml` `web` now gates on `hydra` healthy (the app consumes it). Tests-first: `hydra-admin.test.ts` (request contracts + error mapping), `oauth-login.test.ts` (skip/session/no-session matrix), `app.test.ts` (HTTP: accept→Hydra redirect / no-session→/login bounce / missing-challenge→400 / `/login` return_to forwarding), `config.test.ts` + `compose.test.ts` (web↦hydra dep). Full-stack E2E `e2e/oauth-login.spec.ts` (`compose.e2e-oauth.yml`): boots the real stack incl. Hydra, registers an OAuth2 client, starts an authorization flow, asserts the unauthenticated bounce **and** the authenticated accept (→ Hydra `/oauth2/auth?…login_verifier=…`) — green, then torn down. Stability-reviewer run as a local PR: APPROVE, no Critical/High; addressed its one stability warning — a stale/invalid/consumed challenge (Hydra 4xx, user-reachable via back button/slow login) now degrades to a recoverable 400 instead of a 500, while a genuine Hydra 5xx outage still surfaces as 500 (mirrors the themed-flow + §4 re-mint hardening). Deferred (reviewer-scoped, §9): document that prod `allowed_return_urls` entries must be exact origins with a trailing `/` (the return_to safety leans on Kratos' allow-list). typecheck + 253 units + 8 visual E2E green. Consent handler + client registration are the next §6 items. -- [ ] Consent-challenge handler: show / auto-accept first-party, grant scopes, accept/reject. +- [x] Consent-challenge handler: show / auto-accept first-party, grant scopes, accept/reject. → `src/hydra-admin.ts` gains the consent half of the handshake (`getConsentRequest`/`acceptConsentRequest`/`rejectConsentRequest` + `ConsentRequest`/`AcceptConsent`/`ConsentSession` types; the login/consent URL builder folded into one `reqUrl(kind,…)` + a shared `put()`). `src/oauth-consent.ts` (pure, sibling of `oauth-login.ts`): `resolveConsentChallenge` → **skip** (Hydra already consented / a skip-consent client) or **first-party** (the client's Hydra `metadata.first_party === true`) ⇒ auto-accept, else return 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; absent ⇒ omitted). Wired in `app.ts` at `GET|POST /oauth2/consent` (gated on `hydra`+`kratos`): GET shows/auto-accepts (sets the page CSRF cookie when fresh), POST is **CSRF-guarded** (same signed double-submit as `/logout`) and dispatches `decision=allow`→accept / else→reject → 303 to Hydra; a stale/consumed challenge (Hydra 4xx) degrades to a recoverable 400, a genuine outage (5xx) → 500 (mirrors `/oauth2/login`). `views/oauth-consent.ejs` + `partials/consent-body.ejs` reuse the auth-card: the consent screen lists the requested scopes (friendly labels for the standard OIDC ones) with Allow/Deny submit buttons. Tests-first: `hydra-admin.test.ts` (consent request contracts), `oauth-consent.test.ts` (skip/first-party/third-party/audience/id_token/accept-refetch/reject matrix), `app.test.ts` HTTP integration (auto-accept / screen render+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 the full-stack E2E `e2e/oauth-login.spec.ts` to drive the **whole** authorization-code flow against real Hydra — login accept → follow the login_verifier through Hydra → web's consent screen (third-party client `e2e-login`, scopes listed) → Allow → consent_verifier → the 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; stack torn down. OAuth2 client registration is the next §6 item. - [ ] OAuth2 client registration (admin UI or CLI). - [ ] Run the architecture and the product reviewer agents on the _whole_ project, not just the latest changes, and address their issues. - [ ] Go over all comments in the code and the README and try to make it shorter and more information dense. Remove not strictly needed stuff. diff --git a/views/oauth-consent.ejs b/views/oauth-consent.ejs new file mode 100644 index 0000000..1ff144e --- /dev/null +++ b/views/oauth-consent.ejs @@ -0,0 +1,36 @@ +<%# + Themed OAuth2 consent page (todo §6): shown when a third-party client wants access and the + user must approve. Reuses the auth layout + auth-card; the form posts (Allow/Deny) to our own + /oauth2/consent route, CSRF-guarded (consent-body carries the token). Auto theme (styles.css). +%><% + const brand = locals.brand || "Plainpages"; + const body = include("partials/consent-body", { challenge: consent.challenge, csrfField, csrfToken, scopes: consent.scopes }); +%> + + + + + Authorize <%= consent.client %> + + + + + + <%- include("partials/icons") %> +
+
+
+ + <%= brand %> +
+ <%- include("partials/auth-card", { + action: "/oauth2/consent", + body, + method: "post", + sub: `${consent.client} wants access to your account.`, + title: `Authorize ${consent.client}`, + }) %> +
+
+ + diff --git a/views/partials/consent-body.ejs b/views/partials/consent-body.ejs new file mode 100644 index 0000000..bd7f70b --- /dev/null +++ b/views/partials/consent-body.ejs @@ -0,0 +1,17 @@ +<%# + OAuth2 consent form body (todo §6): the inner of the auth-card form — the CSRF + challenge + hidden inputs, the requested scopes, then Allow / Deny submit buttons (one `decision` field). + Locals: challenge, csrfField, csrfToken, scopes (string[]). Captured by views/oauth-consent.ejs. +-%> +<% const labels = { email: "Your email address", offline_access: "Stay signed in (offline access)", openid: "Verify your identity", profile: "Your basic profile (name)" }; -%> + + +<% if (scopes.length) { -%> + +<% } -%> + +