Skip to content

fix(app): recover from expired proxy authentication - #57834

Open
chetan wants to merge 5 commits into
anomalyco:devfrom
chetan:sso-refresh
Open

chetan wants to merge 5 commits into
anomalyco:devfrom
chetan:sso-refresh

Conversation

@chetan

@chetan chetan commented Oct 21, 2026 •

Copy link
Copy Markdown

Issue for this PR

Related reports: #50481 and #57649. Related PR: #52223.

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

What does this PR do?

When a whole-site SSO proxy session expires, API requests fail. The service worker can still supply the cached app. The browser does not reach the proxy to sign in again.

API requests to the app origin now use manual redirects. A proxy redirect or 401 response starts one navigation to the current URL with reauth=1. This parameter prevents the worker from supplying cached HTML for that navigation. The worker stays installed. The route, query, hash, and deployment prefix stay in the recovery URL. The app removes the parameter after sign-in.

An expired session can also cause a lazy CSS or JavaScript file to fail. The browser loads these files without the API fetch wrapper. The app now listens for vite:preloadError and checks /api/info through the same wrapper. The check has a 10-second time limit. Only a proxy auth failure starts recovery. Missing files and offline failures keep their original errors.

OpenCode UnauthorizedError responses stay unchanged, so its password sign-in screen still works. Recovery stays disabled until an API health check succeeds after the return from sign-in. This prevents repeated reloads. Requests to other origins and the native desktop client are unchanged. Web tabs in desktop browsers use the new handling.

We prefer this PR to #52223 for whole-site SSO proxies. This PR handles proxy 401 responses and redirects directly. It also checks auth after browser CSS and JavaScript load failures. #52223 starts its check only when a wrapped fetch rejects. It does not directly handle 401 responses or browser asset load failures. Both PRs use reauth=1; use one recovery implementation rather than both wrappers.

This change does not handle login pages returned as HTTP 200, or proxies that protect only API paths.

The service worker also leaves common SSO proxy paths to the network: /outpost.goauthentik.io, /oauth2, /.pomerium, /_oauth, /cdn-cgi, /.auth, and /authelia, including their subpaths and query strings. This lets Authentik, OAuth2 Proxy/AWS ALB, Pomerium, Traefik Forward Auth, Cloudflare, Azure Easy Auth, and Authelia handle their own callbacks and portals instead of loading cached OpenCode HTML on an unsupported route. Exact path boundaries keep similarly named app routes unaffected. The extended browser regression reproduced the interception before the fix; all 15 service-worker fixture tests passed afterward, and the updated case passed 10 runs with five workers and no retries. For this follow-up, bun run check was blocked by missing dependencies and type errors in unchanged files; the pre-push hook encountered the same failures and was bypassed.

How did you verify your code works?

  • bun run check passed.
  • All five API recovery unit tests passed.
  • The production build passed.
  • All 17 service-worker browser tests passed in Chromium. These include the two tests that use the full production build.
  • The four new asset tests passed for 10 runs each, with five workers and no retries.
  • The CSS and JavaScript recovery tests failed when the new handler was disabled.
  • The asset tests check sign-in recovery, the return route, missing files, and offline failures.
  • bun run typecheck:e2e reported nine errors in unchanged regression and performance test files. It reported no errors in the changed files.

The recovery flow has not been tested against live Authentik or Authelia.

Screenshots / recordings

No visual controls changed. Recovery uses a full-page navigation.

Recovery flow

Visual

<svg xmlns="http://www.w3.org/2000/svg" width="240" height="72" viewBox="0 0 240 72">
  <style>
    @keyframes pulse { 50% { opacity: .35 } }
    @media (prefers-reduced-motion: no-preference) { .check { animation: pulse 2s infinite } }
  </style>
  <g fill="none" stroke="currentColor" opacity=".5">
    <rect x="4" y="16" width="64" height="40" rx="6"/>
    <rect x="88" y="16" width="64" height="40" rx="6"/>
    <rect x="172" y="16" width="64" height="40" rx="6"/>
    <path d="M70 36h14m-4-4 4 4-4 4M154 36h14m-4-4 4 4-4 4"/>
  </g>
  <g fill="currentColor" text-anchor="middle" font-family="sans-serif" font-size="11">
    <text x="36" y="33">Asset fails</text>
    <text class="check" x="120" y="33">Check auth</text>
    <text x="204" y="33">Sign in</text>
  </g>
  <path d="m197 43 4 4 9-9" fill="none" stroke="currentColor" stroke-width="2"/>
</svg>

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

Added by the opencode bot (AI-generated).

Related: #57649, #50481

@opencode-agent opencode-agent 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.

Changes requested. This recovers when an SSO proxy answers 401, but it also catches the 401s that OpenCode's own server sends, which the app uses for its sign-in screen. I couldn't test it end to end because I have no SSO proxy or real browser. The PR's test and packages/app typecheck pass. I also ran a quick probe of my own: a password-protected opencode serve that keeps returning 401 on /api/info. With the app's 10s health polling, the wrapper ran location.replace('/?auth_refresh=…') every 60s.

Public API: unchanged.

Concerns:

  • Your own server's 401s are treated as an expired proxy session (inline). With a server password, the app relies on a same-origin 401 to show its sign-in screen. Now the page reloads every 60s and wipes what the user is typing there.
  • Overlap with #52223. It tackles the same expired-proxy-session problem (#50481, #57649) and also sets platform.fetch in web.ts, so the two PRs conflict. It confirms the proxy is redirecting (probes /api/info with redirect: "manual") before reloading, and reloads with a ?reauth=1 the service worker lets through, rather than removing the service worker. Adding 401 handling to that approach, with the same check that the 401 comes from the proxy rather than OpenCode, is probably simpler than keeping two separate wrappers. Coordinating there would help.
  • The service worker is removed on every recovery (inline). Offline/PWA support is lost until the next registration.
  • The reload goes to a hardcoded / (inline). It drops the current route and ignores a base path when the app is served under a prefix.
  • You note the SSO flow itself hasn't been tested end to end yet. A run behind Authentik or Authelia would show the 401 path actually ends up on the sign-in page.

Related: #57649, #50481

