Skip to content

[$250] Severe Auth Issues on Mobile: Login Failures, Frequent Logouts, and Inability to Log Out #89613

Description

@m-natarajan

If you haven’t already, check out our contributing guidelines for onboarding and email contributors@expensify.com to request to join our Slack channel!


Version Number:
Reproducible in staging?: Needs Reproduction (Unable to reproduce)
Reproducible in production?: Needs Reproduction
If this was caught during regression testing, add the test name, ID and link from BrowserStack:
Email or phone of affected tester (no customers):
Logs: https://stackoverflow.com/c/expensify/questions/4856
Expensify/Expensify Issue URL:
Issue reported by: @GeoVasile
Slack conversation (hyperlinked to channel name): #Expensify Bugs

Action Performed:

Android (Login Failure)

  1. Open the app
  2. Attempt login via IDP (JumpCloud)
  3. Complete authentication
  4. Observe post-login behavior

iOS (Logout Failure)

  1. Log into the app
  2. Attempt to log out
  3. Observe behavior

Expected Result:

Users should:
Successfully log in after IDP authentication
Remain logged in without unexpected session drops
Be able to log out cleanly

Actual Result:

Android:
Stuck on loading screen after IDP authentication
Unable to complete login
iOS:
Login works
On logout → blank white screen
General:
Frequent/unexpected logouts reported

Workaround:

Unknown

Platforms:

Select the officially supported platforms where the issue was reproduced:

  • Android: App
  • Android: mWeb Chrome
  • iOS: App
  • iOS: mWeb Safari
  • iOS: mWeb Chrome
  • Windows: Chrome
  • MacOS: Chrome / Safari

Issue observed in the following devices

Android (Pixel 7a)
OS: Android 16 (CP1A.260405.005)
App: v9.3.60-22
Android (Pixel 10 Pro XL)
OS: Android 16
App: v9.3.63-0
iOS (iPhone 17 Pro)
OS: 26.4.2
App: v9.3.64

Screenshots/Videos

The videos for the issue are in Slack thread

View all open jobs on GitHub

Upwork Automation - Do Not Edit
  • Upwork Job URL: https://www.upwork.com/jobs/~022051687650242665320
  • Upwork Job ID: 2051687650242665320
  • Last Price Increase: 2026-05-05

