keycloak.ts lib: JWKS cache + JWT verify + AES-GCM cookie + OIDC state (foundation for #2) #14
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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-adminUser Story
story-westside-admin-admin-row-crud. The lib provides the cryptographic primitives that every funnel-exposed admin route depends on. Perfeedback_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-adminconfidential OIDC client was created on 2026-05-03 in thewestside-basketballrealm persop-keycloak-client-creation. The client_secret is inpal-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-sidekeycloak-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.jsonat HEAD has no vitest, notestscript, novitest.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, nopnpm-lock.yaml. All commands usenpm.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 (defaultnode), includesrc/**/*.test.ts.Update:
package.json— addjose(latest 5.x) todependencies. Addvitest(latest 1.x or 2.x — match the SvelteKit version's compatible range) todevDependencies. 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.Localsextension 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 withCOOKIE_SIGNING_KEYenv 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 withrefresh_tokengrant whenexp - now < 30s.Acceptance Criteria
vitestconfigured (vitest.config.tsexists,npm testexits 0 on a fresh checkout).${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).null(not throws) when ciphertext is tampered, key is wrong, or input is malformed — caller distinguishes "no session" vs "tampered session" by null check.stategenerated withcrypto.randomBytes(32)(≥256 bits), HMAC-signed withCOOKIE_SIGNING_KEY, fits inside a single Set-Cookie header.crypto.timingSafeEqual).exp - now < 30s, posts to${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/tokenwithgrant_type=refresh_tokenand confidential-client basic auth (KEYCLOAK_CLIENT_ID:KEYCLOAK_CLIENT_SECRET).verifyKeycloakJwtrejects 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.console.logor any other log call in this lib emits the JWT, refresh token, cookie ciphertext, orCOOKIE_SIGNING_KEY. Verified via grep over the source.Test Expectations
npm test):npm test -- src/lib/server/keycloak.test.ts(or justnpm testto run all).Constraints
jose(5.x) for JWT + JWKS. Do NOT pulljsonwebtoken(CVE history) ornode-jose(slower).node:cryptoWeb Crypto subset for AES-GCM and HMAC. Do NOT pullcrypto-js(browser-targeted).event.cookiesAPI in consumers; this lib only returns ciphertext strings.// eslint-disablelines.package-lock.json, no pnpm). Do NOT add apnpm-lock.yaml. Runnpm installafter adding deps; commit the updatedpackage-lock.json.@sveltejs/kitis at 2.x, vitest 1.x or 2.x is fine — pick one and stick to it. Do NOT pull a 0.x.Checklist
npm testexits 0)feedback_funnel_requires_authRelated
project-westside-admin— project this affectsforgejo_admin/westside-admin#2— parent decompositionarch-dataflow-westside-admin— Flow 1 (auth sequence)feedback_funnel_requires_authsop-keycloak-client-creation— Keycloak sideforgejo_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 refinementsScope Review: NEEDS_REFINEMENT
Review note:
review-1134-2026-05-03Scope 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 againstpackage.json(novitestdep, notestscript, novitest.config.*) and git log (#6/#9only added SvelteKit + TS + ESLint). Update File Targets to includevitest.config.ts(create) andtest/test:watchscripts in thepackage.jsonupdate 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.jsonexists, nopnpm-lock.yaml). Replace the conditionalpnpm test … (or npm test -- if pnpm not adopted yet)with the concretenpm test -- src/lib/server/keycloak.test.ts.Optional refinements:
[BODY]Add explicit test-count forrefreshTokensIfNeeded(3+ cases) in Test Expectations.[SCOPE]arch:keycloaklabel has no backing note (carry-over fromreview-1132-2026-05-03).arch-dataflow-westside-adminFlow 1 +arch-auth-westside-basketballlikely cover this for now — Ava's call whether to createarch-keycloak.Verified OK:
src/lib/server/keycloak.ts,package.json,do-not-touchpaths — all accurate against ground truth at/home/ldraney/westside-admin.story:admin-row-crudmatches project Safety Constraints; story note exists;arch-dataflow-westside-adminFlow 1 covers this lib's full surface.pal-e-deployments#147(per prompt context); siblings #15/#16/#17 do not block #14 starting.Scope Review (iter2): APPROVED
Review note:
review-1134-2026-05-03-iter2All four iter1 recommendations resolved:
[BODY]Vitest-not-scaffolded — RESOLVED. File Targets addsvitest.config.ts(create);package.jsonupdate addsvitestto devDependencies +test/test:watchscripts; new AC pins downvitest configured (npm test exits 0 on a fresh checkout).[BODY]pnpm → npm — RESOLVED. Body now states package manager is npm explicitly; run command isnpm test -- src/lib/server/keycloak.test.ts; Constraints forbid addingpnpm-lock.yaml.[BODY]Explicit refresh test count — RESOLVED. Test Expectations now listsToken refresh: 3+ caseswith the exact 3 scenarios suggested in iter1.[SCOPE]arch-keycloaknote — 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(HEAD803e943): novitestdep, novitest.config.*,package-lock.jsonpresent,pnpm-lock.yamlabsent,src/lib/server/exists with only.gitkeep, do-not-touch paths confirmed.Recommendation: advance #1134 from
backlogtotodoand dispatch the dev agent. Sibling sub-tasks #15/#16/#17 remain blocked on #14 merging (and #16 additionally on #147).