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

@@ -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).