Skip to content

Gate signed-out document requests at the edge instead of flashing the app-shell skeleton#975

Merged
RhysSullivan merged 5 commits into
mainfrom
claude/charming-solomon-f674bd
Jun 12, 2026
Merged

Gate signed-out document requests at the edge instead of flashing the app-shell skeleton#975
RhysSullivan merged 5 commits into
mainfrom
claude/charming-solomon-f674bd

Conversation

@RhysSullivan

@RhysSullivan RhysSullivan commented Jun 12, 2026

Copy link
Copy Markdown
Collaborator

Problem

The same signed-out visit to the cloud root, with the auth probe held open so the loading window is visible — main on the left, this branch on the right:

Signed-out visit, before vs after

Signed-out visitors to executor.sh/cloud were shown the authenticated app-shell skeleton (sidebar nav + content-card grid) until a client-side /account/me round trip returned 401 — at which point the page swapped to a login UI. Every unsigned-out load flashed the wrong shell.

Fix

Three layers, each standing alone:

1. SSR auth gate (apps/cloud/src/auth/ssr-gate.ts)

A TanStack Start request middleware (last in the requestMiddleware chain in start.ts) that intercepts GET/HEAD document navigations before any React renders. It verifies the sealed wos-session cookie directly in the worker — unseal + JWT check against a cached JWKS — so there is no per-request WorkOS round trip except on token refresh. Signed-out visitors get a 302 /login?returnTo=<path> before any app HTML is generated. An invalid cookie is cleared on the way out (on executor.sh, the cookie's mere presence routes / past the marketing page). WorkOS refresh tokens are single-use, so a rotated sealed session is re-set on the response.

/cloud (the path marketing CTAs link to) was previously a 404; it now redirects into the app.

2. Auth-hint cookie (packages/react/src/multiplayer/auth-hint.tsx)

A non-HttpOnly companion to the sealed session: a display-only Schema-validated snapshot of the authenticated identity. AuthProvider writes it whenever /account/me confirms a session and reads it back at mount to seed optimistic "authenticated" state — so signed-in loads paint the real shell immediately without waiting for the probe. Cloud logout and the SSR gate clear it server-side. The resolved /account/me always wins.

3. /login route with redirect-back

A proper /login page (apps/cloud/src/routes/app/login.tsx) with ?returnTo= round-trip plumbing: the SSR gate sets returnTo, the login handler encodes it into the OAuth state parameter beside the CSRF nonce (base64url(JSON { nonce, returnTo }) — no extra cookie), and the callback decodes it from the validated state and redirects there. A isSafeReturnTo validator (relative-only, no //, no /api) prevents open-redirect abuse at both ends. Signed-in visitors hitting /login are bounced straight back.

notFoundComponent added — unknown paths now show a real 404 page instead of a skeleton or a blank app.

Test coverage

e2e/cloud/unauthenticated-skeleton.test.ts — 4 scenarios:

  1. Signed-out root → /login immediately with no app-shell flash (screenshot verified during the in-flight window)
  2. Deep-link returnTo round trip (/tools → gate → login → callback → /tools) + open-redirect rejection
  3. Signed-in passthrough + /login bounce
  4. Real 404 page for unknown paths

e2e/cloud/auth-hint.test.ts — 2 scenarios: a confirmed /account/me writes the hint and the next load paints the real shell while the probe is held open; logout clears the hint with the session.

e2e/cloud/session-gate.test.ts — 3 scenarios: an invalid wos-session is cleared on the way to /login (along with the hint); /cloud routes into the app signed out and signed in; and a session whose access token no longer verifies is refreshed in-flight against the WorkOS emulator — the rotated sealed session reaches the browser, the spent one is revoked server-side (real single-use refresh tokens), and the rotated one keeps working. The stale-token path unseals the real cookie with the same iron-webcrypto sealing the WorkOS SDK uses, corrupts the JWT signature, and reseals — exercising the refresh branch without waiting out a real expiry.

apps/cloud/src/auth/return-to.test.ts + login-state.test.ts — unit tests covering the isSafeReturnTo / safeReturnTo / loginPath validators and the OAuth state envelope codec (round-trip, URL-safe alphabet, junk-tolerance).

All gates pass: bun run format:check, bun run lint, bun run typecheck, bun run test.

… app-shell skeleton

Signed-out visitors to the cloud app were SSR'd the authenticated
app-shell skeleton (sidebar + card grid) until a client-side
/account/me round trip 401'd and swapped in the login page — a
loading preview of an app they were never going to reach.

The sealed session cookie can be verified in the worker itself
(unseal + JWT against cached JWKS; no per-request round trip), so a
new request middleware does exactly that for document navigations:

- signed-out -> 302 to /login?returnTo=<path> before any app HTML is
  served; an invalid cookie is also cleared (its presence is what
  routes the production root past the marketing site)
- signed-in -> pass through, persisting any rotated sealed session
  (refresh tokens are single-use)
- /login is a real route now; returnTo travels with the CSRF state
  cookie through the login flow and the callback resumes the original
  path (validated to same-origin relative, never /api)
- /cloud (the marketing CTA target, previously a 404) redirects into
  the app root

Signed-in loads get faster too: AuthProvider seeds optimistic state
from a new non-HttpOnly display-only auth-hint cookie it maintains on
every confirmed /account/me, so the shell paints without waiting on
the probe; the resolved answer remains the authority. Logout and the
gate clear the hint alongside the session.

Unknown paths get a real 404 page (notFoundComponent) instead of
mounting the auth gate's skeleton.

The unauthenticated-skeleton e2e (the red repro for this report) is
now green and grew scenarios for the deep-link round trip,
open-redirect rejection, signed-in passthrough, and the 404 page.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jun 12, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
executor-cloud 22a0783 Jun 12 2026, 03:52 PM

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jun 12, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
executor-marketing 22a0783 Commit Preview URL

Branch Preview URL
Jun 12 2026, 03:51 PM

@github-actions

github-actions Bot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Cloudflare preview

Torn down — the PR is closed.

@pkg-pr-new

pkg-pr-new Bot commented Jun 12, 2026

Copy link
Copy Markdown

Open in StackBlitz

@executor-js/cli

npm i https://pkg.pr.new/@executor-js/cli@975

@executor-js/config

npm i https://pkg.pr.new/@executor-js/config@975

@executor-js/execution

npm i https://pkg.pr.new/@executor-js/execution@975

@executor-js/sdk

npm i https://pkg.pr.new/@executor-js/sdk@975

@executor-js/codemode-core

npm i https://pkg.pr.new/@executor-js/codemode-core@975

@executor-js/runtime-quickjs

npm i https://pkg.pr.new/@executor-js/runtime-quickjs@975

@executor-js/plugin-file-secrets

npm i https://pkg.pr.new/@executor-js/plugin-file-secrets@975

@executor-js/plugin-graphql

npm i https://pkg.pr.new/@executor-js/plugin-graphql@975

@executor-js/plugin-keychain

npm i https://pkg.pr.new/@executor-js/plugin-keychain@975

@executor-js/plugin-mcp

npm i https://pkg.pr.new/@executor-js/plugin-mcp@975

@executor-js/plugin-onepassword

npm i https://pkg.pr.new/@executor-js/plugin-onepassword@975

@executor-js/plugin-openapi

npm i https://pkg.pr.new/@executor-js/plugin-openapi@975

executor

npm i https://pkg.pr.new/executor@975

commit: 48cf8ee

Comment thread packages/react/src/multiplayer/auth-context.tsx Outdated
Replace the seven hand-tracked primitive fields with a memoized resolved
state: useMemo on the atom value gives the identify and hint-cookie
effects a dependency that tracks real query transitions, and the
context value is memoized so consumers don't re-render on unrelated
renders.
…ookie

The state parameter already round-trips through the identity provider
and is authenticated by the wos-login-state CSRF cookie, so the
destination rides inside it — base64url(JSON { nonce, returnTo }) — and
the wos-login-return-to cookie goes away. The callback re-validates the
decoded returnTo like any other untrusted path.
The state parameter is now base64url(JSON { nonce, returnTo }), so the
scenario decodes it and asserts the nonce inside instead of expecting
the raw hex value.
@RhysSullivan RhysSullivan marked this pull request as ready for review June 12, 2026 14:37
Five new scenarios over the real WorkOS emulator:

- auth-hint: a confirmed /account/me writes the hint and the NEXT load
  paints the real shell while the probe is held open; logout clears the
  hint with the session.
- session-gate: an invalid wos-session is cleared on the way to /login
  (not just redirected); /cloud routes into the app both signed out and
  signed in; and a session whose access token no longer verifies is
  refreshed in-flight — the rotated sealed session reaches the browser,
  the spent one is revoked server-side (real single-use refresh
  tokens), and the rotated one keeps working.

The stale-token path unseals the real session cookie with the same
iron-webcrypto sealing the WorkOS SDK uses, corrupts the JWT signature,
and reseals — exercising the gate's refresh branch without waiting out
a real expiry.
@RhysSullivan RhysSullivan merged commit cbf9365 into main Jun 12, 2026
13 checks passed
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