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

@@ -261,6 +261,126 @@ describe('refresh-токены: выпуск по хэшу (plain не сохр
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 () => {
mockStorage.revokeSession.mockResolvedValue(undefined);