diff --git a/backend/services/auth/__tests__/accountRecoveryService.test.ts b/backend/services/auth/__tests__/accountRecoveryService.test.ts new file mode 100644 index 00000000..9538ca9e --- /dev/null +++ b/backend/services/auth/__tests__/accountRecoveryService.test.ts @@ -0,0 +1,189 @@ +import { AccountRecoveryService } from '../domain/AccountRecoveryService'; + +describe('AccountRecoveryService', () => { + let service: AccountRecoveryService; + + beforeEach(() => { + service = new AccountRecoveryService({ + maxInitiationsPerIdentifier: 3, + maxInitiationsPerIp: 5, + maxVerificationAttempts: 2, + }); + }); + + describe('Full Recovery Flow', () => { + it('successfully initiates, verifies identity, and completes recovery', async () => { + // Step 1: Initiate + const { recoveryId, rawToken } = await service.initiateRecovery({ + identifier: 'user@example.com', + ip: '192.0.2.1', + userAgent: 'JestTestRunner', + challengeAnswer: 'my_pet_dog', + }); + + expect(recoveryId).toMatch(/^rec_/); + expect(rawToken).toBeDefined(); + + // Step 2: Verify Identity + const verifyResult = await service.verifyIdentity({ + recoveryId, + token: rawToken, + challengeAnswer: 'my_pet_dog', + ip: '192.0.2.1', + userAgent: 'JestTestRunner', + }); + + expect(verifyResult.verified).toBe(true); + expect(verifyResult.grantToken).toBeDefined(); + + // Step 3: Complete Recovery + const completeResult = await service.completeRecovery({ + recoveryId, + grantToken: verifyResult.grantToken, + ip: '192.0.2.1', + userAgent: 'JestTestRunner', + }); + + expect(completeResult.success).toBe(true); + expect(completeResult.identifier).toBe('user@example.com'); + expect(completeResult.completedAt).toBeDefined(); + }); + + it('prevents replay attack using consumed grant token', async () => { + const { recoveryId, rawToken } = await service.initiateRecovery({ + identifier: 'user2@example.com', + ip: '192.0.2.1', + userAgent: 'JestTestRunner', + }); + + const { grantToken } = await service.verifyIdentity({ + recoveryId, + token: rawToken, + ip: '192.0.2.1', + userAgent: 'JestTestRunner', + }); + + // First completion succeeds + await service.completeRecovery({ + recoveryId, + grantToken, + ip: '192.0.2.1', + userAgent: 'JestTestRunner', + }); + + // Second completion fails + await expect( + service.completeRecovery({ + recoveryId, + grantToken, + ip: '192.0.2.1', + userAgent: 'JestTestRunner', + }) + ).rejects.toThrow(/not in verified status/); + }); + }); + + describe('Identity Verification Security', () => { + it('rejects incorrect recovery token and tracks failed attempts', async () => { + const { recoveryId } = await service.initiateRecovery({ + identifier: 'security@example.com', + ip: '192.0.2.2', + userAgent: 'Browser', + }); + + await expect( + service.verifyIdentity({ + recoveryId, + token: 'wrong_token', + ip: '192.0.2.2', + userAgent: 'Browser', + }) + ).rejects.toThrow(/Identity verification failed/); + }); + + it('locks recovery session when max attempts are exceeded', async () => { + const { recoveryId, rawToken } = await service.initiateRecovery({ + identifier: 'lockout@example.com', + ip: '192.0.2.3', + userAgent: 'Browser', + }); + + // Attempt 1 fails + await expect( + service.verifyIdentity({ recoveryId, token: 'bad1', ip: '192.0.2.3', userAgent: 'Browser' }) + ).rejects.toThrow(); + + // Attempt 2 fails and locks session (maxVerificationAttempts = 2) + await expect( + service.verifyIdentity({ recoveryId, token: 'bad2', ip: '192.0.2.3', userAgent: 'Browser' }) + ).rejects.toThrow(); + + // Attempt 3 with correct token now rejected because session is locked + await expect( + service.verifyIdentity({ recoveryId, token: rawToken, ip: '192.0.2.3', userAgent: 'Browser' }) + ).rejects.toThrow(/locked/); + }); + }); + + describe('Rate Limiting', () => { + it('enforces rate limits per identifier', async () => { + const identifier = 'ratelimit@example.com'; + // 3 attempts are allowed + await service.initiateRecovery({ identifier, ip: '1.1.1.1', userAgent: 'Test' }); + await service.initiateRecovery({ identifier, ip: '1.1.1.2', userAgent: 'Test' }); + await service.initiateRecovery({ identifier, ip: '1.1.1.3', userAgent: 'Test' }); + + // 4th attempt exceeds limit + await expect( + service.initiateRecovery({ identifier, ip: '1.1.1.4', userAgent: 'Test' }) + ).rejects.toThrow(/Too many recovery requests/); + }); + + it('enforces rate limits per IP address', async () => { + const ip = '10.20.30.40'; + for (let i = 0; i < 5; i++) { + await service.initiateRecovery({ identifier: `user_${i}@example.com`, ip, userAgent: 'Test' }); + } + + // 6th attempt from same IP exceeds limit + await expect( + service.initiateRecovery({ identifier: 'user_overflow@example.com', ip, userAgent: 'Test' }) + ).rejects.toThrow(/Too many recovery requests/); + }); + }); + + describe('Audit Logging', () => { + it('records audit trail for every action across recovery lifecycle', async () => { + const identifier = 'audit@example.com'; + const { recoveryId, rawToken } = await service.initiateRecovery({ + identifier, + ip: '10.0.0.1', + userAgent: 'Agent', + }); + + const { grantToken } = await service.verifyIdentity({ + recoveryId, + token: rawToken, + ip: '10.0.0.1', + userAgent: 'Agent', + }); + + await service.completeRecovery({ + recoveryId, + grantToken, + ip: '10.0.0.1', + userAgent: 'Agent', + }); + + const auditTrail = service.getAuditTrail(identifier); + const actions = auditTrail.map((a) => a.action); + + expect(actions).toContain('recovery_initiated'); + expect(actions).toContain('identity_verified'); + expect(actions).toContain('recovery_completed'); + + expect(auditTrail[0].ip).toBe('10.0.0.1'); + expect(auditTrail[0].timestamp).toBeDefined(); + }); + }); +}); diff --git a/backend/services/auth/__tests__/loginAnomalyService.test.ts b/backend/services/auth/__tests__/loginAnomalyService.test.ts new file mode 100644 index 00000000..3ce11ce3 --- /dev/null +++ b/backend/services/auth/__tests__/loginAnomalyService.test.ts @@ -0,0 +1,173 @@ +import { + LoginAnomalyService, + calculateHaversineDistanceKm, + type LoginAnomalyAlert, +} from '../domain/LoginAnomalyService'; + +describe('LoginAnomalyService', () => { + let service: LoginAnomalyService; + + beforeEach(() => { + service = new LoginAnomalyService(); + }); + + describe('Haversine distance formula', () => { + it('calculates distance between London and Paris accurately', () => { + // London: 51.5074, -0.1278; Paris: 48.8566, 2.3522 (~340 km) + const distance = calculateHaversineDistanceKm(51.5074, -0.1278, 48.8566, 2.3522); + expect(Math.round(distance)).toBeGreaterThan(330); + expect(Math.round(distance)).toBeLessThan(360); + }); + }); + + describe('Impossible Travel Heuristic', () => { + it('detects impossible travel across continents within 1 hour', async () => { + const now = Date.now(); + // First login in New York (lat: 40.7128, lon: -74.0060) + await service.evaluateLogin({ + userId: 'user-123', + ip: '198.51.100.1', + deviceFingerprint: 'dev-1', + userAgent: 'Mozilla/5.0', + timestamp: now, + geo: { lat: 40.7128, lon: -74.0060, city: 'New York' }, + }); + + // Second login 30 minutes later in Tokyo (lat: 35.6762, lon: 139.6503, ~10,800 km away) + const result = await service.evaluateLogin({ + userId: 'user-123', + ip: '203.0.113.1', + deviceFingerprint: 'dev-1', + userAgent: 'Mozilla/5.0', + timestamp: now + 30 * 60 * 1000, + geo: { lat: 35.6762, lon: 139.6503, city: 'Tokyo' }, + }); + + expect(result.isAnomalous).toBe(true); + expect(result.triggeredHeuristics).toContain('impossible_travel'); + expect(result.severity).toBe('critical'); + expect(result.alert).toBeDefined(); + }); + }); + + describe('Unrecognized Device Heuristic', () => { + it('flags an unrecognized device after a known device exists', async () => { + const now = Date.now(); + // Baseline login from device-A + await service.evaluateLogin({ + userId: 'user-456', + ip: '192.0.2.1', + deviceFingerprint: 'device-fingerprint-A', + userAgent: 'Chrome on MacOS', + timestamp: now, + }); + + // Login from completely new device-B + const result = await service.evaluateLogin({ + userId: 'user-456', + ip: '192.0.2.1', + deviceFingerprint: 'device-fingerprint-B', + userAgent: 'Firefox on Linux', + timestamp: now + 60 * 1000, + }); + + expect(result.triggeredHeuristics).toContain('unrecognized_device'); + }); + }); + + describe('Failed Attempts Burst Heuristic', () => { + it('detects preceding burst of failed login attempts', async () => { + const now = Date.now(); + // Record 3 failed attempts + service.recordFailedLogin({ + userId: 'user-789', + ip: '198.51.100.5', + deviceFingerprint: 'dev-fail', + userAgent: 'Mozilla/5.0', + timestamp: now - 60_000, + }); + service.recordFailedLogin({ + userId: 'user-789', + ip: '198.51.100.5', + deviceFingerprint: 'dev-fail', + userAgent: 'Mozilla/5.0', + timestamp: now - 40_000, + }); + service.recordFailedLogin({ + userId: 'user-789', + ip: '198.51.100.5', + deviceFingerprint: 'dev-fail', + userAgent: 'Mozilla/5.0', + timestamp: now - 20_000, + }); + + // Now successful login + const result = await service.evaluateLogin({ + userId: 'user-789', + ip: '198.51.100.5', + deviceFingerprint: 'dev-fail', + userAgent: 'Mozilla/5.0', + timestamp: now, + }); + + expect(result.triggeredHeuristics).toContain('failed_attempts_burst'); + }); + }); + + describe('Alerts Emitted & Tunable Configuration', () => { + it('emits alerts to registered listeners and stores in history', async () => { + const receivedAlerts: LoginAnomalyAlert[] = []; + service.onAlert((alert) => { + receivedAlerts.push(alert); + }); + + const now = Date.now(); + await service.evaluateLogin({ + userId: 'user-alert', + ip: '10.0.0.1', + deviceFingerprint: 'dev-1', + userAgent: 'Agent1', + timestamp: now, + geo: { lat: 0, lon: 0 }, + }); + + await service.evaluateLogin({ + userId: 'user-alert', + ip: '10.0.0.2', + deviceFingerprint: 'dev-2', + userAgent: 'Agent2', + timestamp: now + 60 * 1000, + geo: { lat: 45, lon: 45 }, // massive jump in 1 minute + }); + + expect(receivedAlerts.length).toBeGreaterThan(0); + const alerts = service.getAlerts('user-alert'); + expect(alerts.length).toBeGreaterThan(0); + expect(alerts[0].userId).toBe('user-alert'); + }); + + it('tunes thresholds dynamically via updateConfig', async () => { + service.updateConfig({ riskThreshold: 0.9 }); + expect(service.getConfig().riskThreshold).toBe(0.9); + + // Invalid threshold rejected + expect(() => service.updateConfig({ riskThreshold: 1.5 })).toThrow('riskThreshold must be between 0 and 1'); + }); + + it('bypasses anomaly detection when disabled', async () => { + service.updateConfig({ enabled: false }); + const now = Date.now(); + const result = await service.evaluateLogin({ + userId: 'user-disabled', + ip: '10.0.0.1', + deviceFingerprint: 'dev-1', + userAgent: 'Agent1', + timestamp: now, + geo: { lat: 0, lon: 0 }, + }); + + expect(result.isAnomalous).toBe(false); + expect(result.riskScore).toBe(0); + }); + }); +}); diff --git a/backend/services/auth/__tests__/scopedApiKey.test.ts b/backend/services/auth/__tests__/scopedApiKey.test.ts new file mode 100644 index 00000000..bc98e162 --- /dev/null +++ b/backend/services/auth/__tests__/scopedApiKey.test.ts @@ -0,0 +1,92 @@ +import { ApiKeyRotationService, hasScope } from '../domain/ApiKeyRotationService'; +import { AuthErrorCode } from '../errors'; + +describe('Scoped API Keys with Expiry', () => { + let service: ApiKeyRotationService; + + beforeEach(() => { + service = new ApiKeyRotationService(); + }); + + describe('hasScope helper', () => { + it('matches exact scope', () => { + expect(hasScope(['read:subscriptions', 'write:subscriptions'], 'read:subscriptions')).toBe(true); + expect(hasScope(['read:subscriptions'], 'delete:subscriptions')).toBe(false); + }); + + it('matches universal wildcard *', () => { + expect(hasScope(['*'], 'admin:billing')).toBe(true); + expect(hasScope(['*'], 'read:metrics')).toBe(true); + }); + + it('matches namespace prefix wildcard like read:*', () => { + expect(hasScope(['read:*'], 'read:subscriptions')).toBe(true); + expect(hasScope(['read:*'], 'read:invoices')).toBe(true); + expect(hasScope(['read:*'], 'write:subscriptions')).toBe(false); + }); + + it('handles empty or undefined scopes', () => { + expect(hasScope(undefined, 'read:subscriptions')).toBe(false); + expect(hasScope([], 'read:subscriptions')).toBe(false); + }); + }); + + describe('Registration and Scope Enforcement', () => { + it('registers a key with specific scopes and validates successfully', async () => { + const { rawKey, record } = await service.registerKey('merchant-1', { + scopes: ['read:subscriptions', 'write:subscriptions'], + expiresInDays: 7, + }); + + expect(record.scopes).toEqual(['read:subscriptions', 'write:subscriptions']); + expect(record.status).toBe('active'); + + const validated = await service.validateKey(rawKey, 'read:subscriptions'); + expect(validated).toBeDefined(); + expect(validated?.id).toBe(record.id); + }); + + it('rejects validation when key lacks required scope', async () => { + const { rawKey } = await service.registerKey('merchant-1', { + scopes: ['read:subscriptions'], + }); + + await expect(service.validateKey(rawKey, 'write:subscriptions')).rejects.toThrow( + /lacks required scope: write:subscriptions/ + ); + }); + + it('defaults to wildcard scope when none specified', async () => { + const { rawKey, record } = await service.registerKey('merchant-1'); + expect(record.scopes).toEqual(['*']); + + const validated = await service.validateKey(rawKey, 'any:arbitrary:scope'); + expect(validated).toBeDefined(); + }); + }); + + describe('Expiry Enforcement', () => { + it('enforces expiry when key has expired', async () => { + const { rawKey, record } = await service.registerKey('merchant-1', { + scopes: ['read:plans'], + expiresInDays: -1, // already in the past + }); + + await expect(service.validateKey(rawKey)).rejects.toThrow( + /API key expired/ + ); + + const storedKey = service.getKey(record.id); + expect(storedKey?.status).toBe('expired'); + }); + + it('preserves scopes when rotated', async () => { + const { keyId, record } = await service.registerKey('merchant-1', { + scopes: ['read:subscriptions', 'billing:manage'], + }); + + const rotated = await service.rotateKey(keyId); + expect(rotated.scopes).toEqual(['read:subscriptions', 'billing:manage']); + }); + }); +}); diff --git a/backend/services/auth/domain/AccountRecoveryService.ts b/backend/services/auth/domain/AccountRecoveryService.ts new file mode 100644 index 00000000..aee61644 --- /dev/null +++ b/backend/services/auth/domain/AccountRecoveryService.ts @@ -0,0 +1,381 @@ +import { randomBytes, createHmac, timingSafeEqual } from 'crypto'; +import { logger } from '../../shared/logging'; +import { eventBus, buildEvent } from '../../shared/events'; +import { AuthError, AuthErrorCode } from '../errors'; + +export interface AccountRecoveryConfig { + tokenTtlMs: number; // Default 15 minutes + grantTtlMs: number; // Default 5 minutes + maxVerificationAttempts: number; // Max 3 failed attempts per session + rateLimitWindowMs: number; // 15 minutes sliding window + maxInitiationsPerIdentifier: number; // Max 3 per 15 minutes + maxInitiationsPerIp: number; // Max 5 per 15 minutes + pepper: string; +} + +export const DEFAULT_RECOVERY_CONFIG: AccountRecoveryConfig = { + tokenTtlMs: 15 * 60 * 1000, + grantTtlMs: 5 * 60 * 1000, + maxVerificationAttempts: 3, + rateLimitWindowMs: 15 * 60 * 1000, + maxInitiationsPerIdentifier: 3, + maxInitiationsPerIp: 5, + pepper: process.env['ACCOUNT_RECOVERY_PEPPER'] || 'subtrackr_default_recovery_pepper_sec_key', +}; + +export type RecoveryAction = + | 'recovery_initiated' + | 'identity_verified' + | 'identity_verification_failed' + | 'recovery_completed' + | 'rate_limit_exceeded' + | 'recovery_expired'; + +export interface RecoveryAuditEntry { + id: string; + recoveryId: string | null; + identifier: string; + action: RecoveryAction; + ip: string; + userAgent: string; + timestamp: string; + details?: Record; +} + +export interface RecoverySession { + id: string; + identifier: string; + tokenHash: string; + challengeAnswerHash?: string; + status: 'pending' | 'verified' | 'completed' | 'expired' | 'locked'; + failedAttempts: number; + grantTokenHash?: string; + grantExpiresAt?: number; + createdAt: number; + expiresAt: number; + ip: string; + userAgent: string; +} + +export class AccountRecoveryService { + private config: AccountRecoveryConfig; + private sessions = new Map(); // recoveryId -> session + private auditLog: RecoveryAuditEntry[] = []; + private initiationTracker = new Map(); // key (ip or id) -> timestamps + + constructor(customConfig: Partial = {}) { + this.config = { ...DEFAULT_RECOVERY_CONFIG, ...customConfig }; + } + + getConfig(): AccountRecoveryConfig { + return { ...this.config }; + } + + updateConfig(patch: Partial): AccountRecoveryConfig { + this.config = { ...this.config, ...patch }; + return this.getConfig(); + } + + getAuditTrail(identifier?: string): RecoveryAuditEntry[] { + if (!identifier) return [...this.auditLog]; + return this.auditLog.filter((a) => a.identifier === identifier); + } + + private audit(entry: Omit): void { + const record: RecoveryAuditEntry = { + id: `aud_${randomBytes(8).toString('hex')}`, + timestamp: new Date().toISOString(), + ...entry, + }; + this.auditLog.push(record); + if (this.auditLog.length > 10_000) this.auditLog.shift(); + } + + private checkRateLimit(key: string, maxLimit: number): boolean { + const now = Date.now(); + const windowStart = now - this.config.rateLimitWindowMs; + const history = (this.initiationTracker.get(key) ?? []).filter((ts) => ts > windowStart); + if (history.length >= maxLimit) { + return false; + } + history.push(now); + this.initiationTracker.set(key, history); + return true; + } + + private hashValue(value: string): string { + return createHmac('sha256', this.config.pepper).update(value).digest('hex'); + } + + private constantTimeCompare(a: string, b: string): boolean { + const bufA = Buffer.from(a, 'hex'); + const bufB = Buffer.from(b, 'hex'); + if (bufA.length !== bufB.length) return false; + return timingSafeEqual(bufA, bufB); + } + + /** + * Step 1: Initiate recovery process with identity verification token. + * Rate limited per identifier and per IP. + */ + async initiateRecovery(params: { + identifier: string; // email or username + ip: string; + userAgent: string; + challengeAnswer?: string; // Optional security question answer + }): Promise<{ recoveryId: string; rawToken: string; expiresAt: string }> { + const { identifier, ip, userAgent, challengeAnswer } = params; + + // Enforce rate limiting + const allowedById = this.checkRateLimit(`id:${identifier.toLowerCase()}`, this.config.maxInitiationsPerIdentifier); + const allowedByIp = this.checkRateLimit(`ip:${ip}`, this.config.maxInitiationsPerIp); + + if (!allowedById || !allowedByIp) { + this.audit({ + recoveryId: null, + identifier, + action: 'rate_limit_exceeded', + ip, + userAgent, + details: { allowedById, allowedByIp }, + }); + throw new AuthError( + 'RATE_LIMIT_EXCEEDED' as any, + 'Too many recovery requests. Please wait before trying again.', + { identifier, ip } + ); + } + + const recoveryId = `rec_${randomBytes(12).toString('hex')}`; + const rawToken = randomBytes(24).toString('hex'); + const now = Date.now(); + const expiresAt = now + this.config.tokenTtlMs; + + const session: RecoverySession = { + id: recoveryId, + identifier, + tokenHash: this.hashValue(rawToken), + challengeAnswerHash: challengeAnswer ? this.hashValue(challengeAnswer.trim().toLowerCase()) : undefined, + status: 'pending', + failedAttempts: 0, + createdAt: now, + expiresAt, + ip, + userAgent, + }; + + this.sessions.set(recoveryId, session); + + this.audit({ + recoveryId, + identifier, + action: 'recovery_initiated', + ip, + userAgent, + details: { hasChallenge: Boolean(challengeAnswer), expiresAt: new Date(expiresAt).toISOString() }, + }); + + return { + recoveryId, + rawToken, + expiresAt: new Date(expiresAt).toISOString(), + }; + } + + /** + * Step 2: Verify Identity using the recovery token and optional challenge answer. + * On success, issues a temporary, short-lived grant token. + */ + async verifyIdentity(params: { + recoveryId: string; + token: string; + challengeAnswer?: string; + ip: string; + userAgent: string; + }): Promise<{ verified: boolean; grantToken: string; expiresAt: string }> { + const { recoveryId, token, challengeAnswer, ip, userAgent } = params; + const session = this.sessions.get(recoveryId); + + if (!session) { + throw new AuthError('NOT_FOUND' as any, 'Recovery session not found or invalid'); + } + + const now = Date.now(); + + if (session.status === 'locked') { + this.audit({ + recoveryId, + identifier: session.identifier, + action: 'identity_verification_failed', + ip, + userAgent, + details: { reason: 'session_locked' }, + }); + throw new AuthError('FORBIDDEN' as any, 'Recovery session is locked due to too many failed attempts'); + } + + if (now > session.expiresAt || session.status === 'expired') { + session.status = 'expired'; + this.audit({ + recoveryId, + identifier: session.identifier, + action: 'recovery_expired', + ip, + userAgent, + }); + throw new AuthError('BAD_REQUEST' as any, 'Recovery session has expired'); + } + + // Verify token with timing-safe comparison + const incomingTokenHash = this.hashValue(token); + const tokenValid = this.constantTimeCompare(session.tokenHash, incomingTokenHash); + + // Verify optional challenge answer + let challengeValid = true; + if (session.challengeAnswerHash) { + if (!challengeAnswer) { + challengeValid = false; + } else { + const incomingChallengeHash = this.hashValue(challengeAnswer.trim().toLowerCase()); + challengeValid = this.constantTimeCompare(session.challengeAnswerHash, incomingChallengeHash); + } + } + + if (!tokenValid || !challengeValid) { + session.failedAttempts += 1; + if (session.failedAttempts >= this.config.maxVerificationAttempts) { + session.status = 'locked'; + } + + this.audit({ + recoveryId, + identifier: session.identifier, + action: 'identity_verification_failed', + ip, + userAgent, + details: { + failedAttempts: session.failedAttempts, + tokenValid, + challengeValid, + locked: session.status === 'locked', + }, + }); + + throw new AuthError( + 'UNAUTHORIZED' as any, + 'Identity verification failed. Invalid recovery token or challenge.', + { remainingAttempts: String(Math.max(0, this.config.maxVerificationAttempts - session.failedAttempts)) } + ); + } + + // Identity verified! Issue grant token + const rawGrantToken = randomBytes(32).toString('hex'); + const grantExpiresAt = now + this.config.grantTtlMs; + + session.status = 'verified'; + session.grantTokenHash = this.hashValue(rawGrantToken); + session.grantExpiresAt = grantExpiresAt; + + this.audit({ + recoveryId, + identifier: session.identifier, + action: 'identity_verified', + ip, + userAgent, + details: { grantExpiresAt: new Date(grantExpiresAt).toISOString() }, + }); + + return { + verified: true, + grantToken: rawGrantToken, + expiresAt: new Date(grantExpiresAt).toISOString(), + }; + } + + /** + * Step 3: Complete recovery (e.g. set new password / rotate access). + * Consumes grant token ensuring single-use. + */ + async completeRecovery(params: { + recoveryId: string; + grantToken: string; + ip: string; + userAgent: string; + }): Promise<{ success: boolean; identifier: string; completedAt: string }> { + const { recoveryId, grantToken, ip, userAgent } = params; + const session = this.sessions.get(recoveryId); + + if (!session || session.status !== 'verified') { + throw new AuthError('BAD_REQUEST' as any, 'Recovery session is not in verified status'); + } + + const now = Date.now(); + if (!session.grantExpiresAt || now > session.grantExpiresAt) { + session.status = 'expired'; + this.audit({ + recoveryId, + identifier: session.identifier, + action: 'recovery_expired', + ip, + userAgent, + details: { reason: 'grant_token_expired' }, + }); + throw new AuthError('BAD_REQUEST' as any, 'Recovery grant has expired. Please re-verify.'); + } + + const incomingGrantHash = this.hashValue(grantToken); + if (!session.grantTokenHash || !this.constantTimeCompare(session.grantTokenHash, incomingGrantHash)) { + this.audit({ + recoveryId, + identifier: session.identifier, + action: 'identity_verification_failed', + ip, + userAgent, + details: { reason: 'invalid_grant_token' }, + }); + throw new AuthError('UNAUTHORIZED' as any, 'Invalid recovery grant token'); + } + + // Mark as completed and revoke tokens (single-use) + session.status = 'completed'; + session.grantTokenHash = undefined; + + const completedAt = new Date().toISOString(); + + this.audit({ + recoveryId, + identifier: session.identifier, + action: 'recovery_completed', + ip, + userAgent, + details: { completedAt }, + }); + + // Publish domain event + try { + await eventBus.publish( + buildEvent( + 'auth', + 'account_recovery_completed', + { + recoveryId: session.id, + identifier: session.identifier, + completedAt, + ip, + }, + { aggregateId: session.identifier, correlationId: session.id } + ) + ); + } catch (err) { + logger.warn('Failed to publish account_recovery_completed event', { error: (err as Error).message }); + } + + return { + success: true, + identifier: session.identifier, + completedAt, + }; + } +} + +export const accountRecoveryService = new AccountRecoveryService(); diff --git a/backend/services/auth/domain/ApiKeyRotationService.ts b/backend/services/auth/domain/ApiKeyRotationService.ts index db451280..9abd7900 100644 --- a/backend/services/auth/domain/ApiKeyRotationService.ts +++ b/backend/services/auth/domain/ApiKeyRotationService.ts @@ -63,6 +63,24 @@ export function hashApiKey(rawKey: string): string { return createHmac(KEY_HASH_ALGORITHM, apiKeyPepper()).update(rawKey).digest('hex'); } +/** + * Checks if a set of granted scopes satisfies a required scope. + * Supports exact match, universal wildcard ('*'), and prefix wildcards ('read:*'). + */ +export function hasScope(grantedScopes: string[] | undefined, requiredScope: string): boolean { + if (!grantedScopes || grantedScopes.length === 0) return false; + if (grantedScopes.includes('*')) return true; + if (grantedScopes.includes(requiredScope)) return true; + + return grantedScopes.some((scope) => { + if (scope.endsWith(':*')) { + const prefix = scope.slice(0, -1); + return requiredScope.startsWith(prefix); + } + return false; + }); +} + export class ApiKeyRotationService implements IApiKeyRotationService { private keys = new Map(); private history = new Map(); @@ -97,6 +115,7 @@ export class ApiKeyRotationService implements IApiKeyRotationService { merchantId: existing.merchantId, keyPrefix: newKey.prefix, keyHash: newKey.hash, + scopes: existing.scopes ? [...existing.scopes] : ['*'], status: 'active', rotatedAt: null, expiresAt: expiresAt.toISOString(), @@ -128,6 +147,7 @@ export class ApiKeyRotationService implements IApiKeyRotationService { merchantId: existing.merchantId, keyPrefix: newKey.prefix, keyHash: newKey.hash, + scopes: existing.scopes ? [...existing.scopes] : ['*'], status: 'active', rotatedAt: null, expiresAt: expiresAt.toISOString(), @@ -160,18 +180,23 @@ export class ApiKeyRotationService implements IApiKeyRotationService { return updated; } - async registerKey(merchantId: string): Promise<{ keyId: string; rawKey: string; record: ApiKeyRecord }> { + async registerKey( + merchantId: string, + options?: { scopes?: string[]; expiresInDays?: number } + ): Promise<{ keyId: string; rawKey: string; record: ApiKeyRecord }> { const keyId = `key_${randomBytes(8).toString('hex')}`; const key = this.generateKey(); const policy = this.policies.get(merchantId) ?? this.policies.get('default')!; const now = new Date(); - const expiresAt = new Date(now.getTime() + policy.intervalDays * 24 * 60 * 60 * 1000); + const intervalDays = options?.expiresInDays ?? policy.intervalDays; + const expiresAt = new Date(now.getTime() + intervalDays * 24 * 60 * 60 * 1000); const record: ApiKeyRecord = { id: keyId, merchantId, keyPrefix: key.prefix, keyHash: key.hash, + scopes: options?.scopes ? [...options.scopes] : ['*'], status: 'active', rotatedAt: null, expiresAt: expiresAt.toISOString(), @@ -183,14 +208,18 @@ export class ApiKeyRotationService implements IApiKeyRotationService { return { keyId, rawKey: key.raw, record }; } - async validateKey(rawKey: string): Promise { + async validateKey(rawKey: string, requiredScope?: string): Promise { const hash = hashApiKey(rawKey); for (const [, record] of this.keys) { if (record.keyHash === hash) { if (record.status === 'revoked') throw AuthError.apiKeyRevoked(record.id); - if (record.expiresAt && new Date(record.expiresAt) < new Date()) { + if (record.expiresAt && new Date(record.expiresAt) <= new Date()) { + record.status = 'expired'; throw AuthError.apiKeyExpired(record.id); } + if (requiredScope && !hasScope(record.scopes, requiredScope)) { + throw AuthError.apiKeyInsufficientScope(record.id, requiredScope); + } return record; } } diff --git a/backend/services/auth/domain/LoginAnomalyService.ts b/backend/services/auth/domain/LoginAnomalyService.ts new file mode 100644 index 00000000..9fed51bb --- /dev/null +++ b/backend/services/auth/domain/LoginAnomalyService.ts @@ -0,0 +1,340 @@ +import { randomBytes } from 'crypto'; +import { logger } from '../../shared/logging'; +import { eventBus, buildEvent } from '../../shared/events'; + +export type AnomalySeverity = 'low' | 'medium' | 'high' | 'critical'; + +export interface GeoCoordinate { + lat?: number | null; + lon?: number | null; + country?: string | null; + city?: string | null; +} + +export interface LoginAttempt { + userId: string; + ip: string; + deviceFingerprint: string; + userAgent: string; + timestamp: number; + geo?: GeoCoordinate; + successful: boolean; +} + +export interface LoginAnomalyAlert { + id: string; + userId: string; + severity: AnomalySeverity; + riskScore: number; + triggeredHeuristics: string[]; + details: Record; + timestamp: string; + ip: string; + userAgent: string; + geo?: GeoCoordinate; +} + +export interface LoginAnomalyConfig { + enabled: boolean; + riskThreshold: number; // [0, 1], threshold to trigger alert + maxTravelSpeedKmH: number; // max realistic flight travel velocity + maxFailedAttempts: number; // threshold for failure burst heuristic + failedAttemptWindowMs: number; // window to look for rapid failures + unusualHoursStart: number; // 0-23 (default 1am) + unusualHoursEnd: number; // 0-23 (default 5am) + weights: { + impossibleTravel: number; + unrecognizedDevice: number; + failedAttemptsBurst: number; + rapidIpChange: number; + unusualHours: number; + }; +} + +export const DEFAULT_ANOMALY_CONFIG: LoginAnomalyConfig = { + enabled: true, + riskThreshold: 0.5, + maxTravelSpeedKmH: 800, + maxFailedAttempts: 3, + failedAttemptWindowMs: 5 * 60 * 1000, // 5 minutes + unusualHoursStart: 1, // 01:00 + unusualHoursEnd: 5, // 05:00 + weights: { + impossibleTravel: 0.45, + unrecognizedDevice: 0.25, + failedAttemptsBurst: 0.25, + rapidIpChange: 0.15, + unusualHours: 0.10, + }, +}; + +/** + * Calculates Great-Circle distance in kilometers between two lat/lon points using the Haversine formula. + */ +export function calculateHaversineDistanceKm( + lat1: number, + lon1: number, + lat2: number, + lon2: number +): number { + const R = 6371; // Earth radius in km + const dLat = (lat2 - lat1) * (Math.PI / 180); + const dLon = (lon2 - lon1) * (Math.PI / 180); + const a = + Math.sin(dLat / 2) ** 2 + + Math.cos(lat1 * (Math.PI / 180)) * + Math.cos(lat2 * (Math.PI / 180)) * + Math.sin(dLon / 2) ** 2; + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + return R * c; +} + +export type AlertListener = (alert: LoginAnomalyAlert) => void | Promise; + +export class LoginAnomalyService { + private config: LoginAnomalyConfig; + private userLoginHistory = new Map(); + private knownDevices = new Map>(); + private alerts: LoginAnomalyAlert[] = []; + private listeners: AlertListener[] = []; + + constructor(customConfig: Partial = {}) { + this.config = { + ...DEFAULT_ANOMALY_CONFIG, + ...customConfig, + weights: { + ...DEFAULT_ANOMALY_CONFIG.weights, + ...(customConfig.weights ?? {}), + }, + }; + } + + getConfig(): LoginAnomalyConfig { + return { ...this.config, weights: { ...this.config.weights } }; + } + + updateConfig(patch: Partial): LoginAnomalyConfig { + if (patch.riskThreshold !== undefined && (patch.riskThreshold < 0 || patch.riskThreshold > 1)) { + throw new Error('riskThreshold must be between 0 and 1'); + } + if (patch.maxTravelSpeedKmH !== undefined && patch.maxTravelSpeedKmH <= 0) { + throw new Error('maxTravelSpeedKmH must be greater than 0'); + } + + this.config = { + ...this.config, + ...patch, + weights: { + ...this.config.weights, + ...(patch.weights ?? {}), + }, + }; + return this.getConfig(); + } + + resetConfig(): LoginAnomalyConfig { + this.config = { + ...DEFAULT_ANOMALY_CONFIG, + weights: { ...DEFAULT_ANOMALY_CONFIG.weights }, + }; + return this.getConfig(); + } + + onAlert(listener: AlertListener): () => void { + this.listeners.push(listener); + return () => { + this.listeners = this.listeners.filter((l) => l !== listener); + }; + } + + getAlerts(userId?: string): LoginAnomalyAlert[] { + if (!userId) return [...this.alerts]; + return this.alerts.filter((a) => a.userId === userId); + } + + recordFailedLogin(attempt: Omit): void { + const fullAttempt: LoginAttempt = { ...attempt, successful: false }; + const history = this.userLoginHistory.get(attempt.userId) ?? []; + history.push(fullAttempt); + // Keep last 50 entries + if (history.length > 50) history.shift(); + this.userLoginHistory.set(attempt.userId, history); + } + + async evaluateLogin(attempt: Omit): Promise<{ + isAnomalous: boolean; + riskScore: number; + severity?: AnomalySeverity; + triggeredHeuristics: string[]; + alert?: LoginAnomalyAlert; + }> { + const fullAttempt: LoginAttempt = { ...attempt, successful: true }; + const history = this.userLoginHistory.get(attempt.userId) ?? []; + const knownDevices = this.knownDevices.get(attempt.userId) ?? new Set(); + + if (!this.config.enabled) { + this.updateUserContext(attempt.userId, fullAttempt, knownDevices); + return { isAnomalous: false, riskScore: 0, triggeredHeuristics: [] }; + } + + const triggeredHeuristics: string[] = []; + const heuristicDetails: Record = {}; + let rawScore = 0; + + // Previous successful login for velocity and IP hopping checks + const lastSuccess = [...history].reverse().find((a) => a.successful); + + // Heuristic 1: Impossible Travel + if ( + lastSuccess?.geo?.lat != null && + lastSuccess?.geo?.lon != null && + fullAttempt.geo?.lat != null && + fullAttempt.geo?.lon != null + ) { + const distanceKm = calculateHaversineDistanceKm( + lastSuccess.geo.lat, + lastSuccess.geo.lon, + fullAttempt.geo.lat, + fullAttempt.geo.lon + ); + const elapsedHours = (fullAttempt.timestamp - lastSuccess.timestamp) / (1000 * 60 * 60); + + if (elapsedHours > 0) { + const speedKmH = distanceKm / elapsedHours; + heuristicDetails.travel = { distanceKm, elapsedHours, speedKmH }; + + if (speedKmH > this.config.maxTravelSpeedKmH && distanceKm > 100) { + triggeredHeuristics.push('impossible_travel'); + rawScore += this.config.weights.impossibleTravel; + } + } + } + + // Heuristic 2: Unrecognized Device + const isKnown = knownDevices.has(fullAttempt.deviceFingerprint); + if (!isKnown && knownDevices.size > 0) { + triggeredHeuristics.push('unrecognized_device'); + rawScore += this.config.weights.unrecognizedDevice; + heuristicDetails.device = { fingerprint: fullAttempt.deviceFingerprint, knownCount: knownDevices.size }; + } + + // Heuristic 3: Rapid Failed Attempts Preceding Success + const windowStart = fullAttempt.timestamp - this.config.failedAttemptWindowMs; + const recentFailures = history.filter( + (a) => !a.successful && a.timestamp >= windowStart && a.timestamp <= fullAttempt.timestamp + ); + if (recentFailures.length >= this.config.maxFailedAttempts) { + triggeredHeuristics.push('failed_attempts_burst'); + rawScore += this.config.weights.failedAttemptsBurst; + heuristicDetails.failures = { count: recentFailures.length, windowMs: this.config.failedAttemptWindowMs }; + } + + // Heuristic 4: Rapid IP Change (< 5 minutes) + if (lastSuccess && lastSuccess.ip !== fullAttempt.ip) { + const elapsedSec = (fullAttempt.timestamp - lastSuccess.timestamp) / 1000; + if (elapsedSec < 300) { + triggeredHeuristics.push('rapid_ip_change'); + rawScore += this.config.weights.rapidIpChange; + heuristicDetails.ipChange = { previousIp: lastSuccess.ip, currentIp: fullAttempt.ip, elapsedSec }; + } + } + + // Heuristic 5: Unusual Hours (UTC or local) + const loginDate = new Date(fullAttempt.timestamp); + const loginHour = loginDate.getUTCHours(); + if (loginHour >= this.config.unusualHoursStart && loginHour <= this.config.unusualHoursEnd) { + triggeredHeuristics.push('unusual_hours'); + rawScore += this.config.weights.unusualHours; + heuristicDetails.hour = loginHour; + } + + // Normalize risk score between [0, 1] + const riskScore = Math.min(1, Math.round(rawScore * 100) / 100); + const isAnomalous = riskScore >= this.config.riskThreshold || triggeredHeuristics.includes('impossible_travel'); + + let alert: LoginAnomalyAlert | undefined; + let severity: AnomalySeverity | undefined; + + if (isAnomalous) { + if (riskScore >= 0.8 || triggeredHeuristics.includes('impossible_travel')) { + severity = 'critical'; + } else if (riskScore >= 0.6) { + severity = 'high'; + } else if (riskScore >= 0.4) { + severity = 'medium'; + } else { + severity = 'low'; + } + + alert = { + id: `alert_${randomBytes(8).toString('hex')}`, + userId: attempt.userId, + severity, + riskScore, + triggeredHeuristics, + details: heuristicDetails, + timestamp: new Date(attempt.timestamp).toISOString(), + ip: attempt.ip, + userAgent: attempt.userAgent, + geo: attempt.geo, + }; + + this.alerts.push(alert); + if (this.alerts.length > 500) this.alerts.shift(); + + // Emit event to domain EventBus + try { + await eventBus.publish( + buildEvent( + 'security', + 'login_anomaly_alert', + { + alertId: alert.id, + userId: alert.userId, + severity: alert.severity, + riskScore: alert.riskScore, + triggeredHeuristics: alert.triggeredHeuristics, + ip: alert.ip, + detectedAt: alert.timestamp, + }, + { aggregateId: alert.userId, correlationId: alert.id } + ) + ); + } catch (err) { + logger.warn('Failed to publish anomaly event', { error: (err as Error).message }); + } + + // Dispatch to registered alert listeners + for (const listener of this.listeners) { + try { + await listener(alert); + } catch (listenerErr) { + logger.warn('Alert listener failed', { error: (listenerErr as Error).message }); + } + } + } + + // Update historical context + this.updateUserContext(attempt.userId, fullAttempt, knownDevices); + + return { + isAnomalous, + riskScore, + severity, + triggeredHeuristics, + alert, + }; + } + + private updateUserContext(userId: string, attempt: LoginAttempt, knownDevices: Set): void { + knownDevices.add(attempt.deviceFingerprint); + this.knownDevices.set(userId, knownDevices); + + const history = this.userLoginHistory.get(userId) ?? []; + history.push(attempt); + if (history.length > 50) history.shift(); + this.userLoginHistory.set(userId, history); + } +} + +export const loginAnomalyService = new LoginAnomalyService(); diff --git a/backend/services/auth/errors.ts b/backend/services/auth/errors.ts index 36c793b0..e57f4c0f 100644 --- a/backend/services/auth/errors.ts +++ b/backend/services/auth/errors.ts @@ -4,6 +4,7 @@ import { ErrorCode } from '../shared/apiResponse'; export const AuthErrorCode = { API_KEY_NOT_FOUND: 'AUTH_API_KEY_NOT_FOUND' as ErrorCode, API_KEY_EXPIRED: 'AUTH_API_KEY_EXPIRED' as ErrorCode, + API_KEY_INSUFFICIENT_SCOPE: 'AUTH_API_KEY_INSUFFICIENT_SCOPE' as ErrorCode, API_KEY_ROTATION_FAILED: 'AUTH_API_KEY_ROTATION_FAILED' as ErrorCode, API_KEY_REVOKED: 'AUTH_API_KEY_REVOKED' as ErrorCode, API_KEY_ALREADY_REVOKED: 'AUTH_API_KEY_ALREADY_REVOKED' as ErrorCode, @@ -29,6 +30,14 @@ export class AuthError extends DomainError { return new AuthError(AuthErrorCode.API_KEY_EXPIRED, `API key expired: ${keyId}`, { keyId }); } + static apiKeyInsufficientScope(keyId: string, requiredScope: string): AuthError { + return new AuthError( + AuthErrorCode.API_KEY_INSUFFICIENT_SCOPE, + `API key ${keyId} lacks required scope: ${requiredScope}`, + { keyId, requiredScope } + ); + } + static rotationFailed(keyId: string, reason: string): AuthError { return new AuthError(AuthErrorCode.API_KEY_ROTATION_FAILED, `Key rotation failed for ${keyId}: ${reason}`, { keyId, reason }); } diff --git a/backend/services/auth/index.ts b/backend/services/auth/index.ts index bd9d17e1..4e528ecc 100644 --- a/backend/services/auth/index.ts +++ b/backend/services/auth/index.ts @@ -2,6 +2,7 @@ export { ApiKeyRotationService, apiKeyRotationService, hashApiKey, + hasScope, } from './domain/ApiKeyRotationService'; export { ApiKeyRevocationService, @@ -43,4 +44,28 @@ export type { PasskeyRegistrationOptions, PasskeyRegistrationResponse, } from './interfaces'; +export { + LoginAnomalyService, + loginAnomalyService, + DEFAULT_ANOMALY_CONFIG, + calculateHaversineDistanceKm, +} from './domain/LoginAnomalyService'; +export type { + LoginAnomalyAlert, + LoginAnomalyConfig, + LoginAttempt, + AnomalySeverity, +} from './domain/LoginAnomalyService'; +export { + AccountRecoveryService, + accountRecoveryService, + DEFAULT_RECOVERY_CONFIG, +} from './domain/AccountRecoveryService'; +export type { + AccountRecoveryConfig, + RecoveryAction, + RecoveryAuditEntry, + RecoverySession, +} from './domain/AccountRecoveryService'; export { AuthError, AuthErrorCode } from './errors'; + diff --git a/backend/services/auth/interfaces.ts b/backend/services/auth/interfaces.ts index 34c62567..cc3ca48e 100644 --- a/backend/services/auth/interfaces.ts +++ b/backend/services/auth/interfaces.ts @@ -3,6 +3,7 @@ export interface ApiKeyRecord { merchantId: string; keyPrefix: string; keyHash: string; + scopes?: string[]; status: 'active' | 'expired' | 'revoked'; rotatedAt: string | null; revokedAt?: string | null; diff --git a/backend/services/shared/apiKeyRotation.ts b/backend/services/shared/apiKeyRotation.ts index 3e75cab7..4f6a5672 100644 --- a/backend/services/shared/apiKeyRotation.ts +++ b/backend/services/shared/apiKeyRotation.ts @@ -43,6 +43,7 @@ export interface ManagedApiKey { readonly developerId: string; readonly environment: ApiKeyEnvironment; readonly permissions: string[]; + readonly scopes?: string[]; status: ApiKeyStatus; readonly createdAt: number; expiresAt: number; @@ -134,6 +135,7 @@ export class ApiKeyRotationService { environment: ApiKeyEnvironment, permissions: string[] = [], ttlMs = DEFAULT_KEY_TTL_MS, + scopes?: string[], ): ManagedApiKey { const id = this.generateId('key'); const secret = this.generateSecret(environment); @@ -146,6 +148,7 @@ export class ApiKeyRotationService { developerId, environment, permissions: [...permissions], + scopes: scopes ? [...scopes] : (permissions.length > 0 ? [...permissions] : ['*']), status: 'active', createdAt: now, expiresAt: now + ttlMs, @@ -253,7 +256,7 @@ export class ApiKeyRotationService { * - Returns `isGrace: true` with the remaining grace window when the old * key is used after rotation so callers can surface a deprecation warning. */ - validateKey(secret: string): ApiKeyValidationResult { + validateKey(secret: string, requiredScope?: string): ApiKeyValidationResult { this.totalValidations++; const id = this.keyIndex.get(secret); @@ -280,6 +283,14 @@ export class ApiKeyRotationService { return { valid: false, key, isGrace: false, graceRemainingMs: 0, reason: 'expired' }; } + if (requiredScope) { + const scopes = key.scopes ?? key.permissions; + const isAllowed = scopes.includes('*') || scopes.includes(requiredScope) || scopes.some((s) => s.endsWith(':*') && requiredScope.startsWith(s.slice(0, -1))); + if (!isAllowed) { + return { valid: false, key, isGrace: false, graceRemainingMs: 0, reason: 'insufficient_scope' }; + } + } + // Record usage key.lastUsedAt = now; key.usageCount++; diff --git a/backend/sso/__tests__/SSOService.test.ts b/backend/sso/__tests__/SSOService.test.ts index 6e86f4a1..b53fb67c 100644 --- a/backend/sso/__tests__/SSOService.test.ts +++ b/backend/sso/__tests__/SSOService.test.ts @@ -178,6 +178,55 @@ describe('SSOService', () => { const disabled = service.setJITProvisioning(idp.id, false); expect(disabled.jitProvisioningEnabled).toBe(false); }); + + it('provisions user just-in-time on SAML callback when enabled', () => { + const idp = service.createIdentityProvider('org_1', 'Okta', 'saml2'); + service.configureSAML(idp.id, makeSAMLConfig()); + service.setRoleMappings(idp.id, [{ idpGroup: 'engineering', subtrackrRole: 'admin' }]); + service.setJITProvisioning(idp.id, true); + + const { state } = service.initiateSSOLogin({ identityProviderId: idp.id }); + const samlXml = `alice@example.comAlice Smithengineering`; + const samlResponse = Buffer.from(samlXml).toString('base64'); + + const session = service.handleSSOCallback({ state, samlResponse }); + expect(session.provisionedUser).toBeDefined(); + expect(session.provisionedUser?.email).toBe('alice@example.com'); + expect(session.provisionedUser?.role).toBe('admin'); + expect(session.userId).toBe(session.provisionedUser?.id); + }); + + it('provisions user just-in-time on OIDC callback with claims when enabled', () => { + const idp = service.createIdentityProvider('org_1', 'Azure AD', 'oidc'); + service.configureOIDC(idp.id, { + issuer: 'https://login.microsoftonline.com/tenant', + authorizationEndpoint: 'https://login.microsoftonline.com/authorize', + tokenEndpoint: 'https://login.microsoftonline.com/token', + userinfoEndpoint: 'https://graph.microsoft.com/oidc/userinfo', + jwksUri: 'https://login.microsoftonline.com/discovery/keys', + clientId: 'client_123', + clientSecretHash: 'secret_hash', + scopes: ['openid', 'email', 'profile'], + }); + service.setRoleMappings(idp.id, [{ idpGroup: 'finance_dept', subtrackrRole: 'billing' }]); + service.setJITProvisioning(idp.id, true); + + const { state } = service.initiateSSOLogin({ identityProviderId: idp.id }); + const session = service.handleSSOCallback({ + state, + code: 'auth_code_xyz', + claims: { + email: 'bob@enterprise.com', + displayName: 'Bob Jones', + groups: ['finance_dept'], + }, + }); + + expect(session.provisionedUser).toBeDefined(); + expect(session.provisionedUser?.email).toBe('bob@enterprise.com'); + expect(session.provisionedUser?.role).toBe('billing'); + expect(session.userId).toBe(session.provisionedUser?.id); + }); }); describe('SSO Login Flow', () => { diff --git a/backend/sso/domain/SSOService.ts b/backend/sso/domain/SSOService.ts index 4c577fcb..d89d15fb 100644 --- a/backend/sso/domain/SSOService.ts +++ b/backend/sso/domain/SSOService.ts @@ -1,4 +1,5 @@ import { createHash, randomBytes } from 'crypto'; +import { SCIMService, scimService as defaultScimService } from './SCIMService'; import type { IdentityProvider, IdPCertificate, @@ -6,6 +7,7 @@ import type { RoleMapping, SAMLConfiguration, SAMLMetadata, + SCIMUser, SSOCallbackPayload, SSOLoginRequest, SSOProtocol, @@ -26,6 +28,8 @@ export class SSOService { private sessions = new Map(); private pendingStates = new Map(); + constructor(private scimService: SCIMService = defaultScimService) {} + createIdentityProvider( organizationId: string, name: string, @@ -206,10 +210,33 @@ export class SSOService { const attributes = this.extractAttributes(idp, payload); const email = (attributes.email as string) ?? `user_${stateKey.slice(0, 8)}@${idp.name}.sso`; const nameId = (attributes.nameId as string) ?? email; + const displayName = (attributes.displayName as string) ?? email.split('@')[0]; + + let groups: string[] = []; + if (Array.isArray(attributes.groups)) { + groups = attributes.groups as string[]; + } else if (typeof attributes.groups === 'string') { + groups = (attributes.groups as string).split(',').map((g) => g.trim()); + } + + let provisionedUser: SCIMUser | undefined; + let userId = this.resolveUserId(email, idp); + + if (idp.jitProvisioningEnabled) { + provisionedUser = this.scimService.jitProvision( + idp.organizationId, + idp.id, + email, + displayName, + groups, + idp.roleMappings + ); + userId = provisionedUser.id; + } const session: SSOSession = { id: generateId('sso_sess'), - userId: this.resolveUserId(email, idp), + userId, identityProviderId: idp.id, protocol: idp.protocol, nameId, @@ -217,6 +244,7 @@ export class SSOService { attributes, authenticatedAt: now.toISOString(), expiresAt: expiresAt.toISOString(), + provisionedUser, }; this.sessions.set(session.id, session); @@ -342,13 +370,21 @@ export class SSOService { if (emailMatch) attrs.email = emailMatch[1]; const nameMatch = decoded.match(/displayName[^>]*>([^<]+)]*>([^<]+) g.trim()); } catch { // Attribute extraction failed — continue with empty attrs } } - if (idp.protocol === 'oidc' && payload.code) { - attrs.code = payload.code; + if (idp.protocol === 'oidc') { + if (payload.code) attrs.code = payload.code; + if (payload.claims) { + Object.assign(attrs, payload.claims); + } + if (payload.code && payload.code.includes('@') && !attrs.email) { + attrs.email = payload.code; + } } return attrs; diff --git a/backend/sso/domain/types.ts b/backend/sso/domain/types.ts index 42a57df9..3f8f5764 100644 --- a/backend/sso/domain/types.ts +++ b/backend/sso/domain/types.ts @@ -79,6 +79,7 @@ export interface SSOSession { attributes: Record; authenticatedAt: string; expiresAt: string; + provisionedUser?: SCIMUser; } export interface SSOLoginRequest { @@ -91,6 +92,7 @@ export interface SSOCallbackPayload { code?: string; state?: string; relayState?: string; + claims?: Record; } export interface SAMLMetadata { diff --git a/docs/ENTERPRISE_SSO.md b/docs/ENTERPRISE_SSO.md new file mode 100644 index 00000000..8f60be58 --- /dev/null +++ b/docs/ENTERPRISE_SSO.md @@ -0,0 +1,153 @@ +# Enterprise Single Sign-On (SSO) & JIT Provisioning + +SubTrackr provides enterprise-grade Single Sign-On (SSO) support across **SAML 2.0** and **OpenID Connect (OIDC)**, alongside **Just-in-Time (JIT) provisioning** and **SCIM 2.0** directory integration. + +--- + +## 1. Architecture Overview + +Enterprise organizations can enforce centralized authentication with corporate identity providers (such as Okta, Azure AD / Microsoft Entra ID, Google Workspace, PingFederate, or OneLogin). + +``` + ┌────────────────────────────────────────┐ + │ Identity Provider (IdP) │ + │ (Okta / Azure AD / PingFederate) │ + └──────────────────┬─────────────────────┘ + │ + 1. Initiate SSO Login │ 2. SAML Assertion / + Redirect │ OIDC ID Token + Claims + ▼ + ┌──────────────────┐ + │ SubTrackr SSO │ + │ Gateway / ACS │ + └─────────┬────────┘ + │ + ┌───────────────────┴───────────────────┐ + ▼ ▼ + ┌───────────────────────────┐ ┌───────────────────────────┐ + │ JIT Provisioning │ │ Session Management │ + │ - Auto-create/sync user │ │ - 8-hour secure session │ + │ - Apply role mappings │ │ - Audit log recorded │ + └───────────────────────────┘ └───────────────────────────┘ +``` + +--- + +## 2. Supported Protocols + +### SAML 2.0 +- **Binding**: HTTP-POST and HTTP-Redirect. +- **NameID Format**: Email address (`urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress`). +- **Metadata**: Direct XML metadata import or dynamic configuration from remote IdP metadata URLs. +- **Signatures**: Enforces signed assertions and AuthnRequests. +- **Certificates**: Supports dual-certificate rotation with automated expiring certificate detection. + +### OpenID Connect (OIDC) +- **Grant**: Authorization Code Flow (`response_type=code`). +- **Discovery**: Integrates with IdP discovery endpoints (`.well-known/openid-configuration`). +- **Claims**: Extracts `email`, `displayName`, and corporate `groups` for automated provisioning and role assignment. + +--- + +## 3. Just-in-Time (JIT) User Provisioning + +When JIT provisioning is enabled for an identity provider: +1. **User Authentication**: The user completes authentication at the enterprise IdP. +2. **Attribute Extraction**: SubTrackr parses assertion attributes (email, display name, groups/departments). +3. **Account Provisioning**: + - If the user does not exist in SubTrackr under the organization, a new user account is automatically provisioned. + - If the user already exists, their display name, group memberships, and roles are synchronized on-the-fly (`lastSyncedAt` updated). +4. **Role Mapping**: Corporate IdP group memberships (e.g. `billing-admins`, `it-security`) are automatically translated into SubTrackr platform roles (`admin`, `billing`, `viewer`). + +```typescript +// Example role mapping configuration: +[ + { idpGroup: "Engineering-Leads", subtrackrRole: "admin" }, + { idpGroup: "Finance-Operations", subtrackrRole: "billing" }, + { idpGroup: "All-Employees", subtrackrRole: "viewer" } +] +``` + +--- + +## 4. Emergency Bypass & Resilience + +To prevent enterprise lockouts during third-party IdP outages or misconfigured assertion certificates: +- **Bypass Codes**: Every identity provider generates single-use cryptographically random emergency bypass codes upon creation. +- **Certificate Rotation**: SAML configurations maintain primary and secondary certificates with automatic warnings when certificates are within 30 days of expiration. +- **IP Allowlisting**: Organizations can optionally restrict SSO access to enterprise corporate CIDR ranges. + +--- + +## 5. API Reference + +### Create Identity Provider +`POST /api/v1/sso/identity-providers` +```json +{ + "organizationId": "org_enterprise_01", + "name": "Okta Enterprise", + "protocol": "saml2" +} +``` + +### Configure SAML via Metadata XML +`POST /api/v1/sso/identity-providers/:id/saml/metadata` +```json +{ + "metadataXml": "..." +} +``` + +### Configure OIDC Provider +`POST /api/v1/sso/identity-providers/:id/oidc` +```json +{ + "issuer": "https://login.microsoftonline.com/tenant/v2.0", + "authorizationEndpoint": "https://login.microsoftonline.com/tenant/oauth2/v2.0/authorize", + "tokenEndpoint": "https://login.microsoftonline.com/tenant/oauth2/v2.0/token", + "userinfoEndpoint": "https://graph.microsoft.com/oidc/userinfo", + "jwksUri": "https://login.microsoftonline.com/tenant/discovery/v2.0/keys", + "clientId": "subtrackr-client-id", + "clientSecretHash": "sha256_hashed_secret", + "scopes": ["openid", "email", "profile"] +} +``` + +### Enable JIT Provisioning +`POST /api/v1/sso/identity-providers/:id/jit` +```json +{ + "enabled": true +} +``` + +### Configure Role Mappings +`POST /api/v1/sso/identity-providers/:id/roles` +```json +{ + "mappings": [ + { "idpGroup": "SubTrackr-Admins", "subtrackrRole": "admin" }, + { "idpGroup": "SubTrackr-Finance", "subtrackrRole": "billing" } + ] +] +``` + +### Initiate SSO Login +`POST /api/v1/sso/login` +```json +{ + "identityProviderId": "idp_12345", + "relayState": "/dashboard/subscriptions" +} +``` +**Response**: +```json +{ + "success": true, + "data": { + "redirectUrl": "https://idp.example.com/sso?SAMLRequest=...", + "state": "8f3b2a1c0d4e..." + } +} +```