2.2 JWKS Force Refresh on Validation Failure
Sprint Key: 2-2-jwks-force-refresh-on-validation-failure
Epic: 2
PRD: keycloak-idp
Story 2.2: JWKS Force-Refresh on Validation Failure
Status: done
Story
As a backend system, I want to force-refresh the JWKS cache when token validation fails with a valid expiration, So that token validation is resilient to Keycloak key rotation without user disruption.
Acceptance Criteria
-
Given the backend is validating tokens from one or more Keycloak issuers When a token is validated Then the JWKS public keys for the token's
issare cached in a Map keyed by{iss}with a 5-minute TTL (NFR10) -
Given the JWKS cache for an issuer is populated When a subsequent token from the same issuer is validated before TTL expires Then cached keys are reused — no HTTP request to the JWKS endpoint
-
Given a token validation fails (signature mismatch / unknown
kid) When the token'sexpclaim is still valid (not expired) Then the JWKS cache for that issuer is force-refreshed and validation is retried once (two-attempt pattern) -
Given the JWKS cache was force-refreshed When the retry validation also fails Then the request is rejected with 401
TOKEN_INVALID -
Given a token validation fails When the token's
expclaim is expired Then the request is rejected with 401TOKEN_EXPIREDimmediately — no JWKS refresh is attempted -
Given multiple Keycloak issuers are configured When tokens from different issuers are validated Then each issuer maintains its own JWKS cache independently (FR5)
Tasks / Subtasks
-
Task 1: Implement explicit JWKS TTL wrapper (AC: #1 (closed), #2 (closed), #6 (closed)) -
1.1 Create a createJwksCache()factory function that wraps jose'screateRemoteJWKSet()with an explicit 5-minute TTL- CRITICAL: jose's
jwtVerify(token, jwks, options)callsjwksas a function with signature(protectedHeader, token)— NOTjwks.verify(token, options). The wrapper MUST be a callable function, not a class with averifymethod. Use a closure-based factory that returns a function with attached methods. - The wrapper MUST store both the
createRemoteJWKSet()result AND the JWKS URI (fromdoc.jwks_uriin OIDC discovery) so thatforceRefresh()can re-callcreateRemoteJWKSet()with the correct URI - Factory:
createJwksCache(jwksUri, ttlMs = 300000)returns a callablejwksFnwith attached.forceRefresh()and._isExpired()methods - Internal state:
_inner(the jose JWKS function),_jwksUri(for re-fetch),_createdAt(for TTL check) - On each call, check TTL: if expired, re-call
createRemoteJWKSet()before delegating - Example skeleton:
function createJwksCache(jwksUri, ttlMs = 300000) { let inner = createRemoteJWKSet(new URL(jwksUri)); let createdAt = Date.now(); async function jwksFn(protectedHeader, token) { if (Date.now() - createdAt > ttlMs) { inner = createRemoteJWKSet(new URL(jwksUri)); createdAt = Date.now(); } return inner(protectedHeader, token); } jwksFn.forceRefresh = function () { createdAt = 0; // triggers re-fetch on next call }; jwksFn._isExpired = function () { return Date.now() - createdAt > ttlMs; }; return jwksFn; }
- CRITICAL: jose's
-
1.2 Replace direct createRemoteJWKSet()usage ininit()with the TTL wrapper- Current code (line 63):
const jwks = createRemoteJWKSet(new URL(doc.jwks_uri)); - New code:
const jwks = createJwksCache(doc.jwks_uri); issuerMap.set(doc.issuer, jwks);
- Current code (line 63):
-
1.3 The forceRefresh()method is attached to the returnedjwksFn— call it viaissuerMap.get(iss).forceRefresh()inverifyTokenwhen a retry is needed -
1.4 Ensure TTL is tracked per-issuer in the existing issuerMap(value changes from raw JWKS function tocreateJwksCacheresult — a callable with attached methods) -
1.5 Update _resetForTesting()— no change needed. Existing code already callsissuerMap.clear()which is sufficient since the cache functions will be garbage collected
-
-
Task 2: Implement two-attempt force-refresh in verifyToken(AC: #3 (closed), #4 (closed), #5 (closed))-
2.1 In verifyToken, catchjwtVerifyfailures and distinguish between signature errors (force-refresh candidate) and expiration errors (no refresh)- jose v6 error types:
JWTExpired→ immediateTOKEN_EXPIRED; genericError(signature mismatch) → force-refresh candidate;JWTClaimValidationFailed→TOKEN_INVALID(claim issues likeiss/audmismatch are NOT key rotation problems) - NOTE: jose v6 throws
JWTExpiredspecifically forexpclaims.JWTClaimValidationFailedforexpis NOT expected — jose uses the dedicatedJWTExpiredclass - CRITICAL: jose checks signature BEFORE claims. When a signature error occurs,
expwas never checked by jose. To decide whether to force-refresh, the developer must manually checkexpfrom the unverified JWT payload (already decoded at lines 141-147 forisslookup). Ifpayload.exp < Date.now() / 1000, skip refresh and reject withTOKEN_EXPIRED. The unverified payload extraction already exists in the code — just add anexpcheck alongside theissextraction
- jose v6 error types:
-
2.2 On signature failure with valid exp: calljwks.forceRefresh()(wherejwks = issuerMap.get(iss)) then retryjwtVerifyonce -
2.3 On retry failure: reject with 401 TOKEN_INVALID -
2.4 On expired token ( JWTExpired): reject immediately with 401TOKEN_EXPIRED— no refresh attempt -
2.5 Ensure the retry does not create infinite loops (max 2 attempts total) -
2.6 If forceRefreshitself fails (network error, DNS), also reject with 401TOKEN_INVALID— any error during refresh is treated as a failed retry
-
-
Task 3: Add unit tests (AC: all) -
3.1 Test: cached JWKS is reused within TTL (no re-fetch) -
3.2 Test: cached JWKS expires after 5 minutes and re-fetches -
3.3 Test: signature failure with valid exptriggers force-refresh and retry -
3.4 Test: signature failure retry also fails → TOKEN_INVALID -
3.5 Test: signature failure retry succeeds → token accepted -
3.6 Test: expired token → TOKEN_EXPIRED(no refresh attempt) -
3.7 Test: multi-issuer cache isolation (each issuer cached independently) -
3.8 Test: forceRefreshonly clears cache for the target issuer, not others
-
Dev Notes
Architecture Context (Decision D3)
This story implements the two-attempt force-refresh pattern from Decision D3 in the architecture document. The existing keycloak-auth-service.js already has:
-
Lazy singleton OIDC discovery with 30s retry cooldown (
ensureInitialized()) -
Issuer whitelist via
issuerMap(Map keyed by{iss}) -
jose
createRemoteJWKSet()for JWKS resolution -
Multi-issuer support via
init(url)callable multiple times
What this story adds: The force-refresh logic when jwtVerify fails. Currently, a signature failure immediately returns TOKEN_INVALID — there is no retry mechanism.
Critical Implementation Detail: jose createRemoteJWKSet() Caching
jose's createRemoteJWKSet() has built-in HTTP caching based on Cache-Control / JWKS-TTL headers from the JWKS response. However, Keycloak 26.x may not always return explicit cache headers. In that case, jose may refetch JWKS on every verification call.
Solution (from architecture D3 caching note): Implement an explicit TTL wrapper around createRemoteJWKSet() with a 5-minute TTL. This ensures consistent caching behavior regardless of Keycloak's cache headers. The wrapper should:
- Store the
createRemoteJWKSet()result alongside acreatedAttimestamp - On JWKS function call, check if
Date.now() - createdAt > TTL - If expired, re-call
createRemoteJWKSet()with the stored JWKS URI - The
forceRefreshmethod resetscreatedAtto 0, triggering immediate re-fetch on next use
Exact Force-Refresh Flow (from Architecture — MUST follow)
1. Verify token with cached JWKS → fail
2. Check if token `exp` is still valid (not expired)
3. If yes → force-refresh JWKS for this issuer → re-verify → if fail again, 401 TOKEN_INVALID
4. If no (token expired) → 401 TOKEN_EXPIRED immediately (no refresh)
jose v6.2.2 API Reference
-
const { jwtVerify, createRemoteJWKSet } = require('jose')— CommonJS import (already in codebase) -
createRemoteJWKSet(new URL(jwksUri))— returns a JWKS verification function -
jwtVerify(token, jwksFunction, options)— verifies JWT; throwsJWTExpired,JWTClaimValidationFailed, or genericErrorfor signature failures - Error
namefield:JWTExpiredfor expiration,JWTClaimValidationFailedfor claim issues, genericErrorfor signature mismatch - jose v6.x uses Node.js native
cryptomodule — no additional dependencies needed
Existing Code Analysis
File: components/gov-chat-backend/services/keycloak-auth-service.js
The current verifyToken method (lines 120-199) has this flow:
- Token format validation (lines 121-127)
-
ensureInitialized()call (lines 129-137) - Unverified
issextraction for whitelist lookup (lines 140-147) -
issuerMap.get(unverifiedIss)lookup (lines 149-152) -
jwtVerify(token, jwks, options)call (lines 154-159) -
azpvalidation (lines 161-166) - Error handling:
JWTExpired→TOKEN_EXPIRED,JWTClaimValidationFailed→ variousTOKEN_INVALID, generic →TOKEN_INVALID(lines 177-198)
Key change point: Step 5-7 needs the two-attempt logic. The jwks variable (line 155) is currently the raw createRemoteJWKSet() result. After this story, it will be a createJwksCache() result (a callable function with attached .forceRefresh() method).
Testing Patterns
Existing mock setup (from keycloak-auth-service.test.js):
-
jest.mock('jose')— full mock of jose module -
mockJwtVerify— controlsjwtVerifyreturn value -
mockCreateRemoteJWKSet— controlscreateRemoteJWKSetreturn value (returns a jest.fn()) -
mockFetch— controls globalfetchfor OIDC discovery - Shared fixtures from
__tests__/mocks/mockJwtPayload.js:-
mockJwtPayload— valid payload withexpin the future -
mockExpiredPayload— payload withexpin the past -
generateMockJwtString(payload)— creates a 3-part mock JWT string
-
-
_resetForTesting()called inbeforeEach
For this story's tests:
- Simulate signature failure:
mockJwtVerify.mockRejectedValue(new Error('...'))on first call, thenmockResolvedValue(...)on second call - Simulate TTL expiry: mock
Date.now()withjest.spyOn(Date, 'now') - The
forceRefreshmethod will need the JWKS URI — stored in thecreateJwksCacheclosure alongside the JWKS function -
CRITICAL (Story 1-9 lesson): When declaring mock variable references for jest.mock, you MUST use
varNOTlet— this is due to jest.mock hoisting and TDZ (Temporal Dead Zone). Usingletwill causeReferenceError: Cannot access 'X' before initializationerrors. This exact issue caused test failures in Story 1-9
Project Structure Notes
- Backend uses CommonJS only (
require/module.exports) — no ES imports - 2-space indentation, single quotes, mandatory semicolons
- Test files in
__tests__/directory at service root - Mock fixtures in
__tests__/mocks/ - Logger imported from
../shared-lib(already mocked in tests) -
TokenVerificationErrorclass already defined in the service — reuse for all error types
Worktree Assignment
This story will be implemented in the epic2-backend worktree.
References
- [Source: _bmad-output/planning-artifacts/architecture.md#Decision D3 — JWKS resolution and caching strategy]
- [Source: _bmad-output/planning-artifacts/architecture.md#Process Patterns — JWKS Force-Refresh]
- [Source: _bmad-output/planning-artifacts/architecture.md#Auth Middleware Flow (step 5)]
- [Source: _bmad-output/planning-artifacts/architecture.md#Implementation status (as of Story 1.9)]
- [Source: _bmad-output/planning-artifacts/epics.md#Story 2.2]
- [Source: _bmad-output/project-context.md#Backend (Node.js) — Testing Rules]
- [Source: components/gov-chat-backend/services/keycloak-auth-service.js — existing implementation]
- [Source: components/gov-chat-backend/tests/keycloak-auth-service.test.js — existing test patterns]
- [Source: components/gov-chat-backend/tests/mocks/mockJwtPayload.js — shared mock fixture]
E2E Testing (Conditional - Keycloak Infrastructure Required)
IMPORTANT: This story includes conditional E2E tests that require Keycloak deployment.
E2E Test File: docs/e2e-test-plan-external-idp.md
Execution Conditions:
- E2E tests should ONLY run if Keycloak infrastructure is available
- Tests are automated and precise - follow the test plan exactly, NO improvisation
- Use environment variable or config flag to conditionally enable E2E tests
How to Run E2E Tests:
# Check if Keycloak is available
if curl -s http://localhost:8080/health > /dev/null 2>&1; then
echo "Keycloak infrastructure detected - running E2E tests"
npm run test:e2e
else
echo "Keycloak infrastructure NOT available - skipping E2E tests"
echo "Run unit tests instead: npm test"
fi
E2E Tests for Story 2.2:
- Verify JWKS cache re-fetches after TTL expires (5 minutes)
- Verify JWKS force-refresh on signature failure with valid exp
- Verify force-refresh retry on second attempt
- Verify multi-issuer cache isolation
- Verify expired token immediate rejection (no refresh)
Note: Story 2.2 was completed without E2E tests - this is a retrospective lesson learned for Epic 2.
Dev Agent Record
Agent Model Used
- claude-opus-4-6 (Claude 4.6 Opus)
Debug Log References
- No debug logs required for this story
Completion Notes List
Implementation Summary
-
createJwksCache() factory function implemented with:
- Closure-based pattern storing
_inner,_jwksUri,_createdAt - TTL check on each call (5 minutes default)
-
.forceRefresh()method for immediate re-fetch -
._isExpired()method for testing - Compatible with jose's
jwtVerify(token, jwksFunction, options)signature
- Closure-based pattern storing
-
Two-attempt force-refresh pattern in
verifyToken():- Extracts
expfrom unverified payload for expiration check - Signature failure with valid
exp→ force-refresh + retry - Expired token → immediate
TOKEN_EXPIRED(no refresh) - Claim validation errors →
TOKEN_INVALID(no refresh) - Max 2 attempts total (prevents infinite loops)
- Extracts
-
Unit tests added for:
- Cache reuse within TTL (via verifyToken integration)
- Cache expiry after 5 minutes (via createJwksCache direct test)
- Force-refresh on signature failure with valid exp
- Retry failure →
TOKEN_INVALID - Retry success → token accepted
- Expired token →
TOKEN_EXPIRED(no refresh) - Multi-issuer cache isolation
- Claim validation errors don't trigger refresh
- Force-refresh method triggers re-fetch on next call
Test Results: 81/81 tests passing
Technical Decisions
- Used
const JWKS_CACHE_TTL = 300000(5 minutes) as per NFR10 - Manual
expcheck from unverified payload (jose checks signature before claims) - Helper function
verifyWithJwt()to avoid code duplication in retry logic - Added
_getJwksCache(issuer)testing helper method - Exported
createJwksCache()for direct unit testing of TTL behavior - Mock strategy: Since
jwtVerifyis mocked (ESM compatibility), TTL tests usecreateJwksCache()directly to verify re-fetch behavior
Files Modified
File List
- components/gov-chat-backend/services/keycloak-auth-service.js (modified)
- components/gov-chat-backend/tests/keycloak-auth-service.test.js (modified)