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

@@ -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 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 **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 proves the real stack (incl. Hydra), registers an OAuth2 client, starts an authorization flow, and drives the
§6 `/oauth2/login` handler bounces an unauthenticated user to the themed login and **accepts** the §6 handlers end-to-end — `/oauth2/login` bounces an unauthenticated user to the themed login and
challenge once a Kratos session exists. **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 ```bash
docker compose -f compose.yml -f compose.e2e-oauth.yml run --build --rm e2e # run the suite 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 **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 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 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 ## 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-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/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/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-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/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/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 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/router.ts matchRoute()/allowedMethods()/isAuthorized(): map method+path → plugin route, params, permission gate (§2)
src/view-resolver.ts renderPluginView(): render plugins/<id>/views/<view>.ejs; plugin views can include() core partials (§2) src/view-resolver.ts renderPluginView(): render plugins/<id>/views/<view>.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) 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) 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) 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) 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) 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) 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 html-css-foundation/ HTML design mockups — the source for the building-block
partials; reference the stylesheets in public/css/. partials; reference the stylesheets in public/css/.
``` ```

View File

@@ -1,10 +1,11 @@
import { expect, test } from "@playwright/test"; 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 // 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 // the Kratos session and accepts, Hydra continues to web's /oauth2/consent, web shows the themed
// flow over HTTP (fetch, manual cookies) because the browser hosts differ on the compose network; // consent screen, and Allow drives Hydra to issue the authorization code. We drive the flow over
// this exercises web's server-side challenge handling. The browser-UI login is owned by §8. // 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 WEB = process.env.BASE_URL ?? "http://web:3000";
const KRATOS = process.env.KRATOS_PUBLIC_URL ?? "http://kratos:4433"; const KRATOS = process.env.KRATOS_PUBLIC_URL ?? "http://kratos:4433";
const HYDRA_PUBLIC = process.env.HYDRA_PUBLIC_URL ?? "http://hydra:4444"; 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("; "); 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. // Register a confidential OAuth2 client (admin API) so we can start an authorization flow.
async function createClient(): Promise<string> { async function createClient(): Promise<string> {
const res = await fetch(`${HYDRA_ADMIN}/admin/clients`, { 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. // 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`); 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(); 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" }); const res = await fetch(auth, { redirect: "manual" });
if (jar) absorb(jar, res);
expect([302, 303], `auth flow start: ${res.status}`).toContain(res.status); expect([302, 303], `auth flow start: ${res.status}`).toContain(res.status);
const location = res.headers.get("location") ?? ""; const location = res.headers.get("location") ?? "";
expect(location, "Hydra redirects to our login URL").toContain("/oauth2/login"); 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, "accepted → back to Hydra's /oauth2/auth to continue").toContain("/oauth2/auth");
expect(resume, "carries Hydra's login_verifier").toContain("login_verifier"); 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();
});

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. // OAuth2 login challenge (§6): another app logs in *through* us; Hydra hands the browser here.
const stubHydra = (over: Partial<HydraAdmin> = {}): HydraAdmin => ({ 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" }), 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: "" }), getLoginRequest: async () => ({ challenge: "chal1", skip: false, subject: "" }),
rejectConsentRequest: async () => ({ redirect: "http://acme.example/cb?error=access_denied" }),
rejectLoginRequest: async () => { throw new Error("unused"); }, rejectLoginRequest: async () => { throw new Error("unused"); },
...over, ...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) => { 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" }; 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); 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. // 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) => { test("admin Users screen: gate, list/filter, create, edit, deactivate, delete, recovery (CSRF-guarded)", async (t) => {
const mk = (email: string, over: Partial<Identity> = {}): Identity => const mk = (email: string, over: Partial<Identity> = {}): Identity =>

View File

@@ -23,6 +23,7 @@ import type { KratosAdmin } from "./kratos-admin.ts";
import { KratosError, type KratosPublic } from "./kratos-public.ts"; import { KratosError, type KratosPublic } from "./kratos-public.ts";
import { clearSessionCookie, completeLogin, remintSession, sessionCookie } from "./login.ts"; import { clearSessionCookie, completeLogin, remintSession, sessionCookie } from "./login.ts";
import { resolveLoginChallenge } from "./oauth-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 { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts";
import type { Plugin, RouteResult } from "./plugin.ts"; import type { Plugin, RouteResult } from "./plugin.ts";
import { allowedMethods, isAuthorized, matchRoute } from "./router.ts"; import { allowedMethods, isAuthorized, matchRoute } from "./router.ts";
@@ -236,6 +237,56 @@ export function createApp(options: AppOptions = {}): Server {
return; 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). // Login completion: where Kratos lands the browser after authenticating (kratos.yml).
// Mint our session JWT — read roles from Keto, project onto the identity, tokenize — // 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). // and store it as the cookie; no active session bounces back to sign in (§4).

View File

@@ -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" }); 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 () => { test("a non-2xx response throws a HydraError carrying the status", async () => {
const { fetchImpl } = recorder(() => res(404, { error: "Not Found" })); const { fetchImpl } = recorder(() => res(404, { error: "Not Found" }));
await assert.rejects( await assert.rejects(

View File

@@ -7,6 +7,7 @@
export interface OAuth2Client { export interface OAuth2Client {
client_id?: string; client_id?: string;
client_name?: string; client_name?: string;
metadata?: Record<string, unknown>; // arbitrary client metadata; `first_party: true` ⇒ auto-consent (§6)
} }
// A login request Hydra hands us at /oauth2/login. `skip` ⇒ Hydra already authenticated this // A login request Hydra hands us at /oauth2/login. `skip` ⇒ Hydra already authenticated this
@@ -27,6 +28,32 @@ export interface AcceptLogin {
subject: string; 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<string, unknown>;
id_token?: Record<string, unknown>;
}
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 { export interface RejectRequest {
error?: string; error?: string;
error_description?: string; error_description?: string;
@@ -50,8 +77,11 @@ export class HydraError extends Error {
} }
export interface HydraAdmin { export interface HydraAdmin {
acceptConsentRequest(challenge: string, body: AcceptConsent): Promise<Completed>;
acceptLoginRequest(challenge: string, body: AcceptLogin): Promise<Completed>; acceptLoginRequest(challenge: string, body: AcceptLogin): Promise<Completed>;
getConsentRequest(challenge: string): Promise<ConsentRequest>;
getLoginRequest(challenge: string): Promise<LoginRequest>; getLoginRequest(challenge: string): Promise<LoginRequest>;
rejectConsentRequest(challenge: string, body: RejectRequest): Promise<Completed>;
rejectLoginRequest(challenge: string, body: RejectRequest): Promise<Completed>; rejectLoginRequest(challenge: string, body: RejectRequest): Promise<Completed>;
} }
@@ -60,8 +90,8 @@ export function createHydraAdmin(config: { baseUrl: string; fetchImpl?: typeof f
const http = config.fetchImpl ?? fetch; const http = config.fetchImpl ?? fetch;
const json = { "content-type": "application/json" }; const json = { "content-type": "application/json" };
// Hydra keys the login/consent handshake off a ?login_challenge=/?consent_challenge= query. // Hydra keys the login/consent handshake off a ?login_challenge=/?consent_challenge= query.
const loginUrl = (challenge: string, action = "") => const reqUrl = (kind: "consent" | "login", challenge: string, action = "") =>
`${base}/admin/oauth2/auth/requests/login${action}?login_challenge=${encodeURIComponent(challenge)}`; `${base}/admin/oauth2/auth/requests/${kind}${action}?${kind}_challenge=${encodeURIComponent(challenge)}`;
async function fail(action: string, res: Response): Promise<never> { async function fail(action: string, res: Response): Promise<never> {
throw new HydraError(`Hydra admin ${action} failed (${res.status})`, res.status, await res.text()); 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 }; 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 { return {
async acceptConsentRequest(challenge, body) {
return put("accept consent", reqUrl("consent", challenge, "/accept"), body);
},
async acceptLoginRequest(challenge, 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) { 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); if (res.status !== 200) return fail("get login request", res);
return (await res.json()) as LoginRequest; return (await res.json()) as LoginRequest;
}, },
async rejectConsentRequest(challenge, body) {
return put("reject consent", reqUrl("consent", challenge, "/reject"), body);
},
async rejectLoginRequest(challenge, 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);
}, },
}; };
} }

78
src/oauth-consent.test.ts Normal file
View File

@@ -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<string, unknown>): Session => ({ active: true, identity: { id: SUBJECT, ...(traits ? { traits } : {}) } });
const consent = (over: Partial<ConsentRequest> = {}): 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);
});

