Skip to content
Merged
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
1 change: 1 addition & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,7 @@ const CONST = {
AUTH_METHOD: {
SAML: 'saml',
SHORT_LIVED_AUTH_TOKEN: 'shortLivedAuthToken',
INFINITE_SESSION: 'infiniteSession',
},

AVATAR_MAX_ATTACHMENT_SIZE: 6291456,
Expand Down
22 changes: 21 additions & 1 deletion src/libs/FraudProtection/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {Str} from 'expensify-common';
import Onyx from 'react-native-onyx';
import type {OnyxEntry} from 'react-native-onyx';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {Account, Session} from '@src/types/onyx';
import {init, sendEvent, setAttribute, setAuthenticationData} from './GroupIBSdkBridge';
Expand All @@ -10,11 +11,26 @@ let lastSentIdentity: string | undefined;
let cachedAccount: OnyxEntry<Account>;
let cachedSession: OnyxEntry<Session>;

// Tracks whether we've observed a signed-out state during this app lifetime. Stays false while the very first
// session callback shows an existing authToken (restored from disk on app boot), which lets us report
// `infiniteSession` instead of the stored `authMethod` until the user actively signs out.
let hasObservedSignedOut = false;

function getAuthenticationAttribute(): string {
if (!cachedSession?.authToken) {
return '';
}
if (!hasObservedSignedOut) {
return CONST.AUTH_METHOD.INFINITE_SESSION;
}
return cachedSession?.authMethod ?? '';
}

function sendAccountAttributes() {
setAttribute('email', cachedAccount?.primaryLogin ?? '', false, true);
setAttribute('mfa', cachedAccount?.requiresTwoFactorAuth ? '2fa_enabled' : '2fa_disabled', false, true);
setAttribute('is_validated', cachedAccount?.validated ? 'true' : 'false', false, true);
setAttribute('authentication', cachedSession?.authMethod ?? '', false, true);
setAttribute('authentication', getAuthenticationAttribute(), false, true);
}

function trySendToFraudProtection() {
Expand Down Expand Up @@ -55,6 +71,10 @@ Onyx.connectWithoutView({
cachedSession = session;
const isAuthenticated = !!(session?.authToken ?? null);

if (!isAuthenticated) {
hasObservedSignedOut = true;
}

if (wasAuthenticated && !isAuthenticated) {
sessionID = Str.guid();
lastSentIdentity = undefined;
Expand Down
42 changes: 34 additions & 8 deletions tests/unit/FraudProtectionTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import ONYXKEYS from '@src/ONYXKEYS';
import waitForBatchedUpdates from '../utils/waitForBatchedUpdates';

const mockSetAuthenticationData = jest.fn();
const mockSetAttribute = jest.fn();
const mockSetAttribute = jest.fn<void, unknown[]>();

jest.mock('@libs/FraudProtection/GroupIBSdkBridge', () => ({
init: jest.fn(),
Expand All @@ -12,15 +12,26 @@ jest.mock('@libs/FraudProtection/GroupIBSdkBridge', () => ({
setAuthenticationData: mockSetAuthenticationData,
}));

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

require('@libs/FraudProtection');
beforeAll(async () => {
Onyx.init({keys: ONYXKEYS});

beforeAll(() =>
Onyx.init({
keys: ONYXKEYS,
}),
);
// Pre-populate Onyx with a restored session BEFORE loading FraudProtection so the module's initial session
// callback observes an existing authToken without first observing a signed-out state — the "session restored
// from disk on app boot" scenario.
await Onyx.merge(ONYXKEYS.ACCOUNT, {primaryLogin: 'restored@expensify.com', requiresTwoFactorAuth: false, validated: true});
await Onyx.merge(ONYXKEYS.SESSION, {authToken: 'restoredToken', accountID: 99, authMethod: 'google'});
await waitForBatchedUpdates();

require('@libs/FraudProtection');
await waitForBatchedUpdates();

initialSweepSetAttributeCalls = mockSetAttribute.mock.calls.slice();
});

beforeEach(async () => {
await Onyx.clear();
Expand Down Expand Up @@ -171,4 +182,19 @@ describe('FraudProtection', () => {

expect(mockSetAttribute).toHaveBeenCalledWith('mfa', '2fa_disabled', false, true);
});

it('should forward session.authMethod as the authentication attribute on a fresh sign-in', async () => {
await Onyx.merge(ONYXKEYS.ACCOUNT, {primaryLogin: 'user@expensify.com', requiresTwoFactorAuth: false, validated: true});
await Onyx.merge(ONYXKEYS.SESSION, {authToken: 'token123', accountID: 12345, authMethod: 'shortLivedAuthToken'});
await waitForBatchedUpdates();

expect(mockSetAttribute).toHaveBeenCalledWith('authentication', 'shortLivedAuthToken', false, true);
});

it('should report authentication=infiniteSession when an authenticated session is restored on app boot', () => {
// Asserts against the snapshot captured in beforeAll. Even though the restored session stores
// authMethod='google', the freshly-loaded module overrides it with infiniteSession because no
// signed-out state was observed during this lifetime.
expect(initialSweepSetAttributeCalls).toContainEqual(['authentication', 'infiniteSession', false, true]);
});
});
Loading