Built-in OAuth2 login-challenge handler (todo §6); /oauth2/login resolves a Hydra login challenge via the Kratos session — skip→accept(subject), live session→accept(identity id), no session→bounce to /login?return_to back here so Kratos lands on the challenge once signed in. New src/hydra-admin.ts (fetch client: get/accept/reject login request + HydraError, mirrors the kratos/keto clients) + src/oauth-login.ts (pure resolveLoginChallenge); wired in app.ts (the absolute return URL 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 return_to into the flow init), config.hydraAdminUrl (default http://hydra:4445), server builds the client, compose web now gates on hydra healthy (the app consumes it). A stale/invalid/consumed challenge (Hydra 4xx — back button, slow login) degrades to a recoverable 400, not a 500; a genuine Hydra 5xx outage still surfaces as 500. Tests-first: hydra-admin/oauth-login units + app/config/compose HTTP integration + full-stack e2e/oauth-login.spec.ts (compose.e2e-oauth.yml — registers an OAuth2 client, starts an auth flow, asserts the unauthenticated bounce and the authenticated accept; boot-verified then torn down). Stability-reviewer run as a local PR: APPROVE, no Critical/High; addressed its one warning (4xx→400 degrade). Deferred §9: document that prod allowed_return_urls entries must be exact origins with a trailing /. typecheck + 253 units + 8 visual + oauth-login E2E green. Consent handler + client registration are the next §6 items.
This commit is contained in:
24
README.md
24
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
|
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/`).
|
`--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
|
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
|
Only relevant when **other apps** authenticate *through* plainpages. The app
|
||||||
implements Hydra's login & consent steps — authenticating the user via their Kratos
|
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
|
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
|
in the menu or first-party pages needs Hydra.
|
||||||
touching them.
|
|
||||||
|
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
|
## 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-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/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/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
|
||||||
@@ -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)
|
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)
|
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) + 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, 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
|
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/.
|
||||||
```
|
```
|
||||||
|
|||||||
45
compose.e2e-oauth.yml
Normal file
45
compose.e2e-oauth.yml
Normal file
@@ -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
|
||||||
12
compose.yml
12
compose.yml
@@ -11,8 +11,8 @@ services:
|
|||||||
CACHE_TEMPLATES: "true"
|
CACHE_TEMPLATES: "true"
|
||||||
REQUIRE_SECURE_SECRETS: "true"
|
REQUIRE_SECURE_SECRETS: "true"
|
||||||
SECURE_COOKIES: "true" # prod serves https — mark session/CSRF cookies Secure
|
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
|
# Wait for the services the app talks to (kratos + keto + hydra for the §6 OAuth2 login/
|
||||||
# (admin + JWKS seed). Hydra is post-MVP (§6), not in config.ts, so web skips it.
|
# consent handler) + the one-shot bootstrap (admin + JWKS seed).
|
||||||
depends_on:
|
depends_on:
|
||||||
bootstrap:
|
bootstrap:
|
||||||
condition: service_completed_successfully
|
condition: service_completed_successfully
|
||||||
@@ -20,6 +20,8 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
keto:
|
keto:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
hydra:
|
||||||
|
condition: service_healthy
|
||||||
# §4 verifier reads the same tokenizer JWKS Kratos signs with (config.ts JWKS_URL).
|
# §4 verifier reads the same tokenizer JWKS Kratos signs with (config.ts JWKS_URL).
|
||||||
# Read-only — bootstrap is the only writer.
|
# Read-only — bootstrap is the only writer.
|
||||||
volumes:
|
volumes:
|
||||||
@@ -132,9 +134,9 @@ services:
|
|||||||
restart: "on-failure:5"
|
restart: "on-failure:5"
|
||||||
|
|
||||||
# Ory Hydra — OAuth2/OIDC provider (other apps log in *through* plainpages; README).
|
# 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.
|
# DSN is its own `hydra` DB (init.sql); config in ory/hydra/hydra.yml. web implements the
|
||||||
# Dev permits the http issuer via --dev (compose.override.yml); prod sets an https
|
# login challenge at /oauth2/login (§6, consent next). Dev permits the http issuer via --dev
|
||||||
# issuer via env (URLS_SELF_ISSUER).
|
# (compose.override.yml); prod sets an https issuer via env (URLS_SELF_ISSUER).
|
||||||
hydra-migrate:
|
hydra-migrate:
|
||||||
image: oryd/hydra:v26.2.0
|
image: oryd/hydra:v26.2.0
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
92
e2e/oauth-login.spec.ts
Normal file
92
e2e/oauth-login.spec.ts
Normal file
@@ -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<string> {
|
||||||
|
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<string> {
|
||||||
|
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<string> {
|
||||||
|
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");
|
||||||
|
});
|
||||||
@@ -9,6 +9,7 @@ import { fileURLToPath } from "node:url";
|
|||||||
import { createApp, type AppOptions } from "./app.ts";
|
import { createApp, type AppOptions } from "./app.ts";
|
||||||
import { CSRF_COOKIE, issueCsrfToken } from "./csrf.ts";
|
import { CSRF_COOKIE, issueCsrfToken } from "./csrf.ts";
|
||||||
import { can, check, GuardError, requireSession } from "./guards.ts";
|
import { can, check, GuardError, requireSession } from "./guards.ts";
|
||||||
|
import { HydraError, type HydraAdmin } from "./hydra-admin.ts";
|
||||||
import { staticJwks } from "./jwks.ts";
|
import { staticJwks } from "./jwks.ts";
|
||||||
import type { ExpandTree, KetoClient, RelationTuple, SubjectSet } from "./keto-client.ts";
|
import type { ExpandTree, KetoClient, RelationTuple, SubjectSet } from "./keto-client.ts";
|
||||||
import type { Identity, KratosAdmin } from "./kratos-admin.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
|
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> = {}): 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<void>((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<void>((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<void>((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<void>((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<void>((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.
|
// 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 =>
|
||||||
|
|||||||
37
src/app.ts
37
src/app.ts
@@ -15,12 +15,14 @@ import { PLUGINS_DIR } from "./discovery.ts";
|
|||||||
import { GuardError } from "./guards.ts";
|
import { GuardError } from "./guards.ts";
|
||||||
import { AUTH_FLOWS, buildFlowView } from "./flow-view.ts";
|
import { AUTH_FLOWS, buildFlowView } from "./flow-view.ts";
|
||||||
import { runRequestHooks, runResponseHooks } from "./hooks.ts";
|
import { runRequestHooks, runResponseHooks } from "./hooks.ts";
|
||||||
|
import { HydraError, type HydraAdmin } from "./hydra-admin.ts";
|
||||||
import type { JwksProvider } from "./jwks.ts";
|
import type { JwksProvider } from "./jwks.ts";
|
||||||
import { resolveSession, type VerifyOptions } from "./jwt-middleware.ts";
|
import { resolveSession, type VerifyOptions } from "./jwt-middleware.ts";
|
||||||
import type { KetoClient } from "./keto-client.ts";
|
import type { KetoClient } from "./keto-client.ts";
|
||||||
import type { KratosAdmin } from "./kratos-admin.ts";
|
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 { 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";
|
||||||
@@ -35,6 +37,7 @@ export interface AppOptions {
|
|||||||
// Off by default so edits show live; the app itself never inspects the environment.
|
// Off by default so edits show live; the app itself never inspects the environment.
|
||||||
cache?: boolean;
|
cache?: boolean;
|
||||||
csrfSecret?: string; // HMAC key for the double-submit CSRF token (config.csrfSecret); random if omitted
|
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
|
jwks?: JwksProvider; // verify the session JWT → ctx.user/roles (§4); absent ⇒ always anonymous
|
||||||
keto?: KetoClient; // Keto client; with kratos+kratosAdmin enables login completion (§4)
|
keto?: KetoClient; // Keto client; with kratos+kratosAdmin enables login completion (§4)
|
||||||
kratos?: KratosPublic; // Kratos public client; enables the themed self-service routes (§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 cache = options.cache ?? false;
|
||||||
const csrfSecret = options.csrfSecret ?? randomBytes(32).toString("hex"); // server passes config; tests pass their own
|
const csrfSecret = options.csrfSecret ?? randomBytes(32).toString("hex"); // server passes config; tests pass their own
|
||||||
const secureCookies = options.secureCookies ?? false;
|
const secureCookies = options.secureCookies ?? false;
|
||||||
|
const hydra = options.hydra;
|
||||||
const jwks = options.jwks;
|
const jwks = options.jwks;
|
||||||
const keto = options.keto;
|
const keto = options.keto;
|
||||||
const kratos = options.kratos;
|
const kratos = options.kratos;
|
||||||
@@ -184,7 +188,10 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
const flowId = ctx.url.searchParams.get("flow");
|
const flowId = ctx.url.searchParams.get("flow");
|
||||||
if (!flowId) {
|
if (!flowId) {
|
||||||
// No flow yet: init one server-side, relay Kratos' CSRF cookie, bounce to ?flow=<id>.
|
// No flow yet: init one server-side, relay Kratos' CSRF cookie, bounce to ?flow=<id>.
|
||||||
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);
|
if (setCookie.length) res.appendHeader("set-cookie", setCookie);
|
||||||
res.writeHead(303, { location: `${pathname}?flow=${flow.id}` }).end();
|
res.writeHead(303, { location: `${pathname}?flow=${flow.id}` }).end();
|
||||||
return;
|
return;
|
||||||
@@ -201,6 +208,34 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
return;
|
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).
|
// 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).
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Guards the dev/prod compose split + stack ordering (§3): every image is pinned to an
|
// 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
|
// 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
|
// `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
|
// the ones a browser must reach, and the visual E2E stays Ory-free. Real boot is verified
|
||||||
// by running the stack; this catches edits.
|
// by running the stack; this catches edits.
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
@@ -40,9 +40,10 @@ test("long-running Ory services declare readiness healthchecks", () => {
|
|||||||
`${svc} probes :${port}/health/ready`);
|
`${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");
|
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`),
|
assert.match(webBlock, new RegExp(`${svc}:\\s*\\n\\s*condition:\\s*service_healthy`),
|
||||||
`web waits for ${svc} healthy`);
|
`web waits for ${svc} healthy`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ test("loads dev defaults when the environment is empty", () => {
|
|||||||
assert.equal(c.kratosAdminUrl, "http://kratos:4434");
|
assert.equal(c.kratosAdminUrl, "http://kratos:4434");
|
||||||
assert.equal(c.ketoReadUrl, "http://keto:4466");
|
assert.equal(c.ketoReadUrl, "http://keto:4466");
|
||||||
assert.equal(c.ketoWriteUrl, "http://keto:4467");
|
assert.equal(c.ketoWriteUrl, "http://keto:4467");
|
||||||
|
assert.equal(c.hydraAdminUrl, "http://hydra:4445");
|
||||||
assert.match(c.cookieSecret, /dev-insecure/);
|
assert.match(c.cookieSecret, /dev-insecure/);
|
||||||
assert.match(c.csrfSecret, /dev-insecure/);
|
assert.match(c.csrfSecret, /dev-insecure/);
|
||||||
assert.equal(c.jwtClockSkewSec, 60); // default exp/nbf leeway for Kratos↔web clock drift
|
assert.equal(c.jwtClockSkewSec, 60); // default exp/nbf leeway for Kratos↔web clock drift
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export interface Config {
|
|||||||
cacheTemplates: boolean;
|
cacheTemplates: boolean;
|
||||||
cookieSecret: string;
|
cookieSecret: string;
|
||||||
csrfSecret: string;
|
csrfSecret: string;
|
||||||
|
hydraAdminUrl: string;
|
||||||
jwksUrl: string;
|
jwksUrl: string;
|
||||||
jwtAudience: string | undefined;
|
jwtAudience: string | undefined;
|
||||||
jwtClockSkewSec: number;
|
jwtClockSkewSec: number;
|
||||||
@@ -87,6 +88,8 @@ export function loadConfig(env: Env = process.env): Config {
|
|||||||
cacheTemplates: readBool(env, "CACHE_TEMPLATES", false),
|
cacheTemplates: readBool(env, "CACHE_TEMPLATES", false),
|
||||||
cookieSecret: readSecret(env, "COOKIE_SECRET", "dev-insecure-cookie-secret", requireSecure),
|
cookieSecret: readSecret(env, "COOKIE_SECRET", "dev-insecure-cookie-secret", requireSecure),
|
||||||
csrfSecret: readSecret(env, "CSRF_SECRET", "dev-insecure-csrf-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).
|
// §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
|
// 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).
|
// mounted into web (compose.yml). Prod overrides with a real key (README: rotation).
|
||||||
|
|||||||
60
src/hydra-admin.test.ts
Normal file
60
src/hydra-admin.test.ts
Normal file
@@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
89
src/hydra-admin.ts
Normal file
89
src/hydra-admin.ts
Normal file
@@ -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<Completed>;
|
||||||
|
getLoginRequest(challenge: string): Promise<LoginRequest>;
|
||||||
|
rejectLoginRequest(challenge: string, body: RejectRequest): Promise<Completed>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<never> {
|
||||||
|
throw new HydraError(`Hydra admin ${action} failed (${res.status})`, res.status, await res.text());
|
||||||
|
}
|
||||||
|
async function complete(action: string, res: Response): Promise<Completed> {
|
||||||
|
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" }));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
53
src/oauth-login.test.ts
Normal file
53
src/oauth-login.test.ts
Normal file
@@ -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)}`);
|
||||||
|
});
|
||||||
42
src/oauth-login.ts
Normal file
42
src/oauth-login.ts
Normal file
@@ -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<LoginResolution> {
|
||||||
|
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)}` };
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { createApp } from "./app.ts";
|
|||||||
import { loadConfig } from "./config.ts";
|
import { loadConfig } from "./config.ts";
|
||||||
import { discoverPlugins } from "./discovery.ts";
|
import { discoverPlugins } from "./discovery.ts";
|
||||||
import { runBootHooks } from "./hooks.ts";
|
import { runBootHooks } from "./hooks.ts";
|
||||||
|
import { createHydraAdmin } from "./hydra-admin.ts";
|
||||||
import { createJwksProvider } from "./jwks.ts";
|
import { createJwksProvider } from "./jwks.ts";
|
||||||
import { createKetoClient } from "./keto-client.ts";
|
import { createKetoClient } from "./keto-client.ts";
|
||||||
import { createKratosAdmin } from "./kratos-admin.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 kratos = createKratosPublic({ baseUrl: config.kratosPublicUrl });
|
||||||
const kratosAdmin = createKratosAdmin({ baseUrl: config.kratosAdminUrl });
|
const kratosAdmin = createKratosAdmin({ baseUrl: config.kratosAdminUrl });
|
||||||
const keto = createKetoClient({ readUrl: config.ketoReadUrl, writeUrl: config.ketoWriteUrl });
|
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,
|
// 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).
|
// or fetched http), then served from cache with TTL refresh + rotation-on-miss (§4).
|
||||||
const jwks = await createJwksProvider(config.jwksUrl);
|
const jwks = await createJwksProvider(config.jwksUrl);
|
||||||
@@ -26,6 +29,7 @@ const server = createApp({
|
|||||||
auth: { audience: config.jwtAudience, clockSkewSec: config.jwtClockSkewSec, issuer: config.jwtIssuer },
|
auth: { audience: config.jwtAudience, clockSkewSec: config.jwtClockSkewSec, issuer: config.jwtIssuer },
|
||||||
cache: config.cacheTemplates,
|
cache: config.cacheTemplates,
|
||||||
csrfSecret: config.csrfSecret,
|
csrfSecret: config.csrfSecret,
|
||||||
|
hydra,
|
||||||
jwks,
|
jwks,
|
||||||
keto,
|
keto,
|
||||||
kratos,
|
kratos,
|
||||||
|
|||||||
2
todo.md
2
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.
|
- [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)
|
## 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=<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.
|
- [ ] Consent-challenge handler: show / auto-accept first-party, grant scopes, accept/reject.
|
||||||
- [ ] 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.
|
||||||
|
|||||||
Reference in New Issue
Block a user