From 35d0578e337124b4e5f33609a9d8f3c049e18748 Mon Sep 17 00:00:00 2001 From: stanig2106 Date: Wed, 29 Apr 2026 21:32:32 +0200 Subject: [PATCH] fix: persist OIDC token in localStorage instead of sessionStorage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug: opening calendar in a new browser tab forced a re-login every time, even when the user was authenticated in another tab. Root cause: tokenSet/userData/redirectState were stored in sessionStorage, which is per-tab by design. New tab → empty sessionStorage → app redirects to OIDC. With Dex's password-DB connector (no IdP-side session cookie), Dex re-prompts for credentials. Mail (tmail-flutter) has the same OIDC flow but persists tokens in IndexedDB (Hive box `tokenoidccache`) which IS shared across tabs — hence mail SSO worked but calendar didn't. Fix: replace sessionStorage with localStorage for the auth-related keys (tokenSet, userData, redirectState). localStorage is shared across tabs of the same origin. Other sessionStorage usages (e.g. eventUpdateModalReopen) are unchanged — they are genuinely per-tab. Files touched: - src/features/User/useInitializeApp.ts (load + setup redirect state) - src/features/User/LoginCallback.tsx (save tokens after callback, read pending redirectState, removeItem on completion) - src/utils/apiUtils.ts (write redirectState during 401-driven SSO redirect) - src/components/Calendar/hooks/useUtilMenus.ts (logout cleanup) Verified: open clean tab → login alice → open another tab on calendar.workavia.local → loaded silently with avatar visible, no login form prompted. --- src/components/Calendar/hooks/useUtilMenus.ts | 2 +- src/features/User/LoginCallback.tsx | 26 +++++++++++-------- src/features/User/useInitializeApp.ts | 10 +++---- src/utils/apiUtils.ts | 6 ++--- 4 files changed, 24 insertions(+), 20 deletions(-) diff --git a/src/components/Calendar/hooks/useUtilMenus.ts b/src/components/Calendar/hooks/useUtilMenus.ts index 5974124..bfd967b 100644 --- a/src/components/Calendar/hooks/useUtilMenus.ts +++ b/src/components/Calendar/hooks/useUtilMenus.ts @@ -58,7 +58,7 @@ export const useUtilMenus = (): { } catch (error) { console.error('Logout failed:', error) } finally { - sessionStorage.removeItem('tokenSet') + localStorage.removeItem('tokenSet') handleUserMenuClose() } } diff --git a/src/features/User/LoginCallback.tsx b/src/features/User/LoginCallback.tsx index b3c2425..781553e 100644 --- a/src/features/User/LoginCallback.tsx +++ b/src/features/User/LoginCallback.tsx @@ -27,26 +27,26 @@ export function CallbackResume() { const runCallback = async () => { // Read redirectState inside useEffect to avoid stale closures - const saved = sessionStorage.getItem('redirectState') - ? JSON.parse(sessionStorage.getItem('redirectState') ?? '{}') + const saved = localStorage.getItem('redirectState') + ? JSON.parse(localStorage.getItem('redirectState') ?? '{}') : null // Check if we have saved tokens (already logged in) - const savedToken = sessionStorage.getItem('tokenSet') - ? JSON.parse(sessionStorage.getItem('tokenSet') ?? '{}') + const savedToken = localStorage.getItem('tokenSet') + ? JSON.parse(localStorage.getItem('tokenSet') ?? '{}') : null // If no redirectState but we have saved session, just go home // This can happen if user refreshes callback page or gets redirected here after already logged in if (!saved?.code_verifier) { if (savedToken) { - sessionStorage.removeItem('redirectState') + localStorage.removeItem('redirectState') dispatch(replace('/')) return } console.warn('Missing redirectState') - sessionStorage.removeItem('redirectState') + localStorage.removeItem('redirectState') dispatch(replace('/')) return } @@ -60,10 +60,14 @@ export function CallbackResume() { throw new Error('OAuth callback failed') } - // IMPORTANT: Save tokens to sessionStorage FIRST before making any API calls - // because API calls will read token from sessionStorage - sessionStorage.setItem('tokenSet', JSON.stringify(data.tokenSet)) - sessionStorage.setItem('userData', JSON.stringify(data.userinfo)) + // IMPORTANT: Save tokens to localStorage FIRST before making any API calls + // because API calls will read token from localStorage. + // localStorage (not sessionStorage) so the OIDC session is shared + // across browser tabs — otherwise each new tab forces a re-login + // since Dex (with enablePasswordDB connector) does NOT keep an + // IdP-side session cookie. + localStorage.setItem('tokenSet', JSON.stringify(data.tokenSet)) + localStorage.setItem('userData', JSON.stringify(data.userinfo)) dispatch(setUserData(data.userinfo)) dispatch(setTokens(data.tokenSet)) @@ -71,7 +75,7 @@ export function CallbackResume() { await dispatch(getOpenPaasUserDataAsync()) await dispatch(getCalendarsListAsync()) - sessionStorage.removeItem('redirectState') + localStorage.removeItem('redirectState') } catch (e) { console.error('OIDC callback error:', e) dispatch(setAppLoading(false)) diff --git a/src/features/User/useInitializeApp.ts b/src/features/User/useInitializeApp.ts index 7a63b16..907ea7d 100644 --- a/src/features/User/useInitializeApp.ts +++ b/src/features/User/useInitializeApp.ts @@ -23,11 +23,11 @@ export function useInitializeApp() { hasInitiatedRef.current = true const initiateLogin = async () => { - const savedToken = sessionStorage.getItem('tokenSet') - ? JSON.parse(sessionStorage.getItem('tokenSet') ?? '{}') + const savedToken = localStorage.getItem('tokenSet') + ? JSON.parse(localStorage.getItem('tokenSet') ?? '{}') : null - const savedUser = sessionStorage.getItem('userData') - ? JSON.parse(sessionStorage.getItem('userData') ?? '{}') + const savedUser = localStorage.getItem('userData') + ? JSON.parse(localStorage.getItem('userData') ?? '{}') : null if (savedToken && savedUser) { @@ -45,7 +45,7 @@ export function useInitializeApp() { } const loginurl = await Auth() - sessionStorage.setItem( + localStorage.setItem( 'redirectState', JSON.stringify({ code_verifier: loginurl.code_verifier, diff --git a/src/utils/apiUtils.ts b/src/utils/apiUtils.ts index a04e0b8..16a0c9b 100644 --- a/src/utils/apiUtils.ts +++ b/src/utils/apiUtils.ts @@ -35,7 +35,7 @@ const redirectSSO = async ( try { const loginurl = await Auth() - sessionStorage.setItem( + localStorage.setItem( 'redirectState', JSON.stringify({ code_verifier: loginurl.code_verifier, @@ -85,9 +85,9 @@ export const api: KyInstance = ky.extend({ hooks: { beforeRequest: [ async (request: KyRequest): Promise => { - const saved = sessionStorage.getItem('tokenSet') + const saved = localStorage.getItem('tokenSet') ? (JSON.parse( - sessionStorage.getItem('tokenSet') ?? '{}' + localStorage.getItem('tokenSet') ?? '{}' ) as TokenEndpointResponse & TokenEndpointResponseHelpers) : null const access_token = saved?.access_token as string