feat(auth): access 24ч, jti против гонок ротации, транзакционная ротация, отзыв сессий по sid
This commit is contained in:
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user