11import { jwtDecode } from 'jwt-decode' ;
2- import { derived , get , type Readable } from 'svelte/store' ;
3- import type { BaseUser , User } from '../../types/app' ;
4- import { computeRolesFromJWT } from '../../utilities/auth' ;
5- import { showFailureToast } from '../../utilities/toast' ;
2+ import { derived , get , writable , type Readable } from 'svelte/store' ;
3+ import { restartSharedClient } from '../../stores/gqlClient' ;
4+ import { getCookieValue } from '../../utilities/browser' ;
65import type { MaybeToken } from '../types/oidc' ;
7- import { userStore } from './auth' ;
86
97type CookieChanged = {
108 domain : string ;
@@ -31,10 +29,19 @@ type CookieStore = {
3129declare global {
3230 interface Window {
3331 cookieStore : CookieStore ;
34- addEventListener ( type : string , listener : ( this : Window , ev : CookieChangeEvent ) => void , useCapture ?: boolean ) : void ;
3532 }
3633}
3734
35+ // Store for the current access token (read from cookie)
36+ // Used only for computing refresh timing, not for user state
37+ const accessToken = writable < string | null > ( null ) ;
38+
39+ // Initialize from cookie on load
40+ const initialToken = getCookieValue ( 'accessToken' ) ;
41+ if ( initialToken ) {
42+ accessToken . set ( initialToken ) ;
43+ }
44+
3845export function cookieStoreListener ( ) {
3946 if ( window && 'cookieStore' in window ) {
4047 window . cookieStore . addEventListener ( 'change' , handleCookieStoreChange ) ;
@@ -43,12 +50,9 @@ export function cookieStoreListener() {
4350 console . error ( 'Cookie store is not available in this environment. It is *required* for automatic refresh of JWT.' ) ;
4451 }
4552
46- // Delay is a `derived` value, ultimately from the user store... (see below) .
53+ // Delay is a `derived` value from the access token .
4754 // Whenever the delay changes, any prior timeout is cancelled and a new timeout
4855 // is created (using the new value of delay).
49- //
50- // We track an unsubscribe function to remove the cookie store change listener
51- // when the component is unmounted.
5256 const unsubscribe = delay . subscribe ( value => {
5357 if ( value ) {
5458 console . debug ( `Scheduling token refresh in ${ value } ms` ) ;
@@ -58,18 +62,32 @@ export function cookieStoreListener() {
5862
5963 // Return a cleanup function to remove the cookie store change listener
6064 // and unsubscribe from the delay store.
61- return ( ) => {
65+ const cleanup = ( ) => {
6266 console . debug ( 'Removing cookie store change listener.' ) ;
63- window . cookieStore . removeEventListener ( 'change' , handleCookieStoreChange ) ;
67+ if ( 'cookieStore' in window ) {
68+ window . cookieStore . removeEventListener ( 'change' , handleCookieStoreChange ) ;
69+ }
6470 unsubscribe ( ) ;
71+ if ( prior ) {
72+ clearTimeout ( prior ) ;
73+ prior = null ;
74+ }
6575 } ;
76+
77+ // Store on window so HMR module re-evaluation can find and clean up the old listener
78+ ( window as any ) . __oidcCookieCleanup = cleanup ;
79+
80+ return cleanup ;
6681}
6782
68- // The decoded access token contains a timestamp that indicates when
69- // it will expire.
70- export const accessTokenDecoded : Readable < MaybeToken > = derived ( userStore , $userStore => {
71- if ( $userStore && $userStore . token ) {
72- return jwtDecode ( $userStore . token ) as MaybeToken ;
83+ // The decoded access token contains a timestamp that indicates when it will expire.
84+ export const accessTokenDecoded : Readable < MaybeToken > = derived ( accessToken , $accessToken => {
85+ if ( $accessToken ) {
86+ try {
87+ return jwtDecode ( $accessToken ) as MaybeToken ;
88+ } catch {
89+ return null ;
90+ }
7391 }
7492 return null ;
7593} ) ;
@@ -111,10 +129,10 @@ export async function refresh(): Promise<void> {
111129 }
112130}
113131
114- function reschedule ( fn : ( ) => Promise < void > , delay : number , prior : number | null ) : any {
115- if ( prior ) {
132+ function reschedule ( fn : ( ) => Promise < void > , delay : number , previousTimeout : number | null ) : any {
133+ if ( previousTimeout ) {
116134 console . debug ( `Clearing previous timeout.` ) ;
117- clearTimeout ( prior ) ;
135+ clearTimeout ( previousTimeout ) ;
118136 }
119137 console . debug ( `Scheduling ${ fn . name } in ${ delay } ms` ) ;
120138 return setTimeout ( async ( ) => {
@@ -123,15 +141,23 @@ function reschedule(fn: () => Promise<void>, delay: number, prior: number | null
123141 } catch ( err ) {
124142 // Only log error message, not full object (may contain sensitive data)
125143 console . error ( 'Error in scheduled refresh:' , err instanceof Error ? err . message : 'Unknown error' ) ;
126- showFailureToast ( 'Failed to refresh your credentials, please login again.' ) ;
144+ // Retry after 5 seconds — network may have been temporarily unavailable.
145+ // When it succeeds, the cookie update triggers the normal delay-based scheduling.
146+ console . debug ( 'Scheduling token refresh retry in 5000ms' ) ;
147+ prior = reschedule ( fn , 5000 , prior ) ;
127148 }
128149 } , delay ) ;
129150}
130151
131152/**
132153 * Handles changes and deletions to the cookie store.
133154 *
134- * @param event: CookieChangeEvent - The event containing the changed or deleted cookies.
155+ * Token refresh: Updates accessToken store, dispatches event to update user store,
156+ * and restarts WebSocket. While Hasura validates JWT at connection_init, it also
157+ * monitors expiration and kills connections when tokens expire.
158+ *
159+ * Role change: Handled by Nav.svelte → /auth/changeRole → user store update →
160+ * +layout.svelte reactive block → WebSocket restart.
135161 */
136162const handleCookieStoreChange = async ( ev : Event ) => {
137163 const event = ev as CookieChangeEvent ;
@@ -144,34 +170,46 @@ const handleCookieStoreChange = async (ev: Event) => {
144170 'deleted:' ,
145171 event . deleted . map ( c => c . name ) ,
146172 ) ;
147- event . changed . forEach ( async ( { name, value } ) => {
173+
174+ let tokenRefreshed = false ;
175+
176+ event . changed . forEach ( ( { name, value } ) => {
148177 if ( name === 'accessToken' ) {
149- // set user store
150- const baseUser : BaseUser = { id : null , token : value } ; // id can be null because any time this function is used, its in the context of oidc, and we specifically catch id being null for oidc in computeRolesFromJWT
151- const user : User | null = await computeRolesFromJWT ( baseUser , null ) ; // null role because if after a refresh a user has been demoted, wouldn't want to retain an invalid role
152- userStore . set ( user ) ;
153- }
154- if ( name === 'idToken' ) {
155- const decoded = jwtDecode ( value ) ;
156- // update user store
157- userStore . update ( user => {
158- if ( user && decoded . sub ) {
159- return {
160- ...user ,
161- id : decoded . sub ,
162- } ;
163- }
164- return user ;
165- } ) ;
166- }
167- if ( name === 'activeRole' ) {
168- // update the user store
169- userStore . update ( user => {
170- if ( user ) {
171- user . activeRole = value ;
172- }
173- return user ;
174- } ) ;
178+ // Update internal store for refresh timing
179+ accessToken . set ( value ) ;
180+ tokenRefreshed = true ;
181+
182+ // Dispatch event so the layout can update the user store with the fresh token
183+ window . dispatchEvent ( new CustomEvent ( 'oidc-token-refreshed' , { detail : { token : value } } ) ) ;
175184 }
185+ // Note: activeRole changes are handled by Nav.svelte which updates the user store
186+ // directly after receiving the updated user from the server. The +layout.svelte
187+ // reactive statement then detects the role change and restarts the WebSocket.
176188 } ) ;
189+
190+ if ( tokenRefreshed ) {
191+ // Restart WebSocket to pick up new credentials. While Hasura validates JWT only
192+ // at connection_init, it ALSO monitors token expiration and closes connections
193+ // when JWTs expire (observed in Hasura logs: "Could not verify JWT: JWTExpired").
194+ // Restarting proactively with the fresh token prevents this abrupt 1006 close.
195+ console . debug ( 'Token refreshed, restarting WebSocket with fresh credentials.' ) ;
196+ restartSharedClient ( ) ;
197+ }
177198} ;
199+
200+ // HMR resilience: when this module is re-evaluated during HMR, clean up the old listener
201+ // (which references stale handleCookieStoreChange closure) and immediately re-establish
202+ // with fresh module references. This keeps token refresh working during HMR.
203+ // Only re-establish if there's a valid accessToken (user is authenticated).
204+ if ( typeof window !== 'undefined' ) {
205+ const prevCleanup = ( window as any ) . __oidcCookieCleanup as ( ( ) => void ) | undefined ;
206+ if ( prevCleanup ) {
207+ console . debug ( 'HMR: cleaning up old OIDC listeners.' ) ;
208+ prevCleanup ( ) ;
209+ // Only re-establish listener if we have a valid token (user is authenticated)
210+ if ( getCookieValue ( 'accessToken' ) ) {
211+ console . debug ( 'HMR: re-establishing OIDC listeners with fresh module references.' ) ;
212+ cookieStoreListener ( ) ;
213+ }
214+ }
215+ }
0 commit comments