feat(auth): enhance token refresh handling and clear auth state on errors

This commit is contained in:
RainbowBird
2026-05-08 21:14:00 +08:00
parent 13566667e1
commit 9d6d99192c
3 changed files with 63 additions and 23 deletions
+20 -3
View File
@@ -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
}
+20 -11
View File
@@ -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()
}
/**
+23 -9
View File
@@ -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,
}
})