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

View File

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

View File

@@ -118,12 +118,60 @@ let _refreshPromise: Promise<RefreshResult> | null = null;
type RefreshResult = type RefreshResult =
| { ok: true } | { 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; if (_refreshPromise) return _refreshPromise;
const channel = getRefreshChannel();
// Другая вкладка уже рефрешит — ждём её результат.
if (_crossTabWaiter) return _crossTabWaiter;
_refreshPromise = (async (): Promise<RefreshResult> => { _refreshPromise = (async (): Promise<RefreshResult> => {
channel?.postMessage({ type: 'start' });
let result: RefreshResult;
try { try {
const res = await fetchWithTimeout( const res = await fetchWithTimeout(
'/api/auth/refresh', '/api/auth/refresh',
@@ -137,22 +185,34 @@ async function refreshSession(): Promise<RefreshResult> {
); );
if (res.ok) { if (res.ok) {
const result = await res.json(); const data = await res.json();
if (result.success && result.user) { if (data.success && data.user) {
authService.setCachedUser(result.user); authService.setCachedUser(data.user);
return { ok: true }; 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) { } catch (error) {
if (isNetworkFailure(error)) { if (isNetworkFailure(error)) {
console.warn('Token refresh skipped — no network connection or timeout'); 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(() => { })().finally(() => {
_refreshPromise = null; _refreshPromise = null;
}); });
@@ -160,6 +220,15 @@ async function refreshSession(): Promise<RefreshResult> {
return _refreshPromise; 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( export async function apiRequest(
method: string, method: string,
url: 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 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(); const refreshResult = await refreshSession();
if (refreshResult.ok) { if (refreshResult.ok) {
const retryRes = await fetchWithTimeout( const retryRes = await fetchWithTimeout(
@@ -226,9 +295,10 @@ export async function apiRequest(
await throwIfResNotOk(retryRes); await throwIfResNotOk(retryRes);
return retryRes; return retryRes;
} }
if (refreshResult.reason === 'network') { if (refreshResult.reason === 'transient') {
// The session may still be valid; we just couldn't reach the server. // The session may still be valid; the refresh failed on the network,
// Don't force a logout — propagate a generic error so the caller can retry. // 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'); throw new Error('network_error_during_refresh');
} }
// Server explicitly rejected the session — permanent logout. // Server explicitly rejected the session — permanent logout.
@@ -257,8 +327,9 @@ export const getQueryFn: <T>(options: {
return null; return null;
} }
// Пытаемся обновить сессию и повторить запрос (только если это не auth endpoint) // Пытаемся обновить сессию и повторить запрос (для auth-endpoints —
if (!url.includes('/api/auth/')) { // только /api/auth/me, ровно одна попытка)
if (isRefreshRetryAllowed(url)) {
const refreshResult = await refreshSession(); const refreshResult = await refreshSession();
if (refreshResult.ok) { if (refreshResult.ok) {
const retryRes = await fetchWithTimeout(url, { const retryRes = await fetchWithTimeout(url, {
@@ -273,8 +344,9 @@ export const getQueryFn: <T>(options: {
await throwIfResNotOk(retryRes); await throwIfResNotOk(retryRes);
return await retryRes.json(); return await retryRes.json();
} }
if (refreshResult.reason === 'network') { if (refreshResult.reason === 'transient') {
// Don't force logout when the refresh request itself failed on the network. // 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'); throw new Error('network_error_during_refresh');
} }
// Refresh failed — session is permanently invalid // Refresh failed — session is permanently invalid

View File

@@ -188,7 +188,11 @@ export function registerAuthCoreRoutes(router: Router): void {
} }
}); });
} else { } else {
clearAuthCookies(res); // clearCookies === false: неоднозначный отказ (гонка ротации, successor
// не найден) — cookie не трогаем, у клиента может быть живая winner-cookie.
if (result.clearCookies !== false) {
clearAuthCookies(res);
}
res.status(401).json({ success: false, error: result.error }); res.status(401).json({ success: false, error: result.error });
} }
} catch (error) { } catch (error) {

View File

@@ -270,13 +270,15 @@ export const authLimiter = rateLimit({
}); });
// Отдельный лимит для обновления токенов — приложение вызывает его автоматически, // Отдельный лимит для обновления токенов — приложение вызывает его автоматически,
// поэтому лимит значительно выше чтобы не мешать нормальной работе // поэтому лимит значительно выше чтобы не мешать нормальной работе.
// Ключ per-user из JWT (как у apiLimiter): офис за NAT не делит общий бакет.
export const refreshLimiter = rateLimit({ export const refreshLimiter = rateLimit({
windowMs: 15 * 60 * 1000, windowMs: 15 * 60 * 1000,
max: 120, max: 120,
message: { error: 'Слишком много запросов обновления токена. Попробуйте позже.' }, message: { error: 'Слишком много запросов обновления токена. Попробуйте позже.' },
standardHeaders: true, standardHeaders: true,
legacyHeaders: false, legacyHeaders: false,
keyGenerator: buildRateLimitKey,
}); });
// Общий API лимит — 1500 запросов за 15 минут // Общий API лимит — 1500 запросов за 15 минут

View File

@@ -33,6 +33,9 @@ export interface AuthResponse {
remember?: boolean; remember?: boolean;
message?: string; message?: string;
error?: string; error?: string;
// false = при отказе не стирать auth-cookie (временная/неоднозначная ситуация,
// у клиента может быть валидная cookie successor-сессии).
clearCookies?: boolean;
} }
export const ACCESS_TOKEN_COOKIE = 'access_token'; export const ACCESS_TOKEN_COOKIE = 'access_token';
@@ -334,6 +337,49 @@ export class AuthService {
return { tokens, remember }; return { tokens, remember };
} }
// Обработка повторного использования уже сменённого (rotated) refresh-токена.
// rotated — successor-сессия, найденная по parentRefreshTokenHash.
// В пределах RACE_TOLERANCE_MS это гонка вкладок/PWA: ротируем активную
// сессию family и выдаём новую пару (200). Если активной сессии нет —
// отказ БЕЗ стирания cookie и БЕЗ отзыва family. После окна — настоящий
// reuse-attack: отзыв всей family.
private async handleRotatedToken(rotated: UserSession): Promise<AuthResponse> {
if (rotated.isRevoked) {
console.warn(`[Auth] Refresh denied: rotated session ${rotated.id} is revoked (family ${rotated.familyId})`);
return { success: false, error: 'Недействительный или истекший токен' };
}
const rotatedAt = rotated.createdAt ? new Date(rotated.createdAt).getTime() : 0;
if (Date.now() - rotatedAt < RACE_TOLERANCE_MS) {
// В grace-окне: plain-токены в БД не хранятся (шаг 0.6), вернуть «тот же»
// refresh нельзя — вместо этого активная сессия ротируется и клиент
// получает новую пару.
const activeSession = await storage.getActiveSessionInFamily(rotated.familyId);
if (activeSession) {
const activeUser = await storage.getUserWithOrganization(activeSession.userId);
if (activeUser && activeUser.isActive) {
const { tokens, remember } = await this.rotateFamilySession(activeSession, activeUser);
return {
success: true,
user: { ...activeUser, appRole: activeUser.appRole, permissions: await storage.getAppRolePermissions(activeUser.appRole) } as any,
remember,
tokens,
};
}
}
// Successor есть, но активной сессии не нашлось — неоднозначная ситуация.
// Cookie не трогаем и family не отзываем: у клиента может быть валидная
// cookie winner-запроса, он повторит рефреш ею.
console.warn(`[Auth] Refresh in grace window but no active session in family ${rotated.familyId}`);
return { success: false, error: 'Недействительный или истекший токен', clearCookies: false };
}
// Reuse detected (за пределами grace-окна): revoke the whole family.
console.warn(`[Auth] Refresh token reuse detected for family ${rotated.familyId}, rotated session ${rotated.id}`);
await storage.revokeSessionFamily(rotated.familyId);
return { success: false, error: 'Недействительный или истекший токен' };
}
async refreshToken(refreshToken: string): Promise<AuthResponse> { async refreshToken(refreshToken: string): Promise<AuthResponse> {
return withSuperAdmin(async () => { return withSuperAdmin(async () => {
try { try {
@@ -342,32 +388,13 @@ export class AuthService {
// Token not found as current — maybe it was already rotated (race or reuse). // Token not found as current — maybe it was already rotated (race or reuse).
if (!session) { if (!session) {
const rotatedSession = await storage.getUserSessionByParentToken(refreshToken); const rotatedSession = await storage.getUserSessionByParentToken(refreshToken);
if (rotatedSession && !rotatedSession.isRevoked) { if (rotatedSession) {
const rotatedAt = rotatedSession.createdAt ? new Date(rotatedSession.createdAt).getTime() : 0; return this.handleRotatedToken(rotatedSession);
// Within grace period: rotate the current active session of the same family.
// Plain-токены в БД не хранятся (шаг 0.6), поэтому вернуть «тот же» refresh
// нельзя — вместо этого активная сессия ротируется и клиент получает новую пару.
if (Date.now() - rotatedAt < RACE_TOLERANCE_MS) {
const activeSession = await storage.getActiveSessionInFamily(rotatedSession.familyId);
if (activeSession) {
const activeUser = await storage.getUserWithOrganization(activeSession.userId);
if (activeUser && activeUser.isActive) {
const { tokens, remember } = await this.rotateFamilySession(activeSession, activeUser);
return {
success: true,
user: { ...activeUser, appRole: activeUser.appRole, permissions: await storage.getAppRolePermissions(activeUser.appRole) } as any,
remember,
tokens,
};
}
}
}
// Reuse detected: revoke the whole family.
console.warn(`[Auth] Refresh token reuse detected for family ${rotatedSession.familyId}, rotating session ${rotatedSession.id}`);
await storage.revokeSessionFamily(rotatedSession.familyId);
} }
// Токен вообще неизвестен — cookie не стираем (у клиента может оставаться
// живой access-cookie), family-ревока не для чего вызывать.
console.warn('[Auth] Refresh denied: token not found as current or rotated'); console.warn('[Auth] Refresh denied: token not found as current or rotated');
return { success: false, error: 'Недействительный или истекший токен' }; return { success: false, error: 'Недействительный или истекший токен', clearCookies: false };
} }
if (session.isRevoked) { if (session.isRevoked) {
@@ -375,8 +402,16 @@ export class AuthService {
return { success: false, error: 'Недействительный или истекший токен' }; return { success: false, error: 'Недействительный или истекший токен' };
} }
if (session.isReplaced) { if (session.isReplaced) {
console.warn(`[Auth] Refresh denied: session ${session.id} is already replaced (family ${session.familyId})`); // Гонка ротации: этот токен уже заменён, но его hash в БД сохранён —
return { success: false, error: 'Недействительный или истекший токен' }; // ищем successor-сессию и решаем по grace-окну.
const successor = await storage.getUserSessionByParentToken(refreshToken);
if (successor) {
return this.handleRotatedToken(successor);
}
// Successor не найден (например, запись не дочитана репликой/удалена) —
// 401 БЕЗ стирания cookie и БЕЗ revoke family.
console.warn(`[Auth] Refresh denied: session ${session.id} is replaced but no successor found (family ${session.familyId})`);
return { success: false, error: 'Недействительный или истекший токен', clearCookies: false };
} }
if (session.expiresAt < new Date()) { if (session.expiresAt < new Date()) {
console.warn(`[Auth] Refresh denied: session ${session.id} expired at ${session.expiresAt.toISOString()}`); console.warn(`[Auth] Refresh denied: session ${session.id} expired at ${session.expiresAt.toISOString()}`);

View File

@@ -261,6 +261,126 @@ describe('refresh-токены: выпуск по хэшу (plain не сохр
expect(mockStorage.markSessionReplaced).toHaveBeenCalledWith(activeSession.id, result.tokens!.refreshToken); expect(mockStorage.markSessionReplaced).toHaveBeenCalledWith(activeSession.id, result.tokens!.refreshToken);
}); });
it('grace period: повтор СТАРОГО токена, replaced-сессия найдена по hash → successor ротируется, новая пара, без revoke', async () => {
setupExistingUser();
const oldPlain = 'old-plain-race';
// Старая сессия всё ещё находится по refreshTokenHash (markSessionReplaced
// hash не очищает) и помечена isReplaced
const replaced = makeSession(oldPlain, { id: 10, isReplaced: true });
// Successor-сессия, созданная winner-запросом чуть раньше (в пределах окна)
const successor = makeSession('new-plain-race', { id: 11, createdAt: new Date() });
mockStorage.getUserSessionByToken.mockResolvedValue(replaced);
mockStorage.getUserSessionByParentToken.mockResolvedValue(successor);
mockStorage.getActiveSessionInFamily.mockResolvedValue(successor);
mockStorage.markSessionReplaced.mockResolvedValue(undefined);
const result = await authService.refreshToken(oldPlain);
expect(result.success).toBe(true);
expect(result.tokens?.refreshToken).toBeTruthy();
// Ротируется successor (активная сессия family), family НЕ отзывается
expect(mockStorage.markSessionReplaced).toHaveBeenCalledWith(successor.id, result.tokens!.refreshToken);
expect(mockStorage.revokeSessionFamily).not.toHaveBeenCalled();
expect(result.clearCookies).toBeUndefined();
});
it('replaced-сессия без successor: 401 БЕЗ стирания cookie и БЕЗ revoke family', async () => {
setupExistingUser();
const oldPlain = 'old-plain-no-successor';
const replaced = makeSession(oldPlain, { id: 10, isReplaced: true });
mockStorage.getUserSessionByToken.mockResolvedValue(replaced);
mockStorage.getUserSessionByParentToken.mockResolvedValue(undefined);
const result = await authService.refreshToken(oldPlain);
expect(result.success).toBe(false);
expect(result.clearCookies).toBe(false);
expect(mockStorage.revokeSessionFamily).not.toHaveBeenCalled();
expect(mockStorage.markSessionReplaced).not.toHaveBeenCalled();
});
it('reuse attack: повтор старого токена ПОСЛЕ grace-окна → revoke family (isReplaced-ветка)', async () => {
setupExistingUser();
const oldPlain = 'old-plain-after-window';
const replaced = makeSession(oldPlain, { id: 10, isReplaced: true });
const successor = makeSession('new-plain-after-window', {
id: 11,
createdAt: new Date(Date.now() - 10 * 60 * 1000), // 10 минут назад — окно истекло
});
mockStorage.getUserSessionByToken.mockResolvedValue(replaced);
mockStorage.getUserSessionByParentToken.mockResolvedValue(successor);
mockStorage.revokeSessionFamily.mockResolvedValue(undefined);
const result = await authService.refreshToken(oldPlain);
expect(result.success).toBe(false);
expect(mockStorage.revokeSessionFamily).toHaveBeenCalledWith('family-1');
expect(mockStorage.markSessionReplaced).not.toHaveBeenCalled();
});
it('route: повтор в grace-окне → 200 и новая пара токенов + cookie', async () => {
const app = buildApp();
setupExistingUser();
const oldPlain = 'old-plain-route';
const replaced = makeSession(oldPlain, { id: 10, isReplaced: true });
const successor = makeSession('new-plain-route', { id: 11, createdAt: new Date() });
mockStorage.getUserSessionByToken.mockResolvedValue(replaced);
mockStorage.getUserSessionByParentToken.mockResolvedValue(successor);
mockStorage.getActiveSessionInFamily.mockResolvedValue(successor);
mockStorage.markSessionReplaced.mockResolvedValue(undefined);
const res = await request(app)
.post('/api/auth/refresh')
.send({ refreshToken: oldPlain });
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.tokens?.refreshToken).toBeTruthy();
// Сервер выставил новые auth-cookie
const setCookie = res.headers['set-cookie'];
expect(setCookie).toBeTruthy();
expect(String(setCookie)).toContain('access_token=');
expect(String(setCookie)).toContain('refresh_token=');
expect(mockStorage.revokeSessionFamily).not.toHaveBeenCalled();
});
it('route: отказ без successor не стирает cookie, при revoked-сессии — стирает', async () => {
const app = buildApp();
setupExistingUser();
// Случай 1: replaced без successor → 401 без set-cookie (cookie не трогаем)
mockStorage.getUserSessionByToken.mockResolvedValue(
makeSession('p1', { id: 10, isReplaced: true }),
);
mockStorage.getUserSessionByParentToken.mockResolvedValue(undefined);
const res1 = await request(app)
.post('/api/auth/refresh')
.send({ refreshToken: 'p1' });
expect(res1.status).toBe(401);
expect(res1.headers['set-cookie']).toBeUndefined();
expect(mockStorage.revokeSessionFamily).not.toHaveBeenCalled();
// Случай 2: revoked-сессия → 401 со стиранием cookie
mockStorage.getUserSessionByToken.mockResolvedValue(
makeSession('p2', { id: 12, isRevoked: true }),
);
const res2 = await request(app)
.post('/api/auth/refresh')
.send({ refreshToken: 'p2' });
expect(res2.status).toBe(401);
const cleared = String(res2.headers['set-cookie']);
expect(cleared).toContain('access_token=;');
expect(cleared).toContain('refresh_token=;');
});
it('отзыв сессии при logout: revokeSession вызывается с plain-токеном (хэширует storage)', async () => { it('отзыв сессии при logout: revokeSession вызывается с plain-токеном (хэширует storage)', async () => {
mockStorage.revokeSession.mockResolvedValue(undefined); mockStorage.revokeSession.mockResolvedValue(undefined);