feat(auth): access 24ч, jti против гонок ротации, транзакционная ротация, отзыв сессий по sid

This commit is contained in:
2026-09-21 18:13:46 +03:00
parent 2f9d049718
commit c957a70260
8 changed files with 221 additions and 31 deletions

View File

@@ -11,6 +11,7 @@ const mockStorage = vi.hoisted(() => ({
getUserSessionByToken: vi.fn(),
getUserSessionByParentToken: vi.fn(),
getActiveSessionInFamily: vi.fn(),
rotateUserSession: vi.fn(),
markSessionReplaced: vi.fn(),
revokeSession: vi.fn(),
revokeSessionFamily: vi.fn(),
@@ -182,32 +183,27 @@ describe('refresh-токены: выпуск по хэшу (plain не сохр
const session = makeSession(oldPlain);
mockStorage.getUserSessionByToken.mockResolvedValue(session);
mockStorage.markSessionReplaced.mockResolvedValue(undefined);
mockStorage.rotateUserSession.mockResolvedValue({ id: 2 });
const result = await authService.refreshToken(oldPlain);
expect(result.success).toBe(true);
expect(result.tokens?.refreshToken).toBeTruthy();
// NB: JWT с одинаковым payload и iat (в секундах) детерминирован — при ротации
// в ту же секунду новый токен может совпасть со старым; это свойство JWT, не логики.
// jti в payload: токены уникальны всегда, даже при ротации в ту же секунду
// Поиск сессии шёл по переданному plain-токену (хэширование — внутри storage)
expect(mockStorage.getUserSessionByToken).toHaveBeenCalledWith(oldPlain);
// Старая сессия помечена заменённой по id, новый токен передан plain (хэширует storage)
// Атомарная ротация: replaced + insert в одной транзакции (storage.rotateUserSession)
const newPlain = result.tokens!.refreshToken;
expect(mockStorage.markSessionReplaced).toHaveBeenCalledWith(session.id, newPlain);
// Новая сессия: только хэши, связь с родителем — по хэшу старого токена
const newSessionArg = mockStorage.createUserSession.mock.calls.find(
(c) => c[0].parentRefreshTokenHash,
)?.[0];
expect(newSessionArg).toBeTruthy();
expect(newSessionArg.refreshTokenHash).toBe(hashToken(newPlain));
expect(newSessionArg.parentRefreshTokenHash).toBe(hashToken(oldPlain));
expect(mockStorage.rotateUserSession).toHaveBeenCalledWith(session.id, expect.objectContaining({
refreshTokenHash: hashToken(newPlain),
parentRefreshTokenHash: hashToken(oldPlain),
familyId: 'family-1',
}));
const newSessionArg = mockStorage.rotateUserSession.mock.calls[0][1];
expect(newSessionArg.refreshToken).toBeUndefined();
expect(newSessionArg.parentRefreshToken).toBeUndefined();
expect(newSessionArg.familyId).toBe('family-1');
expect(user.id).toBe(1);
});
@@ -251,14 +247,16 @@ describe('refresh-токены: выпуск по хэшу (plain не сохр
makeSession('stale-plain', { createdAt: new Date() }),
);
mockStorage.getActiveSessionInFamily.mockResolvedValue(activeSession);
mockStorage.markSessionReplaced.mockResolvedValue(undefined);
mockStorage.rotateUserSession.mockResolvedValue({ id: 12 });
const result = await authService.refreshToken('stale-plain');
expect(result.success).toBe(true);
// Plain-токен активной сессии в БД не хранится — выдаётся пара через ротацию
expect(result.tokens?.refreshToken).toBeTruthy();
expect(mockStorage.markSessionReplaced).toHaveBeenCalledWith(activeSession.id, result.tokens!.refreshToken);
expect(mockStorage.rotateUserSession).toHaveBeenCalledWith(activeSession.id, expect.objectContaining({
refreshTokenHash: hashToken(result.tokens!.refreshToken),
}));
});
it('grace period: повтор СТАРОГО токена, replaced-сессия найдена по hash → successor ротируется, новая пара, без revoke', async () => {
@@ -273,14 +271,16 @@ describe('refresh-токены: выпуск по хэшу (plain не сохр
mockStorage.getUserSessionByToken.mockResolvedValue(replaced);
mockStorage.getUserSessionByParentToken.mockResolvedValue(successor);
mockStorage.getActiveSessionInFamily.mockResolvedValue(successor);
mockStorage.markSessionReplaced.mockResolvedValue(undefined);
mockStorage.rotateUserSession.mockResolvedValue({ id: 12 });
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.rotateUserSession).toHaveBeenCalledWith(successor.id, expect.objectContaining({
refreshTokenHash: hashToken(result.tokens!.refreshToken),
}));
expect(mockStorage.revokeSessionFamily).not.toHaveBeenCalled();
expect(result.clearCookies).toBeUndefined();
});