From 9422055471345cbeac80559cde2ff22817342e44 Mon Sep 17 00:00:00 2001 From: Klaudia Blazyczek Date: Fri, 10 Jul 2026 14:44:33 +0200 Subject: [PATCH] feat(pam): implement rate-limit contract and identity lockout service --- .../core/src/contracts/adapters/rate-limit.ts | 1 + .../__tests__/identity.service.test.ts | 29 +++++++++++++++++++ .../core/src/pam/identity/contract/index.ts | 9 +++++- .../core/src/pam/identity/router/index.ts | 12 ++++++-- .../pam/identity/service/identity.service.ts | 1 + .../core/src/server/kernel/rate-limiter.ts | 5 ++++ .../src/server/kernel/redis-rate-limiter.ts | 9 ++++++ 7 files changed, 63 insertions(+), 3 deletions(-) diff --git a/packages/core/src/contracts/adapters/rate-limit.ts b/packages/core/src/contracts/adapters/rate-limit.ts index d54b047..946df7e 100644 --- a/packages/core/src/contracts/adapters/rate-limit.ts +++ b/packages/core/src/contracts/adapters/rate-limit.ts @@ -25,6 +25,7 @@ export type RateLimitResult = { export type RateLimiterAdapter = { consume(key: string, opts: RateLimitOptions): Promise; + reset(key: string): Promise; }; export const RATE_LIMITER: Token = createToken('RATE_LIMITER'); diff --git a/packages/core/src/pam/identity/__tests__/identity.service.test.ts b/packages/core/src/pam/identity/__tests__/identity.service.test.ts index ec5e40c..b2d6f06 100644 --- a/packages/core/src/pam/identity/__tests__/identity.service.test.ts +++ b/packages/core/src/pam/identity/__tests__/identity.service.test.ts @@ -355,6 +355,35 @@ describe('IdentityService - unlockUser', () => { const svc = new IdentityService({ drizzle: drizzle as never, events: makeEvents() as never }); await expect(svc.unlockUser('missing', 'admin1')).rejects.toThrow(); }); + + it('resets the login rate-limit window for the unlocked user', async () => { + const drizzle = makeDrizzle({ + selectRows: [ + [ + { + id: 'u1', + email: 'A@B.DEV', + failedLoginAttempts: 5, + lockoutUntil: new Date('2020-01-01'), + }, + ], + ], + }); + const resetMock = vi.fn().mockResolvedValue(undefined); + const limiter = { + consume: vi.fn(), + reset: resetMock, + } satisfies import('@openora/core/contracts').RateLimiterAdapter; + const svc = new IdentityService({ + drizzle: drizzle as never, + events: makeEvents() as never, + limiter, + }); + + await svc.unlockUser('u1', 'admin1'); + + expect(resetMock).toHaveBeenCalledWith('login:a@b.dev'); + }); }); describe('IdentityService.updateProfile language validation', () => { diff --git a/packages/core/src/pam/identity/contract/index.ts b/packages/core/src/pam/identity/contract/index.ts index 3068703..9734529 100644 --- a/packages/core/src/pam/identity/contract/index.ts +++ b/packages/core/src/pam/identity/contract/index.ts @@ -58,7 +58,14 @@ export const identityContract = { streamSession: oc .route({ method: 'GET', path: '/identity/session/stream' }) - .output(eventIterator(z.object({ type: z.literal('revoked') }))), + .output( + eventIterator( + z.discriminatedUnion('type', [ + z.object({ type: z.literal('revoked') }), + z.object({ type: z.literal('unlocked') }), + ]), + ), + ), enable2fa: oc .route({ method: 'POST', path: '/identity/2fa/enable' }) diff --git a/packages/core/src/pam/identity/router/index.ts b/packages/core/src/pam/identity/router/index.ts index b462d6d..243ccb3 100644 --- a/packages/core/src/pam/identity/router/index.ts +++ b/packages/core/src/pam/identity/router/index.ts @@ -49,12 +49,20 @@ export function createIdentityRouter( const userId = getUserId(context); return createEventStreamGenerator( (push) => { - const unsubscribe = eventBus.on('identity.sessions.revoked_all', (event) => { + const unsubscribeRevoked = eventBus.on('identity.sessions.revoked_all', (event) => { if (event.userId === userId) { push({ type: 'revoked' }); } }); - return unsubscribe; + const unsubscribeUnlocked = eventBus.on('identity.user.unlocked', (event) => { + if (event.userId === userId) { + push({ type: 'unlocked' }); + } + }); + return () => { + unsubscribeRevoked(); + unsubscribeUnlocked(); + }; }, { signal }, ); diff --git a/packages/core/src/pam/identity/service/identity.service.ts b/packages/core/src/pam/identity/service/identity.service.ts index 88db951..dc71d2f 100644 --- a/packages/core/src/pam/identity/service/identity.service.ts +++ b/packages/core/src/pam/identity/service/identity.service.ts @@ -418,6 +418,7 @@ export class IdentityService { } await this.clearLockout(userId); + await this.limiter?.reset(`login:${existingUser.email.toLowerCase()}`); this.events.emit('identity.user.unlocked', { userId, diff --git a/packages/core/src/server/kernel/rate-limiter.ts b/packages/core/src/server/kernel/rate-limiter.ts index fb1f887..ae23f78 100644 --- a/packages/core/src/server/kernel/rate-limiter.ts +++ b/packages/core/src/server/kernel/rate-limiter.ts @@ -44,6 +44,11 @@ export class InProcessRateLimiter implements RateLimiterAdapter { return Promise.resolve({ allowed: false, retryAfterMs: existing.resetAt - now }); } + reset(key: string): Promise { + this.windows.delete(key); + return Promise.resolve(); + } + private sweep(): void { const now = Date.now(); for (const [key, window] of this.windows) { diff --git a/packages/core/src/server/kernel/redis-rate-limiter.ts b/packages/core/src/server/kernel/redis-rate-limiter.ts index cc3850d..0761b0e 100644 --- a/packages/core/src/server/kernel/redis-rate-limiter.ts +++ b/packages/core/src/server/kernel/redis-rate-limiter.ts @@ -46,6 +46,15 @@ export class RedisRateLimiter implements RateLimiterAdapter { } } + async reset(key: string): Promise { + if (!this.client.isReady) return; + try { + await this.client.del(PREFIX + key); + } catch (err) { + this.logger.warn({ keyPrefix: key.split(':')[0], err }, 'rate limiter reset failed'); + } + } + // Backend unreachable: fail-open by default to keep availability (throttling pauses // during an outage); fail-closed for keys that opt into 'deny' where an unthrottled // window is worse than a 429 (credential guessing).