From 95c759d773b3ff2041fa7485fd7b4eab82efd7b5 Mon Sep 17 00:00:00 2001 From: lilleman Date: Wed, 17 Jun 2026 12:02:21 +0200 Subject: [PATCH] =?UTF-8?q?Wire=20Kratos=20session=20tokenizer=20template?= =?UTF-8?q?=20(todo=20=C2=A73);=20plainpages=20JWT=20(sub/email/roles),=20?= =?UTF-8?q?10m=20TTL,=20Jsonnet=20claims=20mapper=20reading=20metadata=5Fa?= =?UTF-8?q?dmin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- ory/kratos/kratos.yml | 16 ++++++++++++++-- ory/kratos/tokenizer/plainpages.jsonnet | 16 ++++++++++++++++ src/kratos.test.ts | 17 +++++++++++++++++ todo.md | 2 +- 5 files changed, 49 insertions(+), 4 deletions(-) create mode 100644 ory/kratos/tokenizer/plainpages.jsonnet diff --git a/README.md b/README.md index cf0d8c2..03b45ff 100644 --- a/README.md +++ b/README.md @@ -455,7 +455,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) + 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) + 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/ory/kratos/kratos.yml b/ory/kratos/kratos.yml index 5d18bc7..e81511a 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. SSO, session tuning, and the JWT -# tokenizer land in later §3/§4 items. +# 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. serve: public: base_url: http://127.0.0.1:4433/ @@ -87,6 +87,18 @@ session: name: plainpages_session persistent: true # survive browser restarts same_site: Lax + # 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. + whoami: + tokenizer: + templates: + plainpages: + ttl: 10m + subject_source: id + claims_mapper_url: file:///etc/config/kratos/tokenizer/plainpages.jsonnet + jwks_url: file:///etc/config/kratos/tokenizer/jwks.json # Dev throwaways — production supplies real secrets via env (§3). cipher = 32 chars. secrets: diff --git a/ory/kratos/tokenizer/plainpages.jsonnet b/ory/kratos/tokenizer/plainpages.jsonnet new file mode 100644 index 0000000..d6078b5 --- /dev/null +++ b/ory/kratos/tokenizer/plainpages.jsonnet @@ -0,0 +1,16 @@ +// Session→JWT claims mapper for the `plainpages` tokenizer (§4). Kratos exposes the +// session as `session`; `sub` is set from the identity id (subject_source: id) and +// can't be overridden here. roles come from metadata_admin — the per-login projection +// of Keto roles the app refreshes at login; absent on a fresh identity ⇒ empty list. +local session = std.extVar('session'); +local meta = + if std.objectHas(session.identity, 'metadata_admin') && session.identity.metadata_admin != null + then session.identity.metadata_admin + else {}; + +{ + claims: { + email: session.identity.traits.email, + roles: if std.objectHas(meta, 'roles') then meta.roles else [], + }, +} diff --git a/src/kratos.test.ts b/src/kratos.test.ts index 4a26098..1d77016 100644 --- a/src/kratos.test.ts +++ b/src/kratos.test.ts @@ -68,6 +68,23 @@ test("session settings: branded cookie, bounded lifespan, sliding refresh", () = assert.match(kratosYml, /earliest_possible_extend:\s*24h/, "sliding-refresh window is set"); }); +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. + 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"); + assert.match(kratosYml, /jwks_url:\s*file:\/\/\/etc\/config\/kratos\/tokenizer\/jwks\.json/, "signs with the mounted JWKS"); + assert.match(kratosYml, /claims_mapper_url:\s*file:\/\/\/etc\/config\/kratos\/tokenizer\/plainpages\.jsonnet/, + "claims via the committed mapper"); +}); + +test("the tokenizer claims mapper emits email + roles from the metadata_admin projection", () => { + const mapper = read("ory/kratos/tokenizer/plainpages.jsonnet"); + assert.match(mapper, /email:\s*session\.identity\.traits\.email/, "email ← identity trait"); + assert.match(mapper, /metadata_admin/, "roles ← metadata_admin (the per-login Keto projection, §4)"); +}); + test("social sign-in is off by default — a clean clone stays password-only", () => { // The oidc method ships present-but-disabled with no providers; operators activate it // purely via env (SELFSERVICE_METHODS_OIDC_*) — no code change, no baked-in creds. diff --git a/todo.md b/todo.md index e1d407b..84afea7 100644 --- a/todo.md +++ b/todo.md @@ -62,7 +62,7 @@ everything via Docker. - [x] Kratos self-service flows (login, registration, recovery, verification, settings) → return URLs at our themed pages. → `ory/kratos/kratos.yml`: all five flows enabled, each `ui_url` (+ after/return URLs) points at our web routes (`/login`, `/registration`, `/recovery`, `/verification`, `/settings`; §4 renders the fields). Recovery + verification run on the email `code` method (login stays password-only — `code.passwordless_enabled` left default-off); registration after-hooks `session` + `show_verification_ui`; settings gets `privileged_session_max_age` + `required_aal: highest_available`. Added a `courier` (SMTP) sending to a pinned dev mail catcher — **mailpit** (`axllent/mailpit:v1.30.1`) in `compose.override.yml`, web UI on `:8025`; prod overrides `COURIER_SMTP_CONNECTION_URI`. Kratos `serve` now runs `--watch-courier` so queued codes actually dispatch (without it they sit "queued"). Tests-first (`kratos.test.ts`: five flow ui_urls → our pages, recovery/verification use `code` + courier + `--watch-courier`, mailpit pin). Boot-verified end-to-end: all four public browser-flows 303 → `127.0.0.1:3000/?flow=…`, a registration delivered a real "Use code … to verify your account" email to mailpit (queue → `sent`); torn down. typecheck + 120 units green. - [x] Kratos OIDC/SSO providers (Google/Microsoft/SAML) config (secrets via env). **None enabled by default** — a clean clone runs password-only; a provider activates purely by supplying its env creds. → `ory/kratos/kratos.yml` adds the `oidc` method present-but-disabled with an empty `providers: []` (clean clone = password-only, boots clean). Activation is pure env, no code/rebuild: `SELFSERVICE_METHODS_OIDC_ENABLED=true` + `SELFSERVICE_METHODS_OIDC_CONFIG_PROVIDERS=[…]` (the whole-array override is the only env-settable form Kratos offers — nested-field env vars aren't supported). Providers (`google`/`microsoft`/OIDC bridges) carry their `client_id`/`client_secret` and reference the committed shared claims mapper `ory/kratos/oidc/claims.jsonnet` (provider claims → `email` + `name{first,last}`). **SAML isn't in OSS Kratos** (Enterprise/Network/Polis only) — documented: front it with an OIDC bridge (Ory Polis) and register that bridge as a generic OIDC provider. README **Social sign-in (SSO)** section documents activation; §4 will derive the buttons from the live provider list. Tests-first (`kratos.test.ts`: oidc disabled + empty by default, mapper maps email/name). Boot-verified both halves: clean stack → login flow has only `default`+`password` groups; a one-off kratos with the SSO env → login flow gains an `oidc` group + a `google` button, no boot errors; torn down. typecheck + 122 units green. - [x] Kratos session settings (cookie name, lifespan, sliding refresh). → `ory/kratos/kratos.yml` adds a `session` block: branded cookie `name: plainpages_session` (`persistent: true`, `same_site: Lax`), `lifespan: 720h` (30d "stay signed in" backbone the app re-mints the ~10m JWT off, §4), and sliding refresh via `earliest_possible_extend: 24h` (an active session extends back to full lifespan only once within 24h of expiry — no DB write per request). Tests-first (`kratos.test.ts`: cookie name + lifespan + extend window). Boot-verified: kratos serves `/health/ready` 200 with the block; a real browser registration (one-off `--dev` kratos, since Secure cookies don't ride plain http — that's the line-69 split) issued `Set-Cookie: plainpages_session=…; Max-Age=2591999; Expires=…; HttpOnly; SameSite=Lax` — name/persistent/lifespan all as configured; torn down. typecheck + 123 units green. -- [ ] Kratos tokenizer template `plainpages`: claims `{ sub, email, roles }`, `ttl ≈ 10m`, `jwks_url` signer, `claims_mapper_url` (Jsonnet reading `metadata_admin.roles`). +- [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. - [ ] Generate + mount the JWT signing JWKS; document key rotation. - [ ] `keto` service (pinned) + `migrate`; namespaces in OPL (`role`, `group`, resource permissions). - [ ] `hydra` service (pinned) + `migrate`; issuer + login/consent URLs → our app.