fix(auth): живая сессия на устройстве — рефреш на холодном старте, grace-окно ротации, без логаута при временных ошибках

- /api/auth/me при 401 делает одну попытку refresh (раньше refresh-retry был
  отключён для всех /api/auth/* — холодный старт после 15 мин простоя = логаут)
- ротация: повтор старого refresh-токена в пределах RACE_TOLERANCE_MS (5 мин)
  выдаёт новую пару от successor вместо deny; cookie больше не стираются при
  stale-токенах (разлогинивались все вкладки устройства); reuse после окна —
  revoke family как раньше (тесты обновлены)
- refreshSession различает 401/403/400 (сессия мертва) vs 429/5xx/сеть
  (transient — нет logout, отложенный retry)
- единый рефреш: useAuth keep-alive и useOfflineSync переведены на refreshSession;
  межвкладочная дедупликация через BroadcastChannel('auth-refresh')
- refreshLimiter: keyGenerator buildRateLimitKey (per-user, не общий IP-бакет офиса)
This commit is contained in:
2026-09-17 21:59:51 +03:00
parent c1d5b1cbee
commit d0112657b8
7 changed files with 295 additions and 68 deletions

View File

@@ -1,5 +1,6 @@
import { useState, useEffect, createContext, useContext, useCallback } from 'react';
import { authService } from '@/services/auth.service';
import { refreshSession } from '@/lib/queryClient';
import type { User } from '@/types/auth.types';
import { AUTH_FAILURE_EVENT } from '@/lib/authEvents';
@@ -161,15 +162,17 @@ export function useAuthProvider() {
};
}, [logout]);
// Keep the session alive in long-lived tabs/PWAs. With a 14-day access token
// this is mostly defensive: it refreshes cookies and prevents the family hard
// cap from expiring while the app is open.
// Keep the session alive in long-lived tabs/PWAs. With a 15-minute access
// token this refreshes cookies and prevents the family hard cap from
// expiring while the app is open. Рефреш идёт через единый refreshSession
// (singleton + межвкладочная дедупликация), иначе параллельные рефреши
// из разных вкладок/хуков попадают под rotation-гонку.
useEffect(() => {
if (!user) return;
const KEEP_ALIVE_MS = 12 * 60 * 60 * 1000; // 12 hours
const interval = setInterval(() => {
authService.refreshTokens().catch(() => {});
void refreshSession();
}, KEEP_ALIVE_MS);
let lastFocusRefresh = 0;
@@ -179,7 +182,7 @@ export function useAuthProvider() {
const now = Date.now();
if (now - lastFocusRefresh < FOCUS_REFRESH_INTERVAL_MS) return;
lastFocusRefresh = now;
authService.refreshTokens().catch(() => {});
void refreshSession();
};
document.addEventListener('visibilitychange', handleVisibilityChange);

View File

@@ -15,7 +15,7 @@ import {
performInitialSync,
checkServerReachable,
} from '@/lib/syncEngine';
import { queryClient } from '@/lib/queryClient';
import { queryClient, refreshSession } from '@/lib/queryClient';
import { saveInbox, pruneStaleInboxTasks } from '@/lib/tasksCache';
import type { InboxTaskSnapshot } from '@/lib/tasksCache';
import { sseManager } from '@/lib/sseManager';
@@ -215,14 +215,10 @@ async function tryUploadFile(
});
if ((res.status === 401 || res.status === 403) && allowRefresh) {
// Try a single session refresh; if it fails the user must re-authenticate.
const refreshRes = await fetch('/api/auth/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
credentials: 'include',
});
if (refreshRes.ok) {
// Try a single session refresh via the shared refreshSession (singleton +
// cross-tab dedup); if it fails the user must re-authenticate.
const refreshResult = await refreshSession();
if (refreshResult.ok) {
return tryUploadFile(file, false);
}
}
@@ -282,13 +278,8 @@ async function trySendAttachmentMessage(
});
if ((res.status === 401 || res.status === 403) && allowRefresh) {
const refreshRes = await fetch('/api/auth/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
credentials: 'include',
});
if (refreshRes.ok) {
const refreshResult = await refreshSession();
if (refreshResult.ok) {
return trySendAttachmentMessage(item, attachments, false);
}
}