From f1ea94e571e0f6aa8a7ecc1987981b6befd02516 Mon Sep 17 00:00:00 2001 From: Antonella Sgarlatta Date: Fri, 14 Aug 2026 15:54:27 -0300 Subject: [PATCH] fix: Fixes ephemeral session revocation --- .../RedisEphemeralSessionRepository.spec.ts | 60 +++++++++++++++++++ .../Redis/RedisEphemeralSessionRepository.ts | 6 ++ .../TypeORMEphemeralSessionRepository.spec.ts | 57 ++++++++++++++++++ .../TypeORMEphemeralSessionRepository.ts | 6 ++ 4 files changed, 129 insertions(+) create mode 100644 packages/auth/src/Infra/Redis/RedisEphemeralSessionRepository.spec.ts create mode 100644 packages/auth/src/Infra/TypeORM/TypeORMEphemeralSessionRepository.spec.ts diff --git a/packages/auth/src/Infra/Redis/RedisEphemeralSessionRepository.spec.ts b/packages/auth/src/Infra/Redis/RedisEphemeralSessionRepository.spec.ts new file mode 100644 index 000000000..b65234c76 --- /dev/null +++ b/packages/auth/src/Infra/Redis/RedisEphemeralSessionRepository.spec.ts @@ -0,0 +1,60 @@ +import * as IORedis from 'ioredis' + +import { EphemeralSession } from '../../Domain/Session/EphemeralSession' +import { RedisEphemeralSessionRepository } from './RedisEphemeralSessionRepository' + +describe('RedisEphemeralSessionRepository', () => { + let redisClient: jest.Mocked + let pipeline: { + del: jest.Mock + srem: jest.Mock + exec: jest.Mock + } + let repository: RedisEphemeralSessionRepository + + beforeEach(() => { + pipeline = { + del: jest.fn().mockReturnThis(), + srem: jest.fn().mockReturnThis(), + exec: jest.fn().mockResolvedValue([]), + } + + redisClient = { + get: jest.fn(), + pipeline: jest.fn().mockReturnValue(pipeline), + } as unknown as jest.Mocked + + repository = new RedisEphemeralSessionRepository(redisClient, 3600) + }) + + describe('deleteOne', () => { + it('should delete private identifier mapping when revoking a session', async () => { + const session = { + uuid: 'session-uuid', + userUuid: 'user-uuid', + privateIdentifier: 'private-id', + } as EphemeralSession + + redisClient.get.mockResolvedValue(JSON.stringify(session)) + + await repository.deleteOne('session-uuid', 'user-uuid') + + expect(redisClient.get).toHaveBeenCalledWith('session:session-uuid:user-uuid') + expect(pipeline.del).toHaveBeenCalledWith('session:session-uuid') + expect(pipeline.del).toHaveBeenCalledWith('session:session-uuid:user-uuid') + expect(pipeline.del).toHaveBeenCalledWith('session-private-id:private-id') + expect(pipeline.srem).toHaveBeenCalledWith('user-sessions:user-uuid', 'session-uuid') + expect(pipeline.exec).toHaveBeenCalled() + }) + + it('should skip private identifier deletion when session is not found', async () => { + redisClient.get.mockResolvedValue(null) + + await repository.deleteOne('session-uuid', 'user-uuid') + + expect(pipeline.del).toHaveBeenCalledWith('session:session-uuid') + expect(pipeline.del).toHaveBeenCalledWith('session:session-uuid:user-uuid') + expect(pipeline.del).not.toHaveBeenCalledWith(expect.stringContaining('session-private-id')) + }) + }) +}) diff --git a/packages/auth/src/Infra/Redis/RedisEphemeralSessionRepository.ts b/packages/auth/src/Infra/Redis/RedisEphemeralSessionRepository.ts index aaf6d3f7b..7e2c6bcb5 100644 --- a/packages/auth/src/Infra/Redis/RedisEphemeralSessionRepository.ts +++ b/packages/auth/src/Infra/Redis/RedisEphemeralSessionRepository.ts @@ -26,12 +26,18 @@ export class RedisEphemeralSessionRepository implements EphemeralSessionReposito } async deleteOne(uuid: string, userUuid: string): Promise { + const session = await this.findOneByUuidAndUserUuid(uuid, userUuid) + const pipeline = this.redisClient.pipeline() pipeline.del(`${this.PREFIX}:${uuid}`) pipeline.del(`${this.PREFIX}:${uuid}:${userUuid}`) pipeline.srem(`${this.USER_SESSIONS_PREFIX}:${userUuid}`, uuid) + if (session?.privateIdentifier) { + pipeline.del(`${this.PREFIX_PRIVATE_ID}:${session.privateIdentifier}`) + } + await pipeline.exec() } diff --git a/packages/auth/src/Infra/TypeORM/TypeORMEphemeralSessionRepository.spec.ts b/packages/auth/src/Infra/TypeORM/TypeORMEphemeralSessionRepository.spec.ts new file mode 100644 index 000000000..20fa62329 --- /dev/null +++ b/packages/auth/src/Infra/TypeORM/TypeORMEphemeralSessionRepository.spec.ts @@ -0,0 +1,57 @@ +import { CacheEntry, CacheEntryRepositoryInterface } from '@standardnotes/domain-core' +import { TimerInterface } from '@standardnotes/time' + +import { EphemeralSession } from '../../Domain/Session/EphemeralSession' +import { TypeORMEphemeralSessionRepository } from './TypeORMEphemeralSessionRepository' + +describe('TypeORMEphemeralSessionRepository', () => { + let cacheEntryRepository: jest.Mocked + let timer: jest.Mocked + let repository: TypeORMEphemeralSessionRepository + + beforeEach(() => { + cacheEntryRepository = { + findUnexpiredOneByKey: jest.fn(), + removeByKey: jest.fn(), + save: jest.fn(), + } as unknown as jest.Mocked + + timer = {} as jest.Mocked + + repository = new TypeORMEphemeralSessionRepository(cacheEntryRepository, 3600, timer) + }) + + describe('deleteOne', () => { + it('should delete private identifier mapping when revoking a session', async () => { + const session = { + uuid: 'session-uuid', + userUuid: 'user-uuid', + privateIdentifier: 'private-id', + } as EphemeralSession + + cacheEntryRepository.findUnexpiredOneByKey.mockResolvedValueOnce( + CacheEntry.create({ + key: 'session:session-uuid:user-uuid', + value: JSON.stringify(session), + expiresAt: new Date(), + }).getValue(), + ) + + await repository.deleteOne('session-uuid', 'user-uuid') + + expect(cacheEntryRepository.removeByKey).toHaveBeenCalledWith('session:session-uuid') + expect(cacheEntryRepository.removeByKey).toHaveBeenCalledWith('session:session-uuid:user-uuid') + expect(cacheEntryRepository.removeByKey).toHaveBeenCalledWith('session-private-id:private-id') + }) + + it('should skip private identifier deletion when session is not found', async () => { + cacheEntryRepository.findUnexpiredOneByKey.mockResolvedValueOnce(null) + + await repository.deleteOne('session-uuid', 'user-uuid') + + expect(cacheEntryRepository.removeByKey).toHaveBeenCalledWith('session:session-uuid') + expect(cacheEntryRepository.removeByKey).toHaveBeenCalledWith('session:session-uuid:user-uuid') + expect(cacheEntryRepository.removeByKey).not.toHaveBeenCalledWith(expect.stringContaining('session-private-id')) + }) + }) +}) diff --git a/packages/auth/src/Infra/TypeORM/TypeORMEphemeralSessionRepository.ts b/packages/auth/src/Infra/TypeORM/TypeORMEphemeralSessionRepository.ts index 76d30392a..f73e0d8b0 100644 --- a/packages/auth/src/Infra/TypeORM/TypeORMEphemeralSessionRepository.ts +++ b/packages/auth/src/Infra/TypeORM/TypeORMEphemeralSessionRepository.ts @@ -27,9 +27,15 @@ export class TypeORMEphemeralSessionRepository implements EphemeralSessionReposi } async deleteOne(uuid: string, userUuid: string): Promise { + const session = await this.findOneByUuidAndUserUuid(uuid, userUuid) + await this.cacheEntryRepository.removeByKey(`${this.PREFIX}:${uuid}`) await this.cacheEntryRepository.removeByKey(`${this.PREFIX}:${uuid}:${userUuid}`) + if (session?.privateIdentifier) { + await this.cacheEntryRepository.removeByKey(`${this.PREFIX_PRIVATE_ID}:${session.privateIdentifier}`) + } + const userSessionsJSON = await this.cacheEntryRepository.findUnexpiredOneByKey( `${this.USER_SESSIONS_PREFIX}:${userUuid}`, )