Activity

  1. added
    BugSomething is broken. Auto assigns a BugZero manager.
    on May 5, 2026
  2. MelvinBot commented on May 5, 2026

    @MelvinBot
    Contributor

    Proposal

    What is the root cause of that problem?

    This issue reports three distinct symptoms for SAML/IDP users on mobile. Each has a different root cause:

    1. Android stuck on loading screen after IDP auth — The native SAML sign-in flow in SAMLSignInPage/index.native.tsx uses openAuthSessionAsync with the redirect URL expensify://open. The handleNavigationStateChange callback closes over credentials?.login and account?.isLoading. While the user is in the external browser completing IDP authentication, Onyx state may update, but the .then() handler captured by the useEffect still references the stale closure. If credentials?.login is not yet populated or account?.isLoading is still true when the callback fires, the condition at line 64 fails, causing clearSignInData() to be called and the user to be dumped back with a generic error — or worse, on Android 16, the custom URL scheme redirect (expensify://open) may not resolve the openAuthSessionAsync promise at all, leaving the user permanently stuck on the loading indicator.

    2. iOS blank white screen on logout — When a SAML user logs out, callSAMLSignOut opens an external browser for IDP logout. If the user dismisses the browser or the redirect fails (result.type !== 'success'), the function rejects with 'Logout cancelled'. In signOutAndRedirectToSignIn, HybridAppModule.startSignOut() was already called at line 345, but the .catch() at line 479 only logs a warning — it never calls redirectToSignIn(). The app is left in a limbo state: OldDot sign-out started but NewDot never redirects, producing a blank white screen.

    3. Frequent/unexpected logouts — For SAML-required accounts, Reauthentication.ts:133-137 immediately redirects to sign-in on any token expiry instead of reauthenticating. With SESSION_EXPIRATION_TIME_MS set to 2 hours, SAML users get forcibly logged out every 2 hours. Additionally, lines 140-147 have a deliberately commented-out credential guard (referencing #fireroom-2026-01-28-user-signout), allowing reauthentication to proceed with empty credentials, which fails and eventually triggers a logout after retries.

    What changes do you think we should make in order to solve the problem?

    1. Fix the stale closure in native SAML sign-in — Refactor SAMLSignInPage/index.native.tsx so that handleNavigationStateChange reads credentials and account from Onyx directly (via Onyx.get() or a ref) at invocation time rather than closing over React state. This ensures the callback always sees the latest values when the browser returns.

    2. Handle cancelled SAML logout gracefully — In Session/index.ts, when callSAMLSignOut rejects with 'Logout cancelled', the .catch() in signOutAndRedirectToSignIn should call redirectToSignIn() (and HybridAppModule.cancelSignOut() for HybridApp) instead of just logging. This ensures the UI always navigates to a valid state.

    3. Frequent logouts — This is by-design for SAML (can't silently reauthenticate), but the commented-out guard in Reauthentication.ts should be resolved — either re-enable it or remove the dead code, as leaving it commented out allows failed reauthentication attempts that degrade UX before the inevitable logout.

    What alternative solutions did you explore? (Optional)

    For the stale closure issue, an alternative would be to delay opening the auth session until Onyx data is confirmed populated, but this wouldn't fully solve the problem since state can change while the browser is open. Reading current state at callback time is more robust.

    For the SAML logout issue, an alternative is to skip the external browser logout entirely on mobile and just invalidate the session server-side. However, this may leave the IDP session active, which could be a security concern for enterprise customers.

    Supporting evidence

    Next Steps for Contributor+ team: Reply with @MelvinBot implement this to create a draft PR, @MelvinBot <your feedback> to refine this analysis, or explain why you are rejecting Melvin's proposal.

  3. added
    ExternalAdded to denote the issue can be worked on by a contributor
    on May 5, 2026
  4. added
    Help WantedApply this label when an issue is open to proposals by contributors
    on May 5, 2026
  5. melvin-bot commented on May 5, 2026

    @melvin-bot

    Triggered auto assignment to Contributor-plus team member for initial proposal review - @aimane-chnaif (External)

  6. changed the title [-]Severe Auth Issues on Mobile: Login Failures, Frequent Logouts, and Inability to Log Out[/-] [+][$250] Severe Auth Issues on Mobile: Login Failures, Frequent Logouts, and Inability to Log Out[/+] on May 5, 2026
  7. melvin-bot commented on May 5, 2026

    @melvin-bot
  8. nabi-ebrahimi commented on May 5, 2026

    @nabi-ebrahimi
    Contributor

    Proposal

    What is the root cause of that problem?

    There are three App-side root causes.

    First, native SAML sign-in opens the browser from an effect and registers the current handleNavigationStateChange callback with openAuthSessionAsync here:

    useEffect(() => {
    // Don't open auth session more than once. If user cancels it we should navigate back to ROUTES.HOME
    if (!SAMLUrl || hasOpenedAuthSession.current) {
    return;
    }
    hasOpenedAuthSession.current = true;
    openAuthSessionAsync(SAMLUrl, CONST.SAML_REDIRECT_URL)
    .then((response: WebBrowserAuthSessionResult) => {
    if (response.type !== 'success') {
    handleExitSAMLFlow();
    return;
    }
    handleNavigationStateChange(response.url);
    })
    .catch((error) => {
    Log.hmmm('SAML sign in failed', {error});
    handleExitSAMLFlow();
    });
    }, [SAMLUrl, handleNavigationStateChange, handleExitSAMLFlow]);
    . That callback closes over account?.isLoading and credentials?.login from the render that opened the browser, then later uses those stale values to decide whether the returned shortLivedAuthToken is valid here:
    const handleNavigationStateChange = useCallback(
    (url: string) => {
    // If we've gotten a callback then remove the option to navigate back to the sign-in page
    if (url.includes('loginCallback')) {
    shouldShowNavigation(false);
    }
    const searchParams = new URLSearchParams(new URL(url).search);
    const jsonParam = searchParams.get('json');
    if (!jsonParam) {
    Log.hmmm('SAMLSignInPage - No JSON parameter found in callback URL');
    return;
    }
    let shortLivedAuthToken: string | null = null;
    try {
    const decodedData = JSON.parse(jsonParam) as Record<string, string | null>;
    shortLivedAuthToken = decodedData.shortLivedAuthToken ?? null;
    if (decodedData.error) {
    Log.hmmm('SAMLSignInPage - SAML login returned error', {error: decodedData.error});
    }
    } catch (parseError) {
    Log.hmmm('SAMLSignInPage - Failed to parse JSON parameter', {error: parseError});
    }
    if (!account?.isLoading && credentials?.login && shortLivedAuthToken) {
    Log.info('SAMLSignInPage - Successfully received shortLivedAuthToken. Signing in...');
    signInWithShortLivedAuthToken(shortLivedAuthToken, true);
    return;
    }
    clearSignInData();
    setAccountError(translate('common.error.login'));
    Navigation.isNavigationReady().then(() => {
    // We must call goBack() to remove the /transition route from history
    Navigation.goBack();
    Navigation.navigate(ROUTES.HOME);
    });
    },
    [credentials?.login, account?.isLoading, translate],
    );
    . If Onyx finishes loading while the user is in the IdP browser, the callback can still see the old loading state and reject a valid SAML callback.

    Second, SAML logout rejects when the external browser result is not success, before the app calls LogOut:

    function callSAMLSignOut(params: LogOutParams, authToken: string): Promise<void | Response<never>> {
    const isWeb = getPlatform() === CONST.PLATFORM.WEB;
    const queryString = isWeb ? `referer=ecash&authToken=${authToken}` : `appversion=${pkg.version}&referer=ecash&authToken=${authToken}`;
    const expectedURL = isWeb ? CONFIG.EXPENSIFY.NEW_EXPENSIFY_URL : CONST.SAML_REDIRECT_URL;
    return openAuthSessionAsync(`${CONFIG.EXPENSIFY.SAML_URL}/logout?${queryString}`, expectedURL)
    .catch((error) => {
    Log.hmmm('SAML sign out failed', {error});
    })
    .then((result) => {
    if (result && result.type !== 'success') {
    if (CONFIG.IS_HYBRID_APP) {
    HybridAppModule.cancelSignOut();
    }
    return Promise.reject(Error('Logout cancelled'));
    }
    // We always want to sign out the user from the app
    // eslint-disable-next-line rulesdir/no-api-side-effects-method
    return API.makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.LOG_OUT, params, {});
    });
    . That rejection skips the normal signOutAndRedirectToSignIn success path, including HybridApp OldDot sign-out and the final redirect, because the outer catch only logs the error:
    // Wait for signOut (if called), then redirect and update Onyx.
    return signOutPromise
    .then((response) => {
    // When signing out from the HybridApp, we need to sign out from the oldDot app as well
    if (CONFIG.IS_HYBRID_APP && shouldSignOutFromOldDot) {
    HybridAppModule.signOutFromOldDot();
    }
    if (isSupportal) {
    // Send event to Fraud Protection backend, otherwise it might consider the user as being suspicious
    FraudProtection.sendEvent(FRAUD_PROTECTION_EVENT.STOP_SUPPORT_SESSION);
    }
    if (response?.hasOldDotAuthCookies) {
    Log.info('Redirecting to OldDot sign out');
    asyncOpenURL(
    redirectToSignIn().then(() => {
    Onyx.multiSet(onyxSetParams);
    }),
    `${CONFIG.EXPENSIFY.EXPENSIFY_URL}${CONST.OLDDOT_URLS.SIGN_OUT}`,
    true,
    true,
    );
    } else if (isPerformingSupportalLogout && hasStashedSession(stashedSession, stashedCredentials)) {
    // We have confirmed here that the supportal agent was logged in, so we can restore the stashed session
    // and then redirect to the oldDot supportal page to restore the stashed session
    // Clear the Onyx DB of stale data that might be present from a previous session
    // of the customer account
    Onyx.clear(KEYS_TO_PRESERVE_SUPPORTAL).then(() => {
    Onyx.multiSet(onyxSetParams).then(() => {
    buildOldDotURL(CONST.OLDDOT_URLS.SUPPORTAL_RESTORE_STASHED_LOGIN).then((oldDotURL) => {
    // Open the oldDot URL to restore the stashed session and go back to OD supportal page
    openExternalLink(oldDotURL, undefined, true);
    });
    });
    });
    } else if (isPerformingSupportalLogout && !hasStashedSession(stashedSession, stashedCredentials)) {
    // If the supportal agent was not logged in, we call `redirectToSignIn` to clear the Onyx DB
    // and then redirect to supportal and restore the stashed session
    redirectToSignIn().then(() => {
    Onyx.multiSet(onyxSetParams).then(() => {
    buildOldDotURL(CONST.OLDDOT_URLS.SUPPORTAL_RESTORE_STASHED_LOGIN).then((oldDotURL) => {
    // Open the oldDot URL to restore the stashed session and go back to OD supportal page
    openExternalLink(oldDotURL, undefined, true);
    });
    });
    });
    } else if (shouldRestoreStashedSession && !shouldStashSession && hasStashedSession(stashedSession, stashedCredentials)) {
    // Preserve SESSION during clear to avoid a login page flash, then restore the stashed session.
    Onyx.clear(KEYS_TO_PRESERVE_SUPPORTAL).then(() => {
    Onyx.multiSet(onyxSetParams).then(() => {
    Onyx.set(ONYXKEYS.STASHED_CREDENTIALS, {});
    Onyx.set(ONYXKEYS.STASHED_SESSION, {});
    confirmReadyToOpenApp();
    openApp();
    if (CONFIG.IS_HYBRID_APP && hasSwitchedAccountInHybridMode) {
    HybridAppModule.switchAccount({
    newDotCurrentAccountEmail: stashedSession.email ?? '',
    authToken: stashedSession.authToken ?? '',
    policyID: '',
    accountID: '',
    });
    }
    });
    });
    } else {
    redirectToSignIn().then(() => {
    Onyx.multiSet(onyxSetParams);
    if (hasSwitchedAccountInHybridMode) {
    openApp();
    }
    });
    }
    })
    .catch((error: string) => Log.warn('Error during sign out process:', error));
    .

    Third, reauthentication immediately redirects every SAML-required account back to sign-in instead of using the stored autogenerated credentials that normal sign-in creates for token refresh:

    if (account?.isSAMLRequired) {
    Log.info(`[Reauthenticate] Redirecting to Sign In because SAML is required`);
    setIsAuthenticating(false);
    redirectToSignIn(undefined, true);
    return false;
    }
    // Prevent reauthentication if credentials are missing (e.g. after sign out)
    if (!credentials?.autoGeneratedLogin || !credentials?.autoGeneratedPassword) {
    Log.info('[Reauthenticate] No credentials available, redirecting to sign in');
    // The following lines are commented out to test if it's the cause of #fireroom-2026-01-28-user-signout
    // setIsAuthenticating(false);
    // redirectToSignIn('No credentials available');
    // return false;
    }
    . Since the session expiration window is two hours, SAML users are forced back through SAML whenever reauth is needed:

    App/src/CONST/index.ts

    Lines 2257 to 2258 in 3031061

    // The number of milliseconds for an idle session to expire
    SESSION_EXPIRATION_TIME_MS: 2 * 3600 * 1000, // 2 hours
    .

    What changes do you think we should make in order to solve the problem?

    Fix the auth flows at the points where they incorrectly abandon valid state.

    • In SAMLSignInPage/index.native.tsx, keep refs for the latest account and credentials, and read those refs when the browser callback returns.
    • In callSAMLSignOut, do not reject on a non-success browser result. Log it and continue to the LOG_OUT request so the existing sign-out chain can redirect and sign out OldDot when needed.
    • In Reauthentication.ts, remove the unconditional account?.isSAMLRequired redirect. Reauthenticate SAML users with saved autogenerated credentials like other users.
    • Restore the missing-credentials guard. If credentials are missing for a SAML account, redirect with isSAMLReauthentication=true; otherwise redirect with the existing missing-credentials error.

    Tests to add:

    • A unit test that SAML-required users with saved credentials call Authenticate and refresh the auth token after a 407.
    • A unit test that cancelled SAML browser logout still calls LOG_OUT.
    • A component test for native SAML sign-in where account.isLoading changes after the browser is opened and before the callback returns.

    The MelvinBot proposal identifies the stale native SAML sign-in callback, but it is incomplete for logout and frequent logout handling. Handling logout only in the outer catch bypasses the normal successful sign-out chain, including the existing HybridApp OldDot sign-out path. It also treats frequent SAML logout as by-design, but the code already has autogenerated credentials for reauthentication and only blocks SAML users because of the unconditional isSAMLRequired branch.

    What alternative solutions did you explore? (Optional)

  9. trasnake87 commented on May 5, 2026

    @trasnake87
    Contributor

    Proposal

    The reported behaviors stem from three independent defects in the mobile SAML/SSO authentication flow, plus a fourth contributing factor for the "frequent logouts" symptom. Each is patched separately.

    Root cause

    1 — signOutAndRedirectToSignIn swallows a rejected SAML logout and never recovers (iOS blank screen):

    callSAMLSignOut in src/libs/actions/Session/index.ts:255-274 opens an external ASWebAuthenticationSession (iOS) / Custom Tab (Android) and rejects with Error('Logout cancelled') when the user dismisses the browser, when the IDP fails to round-trip the expensify://open redirect, or when the OS reports result.type !== 'success' (this includes the 'dismiss' and 'cancel' cases, both common on iOS 17+ when ASWebAuthenticationSession returns WBADismissedReason).

    // src/libs/actions/Session/index.ts:255-274
    function callSAMLSignOut(params: LogOutParams, authToken: string): Promise<void | Response<never>> {
        const isWeb = getPlatform() === CONST.PLATFORM.WEB;
        const queryString = isWeb ? `referer=ecash&authToken=${authToken}` : `appversion=${pkg.version}&referer=ecash&authToken=${authToken}`;
        const expectedURL = isWeb ? CONFIG.EXPENSIFY.NEW_EXPENSIFY_URL : CONST.SAML_REDIRECT_URL;
        return openAuthSessionAsync(`${CONFIG.EXPENSIFY.SAML_URL}/logout?${queryString}`, expectedURL)
            .catch((error) => { Log.hmmm('SAML sign out failed', {error}); })
            .then((result) => {
                if (result && result.type !== 'success') {
                    if (CONFIG.IS_HYBRID_APP) {
                        HybridAppModule.cancelSignOut();
                    }
                    return Promise.reject(Error('Logout cancelled'));
                }
                // eslint-disable-next-line rulesdir/no-api-side-effects-method
                return API.makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.LOG_OUT, params, {});
            });
    }

    By that point, signOutAndRedirectToSignIn has already executed HybridAppModule.startSignOut() at line 345 (HybridApp build) and the modal close at hideContextMenu(false) (line 329). When the rejection bubbles up, the .catch at line 479 just logs:

    // src/libs/actions/Session/index.ts:478-479
    })
    .catch((error: string) => Log.warn('Error during sign out process:', error));

    redirectToSignIn() is never invoked, Onyx.clear() is never called, and the navigation reset to ROUTES.HOME never happens. The active screen (Settings / a logout confirm modal already dismissed) has nothing left to render against, producing the white screen the reporter sees on iPhone 17 Pro / iOS 26.4.2. The same path is also hit when the IDP redirects back successfully but the OS classifies the redirect as a dismiss because the expensify:// scheme is not declared in ios/NewExpensify/Info.plist (only new-expensify is in CFBundleURLSchemes) — ASWebAuthenticationSession can still match the redirect via its callbackURLScheme argument, but only when the surrounding promise chain is allowed to recover from non-success results.

    2 — Stale closure in SAMLSignInPage/index.native.tsx strands Android on the loading screen:

    // src/pages/signin/SAMLSignInPage/index.native.tsx:38-99
    const handleNavigationStateChange = useCallback(
        (url: string) => {
            // ...
            if (!account?.isLoading && credentials?.login && shortLivedAuthToken) {
                Log.info('SAMLSignInPage - Successfully received shortLivedAuthToken. Signing in...');
                signInWithShortLivedAuthToken(shortLivedAuthToken, true);
                return;
            }
            clearSignInData();
            setAccountError(translate('common.error.login'));
            // ...
        },
        [credentials?.login, account?.isLoading, translate],
    );
    
    useEffect(() => {
        if (!SAMLUrl || hasOpenedAuthSession.current) { return; }
        hasOpenedAuthSession.current = true;
        openAuthSessionAsync(SAMLUrl, CONST.SAML_REDIRECT_URL)
            .then((response: WebBrowserAuthSessionResult) => {
                if (response.type !== 'success') { handleExitSAMLFlow(); return; }
                handleNavigationStateChange(response.url);
            })
            .catch((error) => { Log.hmmm('SAML sign in failed', {error}); handleExitSAMLFlow(); });
    }, [SAMLUrl, handleNavigationStateChange, handleExitSAMLFlow]);

    While the user is in the external browser on Android, Custom Tabs detaches the React Native activity. When the activity is restored, Onyx reconnects and account.isLoading / credentials.login may briefly toggle (notably, clearSignInData() is callable from background flows and sets credentials to null). The .then((response) => handleNavigationStateChange(response.url)) callback was captured against the first render's handleNavigationStateChange. The useEffect deps include handleNavigationStateChange, but the early return on hasOpenedAuthSession.current blocks re-binding. Result: the callback compares stale state, the if at line 64 evaluates falsy (typically because credentials?.login is now null after a transient clear), control flow falls through to clearSignInData() + Navigation.goBack() — and on Android 16 with the new predictive-back behavior, the navigation pop fires on a stack that no longer contains /transition, leaving the SAMLLoadingIndicator mounted underneath with no way to escape.

    3 — setAccountError throws away the error when credentials was just cleared:

    The fall-through path at lines 70-76 calls setAccountError(translate('common.error.login')) but immediately after clearSignInData() (line 70) which Onyx.multiSet({ACCOUNT: null, CREDENTIALS: null}). Because Onyx.multiSet and setAccountError's subsequent Onyx.merge(ACCOUNT, ...) are not awaited in order, on devices with slow disk I/O (Pixel 7a / Pixel 10 Pro XL on Android 16) the multiSet(null) can land after the merged error, wiping the toast that would normally tell the user "login failed". The user sees a silent return to a loading screen with no error.

    4 — Reauthentication keeps the failure silent for SAML accounts (frequent unexpected logouts):

    // src/libs/Reauthentication.ts:133-147
    if (account?.isSAMLRequired) {
        Log.info(`[Reauthenticate] Redirecting to Sign In because SAML is required`);
        setIsAuthenticating(false);
        redirectToSignIn(undefined, true);
        return false;
    }
    
    // Prevent reauthentication if credentials are missing (e.g. after sign out)
    if (!credentials?.autoGeneratedLogin || !credentials?.autoGeneratedPassword) {
        Log.info('[Reauthenticate] No credentials available, redirecting to sign in');
        // The following lines are commented out to test if it's the cause of #fireroom-2026-01-28-user-signout
        // setIsAuthenticating(false);
        // redirectToSignIn('No credentials available');
        // return false;
    }

    For SAML-required accounts, every authToken expiry (every 2 hours per CONST.SESSION_EXPIRATION_TIME_MS = 2 * 3600 * 1000) drops the user straight back to sign-in with no error message — redirectToSignIn(undefined, true) passes undefined for errorMessage, so the user has no indication their session expired vs. was logged out by the server. Combined with the dead/commented-out guard at lines 140-147, a stale credentials-less account proceeds into Authenticate(...) with partnerUserID: undefined, which fails server-side and triggers the same silent redirect.

    Proposed fix

    1 — Always finalize the local sign-out, even when the IDP browser was dismissed. Make callSAMLSignOut resolve with null instead of rejecting on cancellation, and have signOutAndRedirectToSignIn proceed with the local Onyx clear regardless of whether the server LOG_OUT was reachable.

    // src/libs/actions/Session/index.ts — replace lines 255-274
    function callSAMLSignOut(params: LogOutParams, authToken: string): Promise<void | Response<never>> {
        const isWeb = getPlatform() === CONST.PLATFORM.WEB;
        const queryString = isWeb ? `referer=ecash&authToken=${authToken}` : `appversion=${pkg.version}&referer=ecash&authToken=${authToken}`;
        const expectedURL = isWeb ? CONFIG.EXPENSIFY.NEW_EXPENSIFY_URL : CONST.SAML_REDIRECT_URL;
        return openAuthSessionAsync(`${CONFIG.EXPENSIFY.SAML_URL}/logout?${queryString}`, expectedURL)
            .catch((error) => {
                Log.hmmm('SAML sign out failed', {error});
                return undefined;
            })
            .then((result) => {
                if (result && result.type !== 'success') {
                    Log.info('SAML logout browser was dismissed; clearing local session anyway.');
                    if (CONFIG.IS_HYBRID_APP) {
                        HybridAppModule.cancelSignOut();
                    }
                    // Do NOT call the LOG_OUT API (we can't prove the IDP session ended)
                    // but DO let the caller proceed to clear local Onyx.
                    return undefined;
                }
                // eslint-disable-next-line rulesdir/no-api-side-effects-method
                return API.makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.LOG_OUT, params, {});
            });
    }

    And tighten the orchestrator's .catch so a thrown error does not leave the UI in limbo:

    // src/libs/actions/Session/index.ts — replace line 479
    .catch((error: string) => {
        Log.warn('Error during sign out process:', error);
        // Failsafe: ensure the user lands on the sign-in screen even if signOut threw.
        return redirectToSignIn();
    });

    2 — Read Onyx values at callback time in the native SAML page so the captured closure can't go stale. Use a ref synced via useEffect, which is the pattern already used elsewhere (e.g. the hasOpenedAuthSession ref in the same file, and useCurrentValueRef-style usage in src/components/withCurrentReportID.tsx).

    // src/pages/signin/SAMLSignInPage/index.native.tsx — replace lines 20-79
    function SAMLSignInPage() {
        const [account] = useOnyx(ONYXKEYS.ACCOUNT);
        const [credentials] = useOnyx(ONYXKEYS.CREDENTIALS);
        const [showNavigation, shouldShowNavigation] = useState(true);
        const [SAMLUrl, setSAMLUrl] = useState('');
        const {translate} = useLocalize();
        const hasOpenedAuthSession = useRef(false);
    
        // Latest-value refs — handlers below read from these, never the closure copy.
        const accountRef = useRef(account);
        const credentialsRef = useRef(credentials);
        useEffect(() => {
            accountRef.current = account;
            credentialsRef.current = credentials;
        }, [account, credentials]);
    
        const handleExitSAMLFlow = useCallback(() => {
            Navigation.isNavigationReady().then(() => {
                Navigation.goBack();
                clearSignInData();
            });
        }, []);
    
        const handleNavigationStateChange = useCallback(
            (url: string) => {
                if (url.includes('loginCallback')) {
                    shouldShowNavigation(false);
                }
    
                const searchParams = new URLSearchParams(new URL(url).search);
                const jsonParam = searchParams.get('json');
                if (!jsonParam) {
                    Log.hmmm('SAMLSignInPage - No JSON parameter found in callback URL');
                    return;
                }
    
                let shortLivedAuthToken: string | null = null;
                try {
                    const decodedData = JSON.parse(jsonParam) as Record<string, string | null>;
                    shortLivedAuthToken = decodedData.shortLivedAuthToken ?? null;
                    if (decodedData.error) {
                        Log.hmmm('SAMLSignInPage - SAML login returned error', {error: decodedData.error});
                    }
                } catch (parseError) {
                    Log.hmmm('SAMLSignInPage - Failed to parse JSON parameter', {error: parseError});
                }
    
                // Read the LATEST Onyx values rather than the captured closure values.
                const currentAccount = accountRef.current;
                const currentCredentials = credentialsRef.current;
    
                if (shortLivedAuthToken && currentCredentials?.login && !currentAccount?.isLoading) {
                    Log.info('SAMLSignInPage - Successfully received shortLivedAuthToken. Signing in...');
                    signInWithShortLivedAuthToken(shortLivedAuthToken, true);
                    return;
                }
    
                // Surface the error BEFORE clearSignInData so the toast is not raced away.
                setAccountError(translate('common.error.login'));
                clearSignInData();
                Navigation.isNavigationReady().then(() => {
                    Navigation.goBack();
                    Navigation.navigate(ROUTES.HOME);
                });
            },
            [translate],
        );
        // ...rest unchanged
    }

    This makes the useEffect dep array shrink to [SAMLUrl, handleExitSAMLFlow, handleNavigationStateChange] where handleNavigationStateChange's identity is now stable, so the early return on hasOpenedAuthSession.current no longer hides a freshness gap.

    3 — Reorder setAccountError before clearSignInData as shown in the snippet above (the setAccountError call now precedes clearSignInData). clearSignInData writes ACCOUNT: null via Onyx.multiSet; performing the error merge first guarantees the toast is set against the still-present ACCOUNT before the null reset is queued.

    4 — Surface a real error message and re-enable the credentials guard in Reauthentication.ts:

    // src/libs/Reauthentication.ts — replace lines 133-147
    if (account?.isSAMLRequired) {
        Log.info(`[Reauthenticate] Redirecting to Sign In because SAML is required`);
        setIsAuthenticating(false);
        // Pass a translatable message so the user sees WHY they were signed out.
        redirectToSignIn(getAuthenticateErrorMessage({jsonCode: CONST.JSON_CODE.EXP_ERROR, message: 'session.SAMLReauthRequired'} as Response), true);
        return false;
    }
    
    if (!credentials?.autoGeneratedLogin || !credentials?.autoGeneratedPassword) {
        Log.info('[Reauthenticate] No credentials available, redirecting to sign in');
        setIsAuthenticating(false);
        redirectToSignIn();
        return false;
    }

    Removing the dead commented-out block (the #fireroom-2026-01-28-user-signout reference is now ~15 weeks old) avoids the secondary path where Authenticate(...) is called with undefined partnerUserID and is rejected by requireParameters, which itself routes to redirectToSignIn and contributes to the "frequent logouts" report.

    Why this approach

    The four changes are minimally scoped: each addresses a single, verifiable defect on the path the issue walks through, and none of them broaden behavior beyond the SAML/IDP code paths. The Promise.reject('Logout cancelled') pattern was useful when the .catch did handle it — today nothing does, so converting it to a graceful resolve plus an unconditional redirectToSignIn() failsafe matches what every other sign-out caller already expects (User.ts:124 and SignInRedirect.ts:94 both rely on signOutFromOldDot() being called after the local clear, not before). The ref-based stale-closure fix is the same pattern the file already uses for hasOpenedAuthSession, so it introduces no new abstraction. Re-enabling the credentials guard in Reauthentication.ts is a straight revert of a temporary diagnostic — the fireroom it referenced has long since concluded, and leaving the guard disabled actively masks the very logout symptom this issue reports. Alternatives considered: forcing a navigation reset inside callSAMLSignOut itself (rejected — couples the SAML helper to navigation, and signOutAndRedirectToSignIn is the documented owner of post-logout navigation); switching expectedURL from expensify://open to new-expensify://open to match the registered scheme (rejected — requires a coordinated server-side change to the IDP redirect target and is broader than the bug warrants).

  10. oqildev commented on May 5, 2026

    @oqildev
    Contributor

    Proposal

    What is the root cause of that problem?

    This issue bundles three symptoms with different root causes — they should be split into separate tickets. I traced the most clearly identifiable one (Android SAML "stuck on loading"), and have a strong hypothesis for the iOS blank-screen logout.

    Android: stuck on loading after IDP (JumpCloud) callback

    • src/pages/signin/SAMLSignInPage/index.native.tsx:57-60 — on success, handleNavigationStateChange calls signInWithShortLivedAuthToken(token, true) and returns. It never navigates off the page.
    • The page relies on the auth gate at src/libs/Navigation/AppNavigator/AppNavigator.native.tsx:15-21 to swap PublicScreens → AuthScreens once authenticated && hybridApp.readyToShowAuthScreens is true.
    • In HybridApp (mobile), hybridApp.readyToShowAuthScreens only flips to true inside signInToOldDotAndChooseExperience in src/libs/HybridApp.ts:75-124, which requires all of: session.authToken, hybridApp.useNewDotSignInPage, credentials.autoGeneratedLogin, credentials.autoGeneratedPassword, account.requiresTwoFactorAuth !== undefined, account.needsTwoFactorAuthSetup !== undefined, and OldDot sign-in to complete with tryNewDot !== undefined.
    • If any precondition stalls (e.g., 2FA fields not populated for a freshly-IDP-provisioned account, or signInToOldDot hangs), the gate never opens. SAMLSignInPage stays mounted on PublicScreens forever, rendering only <SAMLLoadingIndicator />.

    The web/deep-link counterpart src/pages/LogInWithShortLivedAuthTokenPage.tsx:50-55 does not have this bug because it explicitly calls Navigation.navigate(ROUTES.HOME, {forceReplace: true}) after kicking off the SAML sign-in. The native page is missing the equivalent step.

    iOS: blank white screen on logout (hypothesis)

    src/libs/actions/Session/index.ts:343-353 calls HybridAppModule.startSignOut() before awaiting the signOut() promise. For SAML accounts, signOut() → callSAMLSignOut (lines 254-273) opens an external auth session; if result.type !== 'success' the promise rejects with 'Logout cancelled'. The outer .catch at line 478 swallows it without calling redirectToSignIn() — but OldDot has already begun unmounting via startSignOut(). The user is left in a half-signed-out state with no route swap. On iOS 26.4.2 the universal-link callback URL likely does not exactly match CONST.SAML_REDIRECT_URL, which would consistently trigger this branch.

    Frequent unexpected logouts (general)

    Too vague to root-cause without specific repro logs. Most likely path is src/libs/Middleware/Reauthentication.ts → repeated NOT_AUTHENTICATED → redirectToSignIn(). Should be split off into its own issue with concrete logs.

    What changes do you think we should make in order to solve the problem?

    Layer: the SAMLSignInPage native page itself — it owns the IDP redirect and is the only component that knows the sign-in API call has been kicked off. The auth-gate layer (AppNavigator) should not be made unconditional, because readyToShowAuthScreens is intentionally false until OldDot is in sync.

    Mirror the existing pattern from LogInWithShortLivedAuthTokenPage. In SAMLSignInPage/index.native.tsx:57-60, immediately after firing signInWithShortLivedAuthToken(shortLivedAuthToken, true), also schedule:

    Navigation.isNavigationReady().then(() =>
        Navigation.navigate(ROUTES.HOME, {forceReplace: true})
    );
    

    so the user is moved to Home regardless of how/when the HybridApp gate opens. Home renders AuthScreens once authenticated && readyToShowAuthScreens, and shows skeletons until then — which is the correct UX. Today's loading indicator on the public stack is the wrong UX because it has no exit when the gate stalls.

    Edge cases:

    • Offline: API.read queues; finallyData will still run when network returns. We've already replaced the /transition route, so the user sees post-login skeletons rather than a frozen SAML loading screen.
    • API failure: failureData reverts loading flags. The existing failure branch at lines 63-69 already handles the no-token case (clear sign-in, navigate to Home).
    • HybridApp not ready yet: Home simply renders PublicScreens skeletons until readyToShowAuthScreens flips, which is the same behavior the web/transition page has had in production for non-SAML deep links.

    For the iOS logout bug (separate ticket): in signOutAndRedirectToSignIn, the .catch at line 478 should still call redirectToSignIn() when the failure was "Logout cancelled", since by that point HybridAppModule.startSignOut() has already been fired and there is no clean way back.

    What alternative solutions did you explore?

    1. Watch session.authToken in a useEffect inside SAMLSignInPage and navigate when it appears. Rejected: races with the PublicScreens → AuthScreens swap when the gate does open quickly, causing a double navigation flash. Mirroring LogInWithShortLivedAuthTokenPage is the established pattern.

    2. Force setReadyToShowAuthScreens(true) immediately after a SAML sign-in. Rejected: breaks the actual contract of that flag. It exists so AuthScreens don't render before OldDot has finished signing in; flipping it early would reintroduce the ordering bugs that HybridApp.ts:104-122 was specifically written to solve.

    3. Drop readyToShowAuthScreens from the AppNavigator gate on native. Rejected: same reason as Fix spaces #2 — load-bearing for HybridApp's NewDot/OldDot handshake.

    4. Deferred / out of scope for this PR: iOS logout blank screen and the "frequent unexpected logouts" symptom — different root causes, should be separate issues with their own repro logs (the issue is currently marked "Needs Reproduction").

  11. yusufdeveloper2903 commented on May 5, 2026

    @yusufdeveloper2903
    Contributor

    🚫 Duplicated proposal withdrawn by 🤖 ProposalPolice.

  12. github-actions commented on May 5, 2026

    @github-actions
    Contributor

    ⚠️ @yusufdeveloper2903 Your proposal is a duplicate of an already existing proposal and has been automatically withdrawn to prevent spam. Please review the existing proposals before submitting a new one.

  13. 8 remaining items

  14. aimane-chnaif commented on May 7, 2026

    @aimane-chnaif
    Contributor

    Thanks for posting AI generated proposals.
    But, to be considered, please share repro steps and demo videos (before and after fix)

  15. added a commit that references this issue on May 12, 2026
  16. 15antonian commented on May 12, 2026

    @15antonian

    Hi @aimane-chnaif, I don't have access to a live SAML account, but I was able to trigger the bug in a unit test by setting isSAMLRequired=true with valid auto-generated credentials and firing a mock 407. The test fails on main and passes with the fix.

    Test: tests/actions/SessionTest.ts - "SAML reauthentication"

    Compare branch: main...15antonian:App:fix/89613-saml-reauth-auto-credentials

    Is there a SAML test account or JumpCloud sandbox contributors can use? Happy to record a before/after video if so.

  17. oqildev commented on May 13, 2026

    @oqildev
    Contributor

    Hi, @aimane-chnaif Thanks for the feedback. Honest disclosure on repro:

    I can't produce a live demo video. This bug only manifests through a real SAML/IDP (JumpCloud) sign-in flow on mobile, and I don't have access to an Expensify-linked SAML account or a JumpCloud-provisioned test user. The issue itself is also marked "Needs Reproduction (Unable to reproduce)" in staging, which suggests it requires a specific IDP-provisioned account state.

    What I did with the access I had:

    Traced the code path end-to-end on the Android "stuck on loading" symptom, which is the most clearly identifiable one.
    Compared against the working web/deep-link counterpart (LogInWithShortLivedAuthTokenPage.tsx) to confirm the divergence.
    Verified the HybridApp auth-gate preconditions in HybridApp.ts to explain why the gate stalls.
    Quick recap of the proposed fix (Android stuck on loading):

    In SAMLSignInPage/index.native.tsx:57-60, the success branch fires signInWithShortLivedAuthToken(token, true) and returns — it never navigates off the public stack. It depends on the AppNavigator auth gate flipping readyToShowAuthScreens, but that flag only flips when all preconditions in HybridApp.ts:75-124 resolve (2FA fields, OldDot sign-in, tryNewDot, etc.). If any of them stalls for a freshly IDP-provisioned account, the page stays mounted on PublicScreens forever.

    The web counterpart at LogInWithShortLivedAuthTokenPage.tsx:50-55 does not have this bug because it explicitly calls Navigation.navigate(ROUTES.HOME, {forceReplace: true}) after the sign-in call. The native page is missing the equivalent step.

    Fix: mirror that pattern — after firing signInWithShortLivedAuthToken, schedule:

    Navigation.isNavigationReady().then(() =>
        Navigation.navigate(ROUTES.HOME, {forceReplace: true})
    );
    

    Home renders skeletons until readyToShowAuthScreens is true, so the UX is correct regardless of whether the gate opens quickly or stalls.

    iOS blank-screen on logout and the "frequent logouts" symptom I flagged as separate root causes that should be split into their own tickets — they need their own repro logs.

    If anyone with a JumpCloud-linked test account can record a before/after on the Android symptom against this fix, I'd be glad to coordinate. Otherwise, happy to defer to a proposer who has the environment.

  18. aimane-chnaif commented on May 25, 2026

    @aimane-chnaif
    Contributor

    @MelvinBot implement this

  19. MelvinBot commented on May 25, 2026

    @MelvinBot
    Contributor

    🤖 Draft PR created: #91596
    Please mark it as "Ready for review" when it is ready for review.

  20. removed
    Help WantedApply this label when an issue is open to proposals by contributors
    on May 25, 2026
  21. aimane-chnaif commented on Jun 1, 2026

    @aimane-chnaif
    Contributor

    From the slack thread, looks like this was fixed already.
    @MelvinBot close this

  22. MelvinBot commented on Jun 1, 2026

    @MelvinBot
    Contributor

    Closing as requested by aimane-chnaif — per the Slack thread, this has already been fixed. 🤖

  23. melvin-bot commented on Jun 24, 2026

    @melvin-bot

    This issue has not been updated in over 15 days. @aimane-chnaif eroding to Monthly issue.

    P.S. Is everyone reading this sure this is really a near-term priority? Be brave: if you disagree, go ahead and close it out. If someone disagrees, they'll reopen it, and if they don't: one less thing to do!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

BugSomething is broken. Auto assigns a BugZero manager.ExternalAdded to denote the issue can be worked on by a contributorMonthlyKSv2ReviewingHas a PR in review

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions