Skip to content

Commit 0f9a56c

Browse files
authored
Merge pull request #91803 from Expensify/cristi_fp-send-infiniteSession-auth-method
[Fraud Protection] Send authentication=infiniteSession for restored sessions
2 parents 9010a36 + f5b0392 commit 0f9a56c

3 files changed

Lines changed: 56 additions & 9 deletions

File tree

src/CONST/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,7 @@ const CONST = {
375375
AUTH_METHOD: {
376376
SAML: 'saml',
377377
SHORT_LIVED_AUTH_TOKEN: 'shortLivedAuthToken',
378+
INFINITE_SESSION: 'infiniteSession',
378379
},
379380

380381
AVATAR_MAX_ATTACHMENT_SIZE: 6291456,

src/libs/FraudProtection/index.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {Str} from 'expensify-common';
22
import Onyx from 'react-native-onyx';
33
import type {OnyxEntry} from 'react-native-onyx';
4+
import CONST from '@src/CONST';
45
import ONYXKEYS from '@src/ONYXKEYS';
56
import type {Account, Session} from '@src/types/onyx';
67
import {init, sendEvent, setAttribute, setAuthenticationData} from './GroupIBSdkBridge';
@@ -10,11 +11,26 @@ let lastSentIdentity: string | undefined;
1011
let cachedAccount: OnyxEntry<Account>;
1112
let cachedSession: OnyxEntry<Session>;
1213

14+
// Tracks whether we've observed a signed-out state during this app lifetime. Stays false while the very first
15+
// session callback shows an existing authToken (restored from disk on app boot), which lets us report
16+
// `infiniteSession` instead of the stored `authMethod` until the user actively signs out.
17+
let hasObservedSignedOut = false;
18+
19+
function getAuthenticationAttribute(): string {
20+
if (!cachedSession?.authToken) {
21+
return '';
22+
}
23+
if (!hasObservedSignedOut) {
24+
return CONST.AUTH_METHOD.INFINITE_SESSION;
25+
}
26+
return cachedSession?.authMethod ?? '';
27+
}
28+
1329
function sendAccountAttributes() {
1430
setAttribute('email', cachedAccount?.primaryLogin ?? '', false, true);
1531
setAttribute('mfa', cachedAccount?.requiresTwoFactorAuth ? '2fa_enabled' : '2fa_disabled', false, true);
1632
setAttribute('is_validated', cachedAccount?.validated ? 'true' : 'false', false, true);
17-
setAttribute('authentication', cachedSession?.authMethod ?? '', false, true);
33+
setAttribute('authentication', getAuthenticationAttribute(), false, true);
1834
}
1935

2036
function trySendToFraudProtection() {
@@ -55,6 +71,10 @@ Onyx.connectWithoutView({
5571
cachedSession = session;
5672
const isAuthenticated = !!(session?.authToken ?? null);
5773

74+
if (!isAuthenticated) {
75+
hasObservedSignedOut = true;
76+
}
77+
5878
if (wasAuthenticated && !isAuthenticated) {
5979
sessionID = Str.guid();
6080
lastSentIdentity = undefined;

tests/unit/FraudProtectionTest.ts

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import ONYXKEYS from '@src/ONYXKEYS';
33
import waitForBatchedUpdates from '../utils/waitForBatchedUpdates';
44

55
const mockSetAuthenticationData = jest.fn();
6-
const mockSetAttribute = jest.fn();
6+
const mockSetAttribute = jest.fn<void, unknown[]>();
77

88
jest.mock('@libs/FraudProtection/GroupIBSdkBridge', () => ({
99
init: jest.fn(),
@@ -12,15 +12,26 @@ jest.mock('@libs/FraudProtection/GroupIBSdkBridge', () => ({
1212
setAuthenticationData: mockSetAuthenticationData,
1313
}));
1414

15-
// Load the module once. Onyx connections are registered at module scope.
15+
// Captured snapshot of the setAttribute calls made by FraudProtection's initial sweep when it loads with an
16+
// already-authenticated session in Onyx. Captured in beforeAll so the restored-session assertions can read it
17+
// after beforeEach clears the live mock state for the rest of the tests.
18+
let initialSweepSetAttributeCalls: unknown[][] = [];
1619

17-
require('@libs/FraudProtection');
20+
beforeAll(async () => {
21+
Onyx.init({keys: ONYXKEYS});
1822

19-
beforeAll(() =>
20-
Onyx.init({
21-
keys: ONYXKEYS,
22-
}),
23-
);
23+
// Pre-populate Onyx with a restored session BEFORE loading FraudProtection so the module's initial session
24+
// callback observes an existing authToken without first observing a signed-out state — the "session restored
25+
// from disk on app boot" scenario.
26+
await Onyx.merge(ONYXKEYS.ACCOUNT, {primaryLogin: 'restored@expensify.com', requiresTwoFactorAuth: false, validated: true});
27+
await Onyx.merge(ONYXKEYS.SESSION, {authToken: 'restoredToken', accountID: 99, authMethod: 'google'});
28+
await waitForBatchedUpdates();
29+
30+
require('@libs/FraudProtection');
31+
await waitForBatchedUpdates();
32+
33+
initialSweepSetAttributeCalls = mockSetAttribute.mock.calls.slice();
34+
});
2435

2536
beforeEach(async () => {
2637
await Onyx.clear();
@@ -171,4 +182,19 @@ describe('FraudProtection', () => {
171182

172183
expect(mockSetAttribute).toHaveBeenCalledWith('mfa', '2fa_disabled', false, true);
173184
});
185+
186+
it('should forward session.authMethod as the authentication attribute on a fresh sign-in', async () => {
187+
await Onyx.merge(ONYXKEYS.ACCOUNT, {primaryLogin: 'user@expensify.com', requiresTwoFactorAuth: false, validated: true});
188+
await Onyx.merge(ONYXKEYS.SESSION, {authToken: 'token123', accountID: 12345, authMethod: 'shortLivedAuthToken'});
189+
await waitForBatchedUpdates();
190+
191+
expect(mockSetAttribute).toHaveBeenCalledWith('authentication', 'shortLivedAuthToken', false, true);
192+
});
193+
194+
it('should report authentication=infiniteSession when an authenticated session is restored on app boot', () => {
195+
// Asserts against the snapshot captured in beforeAll. Even though the restored session stores
196+
// authMethod='google', the freshly-loaded module overrides it with infiniteSession because no
197+
// signed-out state was observed during this lifetime.
198+
expect(initialSweepSetAttributeCalls).toContainEqual(['authentication', 'infiniteSession', false, true]);
199+
});
174200
});

0 commit comments

Comments
 (0)