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