Repository navigation
Description
Summary
Behind an auth-gating reverse proxy (oauth2-proxy + Keycloak OIDC), the V2 web client never surfaces session expiry: the SSE client silently retries GET /api/event every 1000 ms forever, and the PWA service worker intercepts the OAuth callback navigation, so re-login is impossible without clearing site data.
Environment
- opencode version: 2.0.8
- OS: Linux x86_64, kernel 6.18.42-1-lts (server hosts
opencode serveon 127.0.0.1; bug reproduced in the browser UI it serves) - Terminal: Unavailable: terminal environment variables are not set (headless server deployment)
- Shell: /bin/bash
- Install/channel: latest (release channel)
- Active plugins: none found in config
Reproduction
- Deploy
opencode servebound to 127.0.0.1 behind: browser → Cloudflare tunnel → oauth2-proxy (Keycloak OIDC) → opencode. Only/api/*is auth-gated (since fix(server): authenticate only API requests #46702). - Load the web UI and authenticate once at the IdP. The service worker registers (
navigator.serviceWorker.register("/sw.js"),packages/app/src/entry.tsx). - Let the oauth2-proxy session cookie expire with the tab open (or delete just that cookie).
- The UI freezes silently — no error, no login prompt, no navigation. Network tab:
GET /api/eventrepeating every ~1 s, each request a 302 to the IdP. - Navigate to the app root to log in again. The proxy 302s to Keycloak, which redirects back to
/oauth2/callback?code=…on the app origin. - The page at
/oauth2/callbackrenders the cached SPA shell instead of completing the handshake; the console showsError: Unrecognised route!. The request never reached the proxy. - Only clearing site data (unregistering the service worker) breaks the cycle. The SW re-registers on the next authenticated load, so it recurs at every session expiry, on every device.
Expected Behavior
- A background API call that gets redirected to auth (or returns 401) should be detected by the client and turned into a top-level navigation to the login page, instead of a frozen UI.
- Navigations to
/oauth2/*(the proxy's auth endpoints) must reach the network; the service worker must never intercept them. - A stale service worker should still be able to update while logged out.
Actual Behavior
1/sec retry loop, no cap, no auth detection. On cookie expiry every GET /api/event gets the proxy's 302 to the IdP. The client's fetch follows the redirect transparently and receives the IdP's login-page HTML instead of an event stream; the stream errors out and the connection loop retries after a fixed 1000 ms (const reconnectDelay = 1_000 in packages/client/src/solid/connection.ts on the v2 branch — the attempt counter only drives status reporting; the wait is constant and the loop unbounded). This runs indefinitely with zero errors surfaced in the UI. oauth2-proxy logs confirm the exact 1/sec cadence (one 302 per second per open tab) sustained for hours. Notably the SDK declares 401 as a known status for this endpoint (packages/client/src/promise/generated/client.ts), but a transparently-followed 302 delivering HTML never looks like a 401 to it.
App renders as "loaded" while every request fails. Since #46702 only /api/* is auth-gated, the SPA shell renders from the SW/browser cache and boots unauthenticated, firing background calls forever. Proxy logs: 0 × 200 responses over a 5+ hour window while the UI sat in a never-finishing load state.
Service worker eats the OAuth callback. After authenticating at the IdP, the navigation to /oauth2/callback?code=… is served by the service worker from the precache: Workbox navigateFallback: "/index.html" with navigateFallbackDenylist: [/^\/api(?:\/|$)/, /^\/(?:_assets|assets)(?:\/|$)/] (packages/app/vite.pwa.ts) — /oauth2/* is not denylisted. The callback never reaches the network (proxy logs: zero /oauth2/* requests during the window), so the authorization code is never exchanged and no session cookie is set. The SPA then boots at /oauth2/callback and the router throws Error: Unrecognised route! (packages/app/src/shell/state/layout.tsx — only /, /settings, /new-session and /server/…/session/… are recognised). The user appears "logged in" but isn't; the 302 loop resumes.
Stale SW cannot self-heal. The SW's own update check (GET /sw.js, registered in packages/app/src/entry.tsx) also receives the 302/login-HTML while unauthenticated, so the stale worker stays in control indefinitely. Clearing site data is the only way out, and the SW re-registers on the next authenticated page load — the cycle recurs on every session expiry on every device.
Additional Context
Topology (hostnames redacted):
browser ──> Cloudflare tunnel ──> oauth2-proxy (Keycloak OIDC) ──> opencode serve (127.0.0.1)
session expiry: 302 ──> sso.example.com login page
auth-gates /api/* only (since #46702)
Log evidence (oauth2-proxy access log):
- ~1 request/s to
/api/eventper open tab, all 302, sustained for 4+ hours, no client-side error or navigation. - 0 × 200 responses in a 5+ hour window; user reported "loading but never finishes".
- 0 requests matching
/oauth2/*during the login-attempt window (the callback never hit the network).
Related issues and the delta of this report:
- web: native Basic auth prompt re-fires every second while entering credentials #47062 (open) — native Basic-auth prompt re-fires every second; same underlying reconnect machinery. This is the OIDC-proxy variant, where no prompt exists at all because the redirect is followed silently.
- fix(client): back off reconnects when the stream never connects #48161 (open PR) — adds backoff when the stream never connects. Necessary but insufficient here: a transparently-followed 302 is indistinguishable from a content error client-side, so the client would back off and retry forever instead of sending the user to login.
- [BUG] opencode serve: returns 401 Unauthorized when OPENCODE_SERVER_PASSWORD is unset → UI infinite loading, no auth prompt (regression since ~v2.0.6) #50370 (open) — the 401 variant of the same UX failure (infinite loading, no auth prompt).
- Delta: (1) a reverse proxy's 302-to-IdP during a background fetch is followed transparently and never triggers re-auth — the client needs explicit auth-redirect/401 detection plus top-level navigation; (2) the service worker actively intercepts
/oauth2/*navigations, breaking login entirely — not covered by any of the above.
Suggested fixes:
- Denylist
/oauth2/*(more generally: any non-app path) fromnavigateFallbackinpackages/app/vite.pwa.tsso proxy auth endpoints always pass through. - Detect auth redirects on
/api/*calls (final URL off-origin /Response.redirected, or 401) and do a top-level navigation to the login URL, or surface an explicit "session expired" state. - Consider serving
/sw.jsunauthenticated so a stale worker can update while logged out.
Activity
sbaidon commentedon Sep 27, 2026
Same problem here behind Microsoft Dev Tunnels. The tunnel requires sign-in on every path, including
/.Once the tunnel cookie expires, the service worker serves the cached
index.htmlfor/, so the request never reaches the tunnel and it can't redirect to sign-in. API calls then get a cross-origin 302 and fail without any error in the UI. Opening/api/sessiondirectly is the only way I've found to sign in again.One option that keeps cache-first navigation: if an API call fails and
fetch(url, { redirect: "manual" })comes back asopaqueredirect, navigate to something the service worker lets through, like/?reauth.@Hona, would you take a PR for that?
KillianGDK-FDTI commentedon Sep 29, 2026
I reproduced this on OpenCode V2 2.0.18 at
code.kgk.app, behind Cloudflare Tunnel + Access, using Brave’s installed PWA on Android. After the Access session expires, the app stays on “Loading”. A sign-in attempt returns to/cdn-cgi/access/authorized, but the service worker serves the cached app shell there; the router then reports “Unrecognised route”. Unregistering only the service worker restored the project/session list without clearing cookies.The generated worker’s navigation fallback excludes
/apiand/auth, but not/cdn-cgi/access/. Would you welcome a focused PR excluding Cloudflare’s callback path (and the/oauth2/path described above) from the fallback? The separate expired-session UX could remain a follow-up.sbaidon commentedon Sep 30, 2026
Opened #52223 for the expired-session part. It doesn't touch the callback-path exclusions, so it shouldn't overlap with a PR for those.
kolyavorobey28 commentedon Oct 22, 2026
Hello everyone!
Good grief, please bring back "Opencode Web"!
I used to access Opencode via "Opencode Web," combined with a reverse proxy and Authelia.
Everything worked perfectly. Since updating to version 2, I haven't been able to get it working at all.
Now, when I navigate to my domain name, I go through the SSO process, only to end up on a page asking me to connect to an Opencode server—why on earth would I need that?!
And I can't connect using either the "pair link" or the "use password" option.
Please bring back "Opencode Web"! :(
kevinpark1217 commentedon Oct 26, 2026
If the token comes from an OAuth 2.0 / OIDC identity provider, I published an OpenCode 2 plugin for that case: opencode-oauth (opencode-oauth on npm). It signs a custom provider in with the authorization code flow and PKCE, and OpenCode refreshes the access token through it automatically, with no restart. It doesn't cover keys from a vault or a script.
opencodejr commentedon Oct 28, 2026
Thanks for the detailed report. I reproduced both problems on 2.0.8, and they still happen on the latest
dev(fa1260d).Setup:
opencode serveon 127.0.0.1 behind a small mock oauth2-proxy that gates only/api/*with a session cookie and redirects to a mock IdP on another origin. The app ran in headless Chromium with the service worker installed.GET /api/eventis retried every ~1003 ms (9 requests in 10 s). Each request follows the 302 to the IdP and fails, and the UI shows no error or login prompt./oauth2/callback?code=…never reaches the proxy. The service worker'snavigateFallbackserves the cached app instead, so the cookie is never set. The app shows its "Something went wrong" error screen, and the 302 loop continues. Ondev,navigateFallbackDenylistinpackages/app/vite.pwa.tscovers/apiand/authbut not/oauth2.The script below reproduces both problems, and the screenshot shows the error screen at
/oauth2/callback. I didn't test your point about the stale service worker failing to update/sw.jswhile logged out.Related: #47062 and #50370 (same reconnect loop, Basic-auth and 401 variants) and #48161 (reconnect backoff).
Reproduction script (repro.mjs)
Run it:
bun repro.mjsHamza-Abili commentedon Oct 30, 2026
For the Authelia variant of this issue (
401 + Locationinstead of a redirect), I added live-deployment details to #57834: #57834 (comment)Same symptoms: whole-site Authelia ForwardAuth, cached PWA shell, and Traefik
DownstreamStatus=401/OriginStatus=0. The PR's explicit401handling looks like the right path for this setup.