keycloak.ts lib: JWKS cache + JWT verify + AES-GCM cookie + OIDC state (foundation for #2) #14

Closed
opened 2026-05-03 14:56:01 +00:00 by forgejo_admin · 2 comments

Type

Feature

Lineage

Decomposed from forgejo_admin/westside-admin#2 (Keycloak cookie SSR auth + admin role gate). This is sub-task 1 of 4. Other sub-tasks (hooks.server.ts, callback/logout endpoints, 403 page) depend on this lib landing first. Refined 2026-05-03 per review-1134-2026-05-03 (vitest not scaffolded; npm not pnpm).

Repo

forgejo_admin/westside-admin

User Story

story-westside-admin-admin-row-crud. The lib provides the cryptographic primitives that every funnel-exposed admin route depends on. Per feedback_funnel_requires_auth, every primitive (JWT verify, AES-GCM, OIDC state, JWKS cache) must be unit-tested in isolation before it's wired into request handlers.

Context

Splits out the cryptographic and OIDC-protocol concerns from the SvelteKit hook layer so they can be unit-tested without spinning up a SvelteKit dev server or hitting a real Keycloak. Consumers (hooks.server.ts, /auth/callback, /auth/logout) are landed in subsequent sub-tickets.

The Keycloak westside-admin confidential OIDC client was created on 2026-05-03 in the westside-basketball realm per sop-keycloak-client-creation. The client_secret is in pal-e-deployments/overlays/westside-admin/prod/westside-admin-secrets.enc.yaml (PR #147, QA-approved, awaiting consumer-merge gate). All 5 env vars (KEYCLOAK_URL, KEYCLOAK_REALM, KEYCLOAK_CLIENT_ID, KEYCLOAK_CLIENT_SECRET, COOKIE_SIGNING_KEY) will be available in the cluster Secret once #147 merges. This sub-task does NOT block on #147 — code + unit tests can run without cluster env vars (tests stub them).

Implement from scratch using jose. The reviewer of #2 verified: westside-contracts has zero Keycloak code (uses signed-token URLs); westside-app uses browser-side keycloak-js (adapter-static). No internal prior art for adapter-node Keycloak SSR — this lib is the new pattern reference for future SSR-Keycloak consumers.

Vitest not yet scaffolded. Per review-1134-2026-05-03 ground-truth verification, package.json at HEAD has no vitest, no test script, no vitest.config.ts. This sub-task installs and configures vitest as part of the work — the test framework setup is bundled with the lib it tests, since they ship together as the testable unit.

Package manager: npm. Repo has package-lock.json, no pnpm-lock.yaml. All commands use npm.

File Targets

Create:

  • src/lib/server/keycloak.ts — exports the four primitives below.
  • src/lib/server/keycloak.test.ts — unit tests (vitest).
  • vitest.config.ts — minimal config, server-side environment (default node), include src/**/*.test.ts.

Update:

  • package.json — add jose (latest 5.x) to dependencies. Add vitest (latest 1.x or 2.x — match the SvelteKit version's compatible range) to devDependencies. Add scripts: "test": "vitest run" and "test:watch": "vitest".

Do NOT touch:

  • src/hooks.server.ts (sub-task 2)
  • src/routes/auth/* (sub-task 3)
  • src/routes/(unauthorized)/* (sub-task 4)
  • src/app.d.ts (sub-task 2 — App.Locals extension belongs with hook wiring)

Public surface (informational, not part of acceptance — exact names are at the agent's discretion within the constraints)

  • verifyKeycloakJwt(token: string): Promise<JwtPayload> — fetches JWKS (cached), validates signature, audience (westside-admin), issuer (${KEYCLOAK_URL}/realms/westside-basketball), and exp.
  • encryptCookiePayload(payload: object): string — AES-256-GCM encrypt with COOKIE_SIGNING_KEY env var, returns ciphertext + iv + tag concatenated as URL-safe base64.
  • decryptCookiePayload(ciphertext: string): object | null — inverse; returns null on tamper or wrong key.
  • generateOidcState(): { state: string; signed: string } — random 256-bit state + HMAC-signed wrapper for the transient cookie.
  • verifyOidcState(received: string, signedFromCookie: string): boolean — constant-time compare.
  • refreshTokensIfNeeded(tokens: { access_token, refresh_token, exp }): Promise<typeof tokens> — calls Keycloak token endpoint with refresh_token grant when exp - now < 30s.

Acceptance Criteria

  • vitest configured (vitest.config.ts exists, npm test exits 0 on a fresh checkout).
  • JWKS fetched from ${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/certs, cached with 10-minute TTL, single in-flight refresh (concurrent requests during refresh wait on the same promise — no thundering herd).
  • JWT verify rejects: tampered signature, wrong audience, wrong issuer, expired token, malformed JWT, token signed by a key not in the current JWKS.
  • AES-GCM encrypt/decrypt round-trips arbitrary JSON-serializable payloads.
  • AES-GCM decrypt returns null (not throws) when ciphertext is tampered, key is wrong, or input is malformed — caller distinguishes "no session" vs "tampered session" by null check.
  • OIDC state generated with crypto.randomBytes(32) (≥256 bits), HMAC-signed with COOKIE_SIGNING_KEY, fits inside a single Set-Cookie header.
  • State verification is constant-time (use crypto.timingSafeEqual).
  • Token refresh fires when exp - now < 30s, posts to ${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/token with grant_type=refresh_token and confidential-client basic auth (KEYCLOAK_CLIENT_ID:KEYCLOAK_CLIENT_SECRET).
  • JWKS-unreachable resilience (added per scope review): if the JWKS endpoint is down AND there is no cached key set, verifyKeycloakJwt rejects with a typed error that callers can surface as HTTP 503 (not 401). If a cached key set exists and the cache is within TTL, fall back to it; if cache is stale but JWKS unreachable, log warning and reject.
  • No console.log or any other log call in this lib emits the JWT, refresh token, cookie ciphertext, or COOKIE_SIGNING_KEY. Verified via grep over the source.

Test Expectations

  • Unit (npm test):
    • JWT verify: 7+ cases (valid token, tampered sig, wrong aud, wrong iss, expired, malformed, key-rotated-out).
    • AES-GCM: 4+ cases (round-trip, tampered ciphertext, wrong key, malformed input).
    • State: 3+ cases (round-trip, tampered signature, wrong-key).
    • JWKS cache: 3+ cases (cache hit, cache miss triggers fetch, single in-flight refresh deduplicates concurrent calls).
    • JWKS unreachable: 2+ cases (no cache → 503-typed error; stale cache + unreachable → 503-typed error).
    • Token refresh: 3+ cases (token within 30s of expiry → fetch new tokens; token > 30s remaining → no fetch; refresh-grant 4xx response → typed error).
  • Run command: npm test -- src/lib/server/keycloak.test.ts (or just npm test to run all).
  • All tests pass before PR opens.

Constraints

  • Use jose (5.x) for JWT + JWKS. Do NOT pull jsonwebtoken (CVE history) or node-jose (slower).
  • Use Node's node:crypto Web Crypto subset for AES-GCM and HMAC. Do NOT pull crypto-js (browser-targeted).
  • Use SvelteKit's event.cookies API in consumers; this lib only returns ciphertext strings.
  • All exports must be pure-ish functions. The only stateful element is the JWKS cache singleton — encapsulate it inside the module, do not export the cache.
  • TypeScript strict mode (already on per scaffolding from #6). All exports typed.
  • Match existing eslint rules from the scaffold; no // eslint-disable lines.
  • Do NOT introduce a side-effect import that would fire on dev-server hot-reload (e.g., do not pre-warm the JWKS cache on module load).
  • Package manager: npm (repo has package-lock.json, no pnpm). Do NOT add a pnpm-lock.yaml. Run npm install after adding deps; commit the updated package-lock.json.
  • Vitest version: match the SvelteKit ecosystem's recommended pin. If @sveltejs/kit is at 2.x, vitest 1.x or 2.x is fine — pick one and stick to it. Do NOT pull a 0.x.

Checklist

  • PR opened
  • Tests pass (npm test exits 0)
  • No unrelated changes
  • No tokens / signing keys logged (grep verified)
  • PR body includes funnel-auth review per feedback_funnel_requires_auth
  • project-westside-admin — project this affects
  • forgejo_admin/westside-admin#2 — parent decomposition
  • arch-dataflow-westside-admin — Flow 1 (auth sequence)
  • feedback_funnel_requires_auth
  • sop-keycloak-client-creation — Keycloak side
  • forgejo_admin/pal-e-deployments#147 — env-var landing PR (must merge before integration sub-tasks #15/#16 can validate end-to-end; this sub-task does NOT block on it)
  • review-1134-2026-05-03 — scope review that surfaced the vitest + npm refinements
### Type Feature ### Lineage Decomposed from `forgejo_admin/westside-admin#2` (Keycloak cookie SSR auth + admin role gate). This is sub-task 1 of 4. Other sub-tasks (hooks.server.ts, callback/logout endpoints, 403 page) depend on this lib landing first. Refined 2026-05-03 per review-1134-2026-05-03 (vitest not scaffolded; npm not pnpm). ### Repo `forgejo_admin/westside-admin` ### User Story `story-westside-admin-admin-row-crud`. The lib provides the cryptographic primitives that every funnel-exposed admin route depends on. Per `feedback_funnel_requires_auth`, every primitive (JWT verify, AES-GCM, OIDC state, JWKS cache) must be unit-tested in isolation before it's wired into request handlers. ### Context Splits out the cryptographic and OIDC-protocol concerns from the SvelteKit hook layer so they can be unit-tested without spinning up a SvelteKit dev server or hitting a real Keycloak. Consumers (hooks.server.ts, /auth/callback, /auth/logout) are landed in subsequent sub-tickets. The Keycloak `westside-admin` confidential OIDC client was created on 2026-05-03 in the `westside-basketball` realm per `sop-keycloak-client-creation`. The client_secret is in `pal-e-deployments/overlays/westside-admin/prod/westside-admin-secrets.enc.yaml` (PR #147, QA-approved, awaiting consumer-merge gate). All 5 env vars (`KEYCLOAK_URL`, `KEYCLOAK_REALM`, `KEYCLOAK_CLIENT_ID`, `KEYCLOAK_CLIENT_SECRET`, `COOKIE_SIGNING_KEY`) will be available in the cluster Secret once #147 merges. **This sub-task does NOT block on #147** — code + unit tests can run without cluster env vars (tests stub them). **Implement from scratch using `jose`.** The reviewer of #2 verified: westside-contracts has zero Keycloak code (uses signed-token URLs); westside-app uses browser-side `keycloak-js` (adapter-static). No internal prior art for adapter-node Keycloak SSR — this lib is the new pattern reference for future SSR-Keycloak consumers. **Vitest not yet scaffolded.** Per review-1134-2026-05-03 ground-truth verification, `package.json` at HEAD has no vitest, no `test` script, no `vitest.config.ts`. This sub-task installs and configures vitest as part of the work — the test framework setup is bundled with the lib it tests, since they ship together as the testable unit. **Package manager: npm.** Repo has `package-lock.json`, no `pnpm-lock.yaml`. All commands use `npm`. ### File Targets Create: - `src/lib/server/keycloak.ts` — exports the four primitives below. - `src/lib/server/keycloak.test.ts` — unit tests (vitest). - `vitest.config.ts` — minimal config, server-side environment (default `node`), include `src/**/*.test.ts`. Update: - `package.json` — add `jose` (latest 5.x) to `dependencies`. Add `vitest` (latest 1.x or 2.x — match the SvelteKit version's compatible range) to `devDependencies`. Add scripts: `"test": "vitest run"` and `"test:watch": "vitest"`. Do NOT touch: - `src/hooks.server.ts` (sub-task 2) - `src/routes/auth/*` (sub-task 3) - `src/routes/(unauthorized)/*` (sub-task 4) - `src/app.d.ts` (sub-task 2 — `App.Locals` extension belongs with hook wiring) ### Public surface (informational, not part of acceptance — exact names are at the agent's discretion within the constraints) - `verifyKeycloakJwt(token: string): Promise<JwtPayload>` — fetches JWKS (cached), validates signature, audience (`westside-admin`), issuer (`${KEYCLOAK_URL}/realms/westside-basketball`), and exp. - `encryptCookiePayload(payload: object): string` — AES-256-GCM encrypt with `COOKIE_SIGNING_KEY` env var, returns ciphertext + iv + tag concatenated as URL-safe base64. - `decryptCookiePayload(ciphertext: string): object | null` — inverse; returns null on tamper or wrong key. - `generateOidcState(): { state: string; signed: string }` — random 256-bit state + HMAC-signed wrapper for the transient cookie. - `verifyOidcState(received: string, signedFromCookie: string): boolean` — constant-time compare. - `refreshTokensIfNeeded(tokens: { access_token, refresh_token, exp }): Promise<typeof tokens>` — calls Keycloak token endpoint with `refresh_token` grant when `exp - now < 30s`. ### Acceptance Criteria - [ ] `vitest` configured (`vitest.config.ts` exists, `npm test` exits 0 on a fresh checkout). - [ ] JWKS fetched from `${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/certs`, cached with **10-minute TTL**, single in-flight refresh (concurrent requests during refresh wait on the same promise — no thundering herd). - [ ] JWT verify rejects: tampered signature, wrong audience, wrong issuer, expired token, malformed JWT, token signed by a key not in the current JWKS. - [ ] AES-GCM encrypt/decrypt round-trips arbitrary JSON-serializable payloads. - [ ] AES-GCM decrypt returns `null` (not throws) when ciphertext is tampered, key is wrong, or input is malformed — caller distinguishes "no session" vs "tampered session" by null check. - [ ] OIDC `state` generated with `crypto.randomBytes(32)` (≥256 bits), HMAC-signed with `COOKIE_SIGNING_KEY`, fits inside a single Set-Cookie header. - [ ] State verification is constant-time (use `crypto.timingSafeEqual`). - [ ] Token refresh fires when `exp - now < 30s`, posts to `${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/token` with `grant_type=refresh_token` and confidential-client basic auth (`KEYCLOAK_CLIENT_ID:KEYCLOAK_CLIENT_SECRET`). - [ ] **JWKS-unreachable resilience** (added per scope review): if the JWKS endpoint is down AND there is no cached key set, `verifyKeycloakJwt` rejects with a typed error that callers can surface as HTTP 503 (not 401). If a cached key set exists and the cache is within TTL, fall back to it; if cache is stale but JWKS unreachable, log warning and reject. - [ ] No `console.log` or any other log call in this lib emits the JWT, refresh token, cookie ciphertext, or `COOKIE_SIGNING_KEY`. Verified via grep over the source. ### Test Expectations - Unit (`npm test`): - JWT verify: 7+ cases (valid token, tampered sig, wrong aud, wrong iss, expired, malformed, key-rotated-out). - AES-GCM: 4+ cases (round-trip, tampered ciphertext, wrong key, malformed input). - State: 3+ cases (round-trip, tampered signature, wrong-key). - JWKS cache: 3+ cases (cache hit, cache miss triggers fetch, single in-flight refresh deduplicates concurrent calls). - JWKS unreachable: 2+ cases (no cache → 503-typed error; stale cache + unreachable → 503-typed error). - **Token refresh: 3+ cases** (token within 30s of expiry → fetch new tokens; token > 30s remaining → no fetch; refresh-grant 4xx response → typed error). - Run command: `npm test -- src/lib/server/keycloak.test.ts` (or just `npm test` to run all). - All tests pass before PR opens. ### Constraints - Use `jose` (5.x) for JWT + JWKS. Do NOT pull `jsonwebtoken` (CVE history) or `node-jose` (slower). - Use Node's `node:crypto` Web Crypto subset for AES-GCM and HMAC. Do NOT pull `crypto-js` (browser-targeted). - Use SvelteKit's `event.cookies` API in consumers; this lib only returns ciphertext strings. - All exports must be pure-ish functions. The only stateful element is the JWKS cache singleton — encapsulate it inside the module, do not export the cache. - TypeScript strict mode (already on per scaffolding from #6). All exports typed. - Match existing eslint rules from the scaffold; no `// eslint-disable` lines. - Do NOT introduce a side-effect import that would fire on dev-server hot-reload (e.g., do not pre-warm the JWKS cache on module load). - **Package manager: npm** (repo has `package-lock.json`, no pnpm). Do NOT add a `pnpm-lock.yaml`. Run `npm install` after adding deps; commit the updated `package-lock.json`. - **Vitest version:** match the SvelteKit ecosystem's recommended pin. If `@sveltejs/kit` is at 2.x, vitest 1.x or 2.x is fine — pick one and stick to it. Do NOT pull a 0.x. ### Checklist - [ ] PR opened - [ ] Tests pass (`npm test` exits 0) - [ ] No unrelated changes - [ ] No tokens / signing keys logged (grep verified) - [ ] PR body includes funnel-auth review per `feedback_funnel_requires_auth` ### Related - `project-westside-admin` — project this affects - `forgejo_admin/westside-admin#2` — parent decomposition - `arch-dataflow-westside-admin` — Flow 1 (auth sequence) - `feedback_funnel_requires_auth` - `sop-keycloak-client-creation` — Keycloak side - `forgejo_admin/pal-e-deployments#147` — env-var landing PR (must merge before integration sub-tasks #15/#16 can validate end-to-end; this sub-task does NOT block on it) - `review-1134-2026-05-03` — scope review that surfaced the vitest + npm refinements
Author
Owner

Scope Review: NEEDS_REFINEMENT

Review note: review-1134-2026-05-03

Scope is technically correct and well-bounded; the single defect is a false premise about vitest being scaffolded that propagates into File Targets, Test Expectations, and the run command.

Blockers (must fix before move to todo):

  • [BODY] Vitest is not scaffolded — verified against package.json (no vitest dep, no test script, no vitest.config.*) and git log (#6/#9 only added SvelteKit + TS + ESLint). Update File Targets to include vitest.config.ts (create) and test/test:watch scripts in the package.json update list. Replace "(vitest, already set up in scaffolding from #6)" with an explicit instruction to install + configure it.
  • [BODY] Repo is on npm, not pnpm (package-lock.json exists, no pnpm-lock.yaml). Replace the conditional pnpm test … (or npm test -- if pnpm not adopted yet) with the concrete npm test -- src/lib/server/keycloak.test.ts.

Optional refinements:

  • [BODY] Add explicit test-count for refreshTokensIfNeeded (3+ cases) in Test Expectations.
  • [SCOPE] arch:keycloak label has no backing note (carry-over from review-1132-2026-05-03). arch-dataflow-westside-admin Flow 1 + arch-auth-westside-basketball likely cover this for now — Ava's call whether to create arch-keycloak.

Verified OK:

  • File targets src/lib/server/keycloak.ts, package.json, do-not-touch paths — all accurate against ground truth at /home/ldraney/westside-admin.
  • Traceability: story:admin-row-crud matches project Safety Constraints; story note exists; arch-dataflow-westside-admin Flow 1 covers this lib's full surface.
  • Decomposition: NOT needed — leaf of #2's 4-way split, primitives are cohesive, expected 8-12 min run.
  • Dependency chain: this sub-task does NOT block on pal-e-deployments#147 (per prompt context); siblings #15/#16/#17 do not block #14 starting.
## Scope Review: NEEDS_REFINEMENT Review note: `review-1134-2026-05-03` Scope is technically correct and well-bounded; the single defect is a false premise about vitest being scaffolded that propagates into File Targets, Test Expectations, and the run command. **Blockers (must fix before move to `todo`):** - `[BODY]` Vitest is **not** scaffolded — verified against `package.json` (no `vitest` dep, no `test` script, no `vitest.config.*`) and git log (`#6`/`#9` only added SvelteKit + TS + ESLint). Update File Targets to include `vitest.config.ts` (create) and `test`/`test:watch` scripts in the `package.json` update list. Replace "(vitest, already set up in scaffolding from #6)" with an explicit instruction to install + configure it. - `[BODY]` Repo is on **npm**, not pnpm (`package-lock.json` exists, no `pnpm-lock.yaml`). Replace the conditional `pnpm test … (or npm test -- if pnpm not adopted yet)` with the concrete `npm test -- src/lib/server/keycloak.test.ts`. **Optional refinements:** - `[BODY]` Add explicit test-count for `refreshTokensIfNeeded` (3+ cases) in Test Expectations. - `[SCOPE]` `arch:keycloak` label has no backing note (carry-over from `review-1132-2026-05-03`). `arch-dataflow-westside-admin` Flow 1 + `arch-auth-westside-basketball` likely cover this for now — Ava's call whether to create `arch-keycloak`. **Verified OK:** - File targets `src/lib/server/keycloak.ts`, `package.json`, `do-not-touch` paths — all accurate against ground truth at `/home/ldraney/westside-admin`. - Traceability: `story:admin-row-crud` matches project Safety Constraints; story note exists; `arch-dataflow-westside-admin` Flow 1 covers this lib's full surface. - Decomposition: NOT needed — leaf of #2's 4-way split, primitives are cohesive, expected 8-12 min run. - Dependency chain: this sub-task does NOT block on `pal-e-deployments#147` (per prompt context); siblings #15/#16/#17 do not block #14 starting.
Author
Owner

Scope Review (iter2): APPROVED

Review note: review-1134-2026-05-03-iter2

All four iter1 recommendations resolved:

  • [BODY] Vitest-not-scaffolded — RESOLVED. File Targets adds vitest.config.ts (create); package.json update adds vitest to devDependencies + test/test:watch scripts; new AC pins down vitest configured (npm test exits 0 on a fresh checkout).
  • [BODY] pnpm → npm — RESOLVED. Body now states package manager is npm explicitly; run command is npm test -- src/lib/server/keycloak.test.ts; Constraints forbid adding pnpm-lock.yaml.
  • [BODY] Explicit refresh test count — RESOLVED. Test Expectations now lists Token refresh: 3+ cases with the exact 3 scenarios suggested in iter1.
  • [SCOPE] arch-keycloak note — DEFERRED (acceptable; not a #14 blocker; carries forward as Ava call on parent #1132 or as a standalone scope item).

Ground truth re-verified at /home/ldraney/westside-admin (HEAD 803e943): no vitest dep, no vitest.config.*, package-lock.json present, pnpm-lock.yaml absent, src/lib/server/ exists with only .gitkeep, do-not-touch paths confirmed.

Recommendation: advance #1134 from backlog to todo and dispatch the dev agent. Sibling sub-tasks #15/#16/#17 remain blocked on #14 merging (and #16 additionally on #147).

## Scope Review (iter2): APPROVED Review note: `review-1134-2026-05-03-iter2` All four iter1 recommendations resolved: - `[BODY]` Vitest-not-scaffolded — RESOLVED. File Targets adds `vitest.config.ts` (create); `package.json` update adds `vitest` to devDependencies + `test`/`test:watch` scripts; new AC pins down `vitest configured (npm test exits 0 on a fresh checkout)`. - `[BODY]` pnpm → npm — RESOLVED. Body now states package manager is npm explicitly; run command is `npm test -- src/lib/server/keycloak.test.ts`; Constraints forbid adding `pnpm-lock.yaml`. - `[BODY]` Explicit refresh test count — RESOLVED. Test Expectations now lists `Token refresh: 3+ cases` with the exact 3 scenarios suggested in iter1. - `[SCOPE]` `arch-keycloak` note — DEFERRED (acceptable; not a #14 blocker; carries forward as Ava call on parent #1132 or as a standalone scope item). Ground truth re-verified at `/home/ldraney/westside-admin` (HEAD `803e943`): no `vitest` dep, no `vitest.config.*`, `package-lock.json` present, `pnpm-lock.yaml` absent, `src/lib/server/` exists with only `.gitkeep`, do-not-touch paths confirmed. **Recommendation:** advance #1134 from `backlog` to `todo` and dispatch the dev agent. Sibling sub-tasks #15/#16/#17 remain blocked on #14 merging (and #16 additionally on #147).
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
forgejo_admin/westside-admin#14
No description provided.