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