Fix mobile SAML/Okta double login on session resume#94082
Conversation
…owser Co-authored-by: Rory Abraham <roryabraham@users.noreply.github.com>
|
🚧 @roryabraham has triggered a test Expensify/App build. You can view the workflow run here. |
|
🧪🧪 Use the links below to test this adhoc build on Android, iOS, and Web. Happy testing! 🧪🧪
|
|
@johncschuster do you want to have your customer retest the saml bug on this AdHoc build? The only thing is they won't be able to install the AdHoc build on iOS, so if that's the only device they have they won't be able to test. |
|
Only asking because you said "They would gladly QA this if we need someone to ensure it's working" |
|
ok, the customer was unsuccessful in installing the AdHoc build - no big deal. I think we should move this forward since we can't repro locally. Then we can have the customer retest on staging. |
|
@thelullabyy Please copy/paste the Reviewer Checklist from here into a new comment on this PR and complete it. If you have the K2 extension, you can simply click: [this button] |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: feb15e0ae1
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| // 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. signInWithShortLivedAuthToken() resets it on | ||
| // success; the cancel/error/failure paths reset it via handleExitSAMLFlow and handleNavigationStateChange. | ||
| setIsAuthenticatingWithShortLivedToken(true); |
There was a problem hiding this comment.
Clear SAML guard for malformed callbacks
When the in-app browser returns a success URL without the expected json parameter, handleNavigationStateChange() returns before reaching the new cleanup path, so the guard set here remains true. This function already treats missing json as a possible callback shape, and for loginCallback URLs the back button is hidden; while account.isLoading remains true, future reauthenticate() calls will keep aborting instead of recovering or redirecting. Please clear the guard on that early-return path as well.
Useful? React with 👍 / 👎.
Review: the fix is sound ✅I traced the full data flow and the core reasoning in the PR holds. Setting the Verified claims:
One minor edge case worth a one-line fix (non-blocking): In The unit test in Net: the approach is correct and safe to move forward; the edge case above is optional polish. |
Reviewer Checklist
Screenshots/VideosAndroid: HybridAppAndroid: mWeb ChromeiOS: HybridAppiOS: mWeb SafariMacOS: Chrome / Safari |
| // 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. signInWithShortLivedAuthToken() resets it on | ||
| // success; the cancel/error/failure paths reset it via handleExitSAMLFlow and handleNavigationStateChange. | ||
| setIsAuthenticatingWithShortLivedToken(true); |
There was a problem hiding this comment.
When the in-app browser returns a success URL without the expected json parameter, handleNavigationStateChange() returns before reaching the new cleanup path, so the guard set here remains true. This function already treats missing json as a possible callback shape, and for loginCallback URLs the back button is hidden; while account.isLoading remains true, future reauthenticate() calls will keep aborting instead of recovering or redirecting. Please clear the guard on that early-return path as well.
@MelvinBot What do you think?
This comment was marked as off-topic.
This comment was marked as off-topic.
This comment was marked as off-topic.
This comment was marked as off-topic.
This comment was marked as outdated.
This comment was marked as outdated.
This comment was marked as duplicate.
This comment was marked as duplicate.
|
@roryabraham he still doesnt listen to me, could you please try to ask him? |
This comment was marked as duplicate.
This comment was marked as duplicate.
|
When the in-app browser returns a success URL without the expected json parameter, handleNavigationStateChange() returns before reaching the new cleanup path, so the guard set here remains true. This function already treats missing json as a possible callback shape, and for loginCallback URLs the back button is hidden; while account.isLoading remains true, future reauthenticate() calls will keep aborting instead of recovering or redirecting. Please clear the guard on that early-return path as well. @MelvinBot What do you think? |
|
@roryabraham I think I will ask Melvin to open another PR. It should fix the problem |
|
I think we can close this PR as favor of #95017 |
|
no problem |
…arGuard Clear SAML short-lived-token guard on the no-JSON early-return path (based on #94082)

Explanation of Change
On mobile, when a SAML/Okta SSO user returns to the app after their IdP session has gone idle and completes re-authentication + MFA in the in-app browser, the app briefly freezes, logs them out, and forces a second login. The second login always succeeds.
Root cause — a race on app resume. Opening the in-app browser (
openAuthSessionAsync) backgrounds the app. When the browser closes and the app resumes, two flows fire concurrently:reconnectApp()runs with the now-expiredauthToken, gets a407 NOT_AUTHENTICATED, and triggersreauthenticate(). For SAML users it takes theaccount.isSAMLRequiredbranch and callsredirectToSignIn(undefined, true)→Onyx.clear(), wiping the session and cancelling in-flight requests.shortLivedAuthTokenand callssignInWithShortLivedAuthToken().reauthenticate()already has a guard that aborts whenisAuthenticatingWithShortLivedTokenis set — but that flag was only set optimistically insidesignInWithShortLivedAuthToken(), which runs after the browser returns. So on resume the guard is stillfalse, reauth wins the race, and the session is cleared before the SAML callback can land the user in. The second login works because the app is then in a clean state with no competing reconnection flow.Fix. Set the
isAuthenticatingWithShortLivedTokenguard (the RAM-only keyRAM_ONLY_IS_AUTHENTICATING_WITH_SHORT_LIVED_TOKEN) totruebefore opening the in-app browser, so the guard is active for the entire SAML browser session andreauthenticate()aborts instead of redirecting to sign-in. The guard is reset tofalseon the cancel, error, and callback-failure paths; on success,signInWithShortLivedAuthToken()'s optimistic/finally data takes over flag management as before.The guard reliably aborts during the SAML flow because the page is only reached while
account.isLoadingistrue(SignInPageonly navigates to the SAML page whenshouldInitiateSAMLLogin, which requires!!account.isLoading), andreconnectApp()/openApp()operate on the separateIS_LOADING_APPkey, so resume does not clearaccount.isLoading. This satisfies the guard'saccount?.isLoading !== falsecondition inreauthenticate(). The new flag is a separate key fromaccount.isLoading, so the SAML success check inSAMLSignInPageis unaffected.Changes are native-only (
SAMLSignInPage/index.native.tsx), matching where the bug occurs; desktop uses a different SAML implementation.AI tests run locally by MelvinBot (informational — does not replace human Tests/QA)
npx prettieron changed files — pass (unchanged)npm run lint-changed— passnpm run typecheck(tsc) — passnpm test -- tests/actions/SessionTest.ts— pass (32 tests, incl. new test assertingsetIsAuthenticatingWithShortLivedToken(true)makesreauthenticate()abort)npm test -- tests/ui/SessionTest.tsx— pass (2 tests)npm run react-compiler-compliance-check check src/pages/signin/SAMLSignInPage/index.native.tsx— COMPILEDThis change can only be fully validated on a device with a real Okta SAML session, which MelvinBot cannot operate.
Fixed Issues
$ #86705
PROPOSAL: #86705 (comment)
Tests
n/a - we'll have customer retest on staging.
Offline tests
None.
QA Steps
None.
PR Author Checklist
### Fixed Issuessection aboveTestssectionOffline stepssectionQA stepssectioncanBeMissingparam foruseOnyxtoggleReportand notonIconClick)src/languages/*files and using the translation methodSTYLE.md) were followedAvatar, I verified the components usingAvatarare working as expected)StyleUtils.getBackgroundAndBorderStyle(theme.componentBG))npm run compress-svg)Avataris modified, I verified thatAvataris working as expected in all cases)Designlabel and/or tagged@Expensify/designso the design team can review the changes.ScrollViewcomponent to make it scrollable when more elements are added to the page.mainbranch was merged into this PR after a review, I tested again and verified the outcome was still expected according to theTeststeps.Screenshots/Videos
Android: Native
Android: mWeb Chrome
iOS: Native
iOS: mWeb Safari
MacOS: Chrome / Safari