fix(stage-ui): refresh token concurrency

This commit is contained in:
RainbowBird
2026-04-17 02:59:14 +08:00
committed by RainbowBird
parent 8967bbe211
commit bef1598179
4 changed files with 84 additions and 28 deletions
+2 -13
View File
@@ -2,20 +2,9 @@ import type { AppType } from '../../../../apps/server/src/app'
import { hc } from 'hono/client'
import { getAuthToken } from '../libs/auth'
import { authedFetch } from '../libs/auth-fetch'
import { SERVER_URL } from '../libs/server'
export const client = hc<AppType>(SERVER_URL, {
fetch: (input: RequestInfo | URL, init?: RequestInit) => {
const headers = new Headers(init?.headers)
const token = getAuthToken()
if (token) {
headers.set('Authorization', `Bearer ${token}`)
}
return fetch(input, {
...init,
headers,
credentials: 'omit',
})
},
fetch: authedFetch,
})
+41
View File
@@ -0,0 +1,41 @@
import { useAuthStore } from '../stores/auth'
import { getAuthToken } from './auth'
/**
* Fetch wrapper that transparently refreshes the OIDC access token on 401
* and retries the original request once. Refresh is single-flight across
* concurrent callers via the auth store's `refreshTokenNow()` action.
*
* Why not rely on the proactive 80%-lifetime scheduler alone: clock skew,
* 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.
*/
export async function authedFetch(
input: RequestInfo | URL,
init?: RequestInit,
): Promise<Response> {
const doFetch = (token: string | null): Promise<Response> => {
const headers = new Headers(init?.headers)
if (token)
headers.set('Authorization', `Bearer ${token}`)
return fetch(input, { ...init, headers, credentials: 'omit' })
}
const response = await doFetch(getAuthToken())
if (response.status !== 401)
return response
// Don't recurse on the token endpoint itself
const url = typeof input === 'string'
? input
: input instanceof URL ? input.toString() : input.url
if (url.includes('/oauth2/token'))
return response
const newToken = await useAuthStore().refreshTokenNow()
if (!newToken)
return response
return doFetch(newToken)
}
+9 -6
View File
@@ -34,7 +34,7 @@ export const authClient = createAuthClient({
let initialized = false
export function initializeAuth() {
export async function initializeAuth() {
if (initialized)
return
@@ -42,18 +42,21 @@ export function initializeAuth() {
// (e.g. /auth/callback). initializeAuth() only restores existing
// sessions and refresh schedules — it does NOT consume the code.
fetchSession().catch(() => {})
initialized = true
// Restore OIDC token refresh scheduling from persisted state
// 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.restoreRefreshSchedule()
authStore.onTokenRefreshed(async (accessToken) => {
authStore.token = accessToken
await fetchSession()
})
initialized = true
await authStore.restoreRefreshSchedule()
await fetchSession().catch(() => {})
}
/**
+32 -9
View File
@@ -115,13 +115,20 @@ export const useAuthStore = defineStore('auth', () => {
type TokenRefreshedHook = (accessToken: string) => void | Promise<void>
const tokenRefreshedHooks: TokenRefreshedHook[] = []
const { start: startRefreshTimer, stop: stopRefreshTimer } = useTimeoutFn(
async () => {
if (!refreshToken.value || !oidcClientId.value)
return
// Single-flight refresh: multiple concurrent callers (timer + 401 retry + restore)
// must not trigger multiple token exchanges. All share one in-flight promise.
let inflightRefresh: Promise<string | null> | null = null
async function refreshTokenNow(): Promise<string | null> {
if (inflightRefresh)
return inflightRefresh
if (!refreshToken.value || !oidcClientId.value)
return null
inflightRefresh = (async () => {
try {
const tokens = await refreshAccessToken(oidcClientId.value, refreshToken.value)
const tokens = await refreshAccessToken(oidcClientId.value!, refreshToken.value!)
token.value = tokens.access_token
if (tokens.refresh_token)
refreshToken.value = tokens.refresh_token
@@ -138,6 +145,8 @@ export const useAuthStore = defineStore('auth', () => {
console.error('token refresh hook error', e)
}
}
return tokens.access_token
}
catch {
user.value = null
@@ -146,8 +155,18 @@ export const useAuthStore = defineStore('auth', () => {
refreshToken.value = null
oidcClientId.value = null
tokenExpiry.value = null
return null
}
},
finally {
inflightRefresh = null
}
})()
return inflightRefresh
}
const { start: startRefreshTimer, stop: stopRefreshTimer } = useTimeoutFn(
() => { refreshTokenNow() },
refreshDelayMs,
{ immediate: false },
)
@@ -161,8 +180,11 @@ export const useAuthStore = defineStore('auth', () => {
/**
* Restore refresh scheduling from persisted state after page reload.
* Returns a promise that resolves after an immediate refresh completes
* (when the persisted token is already expired) so callers can avoid
* racing `fetchSession()` against a stale Bearer token.
*/
function restoreRefreshSchedule(): void {
async function restoreRefreshSchedule(): Promise<void> {
if (!refreshToken.value || !oidcClientId.value)
return
@@ -174,8 +196,8 @@ export const useAuthStore = defineStore('auth', () => {
}
}
// Token already expired or no expiry info — refresh immediately
scheduleTokenRefresh(0)
// Already expired — refresh synchronously so subsequent requests use fresh token
await refreshTokenNow()
}
function onTokenRefreshed(hook: TokenRefreshedHook) {
@@ -232,6 +254,7 @@ export const useAuthStore = defineStore('auth', () => {
tokenExpiry,
scheduleTokenRefresh,
restoreRefreshSchedule,
refreshTokenNow,
clearOIDCState,
onTokenRefreshed,
}