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);
+ });
+});