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

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

View File

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