This commit is contained in:
David 2026-08-13 12:45:52 +02:00
parent af9dae72d7
commit 08e614dd1e
2 changed files with 95 additions and 18 deletions

View File

@ -325,7 +325,16 @@ export class AuthController {
throw new UnauthorizedException('No refresh token provided'); throw new UnauthorizedException('No refresh token provided');
} }
const result = await this.authService.refreshAccessToken(refreshToken); let result: Awaited<ReturnType<typeof this.authService.refreshAccessToken>>;
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); this.setAuthCookies(res, result, result.rememberMe);

View File

@ -27,11 +27,46 @@ export function hasSession(): boolean {
return document.cookie.split('; ').some(cookie => cookie.startsWith(`${SESSION_FLAG_COOKIE}=`)); 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<string | null> {
const scopes: Array<string | null> = [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. * Clear client-side auth state.
* Token cookies are httpOnly and are cleared by the backend on logout; * 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 * this removes the user cache, any tokens left over from the legacy
* localStorage-based auth. * localStorage-based auth, and the readable session flag.
*/ */
export function clearAuthTokens(): void { export function clearAuthTokens(): void {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
@ -43,8 +78,50 @@ export function clearAuthTokens(): void {
sessionStorage.removeItem('user'); sessionStorage.removeItem('user');
// Expire the legacy middleware cookie and the session flag (best effort — // Expire the legacy middleware cookie and the session flag (best effort —
// the backend clears the authoritative httpOnly cookies) // the backend clears the authoritative httpOnly cookies)
document.cookie = 'accessToken=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax'; deleteCookie('accessToken');
document.cookie = `${SESSION_FLAG_COOKIE}=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax`; 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<void> {
if (!response.ok) { if (!response.ok) {
// Refresh token invalid or expired, clear everything // Refresh token invalid or expired, clear everything
clearAuthTokens(); clearAuthTokens();
// Redirect to login redirectToLogin();
if (typeof window !== 'undefined') {
window.location.href = '/login';
}
throw new Error('Failed to refresh session'); throw new Error('Failed to refresh session');
} }
} }
@ -146,9 +220,7 @@ export async function apiRequest<T>(
if (!hasSession()) { if (!hasSession()) {
// No session, redirect to login // No session, redirect to login
clearAuthTokens(); clearAuthTokens();
if (typeof window !== 'undefined') { redirectToLogin();
window.location.href = '/login';
}
throw new ApiError('Session expired', 401); throw new ApiError('Session expired', 401);
} }
@ -281,9 +353,7 @@ export async function upload<T>(
return retryResponse.json(); return retryResponse.json();
} catch (refreshError) { } catch (refreshError) {
clearAuthTokens(); clearAuthTokens();
if (typeof window !== 'undefined') { redirectToLogin();
window.location.href = '/login';
}
throw refreshError; throw refreshError;
} }
} }
@ -343,9 +413,7 @@ export async function download(
return; return;
} catch (refreshError) { } catch (refreshError) {
clearAuthTokens(); clearAuthTokens();
if (typeof window !== 'undefined') { redirectToLogin();
window.location.href = '/login';
}
throw refreshError; throw refreshError;
} }
} }