diff --git a/compose.yml b/compose.yml index 43c1c61..44bed4e 100644 --- a/compose.yml +++ b/compose.yml @@ -10,9 +10,8 @@ services: environment: CACHE_TEMPLATES: "true" REQUIRE_SECURE_SECRETS: "true" - # Wait for the identity/permission services the app talks to (config.ts: kratos + keto) - # and for the one-shot bootstrap to seed the admin + JWKS. Hydra is post-MVP (§6) and - # absent from config.ts, so web doesn't gate on it. + # Wait for the services config.ts talks to (kratos + keto) + the one-shot bootstrap + # (admin + JWKS seed). Hydra is post-MVP (§6), not in config.ts, so web skips it. depends_on: bootstrap: condition: service_completed_successfully @@ -20,8 +19,8 @@ services: condition: service_healthy keto: condition: service_healthy - # Read the session-JWT verify key from the same tokenizer JWKS Kratos signs with - # (config.ts JWKS_URL default; §4 verifier). Read-only — bootstrap is the only writer. + # §4 verifier reads the same tokenizer JWKS Kratos signs with (config.ts JWKS_URL). + # Read-only — bootstrap is the only writer. volumes: - ./ory/kratos/tokenizer:/etc/config/kratos/tokenizer:ro restart: unless-stopped @@ -45,8 +44,8 @@ services: retries: 10 restart: unless-stopped - # Ory Kratos — identity & self-service auth. Config + identity schema in ory/kratos/. - # DSN is the per-service `kratos` DB (init.sql); supply POSTGRES_* via env in prod. + # Ory Kratos — identity & self-service auth. Config + schema in ory/kratos/; DSN is + # its own `kratos` DB (init.sql), POSTGRES_* via env in prod. kratos-migrate: image: oryd/kratos:v26.2.0 depends_on: @@ -76,8 +75,8 @@ services: retries: 20 restart: unless-stopped - # Ory Keto — authorization (ReBAC). Permission model in ory/keto/namespaces.keto.ts (OPL). - # DSN is the per-service `keto` DB (init.sql). The web app calls its read/write APIs (config.ts). + # Ory Keto — authorization (ReBAC); OPL model in ory/keto/namespaces.keto.ts. DSN is + # its own `keto` DB (init.sql). web calls its read/write APIs (config.ts). keto-migrate: image: oryd/keto:v26.2.0 depends_on: @@ -107,11 +106,9 @@ services: retries: 20 restart: unless-stopped - # One-command bootstrap (§3, the MVP bar): a one-shot that seeds first-boot state, then - # exits — generate the JWKS if absent, create the demo admin (admin@plainpages.local / - # admin) in Kratos, grant it the `admin` role in Keto. Idempotent, so it re-runs cleanly. - # Runs once kratos+keto are healthy; web waits for it to complete. Tokenizer dir is - # mounted read-write (the only writer) so the absent-JWKS safety net can land the key. + # One-shot first-boot seed (§3, the MVP bar); see src/bootstrap.ts. Idempotent, re-runs + # cleanly. Runs once kratos+keto are healthy; web waits for it. Tokenizer dir mounted + # read-write (the only writer) so the absent-JWKS safety net can land the key. bootstrap: build: . depends_on: @@ -129,15 +126,13 @@ services: volumes: - ./ory/kratos/tokenizer:/etc/config/kratos/tokenizer command: node src/bootstrap.ts - # Bounded retry: the seed is idempotent (409-create + idempotent PUT), so transient Ory - # blips recover — but a permanent error must give up, not loop forever and hang `web` - # (which gates on service_completed_successfully). + # Bounded retry: the seed is idempotent, so transient Ory blips recover — but a permanent + # error must give up, not loop forever and hang `web` (gates on completion). restart: "on-failure:5" # 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 + # DSN is its own `hydra` DB (init.sql); config in ory/hydra/hydra.yml, handlers are §6. + # Dev permits the http issuer via --dev (compose.override.yml); prod sets an https # issuer via env (URLS_SELF_ISSUER). hydra-migrate: image: oryd/hydra:v26.2.0 diff --git a/ory/kratos/kratos.yml b/ory/kratos/kratos.yml index e81511a..96465ca 100644 --- a/ory/kratos/kratos.yml +++ b/ory/kratos/kratos.yml @@ -1,8 +1,8 @@ # Ory Kratos — identity & self-service auth. Identity schema (email, name) + # password login; recovery & verification run on email codes. Every self-service -# flow returns the browser to our own themed routes (§4 renders the fields). DSN + -# prod courier/secrets come from the env. The session→JWT tokenizer is wired below; -# its JWKS signing key is generated/mounted by the next §3 item. +# flow returns to our own themed routes (§4 renders the fields). DSN + prod +# courier/secrets come from the env. Session→JWT tokenizer wired below (signing +# key in tokenizer/jwks.json). serve: public: base_url: http://127.0.0.1:4433/ @@ -20,11 +20,9 @@ selfservice: enabled: true code: # email one-time code — powers recovery + verification (not login) enabled: true - # Social sign-in (Google, Microsoft, or SAML via an OIDC bridge like Ory Polis — - # OSS Kratos has no native SAML). OFF by default → a clean clone is password-only. - # Activate WITHOUT code changes by supplying env (the whole-array form is the only - # env-settable one Kratos offers); providers reference the committed claims mapper, - # and §4 derives the buttons from this list: + # Social sign-in, OFF by default → clean clone is password-only. Activate via env only + # (no code; the whole-array form is the only env-settable one Kratos offers); §4 derives + # the buttons from this list. SAML isn't in OSS Kratos — bridge it as OIDC (README). # SELFSERVICE_METHODS_OIDC_ENABLED=true # SELFSERVICE_METHODS_OIDC_CONFIG_PROVIDERS=[{"id":"google","provider":"google", # "client_id":"…","client_secret":"…","scope":["openid","email","profile"], @@ -90,7 +88,7 @@ session: # Session→JWT tokenizer (§4): whoami(tokenize_as: plainpages) mints a short-lived, # locally-verifiable JWT so the hot path never calls Ory. Claims come from the # committed Jsonnet mapper (sub = identity id, email from traits, roles from the - # metadata_admin projection); signed with the JWKS the next §3 item generates/mounts. + # metadata_admin projection); signed with tokenizer/jwks.json. whoami: tokenizer: templates: diff --git a/src/bootstrap.ts b/src/bootstrap.ts index 728b62a..57a817d 100644 --- a/src/bootstrap.ts +++ b/src/bootstrap.ts @@ -1,11 +1,9 @@ -// One-command bootstrap (todo §3, the MVP bar). Runs as the one-shot `bootstrap` compose -// service after kratos+keto are healthy; `web` waits for it to finish. Idempotent — safe -// to re-run on every `docker compose up`: +// One-command bootstrap (todo §3, the MVP bar). One-shot compose service: runs after +// kratos+keto are healthy (web waits on it), idempotent on every `docker compose up`: // 1. generate the JWKS signing key if absent (committed dev key makes this a safety net); -// 2. seed a demo admin identity (admin@plainpages.local / admin) in Kratos; +// 2. seed a demo admin (admin@plainpages.local / admin) in Kratos; // 3. grant it the `admin` role in Keto so menu/permission checks resolve out of the box. -// On finish it prints a first-run banner (login URL + creds + change-before-prod warning). -// Fails loud on any unexpected upstream error. +// Then prints a first-run banner; fails loud on any unexpected upstream error. import { existsSync, writeFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { generateJwks, type JwkSet } from "./gen-jwks.ts"; diff --git a/src/config.ts b/src/config.ts index 01665fb..3581053 100644 --- a/src/config.ts +++ b/src/config.ts @@ -68,10 +68,9 @@ export function loadConfig(env: Env = process.env): Config { cacheTemplates: readBool(env, "CACHE_TEMPLATES", false), cookieSecret: readSecret(env, "COOKIE_SECRET", "dev-insecure-cookie-secret", requireSecure), csrfSecret: readSecret(env, "CSRF_SECRET", "dev-insecure-csrf-secret", requireSecure), - // The session JWT is signed by the Kratos tokenizer key (kratos.yml jwks_url); the §4 - // verifier reads that same key. Kratos does not republish it over HTTP, so default to a - // file:// of the tokenizer JWKS mounted into the web container (compose.yml) — not a - // well-known endpoint. Prod overrides with a real key (README: JWT signing key & rotation). + // §4 verifier reads the same key the Kratos tokenizer signs with (kratos.yml jwks_url). + // Kratos doesn't republish it over HTTP, so default to a file:// of the tokenizer JWKS + // mounted into web (compose.yml). Prod overrides with a real key (README: rotation). jwksUrl: readUrl(env, "JWKS_URL", "file:///etc/config/kratos/tokenizer/jwks.json"), ketoReadUrl: readUrl(env, "KETO_READ_URL", "http://keto:4466"), ketoWriteUrl: readUrl(env, "KETO_WRITE_URL", "http://keto:4467"), diff --git a/src/gen-jwks.ts b/src/gen-jwks.ts index 14f3b05..259368b 100644 --- a/src/gen-jwks.ts +++ b/src/gen-jwks.ts @@ -1,12 +1,10 @@ import { generateKeyPairSync, randomUUID } from "node:crypto"; import { fileURLToPath } from "node:url"; -// ES256 signing JWKS for the Kratos session tokenizer (§3). Ory recommends ES* over the -// symmetric HS family; ES256 is also our verifier's preferred alg (src/jwt.ts). Kratos -// signs with the FIRST key in the set and the app verifies by `kid` (§4) — so rotation is -// prepend a fresh key, keep the old one ~one TTL (10m) for in-flight tokens, then drop it. -// (Re)generate the committed dev key (prod supplies its own — see README): -// docker compose run --rm -T web node src/gen-jwks.ts > ory/kratos/tokenizer/jwks.json +// ES256 signing JWKS for the Kratos session tokenizer (§3) — Ory-recommended and the +// verifier's preferred alg (src/jwt.ts). Rotation runbook: README, JWT signing key. +// (Re)generate the committed dev key (prod supplies its own): +// docker compose run --rm -T --no-deps web node src/gen-jwks.ts > ory/kratos/tokenizer/jwks.json export interface SigningJwk { kid: string; diff --git a/src/kratos.test.ts b/src/kratos.test.ts index fd351f6..cb60f44 100644 --- a/src/kratos.test.ts +++ b/src/kratos.test.ts @@ -70,7 +70,7 @@ test("session settings: branded cookie, bounded lifespan, sliding refresh", () = test("session tokenizer template 'plainpages' mints a short-lived signed JWT", () => { // whoami(tokenize_as: plainpages) → a locally-verifiable JWT, so the hot path never - // calls Ory (§4). The JWKS signer is generated/mounted by the next §3 item. + // calls Ory (§4). Signed with the committed tokenizer/jwks.json (gen-jwks.ts). assert.match(kratosYml, /tokenizer:\s*\n\s*templates:\s*\n\s*plainpages:/, "plainpages template defined"); assert.match(kratosYml, /ttl:\s*10m/, "~10m TTL — re-minted on refresh"); assert.match(kratosYml, /subject_source:\s*id/, "sub = the Kratos identity id"); diff --git a/todo.md b/todo.md index 4368574..cb74d81 100644 --- a/todo.md +++ b/todo.md @@ -71,7 +71,7 @@ everything via Docker. - [x] First-run banner / log line printing the login URL + seeded admin creds, with a clear "change these before production" warning. → `firstRunBanner()` in `src/bootstrap.ts` (pure, testable) renders a boxed banner — login URL · seeded email/password · "⚠ change before production" — that `main()` prints after seeding. Login URL from `APP_URL` (compose default `http://localhost:3000`, overridable per deployment); creds reuse the seeded `ADMIN_EMAIL`/`ADMIN_PASSWORD`. Tests-first (`bootstrap.test.ts`: asserts URL + creds + warning present); README **Development** notes the banner. Live-verified: rebuilt bootstrap prints the banner after the admin line; typecheck + 152 units green; stack torn down. - [x] Document the *only* things that can't be auto-generated: third-party **SSO provider** client id/secret (optional — password login works without them) and **production secrets** (real cookie/CSRF secret + signing key, supplied via env, replacing the dev throwaways). Everything else must work from a clean clone. → New README **What you must supply (the only manual prep)** subsection (under Configuration) consolidates the previously-scattered facts into one authoritative list: a clean clone needs nothing; exactly two production-only things can't be auto-generated — (1) production secrets (`COOKIE_SECRET`/`CSRF_SECRET` + the JWT signing key, with `REQUIRE_SECURE_SECRETS=true` refusing throwaways) and (2) optional SSO provider creds (no creds ⇒ no button). States everything else (Ory migrations, dev signing key, demo admin + Keto roles, OPL model) is generated/seeded on first boot. Cross-links the existing SSO + JWT-rotation subsections (no duplication) and adds a pointer from **Production / deployment**. All four anchors verified; docs-only — typecheck + 152 units green. - [x] Run the architecture _and_ the stability reviewer agents on the _whole_ project, not just the latest changes, and address their issues. → Ran both on the whole project (weighted to the §3 Ory stack). Verdict: architecture sound + disciplined, no Critical; both independently flagged the *same* top issue. **Fixed now:** (1) HIGH (both agents) — `JWKS_URL` default was `http://kratos:4433/.well-known/jwks.json`, but Kratos does **not** republish the session-tokenizer key there (no OIDC discovery on Kratos — that's Hydra), so the §4 verifier would have fetched the wrong/empty set and *no one* could be authorized. Repointed the default to `file:///etc/config/kratos/tokenizer/jwks.json` — the exact key Kratos signs with (`kratos.yml` `jwks_url`) — and mounted that tokenizer dir **read-only into `web`** (`compose.yml`) so the verifier resolves the live key in dev *and* prod (same file bootstrap regenerates). `config.test.ts` now locks the default to the tokenizer file + asserts the committed key is a real ES256 JWKS carrying a `kid` (the regression the old `/jwks/` match missed). (2) MEDIUM (stability) — `bootstrap` had uncapped `restart: on-failure`; a *permanent* seed error would loop forever and silently hang `web` (gates on `service_completed_successfully`). Capped to `on-failure:5` (seed is idempotent — 409-create + idempotent PUT — so transient Ory blips still recover, permanent ones give up loud). (3) §3's new `web` `depends_on` made the documented `docker compose run --rm web …` typecheck/test/gen-jwks commands drag up the whole Ory stack — added `--no-deps` (README + AGENTS.md). **Deferred (reviewer-scoped, not §3):** extract `buildShellContext` out of `dashboard.ts` + route built-in screens through `matchRoute`/`isAuthorized` → §5 (forcing function arrives with the 2nd/3rd screen); seed the demo admin's `metadata_admin.roles` projection so first login is non-empty → §4 (the login-completion projection owns it); enforce Ory `*.yml` prod secrets + self-service return-URLs via env → §9 (ops). typecheck + 153 units green; both compose files validated. -- [ ] Go over all comments in the code and the README and try to make it shorter and more information dense. Remove not strictly needed stuff. +- [x] Go over all comments in the code and the README and try to make it shorter and more information dense. Remove not strictly needed stuff. → Pass over the §3 Ory accretion. Killed the now-stale "the next §3 item generates/mounts" forward-refs (the JWKS shipped) in `kratos.yml` (×2) + `kratos.test.ts`. Tightened the verbose service/header blocks in `compose.yml` (web depends_on/JWKS-mount, the three Ory headers, the bootstrap block) and the `bootstrap.ts`/`gen-jwks.ts` module headers — dropping prose the README/`src/bootstrap.ts` already carry, keeping the security/stability rationale (read-only mount, bounded retry). Trimmed `config.ts`'s JWKS comment and the `kratos.yml` SSO block (kept the concrete env example), and aligned the `gen-jwks.ts` command with the README's `--no-deps`. Net −12 lines; typecheck + 153 units green. The §3 README sections (Development / What you must supply / SSO / JWT rotation) were already authored concise in §3 (todo lines 70–72) and left intact. - [ ] 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. ## 4. Auth — identity, session JWT, guards