1.7 Transparent Re-authentication on Session Expiry
Sprint Key: 1-7-transparent-re-authentication-on-session-expiry
Epic: 1
PRD: keycloak-idp
Story 1.7: Transparent Re-authentication on Session Expiry
Status: done
Story
As an end user, I want my session to be silently refreshed when my Keycloak token expires, so that I don't need to manually re-authenticate during active use.
Acceptance Criteria
-
Silent Access Token Refresh (FR6)
- Given an authenticated user has an active session with a valid refresh token
- When the access token expires
- Then the
oidc-client-tsUserManager silently refreshes the access token using the refresh token (FR6) - And the
keycloakAuthServicemodule-levelcurrentUseris updated with the new token - And the Vuex auth module updates
accessTokenin state without user interaction
-
Refresh Token Expiry Fallback
- Given the refresh token has also expired
- When
automaticSilentRenewattempts to refresh the access token - Then the
addSilentRenewErrorevent fires - And the user is redirected to the Keycloak login page
-
Vuex State Synchronization
- Given a silent token refresh succeeds
- When the UserManager fires
addUserLoadedevent - Then the Vuex auth module's
accessTokenstate is updated to the new token value - And
isAuthenticatedremainstrue - And the user object (profile fields) is updated if changed
-
In-Flight 401 Retry
- Given an API request fails with HTTP 401 due to an expired access token
- When the response interceptor catches the 401
- Then
keycloakAuthService.signinSilent()is called to force a token refresh - And if successful, the original request is retried once with the new access token
- And if
signinSilent()fails (refresh token expired), the user is redirected to Keycloak login
Tasks / Subtasks
-
Task 1: Add silent renew event listeners and signinSilent to keycloakAuthService.js (AC: #1 (closed), #2 (closed)) -
In initialize(), after creating UserManager, registeraddUserLoadedcallback that updates module-levelcurrentUser -
In initialize(), registeraddSilentRenewErrorcallback that callslogin()to redirect to Keycloak login page -
Add onAccessTokenUpdated(callback)method — stores callback in a Set, invokes all registered callbacks whenaddUserLoadedfires (with updated User object as argument) -
Add removeAccessTokenUpdatedCallback(callback)method for cleanup -
Add signinSilent()method — callsUserManager.signinSilent(), updatescurrentUser, returns the refreshed User -
Store unsubscribe functions from addUserLoadedandaddSilentRenewErrorfor cleanup -
In logout(), call stored unsubscribe functions to remove event listeners before clearing user -
Ensure no localStorage/sessionStorage usage for tokens (NFR3)
-
-
Task 2: Update Vuex auth module to subscribe to silent renew events (AC: #1 (closed), #3 (closed)) -
Add updateAccessTokenmutation that updatesstate.accessTokenwith a new token value -
In initializeaction (after successful user retrieval), register callback viakeycloakAuthService.onAccessTokenUpdated() -
Callback should commit updateAccessTokenwithuser.access_tokenand updatestate.userviamapOidcUserToState -
In logoutaction, callkeycloakAuthService.removeAccessTokenUpdatedCallback()to clean up subscription -
Ensure isAuthenticatedremainstrueafter silent renew (don't callclearAuthorsetAuth)
-
-
Task 3: Update httpService.js 401 retry with signinSilent (AC: #4 (closed)) -
On 401 response (not 403), call await keycloakAuthService.signinSilent()to force a background token refresh -
If signinSilent()returns a user with a new access token, updateoriginalRequest.headers.Authorizationand retry the request once -
If signinSilent()returns null or throws (refresh token expired), redirect to Keycloak login viakeycloakAuthService.login() -
Remove 403 from the retry logic — 403 is an authorization error, not a token expiry issue -
Keep existing _retryCountguard to prevent infinite retry loops -
Ensure only one retry attempt per request
-
-
Task 4: Write unit tests for keycloakAuthService silent renew (AC: #1 (closed), #2 (closed)) -
Test: initialize()registersaddUserLoadedevent listener on UserManager -
Test: addUserLoadedcallback updates module-levelcurrentUserwith new user data -
Test: initialize()registersaddSilentRenewErrorevent listener on UserManager -
Test: addSilentRenewErrorcallback callslogin()to redirect to Keycloak -
Test: onAccessTokenUpdated(callback)registers callback that is invoked onaddUserLoaded -
Test: removeAccessTokenUpdatedCallback(callback)removes previously registered callback -
Test: signinSilent()callsUserManager.signinSilent()and returns refreshed user -
Test: signinSilent()updatescurrentUserwith refreshed user -
Test: logout()removes event listeners (unsubscribe functions called) -
Mock UserManager events: add mock methods events.addUserLoaded,events.addSilentRenewError,signinSilent
-
-
Task 5: Write unit tests for Vuex auth module silent renew (AC: #3 (closed)) -
Test: initializeaction registers callback viaonAccessTokenUpdatedwhen user exists -
Test: registered callback commits updateAccessTokenmutation with new token -
Test: registered callback updates user state via mapOidcUserToState -
Test: initializeaction does NOT register callback when no user -
Test: logoutaction callsremoveAccessTokenUpdatedCallbackfor cleanup -
Test: updateAccessTokenmutation updatesstate.accessTokenwithout affecting other state
-
-
Task 6: Write unit tests for httpService.js 401 retry (AC: #4 (closed)) -
Test: 401 response triggers signinSilent()call -
Test: successful signinSilent()retries request with new token -
Test: failed signinSilent()(returns null) redirects to Keycloak login -
Test: failed signinSilent()(throws) redirects to Keycloak login -
Test: 403 response does NOT trigger signinSilent()(authorization error, not token issue) -
Test: request is not retried more than once ( _retryCountguard) -
Test: non-401 errors are not affected by retry logic
-
-
Task 7: Run full test suite and verify -
Run cd components/gov-chat-frontend && npx jest --verbose -
Verify all existing tests still pass (no regressions from changes) -
Verify all new tests pass -
Run backend tests to confirm no cross-component breakage: cd components/gov-chat-backend && npx jest
-
Dev Notes
Architecture Decisions (from architecture.md)
D5 — OIDC integration: Standalone service class
-
keycloakAuthService.jswrapsoidc-client-tsUserManager - Vuex auth module consumes the service via actions
- Token storage: in-memory only (NFR3)
Key oidc-client-ts API for this story:
| API | Purpose |
|---|---|
automaticSilentRenew: true |
Already configured in oidcConfig.js — enables automatic background token refresh |
UserManager.events.addUserLoaded(cb) |
Fires when a new user is loaded (including after silent renew) — callback receives User object |
UserManager.events.addSilentRenewError(cb) |
Fires when silent renew fails (refresh token expired) — callback receives Error object |
UserManager.signinSilent() |
Manually trigger a silent token refresh via iframe — returns Promise<User | null>
|
accessTokenExpiringNotificationTimeInSeconds |
Default 60 seconds — triggers silent renew before token expires |
Silent renew mechanism:
- When
automaticSilentRenew: trueis set, oidc-client-ts creates aSilentRenewServiceinternally - Before the access token expires (default: 60 seconds before), it opens a hidden iframe to Keycloak's authorization endpoint with
prompt=none - Keycloak responds with a new token if the refresh token is valid
- The
addUserLoadedevent fires with the refreshedUserobject - No page reload, no user interaction — completely transparent
401 retry mechanism:
- When an API request fails with 401 (token expired before silent renew kicked in, or silent renew was too slow)
-
signinSilent()forces an immediate background token refresh - If successful, the original request is retried with the new token
- If the refresh token is also expired,
signinSilent()returns null — redirect to Keycloak login
Key Technical Details
Current keycloakAuthService.js state (to be modified):
- Module-level
currentUservariable stores the OIDC user -
getAccessToken()reads fromcurrentUser?.access_token -
initialize()creates UserManager and callsgetUser()— but does NOT register event listeners - No
signinSilent()method exists - No callback mechanism for external subscribers (Vuex store)
Current httpService.js 401 handling (to be fixed):
- Lines 99-126: On 401/403, calls
keycloakAuthService.getUser()then readsuser?.access_token - This does NOT trigger a token refresh — it just reads the current (expired) user
- Both 401 AND 403 trigger the same logic — 403 should NOT trigger token refresh
- The fix: replace
getUser()withsigninSilent(), remove 403 from retry logic
Current oidcConfig.js:
-
automaticSilentRenew: true— already configured (Story 1-4) - No
silent_redirect_uri— oidc-client-ts uses iframe-based approach by default (sufficient for Keycloak) - No
accessTokenExpiringNotificationTimeInSeconds— uses default 60 seconds
Current Vuex auth module (store/modules/auth.js):
-
initializeaction callskeycloakAuthService.initialize()and sets auth state - No subscription to silent renew events —
accessTokenis only set duringinitialize()andhandleCallback() - No
updateAccessTokenmutation exists
Event flow for silent renew:
Access token approaching expiry (60s before)
→ SilentRenewService triggers signinSilent via iframe
→ Keycloak validates refresh token
→ New access token returned
→ UserManager stores new User internally
→ addUserLoaded event fires with new User
→ keycloakAuthService updates currentUser
→ keycloakAuthService invokes registered callbacks
→ Vuex store commits updateAccessToken
→ Components reading accessToken getter get new value
Event flow for silent renew failure:
Access token approaching expiry (60s before)
→ SilentRenewService triggers signinSilent via iframe
→ Keycloak rejects (refresh token expired)
→ addSilentRenewError event fires with Error
→ keycloakAuthService calls login()
→ User redirected to Keycloak login page
Event flow for 401 retry:
API request with expired access token
→ Backend returns 401
→ httpService interceptor catches 401
→ Calls keycloakAuthService.signinSilent()
→ If successful: updates currentUser, retries request with new token
→ If failed: calls keycloakAuthService.login() → redirect to Keycloak
What This Story Does NOT Cover (deferred to later stories)
- Story 1.8: Standardized error response format (backend error codes — this story only handles frontend)
- Story 2.6: Auth & authorization error display (user-facing error messages for auth failures)
- Story 2.7: Keycloak unavailable detection (health check — unrelated to token refresh)
-
Story 3.2: Session invalidation on user disable/delete (Keycloak revokes refresh token — this story's
addSilentRenewErrornaturally handles the redirect) - Story 3.1: User logout (explicit logout — separate from silent renew failure)
Files to Create
| File | Purpose |
|---|---|
components/gov-chat-frontend/src/__tests__/httpService-401-retry.test.js |
Unit tests for httpService 401 retry with signinSilent |
Files to Modify
| File | Change |
|---|---|
components/gov-chat-frontend/src/services/keycloakAuthService.js |
Add event listeners (addUserLoaded, addSilentRenewError), onAccessTokenUpdated, removeAccessTokenUpdatedCallback, signinSilent, cleanup in logout |
components/gov-chat-frontend/src/store/modules/auth.js |
Add updateAccessToken mutation, subscribe to silent renew in initialize and handleCallback, extract registerSilentRenewCallback helper, cleanup in logout |
components/gov-chat-frontend/src/services/httpService.js |
Replace getUser() with signinSilent() on 401, remove 403 from retry logic, await login() call |
components/gov-chat-frontend/src/__tests__/keycloakAuthService.test.js |
Extend with tests for event listeners, signinSilent, onAccessTokenUpdated |
components/gov-chat-frontend/src/__tests__/store/modules/auth.test.js |
Extend with tests for silent renew subscription, handleCallback callback registration, re-initialize cleanup, updateAccessToken mutation |
_bmad-output/implementation-artifacts/sprint-status.yaml |
Update story status from in-progress to review |
Files NOT Modified (intentionally)
| File | Reason |
|---|---|
components/gov-chat-frontend/src/config/oidcConfig.js |
automaticSilentRenew: true already configured in Story 1-4 — no changes needed |
components/gov-chat-frontend/src/views/CallbackView.vue |
Silent renew uses iframe internally — callback page not involved |
components/gov-chat-frontend/src/router.js |
No routing changes — silent renew is transparent |
components/gov-chat-frontend/src/App.vue |
No changes — initialization already handled |
components/gov-chat-backend/** |
No backend changes — this story is frontend-only |
Previous Story Intelligence (Story 1-6)
Key patterns established:
- Story 1-6 was backend-only (JIT provisioning, ArangoDB UPSERT)
- No frontend changes in 1-6 — the frontend state is exactly as left by Story 1-5
- Story 1-4 created
keycloakAuthService.jsandoidcConfig.js— both are stable - Story 1-5 created
CallbackView.vueand updated router guard — stable
From Story 1-4 (frontend OIDC service):
-
oidc-client-tsv3.5.0 installed — ESM library -
automaticSilentRenew: trueset in oidcConfig.js - UserManager created in
initialize()— stored in module-leveluserManagervariable -
currentUsermodule-level variable tracks the OIDC user -
getAccessToken()reads fromcurrentUser?.access_token - Token storage is in-memory only (NFR3)
- Jest configured with babel-jest, @vue/vue3-jest, moduleNameMapper for
@/
From Story 1-5 (frontend login redirect):
- Router guard waits for
isAuthInitializedbefore making routing decisions -
CallbackView.vuedispatcheshandleCallbackon mount - NavBarComponent logout calls
store.dispatch('logout')
Lessons from code reviews (all stories):
-
__esModule: truerequired in jest.mock for ES default imports (frontend only) -
jest.mock()for oidc-client-ts usesMockUserManagerconstructor pattern -
createMockUser()helper creates realistic OIDC user objects for tests - Vue 3 Options API only — NOT Composition API
- ES modules with
@/path alias for imports
Current httpService.js 401 handling issue:
- Lines 104-110: On 401, calls
getUser()then readsuser?.access_token - This is BROKEN for expired tokens —
getUser()returns the expired user,access_tokenis still the old (expired) token - The retry with the old token will also fail with 401
- The fix in this story: replace with
signinSilent()which actually refreshes the token
Frontend Conventions (from project-context.md)
-
Vue 3 Options API — NOT Composition API, NOT
<script setup> -
ES modules with
@/path alias for imports - 2-space indentation, single quotes, semicolons
-
i18n: Use
translate('key.path', 'default text')for user-facing text - No TypeScript — plain JavaScript
-
Jest:
src/__tests__/,*.test.js,describe/it/expect,jest.mock()for services -
Vuex: Non-namespaced module —
mapGetters,mapActionsin components
Testing Strategy
keycloakAuthService silent renew tests:
- Extend existing
keycloakAuthService.test.jsmock pattern - Add mock methods to MockUserManager:
events: { addUserLoaded, addSilentRenewError },signinSilent -
addUserLoadedandaddSilentRenewErrormust return unsubscribe functions (matching oidc-client-ts API) - Test that
initialize()callsevents.addUserLoaded()andevents.addSilentRenewError() - Simulate silent renew: call the stored
addUserLoadedcallback with a new user, verifycurrentUseris updated - Test
onAccessTokenUpdated: register callback, triggeraddUserLoaded, verify callback invoked with new user - Test
signinSilent(): mock UserManager method, verify it's called and result returned - Test
logout(): verify unsubscribe functions are called
Mock pattern for UserManager events (required for Task 4):
const mockSigninSilent = jest.fn();
const mockAddUserLoaded = jest.fn(() => jest.fn()); // returns unsubscribe fn
const mockAddSilentRenewError = jest.fn(() => jest.fn()); // returns unsubscribe fn
const MockUserManager = jest.fn().mockImplementation(() => ({
getUser: mockGetUser,
signinRedirect: mockSigninRedirect,
signinRedirectCallback: mockSigninRedirectCallback,
signoutRedirect: mockSignoutRedirect,
signinSilent: mockSigninSilent,
events: {
addUserLoaded: mockAddUserLoaded,
addSilentRenewError: mockAddSilentRenewError
}
}));
Note: addUserLoaded and addSilentRenewError return unsubscribe functions because oidc-client-ts follows the subscribe/unsubscribe pattern.
Vuex auth module silent renew tests:
- Extend existing
auth.test.jsmock pattern - Add
onAccessTokenUpdatedandremoveAccessTokenUpdatedCallbackto mock - Test
initializeregisters callback when user exists - Simulate callback invocation, verify
updateAccessTokenmutation committed - Test
logoutcallsremoveAccessTokenUpdatedCallback
httpService.js 401 retry tests:
- New test file
src/__tests__/httpService-401-retry.test.js - Mock axios and keycloakAuthService
- Create httpService instance (or import singleton)
- Test 401 triggers
signinSilent()and retries on success - Test 401 redirects to login on
signinSilent()failure - Test 403 does NOT trigger
signinSilent() - Test request not retried more than once
References
- [Source: _bmad-output/planning-artifacts/architecture.md#D5] — Frontend OIDC service class decision
- [Source: _bmad-output/planning-artifacts/architecture.md#Vuex Auth Module State Shape] — Mandatory state shape
- [Source: _bmad-output/planning-artifacts/epics.md#Story 1.7] — BDD acceptance criteria
- [Source: _bmad-output/planning-artifacts/prd.md#FR6] — Transparent re-authentication requirement
- [Source: _bmad-output/planning-artifacts/prd.md#NFR3] — Tokens in browser memory only
- [Source: _bmad-output/project-context.md] — Frontend conventions (Options API, ES modules, no TS)
- [Source: components/gov-chat-frontend/src/services/keycloakAuthService.js] — Current OIDC service to extend
- [Source: components/gov-chat-frontend/src/store/modules/auth.js] — Current Vuex auth module to update
- [Source: components/gov-chat-frontend/src/services/httpService.js] — Current HTTP service to fix
- [Source: components/gov-chat-frontend/src/config/oidcConfig.js] — OIDC config (automaticSilentRenew already set)
- [Source: oidc-client-ts docs] — UserManager events API (addUserLoaded, addSilentRenewError, signinSilent)
- [Source: _bmad-output/implementation-artifacts/1-4-frontend-oidc-service-class-and-vuex-auth-module.md] — Story 1-4 implementation notes
- [Source: _bmad-output/implementation-artifacts/1-5-frontend-login-redirect-and-auth-guard.md] — Story 1-5 implementation notes
Dev Agent Record
Agent Model Used
GLM-5-Turbo (implementation + code review)
Debug Log References
Completion Notes List
- Task 1-2: Implemented in previous context window. keycloakAuthService got silent renew event listeners, signinSilent(), callback mechanism. Vuex auth module got updateAccessToken mutation and silent renew subscription in initialize.
- Task 3: httpService 401 retry test mock required 3 iterations — singleton interceptor chain cannot be tested through axios mock. Solution: test handleResponseError directly, mock axios as callable jest.fn().
- Code review found H1 (handleCallback missing silent renew callback registration) — fixed by extracting registerSilentRenewCallback helper used by both initialize and handleCallback.
- Code review found H2 (login() not awaited in httpService) — fixed with await.
- Code review found M1 (silentRenewCallback leaks between tests) — fixed by having registerSilentRenewCallback clean up existing callback before registering new one.
- M2 (accessTokenCallbacks not cleared on logout) evaluated and dismissed — consumer (Vuex) properly cleans up via removeAccessTokenUpdatedCallback.
- M3 (tests bypass interceptor chain) evaluated and dismissed — direct method testing is valid unit test strategy.
Change Log
- 2026-04-01: Code review fixes applied (H1, H2, M1, L1, L2). 4 new regression tests added.
File List
| File | Action | Lines Changed |
|---|---|---|
components/gov-chat-frontend/src/services/keycloakAuthService.js |
Modified | +50 (event listeners, signinSilent, callbacks, cleanup) |
components/gov-chat-frontend/src/store/modules/auth.js |
Modified | +35 (updateAccessToken, registerSilentRenewCallback, handleCallback registration) |
components/gov-chat-frontend/src/services/httpService.js |
Modified | ~10 (signinSilent retry, remove 403, await login) |
components/gov-chat-frontend/src/__tests__/keycloakAuthService.test.js |
Modified | +90 (11 new tests for silent renew) |
components/gov-chat-frontend/src/__tests__/store/modules/auth.test.js |
Modified | +70 (9 new tests for silent renew + handleCallback + re-init cleanup) |
components/gov-chat-frontend/src/__tests__/httpService-401-retry.test.js |
Created | 160 (7 tests for 401 retry behavior) |
_bmad-output/implementation-artifacts/sprint-status.yaml |
Modified | 1 (status update) |