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:
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user