Skip to content

fix: prevent SAML re-fire infinite loop during short-lived-token redeem#96124

Open
fabioh8010 wants to merge 4 commits into
Expensify:mainfrom
callstack-internal:fix/saml-refire-loop
Open

fix: prevent SAML re-fire infinite loop during short-lived-token redeem#96124
fabioh8010 wants to merge 4 commits into
Expensify:mainfrom
callstack-internal:fix/saml-refire-loop

Conversation

@fabioh8010

@fabioh8010 fabioh8010 commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Explanation of Change

Fixes a SAML web sign-in loop where an SSO (SAML-required) user bounces between their identity provider and the app and the session never sticks.

On the /transition redirect-back, LogInWithShortLivedAuthTokenPage starts the short-lived token exchange (SignInWithShortLivedAuthToken, which optimistically sets account.isLoading) and navigates to HOME while still unauthenticated. SignInPage then remounts with hasInitiatedSAMLLogin reset (it's component-local useState); because account.isLoading is still true and SAML is required, it re-initiates SAML — a full-page window.location.replace to the IdP that tears the page down before the token exchange can finish. The token is never consumed, a fresh one is minted on the way back, and it loops. The root problem is that account.isLoading is set for two different reasons (about to start SAML vs. a short-lived token exchange already in flight) and SignInPage could not tell them apart.

This gates shouldInitiateSAMLLogin on the existing RAM_ONLY_IS_AUTHENTICATING_WITH_SHORT_LIVED_TOKEN flag, which is set only while a short-lived token exchange is in flight (in getShortLivedLoginParams) but was previously never read. SignInPage no longer re-initiates SAML during the exchange, so the exchange completes and the session sticks. Because the flag is set for the exact duration of the exchange, the fix is independent of the account.isLoading timing race and holds regardless of IdP/device/network speed. The legitimate first SAML initiation (no exchange in flight) is unchanged.

Fixed Issues

$ #95810
PROPOSAL:

Tests

Prerequisites

First, edit src/libs/actions/Session/index.ts to reproduce the SAML-loop bug conditions:

diff --git a/src/libs/actions/Session/index.ts b/src/libs/actions/Session/index.ts
index f909e17b8c5..6580ce394fd 100644
--- a/src/libs/actions/Session/index.ts
+++ b/src/libs/actions/Session/index.ts
@@ -845,9 +845,15 @@ function beginGoogleSignIn(token: string | null, preferredLocale: Locale | undef
 function signInWithShortLivedAuthToken(authToken: string, isSAML = false) {
     const {optimisticData, failureData, finallyData} = getShortLivedLoginParams(false, isSAML);
     const authMethod = isSAML ? CONST.AUTH_METHOD.SAML : CONST.AUTH_METHOD.SHORT_LIVED_AUTH_TOKEN;
-    Device.getDeviceInfoWithID().then((deviceInfo) => {
-        API.read(READ_COMMANDS.SIGN_IN_WITH_SHORT_LIVED_AUTH_TOKEN, {authToken, skipReauthentication: true, authMethod,     deviceInfo}, {optimisticData, failureData, finallyData});
-    });
+    // [SAML-REPRO] Force the sustained loop: apply the optimistic isLoading:true NOW, but delay the actual redeem so the
+    // re-fire tears the page down before auth can persist (mirrors prod, where the redeem is aborted every iteration).
+    // REMOVE before shipping.
+    Onyx.update(optimisticData);
+    setTimeout(() => {
+        Device.getDeviceInfoWithID().then((deviceInfo) => {
+            API.read(READ_COMMANDS.SIGN_IN_WITH_SHORT_LIVED_AUTH_TOKEN, {authToken, skipReauthentication: trueauthMethod,       deviceInfo}, {optimisticData, failureData, finallyData});
+        });
+    }, 3000);
     NetworkStore.setLastShortAuthToken(authToken);
 }
 

Now, you need to setup a local PROD web server so you are able to perform the entire SAML flow:

  1. Reissue the dev cert to also cover new.expensify.com
    1. Same as npm run setup-https, plus one host, written to the cert path. The mkcert CA is already installed, so the new cert is trusted automatically, and dev.new.expensify.com keeps working.
      mkcert -cert-file config/rsbuild/certificate.pem -key-file config/rsbuild/key.pem new.expensify.com dev.new.expensify.com localhost 127.0.0.1
  2. Point the prod host at localhost
    1. Leave www.expensify.com alone — it must keep resolving to real prod Auth.
      echo "127.0.0.1 new.expensify.com" | sudo tee -a /etc/hosts
  3. Build against prod
    1. .env.production is already correct out of the box — EXPENSIFY_URL=https://www.expensify.com/, NEW_EXPENSIFY_URL=https://new.expensify.com/, and USE_WEB_PROXY=false (talks straight to prod). No edits needed. npm run build sets ENV=production → reads .env.production → outputs to dist/ (requires bun).
      npm run build
  4. Serve dist as https://new.expensify.com (SPA-aware, on :443)
    1. Two terminals:
      # terminal A — static server with SPA fallback (so /transition serves index.html)
      npx serve -s dist -l 8082
      
      # terminal B — TLS on 443 → 8082 using the mkcert pair (443 needs sudo)
      sudo npx local-ssl-proxy --source 443 --target 8082 --cert config/rsbuild/certificate.pem --key config/rsbuild/key.pem

To clean up the PROD setup, run these:

sudo sed -i '' '/127\.0\.0\.1  new\.expensify\.com/d' /etc/hosts
npm run setup-https                 # restore the original dev cert

Test steps

  1. Login with a normal account, assert it works.
  2. Refresh the web/app, assert you are still logged in.
  3. Sign out, assert it works.
  4. Login with a SAML domain account, assert the SAML flow is executed just ONE time.
  5. Refresh the web/app, assert you are still logged in.
  6. Sign out, assert it works.
  • Verify that no errors appear in the JS console

Offline tests

N/A

QA Steps

It's only possible to test on PROD environment.

  • Verify that no errors appear in the JS console

PR Author Checklist

  • I linked the correct issue in the ### Fixed Issues section above
  • I wrote clear testing steps that cover the changes made in this PR
    • I added steps for local testing in the Tests section
    • I added steps for the expected offline behavior in the Offline steps section
    • I added steps for Staging and/or Production testing in the QA steps section
    • I added steps to cover failure scenarios (i.e. verify an input displays the correct error message if the entered data is not correct)
    • I turned off my network connection and tested it while offline to ensure it matches the expected behavior (i.e. verify the default avatar icon is displayed if app is offline)
    • I tested this PR with a High Traffic account against the staging or production API to ensure there are no regressions (e.g. long loading states that impact usability).
  • I included screenshots or videos for tests on all platforms
  • I ran the tests on all platforms & verified they passed on:
    • Android: Native
    • Android: mWeb Chrome
    • iOS: Native
    • iOS: mWeb Safari
    • MacOS: Chrome / Safari
  • I verified there are no console errors (if there's a console error not related to the PR, report it or open an issue for it to be fixed)
  • I followed proper code patterns (see Reviewing the code)
    • I verified that comments were added to code that is not self explanatory
    • I verified that any new or modified comments were clear, correct English, and explained "why" the code was doing something instead of only explaining "what" the code was doing.
    • I verified any copy / text that was added to the app is grammatically correct in English. It adheres to proper capitalization guidelines (note: only the first word of header/labels should be capitalized), and is either coming verbatim from figma or has been approved by marketing (in order to get marketing approval, ask the Bug Zero team member to add the Waiting for copy label to the issue)
  • If a new code pattern is added I verified it was agreed to be used by multiple Expensify engineers
  • I followed the guidelines as stated in the Review Guidelines
  • I tested other components that can be impacted by my changes (i.e. if the PR modifies a shared library or component like Avatar, I verified the components using Avatar are working as expected)
  • If a new CSS style is added I verified that:
    • A similar style doesn't already exist
    • The style can't be created with an existing StyleUtils function (i.e. StyleUtils.getBackgroundAndBorderStyle(theme.componentBG))
  • If new assets were added or existing ones were modified, I verified that:
    • The assets are optimized and compressed (for SVG files, run npm run compress-svg)
    • The assets load correctly across all supported platforms.
  • If the PR modifies code that runs when editing or sending messages, I tested and verified there is no unexpected behavior for all supported markdown - URLs, single line code, code blocks, quotes, headings, bold, strikethrough, and italic.
  • If the PR modifies a generic component, I tested and verified that those changes do not break usages of that component in the rest of the App (i.e. if a shared library or component like Avatar is modified, I verified that Avatar is working as expected in all cases)
  • If the PR modifies a component related to any of the existing Storybook stories, I tested and verified all stories for that component are still working as expected.
  • If the PR modifies a component or page that can be accessed by a direct deeplink, I verified that the code functions as expected when the deeplink is used - from a logged in and logged out account.
  • If the PR modifies the UI (e.g. new buttons, new UI components, changing the padding/spacing/sizing, moving components, etc) or modifies the form input styles:
    • I verified that all the inputs inside a form are aligned with each other.
    • I added Design label and/or tagged @Expensify/design so the design team can review the changes.
  • I added unit tests for any new feature or bug fix in this PR to help automatically prevent regressions in this user flow.
  • If the main branch was merged into this PR after a review, I tested again and verified the outcome was still expected according to the Test steps.

Screenshots/Videos

Android: Native
Screen.Recording.2026-07-15.at.12.18.21.mov
Android: mWeb Chrome
iOS: Native
iOS: mWeb Safari
MacOS: Chrome / Safari
Screen.Recording.2026-07-15.at.11.03.00.mov

SignInPage.getRenderOptions re-initiated SAML on the fresh mount that follows
the /transition redeem whenever account.isLoading was still optimistically
true. hasInitiatedSAMLLogin is component-local useState, so it resets on that
mount; on a slow-redeem / fast-IdP combination SAML re-fired (window.location
.replace) before the redeem landed, aborting it — an infinite loop for
SAML-required users (reproduced end-to-end; seen in production across a whole
SAML-required domain).

Gate shouldInitiateSAMLLogin on the RAM-only IS_AUTHENTICATING_WITH_SHORT_LIVED_TOKEN
flag (already set for the exact duration of the redeem in getShortLivedLoginParams,
but previously unread) so SAML cannot re-fire while a redeem is in flight. This
removes the isLoading timing dependency and fixes the infinite case regardless
of device/IdP/network.

Note: this covers the single-tab loop. A multi-tab variant (a second tab
re-firing on the shared account.isLoading) is a known follow-up; it needs a
non-persisted cross-tab signal (the persisted session flag regresses sign-in).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@fabioh8010 fabioh8010 marked this pull request as ready for review July 15, 2026 15:33
@fabioh8010 fabioh8010 requested review from a team as code owners July 15, 2026 15:33
@melvin-bot melvin-bot Bot requested a review from jayeshmangwani July 15, 2026 15:33
@melvin-bot

melvin-bot Bot commented Jul 15, 2026

Copy link
Copy Markdown

@jayeshmangwani 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]

@melvin-bot melvin-bot Bot requested review from heyjennahay and removed request for a team July 15, 2026 15:33

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3d6e5dd563

ℹ️ 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".

// with hasInitiatedSAMLLogin reset. If account.isLoading is still optimistically true (slow IdP/network) SAML would
// re-fire before the redeem lands -> infinite loop. isAuthenticatingWithShortLivedToken (RAM-only, set for the exact
// duration of the redeem) gates that off, independent of the isLoading timing race.
const shouldInitiateSAMLLogin = hasAccount && hasLogin && isSAMLRequired && !hasInitiatedSAMLLogin && !!account.isLoading && !isSupportalSession && !isAuthenticatingWithShortLivedToken;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Set the token guard before awaiting device info

When a SAML /transition callback is handled, LogInWithShortLivedAuthTokenPage calls signInWithShortLivedAuthToken and immediately schedules Navigation.navigate(ROUTES.HOME) for SAML callbacks, but signInWithShortLivedAuthToken does not apply getShortLivedLoginParams (including RAM_ONLY_IS_AUTHENTICATING_WITH_SHORT_LIVED_TOKEN) until Device.getDeviceInfoWithID() resolves. If navigation wins that race on a slower device/browser, this predicate still sees isAuthenticatingWithShortLivedToken === false while the old SAML-required/loading account state is present, so the page can re-fire SAML before the guard is ever set and the loop remains. Set the RAM-only guard synchronously before leaving /transition or before awaiting device info.

Useful? React with 👍 / 👎.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant