Skip to content

[No QA] Split the QA auth callback into capture and exchange (part 3/5) - #100730

Merged
iwiznia merged 16 commits into
Expensify:mainfrom
software-mansion-labs:qa-env/3-cloudflare-session
Oct 8, 2026
Merged

iwiznia merged 16 commits into
Expensify:mainfrom
software-mansion-labs:qa-env/3-cloudflare-session

Conversation

@sharabai

@sharabai sharabai commented Sep 9, 2026 •

Copy link
Copy Markdown
Contributor

Explanation of Change

The boot-time callback handling splits in two. captureAuthCallbackURL reads the authorization code off
window.location, consumes the pending flow record its state names, and rewrites the URL back to where the round
trip started; finishSignInFromURL spends the code capture approved. Capture has to run before anything can
resolve a route from the callback path, which no app route serves, and the exchange cannot run until Onyx is
initialized to persist its session. index.js imports src/setup/captureCloudflareAuthCallbackURL for its side
effect rather than calling it, because a statement in index.js runs only after every import in it has been
evaluated, which is already too late.

The bearer allowlist becomes a list: isQAServerRequest tests membership in every configured QA origin,
while getQAResource stays the single RFC 8707 resource indicator the
token is bound to, since authorize and exchange must send byte identical values. QA_SECURE_EXPENSIFY_URL,
shipped unread in part 1, is the second entry, and a malformed one disables the feature rather than dropping
out: a half populated allowlist would send the shouldUseSecure commands out bearer-less into an
unrecoverable 401. One token covers both hosts only while they stay on a single multi-domain Access
application. CHECK_PATH leaves isQAAuthConfigured(), being the probe's business rather than the
feature's, and the probe checks it itself: an empty one would otherwise POST at the bare API root.

refreshCloudflareSession now requires the token it refreshes from, so no caller can skip the rotation-race
check, and a rotation whose Onyx write fails resolves refreshed anyway, the old token being spent and the
cached pair the only usable credential. The exchange-failed outcome is gone, and a callback that passes
every check records code-captured, which says only that capture handed the code on. A rejected exchange
reaches the log, as does every refused callback with its outcome and reason. Run probe also reports a
rejection before it redirects, so a failing setup cannot loop the tab through Cloudflare unseen. Rows that
mount after the rejection show it at once, so their first press starts a fresh round trip. Otherwise the
first press to learn of it reports it, joining the exchange if it is still in flight, and the press after
that starts the round trip. Clear session and sign-out both drop the stored rejection, and a rejection
arriving after either is not recorded.

Important

The pending flow record lives in localStorage, one record per OAuth state. Chrome can hand the
callback page an earlier page's sessionStorage copy, which failed the round trip with
OAuth callback state mismatch. Each record is single-use, expires after ten minutes, is swept on boot, and
is cleared by sign-out and Clear session. Pressing Allow on an older Authorize screen that has not expired
now completes the sign-in.

Fixed Issues

$ #91419
PROPOSAL:

Tests

Preconditions:

  • Web only. QA auth is hard-off on native, where every module in this diff is a stub.
  • A Cloudflare Access application and .env, set up by following
    the setup guide on #98433 with
    these changes:
    1. Use https://dev.new.expensify.com:8082 wherever the guide says <your-dev-origin>: the Worker's
      Access-Control-Allow-Origin, the allowed redirect URI, and the client registration.
    2. In section 2, add QA_SECURE_EXPENSIFY_URL= with an empty value. The setup has one host.
  • Restart npm run web after every .env edit: dotenv is read once at startup.
  • One Expensify account.
  • The QA rows, used below: Settings > Troubleshoot, or the test tools modal opened with Cmd+D on macOS or
    Ctrl+D elsewhere. They render a "QA auth (Cloudflare)" row with a Run probe button, a "QA auth session" row with a Clear
    session button, and a status line below both once there is a result.

Setting those values also makes a QA row appear in Settings > Troubleshoot > Server. Leave it alone: the
sign-out on a QA crossing and the bearer on the request path both arrive in part 4, so selecting QA here
sends unauthenticated requests to the QA origin.

