Skip to content

web: service worker intercepts OAuth callback and auth expiry causes silent 1s retry loop #50481

Open

Description

@BR4SK1

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 serve on 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

  1. Deploy opencode serve bound 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).
  2. Load the web UI and authenticate once at the IdP. The service worker registers (navigator.serviceWorker.register("/sw.js"), packages/app/src/entry.tsx).
  3. Let the oauth2-proxy session cookie expire with the tab open (or delete just that cookie).
  4. The UI freezes silently — no error, no login prompt, no navigation. Network tab: GET /api/event repeating every ~1 s, each request a 302 to the IdP.
  5. 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.
  6. The page at /oauth2/callback renders the cached SPA shell instead of completing the handshake; the console shows Error: Unrecognised route!. The request never reached the proxy.
  7. 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/event per 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:

Suggested fixes:

  1. Denylist /oauth2/* (more generally: any non-app path) from navigateFallback in packages/app/vite.pwa.ts so proxy auth endpoints always pass through.
  2. 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.
  3. Consider serving /sw.js unauthenticated so a stale worker can update while logged out.

Activity

  1. sbaidon commented on Sep 27, 2026

    @sbaidon

    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.html for /, 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/session directly 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 as opaqueredirect, navigate to something the service worker lets through, like /?reauth.

    @Hona, would you take a PR for that?

  2. KillianGDK-FDTI commented on Sep 29, 2026

    @KillianGDK-FDTI

    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 /api and /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.

  3. sbaidon commented on Sep 30, 2026

    @sbaidon

    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.

  4. kolyavorobey28 commented on Oct 22, 2026

    @kolyavorobey28

    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"! :(

  5. kevinpark1217 commented on Oct 26, 2026

    @kevinpark1217

    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.

  6. opencodejr commented on Oct 28, 2026

    @opencodejr

    Thanks for the detailed report. I reproduced both problems on 2.0.8, and they still happen on the latest dev (fa1260d).

    Setup: opencode serve on 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.

    • Retry loop: after the session cookie is removed and the open stream drops, GET /api/event is 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.
    • OAuth callback: going to /oauth2/callback?code=… never reaches the proxy. The service worker's navigateFallback serves the cached app instead, so the cookie is never set. The app shows its "Something went wrong" error screen, and the 302 loop continues. On dev, navigateFallbackDenylist in packages/app/vite.pwa.ts covers /api and /auth but 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.js while logged out.

    Related: #47062 and #50370 (same reconnect loop, Basic-auth and 401 variants) and #48161 (reconnect backoff).

    Screenshot

    Reproduction script (repro.mjs)
    // Reproduction for anomalyco/opencode#50481.
    // Usage: start `opencode serve --hostname 127.0.0.1 --port 4096` (note the printed password), then
    //   OC_PASSWORD=<password> node repro.mjs     (needs `npm i playwright && npx playwright install chromium`)
    // A mock oauth2-proxy on :8080 gates only /api/* behind a session cookie (302 to a mock IdP on :8081
    // when missing) and injects the server's Basic auth upstream. /oauth2/callback sets the cookie.
    import http from "node:http"
    import { chromium } from "playwright"
    
    const UPSTREAM = { host: "127.0.0.1", port: 4096 }
    const auth = "Basic " + Buffer.from("opencode:" + process.env.OC_PASSWORD).toString("base64")
    const log = []
    const t0 = Date.now()
    const streams = new Set() // open /api/event responses, dropped when the session "expires"
    
    http
      .createServer((req, res) => {
        // Mock IdP: login page that immediately "authenticates" and sends you back to the callback.
        if (req.url.startsWith("/login")) {
          res.writeHead(200, { "content-type": "text/html" })
          return res.end(`<html><body>IdP login<script>setTimeout(()=>location.href="http://localhost:8080/oauth2/callback?code=abc",50)</script></body></html>`)
        }
        res.writeHead(404).end()
      })
      .listen(8081)
    
    http
      .createServer((req, res) => {
        const cookie = /_oauth2_proxy=ok/.test(req.headers.cookie ?? "")
        log.push({ t: Date.now() - t0, url: req.url, cookie })
        if (req.url.startsWith("/oauth2/callback")) {
          res.writeHead(302, { "set-cookie": "_oauth2_proxy=ok; Path=/", location: "/" })
          return res.end()
        }
        if (req.url.startsWith("/oauth2/start")) {
          res.writeHead(302, { location: "http://localhost:8081/login" })
          return res.end()
        }
        if (req.url.startsWith("/api") && !cookie) {
          res.writeHead(302, { location: "http://localhost:8081/login" })
          return res.end()
        }
        if (req.url.startsWith("/api/event")) streams.add(res)
        const up = http.request({ ...UPSTREAM, path: req.url, method: req.method, headers: { ...req.headers, host: "127.0.0.1:4096", authorization: auth } }, (r) => {
          res.writeHead(r.statusCode, r.headers)
          r.pipe(res)
        })
        req.pipe(up)
        up.on("error", () => res.end())
      })
      .listen(8080)
    
    const browser = await chromium.launch()
    const ctx = await browser.newContext()
    await ctx.addCookies([{ name: "_oauth2_proxy", value: "ok", url: "http://localhost:8080" }])
    const page = await ctx.newPage()
    const consoleErrors = []
    page.on("console", (m) => m.type() === "error" && consoleErrors.push(m.text()))
    page.on("pageerror", (e) => consoleErrors.push(String(e)))
    
    console.log("1. load app while authenticated")
    await page.goto("http://localhost:8080/")
    await page.waitForTimeout(8000)
    console.log("   service worker controlling:", await page.evaluate(() => !!navigator.serviceWorker.controller))
    await page.reload()
    await page.waitForTimeout(4000)
    console.log("   service worker controlling after reload:", await page.evaluate(() => !!navigator.serviceWorker.controller))
    
    console.log("2. expire session cookie, watch /api/event for 10s")
    await ctx.clearCookies()
    for (const s of streams) s.destroy() // proxy/tunnel drops the open stream
    const start = log.length
    await page.waitForTimeout(10000)
    const ev = log.slice(start).filter((l) => l.url.startsWith("/api/event"))
    console.log(`   /api/event requests in 10s without cookie: ${ev.length} (all 302 to IdP)`)
    console.log("   gaps ms:", ev.slice(1).map((e, i) => e.t - ev[i].t).join(","))
    await page.screenshot({ path: "/workspace/repro/expired.png" })
    
    console.log("3. user goes through IdP which redirects to /oauth2/callback")
    const before = log.length
    await page.goto("http://localhost:8080/oauth2/callback?code=abc")
    await page.waitForTimeout(10000)
    const cb = log.slice(before).filter((l) => l.url.startsWith("/oauth2/"))
    console.log(`   /oauth2/* requests that reached the proxy: ${cb.length}`)
    console.log("   page URL:", page.url())
    console.log("   cookie set:", (await ctx.cookies()).some((c) => c.name === "_oauth2_proxy"))
    console.log("   console errors:", consoleErrors.filter((e) => /Unrecognised|route/i.test(e)).slice(0, 3))
    await page.screenshot({ path: "/workspace/repro/callback.png" })
    const after = log.slice(before).filter((l) => l.url.startsWith("/api/event")).length
    console.log(`   /api/event requests still hitting proxy without cookie in 10s after callback: ${after}`)
    console.log("   page text:", (await page.evaluate(() => document.body.innerText)).slice(0, 200).replace(/\n/g, " | "))
    console.log("   all console errors:", [...new Set(consoleErrors)].slice(0, 5))
    await browser.close()
    process.exit(0)

    Run it: bun repro.mjs

  7. Hamza-Abili commented on Oct 30, 2026

    @Hamza-Abili

    For the Authelia variant of this issue (401 + Location instead 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 explicit 401 handling looks like the right path for this setup.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions