diff --git a/README.md b/README.md index 03b45ff..e99a2ef 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,24 @@ SSO button (§4 derives the buttons from this list). Open-source Kratos has **no SAML** — front it with an OIDC bridge (Ory Polis) and register that bridge as a generic OIDC provider the same way. +### JWT signing key & rotation + +The session tokenizer (§3) signs each session→JWT with an **ES256** key at +`ory/kratos/tokenizer/jwks.json`. The committed one is a **dev throwaway** (like the +cookie/cipher secrets in `kratos.yml`) — a clean clone works; **never run it in +production**. (Re)generate with the bundled generator: + +```bash +docker compose run --rm -T web node src/gen-jwks.ts > ory/kratos/tokenizer/jwks.json +``` + +**Production:** mount a real key over that path, or set +`SESSION_WHOAMI_TOKENIZER_TEMPLATES_PLAINPAGES_JWKS_URL=base64://`. + +**Rotation (zero downtime):** Kratos signs with the **first** key in the set; the app +selects the verify key by `kid` (§4). So prepend a freshly generated key, keep the old +one for ~one token TTL (10m) so in-flight JWTs still verify, then drop it. + ## Type check & tests ```bash @@ -439,6 +457,7 @@ src/server.ts Entry point — starts the HTTP server (reads PORT, default src/app.ts Request routing + EJS rendering src/static.ts Static file serving (path-traversal protection) + routePublic(): /public// → a plugin's public/ src/jwt.ts JWS signature verify via node:crypto, no jose; claims+JWKS are §4 +src/gen-jwks.ts generateJwks() + CLI: mint the ES256 session-tokenizer signing JWKS (§3); see JWT signing key & rotation src/cookie.ts Cookie parse + secure Set-Cookie build (session/CSRF cookies, §4) src/context.ts RequestContext handed to handlers + buildContext() src/config.ts Env loader — Ory endpoints, cookie/CSRF secrets, JWKS, port; validated at boot @@ -455,7 +474,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) + 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) + 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/tokenizer/jwks.json b/ory/kratos/tokenizer/jwks.json new file mode 100644 index 0000000..6a0ee2e --- /dev/null +++ b/ory/kratos/tokenizer/jwks.json @@ -0,0 +1,14 @@ +{ + "keys": [ + { + "kid": "42634591-3e04-49d5-a818-284d7021a85f", + "alg": "ES256", + "crv": "P-256", + "d": "w5PlXL22zetbkrvjgx7ICtgUyvMG0t3N5I3qok2uoN4", + "kty": "EC", + "use": "sig", + "x": "n_6hOl1l_xcOIkh0EfpJ-bTtzVyrUBe2_6M8_FfmrVg", + "y": "OTtDmRMqXxGMZCG2ybhDBnajd3erMg4W-OKRvBhVzKw" + } + ] +} diff --git a/package.json b/package.json index baa7e69..c58b67b 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "scripts": { "start": "node src/server.ts", "dev": "node --watch src/server.ts", + "gen-jwks": "node src/gen-jwks.ts", "typecheck": "tsc --noEmit", "test": "node --test \"src/**/*.test.ts\"" }, diff --git a/src/gen-jwks.test.ts b/src/gen-jwks.test.ts new file mode 100644 index 0000000..a1677cc --- /dev/null +++ b/src/gen-jwks.test.ts @@ -0,0 +1,44 @@ +// Guards the session-tokenizer signing key (§3): generateJwks() emits a fresh ES256 +// EC private signing key, the committed dev JWKS is a valid such key, and a token signed +// with it verifies through our own verifier (src/jwt.ts) — so what Kratos signs, §4 reads. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { createPrivateKey, sign, type JsonWebKey } from "node:crypto"; +import { generateJwks } from "./gen-jwks.ts"; +import { verifyJws } from "./jwt.ts"; + +const b64url = (s: string) => Buffer.from(s).toString("base64url"); +const committed = JSON.parse(readFileSync(new URL("../ory/kratos/tokenizer/jwks.json", import.meta.url), "utf8")); + +test("generateJwks emits one ES256 EC private signing key with a fresh kid", () => { + const a = generateJwks(); + const b = generateJwks(); + assert.equal(a.keys.length, 1); + const k = a.keys[0]!; + assert.deepEqual({ alg: k.alg, crv: k.crv, kty: k.kty, use: k.use }, { alg: "ES256", crv: "P-256", kty: "EC", use: "sig" }); + assert.ok(k.d && k.x && k.y, "carries the private scalar d (a signing key) + public point"); + assert.match(k.kid, /^[0-9a-f-]{36}$/, "kid is a uuid"); + assert.notEqual(k.kid, b.keys[0]!.kid, "each call mints a unique kid (so rotation differs)"); +}); + +test("the committed dev JWKS is a valid ES256 signing key importable by node:crypto", () => { + const k = committed.keys[0]; + assert.equal(committed.keys.length, 1); + assert.deepEqual({ alg: k.alg, kty: k.kty, use: k.use }, { alg: "ES256", kty: "EC", use: "sig" }); + assert.ok(k.kid && k.d, "has a kid and the private signing scalar"); + assert.doesNotThrow(() => createPrivateKey({ key: k, format: "jwk" }), "Kratos can load it to sign"); +}); + +test("a JWS signed with a generated key verifies via our own verifier (§4 reads what Kratos signs)", () => { + const key = generateJwks().keys[0]!; + const head = b64url(JSON.stringify({ alg: "ES256", kid: key.kid })); + const body = b64url(JSON.stringify({ email: "a@b.c", roles: [], sub: key.kid })); + const sig = sign("SHA256", Buffer.from(`${head}.${body}`), { dsaEncoding: "ieee-p1363", key: createPrivateKey({ key: key as unknown as JsonWebKey, format: "jwk" }) }); + const token = `${head}.${body}.${sig.toString("base64url")}`; + + const { d: _d, ...pub } = key; // verify against the public half only + const decoded = verifyJws(token, pub); + assert.equal(decoded.payload.email, "a@b.c"); + assert.equal(decoded.header.kid, key.kid); +}); diff --git a/src/gen-jwks.ts b/src/gen-jwks.ts new file mode 100644 index 0000000..14f3b05 --- /dev/null +++ b/src/gen-jwks.ts @@ -0,0 +1,32 @@ +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 + +export interface SigningJwk { + kid: string; + alg: "ES256"; + crv: string; + d: string; // private scalar — this is a signing key, keep it secret + kty: string; + use: "sig"; + x: string; + y: string; +} +export interface JwkSet { + keys: SigningJwk[]; +} + +export function generateJwks(): JwkSet { + const { crv, d, kty, x, y } = generateKeyPairSync("ec", { namedCurve: "P-256" }).privateKey.export({ format: "jwk" }); + if (!crv || !d || !kty || !x || !y) throw new Error("unexpected JWK shape from EC key"); + return { keys: [{ kid: randomUUID(), alg: "ES256", crv, d, kty, use: "sig", x, y }] }; +} + +// CLI: print a fresh set to stdout (redirect into the jwks.json above). +if (process.argv[1] === fileURLToPath(import.meta.url)) process.stdout.write(`${JSON.stringify(generateJwks(), null, 2)}\n`); diff --git a/todo.md b/todo.md index 84afea7..673b811 100644 --- a/todo.md +++ b/todo.md @@ -63,7 +63,7 @@ everything via Docker. - [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. - [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. +- [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. - [ ] `keto` service (pinned) + `migrate`; namespaces in OPL (`role`, `group`, resource permissions). - [ ] `hydra` service (pinned) + `migrate`; issuer + login/consent URLs → our app. - [ ] Split dev (`compose.override.yml`) vs prod (`compose.yml`) wiring; health checks + `depends_on` ordering.