A build with no QA auth configured, a blank check path and a rejected code exchange are left to the unit
suites, which drive each of those branches directly.

  1. A full round trip produces a session

    1. Open Settings > Troubleshoot and press Run probe in the "QA auth (Cloudflare)" row. Verify that the whole tab
      navigates to the Cloudflare Access login page on your team domain.
    2. Complete the Access login, then press Allow on the Authorize Client screen. Verify that the tab returns
      to Settings > Troubleshoot and the URL bar reads /settings/troubleshoot, not /oauth/callback.
    3. Press Run probe again. Verify that the status line below the rows reads
      "Probe succeeded (authenticatedVia: )" followed by a timestamp. The value is whatever the Worker
      echoes, and null when it sends none.
    4. Reload the page, press Run probe, and verify that the status line reads the same success without a second
      trip to Cloudflare.
  2. A callback URL is rewritten even when it is refused

    1. From Settings > Troubleshoot, load
      https://dev.new.expensify.com:8082/oauth/callback?code=fake-code&state=fake-state in the URL bar.
    2. Verify that once the app has booted the URL bar no longer reads /oauth/callback, and that the app
      shows the screen you were last on, Settings > Troubleshoot, rather than a not-found page.
    3. Without reloading, verify that the status line below the QA rows reads the sign-in failure copy followed
      by (No pending QA auth flow matches this callback. Start the sign-in again). A reload clears the
      result.
  3. A malformed secure root switches the whole feature off

    1. Set QA_SECURE_EXPENSIFY_URL=http://qa-secure.example.com/ and restart the dev server.
    2. Open Settings > Troubleshoot and verify that the "QA auth (Cloudflare)" and "QA auth session" rows are both
      absent.
    3. Press the "Server" row and verify that the list offers Production and Staging only, with no QA row.
    4. Set QA_SECURE_EXPENSIFY_URL= to an empty value, restart, and verify that both QA rows are back and
      the Server list offers QA again.
  4. Sign-out drops the Cloudflare credential

    1. Complete test 1 so a session exists, then sign out of Expensify.
    2. On the sign-in screen, press Cmd+D to open the QA rows.
    3. Press Run probe and verify that the tab navigates to Cloudflare's Authorize Client screen instead of
      reporting "Probe succeeded", since the session did not survive the sign-out.

The bearer never reaches the secure origin on this branch: the probe fires only at the primary API root, and
nothing attaches the bearer to an app request until part 5, so the allowlist cases in
CloudflareAccessTest are the only proof of that half.

  • Verify that no errors appear in the JS console

Offline tests

N/A: every path in this PR is an OAuth round trip against Cloudflare's edge, which has no offline behaviour
to specify. The one persisted key, CLOUDFLARE_SESSION, is written through Onyx.set with no optimistic
data and no queued write, and the app has no consumer of it yet.

QA Steps

N/A: the only observable surface is the QA auth test tool, which renders solely when a Cloudflare Access
application and its .env values are configured. Neither staging nor production carries them, so
nothing here is reachable on a build QA runs. Nothing user-facing changes on either 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.
  • If the PR adds or modifies the UI:
    • I asked an AI agent to review the changes for accessibility issues and addressed its findings.
    • I tested with a screen reader (VoiceOver on macOS) and verified all new/changed elements are reachable with a logical focus order.
    • I verified all new/changed elements have meaningful accessible names and roles.
    • I verified state changes are announced (e.g. checked/unchecked, expanded/collapsed, selected).
  • 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

N/A: QA auth is hard-off on native. Config/index.native.ts, captureAuthCallbackURL/index.native.ts and
finishSignInFromURL/index.native.ts are stubs, so there is nothing to render.

Android: mWeb Chrome

N/A: the flow navigates the whole tab to Cloudflare and back, and the redirect URI is bound to the dev
origin, so a mobile browser cannot receive the callback.

iOS: Native

N/A: same stubs as Android native.

iOS: mWeb Safari

N/A: same redirect URI binding as Android mWeb.

MacOS: Chrome / Safari

Test 1: A full round trip produces a session

pr-100730-test-1-full-round-trip-produces-session.mp4

Test 2: A callback URL is rewritten even when it is refused

pr-100730-test-2-refused-callback-url-is-rewritten.mp4

Test 3: A malformed secure root switches the whole feature off, with QA_SECURE_EXPENSIFY_URL=http://qa-secure.example.com/

pr-100730-test-3-malformed-secure-root-switches-feature-off-part-1.mp4

Test 3, continued, with QA_SECURE_EXPENSIFY_URL= empty again

pr-100730-test-3-malformed-secure-root-switches-feature-off-part-2.mp4

Test 4: Sign-out drops the Cloudflare credential

pr-100730-test-4-sign-out-drops-cloudflare-credential.mp4

@melvin-bot

melvin-bot Bot commented Sep 9, 2026

Copy link
Copy Markdown

Hey, I noticed you changed src/languages/en.ts in a PR from a fork. For security reasons, translations are not generated automatically for PRs from forks.

If you want to automatically generate translations for other locales, an Expensify employee will have to:

  1. Look at the code and make sure there are no malicious changes.
  2. Run the Generate static translations GitHub workflow. If you have write access and the K2 extension, you can simply click: [this button]

Alternatively, if you are an external contributor, you can run the translation script locally with your own OpenAI API key. To learn more, try running:

npx bun ./scripts/generateTranslations.ts --help

Typically, you'd want to translate only what you changed by running npx bun ./scripts/generateTranslations.ts --compare-ref main

@codecov

codecov Bot commented Sep 9, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ Changes either increased or maintained existing code coverage, great job!

Files with missing lines Coverage Δ
src/CONFIG.ts 85.29% <ø> (ø)
src/CONST/index.ts 95.86% <ø> (ø)
src/libs/CloudflareAccess/AuthServerMetadata.ts 93.54% <100.00%> (ø)
src/libs/CloudflareAccess/Config/index.native.ts 100.00% <100.00%> (ø)
src/libs/CloudflareAccess/Config/index.ts 100.00% <100.00%> (+5.00%) ⬆️
src/libs/CloudflareAccess/OAuthClient.ts 100.00% <100.00%> (ø)
src/libs/CloudflareAccess/fetchWithQAAuth.ts 100.00% <ø> (ø)
...oudflareAccess/finishSignInFromURL/index.native.ts 100.00% <ø> (ø)
...libs/CloudflareAccess/finishSignInFromURL/index.ts 100.00% <100.00%> (+6.12%) ⬆️
src/libs/actions/CloudflareProbe.ts 95.45% <100.00%> (+2.12%) ⬆️
... and 7 more
... and 31 files with indirect coverage changes

@sharabai
sharabai force-pushed the qa-env/3-cloudflare-session branch from 44e9bca to bbeaf6a Compare September 9, 2026 18:05
@sharabai
sharabai force-pushed the qa-env/3-cloudflare-session branch from bbeaf6a to f10d067 Compare September 23, 2026 13:27
@sharabai

Copy link
Copy Markdown
Contributor Author

@codex review

@sharabai

Copy link
Copy Markdown
Contributor Author

@MelvinBot review

@sharabai

Copy link
Copy Markdown
Contributor Author

@MelvinBot find regressions

@MelvinBot

Copy link
Copy Markdown
Contributor

Review

The capture/exchange split looks correct, and CI is green. I found one behavior gap worth fixing before merge, plus two small items.

1. A failed boot-time code exchange is now invisible, and Run loops back to Cloudflare

When exchange-failed was removed, nothing records a rejected exchange for the UI. Here is the sequence:

  1. The callback boot lands on /settings/troubleshoot. The rows mount while the exchange is still in flight, so getFailedRedirectResult sees code-captured and seeds no status.
  2. The exchange rejects, for example because of a wrong client ID, a redirect URI mismatch, or invalid_grant. finishSignInFromURL only logs the error, and codeExchangePromise resets to null.
  3. The user presses Run probe. getPendingCloudflareCodeExchange() returns null and there is no session, so the probe redirects to Cloudflare again.

With Access SSO, this is a silent loop: the tab bounces straight back, and the failure appears only in the log. The description says a press "joins it and reports the failure." That holds only if the user presses Run within the exchange's round trip. A misconfigured .env is the case this tool exists to diagnose.

Suggested fix: Keep the last exchange error in CloudflareSession, or update the captured outcome in the .catch. Then have getFailedRedirectResult or the probe report signInFailed before it redirects.

