Skip to content

Commit 5a27dd0

Browse files
committed
fix: auto-recover from stale CSRF token instead of failing add-to-cart/checkout
Reproduced live: repeated cart/checkout POSTs intermittently return 419 (CSRF token mismatch) even when the X-XSRF-TOKEN header is read fresh from the cookie on every attempt, and once it starts happening the session stays stuck failing until a full page reload. A full reload (fresh Set-Cookie from the server) or hitting /sanctum/csrf-cookie reliably fixes it — confirmed by reproducing the stuck state and recovering it via that endpoint before retrying the same request. useApi.ts now retries once via /sanctum/csrf-cookie on a 419 before surfacing an error, so this self-heals instead of leaving "Add to Cart" silently doing nothing. Added fetchWithCsrfRetry for the raw fetch() calls in Summary.vue (checkout, promotion/gift-card preview), which had the same exposure since they don't go through the composable.
1 parent f3b7792 commit 5a27dd0

2 files changed

Lines changed: 59 additions & 13 deletions

File tree

resources/js/Composables/useApi.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ interface UseApiOptions {
1212
* Timeout in milliseconds
1313
*/
1414
timeout?: number;
15+
/**
16+
* Internal: set when retrying after a CSRF token refresh, to avoid retry loops.
17+
*/
18+
_retriedAfterCsrfRefresh?: boolean;
1519
}
1620

1721
interface ApiError {
@@ -77,6 +81,26 @@ export function useApi() {
7781
loading.value = false;
7882
return response.data;
7983
} catch (err) {
84+
// The session's CSRF token can fall out of sync with the XSRF-TOKEN cookie
85+
// (session storage/rotation quirks). Refresh it once via Sanctum's
86+
// csrf-cookie endpoint and retry before surfacing an error to the user.
87+
if (
88+
axios.isAxiosError(err) &&
89+
err.response?.status === 419 &&
90+
!options?._retriedAfterCsrfRefresh
91+
) {
92+
try {
93+
await axios.get('/sanctum/csrf-cookie', { withCredentials: true });
94+
95+
return await request<T>(method, url, data, {
96+
...options,
97+
_retriedAfterCsrfRefresh: true,
98+
});
99+
} catch {
100+
// Fall through to normal error handling below.
101+
}
102+
}
103+
80104
loading.value = false;
81105
error.value = parseError(err);
82106
return null;
@@ -226,6 +250,37 @@ export function useApi() {
226250
};
227251
}
228252

253+
function getXsrfTokenFromCookie(): string {
254+
const match = document.cookie.split('; ').find(row => row.startsWith('XSRF-TOKEN='));
255+
return match ? decodeURIComponent(match.split('=')[1]) : '';
256+
}
257+
258+
/**
259+
* Wraps `fetch()` for callers that can't use the `useApi` composable (e.g. one-off
260+
* calls outside a component's setup). Injects a fresh X-XSRF-TOKEN header on every
261+
* attempt, and if the session's CSRF token has fallen out of sync with the cookie,
262+
* refreshes it via Sanctum's csrf-cookie endpoint and retries once before giving up.
263+
*/
264+
export async function fetchWithCsrfRetry(input: string, init: RequestInit = {}): Promise<Response> {
265+
const withFreshToken = (): RequestInit => ({
266+
...init,
267+
headers: {
268+
...init.headers,
269+
'X-XSRF-TOKEN': getXsrfTokenFromCookie(),
270+
},
271+
});
272+
273+
const response = await fetch(input, withFreshToken());
274+
275+
if (response.status !== 419) {
276+
return response;
277+
}
278+
279+
await fetch('/sanctum/csrf-cookie', { credentials: 'include' });
280+
281+
return fetch(input, withFreshToken());
282+
}
283+
229284
/**
230285
* Generates a unique idempotency key for checkout operations.
231286
* Keys are stored in sessionStorage to survive page refreshes.

resources/js/Pages/Checkout/Summary.vue

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { useCheckout } from '@/Composables/useCheckout';
99
import { usePayments } from '@/Composables/usePayments';
1010
import { useLocale } from '@/Composables/useLocale';
1111
import { useCurrency } from '@/Composables/useCurrency';
12+
import { fetchWithCsrfRetry } from '@/Composables/useApi';
1213
import type { CartApiResource } from '@/types/api';
1314
1415
function normalizeImages(images: string | string[] | null | undefined): string[] {
@@ -81,12 +82,11 @@ async function applyPromotion(): Promise<void> {
8182
couponLoading.value = true;
8283
8384
try {
84-
const res = await fetch('/api/v1/promotions/preview', {
85+
const res = await fetchWithCsrfRetry('/api/v1/promotions/preview', {
8586
method: 'POST',
8687
headers: {
8788
'Content-Type': 'application/json',
8889
'Accept': 'application/json',
89-
'X-XSRF-TOKEN': getXsrfToken(),
9090
'X-Requested-With': 'XMLHttpRequest',
9191
},
9292
credentials: 'include',
@@ -125,12 +125,11 @@ async function applyGiftCard(): Promise<void> {
125125
giftCardLoading.value = true;
126126
127127
try {
128-
const res = await fetch('/api/v1/gift-cards/preview', {
128+
const res = await fetchWithCsrfRetry('/api/v1/gift-cards/preview', {
129129
method: 'POST',
130130
headers: {
131131
'Content-Type': 'application/json',
132132
'Accept': 'application/json',
133-
'X-XSRF-TOKEN': getXsrfToken(),
134133
'X-Requested-With': 'XMLHttpRequest',
135134
},
136135
credentials: 'include',
@@ -165,12 +164,11 @@ async function handleInitiateCheckout() {
165164
isSubmitting.value = true;
166165
167166
try {
168-
const res = await fetch('/api/v1/checkout', {
167+
const res = await fetchWithCsrfRetry('/api/v1/checkout', {
169168
method: 'POST',
170169
headers: {
171170
'Content-Type': 'application/json',
172171
'Accept': 'application/json',
173-
'X-XSRF-TOKEN': getXsrfToken(),
174172
'X-Requested-With': 'XMLHttpRequest',
175173
'Idempotency-Key': getIdempotencyKey('checkout'),
176174
},
@@ -209,13 +207,6 @@ async function handleInitiateCheckout() {
209207
}
210208
}
211209
212-
function getXsrfToken(): string {
213-
const match = document.cookie
214-
.split('; ')
215-
.find(row => row.startsWith('XSRF-TOKEN='));
216-
return match ? decodeURIComponent(match.split('=')[1]) : '';
217-
}
218-
219210
function getIdempotencyKey(operation: string): string {
220211
const storageKey = `idempotency_${operation}`;
221212
let key = sessionStorage.getItem(storageKey);

0 commit comments

Comments
 (0)