Skip to content

🐛 Fixed expired sessions leaving Admin stuck instead of returning to signin#29328

Merged
9larsons merged 5 commits into
mainfrom
slars/framework-401-session-expiry
Jul 15, 2026
Merged

🐛 Fixed expired sessions leaving Admin stuck instead of returning to signin#29328
9larsons merged 5 commits into
mainfrom
slars/framework-401-session-expiry

Conversation

@9larsons

@9larsons 9larsons commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

ref https://linear.app/ghost/issue/PLA-188/

The React Admin fetch layer (admin-x-framework) did not handle expired sessions. Core returns 403 + Authorization failed when a cookie session is no longer valid, so requests fell through as ValidationError, displayed a generic toast, and left the page stuck. Ember's Ajax service already treats that response, as well as 401 responses, as an invalid session and returns to sign-in.

What this does

  • handle-response.ts classifies both 401 responses and the specific 403 + Authorization failed Admin API response as UnauthorizedError.
  • fetch-api.ts converts those errors to SessionExpiredError for Ghost API requests and, from authenticated Admin routes, performs a full-page window.location.replace to the Admin root. Admin then revalidates through users/me and lands on sign-in.
  • Redirect handling is scoped to URLs containing /ghost/api/. Session endpoints such as /session/ and /session/verify/ are excluded so failed sign-in and verification requests do not redirect, and external/Tinybird requests cannot log the user out.
  • Bare Admin root and unauthenticated routes (#/setup, #/signin, #/signup, and #/reset) still classify the response as SessionExpiredError for toast/Sentry suppression, but do not replace the page. This lets Ember finish booting the authentication UI without a reload loop.
  • A module-level guard ensures concurrent expired requests redirect only once.
  • useHandleError suppresses toast and Sentry reporting specifically for SessionExpiredError, avoiding noise while the page unloads or authentication UI boots.
  • Other 403 permission failures remain ValidationError and keep their existing behavior.

This works for all framework consumers (Admin, Admin X Settings, and ActivityPub) without relying on the Ember bridge, which exposes no session-invalidation method.

Latent bug fixed en route

fetch-api.ts returned handleResponse(response) without awaiting it, so response-derived errors bypassed its retry and error-handling catch. Awaiting it makes the existing MaintenanceError retry path functional and allows session errors to be classified centrally. Two incomplete Response fakes in test/utils/mock-fetch.ts were completed as fallout.

Behavioral changes / reviewer callouts

  • Expired sessions now reload Admin and return to sign-in for Core's normal 403 + Authorization failed response when encountered on an authenticated route.
  • Expected authorization failures while the bare Admin root or an unauthenticated route is booting do not trigger another navigation.
  • Ghost API 401 responses also use the same handling, including inactive or suspended-user cases.
  • All 401 responses are now typed as UnauthorizedError, including excluded session endpoints and external requests; those requests do not redirect.
  • The exact matching 403 response is also typed as UnauthorizedError; unrelated 403 responses are unchanged.
  • Awaiting handleResponse makes the pre-existing 503 retry behavior live. With retries enabled in production, this applies to mutations as well as reads, so a mutation returning 503 may be submitted again for up to 15 seconds.

Verification

  • MSW-backed session-expiry coverage for the real Core 403 payload, retained 401 behavior, redirect-once behavior, unauthenticated Admin routes, session-endpoint exclusion, non-Ghost-API exclusion, and unrelated 403 permission errors.
  • admin-x-framework: 32 test files / 455 tests passing.
  • Type-check, lint, and production build passing.
  • Full-system E2E failures identified the unauthenticated bootstrap reload loop; the pushed fix is awaiting the fresh CI E2E run.

@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

The API response handler now throws UnauthorizedError for HTTP 401 responses and authorization-failed 403 responses. The fetch API awaits response handling, redirects once to the Ghost admin root for eligible expired sessions, and excludes session and external endpoints. Session-expiry errors are excluded from Sentry and clear existing toasts without displaying new ones. Tests cover error classification, redirect behavior, toast handling, and updated fetch mocks.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main change: expired sessions now recover by returning Admin to sign-in instead of leaving it stuck.
Description check ✅ Passed The description matches the changeset and explains the session-expiry handling, redirects, exclusions, and retry behavior.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch slars/framework-401-session-expiry

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@nx-cloud

nx-cloud Bot commented Jul 14, 2026

Copy link
Copy Markdown

🤖 Nx Cloud AI Fix

Ensure the fix-ci command is configured to always run in your CI pipeline to get automatic fixes in future runs. For more information, please see https://nx.dev/ci/features/self-healing-ci


View your CI Pipeline Execution ↗ for commit 220221d

Command Status Duration Result
nx run @tryghost/admin-x-settings:test:acceptance ✅ Succeeded 9m 7s View ↗
nx run-many -t test:unit -p @tryghost/admin-x-f... ✅ Succeeded 4m 52s View ↗
nx run ghost-admin:test ✅ Succeeded 3m 1s View ↗
nx run @tryghost/admin:build ✅ Succeeded 2m 45s View ↗
nx run ghost-monorepo:lint:boundaries ✅ Succeeded 19s View ↗
nx run-many -t lint -p @tryghost/admin-x-framew... ✅ Succeeded 1m 35s View ↗
nx run @tryghost/admin:test:acceptance ✅ Succeeded 1m 28s View ↗
nx run @tryghost/activitypub:test:acceptance ✅ Succeeded 50s View ↗
nx run-many --target=build --projects=tag:publi... ✅ Succeeded <1s View ↗

💡 Verify your cache is correct by running tasks in a sandbox. Read docs ↗


☁️ Nx Cloud last updated this comment at 2026-07-15 17:17:01 UTC

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 1

🧹 Nitpick comments (1)
apps/admin-x-framework/src/hooks/use-handle-error.ts (1)

54-57: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider excluding session expirations from Sentry logging.

Session expiry is a normal and expected user event. Since UnauthorizedError falls under APIError, it will still be caught by the Sentry logging block higher up in this function. Logging it will likely create unnecessary noise in your error tracker.

Consider wrapping the Sentry capture logic to exclude unauthorized errors:

// Around line 41:
if (!(error instanceof UnauthorizedError)) {
    Sentry.captureException(error);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/admin-x-framework/src/hooks/use-handle-error.ts` around lines 54 - 57,
Update the Sentry capture logic in the error-handling function to skip errors
that are instances of UnauthorizedError, while continuing to capture all other
errors. Keep the existing unauthorized-error toast removal and redirect behavior
unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/admin-x-framework/src/utils/api/fetch-api.ts`:
- Around line 53-70: Update SESSION_API_REQUEST so its boundary matcher accepts
a slash, query string, fragment, or end of URL after the session path, such as
using ([/?#]|$). Keep redirectOnSessionExpiry unchanged so session requests with
query parameters or fragments are excluded from the expiry redirect.

---

Nitpick comments:
In `@apps/admin-x-framework/src/hooks/use-handle-error.ts`:
- Around line 54-57: Update the Sentry capture logic in the error-handling
function to skip errors that are instances of UnauthorizedError, while
continuing to capture all other errors. Keep the existing unauthorized-error
toast removal and redirect behavior unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 7f833ee4-33f3-494d-90ae-c7e1d9eb5149

📥 Commits

Reviewing files that changed from the base of the PR and between b64c0cb and e23dc21.

📒 Files selected for processing (7)
  • apps/admin-x-framework/src/hooks/use-handle-error.ts
  • apps/admin-x-framework/src/utils/api/fetch-api.ts
  • apps/admin-x-framework/src/utils/api/handle-response.ts
  • apps/admin-x-framework/src/utils/errors.ts
  • apps/admin-x-framework/test/unit/hooks/use-handle-error.test.tsx
  • apps/admin-x-framework/test/unit/utils/api/session-expiry.test.tsx
  • apps/admin-x-framework/test/utils/mock-fetch.ts

Comment thread apps/admin-x-framework/src/utils/api/fetch-api.ts
@codecov

codecov Bot commented Jul 14, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 74.03%. Comparing base (faa61da) to head (948f268).
⚠️ Report is 35 commits behind head on main.

Additional details and impacted files
@@           Coverage Diff           @@
##             main   #29328   +/-   ##
=======================================
  Coverage   74.03%   74.03%           
=======================================
  Files        1577     1577           
  Lines      136821   136818    -3     
  Branches    16574    16574           
=======================================
- Hits       101299   101298    -1     
+ Misses      34516    34514    -2     
  Partials     1006     1006           
Flag Coverage Δ
admin-tests 55.32% <ø> (+<0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ 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.

9larsons added 3 commits July 15, 2026 11:16
…signin

ref https://linear.app/ghost/issue/PLA-188/add-401-session-expiry-handling-to-the-admin-x-framework-fetch-layer

- the React admin fetch layer had no 401 handling, so an expired session
  surfaced as a generic error toast and left the page dead
- a 401 on an Admin API request (excluding the session endpoint, so
  failed signins are unaffected) now triggers a single full-page replace
  to the admin root - the same mechanism Ember's ajax service uses -
  which reboots Admin unauthenticated and lands on signin; this works
  for every framework consumer without depending on the Ember bridge
- handleResponse was returned un-awaited, so response errors skipped the
  fetch layer's catch entirely; awaiting it also makes the documented
  MaintenanceError (503) retry reachable
ref https://linear.app/ghost/issue/PLA-188/

Only redirected Admin API 401s should be hidden; other unauthorized responses still need visible error feedback. Session URL query and fragment boundaries must also remain inside the authentication flow.
ref https://linear.app/ghost/issue/PLA-188/

- Core returns 403 Authorization failed when an authenticated session expires
- only that exact forbidden response should trigger the existing signin redirect
@9larsons
9larsons force-pushed the slars/framework-401-session-expiry branch from 948f268 to 061dae5 Compare July 15, 2026 16:18
@9larsons
9larsons enabled auto-merge (squash) July 15, 2026 16:24
@github-actions

Copy link
Copy Markdown
Contributor

E2E Tests Failed

To view the Playwright test report locally, run:

REPORT_DIR=$(mktemp -d) && gh run download 29431898400 -n playwright-report -D "$REPORT_DIR" && npx playwright show-report "$REPORT_DIR"

ref https://linear.app/ghost/issue/PLA-188/

- unauthenticated Admin routes legitimately receive authorization failures while booting
- keep session-expiry suppression without replacing a page that is already returning to sign-in
@github-actions

Copy link
Copy Markdown
Contributor

E2E Tests Failed

To view the Playwright test report locally, run:

REPORT_DIR=$(mktemp -d) && gh run download 29433506363 -n playwright-report -D "$REPORT_DIR" && npx playwright show-report "$REPORT_DIR"

ref https://linear.app/ghost/issue/PLA-188/

- 401 bodies are parsed when JSON so consumers reading error.data keep the API's structured errors instead of an unparsed string
- #/reset/:token is an unauthenticated Ember route, so it now skips the expiry redirect like signin/signup/setup
- split the session-expiry check from the redirect side effect so the classification in fetch-api reads directly
- covered the 503 retry path with a test, since awaiting handleResponse made it live
@9larsons
9larsons merged commit 586b590 into main Jul 15, 2026
43 checks passed
@9larsons
9larsons deleted the slars/framework-401-session-expiry branch July 15, 2026 17:20
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