feat(auth): enhance token refresh handling and clear auth state on errors
This commit is contained in:
@@ -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<typeof useAuthStore>): void {
|
||||
authStore.clearAllAuthState()
|
||||
authStore.needsLogin = true
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user