Skip to content

Commit ba8cdea

Browse files
committed
Handle bad tokens more resiliently
1 parent 62f6e29 commit ba8cdea

1 file changed

Lines changed: 70 additions & 8 deletions

File tree

frontend/src/utils/api.ts

Lines changed: 70 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,13 @@ export const API_BASE_URL = RAW_URL.endsWith('/')
88
: RAW_URL;
99

1010
export const BACKEND_URL = new URL(API_BASE_URL).origin;
11+
const WRITE_METHODS = new Set(['post', 'put', 'delete', 'patch']);
12+
const CSRF_COOKIE_NAME = 'XSRF-TOKEN';
13+
14+
type RetriableAxiosConfig = InternalAxiosRequestConfig & {
15+
_csrfRetryAttempted?: boolean;
16+
_csrfRefreshAttempted?: boolean;
17+
};
1118

1219
export const getCookie = (name: string): string | null => {
1320
if (typeof document === 'undefined') return null;
@@ -23,6 +30,32 @@ export const getCookie = (name: string): string | null => {
2330
return null;
2431
};
2532

33+
let csrfRefreshPromise: Promise<void> | null = null;
34+
35+
const shouldAttachCsrfToken = (method?: string) => WRITE_METHODS.has(method?.toLowerCase() || '');
36+
37+
const setCsrfHeader = (config: InternalAxiosRequestConfig, token: string) => {
38+
if (typeof config.headers?.set === 'function') {
39+
config.headers.set('X-XSRF-TOKEN', token);
40+
} else {
41+
(config.headers as any)['X-XSRF-TOKEN'] = token;
42+
}
43+
};
44+
45+
const refreshCsrfToken = async () => {
46+
if (typeof window === 'undefined') return;
47+
48+
if (!csrfRefreshPromise) {
49+
csrfRefreshPromise = api.get(`/status?t=${Date.now()}`, {
50+
headers: { 'Cache-Control': 'no-cache' }
51+
}).then(() => undefined).finally(() => {
52+
csrfRefreshPromise = null;
53+
});
54+
}
55+
56+
await csrfRefreshPromise;
57+
};
58+
2659
export const api = axios.create({
2760
baseURL: API_BASE_URL,
2861
withCredentials: true,
@@ -33,19 +66,22 @@ export const api = axios.create({
3366
});
3467

3568
api.interceptors.request.use(
36-
(config: InternalAxiosRequestConfig) => {
69+
async (config: InternalAxiosRequestConfig) => {
3770
if (!config.headers) {
3871
config.headers = {} as any;
3972
}
4073

41-
if (['post', 'put', 'delete', 'patch'].includes(config.method?.toLowerCase() || '')) {
42-
const token = getCookie('XSRF-TOKEN');
74+
const retryableConfig = config as RetriableAxiosConfig;
75+
if (shouldAttachCsrfToken(config.method)) {
76+
let token = getCookie(CSRF_COOKIE_NAME);
77+
if (!token && !retryableConfig._csrfRefreshAttempted) {
78+
retryableConfig._csrfRefreshAttempted = true;
79+
await refreshCsrfToken();
80+
token = getCookie(CSRF_COOKIE_NAME);
81+
}
82+
4383
if (token) {
44-
if (typeof config.headers.set === 'function') {
45-
config.headers.set('X-XSRF-TOKEN', token);
46-
} else {
47-
(config.headers as any)['X-XSRF-TOKEN'] = token;
48-
}
84+
setCsrfHeader(config, token);
4985
}
5086
}
5187

@@ -54,6 +90,32 @@ api.interceptors.request.use(
5490
error => Promise.reject(error)
5591
);
5692

93+
api.interceptors.response.use(
94+
response => response,
95+
async (error) => {
96+
const config = error.config as RetriableAxiosConfig | undefined;
97+
if (!config || !shouldAttachCsrfToken(config.method) || config._csrfRetryAttempted || error.response?.status !== 403) {
98+
return Promise.reject(error);
99+
}
100+
101+
config._csrfRetryAttempted = true;
102+
103+
try {
104+
await refreshCsrfToken();
105+
const token = getCookie(CSRF_COOKIE_NAME);
106+
if (token) {
107+
if (!config.headers) {
108+
config.headers = {} as any;
109+
}
110+
setCsrfHeader(config, token);
111+
}
112+
return await api.request(config);
113+
} catch {
114+
return Promise.reject(error);
115+
}
116+
}
117+
);
118+
57119
export const extractApiErrorMessage = (error: unknown, fallback: string): string => {
58120
if (typeof error === 'string' && error.trim()) {
59121
return error;

0 commit comments

Comments
 (0)