Generate + mount the JWT signing JWKS (todo §3); ES256 gen-jwks tool, committed dev key, key-rotation docs
This commit is contained in:
21
README.md
21
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://<the JWKS JSON, 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/<id>/ → 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)
|
||||
|
||||
14
ory/kratos/tokenizer/jwks.json
Normal file
14
ory/kratos/tokenizer/jwks.json
Normal file
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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\""
|
||||
},
|
||||
|
||||
44
src/gen-jwks.test.ts
Normal file
44
src/gen-jwks.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
32
src/gen-jwks.ts
Normal file
32
src/gen-jwks.ts
Normal file
@@ -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`);
|
||||
2
todo.md
2
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.
|
||||
|
||||
Reference in New Issue
Block a user