async (input: RequestInfo | URL, init?: RequestInit) => {
const response = await fetch(input, init)
const url = new URL(input instanceof Request ? input.url : input, location.href)
if (response.status !== 401 || url.origin !== location.origin || auth.refreshing) return response

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.

Any same-origin 401 sets this off, including OpenCode's own UnauthorizedError when the server has a password. The web app counts on that 401 (health.unauthorized) to show its sign-in screen. The health check repeats every 10s, so the 60s limit on line 11 becomes a reload once a minute instead of a loop. Each reload clears the sign-in screen and sends the user to /. I reproduced this with a mocked 401 /api/info: 3 reloads in 3 minutes.

Could this only act when the 401 clearly comes from the proxy? For example: no UnauthorizedError JSON body, or a Location header like Authelia sends (#57649), or a check like #52223's /api/info probe.

This removes the service worker for good, so offline/PWA support is gone until it registers again. #52223 gets the same result without removing it: it adds a query parameter (reauth) and keeps that parameter out of the service worker's navigation fallback in vite.pwa.ts, so that one navigation goes to the network.

Going to a hardcoded / drops the route the user was on and breaks when the app is served under a path prefix. Reloading the current URL with an extra query parameter would keep both.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Addressed in e902caf:

  • OpenCode UnauthorizedError JSON is recognized using the client guard and returned unchanged; its body remains readable and the password sign-in flow does not trigger any recovery navigation.
  • Recovery keeps the current path, query, and hash, including a deployment prefix. Prefixed API paths and health checks are recognized.
  • The worker stays installed. Only navigations containing reauth bypass the navigation fallback, and the app entry removes that parameter after returning. This follows the convention in fix(app): recover when a sign-in proxy session expires #52223; the Authentik callback-path exclusion is not part of this PR.
  • Split the parameterized unit test into five focused cases. Added a real Chromium flow using the production wrapper and generated service worker behind a simulated cross-origin sign-in proxy. It verifies return to the original route, preserved worker registration, and a successful API call after login. Removing the reauth exclusion makes that test fail.

Validation: five unit cases, all 11 fixture-based service-worker tests, and bun run check pass. This is a simulated proxy flow, not verification against a live Authentik/Authelia deployment.

I reviewed #52223 and aligned the worker bypass and query cleanup with it. The fetch wrappers still overlap and should be consolidated before both PRs land; this variant handles proxy 401s as well as manual API redirects, without treating OpenCode password challenges as proxy expiry.

@opencode-agent opencode-agent 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.

Thanks for the update. Recovery now stays off after one attempt until /api/info succeeds again. I re-ran my probe of a password-protected opencode serve returning 401: the reload every 60s is gone and it now reloads once. The PR's test and packages/app typecheck pass. I still couldn't test the real SSO flow.

Public API: unchanged.

The earlier thread is still open. A 401 from OpenCode's own password check still costs one reload per tab, which drops the user's route. The service worker is still removed on every recovery. The reload still goes to a hardcoded /. The new ^/api check also means recovery quietly does nothing when the app is served under a path prefix.

The test has grown into one 72-line parameterised case, including a hand-built opaqueredirect response. It's hard to follow. A few small cases would be clearer: 401 triggers one reload, opaque redirect triggers one reload, other-origin and non-/api requests are ignored, and a healthy /api/info re-arms recovery.

It's also still worth coordinating with #52223, which handles the same expiry problem and also sets platform.fetch.

@github-actions github-actions Bot added needs:compliance This means the issue will auto-close after 2 hours. and removed needs:compliance This means the issue will auto-close after 2 hours. labels Oct 22, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Thanks for updating your PR! It now meets our contributing guidelines. 👍

@opencode-agent opencode-agent 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.

My earlier points are fixed. OpenCode's own UnauthorizedError 401 is now passed back without reloading. Recovery no longer removes the service worker, because reauth=1 is excluded from the worker's cached-page fallback. It also keeps the current path, prefix, query and hash. I checked with a probe: a password-protected server polled for three minutes now gets no reloads, and a plain proxy 401 reloads once to /prefix/session/abc?x=1&reauth=1#h. The unit tests (5 cases) and packages/app typecheck pass. I couldn't run the Playwright service-worker test or a live SSO proxy here.

Public API: unchanged.

This now changes the same files as #52223 (entry.tsx, vite.pwa.ts, web.ts) and uses its reauth marker. As you note, one of the two should be rebased onto the other or they should be combined before both land.

@Hamza-Abili

Copy link
Copy Markdown

I hit the same failure on a live Authelia deployment, and I can confirm the 401 + Location path this PR handles is the one that occurs here.

Environment:

  • OpenCode 2.1.4 (opencode serve) behind Traefik 3.7.16
  • Authelia 4.39.28 ForwardAuth, whole-site (not only /api)
  • Installed PWA on iPhone

Observed after the Authelia session expires (4h inactivity / 24h expiration):

  • The open PWA stays on the cached app shell; API calls fail and the UI is empty.
  • Traefik logs show bursts of DownstreamStatus=401, OriginStatus=0, i.e. the request is rejected by ForwardAuth and never reaches OpenCode.
  • curl -H 'Accept: application/json' https://<host>/api/info returns 401 with Location: https://auth.<domain>/mfa/?rd=...
  • curl -H 'Accept: text/html' https://<host>/ returns 302 to the same portal.
  • A manual reload does not recover because the Workbox navigation fallback serves the precached index.html; only opening the Authelia portal directly and signing in restores the app.

This matches #50481 / #57649. I have not tested the PR build yet, but this deployment can be used to verify the live Authelia path, including the one-shot ?reauth=1 reload and the no-loop guard. Happy to run it and report back.

One request: please keep the 401 + Location case covered. Some proxies return 401 instead of a redirect for JSON/SSE clients, so checking only response.redirected would miss this setup.

@chetan

chetan commented Oct 30, 2026

Copy link
Copy Markdown
Author

@Hamza-Abili can you confirm if adding an exception for /authelia is a safe assumption? See the updated description above. Apparently it's user configurable but I expect most use the default suggestion.

@Hamza-Abili

Hamza-Abili commented Oct 31, 2026 •

Copy link
Copy Markdown

@chetan Thanks for the follow-up. I checked this against Authelia's configuration model.

/authelia is safe to add as an exact path boundary: no OpenCode route uses it, and the (?:[/?]|$) boundary keeps /authelia-project on the cached app. But I wouldn't treat it as Authelia's default. The portal path is configured through server.address: the default is tcp://:9091/ (root), while tcp://:9091/authelia and tcp://:9091/subpath are documented examples. session.cookies[].authelia_url can also point to a different origin. So /authelia is a common convention, not a guaranteed default.

In my deployment the portal is on a separate origin (https://auth.<domain>/mfa/), so the service worker never intercepts it. The explicit 401 handling plus the ?reauth=1 bypass are what fix this case; the code keys off the 401 status (the Location header is present but not required). The /authelia exception is harmless but wouldn't have helped here.

So: adding /authelia is safe and useful for the common same-origin subpath deployment, but it is not a complete Authelia assumption. A configurable denylist (or documenting that same-origin portals outside the list need configuration) would cover /mfa, /auth, custom subpaths, etc. The exact-boundary test for /authelia-project is good.

Minor naming point: the /authelia entry is an Authelia portal path, not a Traefik ForwardAuth path; Traefik itself doesn't define a standard portal path.

This was referenced Nov 1, 2026

This branch has not been deployed

No deployments
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.

2 participants