diff --git a/packages/stage-ui/src/libs/auth-fetch.ts b/packages/stage-ui/src/libs/auth-fetch.ts index 4a595f5ee..5c5c0c7b4 100644 --- a/packages/stage-ui/src/libs/auth-fetch.ts +++ b/packages/stage-ui/src/libs/auth-fetch.ts @@ -10,6 +10,12 @@ import { getAuthToken } from './auth' * suspended tabs, and the post-reload race (fetchSession firing before * restoreRefreshSchedule resolves) can all leak an expired Bearer through. * The reactive 401 path is the safety net. + * + * When refresh cannot succeed — missing state (refreshToken/oidcClientId), + * refresh endpoint errors, or a retried request that still returns 401 — + * clear local auth state and flip `needsLogin` so the user is prompted to + * sign in immediately, instead of letting the dead session linger until the + * next fetchSession call on the home page. */ export async function authedFetch( input: RequestInfo | URL, @@ -33,9 +39,20 @@ export async function authedFetch( if (url.includes('/oauth2/token')) return response - const newToken = await useAuthStore().refreshTokenNow() - if (!newToken) + const authStore = useAuthStore() + const newToken = await authStore.refreshTokenNow() + if (!newToken) { + promptReLogin(authStore) return response + } - return doFetch(newToken) + const retried = await doFetch(newToken) + if (retried.status === 401) + promptReLogin(authStore) + return retried +} + +function promptReLogin(authStore: ReturnType): void { + authStore.clearAllAuthState() + authStore.needsLogin = true } diff --git a/packages/stage-ui/src/libs/auth.ts b/packages/stage-ui/src/libs/auth.ts index e0b1cea1d..bc21837a6 100644 --- a/packages/stage-ui/src/libs/auth.ts +++ b/packages/stage-ui/src/libs/auth.ts @@ -45,12 +45,29 @@ export async function initializeAuth() { initialized = true + const authStore = useAuthStore() + + // Normalize "half-cleared" persisted state before anything reads it. + // + // Why: `refreshToken` was added to the auth store before `oidcClientId` + // (commit c73ceeb1f predates f1fe161bc), and `clearOIDCState` (now removed) + // used to clear only the OIDC pair. Browsers that saw either code path can + // end up with a refreshToken but no oidcClientId, which makes + // `refreshTokenNow()` early-return forever — 401s then silently accumulate + // on non-home pages until the user lands on a route that calls fetchSession. + // + // Treat any mismatch as an unauthenticated session; the user will get a + // fresh OIDC login prompt via the standard 401→needsLogin path. + const hasRefreshToken = !!authStore.refreshToken + const hasClientId = !!authStore.oidcClientId + if (hasRefreshToken !== hasClientId) + authStore.clearAllAuthState() + // NOTICE: restoreRefreshSchedule must complete BEFORE fetchSession when // the persisted access token is already expired. Otherwise fetchSession // hits /get-session with the stale Bearer, gets 401, and wipes // refreshToken + oidcClientId before the scheduled refresh can run — // silently logging the user out on reload. - const authStore = useAuthStore() authStore.onTokenRefreshed(async (accessToken) => { authStore.token = accessToken await fetchSession() @@ -94,11 +111,7 @@ export async function fetchSession() { } // Session expired or invalid — clear stale auth state from localStorage - authStore.user = null - authStore.session = null - authStore.token = null - authStore.refreshToken = null - authStore.clearOIDCState() + authStore.clearAllAuthState() return false } @@ -162,11 +175,7 @@ export async function signOut() { // be wiped, so the local user has no way to spend it in the meantime. } - authStore.clearOIDCState() - authStore.user = null - authStore.session = null - authStore.token = null - authStore.refreshToken = null + authStore.clearAllAuthState() } /** diff --git a/packages/stage-ui/src/stores/auth.ts b/packages/stage-ui/src/stores/auth.ts index 405bf9068..e0a05c343 100644 --- a/packages/stage-ui/src/stores/auth.ts +++ b/packages/stage-ui/src/stores/auth.ts @@ -151,13 +151,7 @@ export const useAuthStore = defineStore('auth', () => { return tokens.access_token } catch { - user.value = null - session.value = null - token.value = null - refreshToken.value = null - idToken.value = null - oidcClientId.value = null - tokenExpiry.value = null + clearAllAuthState() return null } finally { @@ -176,6 +170,11 @@ export const useAuthStore = defineStore('auth', () => { function scheduleTokenRefresh(expiresInSeconds: number): void { stopRefreshTimer() + // Guard against missing/invalid lifetimes (e.g. token response omitted + // expires_in). useTimeoutFn with NaN/<=0 delay would fire immediately + // and spin a refresh loop — skip scheduling instead. + if (!Number.isFinite(expiresInSeconds) || expiresInSeconds <= 0) + return // Refresh at 80% of lifetime refreshDelayMs.value = expiresInSeconds * 0.8 * 1000 startRefreshTimer() @@ -212,8 +211,23 @@ export const useAuthStore = defineStore('auth', () => { } } - function clearOIDCState(): void { + /** + * Reset every auth-related field atomically. + * + * Use when: signing out, refresh fails, session is rejected by server, or + * persisted state is detected inconsistent. + * + * Why atomic: `refreshToken` and `oidcClientId` must either both exist or + * both be absent. A "half-cleared" state (one present, one null) makes + * `refreshTokenNow()` early-return without attempting refresh, so 401s + * loop silently until the user lands on a page that calls fetchSession. + */ + function clearAllAuthState(): void { stopRefreshTimer() + user.value = null + session.value = null + token.value = null + refreshToken.value = null oidcClientId.value = null tokenExpiry.value = null idToken.value = null @@ -260,7 +274,7 @@ export const useAuthStore = defineStore('auth', () => { scheduleTokenRefresh, restoreRefreshSchedule, refreshTokenNow, - clearOIDCState, + clearAllAuthState, onTokenRefreshed, } })