From 93e62d86612780231deb2f12db30c3bc5173520c Mon Sep 17 00:00:00 2001 From: lilleman Date: Wed, 17 Jun 2026 15:45:37 +0200 Subject: [PATCH] =?UTF-8?q?Add=20Hydra=20service=20+=20migrate=20(todo=20?= =?UTF-8?q?=C2=A73);=20pin=20oryd/hydra:v26.2.0,=20OAuth2=20issuer=20+=20l?= =?UTF-8?q?ogin/consent=20URLs=20=E2=86=92=20our=20app=20routes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 5 +++-- compose.override.yml | 7 +++++++ compose.yml | 29 ++++++++++++++++++++++++++++ ory/hydra/hydra.yml | 28 +++++++++++++++++++++++++++ src/hydra.test.ts | 46 ++++++++++++++++++++++++++++++++++++++++++++ todo.md | 2 +- 6 files changed, 114 insertions(+), 3 deletions(-) create mode 100644 ory/hydra/hydra.yml create mode 100644 src/hydra.test.ts diff --git a/README.md b/README.md index 06d8508..980e3ce 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,8 @@ only where the platform leaves a gap (see [AGENTS.md](AGENTS.md)). > filters, pagination, forms — extracted from `html-css-foundation/`), the **plugin host** > (discovery, router, per-plugin views + static, the `config/menu.ts` override + branding), and the > **Ory stack** wiring — Postgres, Kratos (+ session→JWT tokenizer) and Keto (authorization, OPL -> namespaces). Hydra and the **auth** wiring that consumes these are the roadmap; sections marked +> namespaces) and Hydra (OAuth2 provider: issuer + login/consent URLs). The **auth** wiring that +> consumes these — and Hydra's login/consent handlers — are the roadmap; sections marked > _(planned)_ are not built yet. ## The MVP — "clone, one command, hack on a plugin" _(planned)_ @@ -475,7 +476,7 @@ src/menu-config.ts loadMenuConfig()/defineMenu(): read config/menu.ts (central views/ Core EJS templates (index = the app-shell People dashboard, 403/404/500, partials/ incl. app shell, nav tree, filter bar, data table, pagination, form field, auth card, 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) + 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) + 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 visual + functional E2E (Dockerfile.e2e + compose.e2e.yml run it) diff --git a/compose.override.yml b/compose.override.yml index 07c2483..6d45f52 100644 --- a/compose.override.yml +++ b/compose.override.yml @@ -18,3 +18,10 @@ services: ports: - "8025:8025" restart: unless-stopped + + # Ory Hydra dev: --dev permits the http issuer/redirect URLs; expose the public port + # so OAuth2 flows reach the host. Prod (§3 dev/prod split) drops --dev for https. + hydra: + command: serve all --dev -c /etc/config/hydra/hydra.yml + ports: + - "4444:4444" diff --git a/compose.yml b/compose.yml index 4c8405c..16a1a04 100644 --- a/compose.yml +++ b/compose.yml @@ -83,5 +83,34 @@ services: command: serve -c /etc/config/keto/keto.yml restart: unless-stopped + # Ory Hydra — OAuth2/OIDC provider (other apps log in *through* plainpages; README). + # DSN is the per-service `hydra` DB (init.sql). Issuer + login/consent/logout run at + # our app routes (ory/hydra/hydra.yml); the handlers that drive them are §6. Dev + # permits the http issuer via --dev (compose.override.yml); prod supplies an https + # issuer via env (URLS_SELF_ISSUER). + hydra-migrate: + image: oryd/hydra:v26.2.0 + depends_on: + postgres: + condition: service_healthy + environment: + DSN: postgres://${POSTGRES_USER:-ory}:${POSTGRES_PASSWORD:-ory}@postgres:5432/hydra?sslmode=disable + volumes: + - ./ory/hydra:/etc/config/hydra:ro + command: -c /etc/config/hydra/hydra.yml migrate sql -e --yes + restart: on-failure + + hydra: + image: oryd/hydra:v26.2.0 + depends_on: + hydra-migrate: + condition: service_completed_successfully + environment: + DSN: postgres://${POSTGRES_USER:-ory}:${POSTGRES_PASSWORD:-ory}@postgres:5432/hydra?sslmode=disable + volumes: + - ./ory/hydra:/etc/config/hydra:ro + command: serve all -c /etc/config/hydra/hydra.yml + restart: unless-stopped + volumes: pgdata: diff --git a/ory/hydra/hydra.yml b/ory/hydra/hydra.yml new file mode 100644 index 0000000..9cab190 --- /dev/null +++ b/ory/hydra/hydra.yml @@ -0,0 +1,28 @@ +# Ory Hydra — OAuth2/OIDC provider, so other apps can authenticate *through* +# plainpages (README: "OAuth2 provider"). The web app implements Hydra's login & +# consent steps at the URLs below, authenticating the user via their Kratos session; +# Hydra mints the tokens. DSN comes from the env (the per-service hydra DB). Only +# relevant when external apps log in through us — nothing first-party needs it (§6). +serve: + public: + port: 4444 + admin: + port: 4445 + +# issuer = the public OAuth2 URL clients use; login/consent/logout hand the browser to +# our themed handlers (§6). Dev defaults (http) — prod overrides issuer via env (https). +urls: + self: + issuer: http://127.0.0.1:4444/ + login: http://127.0.0.1:3000/oauth2/login + consent: http://127.0.0.1:3000/oauth2/consent + logout: http://127.0.0.1:3000/oauth2/logout + +# Dev throwaway — production supplies a real system secret via env (SECRETS_SYSTEM). +secrets: + system: + - PLEASE-CHANGE-ME-dev-hydra-system-secret + +log: + level: info + format: text diff --git a/src/hydra.test.ts b/src/hydra.test.ts new file mode 100644 index 0000000..7e3f4ce --- /dev/null +++ b/src/hydra.test.ts @@ -0,0 +1,46 @@ +// Guards the Ory Hydra config (§3): image pinned to an exact version (AGENTS.md), +// migrations run before the server (hydra-migrate → hydra), the DSN targets the hydra +// database, the server listens on the public/admin ports, and the issuer + +// login/consent/logout URLs point at our app. Real boot is verified by running the +// stack; this catches edits. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; + +const read = (p: string) => readFileSync(new URL(`../${p}`, import.meta.url), "utf8"); +const compose = read("compose.yml"); +const hydraYml = read("ory/hydra/hydra.yml"); + +test("compose pins both hydra services to one exact version", () => { + const tags = [...compose.matchAll(/image:\s*oryd\/hydra:(\S+)/g)].map((m) => m[1]); + assert.equal(tags.length, 2, "hydra + hydra-migrate both present"); + assert.equal(tags[0], tags[1], "both pinned to the same version"); + const tag = tags[0]!; + assert.match(tag, /^v\d+\.\d+\.\d+$/, `${tag} is an exact vMAJOR.MINOR.PATCH`); + assert.doesNotMatch(tag, /latest|[\^~*]/, `${tag} is exact, not floating`); +}); + +test("hydra migrations run once before the server starts", () => { + assert.ok((compose.match(/migrate sql -e --yes/g) ?? []).length >= 2, + "hydra-migrate runs SQL migrations (alongside kratos)"); + assert.ok((compose.match(/condition:\s*service_completed_successfully/g) ?? []).length >= 3, + "hydra waits for hydra-migrate (alongside kratos's + keto's gates)"); +}); + +test("hydra DSN targets the per-service hydra database", () => { + const dsns = [...compose.matchAll(/DSN:\s*(\S+)/g)].map((m) => m[1]).filter((d) => /\/hydra\b/.test(d!)); + assert.ok(dsns.length >= 2, "both hydra services point DSN at the hydra DB"); + for (const dsn of dsns) assert.match(dsn!, /@postgres:5432\/hydra\b/, `${dsn} hits the hydra DB`); +}); + +test("hydra serves OAuth2 on the public + admin ports", () => { + assert.match(hydraYml, /public:\s*\n\s*port:\s*4444/, "public API on 4444"); + assert.match(hydraYml, /admin:\s*\n\s*port:\s*4445/, "admin API on 4445"); +}); + +test("hydra issuer + login/consent/logout URLs point at our app", () => { + assert.match(hydraYml, /issuer:\s*http:\/\/127\.0\.0\.1:4444\//, "issuer is the public OAuth2 URL"); + assert.match(hydraYml, /login:\s*http:\/\/127\.0\.0\.1:3000\/oauth2\/login\b/, "login challenge → our /oauth2/login"); + assert.match(hydraYml, /consent:\s*http:\/\/127\.0\.0\.1:3000\/oauth2\/consent\b/, "consent challenge → our /oauth2/consent"); + assert.match(hydraYml, /logout:\s*http:\/\/127\.0\.0\.1:3000\/oauth2\/logout\b/, "logout → our /oauth2/logout"); +}); diff --git a/todo.md b/todo.md index fe251f2..cf39ce9 100644 --- a/todo.md +++ b/todo.md @@ -65,7 +65,7 @@ everything via Docker. - [x] Kratos tokenizer template `plainpages`: claims `{ sub, email, roles }`, `ttl ≈ 10m`, `jwks_url` signer, `claims_mapper_url` (Jsonnet reading `metadata_admin.roles`). → `ory/kratos/kratos.yml` adds `session.whoami.tokenizer.templates.plainpages`: `ttl: 10m`, `subject_source: id` (sub = identity id), `claims_mapper_url`/`jwks_url` pointing at the mounted config dir. `ory/kratos/tokenizer/plainpages.jsonnet` is the claims mapper — `email` from `session.identity.traits.email`, `roles` from the `metadata_admin` projection (§4 refreshes it from Keto at login; absent on a fresh identity ⇒ `[]`, defensive `objectHas`). `sub` is fixed to the identity id by Kratos (`subject_source`), not the mapper. The JWKS signing key referenced by `jwks_url` is generated/mounted by the next §3 item — Kratos loads it lazily at tokenize time, so this boots clean. Tests-first (`kratos.test.ts`: template ttl/subject_source/urls + mapper email/roles-from-metadata_admin). Boot-verified: kratos serves `/admin/health/ready` 200 with the tokenizer wired (config schema accepts the block); torn down. typecheck + 125 units green. - [x] Generate + mount the JWT signing JWKS; document key rotation. → `src/gen-jwks.ts` (`generateJwks()` + CLI) mints an **ES256** EC P-256 signing key as a JWK Set — Ory's recommended alg and the verifier's preferred (`src/jwt.ts`). The committed `ory/kratos/tokenizer/jwks.json` is the **dev throwaway** (like the cookie/cipher secrets in `kratos.yml`), already mounted via `./ory/kratos:/etc/config/kratos:ro` at the `jwks_url` the tokenizer template points to — so a clean clone signs out of the box. Regenerate/rotate: `docker compose run --rm -T web node src/gen-jwks.ts > ory/kratos/tokenizer/jwks.json` (also `npm run gen-jwks`). README documents prod override (mount a real key or `…_JWKS_URL=base64://…`) + zero-downtime rotation (Kratos signs with the first key, app verifies by `kid` (§4) → prepend new, keep old ~one 10m TTL, drop). Tests-first (`gen-jwks.test.ts`: generator shape + unique kid, committed key validity, **round-trip** — a JWS signed with a generated key verifies through `verifyJws`). Boot-verified the full chain end-to-end: live Kratos registered an identity (API flow), `whoami?tokenize_as=plainpages` returned a real JWT signed with our `kid`, `verifyJws` validated it against the committed public half, claims `{sub, email, roles:[]}` + exp−iat = 600s (10m); torn down. typecheck + 128 units green. - [x] `keto` service (pinned) + `migrate`; namespaces in OPL (`role`, `group`, resource permissions). → `compose.yml` adds `keto`/`keto-migrate` pinned to `oryd/keto:v26.2.0` (Ory's unified versioning — same train as kratos; verified latest stable); `keto-migrate` runs `migrate up -y` against the per-service `keto` DB after postgres is healthy, `keto` waits on it (`service_completed_successfully`) — mirrors the kratos pattern. `ory/keto/keto.yml` serves read on 4466 + write on 4467 (the ports `config.ts` already targets), DSN via env, loads the OPL from the mounted file. `ory/keto/namespaces.keto.ts` is the OPL model: `User` (subject = Kratos id), `Group`/`Role` as subject sets with `members` (the coarse roles read at login → JWT, README), and a fine-grained `Resource` with `permits` view/edit/delete over owner ⊇ editor ⊇ viewer (README's third "may I?" tier). OPL stays out of tsconfig `include` (Keto-dialect, like the jsonnets). README: Status note + Layout updated, the role tuple example fixed to `#members` to match the OPL. Tests-first (`keto.test.ts`: version pin + migrate-before-serve + DSN→keto DB + read/write ports + OPL namespaces/permits). Fixed a pre-existing kratos test that over-asserted *every* compose DSN was kratos's (now scoped to kratos DSNs). Boot-verified the whole model live: migrate exits 0, read API ready, then over the write/read APIs — `role:admin#members@user:alice` checks allowed; `Resource:doc1` owner→delete/view allowed, viewer→view allowed but delete denied, stranger denied; and a transitive `Group:eng members ⊆ Role:editor` resolved `user:erin`→editor; torn down. typecheck + 135 units green. -- [ ] `hydra` service (pinned) + `migrate`; issuer + login/consent URLs → our app. +- [x] `hydra` service (pinned) + `migrate`; issuer + login/consent URLs → our app. → `compose.yml` adds `hydra`/`hydra-migrate` pinned to `oryd/hydra:v26.2.0` (Ory's unified train — same version as kratos/keto; verified latest); `hydra-migrate` runs `migrate sql -e --yes` against the per-service `hydra` DB after postgres is healthy, `hydra` waits on it (`service_completed_successfully`) — mirrors the kratos pattern. `ory/hydra/hydra.yml` serves public 4444 + admin 4445, `urls.self.issuer` = the public OAuth2 URL, and `urls.login`/`consent`/`logout` point at our app routes (`/oauth2/login`, `/oauth2/consent`, `/oauth2/logout`; §6 renders the handlers, namespaced under `/oauth2/` so they don't collide with Kratos's first-party `/login`). Dev throwaway `secrets.system` (prod overrides via env). Hydra refuses an http issuer in prod, so `compose.override.yml` adds `serve all --dev` + exposes `4444` for dev (the full dev/prod split + health checks is the next §3 item). Tests-first (`hydra.test.ts`: version pin + migrate-before-serve + DSN→hydra DB + public/admin ports + issuer/login/consent/logout URLs). Boot-verified end-to-end: migrate exits 0, public+admin `/health/ready` 200, OIDC discovery reports `issuer: http://127.0.0.1:4444/`, and a real authorization flow (created an OAuth2 client, hit `/oauth2/auth`) 302-redirected to `http://127.0.0.1:3000/oauth2/login?login_challenge=…` — our app; torn down. typecheck + 140 units green. - [ ] Split dev (`compose.override.yml`) vs prod (`compose.yml`) wiring; health checks + `depends_on` ordering. - [ ] **One-command bootstrap** (the MVP bar): `docker compose up` brings up web + all Ory services + Postgres with *zero* manual prep. Commit working default Ory configs; auto-run migrations on first boot; auto-generate the JWKS signing key if absent; seed an admin identity + its Keto roles + a demo password (`admin`/`admin`) idempotently. Land an `OPL`/namespace bootstrap so Keto answers checks out of the box. - [ ] First-run banner / log line printing the login URL + seeded admin creds, with a clear "change these before production" warning.