diff --git a/packages/stage-ui/src/composables/api.ts b/packages/stage-ui/src/composables/api.ts index 214a2a343..56421d57f 100644 --- a/packages/stage-ui/src/composables/api.ts +++ b/packages/stage-ui/src/composables/api.ts @@ -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(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, }) diff --git a/packages/stage-ui/src/libs/auth-fetch.ts b/packages/stage-ui/src/libs/auth-fetch.ts new file mode 100644 index 000000000..4a595f5ee --- /dev/null +++ b/packages/stage-ui/src/libs/auth-fetch.ts @@ -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 { + const doFetch = (token: string | null): Promise => { + 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) +} diff --git a/packages/stage-ui/src/libs/auth.ts b/packages/stage-ui/src/libs/auth.ts index 798318773..89464e053 100644 --- a/packages/stage-ui/src/libs/auth.ts +++ b/packages/stage-ui/src/libs/auth.ts @@ -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(() => {}) } /** diff --git a/packages/stage-ui/src/stores/auth.ts b/packages/stage-ui/src/stores/auth.ts index f20de81b3..b1a89a764 100644 --- a/packages/stage-ui/src/stores/auth.ts +++ b/packages/stage-ui/src/stores/auth.ts @@ -115,13 +115,20 @@ export const useAuthStore = defineStore('auth', () => { type TokenRefreshedHook = (accessToken: string) => void | Promise 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 | null = null + async function refreshTokenNow(): Promise { + 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 { 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, }