LibreChat/packages/data-provider/src/request.ts
Danny Avila 753e53eddd
🛬 fix: Coalesce Auth Recovery into a Single Refresh Flight (#13618)
* fix auth recovery singleflight

* add auth recovery e2e coverage

* handle invalid auth redirect timestamp
2026-06-09 12:04:12 -04:00

331 lines
9.7 KiB
TypeScript

/* eslint-disable @typescript-eslint/no-explicit-any */
import axios, { AxiosRequestConfig } from 'axios';
import type * as t from './types';
import { setTokenHeader } from './headers-helpers';
import * as endpoints from './api-endpoints';
async function _get<T>(url: string, options?: AxiosRequestConfig): Promise<T> {
const response = await axios.get(url, { ...options });
return response.data;
}
async function _getResponse<T>(url: string, options?: AxiosRequestConfig): Promise<T> {
return await axios.get(url, { ...options });
}
async function _post(url: string, data?: any) {
const response = await axios.post(url, JSON.stringify(data), {
headers: { 'Content-Type': 'application/json' },
});
return response.data;
}
async function _postMultiPart(url: string, formData: FormData, options?: AxiosRequestConfig) {
const response = await axios.post(url, formData, {
...options,
headers: { 'Content-Type': 'multipart/form-data' },
});
return response.data;
}
async function _postTTS(url: string, formData: FormData, options?: AxiosRequestConfig) {
const response = await axios.post(url, formData, {
...options,
headers: { 'Content-Type': 'multipart/form-data' },
responseType: 'arraybuffer',
});
return response.data;
}
async function _put(url: string, data?: any) {
const response = await axios.put(url, JSON.stringify(data), {
headers: { 'Content-Type': 'application/json' },
});
return response.data;
}
async function _delete<T>(url: string): Promise<T> {
const response = await axios.delete(url);
return response.data;
}
async function _deleteWithOptions<T>(url: string, options?: AxiosRequestConfig): Promise<T> {
const response = await axios.delete(url, { ...options });
return response.data;
}
async function _patch(url: string, data?: any) {
const response = await axios.patch(url, JSON.stringify(data), {
headers: { 'Content-Type': 'application/json' },
});
return response.data;
}
const AUTH_RECOVERY_EVENT = 'authRecovery';
const AUTH_REDIRECT_EVENT = 'authRedirectStarted';
const AUTH_REDIRECT_STORAGE_KEY = 'librechat.auth.redirect.startedAt';
const AUTH_REDIRECT_DEDUPE_MS = 15_000;
const TOKEN_REFRESH_BUFFER_MS = 2 * 60 * 1000;
type RetryableAxiosRequestConfig = AxiosRequestConfig & { _retry?: boolean };
type AuthRecoveryState = {
lastRedirectStartedAt: number;
refreshPromise: Promise<string | null> | null;
};
type AuthRecoveryWindow = Window & {
__librechatAuthRecovery?: AuthRecoveryState;
};
const refreshToken = (retry?: boolean): Promise<t.TRefreshTokenResponse | undefined> =>
_post(endpoints.refreshToken(retry));
const dispatchTokenUpdatedEvent = (token: string) => {
setTokenHeader(token);
clearAuthRedirectStartedAt();
window.dispatchEvent(new CustomEvent('tokenUpdated', { detail: token }));
};
const getAuthRecoveryState = (): AuthRecoveryState => {
const browserWindow = window as AuthRecoveryWindow;
browserWindow.__librechatAuthRecovery ??= {
lastRedirectStartedAt: 0,
refreshPromise: null,
};
return browserWindow.__librechatAuthRecovery;
};
const getAuthRedirectStartedAt = () => {
const state = getAuthRecoveryState();
try {
const startedAt = window.localStorage.getItem(AUTH_REDIRECT_STORAGE_KEY);
const storedStartedAt = startedAt != null ? Number(startedAt) : 0;
const finiteStartedAt = Number.isFinite(storedStartedAt) ? storedStartedAt : 0;
return Math.max(finiteStartedAt, state.lastRedirectStartedAt);
} catch {
return state.lastRedirectStartedAt;
}
};
const setAuthRedirectStartedAt = () => {
const state = getAuthRecoveryState();
state.lastRedirectStartedAt = Date.now();
try {
window.localStorage.setItem(AUTH_REDIRECT_STORAGE_KEY, String(state.lastRedirectStartedAt));
} catch {
// localStorage can be blocked in embedded/private contexts.
}
};
const clearAuthRedirectStartedAt = () => {
const state = getAuthRecoveryState();
state.lastRedirectStartedAt = 0;
try {
window.localStorage.removeItem(AUTH_REDIRECT_STORAGE_KEY);
} catch {
// Ignore unavailable storage.
}
};
const isAuthRedirectInProgress = () => {
const startedAt = getAuthRedirectStartedAt();
return (
Number.isFinite(startedAt) && startedAt > 0 && Date.now() - startedAt < AUTH_REDIRECT_DEDUPE_MS
);
};
const dispatchAuthRecoveryEvent = (state: 'started' | 'finished') => {
window.dispatchEvent(new CustomEvent(AUTH_RECOVERY_EVENT, { detail: { state } }));
};
const setRequestAuthorizationHeader = (config: AxiosRequestConfig, token: string) => {
const headers = (config.headers ?? {}) as Record<string, string>;
headers['Authorization'] = `Bearer ${token}`;
config.headers = headers;
};
const isAuthRecoveryEndpoint = (url?: string) =>
url?.includes('/api/auth/2fa') === true ||
url?.includes('/api/auth/logout') === true ||
url?.includes('/api/auth/refresh') === true;
const startAuthRecovery = (retryRefresh?: boolean) => {
const state = getAuthRecoveryState();
if (state.refreshPromise) {
return state.refreshPromise;
}
dispatchAuthRecoveryEvent('started');
state.refreshPromise = refreshToken(retryRefresh)
.then((response) => {
const token = response?.token ?? '';
if (!token) {
return null;
}
dispatchTokenUpdatedEvent(token);
return token;
})
.finally(() => {
state.refreshPromise = null;
dispatchAuthRecoveryEvent('finished');
});
return state.refreshPromise;
};
const redirectToLoginOnce = () => {
if (isAuthRedirectInProgress()) {
return;
}
const href = endpoints.apiBaseUrl() + endpoints.buildLoginRedirectUrl();
setAuthRedirectStartedAt();
window.dispatchEvent(new CustomEvent(AUTH_REDIRECT_EVENT, { detail: { href } }));
window.location.href = href;
};
const getBearerToken = () => {
const authorization = axios.defaults.headers.common['Authorization'];
if (typeof authorization !== 'string' || !authorization.startsWith('Bearer ')) {
return null;
}
return authorization.slice('Bearer '.length);
};
const getJwtExpiryMs = (token: string) => {
const payload = token.split('.')[1];
if (!payload) {
return null;
}
try {
const normalizedPayload = payload.replace(/-/g, '+').replace(/_/g, '/');
const paddedPayload = normalizedPayload.padEnd(
normalizedPayload.length + ((4 - (normalizedPayload.length % 4)) % 4),
'=',
);
const decodedPayload = JSON.parse(window.atob(paddedPayload)) as { exp?: number };
return typeof decodedPayload.exp === 'number' ? decodedPayload.exp * 1000 : null;
} catch {
return null;
}
};
const shouldRefreshBeforeRequest = (url?: string) => {
if (isAuthRecoveryEndpoint(url) || isAuthRedirectInProgress()) {
return false;
}
const token = getBearerToken();
if (!token) {
return false;
}
const expiresAt = getJwtExpiryMs(token);
if (expiresAt == null) {
return false;
}
const timeUntilExpiry = expiresAt - Date.now();
return timeUntilExpiry > 0 && timeUntilExpiry <= TOKEN_REFRESH_BUFFER_MS;
};
if (typeof window !== 'undefined') {
axios.interceptors.request.use(async (config) => {
const state = getAuthRecoveryState();
if (state.refreshPromise && !isAuthRecoveryEndpoint(config.url)) {
const token = await state.refreshPromise.catch(() => null);
if (token) {
setRequestAuthorizationHeader(config, token);
}
return config;
}
if (!shouldRefreshBeforeRequest(config.url)) {
return config;
}
const token = await startAuthRecovery(false).catch(() => null);
if (token) {
setRequestAuthorizationHeader(config, token);
}
return config;
});
axios.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config as RetryableAxiosRequestConfig | undefined;
if (!error.response) {
return Promise.reject(error);
}
if (!originalRequest) {
return Promise.reject(error);
}
const isRefreshRequest = originalRequest.url?.includes('/api/auth/refresh') === true;
if (isAuthRecoveryEndpoint(originalRequest.url) && !isRefreshRequest) {
return Promise.reject(error);
}
if (isRefreshRequest && getAuthRecoveryState().refreshPromise) {
return Promise.reject(error);
}
/** Skip refresh when the Authorization header has been cleared (e.g. during logout),
* but allow shared link requests to proceed so auth recovery/redirect can happen */
if (
!axios.defaults.headers.common['Authorization'] &&
!window.location.pathname.startsWith('/share/')
) {
return Promise.reject(error);
}
if (isAuthRedirectInProgress()) {
return Promise.reject(error);
}
if (error.response.status === 401 && !originalRequest._retry) {
const hasActiveRecovery = getAuthRecoveryState().refreshPromise != null;
if (!hasActiveRecovery) {
console.warn('401 error, refreshing token');
}
originalRequest._retry = true;
try {
const token = await startAuthRecovery(
// Handle edge case where we get a blank screen if the initial 401 error is from a refresh token request
isRefreshRequest,
);
if (token) {
setRequestAuthorizationHeader(originalRequest, token);
return await axios(originalRequest);
}
redirectToLoginOnce();
return Promise.reject(error);
} catch (err) {
return Promise.reject(err);
}
}
return Promise.reject(error);
},
);
}
export default {
get: _get,
getResponse: _getResponse,
post: _post,
postMultiPart: _postMultiPart,
postTTS: _postTTS,
put: _put,
delete: _delete,
deleteWithOptions: _deleteWithOptions,
patch: _patch,
refreshToken,
dispatchTokenUpdatedEvent,
};