Skip to content

Commit 6bf7260

Browse files
committed
Fix WebSocket lifecycle and token refresh for OIDC
Key changes: - Add proactive WebSocket restart on token refresh (Hasura monitors JWT expiration and kills connections) - Implement hybrid auto-recovery for subscription errors (connectionState listener + fallback timer) - Add token refresh retry on failure (offline resilience) - Fix HMR resilience for cookie store listeners - Prevent token refresh on login page - Fix logout to use window.location.href instead of goto() for server-only routes - Remove noisy auth error logging during normal logout flow - Add getCookieValue utility for reading cookies
1 parent 23c109c commit 6bf7260

7 files changed

Lines changed: 298 additions & 97 deletions

File tree

src/lib/stores/oidc.ts

Lines changed: 87 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
11
import { 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';
65
import type { MaybeToken } from '../types/oidc';
7-
import { userStore } from './auth';
86

97
type CookieChanged = {
108
domain: string;
@@ -31,10 +29,19 @@ type CookieStore = {
3129
declare 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+
3845
export 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
*/
136162
const 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+
}

src/routes/+layout.server.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,7 @@ export const load: LayoutServerLoad = async ({ locals, url }) => {
1414
if (env.PUBLIC_AUTH_OIDC_ENABLED === 'true' && !nonProtectedPage) {
1515
try {
1616
enforce(locals?.user, userIsDefined);
17-
} catch (error) {
18-
console.log(error);
17+
} catch {
1918
const redirectTo = encodeURIComponent(url.pathname + url.search);
2019
redirect(302, `${base}/login?redirectTo=${redirectTo}`);
2120
}

src/routes/+layout.svelte

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import { disposeSharedClient, restartSharedClient } from '../stores/gqlClient';
1717
import { plugins, pluginsError, pluginsLoaded } from '../stores/plugins';
1818
import type { UserStore } from '../types/app';
19+
import { getCookieValue } from '../utilities/browser';
1920
import { loadPluginCode } from '../utilities/plugins';
2021
import type { LayoutData } from './$types';
2122
@@ -29,34 +30,62 @@
2930
$pluginsLoaded = pluginsEnabled ? false : true;
3031
3132
$: {
32-
user.set(data.user || null);
33+
let userData = data.user ? { ...data.user } : null;
34+
// In OIDC mode, data.user may be stale after HMR (token expired, role changed).
35+
// Replace with current cookie values.
36+
if (env.PUBLIC_AUTH_OIDC_ENABLED === 'true' && userData) {
37+
const freshToken = getCookieValue('accessToken');
38+
if (freshToken) {
39+
userData = { ...userData, token: freshToken };
40+
}
41+
const activeRole = getCookieValue('activeRole');
42+
if (activeRole) {
43+
userData = { ...userData, activeRole };
44+
}
45+
}
46+
user.set(userData);
3347
}
3448
3549
// Only restart WebSocket when role actually changes, not on every navigation
3650
// graphql-ws automatically re-subscribes all active subscriptions when reconnected
3751
$: {
3852
const newRole = $user?.activeRole ?? null;
39-
if (newRole !== previousRole && previousRole !== null) {
53+
// Only restart when role actually changes, not on initial load
54+
if (previousRole !== null && newRole !== previousRole) {
4055
restartSharedClient();
4156
}
4257
previousRole = newRole;
4358
}
4459
4560
onMount(() => {
46-
let unsubscribe = () => {};
47-
if (env.PUBLIC_AUTH_OIDC_ENABLED === 'true') {
48-
unsubscribe = cookieStoreListener();
61+
const onTokenRefreshed = (e: Event) => {
62+
const { token } = (e as CustomEvent<{ token: string }>).detail;
63+
user.update(u => (u ? { ...u, token } : u));
64+
};
65+
66+
if (env.PUBLIC_AUTH_OIDC_ENABLED === 'true' && $user) {
67+
cookieStoreListener();
68+
window.addEventListener('oidc-token-refreshed', onTokenRefreshed);
4969
}
5070
5171
if (pluginsEnabled && !$pluginsLoaded) {
5272
loadPlugins();
5373
}
5474
5575
return () => {
56-
unsubscribe();
76+
// Use the window-stored cleanup which always targets the current listener,
77+
// even if HMR re-established it with fresh module references.
78+
const oidcCleanup = (window as any).__oidcCookieCleanup as (() => void) | undefined;
79+
oidcCleanup?.();
80+
window.removeEventListener('oidc-token-refreshed', onTokenRefreshed);
5781
console.log('Unsubscribed from cookie store changes.');
5882
59-
disposeSharedClient();
83+
// Skip disposing the WebSocket client during HMR - the client should persist
84+
// across layout re-mounts so restartSharedClient() can still manage it.
85+
// On full page unload, the browser closes the WebSocket automatically.
86+
if (!import.meta.hot) {
87+
disposeSharedClient();
88+
}
6089
};
6190
});
6291

0 commit comments

Comments
 (0)