78
src/oauth-consent.ts Normal file
View File

@@ -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<string, unknown>): Record<string, unknown> | undefined {
if (!traits) return undefined;
const claims: Record<string, unknown> = {};
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<string> {
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<ConsentResolution> {
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<string> {
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<string> {
return (await deps.hydra.rejectConsentRequest(challenge, { error: "access_denied", error_description: "The user denied the request." })).redirect;
}

View File

@@ -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"; const SELF = "http://127.0.0.1:3000/oauth2/login?login_challenge=chal-1";
function stubHydra(login: LoginRequest, capture?: (b: AcceptLogin) => void): HydraAdmin { function stubHydra(login: LoginRequest, capture?: (b: AcceptLogin) => void): HydraAdmin {
const unused = async () => { throw new Error("unused"); };
return { return {
acceptConsentRequest: unused,
acceptLoginRequest: async (_c, body) => { capture?.(body); return { redirect: "http://hydra/oauth2/auth?login_verifier=v" }; }, acceptLoginRequest: async (_c, body) => { capture?.(body); return { redirect: "http://hydra/oauth2/auth?login_verifier=v" }; },
getConsentRequest: unused,
getLoginRequest: async () => login, getLoginRequest: async () => login,
rejectLoginRequest: async () => { throw new Error("unused"); }, rejectConsentRequest: unused,
rejectLoginRequest: unused,
}; };
} }
const stubKratos = (whoami: KratosPublic["whoami"]): KratosPublic => ({ const stubKratos = (whoami: KratosPublic["whoami"]): KratosPublic => ({

View File

@@ -103,7 +103,7 @@ everything via Docker.
## 6. Hydra — OAuth2/OIDC provider (can ship after the rest) ## 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=<absolute self URL>`, 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. - [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=<absolute self URL>`, 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). - [ ] 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. - [ ] 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. - [ ] 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.

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>