workbench Docs

Findings

Resuming an OAuth-authorize flow needs a real page navigation, not a fetch

GET /authorize's login-CSRF defense sets awb_oauth_binding as an HttpOnly cookie on its own response, then requires that exact value back at resume time (resumeAuthorize, packages/server/src/auth/oauth-server/resume.ts). That's deliberate: the value must be something an attacker who mints their own /authorize ticket cannot also hand to a victim, and a cookie set by one specific browser's hit to /authorize has that property in a way a URL parameter never could — if the value travels in the URL, an attacker who completes their own /authorize request already knows it, and can hand the victim's browser a link carrying ticket=<attacker's>&binding=<same value>, defeating the whole check.

The new "already signed in" resume path (POST /authorize/resume, packages/server/src/api/oauth-routes.ts) needed to read that same cookie back from a portal page — and that's where the dev split (Vite portal on one port, the server on another) bites: the cookie is scoped to whichever origin actually received the response that set it, and in this flow that's always the server's own origin (/authorize is opened directly by the MCP client's browser, never through Vite). A fetch()/XHR from the portal page — even one Vite proxies server-side to the real backend — does not carry that cookie, because the browser decides what to attach based on the URL it's calling (the portal's origin), before any proxying happens. Proxying is invisible to the browser's cookie jar.

The fix is to make the resume step a genuine top-level navigation to the server's own absolute origin — an auto-submitted <form method="POST" action="...">, not a fetch. A real navigation to that origin carries its cookies the normal way, no proxy or CORS involved.

The first version of this got that absolute origin wrong in a way that turned into a second, more serious bug: rather than requiring a separately- configured frontend env var, /authorize handed the choice page its own origin as a resume= query param, and the page trusted it verbatim as the form's POST target. That target carries a live session token (the whole point of the form). A URL query param is exactly what a crafted link controls — .../authorize/choose?ticket=X&resume=https://evil.example/steal would have made an already-signed-in victim's browser silently POST their real bearer token to an attacker's server the moment they opened the link, no click required. Caught by an automated push review before merge, not by anything in this design.

The fix: the resume target now comes only from the portal's own build-time config (VITE_SERVER_URL in packages/portal/src/api.ts, mirroring the existing VITE_API_URL — empty string defaults to same-origin, correct in production where portal and server share one origin). /authorize no longer sends a resume param at all. The general rule this leaves behind: a value that will become part of where a credential gets sent must never be sourced from anything the URL — or any other attacker-reachable input — controls, even when the value "should" always be benign. Query params, postMessage origins, Referer, redirect targets in general — the same shape of bug recurs anywhere a credential's destination is decided at runtime instead of pinned at build/deploy time.

The narrower lesson also still holds: a cookie set by a direct external redirect into the server (an SSO provider callback, an /authorize entry point) is not retrievable by the portal's own fetch client, no matter how the dev proxy is configured — only another direct browser navigation to that same origin gets it back. That's what still forces the form-POST design; it just can't source its target from the request that triggered it.