fix(stage-ui): refresh token concurrency
This commit is contained in:
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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(() => {})
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user