Skip to content

fix: restore the Studio session without leaving the page when Studio requests fail - #3290

Merged
bradenmacdonald merged 5 commits into
openedx:masterfrom
blarghmatey:tmacey/studio-401-login-redirect
Oct 27, 2026
Merged

bradenmacdonald merged 5 commits into
openedx:masterfrom
blarghmatey:tmacey/studio-401-login-redirect

Conversation

@blarghmatey

@blarghmatey blarghmatey commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Description

Many endpoints this app calls are authenticated by the Studio session and not by the JWT cookies (e.g. /xblock/outline/<usage key>, /course_info_update/, /assets/). The Studio session can expire, or not exist yet when the user signed in through the LMS and came straight to this app, while the user is still signed in to the LMS. Today Studio redirects those requests to its login. On the same origin, each one starts an OAuth login of its own and they break each other; across origins, the browser blocks the redirect at the LMS and the request fails with no response. Either way the page shows a load or save error.

This adds a response interceptor on the authenticated HTTP client that gets the Studio session back:

  • A Studio request that fails with a 401, or with no response from one of Studio's older views, triggers a session check (GET /xblock/ through axios's fetch adapter with redirect: 'manual').
  • With no session, Studio requests are held. Requests already sent get up to 5s to finish, then a no-cors request to Studio's /login/ signs in through the LMS without leaving the page, and the held requests are sent again.
  • If the user is signed out of the LMS too, a dialog asks them to sign in. Sign in opens the Studio login in a new tab. When they come back to this tab, the page checks for a session and sends the held requests, so a save in progress goes through. Cancel fails the held requests without sending them, so the page keeps its unsaved changes and shows its usual errors.
  • React Query does not retry a query that failed for lack of a Studio session.

The background sign-in is ported from edx#126 (2U, running in their production on release-ulmo), credited in the commit. The dialog replaces the full-page redirect that both versions had.

This impacts Course Authors. It works with Studio as it is today. openedx/openedx-platform#39296 makes Studio answer 401 instead of redirecting when the client is not asking for HTML, which this handles too.

Supporting information

  • CORS errors when entering directly MFE host through the browser and not by redirections #1080 is the cross-origin form of this failure.
  • On one production instance, a course outline opened with an expired Studio session sent 136 /xblock/outline/ requests within a minute. Studio redirected each one into its own OAuth login against the same session. social-core keeps the OAuth state in a single session key, so the logins overwrote each other: 7 completed and the rest failed in validate_state with AuthStateForbidden or AuthStateMissing, which Studio served as 262 HTTP 500 responses in that minute.

Testing instructions

Use a devstack with production-like CORS so the cross-origin failure shows. In lms/envs/private.py and cms/envs/private.py set CORS_ORIGIN_ALLOW_ALL = False and CORS_ORIGIN_REGEX_WHITELIST = [r'^http://localhost:\d+$'].

  1. In a fresh browser, sign in through the MFE and open Course Updates. Updates load, the network tab shows one no-cors request to <studio>/login/?next=/xblock/, and the page stays put.
  2. Delete the studio_sessionid cookie and post a course update. It saves.
  3. Log out of the LMS in another tab and post an update. The "Sign in to Studio again" dialog opens. Select Sign in, sign in in the new tab (it ends on Studio home), and go back to the first tab. The dialog closes and the update is saved.
  4. Repeat step 3 but select Cancel. The save error shows and the update text is still in the editor. Post the update again: the dialog opens again.

