From 08e614dd1e98c91c13ad251b9d581d2b5c00cfbc Mon Sep 17 00:00:00 2001 From: David Date: Thu, 13 Aug 2026 12:45:52 +0200 Subject: [PATCH] fix bug --- .../controllers/auth.controller.ts | 11 +- apps/frontend/src/lib/api/client.ts | 102 +++++++++++++++--- 2 files changed, 95 insertions(+), 18 deletions(-) diff --git a/apps/backend/src/application/controllers/auth.controller.ts b/apps/backend/src/application/controllers/auth.controller.ts index 2735571..f6290d5 100644 --- a/apps/backend/src/application/controllers/auth.controller.ts +++ b/apps/backend/src/application/controllers/auth.controller.ts @@ -325,7 +325,16 @@ export class AuthController { throw new UnauthorizedException('No refresh token provided'); } - const result = await this.authService.refreshAccessToken(refreshToken); + let result: Awaited>; + try { + result = await this.authService.refreshAccessToken(refreshToken); + } catch (error) { + // The refresh token is expired/revoked: wipe every auth cookie (including + // the readable session flag) so the client stops believing a session + // exists. Without this the frontend keeps retrying and reloading. + this.clearAuthCookies(res); + throw error; + } this.setAuthCookies(res, result, result.rememberMe); diff --git a/apps/frontend/src/lib/api/client.ts b/apps/frontend/src/lib/api/client.ts index 7f12967..448c8b7 100644 --- a/apps/frontend/src/lib/api/client.ts +++ b/apps/frontend/src/lib/api/client.ts @@ -27,11 +27,46 @@ export function hasSession(): boolean { return document.cookie.split('; ').some(cookie => cookie.startsWith(`${SESSION_FLAG_COOKIE}=`)); } +/** + * Every domain scope a cookie could have been set on for the current host. + * + * The backend sets its cookies with `COOKIE_DOMAIN` (e.g. `.preprod.xpeditis.com`), + * so deleting them from JS without the matching `domain` attribute silently + * fails — the browser scopes the deletion to the exact host and the original + * domain-scoped cookie survives. Trying every parent domain guarantees removal + * whatever COOKIE_DOMAIN is set to. + */ +function cookieDomainScopes(): Array { + const scopes: Array = [null]; // host-only cookie + const host = window.location.hostname; + + // No domain cookies on IPs or on `localhost` + if (host === 'localhost' || /^[\d.]+$/.test(host) || host.includes(':')) return scopes; + + const parts = host.split('.'); + for (let i = 0; i < parts.length - 1; i++) { + const domain = parts.slice(i).join('.'); + if (domain.includes('.')) { + scopes.push(domain); + scopes.push(`.${domain}`); + } + } + return scopes; +} + +function deleteCookie(name: string): void { + const expiry = 'expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax'; + for (const domain of cookieDomainScopes()) { + const domainPart = domain ? `; domain=${domain}` : ''; + document.cookie = `${name}=; path=/${domainPart}; ${expiry}`; + } +} + /** * Clear client-side auth state. * Token cookies are httpOnly and are cleared by the backend on logout; - * this removes the user cache and any tokens left over from the legacy - * localStorage-based auth. + * this removes the user cache, any tokens left over from the legacy + * localStorage-based auth, and the readable session flag. */ export function clearAuthTokens(): void { if (typeof window === 'undefined') return; @@ -43,8 +78,50 @@ export function clearAuthTokens(): void { sessionStorage.removeItem('user'); // Expire the legacy middleware cookie and the session flag (best effort — // the backend clears the authoritative httpOnly cookies) - document.cookie = 'accessToken=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax'; - document.cookie = `${SESSION_FLAG_COOKIE}=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax`; + deleteCookie('accessToken'); + deleteCookie(SESSION_FLAG_COOKIE); +} + +/** + * Pages that must never be redirected away from on an auth failure: they ARE + * the destination. Redirecting from here caused an infinite reload loop when a + * stale session cookie survived (`/login` → 401 → refresh fails → reload + * `/login` → …). Matched after stripping the locale prefix. + */ +const AUTH_ENTRY_PATHS = [ + '/login', + '/admin/login', + '/register', + '/forgot-password', + '/reset-password', + '/verify-email', + '/carrier', +]; + +// Kept in sync with i18n/routing.ts (imported statically here to avoid pulling +// next-intl into the API client bundle). +const LOCALES = ['fr', 'en']; + +function currentLocalePrefix(): string { + const segment = window.location.pathname.split('/')[1]; + return LOCALES.includes(segment) ? `/${segment}` : ''; +} + +function isOnAuthEntryPage(): boolean { + const path = window.location.pathname.slice(currentLocalePrefix().length) || '/'; + return AUTH_ENTRY_PATHS.some(p => path === p || path.startsWith(`${p}/`)); +} + +/** + * Send the user back to login after an unrecoverable auth failure. + * No-op when already on a login/registration page, so a stale session can never + * turn into a reload loop. The locale prefix is preserved to avoid a needless + * round-trip through the i18n middleware. + */ +export function redirectToLogin(): void { + if (typeof window === 'undefined') return; + if (isOnAuthEntryPage()) return; + window.location.href = `${currentLocalePrefix()}/login`; } /** @@ -79,10 +156,7 @@ async function refreshSession(): Promise { if (!response.ok) { // Refresh token invalid or expired, clear everything clearAuthTokens(); - // Redirect to login - if (typeof window !== 'undefined') { - window.location.href = '/login'; - } + redirectToLogin(); throw new Error('Failed to refresh session'); } } @@ -146,9 +220,7 @@ export async function apiRequest( if (!hasSession()) { // No session, redirect to login clearAuthTokens(); - if (typeof window !== 'undefined') { - window.location.href = '/login'; - } + redirectToLogin(); throw new ApiError('Session expired', 401); } @@ -281,9 +353,7 @@ export async function upload( return retryResponse.json(); } catch (refreshError) { clearAuthTokens(); - if (typeof window !== 'undefined') { - window.location.href = '/login'; - } + redirectToLogin(); throw refreshError; } } @@ -343,9 +413,7 @@ export async function download( return; } catch (refreshError) { clearAuthTokens(); - if (typeof window !== 'undefined') { - window.location.href = '/login'; - } + redirectToLogin(); throw refreshError; } }