feat(auth): keycloak.ts SSR auth lib + vitest setup #18

Merged
forgejo_admin merged 2 commits from 14-keycloak-ts-lib into main 2026-05-03 15:53:52 +00:00

Summary

Foundation sub-task for the Keycloak admin gate (forgejo_admin/westside-admin#2). Implements the four crypto / OIDC primitives that the next three sub-tasks (#15 hooks.server.ts, #16 /auth/callback + /auth/logout, #17 (unauthorized) 403 page) all consume:

  • verifyKeycloakJwt — JWKS-cached JWT verify (10-min TTL, single in-flight refresh) with iss / aud / exp validation via jose 5.x.
  • encryptCookiePayload / decryptCookiePayload — AES-256-GCM via node:crypto. Decrypt returns null on tamper / wrong-key / malformed input (never throws).
  • generateOidcState / verifyOidcState — 256-bit random state, HMAC-signed wrapper, constant-time compare via timingSafeEqual.
  • refreshTokensIfNeeded — refreshes when exp - now < 30s via confidential-client basic auth on the realm /token endpoint.

Also bundles vitest setup (vitest.config.ts, npm test / npm test:watch scripts, vitest + @types/node devDeps) since the lib ships with its tests as one testable unit per the issue's vitest-not-yet-scaffolded note.

Funnel-auth review

Per feedback_funnel_requires_auth, every Tailscale-funnel-exposed admin route must enforce verified auth before merge. This PR is the cryptographic prerequisite for that gate — it does not itself expose any handler. The public surface is:

Export What it enforces
verifyKeycloakJwt Signature + iss + aud + exp on every funnel-bound request (called from hooks.server.ts in #15). Rejects tampered, wrong-aud, wrong-iss, expired, malformed, or key-rotated tokens with a typed JwtVerificationError callers map to HTTP 401.
decryptCookiePayload AES-256-GCM authentication tag rejects forged cookies (returns null, not throws — caller distinguishes "no session" from "tampered session" by null check).
verifyOidcState Constant-time HMAC comparison on the OIDC state param defeats CSRF + timing attacks against /auth/callback (consumed in #16).
JwksUnreachableError Lets hooks.server.ts return HTTP 503 (not 401) when Keycloak is down so an outage doesn't masquerade as auth failure.

No handler in this PR is reachable from the funnel; the lib is internal-only. The funnel-exposure assertion happens at merge of #15 / #16 / #17 once these primitives are wired in.

Changes

  • src/lib/server/keycloak.ts — 530 lines. The four primitives above plus typed errors (JwksUnreachableError, JwtVerificationError, TokenRefreshError, MissingEnvError) and the encapsulated JWKS cache singleton (10-min TTL, single in-flight refresh, no thundering herd, env-url-keyed so config rotation invalidates). Cold-cache + JWKS-down throws JwksUnreachableError; warm-cache + JWKS-down silently falls back; stale-cache + JWKS-down warns and rejects with no key material in the log.
  • src/lib/server/keycloak.test.ts — 24 vitest cases (issue requires 22+):
    • JWT verify: 7 (valid, tampered sig, wrong aud, wrong iss, expired, malformed, key rotated out)
    • AES-GCM: 4 (round-trip, tampered, wrong key, malformed)
    • OIDC state: 3 (round-trip, tampered HMAC, rotated key)
    • JWKS cache: 3 (cache hit, TTL refresh, single in-flight dedup)
    • JWKS-unreachable: 2 (cold-cache 503, stale-cache 503)
    • Token refresh: 3 (within 30s triggers, > 30s skips, 4xx -> typed error)
    • Source hygiene: 2 (no console.log; non-deterministic state)
  • vitest.config.ts — node environment, includes src/**/*.test.ts, globals: false.
  • package.json — adds jose@5.10.0 dependency, vitest@^2.1.9 + @types/node@22.19.17 devDependencies, test + test:watch scripts.
  • package-lock.json — regenerated.

Implementation note: I use createLocalJWKSet (not createRemoteJWKSet) and fetch the JWKS document myself. jose 5.x's createRemoteJWKSet bypasses globalThis.fetch and uses node:http / https directly, which makes JWKS-unreachable testing impossible without an actual network mock. Fetching the document ourselves keeps the code testable AND lets us surface "unreachable" vs "verify failure" cleanly.

Test Plan

  • npm install on a fresh checkout succeeds.
  • npm test exits 0 — 24/24 cases pass (verified locally).
  • npm run check passes — 0 errors, 0 warnings (verified locally).
  • npx eslint src/lib/server/keycloak.ts src/lib/server/keycloak.test.ts vitest.config.ts reports 0 errors (verified locally).
  • npx prettier --check src/lib/server/keycloak.ts src/lib/server/keycloak.test.ts vitest.config.ts package.json passes (verified locally).
  • Source grep: no console.log in keycloak.ts; the only log call is one console.warn in the stale-cache path that emits the cache age in milliseconds (an integer), not key material. Verified by the source hygiene test in keycloak.test.ts.

Review Checklist

  • Uses jose 5.x for JWT + JWKS (no jsonwebtoken, no node-jose).
  • Uses node:crypto Web Crypto subset for AES-GCM + HMAC (no crypto-js).
  • Package manager is npm; package-lock.json updated and committed; no pnpm-lock.yaml introduced.
  • JWKS cache: 10-minute TTL via JWKS_CACHE_TTL_MS; single in-flight refresh via jwksCache.inFlight.
  • decryptCookiePayload returns null on tamper / wrong-key / malformed input — never throws.
  • OIDC state generated with crypto.randomBytes(32) (256 bits).
  • State verification uses crypto.timingSafeEqual (constant-time) for both the embedded-state equality and HMAC equality.
  • JWKS-unreachable resilience: cold-cache + down -> typed error suitable for HTTP 503; warm-cache + down within TTL -> fall back to cached keys; stale-cache + down -> log warning and reject.
  • Token refresh threshold: TOKEN_REFRESH_THRESHOLD_S = 30. Sends grant_type=refresh_token with confidential-client basic auth (KEYCLOAK_CLIENT_ID:KEYCLOAK_CLIENT_SECRET) to the realm /token endpoint.
  • No console.log of JWT, refresh token, cookie ciphertext, or COOKIE_SIGNING_KEY (verified by automated source-hygiene test).
  • No // eslint-disable lines.
  • No any casts in exported types.
  • TypeScript strict mode (already on); npm run check is clean.
  • No side-effect import that fires on dev-server hot reload (JWKS cache is lazy — first call to verifyKeycloakJwt populates it).
  • JWKS cache singleton is encapsulated in the module — only __resetJwksCacheForTests and __setFetchForTests (both prefixed and documented as test-only) are exported.
  • No files outside scope touched: src/hooks.server.ts (#15), src/routes/auth/* (#16), src/routes/(unauthorized)/* (#17), src/app.d.ts (#15) all left as-is.
  • PR title uses feat(auth): prefix per the issue's title-prefix requirement.
  • Closes #14
  • Parent: forgejo_admin/westside-admin#2 (Keycloak cookie SSR auth + admin role gate)
  • Sibling sub-tasks that consume this lib:
    • forgejo_admin/westside-admin#15hooks.server.ts + app.d.ts
    • forgejo_admin/westside-admin#16/auth/callback + /auth/logout
    • forgejo_admin/westside-admin#17(unauthorized) 403 page
  • sop-keycloak-client-creation — Keycloak side (client created 2026-05-03 per #2 review)
  • arch-dataflow-westside-admin — Flow 1 (auth sequence)
  • feedback_funnel_requires_auth — convention this lib enforces
  • forgejo_admin/pal-e-deployments#147 — env-var landing PR (does NOT block this sub-task; integration sub-tasks #15 / #16 will need it before they can validate end-to-end)
  • review-1134-2026-05-03 — scope review that surfaced the vitest + npm refinements baked into this implementation
## Summary Foundation sub-task for the Keycloak admin gate (`forgejo_admin/westside-admin#2`). Implements the four crypto / OIDC primitives that the next three sub-tasks (`#15` hooks.server.ts, `#16` `/auth/callback` + `/auth/logout`, `#17` `(unauthorized)` 403 page) all consume: - `verifyKeycloakJwt` — JWKS-cached JWT verify (10-min TTL, single in-flight refresh) with iss / aud / exp validation via `jose` 5.x. - `encryptCookiePayload` / `decryptCookiePayload` — AES-256-GCM via `node:crypto`. Decrypt returns `null` on tamper / wrong-key / malformed input (never throws). - `generateOidcState` / `verifyOidcState` — 256-bit random state, HMAC-signed wrapper, constant-time compare via `timingSafeEqual`. - `refreshTokensIfNeeded` — refreshes when `exp - now < 30s` via confidential-client basic auth on the realm `/token` endpoint. Also bundles vitest setup (`vitest.config.ts`, `npm test` / `npm test:watch` scripts, `vitest` + `@types/node` devDeps) since the lib ships with its tests as one testable unit per the issue's vitest-not-yet-scaffolded note. ## Funnel-auth review Per `feedback_funnel_requires_auth`, every Tailscale-funnel-exposed admin route must enforce verified auth before merge. This PR is the cryptographic prerequisite for that gate — it does not itself expose any handler. The public surface is: | Export | What it enforces | |---|---| | `verifyKeycloakJwt` | Signature + iss + aud + exp on every funnel-bound request (called from `hooks.server.ts` in `#15`). Rejects tampered, wrong-aud, wrong-iss, expired, malformed, or key-rotated tokens with a typed `JwtVerificationError` callers map to HTTP 401. | | `decryptCookiePayload` | AES-256-GCM authentication tag rejects forged cookies (returns `null`, not throws — caller distinguishes "no session" from "tampered session" by null check). | | `verifyOidcState` | Constant-time HMAC comparison on the OIDC `state` param defeats CSRF + timing attacks against `/auth/callback` (consumed in `#16`). | | `JwksUnreachableError` | Lets `hooks.server.ts` return HTTP 503 (not 401) when Keycloak is down so an outage doesn't masquerade as auth failure. | No handler in this PR is reachable from the funnel; the lib is internal-only. The funnel-exposure assertion happens at merge of `#15` / `#16` / `#17` once these primitives are wired in. ## Changes - `src/lib/server/keycloak.ts` — 530 lines. The four primitives above plus typed errors (`JwksUnreachableError`, `JwtVerificationError`, `TokenRefreshError`, `MissingEnvError`) and the encapsulated JWKS cache singleton (10-min TTL, single in-flight refresh, no thundering herd, env-url-keyed so config rotation invalidates). Cold-cache + JWKS-down throws `JwksUnreachableError`; warm-cache + JWKS-down silently falls back; stale-cache + JWKS-down warns and rejects with no key material in the log. - `src/lib/server/keycloak.test.ts` — 24 vitest cases (issue requires 22+): - JWT verify: 7 (valid, tampered sig, wrong aud, wrong iss, expired, malformed, key rotated out) - AES-GCM: 4 (round-trip, tampered, wrong key, malformed) - OIDC state: 3 (round-trip, tampered HMAC, rotated key) - JWKS cache: 3 (cache hit, TTL refresh, single in-flight dedup) - JWKS-unreachable: 2 (cold-cache 503, stale-cache 503) - Token refresh: 3 (within 30s triggers, > 30s skips, 4xx -> typed error) - Source hygiene: 2 (no `console.log`; non-deterministic state) - `vitest.config.ts` — node environment, includes `src/**/*.test.ts`, `globals: false`. - `package.json` — adds `jose@5.10.0` dependency, `vitest@^2.1.9` + `@types/node@22.19.17` devDependencies, `test` + `test:watch` scripts. - `package-lock.json` — regenerated. Implementation note: I use `createLocalJWKSet` (not `createRemoteJWKSet`) and fetch the JWKS document myself. `jose` 5.x's `createRemoteJWKSet` bypasses `globalThis.fetch` and uses `node:http` / `https` directly, which makes JWKS-unreachable testing impossible without an actual network mock. Fetching the document ourselves keeps the code testable AND lets us surface "unreachable" vs "verify failure" cleanly. ## Test Plan - [ ] `npm install` on a fresh checkout succeeds. - [ ] `npm test` exits 0 — 24/24 cases pass (verified locally). - [ ] `npm run check` passes — 0 errors, 0 warnings (verified locally). - [ ] `npx eslint src/lib/server/keycloak.ts src/lib/server/keycloak.test.ts vitest.config.ts` reports 0 errors (verified locally). - [ ] `npx prettier --check src/lib/server/keycloak.ts src/lib/server/keycloak.test.ts vitest.config.ts package.json` passes (verified locally). - [ ] Source grep: no `console.log` in `keycloak.ts`; the only log call is one `console.warn` in the stale-cache path that emits the cache age in milliseconds (an integer), not key material. Verified by the `source hygiene` test in `keycloak.test.ts`. ## Review Checklist - [x] Uses `jose` 5.x for JWT + JWKS (no `jsonwebtoken`, no `node-jose`). - [x] Uses `node:crypto` Web Crypto subset for AES-GCM + HMAC (no `crypto-js`). - [x] Package manager is `npm`; `package-lock.json` updated and committed; no `pnpm-lock.yaml` introduced. - [x] JWKS cache: 10-minute TTL via `JWKS_CACHE_TTL_MS`; single in-flight refresh via `jwksCache.inFlight`. - [x] `decryptCookiePayload` returns `null` on tamper / wrong-key / malformed input — never throws. - [x] OIDC state generated with `crypto.randomBytes(32)` (256 bits). - [x] State verification uses `crypto.timingSafeEqual` (constant-time) for both the embedded-state equality and HMAC equality. - [x] JWKS-unreachable resilience: cold-cache + down -> typed error suitable for HTTP 503; warm-cache + down within TTL -> fall back to cached keys; stale-cache + down -> log warning and reject. - [x] Token refresh threshold: `TOKEN_REFRESH_THRESHOLD_S = 30`. Sends `grant_type=refresh_token` with confidential-client basic auth (`KEYCLOAK_CLIENT_ID:KEYCLOAK_CLIENT_SECRET`) to the realm `/token` endpoint. - [x] No `console.log` of JWT, refresh token, cookie ciphertext, or `COOKIE_SIGNING_KEY` (verified by automated source-hygiene test). - [x] No `// eslint-disable` lines. - [x] No `any` casts in exported types. - [x] TypeScript strict mode (already on); `npm run check` is clean. - [x] No side-effect import that fires on dev-server hot reload (JWKS cache is lazy — first call to `verifyKeycloakJwt` populates it). - [x] JWKS cache singleton is encapsulated in the module — only `__resetJwksCacheForTests` and `__setFetchForTests` (both prefixed and documented as test-only) are exported. - [x] No files outside scope touched: `src/hooks.server.ts` (#15), `src/routes/auth/*` (#16), `src/routes/(unauthorized)/*` (#17), `src/app.d.ts` (#15) all left as-is. - [x] PR title uses `feat(auth):` prefix per the issue's title-prefix requirement. ## Related Notes - Closes #14 - Parent: `forgejo_admin/westside-admin#2` (Keycloak cookie SSR auth + admin role gate) - Sibling sub-tasks that consume this lib: - `forgejo_admin/westside-admin#15` — `hooks.server.ts` + `app.d.ts` - `forgejo_admin/westside-admin#16` — `/auth/callback` + `/auth/logout` - `forgejo_admin/westside-admin#17` — `(unauthorized)` 403 page - `sop-keycloak-client-creation` — Keycloak side (client created 2026-05-03 per #2 review) - `arch-dataflow-westside-admin` — Flow 1 (auth sequence) - `feedback_funnel_requires_auth` — convention this lib enforces - `forgejo_admin/pal-e-deployments#147` — env-var landing PR (does NOT block this sub-task; integration sub-tasks `#15` / `#16` will need it before they can validate end-to-end) - `review-1134-2026-05-03` — scope review that surfaced the vitest + npm refinements baked into this implementation
feat(auth): keycloak.ts SSR auth lib + vitest setup
All checks were successful
ci/woodpecker/pr/woodpecker Pipeline was successful
41578ff0a2
Foundation sub-task for the Keycloak admin gate (#2). Implements the
crypto + OIDC primitives that hooks.server.ts (#15), /auth/callback +
/auth/logout (#16), and the (unauthorized) 403 page (#17) all consume.

- src/lib/server/keycloak.ts:
  * verifyKeycloakJwt: JWKS-cached JWT verify (10-min TTL, single
    in-flight refresh) with iss/aud/exp validation via jose 5.x.
  * encryptCookiePayload / decryptCookiePayload: AES-256-GCM via
    node:crypto. Decrypt returns null on tamper / wrong-key /
    malformed input (never throws to caller).
  * generateOidcState / verifyOidcState: 256-bit random state, HMAC-
    signed wrapper, constant-time comparison via timingSafeEqual.
  * refreshTokensIfNeeded: refreshes when exp - now < 30s using
    confidential-client basic auth (KEYCLOAK_CLIENT_ID:SECRET).
  * Typed errors (JwksUnreachableError, JwtVerificationError,
    TokenRefreshError, MissingEnvError) so callers map to
    HTTP 503 / 401 cleanly.
  * JWKS-unreachable resilience: cold cache + JWKS down -> 503-typed
    error; warm cache + JWKS down -> silent fall-back to cached keys;
    stale cache + JWKS down -> warn + reject (no key material logged).

- src/lib/server/keycloak.test.ts: 24 vitest cases covering JWT
  verify (7), AES-GCM (4), OIDC state (3), JWKS cache (3),
  JWKS-unreachable (2), token refresh (3), source hygiene (2).
  Tests stub fetch in-process; no live Keycloak required.

- vitest.config.ts: node environment, includes src/**/*.test.ts.

- package.json: jose 5.10.0 dep; vitest 2.x + @types/node devDeps;
  test + test:watch scripts.

Closes #14

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author
Owner

PR #18 Review

DOMAIN REVIEW

Tech stack: TypeScript / SvelteKit server-side / jose 5.x / node:crypto. Cryptographic correctness is the dominant concern.

Source: src/lib/server/keycloak.ts

Strong points (verified against the raw file at branch 14-keycloak-ts-lib):

  • AES-256-GCM round trip is implemented correctly: 12-byte IV from randomBytes, 16-byte tag via getAuthTag(), layout iv || ciphertext || tag in url-safe base64. decryptCookiePayload returns null on tamper, wrong key, malformed input, AND non-JSON plaintext (catches both decipher.final() and JSON.parse failures in one try/catch). Matches the SOP requirement that decrypt never throws to the caller.
  • verifyOidcState uses timingSafeEqual for both the embedded-state comparison (line 449) and the HMAC comparison (line 463). Length-checks before each timingSafeEqual (required — that function throws on length mismatch). Constant-time comparison is correctly implemented.
  • Token refresh uses confidential-client basic auth (line 501): Authorization: Basic base64(client_id:client_secret) with grant_type=refresh_token body. Matches the SOP.
  • Refresh threshold (exp - now < 30s) is correctly checked using Math.floor(Date.now()/1000) and integer comparison (line 494-495).
  • Domain-separated keys: deriveAesKey() and deriveHmacKey() both SHA-256 over COOKIE_SIGNING_KEY, but deriveHmacKey prefixes a domain string 'hmac-state ' so a leak of one doesn't leak the other. Solid.
  • Typed error classes (JwksUnreachableError, JwtVerificationError, TokenRefreshError, MissingEnvError) with explicit code discriminants — easy for callers to map to HTTP status codes.
  • verifyKeycloakJwt uses jose's native joseErrors.JWTExpired, JWTClaimValidationFailed, JWSSignatureVerificationFailed, JWKSNoMatchingKey, JWSInvalid, JWTInvalid discrimination. Clean error mapping.
  • Refresh error path on line 519-521 correctly logs only the status, not the response body, which "may echo tokens" — explicit comment confirming the security-conscious choice.
  • console.warn on line 242-244 reports only ageMs, never any key material.
  • verifyKeycloakJwt short-circuits empty/non-string tokens BEFORE getJwks() (line 277-279), avoiding wasted JWKS fetches on garbage input.

createLocalJWKSet + custom fetch (flagged item 1):

The dev's tradeoff is documented inline (lines 212-215): "jose's createRemoteJWKSet bypasses globalThis.fetch (uses node:http/https directly), so to keep our impl testable AND to surface 'unreachable' vs 'verify failure' cleanly we always fetch + parse here, then feed the keys to createLocalJWKSet."

Verified against SOP:

  • 10-min TTL: JWKS_CACHE_TTL_MS = 10 * 60 * 1000. PASS.
  • Single in-flight refresh: lines 206-256 — concurrent callers share jwksCache.inFlight. JS single-threaded synchronous execution between line 210 (IIFE creation) and line 254 (inFlight assignment) means there's no race window for a duplicate to slip in. Implementation is correct.
  • Cold cache + unreachable: line 246 throws JwksUnreachableError. Tested.
  • Warm cache + unreachable (still within TTL): lines 235-240 silently fall back to cached JWKS. Implemented but not tested — see BLOCKERS section below.
  • Stale cache + unreachable: lines 241-245 emit a sanitized console.warn and throw JwksUnreachableError. Tested.

Security check on local-set injection vector: the JWKS doc passed to createLocalJWKSet (line 227) comes from fetchImpl(jwksUrl()) where jwksUrl() is constructed from KEYCLOAK_URL and KEYCLOAK_REALM env vars — NOT from any user-controlled input. The user-controlled token parameter (line 276) is verified against the JWKS but never feeds back into the JWKS construction. No injection vector. PASS.

@types/node@22.19.17 devDependency (flagged item 2):

Verified against scaffold package.json at branch main: @types/node is genuinely absent from devDependencies AND from the lockfile (no transitive resolve). The new keycloak.ts uses process.env, Buffer, and import { ... } from 'node:crypto'svelte-check --tsconfig ./tsconfig.json with strict: true would fail without Node ambient types. So adding it is justified.

However the version pin format is inconsistent: every other devDependency in package.json uses caret (^), but @types/node is pinned exactly (22.19.17). Node 22 is the runtime LTS (matches the node:22-alpine Dockerfile from #7), so ^22.19.17 would still constrain to Node 22 minor/patch updates and stay aligned with the rest of the ecosystem. Recommend ^22.19.17. Nit, not a blocker.

Test suite (src/lib/server/keycloak.test.ts):

  • 24 it(...) cases confirmed (no .skip, .todo, or .only). Matches PR body.
  • All tests mocked via __setFetchForTests — no real network calls.
  • beforeEach/afterEach reset cache + fetch override. Hermetic.
  • Env vars set in beforeEach are dummy fixtures ('unit-test-client-secret', 'unit-test-signing-key-with-enough-entropy-1234567890'). No real secrets.
  • Tamper test for AES-GCM flips a character in the ciphertext base64 and asserts toBeNull() — confirms decrypt does not throw.
  • Signature-tampering test mutates actual signature bytes (not just uses a different key) and asserts JwtVerificationError.
  • Token-refresh test inspects the Authorization header for exact Basic ${base64(client_id:client_secret)} format and parses URLSearchParams body for grant_type=refresh_token. Matches SOP requirement for confidential-client auth verification.
  • Source-hygiene test reads keycloak.ts via fs.readFile, strips comments, then asserts expect(stripped).not.toMatch(/console\.log\s*\(/) and applies regex /access_token|refresh_token|JWT|ciphertext|signing/i against any remaining log statements. Catches tokens.access_token, tokens.refresh_token, JWT, ciphertext, AND anything with "signing" in it (so COOKIE_SIGNING_KEY is covered). Solid hygiene gate.
  • No as any, no // eslint-disable lines. PASS strict-mode check.

Test execution not independently verified. As a read-only reviewer with no Bash access, I cannot run npm test to confirm all 24 cases actually pass. The dev claims they pass; the test code structure looks correct and uses well-formed vitest constructs, but the verdict below is contingent on CI confirming green. If npm install && npm test is wired into CI, that's covered.

BLOCKERS

  1. Test coverage gap: warm-cache fallback path is implemented but untested. Lines 235-240 of keycloak.ts implement the "warm cache + JWKS unreachable" silent-fallback path required by the SOP (cold/warm/stale resilience). Two tests cover cold-cache rejection and stale-cache rejection, but no test exercises the case where JWKS becomes unreachable while the cache is still fresh and asserts that verification still succeeds against the cached keys. This is the most operationally important resilience path (it's what keeps the admin app working during transient Keycloak hiccups) and per the SOP's feedback_funnel_requires_auth posture it must be verifiable. Add an it('falls back to warm cache when JWKS becomes unreachable within TTL') test.

NITS

  1. Concurrent-dedup test assertion is permissive. expect(jwksFetches).toBeLessThanOrEqual(2) allows the test to pass even if a regression introduces a second JWKS fetch under contention. Given the implementation guarantees exactly 1 fetch (single-threaded JS + IIFE assignment ordering), the assertion should be tightened to toBe(1). The current bound would silently mask a real bug.
  2. @types/node version pin format is inconsistent. Use ^22.19.17 for parity with the rest of devDependencies (every other dep uses caret). Aligns with Node 22 LTS / node:22-alpine runtime.
  3. Module-load-time fetch binding. let fetchImpl: typeof fetch = globalThis.fetch.bind(globalThis) (line 178) executes at module import time. Node 22 always has globalThis.fetch, so this is safe in practice; under pathological test harness scenarios (e.g., a runner that polyfills fetch after import) it could surprise. Defensive fix: lazy-initialize on first use, or wrap in a getter. Low priority.
  4. Doc-comment claim about getJwks() is slightly off. The JSDoc on line 187 says "cache hit, fresh: return cached fn synchronously (resolved promise)." It's still wrapped in a Promise (the fn is async), so "synchronously" is a minor misnomer — the resolution is microtask-immediate, not actually synchronous. Cosmetic.
  5. './keycloak.js' import in test file. Tests import via './keycloak.js' (Node ESM convention) — fine, but worth noting that vitest.config.ts doesn't set any explicit moduleResolution override, so this relies on tsconfig.json's "moduleResolution": "bundler" and Vite's resolver. Confirm CI runs npm test cleanly to validate.
  6. PR body says "(530 lines)" for keycloak.ts. Source is 530 lines — matches. No issue, just confirming.

SOP COMPLIANCE

  • Branch named after issue: 14-keycloak-ts-lib follows {issue-number}-{kebab-case-purpose} convention.
  • PR body has ## Summary, ## Changes, ## Test Plan, ## Review Checklist, ## Related Notes — all present, plus a dedicated ## Funnel-auth review section per feedback_funnel_requires_auth.
  • Funnel-auth review per feedback_funnel_requires_auth: explicitly addressed. The lib itself does not expose any funnel-bound handler — it's a foundation primitive — and the PR body makes this explicit. Subsequent PRs (#15, #16) will be where actual funnel-bound auth gating lands; their reviews must verify those handlers wire verifyKeycloakJwt + role checks in.
  • Out-of-bounds files untouched: confirmed via diff. src/hooks.server.ts, src/routes/auth/*, src/routes/(unauthorized)/*, src/app.d.ts are NOT modified by this PR. Scope discipline: clean.
  • No secrets / .env / credentials committed. Env values in tests are dummy fixtures ('unit-test-client-secret', etc.). Source uses requireEnv() lazily.
  • No scope creep: 5 files changed, all directly part of the foundation (lib + test + vitest config + dependency manifests).
  • No feedback_yaml_parse_validation concerns — this PR doesn't touch any YAML.
  • sop-keycloak-client-creation: this PR consumes the contract (KEYCLOAK_URL, KEYCLOAK_REALM, KEYCLOAK_CLIENT_ID, KEYCLOAK_CLIENT_SECRET) but doesn't itself create the client. Issue #2 owns that wiring. The lib is correctly designed to be agnostic to whether the client exists yet — requireEnv throws MissingEnvError only at first call, not at module load.

PROCESS OBSERVATIONS

  • Strong DORA hygiene. Foundation-first decomposition (#14#15, #16, #17#2) means the cryptographic primitives land tested and reviewable in isolation, before any HTTP handler can call them. This is the right shape — change failure rate stays low because the security-critical code is unit-testable and unit-tested.
  • Funnel-auth posture: this PR alone does NOT enable any funnel-exposed handler. Per feedback_funnel_requires_auth, the gate fires on the PRs that consume this lib (#15 hooks.server.ts, #16 callback/logout). Reviewers of those PRs MUST verify that every funnel-bound route invokes verifyKeycloakJwt + role gate before serving any data. Cite this PR as the cryptographic prerequisite when reviewing them.
  • Test execution is dev-attested, not reviewer-verified. I could not run npm test from a read-only review surface. Recommend CI gate (.woodpecker.yaml step running npm test) before this lib is consumed by #15 / #16. If that's already in flight via #11 / #13, ignore.
  • Documentation gap to flag for /update-docs: the feedback_funnel_requires_auth convention should reference this PR as the canonical example of "foundation-only crypto PR that itself doesn't expose a handler" — useful precedent for future foundation-then-handler decomposition.

VERDICT: NOT APPROVED

Single blocker: the warm-cache fallback path (the operationally most important JWKS resilience case) is implemented but has zero test coverage. Add the missing test, tighten the concurrent-dedup assertion to toBe(1) while you're in there (nit #1), and re-request review. Everything else is solid — crypto choices, error typing, scope discipline, SOP compliance are all clean.

## PR #18 Review ### DOMAIN REVIEW Tech stack: TypeScript / SvelteKit server-side / `jose` 5.x / `node:crypto`. Cryptographic correctness is the dominant concern. **Source: `src/lib/server/keycloak.ts`** Strong points (verified against the raw file at branch `14-keycloak-ts-lib`): - AES-256-GCM round trip is implemented correctly: 12-byte IV from `randomBytes`, 16-byte tag via `getAuthTag()`, layout `iv || ciphertext || tag` in url-safe base64. `decryptCookiePayload` returns `null` on tamper, wrong key, malformed input, AND non-JSON plaintext (catches both `decipher.final()` and `JSON.parse` failures in one try/catch). Matches the SOP requirement that decrypt never throws to the caller. - `verifyOidcState` uses `timingSafeEqual` for both the embedded-state comparison (line 449) and the HMAC comparison (line 463). Length-checks before each `timingSafeEqual` (required — that function throws on length mismatch). Constant-time comparison is correctly implemented. - Token refresh uses confidential-client basic auth (line 501): `Authorization: Basic base64(client_id:client_secret)` with `grant_type=refresh_token` body. Matches the SOP. - Refresh threshold (`exp - now < 30s`) is correctly checked using `Math.floor(Date.now()/1000)` and integer comparison (line 494-495). - Domain-separated keys: `deriveAesKey()` and `deriveHmacKey()` both SHA-256 over `COOKIE_SIGNING_KEY`, but `deriveHmacKey` prefixes a domain string `'hmac-state '` so a leak of one doesn't leak the other. Solid. - Typed error classes (`JwksUnreachableError`, `JwtVerificationError`, `TokenRefreshError`, `MissingEnvError`) with explicit `code` discriminants — easy for callers to map to HTTP status codes. - `verifyKeycloakJwt` uses `jose`'s native `joseErrors.JWTExpired`, `JWTClaimValidationFailed`, `JWSSignatureVerificationFailed`, `JWKSNoMatchingKey`, `JWSInvalid`, `JWTInvalid` discrimination. Clean error mapping. - Refresh error path on line 519-521 correctly logs only the status, not the response body, which "may echo tokens" — explicit comment confirming the security-conscious choice. - `console.warn` on line 242-244 reports only `ageMs`, never any key material. - `verifyKeycloakJwt` short-circuits empty/non-string tokens BEFORE `getJwks()` (line 277-279), avoiding wasted JWKS fetches on garbage input. **`createLocalJWKSet` + custom fetch (flagged item 1):** The dev's tradeoff is documented inline (lines 212-215): "jose's `createRemoteJWKSet` bypasses `globalThis.fetch` (uses `node:http/https` directly), so to keep our impl testable AND to surface 'unreachable' vs 'verify failure' cleanly we always fetch + parse here, then feed the keys to `createLocalJWKSet`." Verified against SOP: - 10-min TTL: `JWKS_CACHE_TTL_MS = 10 * 60 * 1000`. PASS. - Single in-flight refresh: lines 206-256 — concurrent callers share `jwksCache.inFlight`. JS single-threaded synchronous execution between line 210 (IIFE creation) and line 254 (inFlight assignment) means there's no race window for a duplicate to slip in. Implementation is correct. - Cold cache + unreachable: line 246 throws `JwksUnreachableError`. Tested. - Warm cache + unreachable (still within TTL): lines 235-240 silently fall back to cached JWKS. **Implemented but not tested** — see BLOCKERS section below. - Stale cache + unreachable: lines 241-245 emit a sanitized `console.warn` and throw `JwksUnreachableError`. Tested. Security check on local-set injection vector: the JWKS doc passed to `createLocalJWKSet` (line 227) comes from `fetchImpl(jwksUrl())` where `jwksUrl()` is constructed from `KEYCLOAK_URL` and `KEYCLOAK_REALM` env vars — NOT from any user-controlled input. The user-controlled `token` parameter (line 276) is verified against the JWKS but never feeds back into the JWKS construction. No injection vector. PASS. **`@types/node@22.19.17` devDependency (flagged item 2):** Verified against scaffold `package.json` at branch `main`: `@types/node` is genuinely absent from devDependencies AND from the lockfile (no transitive resolve). The new `keycloak.ts` uses `process.env`, `Buffer`, and `import { ... } from 'node:crypto'` — `svelte-check --tsconfig ./tsconfig.json` with `strict: true` would fail without Node ambient types. So adding it is justified. However the version pin format is inconsistent: every other devDependency in `package.json` uses caret (`^`), but `@types/node` is pinned exactly (`22.19.17`). Node 22 is the runtime LTS (matches the `node:22-alpine` Dockerfile from #7), so `^22.19.17` would still constrain to Node 22 minor/patch updates and stay aligned with the rest of the ecosystem. Recommend `^22.19.17`. Nit, not a blocker. **Test suite (`src/lib/server/keycloak.test.ts`):** - 24 `it(...)` cases confirmed (no `.skip`, `.todo`, or `.only`). Matches PR body. - All tests mocked via `__setFetchForTests` — no real network calls. - `beforeEach`/`afterEach` reset cache + fetch override. Hermetic. - Env vars set in `beforeEach` are dummy fixtures (`'unit-test-client-secret'`, `'unit-test-signing-key-with-enough-entropy-1234567890'`). No real secrets. - Tamper test for AES-GCM flips a character in the ciphertext base64 and asserts `toBeNull()` — confirms decrypt does not throw. - Signature-tampering test mutates actual signature bytes (not just uses a different key) and asserts `JwtVerificationError`. - Token-refresh test inspects the Authorization header for exact `Basic ${base64(client_id:client_secret)}` format and parses URLSearchParams body for `grant_type=refresh_token`. Matches SOP requirement for confidential-client auth verification. - Source-hygiene test reads `keycloak.ts` via `fs.readFile`, strips comments, then asserts `expect(stripped).not.toMatch(/console\.log\s*\(/)` and applies regex `/access_token|refresh_token|JWT|ciphertext|signing/i` against any remaining log statements. Catches `tokens.access_token`, `tokens.refresh_token`, `JWT`, `ciphertext`, AND anything with "signing" in it (so `COOKIE_SIGNING_KEY` is covered). Solid hygiene gate. - No `as any`, no `// eslint-disable` lines. PASS strict-mode check. **Test execution not independently verified.** As a read-only reviewer with no Bash access, I cannot run `npm test` to confirm all 24 cases actually pass. The dev claims they pass; the test code structure looks correct and uses well-formed vitest constructs, but the verdict below is contingent on CI confirming green. If `npm install && npm test` is wired into CI, that's covered. ### BLOCKERS 1. **Test coverage gap: warm-cache fallback path is implemented but untested.** Lines 235-240 of `keycloak.ts` implement the "warm cache + JWKS unreachable" silent-fallback path required by the SOP (cold/warm/stale resilience). Two tests cover cold-cache rejection and stale-cache rejection, but no test exercises the case where JWKS becomes unreachable while the cache is still fresh and asserts that verification still succeeds against the cached keys. This is the most operationally important resilience path (it's what keeps the admin app working during transient Keycloak hiccups) and per the SOP's `feedback_funnel_requires_auth` posture it must be verifiable. Add an `it('falls back to warm cache when JWKS becomes unreachable within TTL')` test. ### NITS 1. **Concurrent-dedup test assertion is permissive.** `expect(jwksFetches).toBeLessThanOrEqual(2)` allows the test to pass even if a regression introduces a second JWKS fetch under contention. Given the implementation guarantees exactly 1 fetch (single-threaded JS + IIFE assignment ordering), the assertion should be tightened to `toBe(1)`. The current bound would silently mask a real bug. 2. **`@types/node` version pin format is inconsistent.** Use `^22.19.17` for parity with the rest of `devDependencies` (every other dep uses caret). Aligns with Node 22 LTS / `node:22-alpine` runtime. 3. **Module-load-time fetch binding.** `let fetchImpl: typeof fetch = globalThis.fetch.bind(globalThis)` (line 178) executes at module import time. Node 22 always has `globalThis.fetch`, so this is safe in practice; under pathological test harness scenarios (e.g., a runner that polyfills fetch after import) it could surprise. Defensive fix: lazy-initialize on first use, or wrap in a getter. Low priority. 4. **Doc-comment claim about `getJwks()` is slightly off.** The JSDoc on line 187 says "cache hit, fresh: return cached fn synchronously (resolved promise)." It's still wrapped in a Promise (the fn is `async`), so "synchronously" is a minor misnomer — the resolution is microtask-immediate, not actually synchronous. Cosmetic. 5. **`'./keycloak.js'` import in test file.** Tests import via `'./keycloak.js'` (Node ESM convention) — fine, but worth noting that `vitest.config.ts` doesn't set any explicit moduleResolution override, so this relies on `tsconfig.json`'s `"moduleResolution": "bundler"` and Vite's resolver. Confirm CI runs `npm test` cleanly to validate. 6. **PR body says "(530 lines)" for `keycloak.ts`.** Source is 530 lines — matches. No issue, just confirming. ### SOP COMPLIANCE - [x] Branch named after issue: `14-keycloak-ts-lib` follows `{issue-number}-{kebab-case-purpose}` convention. - [x] PR body has `## Summary`, `## Changes`, `## Test Plan`, `## Review Checklist`, `## Related Notes` — all present, plus a dedicated `## Funnel-auth review` section per `feedback_funnel_requires_auth`. - [x] Funnel-auth review per `feedback_funnel_requires_auth`: explicitly addressed. The lib itself does not expose any funnel-bound handler — it's a foundation primitive — and the PR body makes this explicit. Subsequent PRs (#15, #16) will be where actual funnel-bound auth gating lands; their reviews must verify those handlers wire `verifyKeycloakJwt` + role checks in. - [x] Out-of-bounds files untouched: confirmed via diff. `src/hooks.server.ts`, `src/routes/auth/*`, `src/routes/(unauthorized)/*`, `src/app.d.ts` are NOT modified by this PR. Scope discipline: clean. - [x] No secrets / .env / credentials committed. Env values in tests are dummy fixtures (`'unit-test-client-secret'`, etc.). Source uses `requireEnv()` lazily. - [x] No scope creep: 5 files changed, all directly part of the foundation (lib + test + vitest config + dependency manifests). - [x] No `feedback_yaml_parse_validation` concerns — this PR doesn't touch any YAML. - [x] `sop-keycloak-client-creation`: this PR consumes the contract (`KEYCLOAK_URL`, `KEYCLOAK_REALM`, `KEYCLOAK_CLIENT_ID`, `KEYCLOAK_CLIENT_SECRET`) but doesn't itself create the client. Issue #2 owns that wiring. The lib is correctly designed to be agnostic to whether the client exists yet — `requireEnv` throws `MissingEnvError` only at first call, not at module load. ### PROCESS OBSERVATIONS - **Strong DORA hygiene.** Foundation-first decomposition (#14 → #15, #16, #17 → #2) means the cryptographic primitives land tested and reviewable in isolation, before any HTTP handler can call them. This is the right shape — change failure rate stays low because the security-critical code is unit-testable and unit-tested. - **Funnel-auth posture: this PR alone does NOT enable any funnel-exposed handler.** Per `feedback_funnel_requires_auth`, the gate fires on the PRs that consume this lib (#15 hooks.server.ts, #16 callback/logout). Reviewers of those PRs MUST verify that every funnel-bound route invokes `verifyKeycloakJwt` + role gate before serving any data. Cite this PR as the cryptographic prerequisite when reviewing them. - **Test execution is dev-attested, not reviewer-verified.** I could not run `npm test` from a read-only review surface. Recommend CI gate (`.woodpecker.yaml` step running `npm test`) before this lib is consumed by #15 / #16. If that's already in flight via #11 / #13, ignore. - **Documentation gap to flag for `/update-docs`:** the `feedback_funnel_requires_auth` convention should reference this PR as the canonical example of "foundation-only crypto PR that itself doesn't expose a handler" — useful precedent for future foundation-then-handler decomposition. ### VERDICT: NOT APPROVED Single blocker: the warm-cache fallback path (the operationally most important JWKS resilience case) is implemented but has zero test coverage. Add the missing test, tighten the concurrent-dedup assertion to `toBe(1)` while you're in there (nit #1), and re-request review. Everything else is solid — crypto choices, error typing, scope discipline, SOP compliance are all clean.
fix(qa): address PR #18 blocker — warm-cache JWKS fallback test + nits
All checks were successful
ci/woodpecker/pr/woodpecker Pipeline was successful
39eaf4e7db
QA verdict on PR #18 (comment 15937) flagged one blocker and three nits.
This commit addresses all four in the same focused diff.

Blocker
- Add `falls back to warm cache when JWKS becomes unreachable within TTL`
  test in src/lib/server/keycloak.test.ts. Pre-warms the JWKS cache, advances
  time to within the TTL window, switches the stubbed fetch to fail, then
  verifies a token signed by the warm-cache key. Asserts: verification
  succeeds, no JwksUnreachableError, and no additional JWKS fetch fires
  (the cache-hit fast path short-circuits before fetch is invoked). Also
  re-confirms the failing fetch wiring by tipping past the TTL and asserting
  JwksUnreachableError DOES fire from the stale-cache path.

Nits
- Tighten the concurrent-dedup test assertion from `toBeLessThanOrEqual(2)`
  to `toBe(1)`. Single in-flight refresh is guaranteed by JS single-threaded
  IIFE assignment ordering, and we use createLocalJWKSet (not Remote), so
  jose never refetches during jwtVerify — only our probe path can fetch.
  The loose assertion would have masked a real regression.
- Use caret pin `^22.19.17` for `@types/node` to match the rest of
  devDependencies in package.json.
- Tighten the JSDoc on `getJwks()` in src/lib/server/keycloak.ts: replace
  imprecise "synchronously" wording (the function is async), correct the
  "remote-JWKS fn" reference to match the actual createLocalJWKSet
  implementation, and explicitly distinguish the warm-cache fallback as
  the resilience path for transient outages.

Verification
- `npm test` → 25/25 pass (was 24/24).
- `npm run check` → 0 errors, 0 warnings.
- `npx prettier --check` on touched files → all formatted.

Files changed: package.json, src/lib/server/keycloak.ts,
src/lib/server/keycloak.test.ts. Lib public API surface unchanged.

QA reference: #18 (comment)
Refs #14, PR #18.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author
Owner

QA fix pushed to 14-keycloak-ts-lib as commit 39eaf4e. Re-requesting review.

All four items from comment #15937 addressed:

Blocker — warm-cache JWKS fallback test (added)
New it('falls back to warm cache when JWKS becomes unreachable within TTL') in src/lib/server/keycloak.test.ts. Pre-warms the JWKS cache, advances time to within the 10-min TTL window, switches the stubbed fetch to fail, then verifies a token signed by the warm-cache key. Asserts: verification succeeds, no JwksUnreachableError, and no additional JWKS fetch fires (cache-hit fast path short-circuits before fetch is invoked). Belt-and-braces: re-confirms the failing fetch wiring by tipping past the TTL and asserting JwksUnreachableError does fire from the stale-cache path.

Nit 1 — concurrent-dedup assertion tightened
expect(jwksFetches).toBeLessThanOrEqual(2) -> expect(jwksFetches).toBe(1). Single in-flight refresh is guaranteed (JS single-threaded IIFE assignment ordering, and we use createLocalJWKSet not createRemoteJWKSet, so jose never refetches during jwtVerify). The loose bound would have masked real regressions.

Nit 2 — @types/node pin format
"22.19.17" -> "^22.19.17" to match the caret style used by every other entry in devDependencies.

Nit 3 — getJwks() JSDoc tightened
Replaced imprecise "synchronously" wording (the function is async) with accurate "already-resolved promise" phrasing. Corrected "remote-JWKS fn" to reference createLocalJWKSet (matches actual implementation). Explicitly named the warm-cache fallback as the resilience path for transient outages.

Verification

  • npm test -> 25/25 pass (was 24/24)
  • npm run check -> 0 errors, 0 warnings
  • npx prettier --check on touched files -> all formatted
  • Lib public API surface unchanged. Only package.json, src/lib/server/keycloak.ts, and src/lib/server/keycloak.test.ts modified.
QA fix pushed to `14-keycloak-ts-lib` as commit `39eaf4e`. Re-requesting review. All four items from comment #15937 addressed: **Blocker — warm-cache JWKS fallback test (added)** New `it('falls back to warm cache when JWKS becomes unreachable within TTL')` in `src/lib/server/keycloak.test.ts`. Pre-warms the JWKS cache, advances time to within the 10-min TTL window, switches the stubbed fetch to fail, then verifies a token signed by the warm-cache key. Asserts: verification succeeds, no `JwksUnreachableError`, and no additional JWKS fetch fires (cache-hit fast path short-circuits before fetch is invoked). Belt-and-braces: re-confirms the failing fetch wiring by tipping past the TTL and asserting `JwksUnreachableError` does fire from the stale-cache path. **Nit 1 — concurrent-dedup assertion tightened** `expect(jwksFetches).toBeLessThanOrEqual(2)` -> `expect(jwksFetches).toBe(1)`. Single in-flight refresh is guaranteed (JS single-threaded IIFE assignment ordering, and we use `createLocalJWKSet` not `createRemoteJWKSet`, so jose never refetches during `jwtVerify`). The loose bound would have masked real regressions. **Nit 2 — `@types/node` pin format** `"22.19.17"` -> `"^22.19.17"` to match the caret style used by every other entry in `devDependencies`. **Nit 3 — `getJwks()` JSDoc tightened** Replaced imprecise "synchronously" wording (the function is `async`) with accurate "already-resolved promise" phrasing. Corrected "remote-JWKS fn" to reference `createLocalJWKSet` (matches actual implementation). Explicitly named the warm-cache fallback as the resilience path for transient outages. **Verification** - `npm test` -> 25/25 pass (was 24/24) - `npm run check` -> 0 errors, 0 warnings - `npx prettier --check` on touched files -> all formatted - Lib public API surface unchanged. Only `package.json`, `src/lib/server/keycloak.ts`, and `src/lib/server/keycloak.test.ts` modified.
Author
Owner

PR #18 Re-Review (loop iteration 2)

Fix commit 39eaf4e verified against the four prior review items.

DOMAIN REVIEW

SvelteKit/TypeScript SSR auth lib. Re-verified the four targeted changes from comment 15937:

1. Warm-cache JWKS fallback test — VERIFIED
src/lib/server/keycloak.test.ts:428-479. New it('falls back to warm cache when JWKS becomes unreachable within TTL', ...) case is a clean four-phase test:

  • Phase 1 (lines 447-451): warms cache via valid JWKS at baseTime, asserts exactly 1 fetch hit.
  • Phase 2 (line 456): advances to baseTime + JWKS_CACHE_TTL_MS / 2 — comfortably within TTL.
  • Phase 3 (line 459): flips fetch mock to ECONNREFUSED.
  • Phase 4 (lines 464-470): signs a NEW token with the cached key (signerA), asserts verifyKeycloakJwt succeeds and that NO additional JWKS fetch was attempted (still 1).
  • Belt-and-braces (lines 473-478): tips past TTL, confirms JwksUnreachableError does fire when stale + down — proves the failing fetch is wired correctly.

This is exactly the operationally-critical path the prior review called out. Solid test design.

2. Dedup assertion tightened — VERIFIED
src/lib/server/keycloak.test.ts:410: expect(jwksFetches).toBe(1). Previous toBeLessThanOrEqual(2) is gone. The accompanying comment (lines 405-409) correctly justifies the strict assertion via the JS single-threaded model + IIFE-then-assignment + createLocalJWKSet (no jose-internal refetch).

3. @types/node pin format — VERIFIED
package.json:22: "@types/node": "^22.19.17". Caret prefix matches every other devDependency in the file (lines 19-36 are uniformly ^). Tilde inconsistency resolved.

4. getJwks() doc comment — VERIFIED
src/lib/server/keycloak.ts:186-200. Doc now enumerates all three branches the function actually implements:

  • cache hit, fresh: return cached fn, no fetch
  • cache miss or stale: fetch + parse + feed createLocalJWKSet, concurrent callers share inFlight
  • fetch fails: warm-cache fallback (within TTL) / sanitized warn + JwksUnreachableError (stale) / JwksUnreachableError (no cache)

Wording matches code behavior on lines 201-263 line-for-line.

Test Count

  • verifyKeycloakJwt: 7
  • encryptCookiePayload / decryptCookiePayload: 4
  • generateOidcState / verifyOidcState: 3
  • JWKS cache: 3
  • JWKS-unreachable resilience: 3 (was 2; new warm-cache test)
  • refreshTokensIfNeeded: 3
  • source hygiene: 2

Total: 25 cases. Matches the expected count.

Scope Verification

Only the three files referenced in the prior review were touched:

  • package.json — single-line caret-prefix change on @types/node
  • src/lib/server/keycloak.ts — doc-comment-only change on getJwks()
  • src/lib/server/keycloak.test.ts — added one warm-cache test, tightened one dedup assertion

No public API surface change — no exported function signatures or types modified. verifyKeycloakJwt, encryptCookiePayload, decryptCookiePayload, generateOidcState, verifyOidcState, refreshTokensIfNeeded, and the typed error classes are unchanged. Hard scope honored.

BLOCKERS

None.

NITS (non-blocking, ack-only)

  • src/lib/server/keycloak.test.ts:415 section header still reads // JWKS unreachable resilience (2 cases) but now contains 3. Top-of-file docstring on line 12 says 2+ cases so it's still technically accurate; only the section banner drifted. Trivial.

SOP COMPLIANCE

  • Branch named after issue (#1414-keycloak-...)
  • PR body follows template (verified at original review)
  • Related references issue/plan
  • No secrets committed
  • Hard scope on fix commit (only the three flagged files)
  • No public API surface change

PROCESS OBSERVATIONS

Iteration 2 in the review-fix loop landed cleanly. Fix commit is surgical: doc-only edits for #3-4, two test edits for #1-2, one package.json line. Exactly the right size for a re-review. Cycle time impact minimal; change failure risk effectively zero.

VERDICT: APPROVED

## PR #18 Re-Review (loop iteration 2) Fix commit `39eaf4e` verified against the four prior review items. ### DOMAIN REVIEW SvelteKit/TypeScript SSR auth lib. Re-verified the four targeted changes from comment 15937: **1. Warm-cache JWKS fallback test — VERIFIED** `src/lib/server/keycloak.test.ts:428-479`. New `it('falls back to warm cache when JWKS becomes unreachable within TTL', ...)` case is a clean four-phase test: - Phase 1 (lines 447-451): warms cache via valid JWKS at `baseTime`, asserts exactly 1 fetch hit. - Phase 2 (line 456): advances to `baseTime + JWKS_CACHE_TTL_MS / 2` — comfortably within TTL. - Phase 3 (line 459): flips fetch mock to `ECONNREFUSED`. - Phase 4 (lines 464-470): signs a NEW token with the cached key (`signerA`), asserts `verifyKeycloakJwt` succeeds and that NO additional JWKS fetch was attempted (still `1`). - Belt-and-braces (lines 473-478): tips past TTL, confirms `JwksUnreachableError` does fire when stale + down — proves the failing fetch is wired correctly. This is exactly the operationally-critical path the prior review called out. Solid test design. **2. Dedup assertion tightened — VERIFIED** `src/lib/server/keycloak.test.ts:410`: `expect(jwksFetches).toBe(1)`. Previous `toBeLessThanOrEqual(2)` is gone. The accompanying comment (lines 405-409) correctly justifies the strict assertion via the JS single-threaded model + IIFE-then-assignment + `createLocalJWKSet` (no jose-internal refetch). **3. `@types/node` pin format — VERIFIED** `package.json:22`: `"@types/node": "^22.19.17"`. Caret prefix matches every other devDependency in the file (lines 19-36 are uniformly `^`). Tilde inconsistency resolved. **4. `getJwks()` doc comment — VERIFIED** `src/lib/server/keycloak.ts:186-200`. Doc now enumerates all three branches the function actually implements: - cache hit, fresh: return cached fn, no fetch - cache miss or stale: fetch + parse + feed `createLocalJWKSet`, concurrent callers share `inFlight` - fetch fails: warm-cache fallback (within TTL) / sanitized warn + `JwksUnreachableError` (stale) / `JwksUnreachableError` (no cache) Wording matches code behavior on lines 201-263 line-for-line. ### Test Count - `verifyKeycloakJwt`: 7 - `encryptCookiePayload / decryptCookiePayload`: 4 - `generateOidcState / verifyOidcState`: 3 - `JWKS cache`: 3 - `JWKS-unreachable resilience`: 3 (was 2; new warm-cache test) - `refreshTokensIfNeeded`: 3 - `source hygiene`: 2 **Total: 25 cases.** Matches the expected count. ### Scope Verification Only the three files referenced in the prior review were touched: - `package.json` — single-line caret-prefix change on `@types/node` - `src/lib/server/keycloak.ts` — doc-comment-only change on `getJwks()` - `src/lib/server/keycloak.test.ts` — added one warm-cache test, tightened one dedup assertion No public API surface change — no exported function signatures or types modified. `verifyKeycloakJwt`, `encryptCookiePayload`, `decryptCookiePayload`, `generateOidcState`, `verifyOidcState`, `refreshTokensIfNeeded`, and the typed error classes are unchanged. Hard scope honored. ### BLOCKERS None. ### NITS (non-blocking, ack-only) - `src/lib/server/keycloak.test.ts:415` section header still reads `// JWKS unreachable resilience (2 cases)` but now contains 3. Top-of-file docstring on line 12 says `2+ cases` so it's still technically accurate; only the section banner drifted. Trivial. ### SOP COMPLIANCE - [x] Branch named after issue (#14 → `14-keycloak-...`) - [x] PR body follows template (verified at original review) - [x] Related references issue/plan - [x] No secrets committed - [x] Hard scope on fix commit (only the three flagged files) - [x] No public API surface change ### PROCESS OBSERVATIONS Iteration 2 in the review-fix loop landed cleanly. Fix commit is surgical: doc-only edits for #3-4, two test edits for #1-2, one `package.json` line. Exactly the right size for a re-review. Cycle time impact minimal; change failure risk effectively zero. ### VERDICT: APPROVED
forgejo_admin deleted branch 14-keycloak-ts-lib 2026-05-03 15:53:52 +00:00
Sign in to join this conversation.
No reviewers
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!18
No description provided.