Add Hydra service + migrate (todo §3); pin oryd/hydra:v26.2.0, OAuth2 issuer + login/consent URLs → our app routes
This commit is contained in:
@@ -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**
|
> 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
|
> (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
|
> **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.
|
> _(planned)_ are not built yet.
|
||||||
|
|
||||||
## The MVP — "clone, one command, hack on a plugin" _(planned)_
|
## 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)
|
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)
|
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) + 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)
|
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 visual + functional E2E (Dockerfile.e2e + compose.e2e.yml run it)
|
e2e/ Playwright visual + functional E2E (Dockerfile.e2e + compose.e2e.yml run it)
|
||||||
|
|||||||
@@ -18,3 +18,10 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "8025:8025"
|
- "8025:8025"
|
||||||
restart: unless-stopped
|
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"
|
||||||
|
|||||||
29
compose.yml
29
compose.yml
@@ -83,5 +83,34 @@ services:
|
|||||||
command: serve -c /etc/config/keto/keto.yml
|
command: serve -c /etc/config/keto/keto.yml
|
||||||
restart: unless-stopped
|
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:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
|
|||||||
28
ory/hydra/hydra.yml
Normal file
28
ory/hydra/hydra.yml
Normal file
@@ -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
|
||||||
46
src/hydra.test.ts
Normal file
46
src/hydra.test.ts
Normal file
@@ -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");
|
||||||
|
});
|
||||||
2
todo.md
2
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] 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] 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.
|
- [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.
|
- [ ] 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.
|
- [ ] **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.
|
- [ ] First-run banner / log line printing the login URL + seeded admin creds, with a clear "change these before production" warning.
|
||||||
|
|||||||
Reference in New Issue
Block a user