diff --git a/src/libs/actions/Session/index.ts b/src/libs/actions/Session/index.ts index a34cc42a6db6..cdc29cdd8a5f 100644 --- a/src/libs/actions/Session/index.ts +++ b/src/libs/actions/Session/index.ts @@ -837,6 +837,20 @@ function signInWithShortLivedAuthToken(authToken: string, isSAML = false) { NetworkStore.setLastShortAuthToken(authToken); } +/** + * Marks (or clears) that a short-lived-token sign-in is in progress. + * + * Native SAML sign-in opens an in-app browser, which backgrounds the app. On resume, `reconnectApp()` fires + * with the now-expired authToken and gets a 407; if this guard isn't already set, `reauthenticate()` takes + * the `isSAMLRequired` branch and calls `redirectToSignIn()`, wiping the session before the SAML callback can + * sign the user back in. Setting it `true` before opening the browser makes `reauthenticate()` abort while the + * SAML sign-in is in progress. `signInWithShortLivedAuthToken` clears it on completion; the cancel/error paths + * clear it explicitly. + */ +function setIsAuthenticatingWithShortLivedToken(isAuthenticating: boolean) { + Onyx.set(ONYXKEYS.RAM_ONLY_IS_AUTHENTICATING_WITH_SHORT_LIVED_TOKEN, isAuthenticating); +} + /** * Sign the user into the application. This will first authenticate their account * then it will create a temporary login for them which is used when re-authenticating @@ -1686,6 +1700,7 @@ export { signInWithValidateCodeAndNavigate, initAutoAuthState, signInWithShortLivedAuthToken, + setIsAuthenticatingWithShortLivedToken, cleanupSession, signOut, signOutAndRedirectToSignIn, diff --git a/src/pages/signin/SAMLSignInPage/index.native.tsx b/src/pages/signin/SAMLSignInPage/index.native.tsx index b069f49ae5b6..7bf2b7f842cf 100644 --- a/src/pages/signin/SAMLSignInPage/index.native.tsx +++ b/src/pages/signin/SAMLSignInPage/index.native.tsx @@ -11,7 +11,7 @@ import getPlatform from '@libs/getPlatform'; import Log from '@libs/Log'; import {handleSAMLLoginError, postSAMLLogin} from '@libs/LoginUtils'; import Navigation from '@libs/Navigation/Navigation'; -import {clearSignInData, setAccountError, signInWithShortLivedAuthToken} from '@userActions/Session'; +import {clearSignInData, setAccountError, setIsAuthenticatingWithShortLivedToken, signInWithShortLivedAuthToken} from '@userActions/Session'; import CONFIG from '@src/CONFIG'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -26,6 +26,8 @@ function SAMLSignInPage() { const hasOpenedAuthSession = useRef(false); const handleExitSAMLFlow = useCallback(() => { + // Clear the guard we set before opening the in-app browser so we don't block future reauthentication + setIsAuthenticatingWithShortLivedToken(false); Navigation.isNavigationReady().then(() => { Navigation.goBack(); clearSignInData(); @@ -67,6 +69,8 @@ function SAMLSignInPage() { return; } + // The browser returned but we couldn't sign in, so clear the guard we set before opening it + setIsAuthenticatingWithShortLivedToken(false); clearSignInData(); setAccountError(translate('common.error.login')); Navigation.isNavigationReady().then(() => { @@ -84,6 +88,12 @@ function SAMLSignInPage() { return; } hasOpenedAuthSession.current = true; + // Opening the in-app browser backgrounds the app. When it returns, the app resumes and fires + // reconnectApp() with the expired authToken, which 407s and would trigger reauthenticate() -> + // redirectToSignIn(), wiping the session before the SAML callback can sign the user back in. Setting + // this guard up front makes reauthenticate() abort while the SAML sign-in is in progress. It is cleared + // by signInWithShortLivedAuthToken() on success, and on the cancel/error paths below. + setIsAuthenticatingWithShortLivedToken(true); openAuthSessionAsync(SAMLUrl, CONST.SAML_REDIRECT_URL) .then((response: WebBrowserAuthSessionResult) => { if (response.type !== 'success') { diff --git a/tests/unit/SAMLSignInPageTest.tsx b/tests/unit/SAMLSignInPageTest.tsx new file mode 100644 index 000000000000..b56b9dff5c71 --- /dev/null +++ b/tests/unit/SAMLSignInPageTest.tsx @@ -0,0 +1,100 @@ +import {act, render} from '@testing-library/react-native'; +import {openAuthSessionAsync} from 'expo-web-browser'; +import React from 'react'; +import Onyx from 'react-native-onyx'; +import type {OnyxEntry} from 'react-native-onyx'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import * as Session from '@libs/actions/Session'; +import SAMLSignInPage from '@pages/signin/SAMLSignInPage'; +import ONYXKEYS from '@src/ONYXKEYS'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +jest.mock('expo-web-browser', () => ({ + openAuthSessionAsync: jest.fn(() => Promise.resolve({type: 'cancel'})), +})); + +jest.mock('@libs/LoginUtils', () => ({ + postSAMLLogin: jest.fn(() => Promise.resolve({url: 'https://idp.example.com/saml'})), + handleSAMLLoginError: jest.fn(), +})); + +jest.mock('@libs/Navigation/Navigation', () => ({ + isNavigationReady: jest.fn(() => Promise.resolve()), + goBack: jest.fn(), + navigate: jest.fn(), +})); + +jest.mock('@hooks/useLocalize', () => + jest.fn(() => ({ + translate: (key: string) => key, + })), +); + +// Stub the presentational wrappers — SAMLSignInPage's effects (which is what we're testing) run on mount +// regardless of what these render, and stubbing them avoids pulling in the navigation-heavy render tree. +jest.mock('@components/ScreenWrapper', () => ({__esModule: true, default: () => null})); +jest.mock('@components/BlockingViews/FullPageOfflineBlockingView', () => ({__esModule: true, default: () => null})); +jest.mock('@components/HeaderWithBackButton', () => ({__esModule: true, default: () => null})); +jest.mock('@components/SAMLLoadingIndicator', () => ({__esModule: true, default: () => null})); + +const mockedOpenAuthSessionAsync = jest.mocked(openAuthSessionAsync); + +async function setCredentials() { + await act(async () => { + await Onyx.set(ONYXKEYS.CREDENTIALS, {login: 'test@example.com'}); + await Onyx.set(ONYXKEYS.ACCOUNT, {isLoading: false}); + await Onyx.set(ONYXKEYS.RAM_ONLY_IS_AUTHENTICATING_WITH_SHORT_LIVED_TOKEN, false); + }); + await waitForBatchedUpdatesWithAct(); +} + +describe('SAMLSignInPage (native)', () => { + beforeEach(async () => { + jest.clearAllMocks(); + await act(async () => { + await Onyx.clear(); + }); + }); + + test('sets the short-lived-token guard before opening the SAML browser (prevents the resume-after-idle teardown)', async () => { + const setGuardSpy = jest.spyOn(Session, 'setIsAuthenticatingWithShortLivedToken'); + await setCredentials(); + + render( + + + , + ); + await waitForBatchedUpdatesWithAct(); + + // The in-app browser was opened, and the guard was set to true BEFORE that call, so a reconnectApp() + // 407 firing while the app is backgrounded aborts reauthenticate() instead of tearing down the session. + expect(mockedOpenAuthSessionAsync).toHaveBeenCalledTimes(1); + expect(setGuardSpy).toHaveBeenCalledWith(true); + const setTrueOrder = setGuardSpy.mock.calls.findIndex((call) => call.at(0) === true); + expect(setGuardSpy.mock.invocationCallOrder.at(setTrueOrder)).toBeLessThan(mockedOpenAuthSessionAsync.mock.invocationCallOrder.at(0) ?? Infinity); + }); + + test('clears the guard when the SAML browser is cancelled so future reauthentication is not blocked', async () => { + let guard: OnyxEntry; + Onyx.connect({ + key: ONYXKEYS.RAM_ONLY_IS_AUTHENTICATING_WITH_SHORT_LIVED_TOKEN, + callback: (value) => { + guard = value; + }, + }); + // openAuthSessionAsync is mocked to resolve as a cancellation by default (see top of file) + await setCredentials(); + + render( + + + , + ); + await waitForBatchedUpdatesWithAct(); + + expect(mockedOpenAuthSessionAsync).toHaveBeenCalledTimes(1); + // After the user cancels, the guard must be cleared (otherwise reauthenticate stays blocked forever) + expect(guard).toBe(false); + }); +});