Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 120 additions & 0 deletions docs/social-linking-pop-challenge.md
Original file line number Diff line number Diff line change
@@ -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`.
11 changes: 11 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
196 changes: 196 additions & 0 deletions src/auth/social/socialAuthService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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);
});
});
Loading
Loading