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);
}
}

View File

@@ -118,12 +118,60 @@ let _refreshPromise: Promise<RefreshResult> | null = null;
type RefreshResult =
| { ok: true }
| { ok: false; reason: 'network' | 'unauthorized' };
| { ok: false; reason: 'transient' | 'unauthorized' };
async function refreshSession(): Promise<RefreshResult> {
// Межвкладочная дедупликация рефреша: вкладка-лидер шлёт 'start'/'done',
// остальные вкладки ждут 'done' вместо собственного запроса (cookie-jar общий,
// результат рефреша в другой вкладке действует и здесь).
const AUTH_REFRESH_CHANNEL = 'auth-refresh';
const CROSS_TAB_WAIT_TIMEOUT_MS = 8000;
let _channel: BroadcastChannel | null | undefined; // undefined = ещё не инициализирован
let _crossTabWaiter: Promise<RefreshResult> | null = null;
let _crossTabResolve: ((result: RefreshResult) => void) | null = null;
function getRefreshChannel(): BroadcastChannel | null {
if (_channel !== undefined) return _channel;
if (typeof BroadcastChannel === 'undefined') {
_channel = null;
return null;
}
try {
_channel = new BroadcastChannel(AUTH_REFRESH_CHANNEL);
_channel.onmessage = (event: MessageEvent) => {
const msg = event.data as { type?: string; result?: RefreshResult } | undefined;
if (msg?.type === 'start' && !_refreshPromise && !_crossTabWaiter) {
_crossTabWaiter = new Promise<RefreshResult>((resolve) => {
_crossTabResolve = resolve;
setTimeout(() => {
// Лидер не прислал результат (закрылся/упал) — пробуем сами.
_crossTabWaiter = null;
_crossTabResolve = null;
resolve({ ok: false, reason: 'transient' });
}, CROSS_TAB_WAIT_TIMEOUT_MS);
});
} else if (msg?.type === 'done' && _crossTabResolve) {
_crossTabResolve(msg.result ?? { ok: false, reason: 'transient' });
_crossTabWaiter = null;
_crossTabResolve = null;
}
};
} catch {
_channel = null;
}
return _channel;
}
export async function refreshSession(): Promise<RefreshResult> {
if (_refreshPromise) return _refreshPromise;
const channel = getRefreshChannel();
// Другая вкладка уже рефрешит — ждём её результат.
if (_crossTabWaiter) return _crossTabWaiter;
_refreshPromise = (async (): Promise<RefreshResult> => {
channel?.postMessage({ type: 'start' });
let result: RefreshResult;
try {
const res = await fetchWithTimeout(
'/api/auth/refresh',
@@ -137,22 +185,34 @@ async function refreshSession(): Promise<RefreshResult> {
);
if (res.ok) {
const result = await res.json();
if (result.success && result.user) {
authService.setCachedUser(result.user);
return { ok: true };
const data = await res.json();
if (data.success && data.user) {
authService.setCachedUser(data.user);
result = { ok: true };
} else {
// 200 без user — аномалия, но не отказ сессии: считаем временным сбоем.
result = { ok: false, reason: 'transient' };
}
} else if (res.status === 401 || res.status === 403 || res.status === 400) {
// 401/403 — сервер явно отклонил сессию; 400 — refresh-cookie
// отсутствует вовсе (истёк по сроку/вычищен). Все три — сессия мертва.
result = { ok: false, reason: 'unauthorized' };
} else {
// 429 (rate limit), 5xx — временный сбой, сессия может быть жива.
console.warn(`Token refresh got transient HTTP ${res.status}`);
result = { ok: false, reason: 'transient' };
}
// Server explicitly rejected the session.
return { ok: false, reason: 'unauthorized' };
} catch (error) {
if (isNetworkFailure(error)) {
console.warn('Token refresh skipped — no network connection or timeout');
return { ok: false, reason: 'network' };
} else {
console.error('Token refresh failed:', error);
}
console.error('Token refresh failed:', error);
return { ok: false, reason: 'unauthorized' };
// Сетевой сбой/таймаут — сессию не считаем мёртвой.
result = { ok: false, reason: 'transient' };
}
channel?.postMessage({ type: 'done', result });
return result;
})().finally(() => {
_refreshPromise = null;
});
@@ -160,6 +220,15 @@ async function refreshSession(): Promise<RefreshResult> {
return _refreshPromise;
}
// GET /api/auth/me — единственный auth-endpoint, для которого при 401/403
// разрешена ОДНА попытка refresh + retry (холодный старт с истёкшим
// access-cookie, но живым refresh-cookie). Остальные /api/auth/* (login,
// refresh, logout...) рефрешить нельзя — иначе цикл.
function isRefreshRetryAllowed(url: string): boolean {
if (!url.includes('/api/auth/')) return true;
return url.includes('/api/auth/me');
}
export async function apiRequest(
method: string,
url: string,
@@ -215,7 +284,7 @@ export async function apiRequest(
}
// If we got 401/403, try to refresh the session and retry once.
if ((res.status === 401 || res.status === 403) && !url.includes('/api/auth/')) {
if ((res.status === 401 || res.status === 403) && isRefreshRetryAllowed(url)) {
const refreshResult = await refreshSession();
if (refreshResult.ok) {
const retryRes = await fetchWithTimeout(
@@ -226,9 +295,10 @@ export async function apiRequest(
await throwIfResNotOk(retryRes);
return retryRes;
}
if (refreshResult.reason === 'network') {
// The session may still be valid; we just couldn't reach the server.
// Don't force a logout — propagate a generic error so the caller can retry.
if (refreshResult.reason === 'transient') {
// The session may still be valid; the refresh failed on the network,
// rate limit or a 5xx. Don't force a logout — propagate a generic error
// so the caller can retry.
throw new Error('network_error_during_refresh');
}
// Server explicitly rejected the session — permanent logout.
@@ -257,8 +327,9 @@ export const getQueryFn: <T>(options: {
return null;
}
// Пытаемся обновить сессию и повторить запрос (только если это не auth endpoint)
if (!url.includes('/api/auth/')) {
// Пытаемся обновить сессию и повторить запрос (для auth-endpoints —
// только /api/auth/me, ровно одна попытка)
if (isRefreshRetryAllowed(url)) {
const refreshResult = await refreshSession();
if (refreshResult.ok) {
const retryRes = await fetchWithTimeout(url, {
@@ -273,8 +344,9 @@ export const getQueryFn: <T>(options: {
await throwIfResNotOk(retryRes);
return await retryRes.json();
}
if (refreshResult.reason === 'network') {
// Don't force logout when the refresh request itself failed on the network.
if (refreshResult.reason === 'transient') {
// Don't force logout when the refresh request itself failed on the
// network, rate limit or a 5xx.
throw new Error('network_error_during_refresh');
}
// Refresh failed — session is permanently invalid