fix: prevent forced re-login from concurrent refresh token rotation
Rotated refresh tokens now stay valid for a 60s grace window so concurrent refreshes (e.g. multiple browser tabs) each obtain a valid new pair instead of being logged out. Admin-web additionally serializes refreshes across tabs via the Web Locks API and reuses tokens already rotated by another tab. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -179,6 +179,7 @@ import {
|
||||
markStoredMembershipBlocked,
|
||||
readStoredSession,
|
||||
setStoredTokens,
|
||||
type SessionSnapshot,
|
||||
} from './session'
|
||||
|
||||
const rawBaseURL = import.meta.env.VITE_API_BASE_URL ?? ''
|
||||
@@ -265,6 +266,64 @@ export function shouldRefreshAccessToken(
|
||||
return !expiresAt || expiresAt * 1000 - Date.now() <= minRemainingMs
|
||||
}
|
||||
|
||||
const authRefreshLockName = 'geo.admin-web.auth-refresh'
|
||||
|
||||
async function withCrossTabRefreshLock<T>(task: () => Promise<T>): Promise<T> {
|
||||
const locks = typeof navigator !== 'undefined' ? navigator.locks : undefined
|
||||
if (!locks?.request) {
|
||||
return task()
|
||||
}
|
||||
return (await locks.request(authRefreshLockName, task)) as T
|
||||
}
|
||||
|
||||
function storedTokensFromSnapshot(snapshot: SessionSnapshot): AuthTokens | null {
|
||||
if (!snapshot.accessToken || !snapshot.refreshToken) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
accessToken: snapshot.accessToken,
|
||||
refreshToken: snapshot.refreshToken,
|
||||
expiresAt: snapshot.accessTokenExpiresAt ?? undefined,
|
||||
}
|
||||
}
|
||||
|
||||
async function performStoredAuthRefresh(initialRefreshToken: string): Promise<AuthTokens> {
|
||||
// Another tab may have rotated the token while we waited for the lock:
|
||||
// reuse its result instead of racing the one-time-use refresh token.
|
||||
const current = readStoredSession()
|
||||
const currentTokens = storedTokensFromSnapshot(current)
|
||||
if (
|
||||
currentTokens &&
|
||||
currentTokens.refreshToken !== initialRefreshToken &&
|
||||
!shouldRefreshAccessToken(current.accessTokenExpiresAt)
|
||||
) {
|
||||
return currentTokens
|
||||
}
|
||||
|
||||
const requestedRefreshToken = currentTokens?.refreshToken ?? initialRefreshToken
|
||||
|
||||
try {
|
||||
const data = await publicClient.post<RefreshResponse, { refresh_token: string }>(
|
||||
'/api/auth/refresh',
|
||||
{ refresh_token: requestedRefreshToken },
|
||||
)
|
||||
const tokens = toAuthTokens(data)
|
||||
setStoredTokens(tokens)
|
||||
return tokens
|
||||
} catch (error) {
|
||||
if (isAuthSessionExpiredError(error)) {
|
||||
const snapshot = readStoredSession()
|
||||
const fallbackTokens = storedTokensFromSnapshot(snapshot)
|
||||
if (fallbackTokens && fallbackTokens.refreshToken !== requestedRefreshToken) {
|
||||
return fallbackTokens
|
||||
}
|
||||
|
||||
expireStoredSession()
|
||||
}
|
||||
throw markAuthErrorHandled(error)
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshStoredAuthSession(
|
||||
refreshToken = getStoredRefreshToken(),
|
||||
): Promise<AuthTokens> {
|
||||
@@ -273,42 +332,12 @@ export async function refreshStoredAuthSession(
|
||||
throw authRequiredError()
|
||||
}
|
||||
|
||||
const requestedRefreshToken = refreshToken
|
||||
|
||||
if (!storedRefreshPromise) {
|
||||
storedRefreshPromise = publicClient
|
||||
.post<RefreshResponse, { refresh_token: string }>('/api/auth/refresh', {
|
||||
refresh_token: requestedRefreshToken,
|
||||
})
|
||||
.then((data) => {
|
||||
const tokens = toAuthTokens(data)
|
||||
setStoredTokens(tokens)
|
||||
return tokens
|
||||
})
|
||||
.catch((error) => {
|
||||
if (isAuthSessionExpiredError(error)) {
|
||||
const current = readStoredSession()
|
||||
if (
|
||||
current.accessToken &&
|
||||
current.refreshToken &&
|
||||
current.refreshToken !== requestedRefreshToken
|
||||
) {
|
||||
const tokens = {
|
||||
accessToken: current.accessToken,
|
||||
refreshToken: current.refreshToken,
|
||||
expiresAt: current.accessTokenExpiresAt ?? undefined,
|
||||
}
|
||||
setStoredTokens(tokens)
|
||||
return tokens
|
||||
}
|
||||
|
||||
expireStoredSession()
|
||||
}
|
||||
throw markAuthErrorHandled(error)
|
||||
})
|
||||
.finally(() => {
|
||||
storedRefreshPromise = null
|
||||
})
|
||||
storedRefreshPromise = withCrossTabRefreshLock(() =>
|
||||
performStoredAuthRefresh(refreshToken),
|
||||
).finally(() => {
|
||||
storedRefreshPromise = null
|
||||
})
|
||||
}
|
||||
|
||||
return storedRefreshPromise
|
||||
|
||||
Reference in New Issue
Block a user