2. Minor: signing out during a refresh now gives an HTTP 401 instead of reauthRequired

Before this PR, signing out bumped sessionGeneration. Now, if Onyx.clear runs while a refresh is in flight and the refresh fails with invalid_grant, sessionCache is null. The check at CloudflareSession.ts:174 then returns skipped-newer-token. fetchWithQAAuth retries without a bearer and returns the raw 401, so the probe shows HTTP 401. This case is rare. A !sessionCache check before the comparison would return reauth-required instead.

3. Nits

What I checked and found sound
  • Import order: index.js evaluates the capture import before ./src/App. The capture module pulls in only CONFIG, ObjectUtils, and the Cloudflare helpers, so the URL rewrite happens before navigation resolves a route.
  • Callback validation: Validation order is unchanged. The flow is consumed first, the URL is rewritten unconditionally, and state is checked before error and code. The same-origin return path is kept.
  • Allowlist: isQAAuthConfigured refuses a malformed secure root, and getQAResource stays single-valued for the authorize and exchange requests.
  • Refresh: refreshCloudflareSession(staleAccessToken) is now required, so every caller gets the rotation-race check.
  • Sign-out: Onyx.clear still drops CLOUDFLARE_SESSION, which KeysToPreserveTest covers.
  • CI: Every check passes except the approval gate.

view run

@melvin-bot

melvin-bot Bot commented Sep 29, 2026

Copy link
Copy Markdown

⚠️ MelvinBot didn't finish that one — the run was killed before I could reply (usually a Cloudflare CPU limit on a long investigation). Please retry with a more specific question, and if this keeps happening post in #melvinbot-ai.

@sharabai

Copy link
Copy Markdown
Contributor Author

Replying to this comment.

Fixed in 28ceff6, and the PR body now names the right file.

