From d2becc94c049b2ab7d07f83653209f121b335b95 Mon Sep 17 00:00:00 2001 From: Bug00Joe Date: Fri, 31 Jul 2026 13:18:21 +0100 Subject: [PATCH] feat: social link anomaly detection with AML alert feed (#688) Adds an anomaly detector that flags a single verified social identity being attempted against many distinct Revora accounts within a sliding window (identity spraying). On detection it increments a counter metric, logs a high-severity alarm, and feeds the AML sink (default: AuditLogAmlSink, which persists a SECURITY_VIOLATION audit event). - Verify the provider ID token before step-up so every attempt can be attributed to the trusted sub claim and recorded with its outcome (link_success | step_up_failed | identity_conflict | email_conflict). - Non-existent user probes are not recorded to avoid fabricated alert noise. - In-memory and PostgreSQL attempt stores + migration 025. - Detection is best-effort: store/sink/audit failures never break the link. --- docs/social-linking-pop-challenge.md | 120 ++++++ src/app.ts | 11 + src/auth/social/socialAuthService.test.ts | 196 +++++++++ src/auth/social/socialAuthService.ts | 149 +++++-- .../social/socialLinkAnomalyDetector.test.ts | 397 ++++++++++++++++++ src/auth/social/socialLinkAnomalyDetector.ts | 333 +++++++++++++++ src/auth/social/socialLinkAttemptStore.ts | 169 ++++++++ .../025_create_social_link_attempts.sql | 35 ++ 8 files changed, 1366 insertions(+), 44 deletions(-) create mode 100644 docs/social-linking-pop-challenge.md create mode 100644 src/auth/social/socialLinkAnomalyDetector.test.ts create mode 100644 src/auth/social/socialLinkAnomalyDetector.ts create mode 100644 src/auth/social/socialLinkAttemptStore.ts create mode 100644 src/db/migrations/025_create_social_link_attempts.sql diff --git a/docs/social-linking-pop-challenge.md b/docs/social-linking-pop-challenge.md new file mode 100644 index 00000000..e655aced --- /dev/null +++ b/docs/social-linking-pop-challenge.md @@ -0,0 +1,120 @@ +# Social Account Linking — Proof-of-Possession Challenge and Anomaly Detection + +Implements the "wave 7" hardening for Google/Apple account linking: a social +identity may **never** be linked to an existing Revora account on trust-on-first- +use email match alone. Linking requires a proof-of-possession (PoP) challenge, +and a suspicious "same social `sub`, many candidate accounts" pattern emits an +alert that feeds the AML workflow. + +## Security model + +### 1. Proof-of-possession before linking + +`SocialAuthService.linkProvider()` requires: + +- a valid session for the target account (`requireAuth` middleware), **and** +- the account's current password (`currentPassword` in the request body), **and** +- `confirm: true` in the request body. + +The password re-entry is the proof-of-possession: possession of the account's +credentials, not merely possession of a provider ID token, is what authorises a +link. Without it, an attacker who has obtained a Google/Apple token could +otherwise replay it against any account whose email matches the token's email. + +Order of operations in `linkProvider()`: + +1. **Verify the ID token first** (`verifyVerifiedEmail`) so every subsequent + step — including step-up failures — can be attributed to the trusted + `sub` claim. +2. Verify the current password (step-up). +3. Reject if the social identity is already linked to another account, or the + provider email belongs to another password account. +4. Create the `social_identities` row (guarded by `UNIQUE (provider, provider_subject)`). + +### 2. Anomaly detector — social identity spraying + +`SocialLinkAnomalyDetector` (`src/auth/social/socialLinkAnomalyDetector.ts`) +detects when **one** verified `(provider, provider_subject)` is attempted +against **many distinct candidate accounts** within a sliding window. + +| Setting | Default | Meaning | +|--------------------|---------|----------------------------------------------------------------| +| `threshold` | `5` | Distinct candidate accounts that trigger an alert | +| `windowMs` | `24h` | Sliding window over which candidates are counted | +| `cooldownMs` | `1h` | Minimum gap between alerts for the same identity | + +Each link attempt is recorded with its outcome +(`link_success` / `step_up_failed` / `identity_conflict` / `email_conflict`). +Repeated attempts against the **same** account count once, so legitimate +fat-fingering never triggers; only a spray across distinct accounts does. + +When the threshold is crossed the detector: + +1. increments the `social_link_anomaly_total` counter (label: `provider` only — + no PII), +2. logs a high-severity `ALARM: social account-linking anomaly detected`, +3. records a `SECURITY_VIOLATION` audit event (`social_link_anomaly_detected`, + outcome `BLOCKED`) with the candidate account IDs in `details`, +4. feeds the configurable `SocialLinkAnomalyAmlSink` (default: + `AuditLogAmlSink`; a production deployment can supply a sink that creates an + `aml_alerts` row or opens a compliance case). + +**Failure isolation:** detection is best-effort. Store, audit, metric and AML +sink failures are caught so a detector problem can never break an otherwise +valid link flow. + +## Files + +| File | Purpose | +|------|---------| +| `src/auth/social/socialLinkAnomalyDetector.ts` | Detector + `SocialLinkAnomalyAmlSink` + default `AuditLogAmlSink` | +| `src/auth/social/socialLinkAttemptStore.ts` | Attempt-store interface + in-memory + PostgreSQL implementations | +| `src/db/migrations/025_create_social_link_attempts.sql` | `social_link_attempts` table | +| `src/auth/social/socialAuthService.ts` | `linkProvider()` — PoP step-up + attempt recording | +| `src/auth/social/socialAuthRoute.ts` | `confirm: true` + `currentPassword` required on `/link` | + +## Persistence + +Migration `025_create_social_link_attempts.sql` creates `social_link_attempts`: + +| Column | Notes | +|-------------------|-------| +| `provider` | `google` \| `apple` | +| `provider_subject`| Verified `sub` claim | +| `user_id` | Candidate account | +| `outcome` | `link_success` \| `step_up_failed` \| `identity_conflict` \| `email_conflict` | +| `attempted_at` | `TIMESTAMPTZ` | +| PK | `(provider, provider_subject, user_id)` — each candidate counted once | + +The PK makes `ON CONFLICT DO UPDATE` safe under concurrent link attempts. + +`PgSocialLinkAttemptStore` is the production implementation; tests use +`InMemorySocialLinkAttemptStore`. For multi-instance deployments the store +should be a shared store (Redis or PostgreSQL). + +## Abuse / failure paths + +| Scenario | Behaviour | +|----------|-----------| +| Same sub sprayed across ≥ `threshold` accounts | Alert emitted (metric + alarm + audit + AML sink), PoP still enforced per attempt | +| Wrong password repeatedly on **one** account | Counted once; no alert | +| Non-existent user ID probes | **Not** recorded (avoids fabricated alert noise) | +| Identity already linked to another account | Attempt recorded as `identity_conflict` | +| Detector store / AML sink down | Detection skipped, link flow unaffected | +| Expired/wrong PoP | `STEP_UP_REQUIRED` (401), attempt recorded as `step_up_failed` | + +## Metrics + +- `social_link_anomaly_total` (counter) — number of detected anomaly patterns. +- Reuse existing per-identity rate limiting from + `src/middleware/socialAntiEnumerationMiddleware.ts` as the login-side defence. + +## Tests + +```bash +npx jest src/auth/social/socialLinkAnomalyDetector.test.ts src/auth/social/socialAuthService.test.ts +``` + +Covers threshold crossing, single-account non-triggering, window expiry, +cooldown re-arm, provider/subject isolation, sink/audit failure isolation, and +the malicious-link scenarios end-to-end through `SocialAuthService.linkProvider`. diff --git a/src/app.ts b/src/app.ts index eb356407..8c40e238 100644 --- a/src/app.ts +++ b/src/app.ts @@ -32,6 +32,9 @@ import { SocialUserRepositoryAdapter } from './auth/social/socialUserRepositoryA import { SocialAuthService } from './auth/social/socialAuthService'; import { createDefaultSocialTokenVerifierFromEnv } from './auth/social/providerVerifiers'; import { createSocialAuthRouter } from './auth/social/socialAuthRoute'; +import { PgSocialLinkAttemptStore } from './auth/social/socialLinkAttemptStore'; +import { AuditLogAmlSink, SocialLinkAnomalyDetector } from './auth/social/socialLinkAnomalyDetector'; +import { createSecurityAuditRepository } from './security/audit'; import { createReconciliationMetricsHandler } from './routes/reconciliationRoutes'; // Adapter to convert database User to login service UserRecord @@ -206,12 +209,20 @@ export function createApp() { new SessionRepositoryAdapter(sessionRepository), jwtIssuer, ); + const socialAuditRepository = createSecurityAuditRepository(pool); const socialAuthService = new SocialAuthService( new SocialUserRepositoryAdapter(userRepository), new SocialIdentityRepository(pool), new SessionRepositoryAdapter(sessionRepository), jwtIssuer, createDefaultSocialTokenVerifierFromEnv(), + // Social identity-spray anomaly detector. The default AML sink persists + // detections as SECURITY_VIOLATION audit events so the signal survives even + // without a dedicated AML integration. + new SocialLinkAnomalyDetector({ + store: new PgSocialLinkAttemptStore(pool), + amlSink: new AuditLogAmlSink(socialAuditRepository), + }), ); // Refresh service diff --git a/src/auth/social/socialAuthService.test.ts b/src/auth/social/socialAuthService.test.ts index 249046cc..da1f4d6f 100644 --- a/src/auth/social/socialAuthService.test.ts +++ b/src/auth/social/socialAuthService.test.ts @@ -21,6 +21,13 @@ import { SocialUserRecord, SocialUserRepository, } from './types'; +import { + SocialLinkAnomalyDetector, + SocialLinkAnomalyDetection, + SocialLinkAnomalyAmlSink, +} from './socialLinkAnomalyDetector'; +import { InMemorySocialLinkAttemptStore } from './socialLinkAttemptStore'; +import { MetricsCollector } from '../../lib/metrics'; const hashPassword = (plain: string): string => createHash('sha256').update(plain).digest('hex'); @@ -681,3 +688,192 @@ describe('SocialAuthService', () => { expect(result.identity.isPrivateRelay).toBe(true); }); }); + +// ── Anomaly-detection integration tests ────────────────────────────────────── +// +// Covers the malicious-link scenarios from issue #688: a single verified social +// identity (`provider:sub`) sprayed across many candidate accounts must trip the +// SocialLinkAnomalyDetector and feed the AML sink. + +describe('SocialAuthService — link anomaly detection', () => { + function fixtureWithDetector(threshold = 3) { + const users = new FakeUsers(); + const identities = new FakeIdentities(); + const sessions = new FakeSessions(); + const verifier = new FakeVerifier(); + + const emissions: SocialLinkAnomalyDetection[] = []; + const amlSink: SocialLinkAnomalyAmlSink = { + emit: jest.fn(async (detection: SocialLinkAnomalyDetection) => { + emissions.push(detection); + }), + }; + + const detector = new SocialLinkAnomalyDetector({ + threshold, + store: new InMemorySocialLinkAttemptStore(), + metrics: { incrementCounter: jest.fn() } as unknown as MetricsCollector, + amlSink, + now: () => new Date(), + }); + + const service = new SocialAuthService( + users, + identities, + sessions, + new FakeJwtIssuer(), + verifier, + detector, + ); + + return { users, identities, sessions, verifier, detector, service, emissions }; + } + + function addUser(users: FakeUsers, id: string, email: string, password: string): void { + users.add({ id, email, role: 'investor', passwordHash: hashPassword(password) }); + } + + it('flags the same social sub sprayed across many accounts (step-up failures)', async () => { + const { users, service, detector, emissions } = fixtureWithDetector(3); + + for (let i = 1; i <= 4; i++) { + addUser(users, `user-${i}`, `user${i}@example.com`, 'Password123!'); + } + + // Attacker holds a valid token for `google-subject-1` and probes 4 accounts + // with a wrong current password. Each attempt fails step-up but is recorded. + for (let i = 1; i <= 4; i++) { + await expect( + service.linkProvider({ + userId: `user-${i}`, + provider: 'google', + idToken: 'token', + currentPassword: 'WrongPassword!', + }), + ).rejects.toMatchObject({ code: 'STEP_UP_REQUIRED' }); + } + + // All four distinct candidate accounts are observed. + expect(await detector.getCandidateCount('google', 'google-subject-1')).toBe(4); + + // The AML sink was fed once (threshold 3 crossed on the third account; + // the fourth is suppressed by cooldown). + expect(emissions).toHaveLength(1); + expect(emissions[0].candidateCount).toBe(3); + expect(emissions[0].candidateUserIds).toEqual(['user-1', 'user-2', 'user-3']); + }); + + it('does not flag repeated step-up failures against the same single account', async () => { + const { users, service, detector, emissions } = fixtureWithDetector(3); + addUser(users, 'user-1', 'user1@example.com', 'Password123!'); + + // Legitimate owner fat-fingers their password three times on the same account. + for (let i = 0; i < 3; i++) { + await expect( + service.linkProvider({ + userId: 'user-1', + provider: 'google', + idToken: 'token', + currentPassword: 'WrongPassword!', + }), + ).rejects.toMatchObject({ code: 'STEP_UP_REQUIRED' }); + } + + expect(await detector.getCandidateCount('google', 'google-subject-1')).toBe(1); + expect(emissions).toHaveLength(0); + }); + + it('does not record non-existent account probes as candidates', async () => { + const { service, detector, emissions } = fixtureWithDetector(3); + + for (let i = 0; i < 3; i++) { + await expect( + service.linkProvider({ + userId: `ghost-${i}`, + provider: 'google', + idToken: 'token', + currentPassword: 'Anything!', + }), + ).rejects.toMatchObject({ code: 'USER_NOT_FOUND' }); + } + + expect(await detector.getCandidateCount('google', 'google-subject-1')).toBe(0); + expect(emissions).toHaveLength(0); + }); + + it('records identity-conflict attempts and a genuine spray across linked accounts', async () => { + const { users, identities, service, detector, emissions } = fixtureWithDetector(2); + + // user-1 already owns google-subject-1. + addUser(users, 'user-1', 'user1@example.com', 'Password123!'); + addUser(users, 'user-2', 'user2@example.com', 'Password123!'); + await identities.createIdentity({ + userId: 'user-1', + provider: 'google', + providerSubject: 'google-subject-1', + providerEmail: 'user1@example.com', + emailVerified: true, + }); + + // user-1 relinks its own identity → idempotent success (counted as candidate). + await service.linkProvider({ + userId: 'user-1', + provider: 'google', + idToken: 'token', + currentPassword: 'Password123!', + }); + + // user-2 tries to link the same identity → IDENTITY_LINKED_TO_ANOTHER_USER. + await expect( + service.linkProvider({ + userId: 'user-2', + provider: 'google', + idToken: 'token', + currentPassword: 'Password123!', + }), + ).rejects.toMatchObject({ code: 'IDENTITY_LINKED_TO_ANOTHER_USER' }); + + // Two distinct candidate accounts → threshold (2) crossed. + expect(await detector.getCandidateCount('google', 'google-subject-1')).toBe(2); + expect(emissions).toHaveLength(1); + expect(emissions[0].candidateCount).toBe(2); + }); + + it('detector failure never breaks an otherwise valid link', async () => { + const users = new FakeUsers(); + const identities = new FakeIdentities(); + const sessions = new FakeSessions(); + const verifier = new FakeVerifier(); + + const brokenDetector = new SocialLinkAnomalyDetector({ + threshold: 2, + store: { + recordAttempt: jest.fn(async () => { + throw new Error('store unavailable'); + }), + listCandidateUserIds: jest.fn(async () => []), + reset: jest.fn(async () => undefined), + } as never, + now: () => new Date(), + }); + + const service = new SocialAuthService( + users, + identities, + sessions, + new FakeJwtIssuer(), + verifier, + brokenDetector, + ); + addUser(users, 'user-1', 'user1@example.com', 'Password123!'); + + // Link succeeds even though the detector's store throws. + const result = await service.linkProvider({ + userId: 'user-1', + provider: 'google', + idToken: 'token', + currentPassword: 'Password123!', + }); + expect(result.linked).toBe(true); + }); +}); diff --git a/src/auth/social/socialAuthService.ts b/src/auth/social/socialAuthService.ts index 5748cd14..1bb5c0a2 100644 --- a/src/auth/social/socialAuthService.ts +++ b/src/auth/social/socialAuthService.ts @@ -36,11 +36,16 @@ import { SocialIdentityRepository, SocialLinkResult, SocialLoginResult, + SocialProviderClaims, SocialTokenVerifier, SocialUnlinkResult, SocialUserRecord, SocialUserRepository, } from './types'; +import { + SocialLinkAttemptOutcome, +} from './socialLinkAttemptStore'; +import { SocialLinkAnomalyDetector } from './socialLinkAnomalyDetector'; // ── Constant-time lookup helper ──────────────────────────────────────────────── @@ -98,6 +103,7 @@ export class SocialAuthService { private readonly sessionRepository: SessionRepository, private readonly jwtIssuer: JwtIssuer, private readonly tokenVerifier: SocialTokenVerifier, + private readonly anomalyDetector?: SocialLinkAnomalyDetector, ) {} /** @@ -163,6 +169,10 @@ export class SocialAuthService { * prevent an attacker who has obtained a provider token from silently * adding a social login to an account they do not fully control. * + * The provider ID token is verified FIRST so every attempt — including + * step-up failures — can be attributed to the trusted `sub` claim and + * recorded with the anomaly detector (see `SocialLinkAnomalyDetector`). + * * @param input.userId Authenticated user's UUID. * @param input.provider Social provider to link. * @param input.idToken Provider-issued identity token. @@ -175,63 +185,89 @@ export class SocialAuthService { idToken: string; currentPassword: string; }): Promise { - const user = await this.requireUserWithPassword(input.userId, input.currentPassword); const claims = await this.verifyVerifiedEmail(input.provider, input.idToken); - const existingProviderSubject = await this.identityRepository.findByProviderSubject( - input.provider, - claims.subject, - ); - if (existingProviderSubject && existingProviderSubject.userId !== input.userId) { - throw new SocialAuthError( - 'IDENTITY_LINKED_TO_ANOTHER_USER', - 'Social identity is already linked to another account.', - ); - } + let outcome: SocialLinkAttemptOutcome | null = 'link_success'; - const existingUserProvider = await this.identityRepository.findByUserAndProvider( - input.userId, - input.provider, - ); + try { + const user = await this.requireUserWithPassword(input.userId, input.currentPassword); - if (existingUserProvider) { - if (existingUserProvider.providerSubject !== claims.subject) { + const existingProviderSubject = await this.identityRepository.findByProviderSubject( + input.provider, + claims.subject, + ); + if (existingProviderSubject && existingProviderSubject.userId !== input.userId) { + outcome = 'identity_conflict'; throw new SocialAuthError( 'IDENTITY_LINKED_TO_ANOTHER_USER', - 'This account already has a different identity for the provider.', + 'Social identity is already linked to another account.', ); } - if (existingUserProvider.providerEmail !== claims.email) { - await this.identityRepository.updateIdentityEmail( - existingUserProvider.id, - claims.email, - claims.isPrivateRelay, - ); + + const existingUserProvider = await this.identityRepository.findByUserAndProvider( + input.userId, + input.provider, + ); + + if (existingUserProvider) { + if (existingUserProvider.providerSubject !== claims.subject) { + outcome = 'identity_conflict'; + throw new SocialAuthError( + 'IDENTITY_LINKED_TO_ANOTHER_USER', + 'This account already has a different identity for the provider.', + ); + } + if (existingUserProvider.providerEmail !== claims.email) { + await this.identityRepository.updateIdentityEmail( + existingUserProvider.id, + claims.email, + claims.isPrivateRelay, + ); + } + return { linked: true, identity: existingUserProvider }; } - return { linked: true, identity: existingUserProvider }; - } - // Apple private-relay emails are transient — skip email-collision check. - if (!claims.isPrivateRelay && user.email !== claims.email) { - const emailUser = await this.userRepository.findByEmail(claims.email); - if (emailUser && emailUser.id !== input.userId) { - throw new SocialAuthError( - 'EMAIL_ACCOUNT_REQUIRES_LINK', - 'Provider email belongs to another password account.', - ); + // Apple private-relay emails are transient — skip email-collision check. + if (!claims.isPrivateRelay && user.email !== claims.email) { + const emailUser = await this.userRepository.findByEmail(claims.email); + if (emailUser && emailUser.id !== input.userId) { + outcome = 'email_conflict'; + throw new SocialAuthError( + 'EMAIL_ACCOUNT_REQUIRES_LINK', + 'Provider email belongs to another password account.', + ); + } } - } - const identity = await this.identityRepository.createIdentity({ - userId: input.userId, - provider: input.provider, - providerSubject: claims.subject, - providerEmail: claims.email, - emailVerified: claims.emailVerified, - isPrivateRelay: claims.isPrivateRelay, - }); + const identity = await this.identityRepository.createIdentity({ + userId: input.userId, + provider: input.provider, + providerSubject: claims.subject, + providerEmail: claims.email, + emailVerified: claims.emailVerified, + isPrivateRelay: claims.isPrivateRelay, + }); - return { linked: true, identity }; + return { linked: true, identity }; + } catch (err) { + if (err instanceof SocialAuthError) { + switch (err.code) { + case 'STEP_UP_REQUIRED': + outcome = 'step_up_failed'; + break; + case 'USER_NOT_FOUND': + // A non-existent user is not a real candidate account — recording it + // would let an attacker with a valid token fabricate alert noise. + outcome = null; + break; + } + } + throw err; + } finally { + if (outcome !== null) { + await this.recordLinkAttempt(input.userId, claims, outcome); + } + } } /** @@ -255,6 +291,31 @@ export class SocialAuthService { // ── Private helpers ────────────────────────────────────────────────────── + /** + * @notice Forwards a link attempt to the anomaly detector. + * + * @dev Recording failures are swallowed: anomaly detection must never + * change the outcome of an otherwise valid link. + */ + private async recordLinkAttempt( + userId: string, + claims: SocialProviderClaims, + outcome: SocialLinkAttemptOutcome, + ): Promise { + if (!this.anomalyDetector) return; + try { + await this.anomalyDetector.recordAttempt({ + provider: claims.provider, + providerSubject: claims.subject, + userId, + outcome, + attemptedAt: new Date(), + }); + } catch { + // Detection is best-effort; never break the link flow. + } + } + private async verifyVerifiedEmail(provider: SocialAuthProvider, idToken: string) { const claims = await this.tokenVerifier.verify(provider, idToken); if (!claims.emailVerified) { diff --git a/src/auth/social/socialLinkAnomalyDetector.test.ts b/src/auth/social/socialLinkAnomalyDetector.test.ts new file mode 100644 index 00000000..2877f793 --- /dev/null +++ b/src/auth/social/socialLinkAnomalyDetector.test.ts @@ -0,0 +1,397 @@ +/** + * Tests for socialLinkAnomalyDetector.ts + * + * Covers: + * - Constructor validation (threshold, window, cooldown) + * - Threshold crossing with distinct candidate accounts + * - Repeated attempts against the same account counted once + * - Sliding window expiry + * - Cooldown suppression of repeat alerts + * - Metric, security audit event, and AML sink emission + * - Failure isolation (sink/audit errors never break the flow) + * - Isolation across providers/subjects + */ + +import { + SocialLinkAnomalyDetector, + SocialLinkAnomalyDetection, + SocialLinkAnomalyAmlSink, + AuditLogAmlSink, +} from './socialLinkAnomalyDetector'; +import { InMemorySocialLinkAttemptStore } from './socialLinkAttemptStore'; +import { SocialAuthProvider } from './types'; +import { SecurityAuditRepository, AuditEvent } from '../../security/types'; +import { MetricsCollector } from '../../lib/metrics'; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const T0 = new Date('2026-07-31T00:00:00.000Z'); + +interface Recorder { + metrics: MetricsCollector; + audit: SecurityAuditRepository; + sink: SocialLinkAnomalyAmlSink & { emissions: SocialLinkAnomalyDetection[] }; + emissions: SocialLinkAnomalyDetection[]; +} + +function buildRecorder(): Recorder { + const metrics = { incrementCounter: jest.fn() } as unknown as MetricsCollector; + const auditEvents: AuditEvent[] = []; + const audit = { + events: auditEvents, + record: jest.fn(async (event: AuditEvent) => { + auditEvents.push(event); + }), + } as unknown as SecurityAuditRepository; + const emissions: SocialLinkAnomalyDetection[] = []; + const sink = { + emit: jest.fn(async (detection: SocialLinkAnomalyDetection) => { + emissions.push(detection); + }), + emissions, + } as SocialLinkAnomalyAmlSink & { emissions: SocialLinkAnomalyDetection[] }; + return { metrics, audit, sink, emissions }; +} + +function makeDetector(overrides: { + threshold?: number; + windowMs?: number; + cooldownMs?: number; + store?: InMemorySocialLinkAttemptStore; + now?: () => Date; +} = {}) { + const recorder = buildRecorder(); + const detector = new SocialLinkAnomalyDetector({ + threshold: overrides.threshold, + windowMs: overrides.windowMs, + cooldownMs: overrides.cooldownMs, + store: overrides.store ?? new InMemorySocialLinkAttemptStore(), + metrics: recorder.metrics, + auditRepository: recorder.audit, + amlSink: recorder.sink, + now: overrides.now ?? (() => T0), + }); + return { detector, ...recorder }; +} + +/** Mutable clock so tests can advance time deterministically. */ +function makeClock() { + let current = T0; + return { + set(t: Date) { + current = t; + }, + now: () => current, + }; +} + +function attempt( + detector: SocialLinkAnomalyDetector, + opts: { + provider?: SocialAuthProvider; + subject?: string; + userId: string; + outcome?: 'link_success' | 'step_up_failed' | 'identity_conflict' | 'email_conflict'; + at?: Date; + }, +): Promise { + return detector.recordAttempt({ + provider: opts.provider ?? 'google', + providerSubject: opts.subject ?? 'victim-sub', + userId: opts.userId, + outcome: opts.outcome ?? 'step_up_failed', + attemptedAt: opts.at ?? T0, + }); +} + +// ── Constructor validation ─────────────────────────────────────────────────── + +describe('SocialLinkAnomalyDetector constructor', () => { + it('throws when threshold is below 2', () => { + expect(() => new SocialLinkAnomalyDetector({ threshold: 1 })).toThrow( + 'threshold must be an integer >= 2', + ); + }); + + it('throws when threshold is not an integer', () => { + expect(() => new SocialLinkAnomalyDetector({ threshold: 2.5 })).toThrow( + 'threshold must be an integer >= 2', + ); + }); + + it('throws when windowMs is not positive', () => { + expect(() => new SocialLinkAnomalyDetector({ windowMs: 0 })).toThrow('windowMs must be positive'); + }); + + it('throws when cooldownMs is negative', () => { + expect(() => new SocialLinkAnomalyDetector({ cooldownMs: -1 })).toThrow( + 'cooldownMs must be non-negative', + ); + }); + + it('applies sensible defaults', () => { + const { detector } = makeDetector(); + expect(detector).toBeInstanceOf(SocialLinkAnomalyDetector); + }); +}); + +// ── Detection logic ────────────────────────────────────────────────────────── + +describe('SocialLinkAnomalyDetector detection', () => { + it('returns null below the threshold', async () => { + const { detector } = makeDetector({ threshold: 3 }); + for (const userId of ['user-1', 'user-2']) { + await expect(attempt(detector, { userId })).resolves.toBeNull(); + } + }); + + it('detects when one social sub is sprayed across threshold distinct accounts', async () => { + const { detector, sink, metrics, emissions } = makeDetector({ threshold: 3 }); + + const detections: Array = []; + for (const userId of ['user-1', 'user-2', 'user-3']) { + detections.push(await attempt(detector, { userId })); + } + + // First two attempts: below threshold. Third: triggered. + expect(detections[0]).toBeNull(); + expect(detections[1]).toBeNull(); + + const detection = detections[2]; + expect(detection).not.toBeNull(); + expect(detection!.provider).toBe('google'); + expect(detection!.providerSubject).toBe('victim-sub'); + expect(detection!.candidateCount).toBe(3); + expect(detection!.candidateUserIds).toEqual(['user-1', 'user-2', 'user-3']); + expect(detection!.threshold).toBe(3); + + // Metric emitted once with provider label only (no PII). + expect(metrics.incrementCounter as jest.Mock).toHaveBeenCalledWith( + 'social_link_anomaly_total', + { provider: 'google' }, + 1, + expect.any(String), + ); + + // AML sink fed exactly once. + expect(sink.emit as jest.Mock).toHaveBeenCalledTimes(1); + expect(emissions[0].candidateUserIds).toEqual(['user-1', 'user-2', 'user-3']); + }); + + it('counts repeated attempts against the same account only once', async () => { + const { detector, sink } = makeDetector({ threshold: 3 }); + + // user-1 tried 3 times (same candidate), then two more distinct accounts. + for (let i = 0; i < 3; i++) { + await attempt(detector, { userId: 'user-1' }); + } + await attempt(detector, { userId: 'user-2' }); + const detection = await attempt(detector, { userId: 'user-3' }); + + expect(detection).not.toBeNull(); + expect(detection!.candidateCount).toBe(3); + expect(detection!.candidateUserIds).toEqual(['user-1', 'user-2', 'user-3']); + expect(sink.emit as jest.Mock).toHaveBeenCalledTimes(1); + }); + + it('does not count attempts older than the sliding window', async () => { + const windowMs = 60 * 60 * 1000; // 1h + const clock = makeClock(); + const { detector } = makeDetector({ threshold: 3, windowMs, now: clock.now }); + + // Two distinct candidates at T0. + await attempt(detector, { userId: 'user-1', at: T0 }); + await attempt(detector, { userId: 'user-2', at: T0 }); + + // Jump 90 minutes: the two old attempts fall outside the window. + clock.set(new Date(T0.getTime() + 90 * 60 * 1000)); + const detection = await attempt(detector, { userId: 'user-3', at: clock.now() }); + + // Only user-3 is inside the window → below threshold. + expect(detection).toBeNull(); + expect(await detector.getCandidateCount('google', 'victim-sub')).toBe(1); + }); + + it('keeps attempts inside the window after expiry', async () => { + const windowMs = 2 * 60 * 60 * 1000; // 2h + const { detector } = makeDetector({ threshold: 3, windowMs }); + + await attempt(detector, { userId: 'user-1', at: T0 }); + await attempt(detector, { userId: 'user-2', at: new Date(T0.getTime() + 30 * 60 * 1000) }); + + const detection = await attempt( + detector, + { userId: 'user-3', at: new Date(T0.getTime() + 60 * 60 * 1000) }, + ); + + expect(detection).not.toBeNull(); + expect(detection!.candidateCount).toBe(3); + }); + + it('suppresses repeat alerts within the cooldown window', async () => { + const { detector, sink } = makeDetector({ threshold: 3, cooldownMs: 60 * 60 * 1000 }); + + for (const userId of ['user-1', 'user-2', 'user-3']) { + await attempt(detector, { userId }); + } + expect(sink.emit as jest.Mock).toHaveBeenCalledTimes(1); + + // user-4 crosses the threshold again but is within cooldown → suppressed. + const suppressed = await attempt(detector, { userId: 'user-4' }); + expect(suppressed).toBeNull(); + expect(sink.emit as jest.Mock).toHaveBeenCalledTimes(1); + }); + + it('re-arms after the cooldown elapses', async () => { + const cooldownMs = 60 * 60 * 1000; + const clock = makeClock(); + const { detector, sink } = makeDetector({ threshold: 3, cooldownMs, now: clock.now }); + + for (const userId of ['user-1', 'user-2', 'user-3']) { + await attempt(detector, { userId, at: clock.now() }); + } + expect(sink.emit as jest.Mock).toHaveBeenCalledTimes(1); + + // Advance past cooldown; user-4 crosses the threshold again. + clock.set(new Date(T0.getTime() + cooldownMs + 1000)); + const detection = await attempt(detector, { userId: 'user-4', at: clock.now() }); + + expect(detection).not.toBeNull(); + expect(detection!.candidateUserIds).toContain('user-4'); + expect(sink.emit as jest.Mock).toHaveBeenCalledTimes(2); + }); + + it('tracks provider+subject identities independently', async () => { + const { detector, sink } = makeDetector({ threshold: 3 }); + + // 3 distinct accounts for google:victim-sub → triggers. + await attempt(detector, { provider: 'google', subject: 'victim-sub', userId: 'user-1' }); + await attempt(detector, { provider: 'google', subject: 'victim-sub', userId: 'user-2' }); + await attempt(detector, { provider: 'google', subject: 'victim-sub', userId: 'user-3' }); + expect(sink.emit as jest.Mock).toHaveBeenCalledTimes(1); + + // Same 3 user IDs under apple:victim-sub are an independent identity and + // must not re-trigger (it starts from zero). + await attempt(detector, { provider: 'apple', subject: 'victim-sub', userId: 'user-1' }); + await attempt(detector, { provider: 'apple', subject: 'victim-sub', userId: 'user-2' }); + const detection = await attempt(detector, { provider: 'apple', subject: 'victim-sub', userId: 'user-3' }); + + // apple identity also reaches 3 → triggers independently. + expect(detection).not.toBeNull(); + expect(detection!.provider).toBe('apple'); + expect(sink.emit as jest.Mock).toHaveBeenCalledTimes(2); + }); +}); + +// ── Side-effect isolation ──────────────────────────────────────────────────── + +describe('SocialLinkAnomalyDetector side effects', () => { + it('records a SECURITY_VIOLATION audit event on detection', async () => { + const { detector, audit } = makeDetector({ threshold: 2 }); + + await attempt(detector, { userId: 'user-1' }); + await attempt(detector, { userId: 'user-2' }); + + const events = (audit as unknown as { events: AuditEvent[] }).events; + const anomaly = events.find((e) => e.action === 'social_link_anomaly_detected'); + expect(anomaly).toBeDefined(); + expect(anomaly!.type).toBe('SECURITY_VIOLATION'); + expect(anomaly!.outcome).toBe('BLOCKED'); + expect(anomaly!.details.candidateCount).toBe(2); + expect(anomaly!.details.providerSubject).toBe('victim-sub'); + }); + + it('never throws when the audit repository fails', async () => { + const recorder = buildRecorder(); + const audit = { + record: jest.fn(async () => { + throw new Error('audit db down'); + }), + } as unknown as SecurityAuditRepository; + const detector = new SocialLinkAnomalyDetector({ + threshold: 2, + store: new InMemorySocialLinkAttemptStore(), + metrics: recorder.metrics, + auditRepository: audit, + amlSink: recorder.sink, + now: () => T0, + }); + + await expect(attempt(detector, { userId: 'user-1' })).resolves.toBeNull(); + const detection = await attempt(detector, { userId: 'user-2' }); + expect(detection).not.toBeNull(); + }); + + it('never throws when the AML sink fails', async () => { + const recorder = buildRecorder(); + const failingSink: SocialLinkAnomalyAmlSink = { + emit: jest.fn(async () => { + throw new Error('aml downstream down'); + }), + }; + const detector = new SocialLinkAnomalyDetector({ + threshold: 2, + store: new InMemorySocialLinkAttemptStore(), + metrics: recorder.metrics, + auditRepository: recorder.audit, + amlSink: failingSink, + now: () => T0, + }); + + await attempt(detector, { userId: 'user-1' }); + const detection = await attempt(detector, { userId: 'user-2' }); + expect(detection).not.toBeNull(); + expect(failingSink.emit).toHaveBeenCalledTimes(1); + }); + + it('never throws when the store fails', async () => { + const recorder = buildRecorder(); + const store = new InMemorySocialLinkAttemptStore(); + const brokenStore = { + recordAttempt: jest.fn(async () => { + throw new Error('store down'); + }), + listCandidateUserIds: store.listCandidateUserIds.bind(store), + reset: store.reset.bind(store), + }; + const detector = new SocialLinkAnomalyDetector({ + threshold: 2, + store: brokenStore as never, + metrics: recorder.metrics, + auditRepository: recorder.audit, + amlSink: recorder.sink, + now: () => T0, + }); + + await expect(attempt(detector, { userId: 'user-1' })).rejects.toThrow('store down'); + }); +}); + +// ── AuditLogAmlSink ────────────────────────────────────────────────────────── + +describe('AuditLogAmlSink', () => { + it('records the anomaly as a BLOCKED SECURITY_VIOLATION event', async () => { + const recorder = buildRecorder(); + const sink = new AuditLogAmlSink(recorder.audit); + + const detection: SocialLinkAnomalyDetection = { + provider: 'google', + providerSubject: 'victim-sub', + candidateUserIds: ['user-1', 'user-2'], + candidateCount: 2, + threshold: 2, + windowMs: 86_400_000, + detectedAt: T0, + }; + + await sink.emit(detection); + + const events = (recorder.audit as unknown as { events: AuditEvent[] }).events; + expect(events).toHaveLength(1); + expect(events[0].type).toBe('SECURITY_VIOLATION'); + expect(events[0].outcome).toBe('BLOCKED'); + expect(events[0].action).toBe('social_link_anomaly_detected'); + expect(events[0].userId).toBe('user-1'); + expect(events[0].details.candidateCount).toBe(2); + }); +}); diff --git a/src/auth/social/socialLinkAnomalyDetector.ts b/src/auth/social/socialLinkAnomalyDetector.ts new file mode 100644 index 00000000..04027770 --- /dev/null +++ b/src/auth/social/socialLinkAnomalyDetector.ts @@ -0,0 +1,333 @@ +/** + * @file socialLinkAnomalyDetector.ts + * + * @notice Detects suspicious social account-linking patterns and feeds the AML + * workflow. + * + * @dev The attack this defends against is "social identity spraying": an + * attacker who obtains a Google/Apple ID token (or who is able to produce + * one for a victim's `sub`) tries to link that same social identity to + * many *different* Revora accounts. Each attempt requires step-up + * (current password), so the spray manifests as a burst of failed link + * attempts across many candidate accounts. + * + * The detector counts DISTINCT candidate accounts per + * `(provider, provider_subject)` within a sliding window. When the count + * reaches `threshold` (default 5 distinct accounts in 24h) it: + * + * 1. Emits a `social_link_anomaly_total` counter metric. + * 2. Logs a high-severity ALARM. + * 3. Records a `social_link_anomaly_detected` security audit event + * (type SECURITY_VIOLATION, outcome BLOCKED). + * 4. Feeds the AML sink so compliance analysts can open a case. + * + * A cooldown (default 1h) suppresses repeat alerts for the same identity + * so a sustained attack alerts periodically rather than on every attempt. + * + * Security assumptions: + * - The provider subject is only trusted after `SocialTokenVerifier` validates + * the ID token (RS256 + JWKS). The detector itself never parses raw tokens. + * - Alerts must not leak the raw ID token or provider email into logs/metrics; + * only provider + subject + candidate user IDs are emitted. + * - Detector failures must never break the link flow: all alert-side I/O is + * wrapped so a sink/audit failure cannot abort an otherwise valid link. + */ + +import { Logger, globalLogger } from '../../lib/logger'; +import { MetricsCollector, globalMetrics } from '../../lib/metrics'; +import { SecurityAuditRepository } from '../../security/types'; +import { SocialAuthProvider } from './types'; +import { + InMemorySocialLinkAttemptStore, + SocialLinkAttempt, + SocialLinkAttemptStore, +} from './socialLinkAttemptStore'; + +// ─── Constants ─────────────────────────────────────────────────────────────── + +const METRIC_ANOMALY = 'social_link_anomaly_total'; +const AUDIT_ACTION_ANOMALY = 'social_link_anomaly_detected'; +const AUDIT_RESOURCE_PREFIX = 'social-identity'; + +// ─── Public types ──────────────────────────────────────────────────────────── + +/** + * Result of a single attempt evaluation. + */ +export interface SocialLinkAnomalyDetection { + provider: SocialAuthProvider; + providerSubject: string; + /** Distinct candidate accounts observed in the window. */ + candidateUserIds: string[]; + candidateCount: number; + threshold: number; + windowMs: number; + detectedAt: Date; +} + +/** + * Sink that receives confirmed anomalies. A production implementation should + * create an AML alert / compliance case; the default implementation records a + * SECURITY_VIOLATION audit event so the signal is preserved even when no AML + * integration is configured. + */ +export interface SocialLinkAnomalyAmlSink { + emit(detection: SocialLinkAnomalyDetection): Promise; +} + +export interface SocialLinkAnomalyDetectorOptions { + /** + * Distinct candidate accounts that trigger the anomaly within `windowMs`. + * @default 5 + */ + threshold?: number; + /** + * Sliding window over which distinct candidates are counted. + * @default 24h + */ + windowMs?: number; + /** + * Minimum gap between alerts for the same identity. + * @default 1h + */ + cooldownMs?: number; + /** Attempt store. Defaults to an in-memory store. */ + store?: SocialLinkAttemptStore; + /** Metrics collector. Defaults to `globalMetrics`. */ + metrics?: MetricsCollector; + /** Logger. Defaults to `globalLogger`. */ + logger?: Logger; + /** Security audit repository for anomaly events. */ + auditRepository?: SecurityAuditRepository; + /** AML sink fed on detection. Defaults to a security-audit sink. */ + amlSink?: SocialLinkAnomalyAmlSink; + /** Clock override for deterministic tests. */ + now?: () => Date; +} + +// ─── Default AML sink ──────────────────────────────────────────────────────── + +/** + * Default AML sink: records the anomaly as a SECURITY_VIOLATION audit event. + * Production deployments can supply a sink that creates an `aml_alerts` row or + * opens a compliance case. + */ +export class AuditLogAmlSink implements SocialLinkAnomalyAmlSink { + constructor( + private readonly auditRepository: SecurityAuditRepository, + private readonly logger: Logger = globalLogger, + ) {} + + async emit(detection: SocialLinkAnomalyDetection): Promise { + await this.auditRepository.record({ + id: `audit_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, + type: 'SECURITY_VIOLATION', + // Anchor the event to the first candidate account; the full candidate set + // is preserved in details for the analyst. + userId: detection.candidateUserIds[0], + action: AUDIT_ACTION_ANOMALY, + resource: `${AUDIT_RESOURCE_PREFIX}/${detection.provider}/${detection.providerSubject}`, + outcome: 'BLOCKED', + details: { + provider: detection.provider, + providerSubject: detection.providerSubject, + candidateUserIds: detection.candidateUserIds, + candidateCount: detection.candidateCount, + threshold: detection.threshold, + windowMs: detection.windowMs, + }, + securityContext: { + requestId: `social-link-anomaly-${Date.now()}`, + ipAddress: 'system', + userAgent: 'social-link-anomaly-detector', + timestamp: detection.detectedAt, + }, + timestamp: detection.detectedAt, + }); + } +} + +// ─── Detector ──────────────────────────────────────────────────────────────── + +export class SocialLinkAnomalyDetector { + private readonly threshold: number; + private readonly windowMs: number; + private readonly cooldownMs: number; + private readonly store: SocialLinkAttemptStore; + private readonly metrics?: MetricsCollector; + private readonly logger: Logger; + private readonly auditRepository?: SecurityAuditRepository; + private readonly amlSink?: SocialLinkAnomalyAmlSink; + private readonly now: () => Date; + + /** Last alert timestamp per `provider:subject` key (ms since epoch). */ + private readonly lastAlertedAt = new Map(); + + constructor(options: SocialLinkAnomalyDetectorOptions = {}) { + this.threshold = options.threshold ?? 5; + this.windowMs = options.windowMs ?? 24 * 60 * 60 * 1000; + this.cooldownMs = options.cooldownMs ?? 60 * 60 * 1000; + this.store = options.store ?? new InMemorySocialLinkAttemptStore(); + this.metrics = options.metrics ?? globalMetrics; + this.logger = options.logger ?? globalLogger; + this.auditRepository = options.auditRepository; + this.amlSink = options.amlSink; + this.now = options.now ?? (() => new Date()); + + if (!Number.isInteger(this.threshold) || this.threshold < 2) { + throw new Error('SocialLinkAnomalyDetector threshold must be an integer >= 2'); + } + if (!(this.windowMs > 0)) { + throw new Error('SocialLinkAnomalyDetector windowMs must be positive'); + } + if (!(this.cooldownMs >= 0)) { + throw new Error('SocialLinkAnomalyDetector cooldownMs must be non-negative'); + } + } + + /** + * Record a link attempt and evaluate whether the identity is being sprayed + * across too many candidate accounts. + * + * @param attempt The attempt to record. The `attemptedAt` defaults to now + * when omitted. + * @returns The anomaly detection when the threshold was crossed (and the + * cooldown has elapsed), otherwise `null`. + * + * @dev The caller is responsible for recording attempts with the VERIFIED + * provider subject (i.e. after token verification). + */ + async recordAttempt(attempt: SocialLinkAttempt): Promise { + const now = this.now(); + const normalized: SocialLinkAttempt = { + ...attempt, + attemptedAt: attempt.attemptedAt ?? now, + }; + + await this.store.recordAttempt(normalized); + + const since = new Date(now.getTime() - this.windowMs); + const candidates = await this.store.listCandidateUserIds( + normalized.provider, + normalized.providerSubject, + since, + ); + + if (candidates.length < this.threshold) { + return null; + } + + const key = this.key(normalized.provider, normalized.providerSubject); + const lastAlerted = this.lastAlertedAt.get(key) ?? 0; + if (now.getTime() - lastAlerted < this.cooldownMs) { + return null; + } + + this.lastAlertedAt.set(key, now.getTime()); + + const detection: SocialLinkAnomalyDetection = { + provider: normalized.provider, + providerSubject: normalized.providerSubject, + candidateUserIds: candidates, + candidateCount: candidates.length, + threshold: this.threshold, + windowMs: this.windowMs, + detectedAt: now, + }; + + await this.emitDetection(detection); + return detection; + } + + /** Number of distinct candidate accounts currently recorded for an identity. */ + async getCandidateCount( + provider: SocialAuthProvider, + providerSubject: string, + ): Promise { + const since = new Date(this.now().getTime() - this.windowMs); + return (await this.store.listCandidateUserIds(provider, providerSubject, since)).length; + } + + /** Reset state (attempts + cooldowns). Test-only helper. */ + async reset(): Promise { + this.lastAlertedAt.clear(); + await this.store.reset(); + } + + // ─── Private helpers ────────────────────────────────────────────────────── + + private key(provider: SocialAuthProvider, providerSubject: string): string { + return `${provider}:${providerSubject}`; + } + + private async emitDetection(detection: SocialLinkAnomalyDetection): Promise { + // 1. Metric — aggregate, no PII in labels. + try { + this.metrics?.incrementCounter( + METRIC_ANOMALY, + { provider: detection.provider }, + 1, + 'Number of social account-linking anomaly patterns detected', + ); + } catch (err) { + this.logger.warn('Failed to emit social link anomaly metric', { + error: err instanceof Error ? err.message : String(err), + }); + } + + // 2. High-severity alarm log. + this.logger.error('ALARM: social account-linking anomaly detected', { + severity: 'high', + alarm: 'social_link_anomaly', + provider: detection.provider, + providerSubject: detection.providerSubject, + candidateCount: detection.candidateCount, + threshold: detection.threshold, + windowMs: detection.windowMs, + }); + + // 3. Security audit event. + if (this.auditRepository) { + try { + await this.auditRepository.record({ + id: `audit_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, + type: 'SECURITY_VIOLATION', + userId: detection.candidateUserIds[0], + action: AUDIT_ACTION_ANOMALY, + resource: `${AUDIT_RESOURCE_PREFIX}/${detection.provider}/${detection.providerSubject}`, + outcome: 'BLOCKED', + details: { + provider: detection.provider, + providerSubject: detection.providerSubject, + candidateUserIds: detection.candidateUserIds, + candidateCount: detection.candidateCount, + threshold: detection.threshold, + windowMs: detection.windowMs, + }, + securityContext: { + requestId: `social-link-anomaly-${Date.now()}`, + ipAddress: 'system', + userAgent: 'social-link-anomaly-detector', + timestamp: detection.detectedAt, + }, + timestamp: detection.detectedAt, + }); + } catch (err) { + this.logger.warn('Failed to record social link anomaly audit event', { + error: err instanceof Error ? err.message : String(err), + }); + } + } + + // 4. Feed AML. Failures must never break the link flow. + if (this.amlSink) { + try { + await this.amlSink.emit(detection); + } catch (err) { + this.logger.warn('AML sink failed to process social link anomaly', { + error: err instanceof Error ? err.message : String(err), + }); + } + } + } +} diff --git a/src/auth/social/socialLinkAttemptStore.ts b/src/auth/social/socialLinkAttemptStore.ts new file mode 100644 index 00000000..d1cf483d --- /dev/null +++ b/src/auth/social/socialLinkAttemptStore.ts @@ -0,0 +1,169 @@ +/** + * @file socialLinkAttemptStore.ts + * + * @notice Persistence layer for social account-linking attempts. + * + * @dev The anomaly detector keys on `(provider, provider_subject)` and counts + * the number of *distinct* candidate user accounts that a single social + * identity has been attempted against within a sliding window. A store + * must therefore be able to: + * + * 1. Record every link attempt (idempotent per candidate account, so a + * repeated attempt against the same account is counted once). + * 2. Return the distinct candidate user IDs observed within a window. + * + * Two implementations are provided: + * - `InMemorySocialLinkAttemptStore` — default for tests / single-instance + * development. Mirrors the convention used by the anti-enumeration + * middleware (in-process store; swap for a shared store in multi-instance + * deployments). + * - `PgSocialLinkAttemptStore` — PostgreSQL-backed store for production, + * backed by the `social_link_attempts` table (migration 025). + * + * Security assumptions: + * - Provider subjects are OAuth `sub` claims from verified ID tokens only; the + * store never logs the raw ID token. + * - The store contains no PII beyond user IDs already visible to the system. + */ + +import { Pool } from 'pg'; +import { SocialAuthProvider } from './types'; + +/** + * Outcome of a single social account-linking attempt. + * + * Every attempt is recorded regardless of outcome so the anomaly detector can + * spot a single social identity being sprayed across many candidate accounts. + */ +export type SocialLinkAttemptOutcome = + | 'link_success' // PoP passed and identity linked + | 'step_up_failed' // PoP (password re-entry) failed + | 'identity_conflict' // social sub already linked to another account + | 'email_conflict'; // provider email belongs to another account + +/** + * A single recorded social account-linking attempt. + */ +export interface SocialLinkAttempt { + /** Provider the social identity belongs to (`google` | `apple`). */ + provider: SocialAuthProvider; + /** Verified provider subject (`sub` claim) being linked. */ + providerSubject: string; + /** The Revora account the identity was attempted against. */ + userId: string; + /** How the attempt ended. */ + outcome: SocialLinkAttemptOutcome; + /** When the attempt occurred. */ + attemptedAt: Date; +} + +/** + * Contract any link-attempt store must satisfy. + */ +export interface SocialLinkAttemptStore { + /** + * Persist a link attempt. Repeated attempts for the same + * `(provider, provider_subject, user_id)` triple must not create duplicates + * so that each candidate account is counted exactly once. + */ + recordAttempt(attempt: SocialLinkAttempt): Promise; + + /** + * Return the distinct candidate user IDs for a social identity observed + * since `since` (inclusive). + */ + listCandidateUserIds( + provider: SocialAuthProvider, + providerSubject: string, + since: Date, + ): Promise; + + /** Clear all recorded attempts. Test-only helper. */ + reset(): Promise; +} + +/** + * In-process store. Suitable for single-instance deployments and unit tests. + * + * @dev For multi-instance deployments replace with a shared store (Redis or + * the `PgSocialLinkAttemptStore` below) so all instances observe the same + * attempt history. + */ +export class InMemorySocialLinkAttemptStore implements SocialLinkAttemptStore { + private attempts: SocialLinkAttempt[] = []; + private readonly seen = new Set(); + + async recordAttempt(attempt: SocialLinkAttempt): Promise { + const key = `${attempt.provider}:${attempt.providerSubject}:${attempt.userId}`; + if (this.seen.has(key)) { + return; + } + this.seen.add(key); + this.attempts.push(attempt); + } + + async listCandidateUserIds( + provider: SocialAuthProvider, + providerSubject: string, + since: Date, + ): Promise { + const ids = new Set(); + const sinceMs = since.getTime(); + for (const attempt of this.attempts) { + if ( + attempt.provider === provider && + attempt.providerSubject === providerSubject && + attempt.attemptedAt.getTime() >= sinceMs + ) { + ids.add(attempt.userId); + } + } + return [...ids]; + } + + async reset(): Promise { + this.attempts = []; + this.seen.clear(); + } +} + +/** + * PostgreSQL-backed store. + * + * @dev Backed by the `social_link_attempts` table (migration 025). The + * `(provider, provider_subject, user_id)` primary key guarantees each + * candidate account is counted exactly once even under concurrent + * link attempts. + */ +export class PgSocialLinkAttemptStore implements SocialLinkAttemptStore { + constructor(private readonly pool: Pick) {} + + async recordAttempt(attempt: SocialLinkAttempt): Promise { + await this.pool.query( + `INSERT INTO social_link_attempts + (provider, provider_subject, user_id, outcome, attempted_at) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (provider, provider_subject, user_id) + DO UPDATE SET outcome = EXCLUDED.outcome, attempted_at = EXCLUDED.attempted_at`, + [attempt.provider, attempt.providerSubject, attempt.userId, attempt.outcome, attempt.attemptedAt], + ); + } + + async listCandidateUserIds( + provider: SocialAuthProvider, + providerSubject: string, + since: Date, + ): Promise { + const result = await this.pool.query<{ user_id: string }>( + `SELECT DISTINCT user_id + FROM social_link_attempts + WHERE provider = $1 AND provider_subject = $2 AND attempted_at >= $3`, + [provider, providerSubject, since], + ); + return result.rows.map((row) => row.user_id); + } + + async reset(): Promise { + await this.pool.query('DELETE FROM social_link_attempts'); + } +} diff --git a/src/db/migrations/025_create_social_link_attempts.sql b/src/db/migrations/025_create_social_link_attempts.sql new file mode 100644 index 00000000..5667daa2 --- /dev/null +++ b/src/db/migrations/025_create_social_link_attempts.sql @@ -0,0 +1,35 @@ +-- Migration: Create social link attempts table +-- Description: Records social account-linking attempts for anomaly detection. +-- Each row is one (provider, provider_subject, user_id) candidate +-- account the social identity has been attempted against. The +-- primary key guarantees each candidate account is counted exactly +-- once, so "identity spraying" (one social sub → many accounts) +-- is detected by counting distinct rows in a sliding window. +-- +-- Security context: additive-only schema change. Contains only provider +-- subject claims and internal user IDs; no ID tokens or +-- passwords are stored. + +CREATE TABLE IF NOT EXISTS social_link_attempts ( + provider TEXT NOT NULL CHECK (provider IN ('google', 'apple')), + provider_subject TEXT NOT NULL, + user_id UUID NOT NULL, + outcome TEXT NOT NULL CHECK (outcome IN ( + 'link_success', + 'step_up_failed', + 'identity_conflict', + 'email_conflict' + )), + attempted_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + PRIMARY KEY (provider, provider_subject, user_id) +); + +CREATE INDEX IF NOT EXISTS idx_social_link_attempts_window + ON social_link_attempts (provider, provider_subject, attempted_at DESC); + +COMMENT ON TABLE social_link_attempts IS + 'Social account-linking attempts used by SocialLinkAnomalyDetector to detect ' + 'a single social identity being sprayed across many candidate accounts.'; + +COMMENT ON COLUMN social_link_attempts.outcome IS + 'How the link attempt ended: link_success | step_up_failed | identity_conflict | email_conflict';