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

36
views/oauth-consent.ejs Normal file
View File

@@ -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 });
%><!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Authorize <%= consent.client %></title>
<link rel="stylesheet" href="/public/css/styles.css" />
<link rel="stylesheet" href="/public/css/auth.css" />
<link rel="icon" href="/public/favicon.svg" />
</head>
<body>
<%- include("partials/icons") %>
<main class="auth-stage">
<div class="auth">
<div class="auth-brand">
<span class="brand-mark"><svg class="ico ico-sm"><use href="#i-box" /></svg></span>
<span class="brand-name"><%= brand %></span>
</div>
<%- include("partials/auth-card", {
action: "/oauth2/consent",
body,
method: "post",
sub: `${consent.client} wants access to your account.`,
title: `Authorize ${consent.client}`,
}) %>
</div>
</main>
</body>
</html>

View File

@@ -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)" }; -%>
<input type="hidden" name="<%= csrfField %>" value="<%= csrfToken %>">
<input type="hidden" name="consent_challenge" value="<%= challenge %>">
<% if (scopes.length) { -%>
<ul class="plain-list consent-scopes">
<% scopes.forEach((s) => { -%>
<li><strong><%= s %></strong><% if (labels[s]) { %> — <%= labels[s] %><% } %></li>
<% }) -%>
</ul>
<% } -%>
<button type="submit" class="btn btn-block btn-primary" name="decision" value="allow">Allow</button>
<button type="submit" class="btn btn-block" name="decision" value="deny">Deny</button>