fix: persist OIDC token in localStorage instead of sessionStorage

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.
This commit is contained in:
stanig2106
2026-04-29 21:32:32 +02:00
parent faefd31ac3
commit 35d0578e33
4 changed files with 24 additions and 20 deletions
@@ -58,7 +58,7 @@ export const useUtilMenus = (): {
} catch (error) {
console.error('Logout failed:', error)
} finally {
sessionStorage.removeItem('tokenSet')
localStorage.removeItem('tokenSet')
handleUserMenuClose()
}
}
+15 -11
View File
@@ -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))
+5 -5
View File
@@ -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,
+3 -3
View File
@@ -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<KyRequest> => {
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