Repository navigation
[$250] Severe Auth Issues on Mobile: Login Failures, Frequent Logouts, and Inability to Log Out #89613
Description
Activity
- addedDailyKSv2KSv2BugSomething is broken. Auto assigns a BugZero manager.Something is broken. Auto assigns a BugZero manager.
on May 5, 2026 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.tsxusesopenAuthSessionAsyncwith the redirect URLexpensify://open. ThehandleNavigationStateChangecallback closes overcredentials?.loginandaccount?.isLoading. While the user is in the external browser completing IDP authentication, Onyx state may update, but the.then()handler captured by theuseEffectstill references the stale closure. Ifcredentials?.loginis not yet populated oraccount?.isLoadingis still true when the callback fires, the condition at line 64 fails, causingclearSignInData()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 theopenAuthSessionAsyncpromise at all, leaving the user permanently stuck on the loading indicator.2. iOS blank white screen on logout — When a SAML user logs out,
callSAMLSignOutopens 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'. InsignOutAndRedirectToSignIn,HybridAppModule.startSignOut()was already called at line 345, but the.catch()at line 479 only logs a warning — it never callsredirectToSignIn(). 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-137immediately redirects to sign-in on any token expiry instead of reauthenticating. WithSESSION_EXPIRATION_TIME_MSset 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.tsxso thathandleNavigationStateChangereadscredentialsandaccountfrom Onyx directly (viaOnyx.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, whencallSAMLSignOutrejects with'Logout cancelled', the.catch()insignOutAndRedirectToSignInshould callredirectToSignIn()(andHybridAppModule.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.tsshould 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
- Native SAML sign-in with stale closure:
SAMLSignInPage/index.native.tsx:38-99 - SAML redirect URL constant:
CONST/index.ts:1143 - SAML logout with unhandled rejection:
Session/index.ts:255-274 - Sign-out orchestration with silent catch:
Session/index.ts:344-479 - SAML early-exit in reauthentication:
Reauthentication.ts:133-137 - Commented-out credential guard:
Reauthentication.ts:140-147 - Session expiration (2h):
CONST/index.ts:2259
Next Steps for Contributor+ team: Reply with
@MelvinBot implement thisto create a draft PR,@MelvinBot <your feedback>to refine this analysis, or explain why you are rejecting Melvin's proposal.- Native SAML sign-in with stale closure:
- addedExternalAdded to denote the issue can be worked on by a contributorAdded to denote the issue can be worked on by a contributor
on May 5, 2026 - addedHelp WantedApply this label when an issue is open to proposals by contributorsApply this label when an issue is open to proposals by contributors
on May 5, 2026 Triggered auto assignment to Contributor-plus team member for initial proposal review - @aimane-chnaif (
External)- 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 Job added to Upwork: https://www.upwork.com/jobs/~022051687650242665320
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
handleNavigationStateChangecallback withopenAuthSessionAsynchere:. That callback closes overApp/src/pages/signin/SAMLSignInPage/index.native.tsx
Lines 81 to 99 in 3031061
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]); account?.isLoadingandcredentials?.loginfrom the render that opened the browser, then later uses those stale values to decide whether the returnedshortLivedAuthTokenis valid here:. 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.App/src/pages/signin/SAMLSignInPage/index.native.tsx
Lines 38 to 79 in 3031061
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], ); Second, SAML logout rejects when the external browser result is not
success, before the app callsLogOut:. That rejection skips the normalApp/src/libs/actions/Session/index.ts
Lines 255 to 273 in 3031061
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, {}); }); signOutAndRedirectToSignInsuccess path, including HybridApp OldDot sign-out and the final redirect, because the outer catch only logs the error:.App/src/libs/actions/Session/index.ts
Lines 404 to 479 in 3031061
// 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:
. Since the session expiration window is two hours, SAML users are forced back through SAML whenever reauth is needed:App/src/libs/Reauthentication.ts
Lines 133 to 147 in 3031061
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; } .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 latestaccountandcredentials, 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 theLOG_OUTrequest so the existing sign-out chain can redirect and sign out OldDot when needed. - In
Reauthentication.ts, remove the unconditionalaccount?.isSAMLRequiredredirect. 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
Authenticateand 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.isLoadingchanges 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
isSAMLRequiredbranch.What alternative solutions did you explore? (Optional)
- In
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 —
signOutAndRedirectToSignInswallows a rejected SAML logout and never recovers (iOS blank screen):callSAMLSignOutinsrc/libs/actions/Session/index.ts:255-274opens an externalASWebAuthenticationSession(iOS) / Custom Tab (Android) and rejects withError('Logout cancelled')when the user dismisses the browser, when the IDP fails to round-trip theexpensify://openredirect, or when the OS reportsresult.type !== 'success'(this includes the'dismiss'and'cancel'cases, both common on iOS 17+ whenASWebAuthenticationSessionreturnsWBADismissedReason).// 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,
signOutAndRedirectToSignInhas already executedHybridAppModule.startSignOut()at line 345 (HybridApp build) and the modal close athideContextMenu(false)(line 329). When the rejection bubbles up, the.catchat 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 toROUTES.HOMEnever 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 theexpensify://scheme is not declared inios/NewExpensify/Info.plist(onlynew-expensifyis inCFBundleURLSchemes) —ASWebAuthenticationSessioncan still match the redirect via itscallbackURLSchemeargument, but only when the surrounding promise chain is allowed to recover from non-success results.2 — Stale closure in
SAMLSignInPage/index.native.tsxstrands 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.loginmay briefly toggle (notably,clearSignInData()is callable from background flows and setscredentialstonull). The.then((response) => handleNavigationStateChange(response.url))callback was captured against the first render'shandleNavigationStateChange. TheuseEffectdeps includehandleNavigationStateChange, but the early return onhasOpenedAuthSession.currentblocks re-binding. Result: the callback compares stale state, theifat line 64 evaluates falsy (typically becausecredentials?.loginis nownullafter a transient clear), control flow falls through toclearSignInData()+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 theSAMLLoadingIndicatormounted underneath with no way to escape.3 —
setAccountErrorthrows away the error whencredentialswas just cleared:The fall-through path at lines 70-76 calls
setAccountError(translate('common.error.login'))but immediately afterclearSignInData()(line 70) whichOnyx.multiSet({ACCOUNT: null, CREDENTIALS: null}). BecauseOnyx.multiSetandsetAccountError's subsequentOnyx.merge(ACCOUNT, ...)are not awaited in order, on devices with slow disk I/O (Pixel 7a / Pixel 10 Pro XL on Android 16) themultiSet(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)passesundefinedforerrorMessage, 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 intoAuthenticate(...)withpartnerUserID: 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
callSAMLSignOutresolve withnullinstead of rejecting on cancellation, and havesignOutAndRedirectToSignInproceed with the local Onyx clear regardless of whether the serverLOG_OUTwas 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
.catchso 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. thehasOpenedAuthSessionref in the same file, anduseCurrentValueRef-style usage insrc/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
useEffectdep array shrink to[SAMLUrl, handleExitSAMLFlow, handleNavigationStateChange]wherehandleNavigationStateChange's identity is now stable, so the early return onhasOpenedAuthSession.currentno longer hides a freshness gap.3 — Reorder
setAccountErrorbeforeclearSignInDataas shown in the snippet above (thesetAccountErrorcall now precedesclearSignInData).clearSignInDatawritesACCOUNT: nullviaOnyx.multiSet; performing the error merge first guarantees the toast is set against the still-presentACCOUNTbefore 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-signoutreference is now ~15 weeks old) avoids the secondary path whereAuthenticate(...)is called withundefinedpartnerUserID and is rejected byrequireParameters, which itself routes toredirectToSignInand 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.catchdid handle it — today nothing does, so converting it to a graceful resolve plus an unconditionalredirectToSignIn()failsafe matches what every other sign-out caller already expects (User.ts:124andSignInRedirect.ts:94both rely onsignOutFromOldDot()being called after the local clear, not before). The ref-based stale-closure fix is the same pattern the file already uses forhasOpenedAuthSession, so it introduces no new abstraction. Re-enabling the credentials guard inReauthentication.tsis 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 insidecallSAMLSignOutitself (rejected — couples the SAML helper to navigation, andsignOutAndRedirectToSignInis the documented owner of post-logout navigation); switchingexpectedURLfromexpensify://opentonew-expensify://opento match the registered scheme (rejected — requires a coordinated server-side change to the IDP redirect target and is broader than the bug warrants).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,handleNavigationStateChangecallssignInWithShortLivedAuthToken(token, true)andreturns. It never navigates off the page.- The page relies on the auth gate at
src/libs/Navigation/AppNavigator/AppNavigator.native.tsx:15-21to swapPublicScreens→AuthScreensonceauthenticated && hybridApp.readyToShowAuthScreensis true. - In HybridApp (mobile),
hybridApp.readyToShowAuthScreensonly flips totrueinsidesignInToOldDotAndChooseExperienceinsrc/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 withtryNewDot !== undefined. - If any precondition stalls (e.g., 2FA fields not populated for a freshly-IDP-provisioned account, or
signInToOldDothangs), the gate never opens.SAMLSignInPagestays mounted onPublicScreensforever, rendering only<SAMLLoadingIndicator />.
The web/deep-link counterpart
src/pages/LogInWithShortLivedAuthTokenPage.tsx:50-55does not have this bug because it explicitly callsNavigation.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-353callsHybridAppModule.startSignOut()before awaiting thesignOut()promise. For SAML accounts,signOut()→callSAMLSignOut(lines 254-273) opens an external auth session; ifresult.type !== 'success'the promise rejects with'Logout cancelled'. The outer.catchat line 478 swallows it without callingredirectToSignIn()— but OldDot has already begun unmounting viastartSignOut(). 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 matchCONST.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→ repeatedNOT_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
SAMLSignInPagenative 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, becausereadyToShowAuthScreensis intentionallyfalseuntil OldDot is in sync.Mirror the existing pattern from
LogInWithShortLivedAuthTokenPage. InSAMLSignInPage/index.native.tsx:57-60, immediately after firingsignInWithShortLivedAuthToken(shortLivedAuthToken, true), also schedule:Navigation.isNavigationReady().then(() => Navigation.navigate(ROUTES.HOME, {forceReplace: true}) );so the user is moved to
Homeregardless of how/when the HybridApp gate opens.HomerendersAuthScreensonceauthenticated && 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.readqueues;finallyDatawill still run when network returns. We've already replaced the/transitionroute, so the user sees post-login skeletons rather than a frozen SAML loading screen. - API failure:
failureDatareverts 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:
Homesimply rendersPublicScreensskeletons untilreadyToShowAuthScreensflips, 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.catchat line 478 should still callredirectToSignIn()when the failure was"Logout cancelled", since by that pointHybridAppModule.startSignOut()has already been fired and there is no clean way back.What alternative solutions did you explore?
-
Watch
session.authTokenin auseEffectinsideSAMLSignInPageand navigate when it appears. Rejected: races with thePublicScreens→AuthScreensswap when the gate does open quickly, causing a double navigation flash. MirroringLogInWithShortLivedAuthTokenPageis the established pattern. -
Force
setReadyToShowAuthScreens(true)immediately after a SAML sign-in. Rejected: breaks the actual contract of that flag. It exists soAuthScreensdon't render before OldDot has finished signing in; flipping it early would reintroduce the ordering bugs thatHybridApp.ts:104-122was specifically written to solve. -
Drop
readyToShowAuthScreensfrom theAppNavigatorgate on native. Rejected: same reason as Fix spaces #2 — load-bearing for HybridApp's NewDot/OldDot handshake. -
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").
🚫 Duplicated proposal withdrawn by 🤖 ProposalPolice.
⚠️ @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.8 remaining items
Thanks for posting AI generated proposals.
But, to be considered, please share repro steps and demo videos (before and after fix)- added a commit that references this issue
on May 12, 2026 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=truewith valid auto-generated credentials and firing a mock 407. The test fails onmainand 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.
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.
@MelvinBot implement this
🤖 Draft PR created: #91596
Please mark it as "Ready for review" when it is ready for review.- removedHelp WantedApply this label when an issue is open to proposals by contributorsApply this label when an issue is open to proposals by contributors
on May 25, 2026 From the slack thread, looks like this was fixed already.
@MelvinBot close thisClosing as requested by
aimane-chnaif— per the Slack thread, this has already been fixed. 🤖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!
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsDone
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)
iOS (Logout Failure)
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:
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