npx jest src/generic/studio-login-redirect: 51 tests, covering every line of index.ts and StudioLoginModal.tsx. npm run lint and npm run types pass. I have not run steps 1-4 against a running Studio; 2U ran steps 1-2 with their version (results in edx#126).

Other information

  • The dialog keeps Studio requests held for as long as it is open. That is what lets the pending save go through after sign-in. Only its Cancel button closes it, and Cancel is disabled while it checks for the session.
  • For 30s after Cancel, only a save opens the dialog again. Reads that fail for lack of a session fail without it, so pages that poll Studio (export every 3s, the course optimizer every 2s) do not reopen it right away.
  • The login tab ends on Studio home rather than this page, so the user does not get a second copy of the page where an edit could be overwritten by the save held in the first one.
  • Signing in gives Studio a new CSRF token, so the interceptor clears frontend-platform's CSRF token cache with getCsrfTokenService(), which frontend-platform documents as mainly for testing. Without it, every save after a sign-in is refused with 403.
  • The session check uses the legacy GET /xblock/, which answers 400 with a session and redirects (or, with the platform change, answers 401) without one.
  • Content inside iframes, like the unit page, is not covered. Studio pages handle their own login.

Best Practices Checklist

  • Any new files are using TypeScript (.ts, .tsx).
  • Avoid propTypes and defaultProps in any new or modified code.
  • Tests should use the helpers in src/testUtils.tsx (specifically initializeMocks)
  • Do not add new fields to the Redux state/store. Use React Context to share state among multiple components.
  • Use React Query to load data from REST APIs. See any apiHooks.ts in this repo for examples.
  • All new i18n messages in messages.ts files have a description for translators to use.
  • Avoid using ../ in import paths. To import from parent folders, use @src, e.g. import { initializeMocks } from '@src/testUtils'; instead of from '../../../../testUtils'

https://claude.ai/code/session_01JxJWMHVXsuXaChbeEpKT8t

@openedx-webhooks openedx-webhooks added the open-source-contribution PR author is not from Axim or 2U label Oct 6, 2026
@openedx-webhooks

Copy link
Copy Markdown

Thanks for the pull request, @blarghmatey!

This repository is currently maintained by @bradenmacdonald.

Once you've gone through the following steps feel free to tag them in a comment and let them know that your changes are ready for engineering review.

🔘 Get product approval

If you haven't already, check this list to see if your contribution needs to go through the product review process.

  • If it does, you'll need to submit a product proposal for your contribution, and have it reviewed by the Product Working Group.
    • This process (including the steps you'll need to take) is documented here.
  • If it doesn't, simply proceed with the next step.
🔘 Provide context

To help your reviewers and other members of the community understand the purpose and larger context of your changes, feel free to add as much of the following information to the PR description as you can:

  • Dependencies

    This PR must be merged before / after / at the same time as ...

  • Blockers

    This PR is waiting for OEP-1234 to be accepted.

  • Timeline information

    This PR must be merged by XX date because ...

  • Partner information

    This is for a course on edx.org.

  • Supporting documentation
  • Relevant Open edX discussion forum threads
🔘 Get a green build

If one or more checks are failing, continue working on your changes until this is no longer the case and your build turns green.

Details
Where can I find more information?

If you'd like to get more details on all aspects of the review process for open source pull requests (OSPRs), check out the following resources:

When can I expect my changes to be merged?

Our goal is to get community contributions seen and reviewed as efficiently as possible.

However, the amount of time that it takes to review and merge a PR can vary significantly based on factors such as:

  • The size and impact of the changes that it introduces
  • The need for product review
  • Maintenance status of the parent repository

💡 As a result it may take up to several weeks or months to complete a review and merge your PR.

@codecov

codecov Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 96.22%. Comparing base (08c1331) to head (997f5f9).

Additional details and impacted files
@@            Coverage Diff             @@
##           master    #3290      +/-   ##
==========================================
+ Coverage   96.20%   96.22%   +0.02%     
==========================================
  Files        1446     1449       +3     
  Lines       35391    35613     +222     
  Branches     8468     8528      +60     
==========================================
+ Hits        34048    34270     +222     
- Misses       1285     1301      +16     
+ Partials       58       42      -16     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@bradenmacdonald

Copy link
Copy Markdown
Contributor

@blarghmatey Can you please link to the corresponding platform PR?

Ideally, we'd convert all these endpoints to JWT authentication. Such work is ongoing but there's a lot of legacy endpoints and I think it's still worth merging this PR in the meantime. There are also a few things like course export downloads, taxonomy export downloads, and library export downloads which send requests to the backend as regular browser navigation, without auth headers.

One blocking concern though: a 401 on a save, such as PUT /xblock/<id> from the editors or custom pages, sends the browser away immediately. The user loses the edit they were trying to save. For mutations, or maybe for everything, showing a "Your Studio session expired — sign in again" modal or toast would be safer than redirecting.

Also, content within iframes, like the unit page content, won't be intercepted. I guess this is fine.

abhalsod-sonata added a commit to edx/frontend-app-authoring that referenced this pull request Oct 16, 2026
)

* fix: restore the Studio session without leaving the page (LP-1313)

Several Studio views that this MFE calls (course updates, handouts, the
xblock and outline handlers, assets, certificates, ...) need the Studio
session and ignore the JWT cookie. A user who is signed in to the LMS
but not to Studio gets those requests redirected to Studio's login. The
browser blocks that cross-origin redirect at the LMS, the request fails
with no response, and the page shows "Failed to load course updates" or
"Unable to save changes".

Add a response interceptor on the authenticated HTTP client, based on
openedx#3290:

- A Studio request that fails with a 401 or with no response is held
  back when a check shows there is no Studio session.
- A no-cors request to Studio's /login/ follows the OAuth redirects
  through the LMS and signs the user in to Studio without leaving the
  page. The held requests are then sent again, so the page gets its
  data and saves go through. The cached CSRF token is cleared, as
  Studio replaces it at login.
- If the user is signed out of the LMS too, a page that was loading
  data goes to Studio's login and comes back. A page with a save in
  progress asks first ("Sign in to Studio again"), so the change is
  not lost without warning.
- Studio requests made meanwhile are held, so they cannot break the
  login in progress, and React Query does not retry these failures.

* fix: harden the Studio session recovery after review (LP-1313)

- Count Studio saves from when they are sent until they are answered.
  A page with one asks before going to the login, even when only a
  read has failed so far.
- Treat a 401 from the session check as no session.
- Give the session check and the background sign-in a 10 s deadline.
  When Studio does not answer, the held requests go on as before, with
  no sign-in, redirect or dialog.
- Cancel in the dialog fails the requests that waited to be sent,
  without sending them. The hold timeout after a redirect still sends
  them.
- Stamp each Studio request with the number of background sign-ins
  done when it was sent. A failure from before the last sign-in is
  sent again once if it is a read, and only on a 401 if it is a save.

* fix: drop the sign-in dialog and use the HTTP client for the Studio session (LP-1313)

- When the user is signed out of the LMS too, the page goes to the
  Studio login and comes back, for reads and saves alike. The "Sign in
  to Studio again" dialog, its messages and its tests are removed, and
  so is the count of saves in progress, which only chose between the
  dialog and the redirect. If the last login did not restore the
  session, the held requests fail as before instead of going to the
  login again.
- The session check and the background sign-in go through
  frontend-platform's getHttpClient() with axios's fetch adapter and
  fetchOptions (redirect: 'manual', mode: 'no-cors') instead of calling
  fetch directly. axios's timeout replaces the AbortController helper.
- Tests give the plain client's fetch adapter their fetch mock through
  defaults.env.fetch, as axios finds no fetch in jsdom when it loads.
  New tests cover the client options, a failed save going to the
  login, the loop guard, isStudioUnauthorizedError and
  redirectToStudioLogin.

* fix: send a Studio request again only if it was refused (LP-1313)

A request that got no response now counts as refused for lack of a
Studio session only on Studio's older views, which redirect to the
login before doing anything. Studio's REST APIs (under /api/) answer
401 instead, so one of their requests that got no response may have
been carried out. It now goes to the page as before, and is not held
or sent again after the background sign-in.

* fix: wait for Studio requests already sent before the background sign-in (LP-1313)

A Studio request sent before the background sign-in can still follow
Studio's login redirect while the sign-in runs. Each one that reaches
the login starts a login of its own and replaces the OAuth state that
the sign-in needs, so the sign-in could fail and send a user who is
still signed in to the LMS to the login page.

The interceptor now counts requests to Studio's older views from when
they are sent until they are answered, and the sign-in waits for them,
for at most WAIT_FOR_SENT_REQUESTS_MS (5 s). A request stops counting
when its answer arrives, before it is held, so held requests do not
keep the sign-in waiting.

Tests cover a redirect that overlaps the sign-in, the 5 s fallback, and
REST API requests that get no response before and during the sign-in.
@abhalsod-sonata

Copy link
Copy Markdown
Contributor

Hi @bradenmacdonald, @blarghmatey

At 2U we hit the same problem (#1080) on our fork and fixed it in the MFE, building on this PR: edx#126. It is merged on our release-ulmo branch and running in our production. Rather than open a competing PR, we would like to ask how you would prefer to bring it upstream.

It keeps this PR's module (src/generic/studio-login-redirect/) and adds:

  • It works without the platform change. Today Studio answers these requests with a redirect to its login, and the browser turns that into a CORS network error, so no 401 reaches the app. When a request to one of these views fails that way, the MFE checks the Studio session with GET /xblock/ and redirect: 'manual', through the HTTP client's fetch adapter: a redirect means there is no session. A Studio 401 counts too, so it keeps working once the platform change lands.
  • It does not leave the page while the LMS session is valid. It signs in to Studio in the background with a no-cors request to Studio's /login/ (the same SSO that a unit page's container_embed iframe runs), then sends the held requests again. A save is sent again instead of being lost, which covers the blocking concern above in the common case.
  • One login at a time. Studio requests are held during that sign-in, and it first waits up to 5 s for requests already sent, so none of them replaces its OAuth state (the AuthStateForbidden / AuthStateMissing errors in the description).
  • Fallback. If the user is signed out of the LMS too, it goes to the login once and comes back, as this PR does: 60 s loop guard, and the MFE host must be in SOCIAL_AUTH_EDX_OAUTH2_ALLOWED_REDIRECT_HOSTS. A pending save is lost in that case.

How would you like to proceed?

  1. @blarghmatey takes these changes into this PR.
  2. We open a PR against master that builds on this one, with credit, and replaces it.
  3. This PR merges first, and we follow up with the background sign-in as a separate PR.

Whichever you prefer, we are happy to port it to master along with its tests.

@bradenmacdonald

Copy link
Copy Markdown
Contributor

I think we want "all of the above" (fix the platform to return 401 for non-HTML responses, fix Authoring MFE to handle the error whether or not studio returns 401, auto-sign in to Studio using the LMS creds if possible, direct user to sign in to Studio if not). But I do think we should add a modal popup to prompt the user rather than just redirecting them and potentially losing their work.

As to the order of the PRs or which incorporates which, or merging it as several different PRs, it's up to you two.

@mphilbrick211 mphilbrick211 moved this from Needs Triage to In Eng Review in Contributions Oct 22, 2026
blarghmatey and others added 3 commits October 22, 2026 19:26
Many endpoints this app calls are authenticated by the Studio session and
not by the JWT cookies, so the Studio session can expire while the user is
still signed in to the LMS. Nothing in this app handles a 401 from Studio.

This adds a response interceptor that sends the browser to Studio's
/login/ with `next` set to the current page when Studio answers 401. One
page fails many requests together, so they share a single navigation, and
a sessionStorage timestamp stops a login that did not restore the session
from being retried in a loop. React Query no longer retries those 401s.

Claude-Session: https://claude.ai/code/session_01Rr6xsQQbrmFiSgQWMHJSA2
… login

Today Studio answers a request without a Studio session by redirecting
it to its login. Cross-origin, the browser blocks that redirect at the
LMS, so the request fails with no response and the 401 handling never
runs. Going to the login also loses a save in progress, even when the
user is still signed in to the LMS.

This ports the recovery from edx#126, which runs
in 2U's production. A Studio request that fails with a 401, or with no
response from one of Studio's older views, triggers a session check
(GET /xblock/ through the fetch adapter with redirect: 'manual'). With
no session, Studio requests are held, the requests already sent are
given up to 5s to finish, and a no-cors GET to Studio's /login/ signs in
through the LMS without leaving the page. The held requests are then
sent again. Only when the LMS session is gone too does the page go to
the login, as before.

The code is 2U's, with oxlint and dprint fixes for master.

Co-authored-by: Aditya Bhalsod <abhalsod-sonata@2u.com>
Claude-Session: https://claude.ai/code/session_01JxJWMHVXsuXaChbeEpKT8t
…g the page

When the background sign-in finds that the user is signed out of the LMS
too, the page went to the Studio login, and a save in progress was lost
on the way.

This opens a dialog instead, and Studio requests stay held while it is
open. Sign in opens the Studio login in a new tab. When this page is
shown or focused again, it checks for a Studio session (running the
background sign-in if the user only got as far as the LMS) and sends the
held requests, so a pending save goes through. Continue does the same
by hand. Cancel fails the held requests without sending them, so the
page keeps its unsaved changes and shows its usual errors.

Only the Cancel button closes the dialog (an Escape or a click outside
would drop a pending save), and it is disabled during the check, so a
check cannot outlive the dialog. Cancel also clears the CSRF token
cache: the user may have signed in from the other tab anyway, which
rotates Studio's CSRF token, and every later save would get a 403. With
no automatic navigation left, the redirect loop guard and the hold timer
go too.

Claude-Session: https://claude.ai/code/session_01JxJWMHVXsuXaChbeEpKT8t
@blarghmatey
blarghmatey force-pushed the tmacey/studio-401-login-redirect branch from af45439 to 9881999 Compare October 22, 2026 23:53
@blarghmatey blarghmatey changed the title fix: go to the Studio login once when Studio answers 401 fix: restore the Studio session without leaving the page when Studio requests fail Oct 22, 2026
@blarghmatey

Copy link
Copy Markdown
Contributor Author

@bradenmacdonald I took 2U's changes into this PR (edx#126, credited in e3f71b7) and replaced the redirect with a dialog (9881999). The page first tries to sign in to Studio in the background. If the user is signed out of the LMS too, the dialog's Sign in opens the login in a new tab while Studio requests stay held, and when the user comes back the held save is sent, so the edit is not lost. Cancel keeps the page with its unsaved changes. The platform change is openedx/openedx-platform#39296. Content inside iframes is still not covered.

@blarghmatey

Copy link
Copy Markdown
Contributor Author

@abhalsod-sonata I went with option 1: your recovery from edx#126 is in this PR as e3f71b7, with you as co-author and only oxlint/dprint fixes for master. The one change on top (9881999) is the signed-out-of-the-LMS case, which now opens a dialog that signs in in a new tab and then sends the held requests, instead of the redirect. Your review would help, especially from what you have seen in production.

@abhalsod-sonata

Copy link
Copy Markdown
Contributor

Thanks @blarghmatey for taking this in, and @bradenmacdonald for the direction. Option 1 works for us.

I checked e3f71b7 against edx#126 and it is the same code, only reformatted. The dialog in 9881999 looks good to me and solves the lost-save problem. Our version (#126) has been running in production since Oct 16.

Two small suggestions, not blocking:

  1. After the user clicks Cancel, the dialog can open again right away if the page keeps calling Studio (for example, the export page checks its status every 3 seconds). A short pause before showing it again would help.
  2. The Sign in link opens the same page in the new tab, so the user ends up with two copies of it. If they edit in the new tab, the save still waiting in the first tab can overwrite that edit later. Sending the new tab to Studio home instead would avoid this.

Thanks again!

The dialog's Sign in link came back to the page that opened it, so the user
ended up with two copies of the page. An edit made in the new tab could then
be overwritten by the save still held in the first one. Without a `next`,
Studio's login goes to its LOGIN_REDIRECT_URL (Studio home) instead.

Claude-Session: https://claude.ai/code/session_01JxJWMHVXsuXaChbeEpKT8t
Pages that poll Studio (e.g. the export page every 3s, the course optimizer
every 2s) opened the dialog again right after the user cancelled it. For 30s
after Cancel, a read that fails for lack of a Studio session now fails
without a background sign-in or the dialog. A save still opens it, as that
is the user asking for it.

Claude-Session: https://claude.ai/code/session_01JxJWMHVXsuXaChbeEpKT8t
@blarghmatey

Copy link
Copy Markdown
Contributor Author

@abhalsod-sonata both are in. 49021a2 sends the Sign in tab to Studio home: without a next, Studio's login falls back to its LOGIN_REDIRECT_URL. 997f5f9 stops reads from reopening the dialog for 30s after Cancel, so the export (3s) and course optimizer (2s) polling no longer bring it straight back. A save still opens it, since that is the user asking to save.

@bradenmacdonald bradenmacdonald 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.

It's a lot of new code for something as basic as authentication, but the current situation is quite messy and the resulting user experience is a million times better with this PR, so I think it's well worth merging. I tried it out as suggested and it worked really nicely in each situation.

I do hope that the API Revamp Project and perhaps a better API client in frontend-base or as part of that revamp will reduce the need for this sort of thing.

@bradenmacdonald
bradenmacdonald merged commit 9793e5b into openedx:master Oct 27, 2026
7 checks passed
@github-project-automation github-project-automation Bot moved this from In Eng Review to Done in Contributions Oct 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

open-source-contribution PR author is not from Axim or 2U

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

5 participants