From 3c8090e8e336aa4205117933129c7058d2d0aca1 Mon Sep 17 00:00:00 2001 From: lilleman Date: Thu, 18 Jun 2026 21:45:24 +0200 Subject: [PATCH] =?UTF-8?q?Built-in=20OAuth2=20login-challenge=20handler?= =?UTF-8?q?=20(todo=20=C2=A76);=20/oauth2/login=20resolves=20a=20Hydra=20l?= =?UTF-8?q?ogin=20challenge=20via=20the=20Kratos=20session=20=E2=80=94=20s?= =?UTF-8?q?kip=E2=86=92accept(subject),=20live=20session=E2=86=92accept(id?= =?UTF-8?q?entity=20id),=20no=20session=E2=86=92bounce=20to=20/login=3Fret?= =?UTF-8?q?urn=5Fto=20back=20here=20so=20Kratos=20lands=20on=20the=20chall?= =?UTF-8?q?enge=20once=20signed=20in.=20New=20src/hydra-admin.ts=20(fetch?= =?UTF-8?q?=20client:=20get/accept/reject=20login=20request=20+=20HydraErr?= =?UTF-8?q?or,=20mirrors=20the=20kratos/keto=20clients)=20+=20src/oauth-lo?= =?UTF-8?q?gin.ts=20(pure=20resolveLoginChallenge);=20wired=20in=20app.ts?= =?UTF-8?q?=20(the=20absolute=20return=20URL=20derives=20from=20the=20requ?= =?UTF-8?q?est=20Host=20+=20the=20SECURE=5FCOOKIES=20scheme=20=E2=80=94=20?= =?UTF-8?q?a=20spoofed=20Host=20can't=20escape,=20Kratos=20validates=20ret?= =?UTF-8?q?urn=5Fto=20against=20its=20allow-list;=20/login=20now=20bakes?= =?UTF-8?q?=20return=5Fto=20into=20the=20flow=20init),=20config.hydraAdmin?= =?UTF-8?q?Url=20(default=20http://hydra:4445),=20server=20builds=20the=20?= =?UTF-8?q?client,=20compose=20web=20now=20gates=20on=20hydra=20healthy=20?= =?UTF-8?q?(the=20app=20consumes=20it).=20A=20stale/invalid/consumed=20cha?= =?UTF-8?q?llenge=20(Hydra=204xx=20=E2=80=94=20back=20button,=20slow=20log?= =?UTF-8?q?in)=20degrades=20to=20a=20recoverable=20400,=20not=20a=20500;?= =?UTF-8?q?=20a=20genuine=20Hydra=205xx=20outage=20still=20surfaces=20as?= =?UTF-8?q?=20500.=20Tests-first:=20hydra-admin/oauth-login=20units=20+=20?= =?UTF-8?q?app/config/compose=20HTTP=20integration=20+=20full-stack=20e2e/?= =?UTF-8?q?oauth-login.spec.ts=20(compose.e2e-oauth.yml=20=E2=80=94=20regi?= =?UTF-8?q?sters=20an=20OAuth2=20client,=20starts=20an=20auth=20flow,=20as?= =?UTF-8?q?serts=20the=20unauthenticated=20bounce=20and=20the=20authentica?= =?UTF-8?q?ted=20accept;=20boot-verified=20then=20torn=20down).=20Stabilit?= =?UTF-8?q?y-reviewer=20run=20as=20a=20local=20PR:=20APPROVE,=20no=20Criti?= =?UTF-8?q?cal/High;=20addressed=20its=20one=20warning=20(4xx=E2=86=92400?= =?UTF-8?q?=20degrade).=20Deferred=20=C2=A79:=20document=20that=20prod=20a?= =?UTF-8?q?llowed=5Freturn=5Furls=20entries=20must=20be=20exact=20origins?= =?UTF-8?q?=20with=20a=20trailing=20/.=20typecheck=20+=20253=20units=20+?= =?UTF-8?q?=208=20visual=20+=20oauth-login=20E2E=20green.=20Consent=20hand?= =?UTF-8?q?ler=20+=20client=20registration=20are=20the=20next=20=C2=A76=20?= =?UTF-8?q?items.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 24 +++++++++-- compose.e2e-oauth.yml | 45 ++++++++++++++++++++ compose.yml | 12 +++--- e2e/oauth-login.spec.ts | 92 +++++++++++++++++++++++++++++++++++++++++ src/app.test.ts | 67 ++++++++++++++++++++++++++++++ src/app.ts | 37 ++++++++++++++++- src/compose.test.ts | 7 ++-- src/config.test.ts | 1 + src/config.ts | 3 ++ src/hydra-admin.test.ts | 60 +++++++++++++++++++++++++++ src/hydra-admin.ts | 89 +++++++++++++++++++++++++++++++++++++++ src/oauth-login.test.ts | 53 ++++++++++++++++++++++++ src/oauth-login.ts | 42 +++++++++++++++++++ src/server.ts | 4 ++ todo.md | 2 +- 15 files changed, 524 insertions(+), 14 deletions(-) create mode 100644 compose.e2e-oauth.yml create mode 100644 e2e/oauth-login.spec.ts create mode 100644 src/hydra-admin.test.ts create mode 100644 src/hydra-admin.ts create mode 100644 src/oauth-login.test.ts create mode 100644 src/oauth-login.ts diff --git a/README.md b/README.md index dfeb065..a547d58 100644 --- a/README.md +++ b/README.md @@ -241,6 +241,16 @@ 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. + +```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 down -v # tear down after +``` + `--build` rebuilds the runner so spec edits are always picked up (the image bakes in `e2e/`). Screenshots + an HTML report land in `e2e/artifacts/` (git-ignored). Every user-facing flow @@ -483,8 +493,12 @@ authors them anywhere else. Only relevant when **other apps** authenticate *through* plainpages. The app implements Hydra's login & consent steps — authenticating the user via their Kratos session — and Hydra issues the access / refresh / id tokens those apps use. Nothing -in the menu or first-party pages needs Hydra; it can be added later without -touching them. +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. ## Stateless — no application database @@ -527,6 +541,8 @@ 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/oauth-login.ts resolveLoginChallenge(): authenticate a Hydra login challenge via the Kratos session → accept, or bounce to /login (§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 @@ -554,10 +570,10 @@ src/menu-config.ts loadMenuConfig()/defineMenu(): read config/menu.ts (central 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) 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) + 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) 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, full Ory stack); Dockerfile.e2e + compose.e2e[-auth].yml run them +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 html-css-foundation/ HTML design mockups — the source for the building-block partials; reference the stylesheets in public/css/. ``` diff --git a/compose.e2e-oauth.yml b/compose.e2e-oauth.yml new file mode 100644 index 0000000..d52691c --- /dev/null +++ b/compose.e2e-oauth.yml @@ -0,0 +1,45 @@ +# Full-stack OAuth2 E2E — the §6 login-challenge handler. Another app logs in *through* us: +# Hydra starts an authorization flow and hands the browser to web's /oauth2/login; web resolves +# it via the Kratos session and accepts. Runs against the real stack (Postgres + Kratos + Keto + +# Hydra + bootstrap + web). The runner drives the flow over HTTP (fetch, manual cookies), so it +# reaches the Ory services by their compose-network names. +# docker compose -f compose.yml -f compose.e2e-oauth.yml run --build --rm e2e +# docker compose -f compose.yml -f compose.e2e-oauth.yml down -v # tear down after +services: + web: + # Dev throwaways are fine for the test stack; the runner hits web over http. + environment: + CACHE_TEMPLATES: "true" + REQUIRE_SECURE_SECRETS: "false" + SECURE_COOKIES: "false" + healthcheck: + test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:3000/"] + interval: 2s + timeout: 4s + retries: 30 + + # --dev permits the http issuer (the base file drops it for an https prod issuer). + hydra: + command: serve all --dev -c /etc/config/hydra/hydra.yml + + # Point the public base_url at the compose-network host so the runner can drive the Kratos + # login flow over `kratos:4433` (kratos.yml's default 127.0.0.1 base_url only resolves host-side). + kratos: + environment: + SERVE_PUBLIC_BASE_URL: http://kratos:4433/ + + e2e: + build: + context: . + dockerfile: Dockerfile.e2e + depends_on: + web: + condition: service_healthy + environment: + BASE_URL: http://web:3000 + HYDRA_ADMIN_URL: http://hydra:4445 + HYDRA_PUBLIC_URL: http://hydra:4444 + KRATOS_PUBLIC_URL: http://kratos:4433 + command: ["npx", "playwright", "test", "oauth-login.spec.ts"] + volumes: + - ./e2e/artifacts:/e2e/artifacts diff --git a/compose.yml b/compose.yml index a528df9..c24b79a 100644 --- a/compose.yml +++ b/compose.yml @@ -11,8 +11,8 @@ services: CACHE_TEMPLATES: "true" REQUIRE_SECURE_SECRETS: "true" SECURE_COOKIES: "true" # prod serves https — mark session/CSRF cookies Secure - # Wait for the services config.ts talks to (kratos + keto) + the one-shot bootstrap - # (admin + JWKS seed). Hydra is post-MVP (§6), not in config.ts, so web skips it. + # Wait for the services the app talks to (kratos + keto + hydra for the §6 OAuth2 login/ + # consent handler) + the one-shot bootstrap (admin + JWKS seed). depends_on: bootstrap: condition: service_completed_successfully @@ -20,6 +20,8 @@ services: condition: service_healthy keto: condition: service_healthy + hydra: + condition: service_healthy # §4 verifier reads the same tokenizer JWKS Kratos signs with (config.ts JWKS_URL). # Read-only — bootstrap is the only writer. volumes: @@ -132,9 +134,9 @@ services: restart: "on-failure:5" # Ory Hydra — OAuth2/OIDC provider (other apps log in *through* plainpages; README). - # DSN is its own `hydra` DB (init.sql); config in ory/hydra/hydra.yml, handlers are §6. - # Dev permits the http issuer via --dev (compose.override.yml); prod sets an https - # issuer via env (URLS_SELF_ISSUER). + # DSN is its own `hydra` DB (init.sql); config in ory/hydra/hydra.yml. web implements the + # login challenge at /oauth2/login (§6, consent next). Dev permits the http issuer via --dev + # (compose.override.yml); prod sets an https issuer via env (URLS_SELF_ISSUER). hydra-migrate: image: oryd/hydra:v26.2.0 depends_on: diff --git a/e2e/oauth-login.spec.ts b/e2e/oauth-login.spec.ts new file mode 100644 index 0000000..e1fdaee --- /dev/null +++ b/e2e/oauth-login.spec.ts @@ -0,0 +1,92 @@ +import { expect, test } from "@playwright/test"; + +// Full-stack OAuth2 login-challenge 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. +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"; +const HYDRA_ADMIN = process.env.HYDRA_ADMIN_URL ?? "http://hydra:4445"; +const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap (§3) +const ADMIN_PASSWORD = "admin"; + +function setCookieLine(res: Response, name: string): string | undefined { + return res.headers.getSetCookie().find((c) => c.startsWith(`${name}=`)); +} +function cookieValue(line: string): string { + return line.split(";", 1)[0]!.slice(line.indexOf("=") + 1); +} +function relayCookies(res: Response): string { + return res.headers.getSetCookie().map((c) => c.split(";", 1)[0]!).filter((kv) => kv.split("=")[1] !== "").join("; "); +} + +// 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`, { + body: JSON.stringify({ + client_name: "e2e-login", + grant_types: ["authorization_code"], + redirect_uris: ["http://127.0.0.1:3000/callback"], + response_types: ["code"], + scope: "openid offline", + token_endpoint_auth_method: "client_secret_post", + }), + headers: { "content-type": "application/json" }, + method: "POST", + }); + const body = await res.json().catch(() => null); + expect(res.status, `create client: ${JSON.stringify(body)}`).toBe(201); + return body.client_id; +} + +// Hit Hydra's authorization endpoint; it redirects to web's login URL carrying a login_challenge. +async function startAuthFlow(clientId: string): 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" }); + 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"); + const challenge = new URL(location).searchParams.get("login_challenge"); + expect(challenge, "carries a login_challenge").toBeTruthy(); + return challenge!; +} + +// Authenticate the seeded admin via Kratos' browser login flow; return its session cookie value. +async function kratosLogin(): Promise { + const init = await fetch(`${KRATOS}/self-service/login/browser`, { headers: { accept: "application/json" } }); + const flow = await init.json(); + const csrf = flow.ui.nodes.find((n: { attributes?: { name?: string } }) => n.attributes?.name === "csrf_token"); + const submit = await fetch(flow.ui.action, { + body: JSON.stringify({ csrf_token: csrf?.attributes?.value ?? "", identifier: ADMIN_EMAIL, method: "password", password: ADMIN_PASSWORD }), + headers: { accept: "application/json", "content-type": "application/json", cookie: relayCookies(init) }, + method: "POST", + redirect: "manual", + }); + expect(submit.status, `login submit: ${await submit.text()}`).toBe(200); + return cookieValue(setCookieLine(submit, "plainpages_session")!); +} + +test("Hydra login challenge: an unauthenticated user bounces to /login, an authenticated one is accepted", async () => { + test.setTimeout(60_000); + + const challenge = await startAuthFlow(await createClient()); + const loginUrl = `${WEB}/oauth2/login?login_challenge=${challenge}`; + + // 1. No Kratos session → web bounces to the themed login, carrying a return_to back to the challenge. + const anon = await fetch(loginUrl, { redirect: "manual" }); + expect(anon.status).toBe(303); + const bounce = anon.headers.get("location") ?? ""; + expect(bounce).toMatch(/^\/login\?return_to=/); + expect(decodeURIComponent(bounce.split("return_to=")[1]!)).toMatch(/\/oauth2\/login\?login_challenge=/); + + // 2. With a live Kratos session → web accepts the challenge; Hydra hands back a resume URL. + const session = await kratosLogin(); + const accepted = await fetch(loginUrl, { headers: { cookie: `plainpages_session=${session}` }, redirect: "manual" }); + expect(accepted.status).toBe(303); + const resume = accepted.headers.get("location") ?? ""; + expect(resume, "accepted → back to Hydra's /oauth2/auth to continue").toContain("/oauth2/auth"); + expect(resume, "carries Hydra's login_verifier").toContain("login_verifier"); +}); diff --git a/src/app.test.ts b/src/app.test.ts index 6d05164..5a5cfa9 100644 --- a/src/app.test.ts +++ b/src/app.test.ts @@ -9,6 +9,7 @@ import { fileURLToPath } from "node:url"; import { createApp, type AppOptions } from "./app.ts"; import { CSRF_COOKIE, issueCsrfToken } from "./csrf.ts"; import { can, check, GuardError, requireSession } from "./guards.ts"; +import { HydraError, type HydraAdmin } from "./hydra-admin.ts"; import { staticJwks } from "./jwks.ts"; import type { ExpandTree, KetoClient, RelationTuple, SubjectSet } from "./keto-client.ts"; import type { Identity, KratosAdmin } from "./kratos-admin.ts"; @@ -493,6 +494,72 @@ test("logout (CSRF-guarded POST): valid token revokes the Kratos session + clear assert.equal((await post("", `_csrf=${token}`)).status, 403); // no cookie to match }); +// OAuth2 login challenge (§6): another app logs in *through* us; Hydra hands the browser here. +const stubHydra = (over: Partial = {}): HydraAdmin => ({ + acceptLoginRequest: async () => ({ redirect: "http://127.0.0.1:4444/oauth2/auth?login_verifier=v" }), + getLoginRequest: async () => ({ challenge: "chal1", skip: false, subject: "" }), + rejectLoginRequest: async () => { throw new Error("unused"); }, + ...over, +}); + +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" }; + let acceptedSubject: string | undefined; + const hydra = stubHydra({ acceptLoginRequest: async (_c, b) => { acceptedSubject = b.subject; return { redirect: "http://127.0.0.1:4444/oauth2/auth?login_verifier=v" }; } }); + + const signedIn = createApp({ hydra, kratos: withWhoami(async () => ({ active: true, identity }) as Session) }); + await new Promise((r) => signedIn.listen(0, r)); + t.after(() => signedIn.close()); + const base = `http://localhost:${(signedIn.address() as AddressInfo).port}`; + + // Signed in: accept the challenge with the Kratos identity → 303 to Hydra's resume URL. + const accept = await fetch(base + "/oauth2/login?login_challenge=chal1", { headers: { cookie: "plainpages_session=s" }, redirect: "manual" }); + assert.equal(accept.status, 303); + assert.match(accept.headers.get("location") ?? "", /\/oauth2\/auth\?login_verifier=v/); + assert.equal(acceptedSubject, identity.id); + + // Missing login_challenge → 400 (someone hit the endpoint directly). + assert.equal((await fetch(base + "/oauth2/login", { redirect: "manual" })).status, 400); + + // A stale/invalid/consumed challenge (Hydra 4xx — back button, slow login) degrades to a + // recoverable 400, not a 500. A genuine Hydra outage (5xx) still surfaces as a 500. + const staleHydra = stubHydra({ getLoginRequest: async () => { throw new HydraError("gone", 410, ""); } }); + const stale = createApp({ hydra: staleHydra, kratos: withWhoami(async () => null) }); + await new Promise((r) => stale.listen(0, r)); + t.after(() => stale.close()); + const staleBase = `http://localhost:${(stale.address() as AddressInfo).port}`; + assert.equal((await fetch(staleBase + "/oauth2/login?login_challenge=gone", { redirect: "manual" })).status, 400); + const downHydra = stubHydra({ getLoginRequest: async () => { throw new HydraError("down", 503, ""); } }); + const down = createApp({ hydra: downHydra, 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/login?login_challenge=x`, { redirect: "manual" })).status, 500); + + // Not signed in: bounce to the themed login, return_to carrying an absolute URL back to here. + const anon = createApp({ hydra: stubHydra(), kratos: withWhoami(async () => null) }); + await new Promise((r) => anon.listen(0, r)); + t.after(() => anon.close()); + const bounce = await fetch(`http://localhost:${(anon.address() as AddressInfo).port}/oauth2/login?login_challenge=chal1`, { redirect: "manual" }); + assert.equal(bounce.status, 303); + const loc = bounce.headers.get("location") ?? ""; + assert.match(loc, /^\/login\?return_to=/); + assert.match(decodeURIComponent(loc.split("return_to=")[1]!), /^http:\/\/[^/]+\/oauth2\/login\?login_challenge=chal1$/); +}); + +test("/login?return_to=… bakes the return target into the Kratos flow init (§6 OAuth bounce)", async (t) => { + let seenReturnTo: string | undefined; + const kratos: KratosPublic = { + ...mockKratos(async () => { throw new Error("unused"); }), + initBrowserFlow: async (_t, opts) => { seenReturnTo = opts?.returnTo; return { flow: { id: "f1", ui: { action: "", method: "post", nodes: [] } }, setCookie: [] }; }, + }; + const app = createApp({ kratos }); + await new Promise((r) => app.listen(0, r)); + t.after(() => app.close()); + const returnTo = "http://127.0.0.1:3000/oauth2/login?login_challenge=c"; + await fetch(`http://localhost:${(app.address() as AddressInfo).port}/login?return_to=${encodeURIComponent(returnTo)}`, { redirect: "manual" }); + assert.equal(seenReturnTo, returnTo); +}); + // 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 16abb39..47c75ca 100644 --- a/src/app.ts +++ b/src/app.ts @@ -15,12 +15,14 @@ import { PLUGINS_DIR } from "./discovery.ts"; import { GuardError } from "./guards.ts"; import { AUTH_FLOWS, buildFlowView } from "./flow-view.ts"; import { runRequestHooks, runResponseHooks } from "./hooks.ts"; +import { HydraError, type HydraAdmin } from "./hydra-admin.ts"; import type { JwksProvider } from "./jwks.ts"; import { resolveSession, type VerifyOptions } from "./jwt-middleware.ts"; import type { KetoClient } from "./keto-client.ts"; 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 { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts"; import type { Plugin, RouteResult } from "./plugin.ts"; import { allowedMethods, isAuthorized, matchRoute } from "./router.ts"; @@ -35,6 +37,7 @@ export interface AppOptions { // Off by default so edits show live; the app itself never inspects the environment. cache?: boolean; csrfSecret?: string; // HMAC key for the double-submit CSRF token (config.csrfSecret); random if omitted + hydra?: HydraAdmin; // Hydra admin client; with kratos enables the OAuth2 login challenge (§6) jwks?: JwksProvider; // verify the session JWT → ctx.user/roles (§4); absent ⇒ always anonymous keto?: KetoClient; // Keto client; with kratos+kratosAdmin enables login completion (§4) kratos?: KratosPublic; // Kratos public client; enables the themed self-service routes (§4) @@ -52,6 +55,7 @@ export function createApp(options: AppOptions = {}): Server { const cache = options.cache ?? false; const csrfSecret = options.csrfSecret ?? randomBytes(32).toString("hex"); // server passes config; tests pass their own const secureCookies = options.secureCookies ?? false; + const hydra = options.hydra; const jwks = options.jwks; const keto = options.keto; const kratos = options.kratos; @@ -184,7 +188,10 @@ export function createApp(options: AppOptions = {}): Server { const flowId = ctx.url.searchParams.get("flow"); if (!flowId) { // No flow yet: init one server-side, relay Kratos' CSRF cookie, bounce to ?flow=. - const { flow, setCookie } = await kratos.initBrowserFlow(flowType, cookie ? { cookie } : {}); + // A `return_to` (e.g. the OAuth2 login challenge bouncing here, §6) is baked into the + // flow so Kratos lands back there after login instead of the default completion route. + const returnTo = ctx.url.searchParams.get("return_to") ?? undefined; + const { flow, setCookie } = await kratos.initBrowserFlow(flowType, { ...(cookie ? { cookie } : {}), ...(returnTo ? { returnTo } : {}) }); if (setCookie.length) res.appendHeader("set-cookie", setCookie); res.writeHead(303, { location: `${pathname}?flow=${flow.id}` }).end(); return; @@ -201,6 +208,34 @@ export function createApp(options: AppOptions = {}): Server { return; } + // OAuth2 login challenge (§6): Hydra hands the browser here when another app logs in + // *through* us. Resolve it via the Kratos session and accept; an unauthenticated user + // bounces to our themed login and returns here once signed in. Challenge looked up over + // Hydra's admin API. Nothing first-party needs this — it's the OAuth2-provider role only. + if (hydra && kratos && pathname === "/oauth2/login" && (method === "GET" || method === "HEAD")) { + const challenge = ctx.url.searchParams.get("login_challenge"); + if (!challenge) { + res.writeHead(400, { "content-type": "text/plain; charset=utf-8" }).end("Missing login_challenge"); + return; + } + // Absolute return target so Kratos lands back here post-login. Host reflects what the + // browser used (so it matches Kratos' allowed_return_urls); scheme follows SECURE_COOKIES. + // A spoofed Host can't escape — Kratos validates return_to against its allow-list. + const origin = `${secureCookies ? "https" : "http"}://${req.headers.host ?? "127.0.0.1:3000"}`; + const selfUrl = `${origin}/oauth2/login?login_challenge=${encodeURIComponent(challenge)}`; + try { + const { redirect } = await resolveLoginChallenge({ hydra, kratos }, challenge, req.headers.cookie, selfUrl); + res.writeHead(303, { location: redirect }).end(); + } catch (err) { + // A stale/invalid/consumed challenge (Hydra 4xx — back button, slow login, re-used URL) is + // user-reachable: tell them to restart rather than 500. A 5xx (Hydra down) rethrows → 500. + if (err instanceof HydraError && err.status < 500) { + res.writeHead(400, { "content-type": "text/plain; charset=utf-8" }).end("This sign-in request has expired. Please start again from the application you were signing in to."); + } else throw err; + } + return; + } + // 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/compose.test.ts b/src/compose.test.ts index 66327e8..3ae69bb 100644 --- a/src/compose.test.ts +++ b/src/compose.test.ts @@ -1,7 +1,7 @@ // Guards the dev/prod compose split + stack ordering (§3): every image is pinned to an // exact version (AGENTS.md), long-running Ory services carry readiness healthchecks so // `depends_on: service_healthy` works, the web app waits for the services it talks to -// (kratos + keto, per config.ts), prod publishes no internal Ory ports while dev exposes +// (kratos + keto + hydra), prod publishes no internal Ory ports while dev exposes // the ones a browser must reach, and the visual E2E stays Ory-free. Real boot is verified // by running the stack; this catches edits. import { test } from "node:test"; @@ -40,9 +40,10 @@ test("long-running Ory services declare readiness healthchecks", () => { `${svc} probes :${port}/health/ready`); }); -test("web waits for kratos and keto to be healthy before starting", () => { +test("web waits for kratos, keto and hydra to be healthy before starting", () => { assert.match(webBlock, /depends_on:/, "web declares dependencies"); - for (const svc of ["kratos", "keto"]) + // hydra: the §6 OAuth2 login/consent handler talks to its admin API. + for (const svc of ["kratos", "keto", "hydra"]) assert.match(webBlock, new RegExp(`${svc}:\\s*\\n\\s*condition:\\s*service_healthy`), `web waits for ${svc} healthy`); }); diff --git a/src/config.test.ts b/src/config.test.ts index 9d53277..501c472 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -19,6 +19,7 @@ test("loads dev defaults when the environment is empty", () => { assert.equal(c.kratosAdminUrl, "http://kratos:4434"); assert.equal(c.ketoReadUrl, "http://keto:4466"); assert.equal(c.ketoWriteUrl, "http://keto:4467"); + assert.equal(c.hydraAdminUrl, "http://hydra:4445"); assert.match(c.cookieSecret, /dev-insecure/); assert.match(c.csrfSecret, /dev-insecure/); assert.equal(c.jwtClockSkewSec, 60); // default exp/nbf leeway for Kratos↔web clock drift diff --git a/src/config.ts b/src/config.ts index aa11af1..bd75519 100644 --- a/src/config.ts +++ b/src/config.ts @@ -12,6 +12,7 @@ export interface Config { cacheTemplates: boolean; cookieSecret: string; csrfSecret: string; + hydraAdminUrl: string; jwksUrl: string; jwtAudience: string | undefined; jwtClockSkewSec: number; @@ -87,6 +88,8 @@ export function loadConfig(env: Env = process.env): Config { cacheTemplates: readBool(env, "CACHE_TEMPLATES", false), cookieSecret: readSecret(env, "COOKIE_SECRET", "dev-insecure-cookie-secret", requireSecure), csrfSecret: readSecret(env, "CSRF_SECRET", "dev-insecure-csrf-secret", requireSecure), + // Hydra admin API — the OAuth2 login/consent challenge handshake (§6); not on the first-party path. + hydraAdminUrl: readUrl(env, "HYDRA_ADMIN_URL", "http://hydra:4445"), // §4 verifier reads the same key the Kratos tokenizer signs with (kratos.yml jwks_url). // Kratos doesn't republish it over HTTP, so default to a file:// of the tokenizer JWKS // mounted into web (compose.yml). Prod overrides with a real key (README: rotation). diff --git a/src/hydra-admin.test.ts b/src/hydra-admin.test.ts new file mode 100644 index 0000000..f46dea5 --- /dev/null +++ b/src/hydra-admin.test.ts @@ -0,0 +1,60 @@ +// Hydra admin-API client (§6): typed fetch wrappers over Ory Hydra's OAuth2 login/consent +// challenge handshake. Guards the request contracts (URLs, method, login_challenge query, +// JSON body) and the result mapping (200 → request/redirect, non-2xx → HydraError). Live +// wiring is verified by the OAuth login E2E. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { createHydraAdmin, HydraError } from "./hydra-admin.ts"; + +const BASE = "http://hydra:4445"; +const CHALLENGE = "a1b2c3d4e5f6"; +const SUBJECT = "01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b55"; + +function res(status: number, body?: unknown): Response { + const h = new Headers(); + if (body !== undefined) h.set("content-type", "application/json"); + return new Response(body === undefined ? null : JSON.stringify(body), { status, headers: h }); +} +function recorder(handler: (url: string, init: RequestInit | undefined) => Response) { + const calls: { body: string | undefined; method: string; url: string }[] = []; + const fetchImpl = (async (input: unknown, init?: RequestInit) => { + calls.push({ body: init?.body as string | undefined, method: init?.method ?? "GET", url: String(input) }); + return handler(String(input), init); + }) as typeof fetch; + return { calls, fetchImpl }; +} + +test("getLoginRequest GETs the login challenge and returns the request", async () => { + const request = { challenge: CHALLENGE, client: { client_id: "c1" }, requested_scope: ["openid"], skip: false, subject: "" }; + const { calls, fetchImpl } = recorder(() => res(200, request)); + const out = await createHydraAdmin({ baseUrl: BASE, fetchImpl }).getLoginRequest(CHALLENGE); + assert.deepEqual(out, request); + assert.equal(calls[0]!.method, "GET"); + assert.match(calls[0]!.url, /\/admin\/oauth2\/auth\/requests\/login\?login_challenge=a1b2c3d4e5f6$/); +}); + +test("acceptLoginRequest PUTs the subject and returns Hydra's redirect_to", async () => { + const { calls, fetchImpl } = recorder(() => res(200, { redirect_to: "http://hydra/oauth2/auth?login_verifier=v" })); + const out = await createHydraAdmin({ baseUrl: BASE, fetchImpl }).acceptLoginRequest(CHALLENGE, { remember: true, remember_for: 0, subject: SUBJECT }); + assert.equal(out.redirect, "http://hydra/oauth2/auth?login_verifier=v"); + assert.equal(calls[0]!.method, "PUT"); + assert.match(calls[0]!.url, /\/admin\/oauth2\/auth\/requests\/login\/accept\?login_challenge=a1b2c3d4e5f6$/); + assert.deepEqual(JSON.parse(calls[0]!.body!), { remember: true, remember_for: 0, subject: SUBJECT }); +}); + +test("rejectLoginRequest 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 }).rejectLoginRequest(CHALLENGE, { error: "access_denied", error_description: "no" }); + assert.equal(out.redirect, "http://client/cb?error=access_denied"); + assert.equal(calls[0]!.method, "PUT"); + assert.match(calls[0]!.url, /\/admin\/oauth2\/auth\/requests\/login\/reject\?login_challenge=a1b2c3d4e5f6$/); + assert.deepEqual(JSON.parse(calls[0]!.body!), { error: "access_denied", error_description: "no" }); +}); + +test("a non-2xx response throws a HydraError carrying the status", async () => { + const { fetchImpl } = recorder(() => res(404, { error: "Not Found" })); + await assert.rejects( + createHydraAdmin({ baseUrl: BASE, fetchImpl }).getLoginRequest("gone"), + (e: unknown) => e instanceof HydraError && e.status === 404, + ); +}); diff --git a/src/hydra-admin.ts b/src/hydra-admin.ts new file mode 100644 index 0000000..038c626 --- /dev/null +++ b/src/hydra-admin.ts @@ -0,0 +1,89 @@ +// Hydra admin-API client (todo §6): typed `fetch` wrappers over Ory Hydra's OAuth2 admin +// endpoints (internal admin port) — the login/consent challenge handshake other apps log in +// *through* us with. Built-in `fetch` only, no SDK dep (AGENTS.md); `fetchImpl`-injectable +// like the kratos/keto clients. We authenticate the user (login) and grant scopes (consent); +// Hydra mints the tokens. + +export interface OAuth2Client { + client_id?: string; + client_name?: string; +} + +// A login request Hydra hands us at /oauth2/login. `skip` ⇒ Hydra already authenticated this +// subject (honour it, don't re-prompt); otherwise we authenticate via the Kratos session. +export interface LoginRequest { + challenge: string; + client?: OAuth2Client; + request_url?: string; + requested_scope?: string[]; + skip: boolean; + subject: string; +} + +export interface AcceptLogin { + acr?: string; + remember?: boolean; + remember_for?: number; // seconds; 0 ⇒ for the browser-session lifetime + subject: string; +} + +export interface RejectRequest { + error?: string; + error_description?: string; +} + +// Hydra's answer to an accept/reject: the URL to send the browser to, to resume the flow. +export interface Completed { + redirect: string; +} + +// Carries the HTTP status so a caller can branch (parallels KratosError/KetoError). +export class HydraError extends Error { + body: string; + status: number; + constructor(message: string, status: number, body: string) { + super(message); + this.body = body; + this.name = "HydraError"; + this.status = status; + } +} + +export interface HydraAdmin { + acceptLoginRequest(challenge: string, body: AcceptLogin): Promise; + getLoginRequest(challenge: string): Promise; + rejectLoginRequest(challenge: string, body: RejectRequest): Promise; +} + +export function createHydraAdmin(config: { baseUrl: string; fetchImpl?: typeof fetch }): HydraAdmin { + const base = config.baseUrl.replace(/\/+$/, ""); + 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)}`; + + async function fail(action: string, res: Response): Promise { + throw new HydraError(`Hydra admin ${action} failed (${res.status})`, res.status, await res.text()); + } + async function complete(action: string, res: Response): Promise { + if (res.status !== 200) return fail(action, res); + return { redirect: ((await res.json()) as { redirect_to: string }).redirect_to }; + } + + return { + async acceptLoginRequest(challenge, body) { + return complete("accept login", await http(loginUrl(challenge, "/accept"), { body: JSON.stringify(body), headers: json, method: "PUT" })); + }, + + async getLoginRequest(challenge) { + const res = await http(loginUrl(challenge)); + if (res.status !== 200) return fail("get login request", res); + return (await res.json()) as LoginRequest; + }, + + async rejectLoginRequest(challenge, body) { + return complete("reject login", await http(loginUrl(challenge, "/reject"), { body: JSON.stringify(body), headers: json, method: "PUT" })); + }, + }; +} diff --git a/src/oauth-login.test.ts b/src/oauth-login.test.ts new file mode 100644 index 0000000..18bb4e8 --- /dev/null +++ b/src/oauth-login.test.ts @@ -0,0 +1,53 @@ +// OAuth2 login-challenge resolution (§6): given a Hydra login challenge, authenticate the user +// via their Kratos session and accept — or bounce an unauthenticated user to the Kratos login UI. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import type { AcceptLogin, HydraAdmin, LoginRequest } from "./hydra-admin.ts"; +import type { KratosPublic, Session } from "./kratos-public.ts"; +import { resolveLoginChallenge } from "./oauth-login.ts"; + +const CHALLENGE = "chal-1"; +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 { + return { + acceptLoginRequest: async (_c, body) => { capture?.(body); return { redirect: "http://hydra/oauth2/auth?login_verifier=v" }; }, + getLoginRequest: async () => login, + 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 session = (id: string): Session => ({ active: true, identity: { id } }); + +test("a live Kratos session accepts the login with that subject → Hydra redirect", async () => { + let accepted: AcceptLogin | undefined; + const hydra = stubHydra({ challenge: CHALLENGE, skip: false, subject: "" }, (b) => { accepted = b; }); + const out = await resolveLoginChallenge({ hydra, kratos: stubKratos(async () => session(SUBJECT)) }, CHALLENGE, "plainpages_session=s", SELF); + assert.equal(out.redirect, "http://hydra/oauth2/auth?login_verifier=v"); + assert.equal(accepted?.subject, SUBJECT); + assert.equal(accepted?.remember, true); +}); + +test("skip (Hydra already authenticated) accepts the request's subject without checking Kratos", async () => { + let accepted: AcceptLogin | undefined; + let whoamiCalled = false; + const hydra = stubHydra({ challenge: CHALLENGE, skip: true, subject: SUBJECT }, (b) => { accepted = b; }); + const kratos = stubKratos(async () => { whoamiCalled = true; return null; }); + const out = await resolveLoginChallenge({ hydra, kratos }, CHALLENGE, undefined, SELF); + assert.equal(out.redirect, "http://hydra/oauth2/auth?login_verifier=v"); + assert.equal(accepted?.subject, SUBJECT); + assert.equal(whoamiCalled, false, "skip short-circuits the Kratos check"); +}); + +test("no Kratos session bounces to the themed login UI, returning here once authenticated", async () => { + const hydra = stubHydra({ challenge: CHALLENGE, skip: false, subject: "" }); + const out = await resolveLoginChallenge({ hydra, kratos: stubKratos(async () => null) }, CHALLENGE, undefined, SELF); + assert.equal(out.redirect, `/login?return_to=${encodeURIComponent(SELF)}`); +}); diff --git a/src/oauth-login.ts b/src/oauth-login.ts new file mode 100644 index 0000000..fa1b60b --- /dev/null +++ b/src/oauth-login.ts @@ -0,0 +1,42 @@ +// OAuth2 login-challenge handler (todo §6): when another app logs in *through* plainpages, +// Hydra hands the browser to /oauth2/login?login_challenge=… (hydra.yml urls.login). We +// authenticate the user with their existing Kratos session and accept the request; Hydra then +// proceeds to consent and mints the tokens. No first-party page needs this — it's the OAuth2 +// provider role only (README). +import type { HydraAdmin } from "./hydra-admin.ts"; +import type { KratosPublic } from "./kratos-public.ts"; + +// Remember the Hydra login for the browser-session lifetime (0), so a client re-authorizing +// doesn't re-run this on every token refresh while the Kratos session lives. +const REMEMBER_FOR = 0; + +export interface OAuthLoginDeps { + hydra: HydraAdmin; + kratos: KratosPublic; +} + +export interface LoginResolution { + redirect: string; +} + +// Resolve a login challenge: +// - skip (Hydra already authenticated the subject) → accept it, don't re-prompt. +// - a live Kratos session → accept with that identity as the subject. +// - no session → send the browser to our themed Kratos +// login, returning to `selfUrl` (this challenge) once authenticated, where whoami succeeds. +export async function resolveLoginChallenge( + deps: OAuthLoginDeps, + challenge: string, + cookie: string | undefined, + selfUrl: string, +): Promise { + const login = await deps.hydra.getLoginRequest(challenge); + if (login.skip) { + return deps.hydra.acceptLoginRequest(challenge, { subject: login.subject }); + } + const session = await deps.kratos.whoami(cookie ? { cookie } : {}); + if (session?.identity) { + return deps.hydra.acceptLoginRequest(challenge, { remember: true, remember_for: REMEMBER_FOR, subject: session.identity.id }); + } + return { redirect: `/login?return_to=${encodeURIComponent(selfUrl)}` }; +} diff --git a/src/server.ts b/src/server.ts index fd875a0..49be9c6 100644 --- a/src/server.ts +++ b/src/server.ts @@ -2,6 +2,7 @@ import { createApp } from "./app.ts"; import { loadConfig } from "./config.ts"; import { discoverPlugins } from "./discovery.ts"; import { runBootHooks } from "./hooks.ts"; +import { createHydraAdmin } from "./hydra-admin.ts"; import { createJwksProvider } from "./jwks.ts"; import { createKetoClient } from "./keto-client.ts"; import { createKratosAdmin } from "./kratos-admin.ts"; @@ -14,6 +15,8 @@ const menu = await loadMenuConfig(); // config/menu.ts override + branding — f const kratos = createKratosPublic({ baseUrl: config.kratosPublicUrl }); const kratosAdmin = createKratosAdmin({ baseUrl: config.kratosAdminUrl }); const keto = createKetoClient({ readUrl: config.ketoReadUrl, writeUrl: config.ketoWriteUrl }); +// Hydra admin client for the OAuth2 login/consent challenge handshake (§6). +const hydra = createHydraAdmin({ baseUrl: config.hydraAdminUrl }); // Session-JWT verify key: primed at boot from the configured JWKS (file mount, base64 inline, // or fetched http), then served from cache with TTL refresh + rotation-on-miss (§4). const jwks = await createJwksProvider(config.jwksUrl); @@ -26,6 +29,7 @@ const server = createApp({ auth: { audience: config.jwtAudience, clockSkewSec: config.jwtClockSkewSec, issuer: config.jwtIssuer }, cache: config.cacheTemplates, csrfSecret: config.csrfSecret, + hydra, jwks, keto, kratos, diff --git a/todo.md b/todo.md index 95f408c..cb65f03 100644 --- a/todo.md +++ b/todo.md @@ -102,7 +102,7 @@ everything via Docker. - [x] Go over all tests and combine/unify ones that cover the same stuff or are very related and could be combined in a good way. Remove tests that aren't helping, we only want tests that are actually helpful to us. → Pass over the §5 admin tests. The genuine §5-era duplication was all in `app.test.ts`: the three admin-screen HTTP tests (Users/Groups/Roles) each repeated an identical ~13-line harness preamble (createApp + listen + url + CSRF token + admin cookie + get/post), an identical 5-line gate block, and a stateful in-memory `KetoClient` defined 3× (the trivial `stubKeto` + two byte-identical inline fakes). Unified into shared helpers — `adminHarness(t, opts)` → `{url, token, get, post}`, `assertAdminGate(url, get, path)`, and one `fakeKeto(tuples?, over?)` that subsumes `stubKeto` (the login tests now use `fakeKeto([], …)`) and both inline admin fakes (`fakeKeto(tuples)` / `fakeKeto(tuples, { expand })`); hoisted the shared `sameSet`/`matchesTuple` up next to it. The per-module unit files (admin-users/groups/roles + the focused units) already follow the deliberate matrix pattern and the §3/§4 "don't force-merge across distinct modules" rule, so the near-identical `build*ListModel` tests stay per-file (each guards its own function; the source-side list-model dedup is the deferred arch-M3 item, not the test side). −30 net lines, zero coverage lost; typecheck + 244 units green. ## 6. Hydra — OAuth2/OIDC provider (can ship after the rest) -- [ ] Login-challenge handler: authenticate via Kratos session, accept/reject. +- [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. - [ ] 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.