@sharabai sharabai changed the title [No QA] [HOLD for #100209] Split the QA auth callback into capture and exchange (part 3/5) [No QA] Split the QA auth callback into capture and exchange (part 3/5) Sep 30, 2026
@sharabai

Copy link
Copy Markdown
Contributor Author

@MelvinBot review

@MelvinBot

Copy link
Copy Markdown
Contributor

Re-review after 28ceff6

Items 2 and 3 from my last review are fixed. Item 1 is only half fixed, and the PR body no longer matches the code. CI is green apart from the approval and checklist gates.

1. The silent loop still happens when the rows are already mounted

getCloudflareCodeExchangeError() is read only by useState's initializer. That fixes a remount after the exchange has settled. It does not fix the case from the last review:

  1. The callback lands on /settings/troubleshoot, and the rows mount while the exchange is in flight. The seed is null.
  2. The exchange rejects. The error is stored, and codeExchangePromise resets.
  3. You press Run probe. runCloudflareAuthProbe finds no pending exchange and no session, so it redirects to Cloudflare without reporting the error.

Suggested fix: Have the probe read and clear the error after the pending-exchange check. It returns signInFailed once, and the next press starts a fresh round trip. clearCloudflareSession should reset the error too. Otherwise, after Clear session, the rows show the old failure again on remount. Add a CloudflareProbeTest case for "exchange rejected before Run".

2. The PR body contradicts the new code

  • The [!IMPORTANT] callout says the session-cleanup callback is removed and sign-out no longer cancels in-flight work. 28ceff6 restores that callback, so a late rotation or exchange is dropped again.
  • "Once a rejection has settled the QA rows no longer show it" is no longer true. The rows now seed from the stored error.
What I checked and found sound
  • Sign-out refresh: The generation bump now returns reauth-required for a refresh that fails after sign-out. The rewritten CloudflareSessionTest case covers it.
  • Import order: The early captureCloudflareAuthCallbackURL import still does not pull in CloudflareSession or SessionCleanup. The URL rewrite still runs before navigation.
  • Cleanup registration: The callback is registered only when QA auth is configured. runSessionCleanupCallbacks runs from cleanupSession on sign-out, so the cache clears without waiting for the async Onyx callback.
  • Nits: The fetchWithQAAuth doc comment and the capture file name in the body are fixed.

@sharabai

Copy link
Copy Markdown
Contributor Author

@MelvinBot review, the PR description has been changed too

@MelvinBot

Copy link
Copy Markdown
Contributor

Re-review after 7042e28

Both items from my last review are fixed. I found no new issues. The only failing checks are the approval and checklist gates, and validate-perf-tests is still pending.

  • Silent loop: Fixed. When the exchange fails after the rows mount, the probe now reports the stored error instead of redirecting. The next press, made after seeing signInFailed, starts a fresh round trip. Clear session resets the error. Both paths have new tests in CloudflareProbeTest and CloudflareSessionTest.
  • PR body: Fixed. The callout now matches the restored sign-out cleanup, and the tests match the code.

Optional wording nit: The body says "the next Run probe starts a fresh round trip." That is true when the rows already show the failure. If the exchange fails after the rows mount, the next press reports the failure, and the press after that redirects.

Other paths I traced
  • Joining an in-flight exchange: Run reports signInFailed, and the next press redirects, because it passes shouldRedirectOnSignInFailed.
  • Remount after a settled failure: The rows seed signInFailed, so the first press redirects.
  • Refused callback (test 2): This behaves as before. No exchange error is stored, so the flag doesn't change the outcome.
  • Another tab signs in: The session from Onyx comes first, so a stored error doesn't block the request.
  • Merge commits: 94d422b restored the .github/actions bundles, and no .github files remain in the PR diff.

@sharabai
sharabai marked this pull request as ready for review September 30, 2026 15:50
@sharabai
sharabai requested a review from a team as a code owner September 30, 2026 15:50
@huult

huult commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

@MelvinBot review this

@MelvinBot

Copy link
Copy Markdown
Contributor

Review of cc44434

The capture/exchange split, the origin allowlist, and the required refresh token all look correct. I found one small gap in the stored exchange error. The only failing checks are the approval gate and the reviewer checklist.

1. The stored exchange error can outlive a Clear session or a sign-out

codeExchangeErrorMessage follows different rules from the rest of the module:

  • The .catch that stores it does not check sessionGeneration. The module's own rule says every late result must check it. If you press Clear session during the boot exchange and the exchange then rejects, the error is stored after the clear.
  • The sign-out cleanup does not reset it, but clearCloudflareSession does. Its comment says it works "exactly like on sign-out." Here is an example. The boot exchange fails, another tab signs in, and then you sign out. Run probe then reports the old failure instead of redirecting.

In both cases, you see a stale failure and need one extra press. Nothing breaks.

Suggested fix: Store the error only when generation === sessionGeneration. Reset it in the sign-out cleanup callback. Add a CloudflareSessionTest case for each.

What I checked and found sound
  • Import order: On web, index.js evaluates the capture import before ./src/App. The capture module pulls in only CONFIG, CONST, ObjectUtils, and the Cloudflare helpers, so it rewrites the URL before any route resolves. On native the import runs a stub.
  • Callback validation: The flow record is consumed first and the URL is always rewritten. Then state is checked before error and code, and the return path must be same-origin.
  • Allowlist: isQAServerRequest matches exact origins only. A non-HTTPS QA_SECURE_EXPENSIFY_URL disables the feature, and normalizeOptionalRoot keeps an empty value empty. getQAResource stays single-valued for authorize, exchange, and metadata discovery.
  • Refresh: Both callers, fetchWithQAAuth and the probe, pass the token. So the rotation-race check always runs.
  • Probe: Joining an in-flight exchange, a failure after the rows mount, and a failure after a remount each give one signInFailed. The next press redirects. A blank CHECK_PATH returns an error instead of POSTing to the API root.
  • Sign-out: The generation bump and synchronous cache reset are still in place. Onyx.clear drops CLOUDFLARE_SESSION, which KeysToPreserveTest covers.

view run

return {outcome: 'no-pending-flow', errorMessage: 'No pending QA auth flow in this tab. Start the sign-in again'};
}

if (params.get('state') !== flow.state) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Screen.Recording.2026-10-02.at.12.49.22.mov

@sharabai After my first successful login, I cleared the session. Then I ran the probe once more and clicked Allow, and this time it showed OAuth callback state mismatch, as in the video above. I think the problem is in how the flow state is handled in storage. Can you help me investigate this?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@sharabai what do you think?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@huult I'll get back to you later today.

@sharabai

sharabai commented Oct 5, 2026

Copy link
Copy Markdown
Contributor Author

Replying to this comment.

Fixed in 3a8fb2d.

@sharabai

sharabai commented Oct 5, 2026

Copy link
Copy Markdown
Contributor Author

@sharabai what do you think?

@huult, great find, thanks for digging into this! Going through your steps as written, I couldn't get it to happen, but with some less typical back-and-forth between the App and Cloudflare I did hit the same error. Your find also pointed me at a few things in this PR worth improving, and I'm working on them now.

@huult

huult commented Oct 6, 2026

Copy link
Copy Markdown
Contributor
Screen.Recording.2026-10-06.at.11.05.40.mov

@sharabai You can see in my video that I’m still able to reproduce it on my side.

@huult

huult commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

Tag me when it's ready for another look.

@sharabai

sharabai commented Oct 8, 2026

Copy link
Copy Markdown
Contributor Author

@huult, ready for another look. Fixed in 5cd3b91: the pending sign-in is now kept in localStorage under its own state, so the callback can no longer read an older one. Could you run your steps again? Btw, great job!

@huult

huult commented Oct 8, 2026

Copy link
Copy Markdown
Contributor
Screen.Recording.2026-10-08.at.20.21.03.mov

It works.

@huult

huult commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

@sharabai Could you check the failed Jest tests?

@sharabai

sharabai commented Oct 8, 2026

Copy link
Copy Markdown
Contributor Author

@sharabai Could you check the failed Jest tests?

@huult I don't think it's this PR's fault, just some flaky tests I guess. I merged main, we'll see if it'll help.

@huult

huult commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

Yeah, this test is failing because of main. Please sync with main to fix it.

@sharabai
sharabai force-pushed the qa-env/3-cloudflare-session branch from 8bf1b32 to d8295db Compare October 8, 2026 14:15
@sharabai

sharabai commented Oct 8, 2026

Copy link
Copy Markdown
Contributor Author

@huult luck's on our side this time. It passed 🎊

@huult huult left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM.

@melvin-bot
melvin-bot Bot requested a review from iwiznia October 8, 2026 15:14
@sharabai

sharabai commented Oct 8, 2026

Copy link
Copy Markdown
Contributor Author

@huult a quick update on the OAuth callback state mismatch you found: it's caused by a Chrome bug. Some Chrome profiles are enrolled in an experiment (TransientKeepAlivePolicy) that can reuse an old renderer process when a tab comes back to a site, and that process hands the page a stale copy of sessionStorage. So after the Cloudflare round trip, the app read the state from the previous sign-in instead of the current one. It only happens in enrolled profiles, which is why it was so hard to reproduce.

I reported it to Chromium: https://issues.chromium.org/issues/571157144

The fix here doesn't wait on Chrome: the pending sign-in now lives in localStorage under its own state, and localStorage isn't affected. Thanks again for catching it!

@iwiznia
iwiznia merged commit 9e1189a into Expensify:main Oct 8, 2026
35 of 37 checks passed
@github-actions

github-actions Bot commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

🚧 iwiznia has triggered a test Expensify/App build. You can view the workflow run here.

@OSBotify

OSBotify commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

✋ This PR was not deployed to staging yet because QA is ongoing. It will be automatically deployed to staging after the next production release.

@OSBotify OSBotify mentioned this pull request Oct 8, 2026
90 tasks done
@OSBotify

OSBotify commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

🚀 Deployed to staging by https://github.com/iwiznia in version: 9.5.6-0 🚀

platform result
🕸 web 🕸 success ✅
🤖 android 🤖 success ✅
🍎 iOS 🍎 success ✅

@OSBotify

OSBotify commented Oct 9, 2026

Copy link
Copy Markdown
Contributor

🚀 Deployed to production by https://github.com/puneetlath in version: 9.5.6-6 🚀

platform result
🕸 web 🕸 success ✅
🤖 android 🤖 success ✅
🍎 iOS 🍎 success ✅

Bundle Size Analysis (Sentry):

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.

6 participants