fix: proxy /oauth/* through the BFF and stop guessing redirect_uri - #101
Conversation
cafalchio
left a comment
There was a problem hiding this comment.
LGTM
I will trust your test, but I am not qualified for reviewing the ts.
There was a problem hiding this comment.
Verified on 6458-oauth-callback-path: client and server tsc clean, server suite 95 passed, client suite green.
The proxy pair reads right. forwardOAuthGet forwards only status, Location and Content-Type, so upstream Set-Cookie can never reach the browser; the authorize hop injects the session bearer and a browser-supplied Authorization cannot override it; redirect: "manual" keeps the 302 aimed at the provider. I also checked the CSRF reasoning, since a top-level window.open GET sends no Origin header: isForbiddenCrossOrigin falls through to Sec-Fetch-Site (origin-guard.ts:38-42), which reads cross-site for the hostile-opener case and same-origin for the real popup, so the guard does hold for the case the header comment describes.
Potential blocker: proxy-level failures leave the popup showing JSON and the form waiting indefinitely:
Every failure these two routes can produce is a JSON body: 401 from sessionAuth, 403 from the origin guard, 502 from forwardOAuthGet's catch. The popup is a bare window.open, so the browser just renders that JSON and nothing posts a message. triggerOAuthAuthorization (src/api/servers.ts:214-243) settles only on a postMessage or on authWindow.closed, so the form sits on "Waiting for OAuth authorization in the popup window" until the user closes the popup by hand, and then reports "OAuth authorization was cancelled", which is not what happened.
The 502 is the plausible one: the 30s timeout is explicitly sized for the DCR round trip, so a slow or unreachable registration endpoint lands exactly here. Returning the same HTML shape the callback hop returns, postMessage an oauth_callback error then window.close(), would close the loop for all three.
Copy change request
redirectUriAutoHelp reads "The gateway fills this in from its own configured public URL (APP_DOMAIN)". We should move away from using this term (other than the product description as an "AI Gateway".
Change to something like "Set automatically from the server's public URL".
Same field: redirectUriLocalWarning says "The server's public URL is not configured. Redirect URIs derived from localhost will not work", but it now fires only for an explicitly stored localhost URI, so nothing is derived and the public URL is configured, just to localhost.
|
Filed the sibling case this PR does not cover: IBM/mcp-context-forge#6632.
|
- Wire an onSend hook into the /oauth/authorize and /oauth/callback proxy
routes so the three failures the BFF itself can produce (401 session,
403 cross-site, 502 upstream unreachable) post an oauth_callback error
and close the popup instead of rendering raw JSON, matching mcpgateway's
own callback HTML shape. Without this, triggerOAuthAuthorization never
resolved or rejected until the user closed the popup by hand, then
wrongly reported "cancelled".
- Reword redirectUriAutoHelp/redirectUriLocalWarning (en-US/es-ES/pt-BR):
drop the internal APP_DOMAIN term, and fix redirectUriLocalWarning's
wording now that it only fires for an explicitly stored localhost URI,
not a derived one.
Addresses review feedback on #101
Signed-off-by: Marek Dano <mk.dano@gmail.com>
Signed-off-by: Marek Dano <mk.dano@gmail.com>
- Wire an onSend hook into the /oauth/authorize and /oauth/callback proxy
routes so the three failures the BFF itself can produce (401 session,
403 cross-site, 502 upstream unreachable) post an oauth_callback error
and close the popup instead of rendering raw JSON, matching mcpgateway's
own callback HTML shape. Without this, triggerOAuthAuthorization never
resolved or rejected until the user closed the popup by hand, then
wrongly reported "cancelled".
- Reword redirectUriAutoHelp/redirectUriLocalWarning (en-US/es-ES/pt-BR):
drop the internal APP_DOMAIN term, and fix redirectUriLocalWarning's
wording now that it only fires for an explicitly stored localhost URI,
not a derived one.
Addresses review feedback on #101
Signed-off-by: Marek Dano <mk.dano@gmail.com>
Signed-off-by: Marek Dano <mk.dano@gmail.com>
1439b0c to
58264b7
Compare
|
Nice work overall — BFF proxying, cookie stripping, and unit/E2E coverage are strong. A few blockers before merge:
Once these are addressed, this looks much safer to merge. |
vishu-bh
left a comment
There was a problem hiding this comment.
Nice work on this OAuth flow improvement. Proxying both OAuth hops through BFF, preserving provider redirects, and removing client-side redirect_uri guessing are thoughtful changes.
Please check few findings in previous comments
- Require a single-use, session-bound nonce (POST /oauth/authorize-nonce,
CSRF-protected) before GET /oauth/authorize/:gatewayId will proceed.
isForbiddenCrossOrigin alone let a same-site sibling subdomain through --
a top-level GET navigation carries no Origin header and reports
Sec-Fetch-Site: same-site (not cross-site), so it still rode the
victim's SameSite=Lax session cookie into DCR registration and DB
writes. The nonce closes that: it can only be minted by a same-origin,
CSRF-protected POST a hostile sibling has no way to forge.
- Add GET /oauth/callback-url so the SPA can default a new gateway's
redirect_uri to this deployment's own /oauth/callback proxy (derived
server-side via origin-guard.ts's resolvePublicOrigin, not
window.location.origin) instead of leaving it unset. Unset relied on
mcpgateway's own APP_DOMAIN default, unreachable in a split deployment
where only the web UI is public-facing.
- Catch a body-read failure in forwardOAuthGet after upstream headers
arrive (e.g. the connection drops mid-response), returning the same
postMessage-and-close 502 shape instead of an uncaught 500.
- Fix triggerOAuthAuthorization's nonce-mint call to send {} instead of
an empty body under Content-Type: application/json, which Fastify's
default JSON parser rejects before the route ever runs -- same fix
/auth/logout already needed for the same reason.
Addresses review feedback on #101
Signed-off-by: Marek Dano <mk.dano@gmail.com>
vishu-bh
left a comment
There was a problem hiding this comment.
Thanks for addressing previous comments, just some minor P2 needs checking
| ): Promise<boolean> { | ||
| if (!nonce) return false; | ||
| const key = nonceRedisKey(nonce); | ||
| const mintedForSession = await redis.get(key); |
There was a problem hiding this comment.
GET then DEL is not atomic. Two concurrent requests using same nonce can both read its session binding before either request deletes key, allowing both OAuth initiations to proceed. Please consume/check nonce atomically (Lua script or GETDEL-based operation) and add concurrent-consumption coverage.
| // same reason redirect_uri stopped being guessed client-side in the first | ||
| // place: behind a reverse proxy the browser's own address isn't reliably | ||
| // this deployment's public one (see oauth-callback-url.ts). | ||
| const { data: defaultOAuthRedirectUri } = useQuery<{ redirectUri: string }>( |
There was a problem hiding this comment.
/oauth/callback-url loads asynchronously, but form can still submit while request is pending or after it fails. That leaves oauthRedirectUri empty and falls back to gateway APP_DOMAIN, reintroducing split-deployment failure this change fixes. Please disable submission until callback URL resolves, or show retryable error; add slow/failing callback-URL tests.
…etch
- consumeOAuthAuthorizeNonce did GET then DEL as two separate awaits,
letting two concurrent requests for the same nonce both read its
session binding before either delete ran, so both could proceed.
Switched to atomic GETDEL (added to RedisLike, FakeRedis, and
MemoryRedis) and added a concurrent-request regression test.
- useMCPServerForm let the form submit while GET /oauth/callback-url
was still pending or had failed, leaving oauthRedirectUri empty and
falling back to mcpgateway's own APP_DOMAIN default -- the exact
split-deployment failure the redirect_uri default was added to fix.
Submission is now blocked until it resolves (isValid gate plus a
handleSubmit backstop), and OAuth2Auth shows a retryable error with
a Retry button while it's down.
Addresses review feedback on #101:
#101 (review)
Signed-off-by: Marek Dano <mk.dano@gmail.com>
Signed-off-by: Marek Dano <mk.dano@gmail.com>
|
@vishu-bh - the issues addressed! |
Fixes #6458
Summary
The OAuth authorization-code popup had no reliable home. Two root causes:
/oauth/*proxy on the BFF. The popup's first hop (window.open("/oauth/authorize/{id}?popup=true")) is a raw browser navigation with no/apiprefix, so it fell through to the SPA's catch-all 404 handler instead of reaching the gateway - the popup opened the React shell instead of redirecting to the provider.redirect_uriderived fromwindow.location.origin. The web UI's own origin isn't necessarily where the gateway serves/oauth/callbackin a split deployment, so the value registered with the OAuth provider could silently diverge from what the gateway/APP_DOMAIN actually use.Changes
server/src/routes/proxy/oauth-authorize.ts(new): authenticated proxy for the popup's first hop — injects the session's bearer token (same ascatch-all.tsdoes for/api/*), forwards the provider redirect untouched, and rejects cross-site requests via the sameisForbiddenCrossOriginguardlogin.ts/proxy-sse.tsalready use (this route isn't idempotent — it can run DCR registration and DB writes - and can't rely on a CSRF token sincewindow.opensets no headers).server/src/routes/proxy/oauth-callback.ts(new): unauthenticated proxy for the second hop, needed both for gateways with a pre-existingredirect_uripointing at the web UI's origin, and so the flow works when the gateway isn't independently internet-reachable (common split deployment).server/src/lib/oauth-upstream-forward.ts(new): shared fetch/timeout/error/header-forwarding logic between the two proxy routes.src/components/mcp-servers/OAuth2Auth.tsx: stopped deriving/submittingredirect_urifromwindow.location.origin. When no value is stored, the form now shows an explicit "determined automatically by the server" placeholder instead of a guess, letting the gateway's ownAPP_DOMAIN-based default apply.onRedirectUriChangeprop chain throughAdvancedSettings.tsx/MCPServerForm.tsx.oauth-authorize.test.ts,oauth-callback.test.ts, including a cross-origin rejection test), updatedOAuth2Auth.test.tsx, and a newe2e/oauth-authorization.spec.tsdriving the full popup flow through a real browser (success and error paths) with the popup's network mocked at the browser-context level.Test plan
npm run typecheck(root +server/)npm run lintnpx vitest run— 95 BFF + 3304 frontend tests passingnpx playwright test— new OAuth specs + fullservers.spec.tspassingTesting locally (split-origin deployment)
The bug only reproduces when the web UI and the gateway are on genuinely different origins, so
localhostfor both isn't enough. Using/etc/hostsaliases avoids needing a second device or exposing anything beyond loopback:sudo sh -c 'echo "127.0.0.1 web.local" >> /etc/hosts'
sudo sh -c 'echo "127.0.0.1 api.local" >> /etc/hosts'
mcp-context-forge/.env— the gateway defaults to binding127.0.0.1only, so it must be opened up to accept requests addressed toapi.local:HOST=0.0.0.0
APP_DOMAIN=http://api.local:8000
contextforge-web-ui/.env— for local HTTP testing:COOKIE_SECURE=false
(
CONTEXTFORGE_URLcan stayhttp://127.0.0.1:8000— that hop is server-to-server, same machine, origin doesn't matter there.)http://web.local:3000— notlocalhost:3000— sowindow.location.origingenuinely differs fromAPP_DOMAIN.http://api.local:8000/oauth/callback, and delete/recreate the gateway in the UI soredirect_uristarts unset (picks up the newAPP_DOMAIN- based default rather than a stale value from before this fix).api.local:8000/oauth/callback, and close itself with a success notification in the opener.