diff --git a/e2e/oauth-authorization.spec.ts b/e2e/oauth-authorization.spec.ts new file mode 100644 index 00000000..4171b34b --- /dev/null +++ b/e2e/oauth-authorization.spec.ts @@ -0,0 +1,242 @@ +/** + * OAuth authorization-code popup flow (mcp-context-forge#6458). + * + * The real round trip -- BFF proxies GET /oauth/authorize/{id} to mcpgateway, + * which 302s to the OAuth provider; the provider redirects back to + * /oauth/callback, which the BFF also proxies; that page posts the result to + * window.opener and closes -- can't be driven through a real IdP in CI. What + * *is* testable end to end through a real browser, without any backend, is + * the client-side contract those two hops feed into: triggerOAuthAuthorization + * (client/src/api/servers.ts) opens the popup, listens for a same-window + * postMessage, and resolves/rejects the promise that drives the form's + * pending/success/error states. This stubs the popup's very first navigation + * (the oauth/authorize route) with the exact HTML shape mcpgateway's own + * _popup_notification_script produces, so the assertion is: does the whole + * chain from clicking "Connect server" to the success notification actually + * work, not just each piece in isolation (already covered by + * src/api/servers.test.ts and server/test/oauth-*.test.ts). + */ +import { test, expect } from "./fixtures/auth"; +import { APP } from "./utils/paths"; + +const GATEWAY_ID = "gw-oauth-1"; +const GATEWAY_NAME = "GitHub OAuth Test"; + +test.describe("OAuth authorization-code popup flow", () => { + test.beforeEach(async ({ page, apiMock }) => { + await apiMock.mockPermissions(); + await page.route("**/gateways?*", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ gateways: [], nextCursor: null }), + }); + }); + }); + + test("create -> popup -> postMessage -> activate -> fetch tools", async ({ page, context }) => { + // Registered at the browser-context level (not just this page) so it also + // covers the popup window's own navigation, exactly like mcpgateway's + // popup-branch callback HTML: postMessage(payload, '*') then window.close(). + await context.route("**/oauth/authorize/**", async (route) => { + await route.fulfill({ + status: 200, + contentType: "text/html", + body: `
`, + }); + }); + + await page.route("**/gateways", async (route) => { + if (route.request().method() !== "POST") return route.fallback(); + await route.fulfill({ + status: 201, + contentType: "application/json", + body: JSON.stringify({ id: GATEWAY_ID, name: GATEWAY_NAME }), + }); + }); + + // triggerOAuthAuthorization mints this before navigating the popup (see + // src/api/servers.ts) -- a same-origin, CSRF-protected POST the popup's + // own window.open() navigation can't carry itself. + await page.route("**/oauth/authorize-nonce", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ nonce: "e2e-test-nonce" }), + }); + }); + + await page.route(`**/gateways/${GATEWAY_ID}/state*`, async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ status: "success", message: "activated" }), + }); + }); + + await page.route(`**/oauth/fetch-tools/${GATEWAY_ID}`, async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ success: true, message: "Fetched 3 tools." }), + }); + }); + + await page.goto(APP.SERVERS); + await page.waitForLoadState("networkidle"); + + await page.getByRole("button", { name: /Connect/i }).click(); + + await page.getByLabel("Name").fill(GATEWAY_NAME); + await page.getByLabel("URL").fill("https://api.githubcopilot.com/mcp"); + + await page.getByRole("button", { name: "Advanced settings" }).click(); + await page.getByText("OAuth 2.0", { exact: true }).click(); + + await page.getByLabel(/Grant type/).click(); + await page.getByRole("option", { name: /Authorization code/i }).click(); + + await page.getByLabel("Issuer URL").fill("https://github.com"); + await page.getByLabel("Client ID").fill("test-client-id"); + await page.getByLabel("Client Secret").fill("test-client-secret"); // pragma: allowlist secret + await page.getByLabel("Authorization URL").fill("https://github.com/login/oauth/authorize"); + await page.getByLabel("Token URL").fill("https://github.com/login/oauth/access_token"); + + // The auto-placeholder from the redirect_uri fix (mcp-context-forge#6458): + // never guessed from window.location.origin, never submitted as a value. + await expect(page.getByLabel(/Redirect URI/i)).toHaveValue( + "Determined automatically by the server", + ); + + await page.getByRole("button", { name: "Connect server" }).click(); + + await expect( + page.getByText(/Waiting for OAuth authorization in the popup window/i), + ).toBeVisible(); + await expect(page.getByText(/OAuth authorization successful/i)).toBeVisible(); + await expect(page.getByText(/Fetched 3 tools\./i)).toBeVisible(); + }); + + test("shows an error notification when the popup posts an error result", async ({ + page, + context, + }) => { + await context.route("**/oauth/authorize/**", async (route) => { + await route.fulfill({ + status: 200, + contentType: "text/html", + body: ``, + }); + }); + + await page.route("**/gateways", async (route) => { + if (route.request().method() !== "POST") return route.fallback(); + await route.fulfill({ + status: 201, + contentType: "application/json", + body: JSON.stringify({ id: GATEWAY_ID, name: GATEWAY_NAME }), + }); + }); + + await page.route("**/oauth/authorize-nonce", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ nonce: "e2e-test-nonce" }), + }); + }); + + await page.goto(APP.SERVERS); + await page.waitForLoadState("networkidle"); + + await page.getByRole("button", { name: /Connect/i }).click(); + await page.getByLabel("Name").fill(GATEWAY_NAME); + await page.getByLabel("URL").fill("https://api.githubcopilot.com/mcp"); + await page.getByRole("button", { name: "Advanced settings" }).click(); + await page.getByText("OAuth 2.0", { exact: true }).click(); + await page.getByLabel(/Grant type/).click(); + await page.getByRole("option", { name: /Authorization code/i }).click(); + await page.getByLabel("Issuer URL").fill("https://github.com"); + await page.getByLabel("Client ID").fill("test-client-id"); + await page.getByLabel("Client Secret").fill("test-client-secret"); // pragma: allowlist secret + await page.getByLabel("Authorization URL").fill("https://github.com/login/oauth/authorize"); + await page.getByLabel("Token URL").fill("https://github.com/login/oauth/access_token"); + + await page.getByRole("button", { name: "Connect server" }).click(); + + await expect(page.getByText(/User cancelled/i)).toBeVisible(); + // The form must stay open on error so the user can see it and retry. + await expect(page.getByRole("button", { name: "Connect server" })).toBeVisible(); + }); + + test("defaults redirect_uri to the BFF's own callback URL and submits it -- the split-deployment case", async ({ + page, + }) => { + // Stands in for server/src/routes/proxy/oauth-callback-url.ts's real + // response: a public origin distinct from this page's own origin, the + // way it would differ when mcpgateway itself isn't independently + // browser-reachable (see that route's doc comment for why the field + // can't just be left unset in that topology). + const BFF_CALLBACK_URL = "https://web.example.com/oauth/callback"; + await page.route("**/oauth/callback-url", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ redirectUri: BFF_CALLBACK_URL }), + }); + }); + + const createRequest = page.waitForRequest( + (request) => request.url().includes("/gateways") && request.method() === "POST", + ); + await page.route("**/gateways", async (route) => { + if (route.request().method() !== "POST") return route.fallback(); + await route.fulfill({ + status: 201, + contentType: "application/json", + body: JSON.stringify({ id: GATEWAY_ID, name: GATEWAY_NAME }), + }); + }); + + await page.goto(APP.SERVERS); + await page.waitForLoadState("networkidle"); + + await page.getByRole("button", { name: /Connect/i }).click(); + await page.getByLabel("Name").fill(GATEWAY_NAME); + await page.getByLabel("URL").fill("https://api.githubcopilot.com/mcp"); + await page.getByRole("button", { name: "Advanced settings" }).click(); + await page.getByText("OAuth 2.0", { exact: true }).click(); + await page.getByLabel(/Grant type/).click(); + await page.getByRole("option", { name: /Authorization code/i }).click(); + + await expect(page.getByLabel(/Redirect URI/i)).toHaveValue(BFF_CALLBACK_URL); + + await page.getByLabel("Issuer URL").fill("https://github.com"); + await page.getByLabel("Client ID").fill("test-client-id"); + await page.getByLabel("Client Secret").fill("test-client-secret"); // pragma: allowlist secret + await page.getByLabel("Authorization URL").fill("https://github.com/login/oauth/authorize"); + await page.getByLabel("Token URL").fill("https://github.com/login/oauth/access_token"); + + await page.getByRole("button", { name: "Connect server" }).click(); + + const request = await createRequest; + const body = request.postDataJSON() as { oauth_config?: { redirect_uri?: string } }; + expect(body.oauth_config?.redirect_uri).toBe(BFF_CALLBACK_URL); + }); +}); diff --git a/server/src/config.ts b/server/src/config.ts index 5439f671..ecd747f0 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -37,6 +37,21 @@ export const config = { // default), so this must stay above the upstream email-delivery timeout. passwordResetRequestTimeoutMs: Number(optional("PASSWORD_RESET_REQUEST_TIMEOUT_MS", "30000")), + // Shared by both OAuth popup proxy routes (routes/proxy/oauth-authorize.ts, + // oauth-callback.ts). GET /oauth/authorize/{id} can synchronously run DCR + // registration (an outbound call to the IdP's own registration/discovery + // endpoints) before it redirects, so this is sized for that -- more + // headroom than a plain API call needs. + oauthProxyTimeoutMs: Number(optional("OAUTH_PROXY_TIMEOUT_MS", "30000")), + + // TTL for the one-time nonce minted by POST /oauth/authorize-nonce and + // required by GET /oauth/authorize/:gatewayId (see + // lib/oauth-authorize-nonce.ts). Short-lived on purpose: the SPA consumes + // it within milliseconds of minting it, so this only needs to cover + // however long a client can plausibly sit on a minted-but-unused nonce + // (e.g. a popup blocked before it navigates), not the OAuth flow itself. + oauthAuthorizeNonceTtlSeconds: Number(optional("OAUTH_AUTHORIZE_NONCE_TTL_SECONDS", "120")), + // memory:// (default) = in-process store, no Redis needed — dev only. // See lib/memory-redis.ts. Use a real redis:// URL beyond a single // local dev process. optionalUnset so REDIS_URL="" also falls through @@ -104,6 +119,17 @@ if ( throw new Error("PASSWORD_RESET_REQUEST_TIMEOUT_MS must be a positive integer"); } +if (!Number.isSafeInteger(config.oauthProxyTimeoutMs) || config.oauthProxyTimeoutMs <= 0) { + throw new Error("OAUTH_PROXY_TIMEOUT_MS must be a positive integer"); +} + +if ( + !Number.isSafeInteger(config.oauthAuthorizeNonceTtlSeconds) || + config.oauthAuthorizeNonceTtlSeconds <= 0 +) { + throw new Error("OAUTH_AUTHORIZE_NONCE_TTL_SECONDS must be a positive integer"); +} + // COOKIE_SECURE=true (prod default) with neither PUBLIC_ORIGIN nor TRUST_PROXY // set means origin-guard.ts derives its expected origin from request.protocol, // which is wrong behind a TLS-terminating proxy (it reads "http" while the diff --git a/server/src/index.ts b/server/src/index.ts index 9c8d5ba5..69270114 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -23,6 +23,10 @@ import loginRoute from "./routes/auth/login.js"; import logoutRoute from "./routes/auth/logout.js"; import sessionRoute from "./routes/auth/session.js"; import catchAllProxyRoute from "./routes/proxy/catch-all.js"; +import oauthAuthorizeProxyRoute from "./routes/proxy/oauth-authorize.js"; +import oauthAuthorizeNonceRoute from "./routes/proxy/oauth-authorize-nonce.js"; +import oauthCallbackProxyRoute from "./routes/proxy/oauth-callback.js"; +import oauthCallbackUrlRoute from "./routes/proxy/oauth-callback-url.js"; import publicPasswordResetRoute from "./routes/proxy/public-password-reset.js"; import { startRevocationSubscriber } from "./routes/sse/revocation-subscriber.js"; import sseRoutes from "./routes/sse/routes.js"; @@ -49,6 +53,10 @@ await fastify.register(sessionRoute); await fastify.register(changePasswordRequiredRoute); await fastify.register(sseRoutes); await fastify.register(publicPasswordResetRoute); +await fastify.register(oauthAuthorizeNonceRoute); +await fastify.register(oauthAuthorizeProxyRoute); +await fastify.register(oauthCallbackProxyRoute); +await fastify.register(oauthCallbackUrlRoute); await fastify.register(catchAllProxyRoute); await fastify.register(appRoute); diff --git a/server/src/lib/oauth-authorize-nonce.ts b/server/src/lib/oauth-authorize-nonce.ts new file mode 100644 index 00000000..a1f96d68 --- /dev/null +++ b/server/src/lib/oauth-authorize-nonce.ts @@ -0,0 +1,58 @@ +// Location: ./client/server/src/lib/oauth-authorize-nonce.ts +// Copyright contributors to the MCP-CONTEXT-FORGE project +// SPDX-License-Identifier: Apache-2.0 +// +// One-time, session-bound nonce gating GET /oauth/authorize/:gatewayId (see +// routes/proxy/oauth-authorize-nonce.ts, routes/proxy/oauth-authorize.ts). +// +// isForbiddenCrossOrigin alone isn't enough on that route: window.open()'s +// top-level navigation carries no Origin header (GET navigations don't send +// one -- see origin-guard.ts), so the guard falls back to Sec-Fetch-Site, +// which reports "same-site" -- not "cross-site" -- for a request from a +// hostile *sibling* subdomain under the same registrable domain (e.g. +// evil.example.com against app.example.com). That sibling still rides the +// victim's SameSite=Lax session cookie on a top-level GET, so without this +// nonce it could otherwise trigger DCR registration and DB writes against a +// gateway of its choosing using the victim's session. +// +// The fix: mint the nonce only from a same-origin, CSRF-protected POST +// (fastify.csrfProtection -- the plugin already used for every other +// mutating browser->BFF call). A hostile sibling subdomain can't forge that +// POST: it doesn't have the CSRF token, which is handed to the SPA only in +// the JSON body of /auth/login and /auth/session, readable by same-origin +// script alone. The authorize route then requires this nonce and consumes +// it, so a captured or guessed authorize URL is usable at most once, and +// only by the session that minted it. + +import { randomUUID } from "node:crypto"; + +import { config } from "../config.js"; +import type { RedisLike } from "./session-store.js"; + +function nonceRedisKey(nonce: string): string { + return `${config.redisKeyPrefix}:oauth-authorize-nonce:${nonce}`; +} + +export async function mintOAuthAuthorizeNonce( + redis: RedisLike, + sessionId: string, +): Promise+ {intl.formatMessage({ id: "mcpServer.auth.oauth.redirectUriHelp" })} +
+ > + ) : ( - -- {intl.formatMessage({ id: "mcpServer.auth.oauth.redirectUriHelp" })} -
+ )} + {!hasStoredRedirectUri && ( ++ {intl.formatMessage({ id: "mcpServer.auth.oauth.redirectUriAutoHelp" })} +
+ )} {isLocalRedirect && ({intl.formatMessage({ id: "mcpServer.auth.oauth.redirectUriLocalWarning" })} diff --git a/src/hooks/useMCPServerForm.ts b/src/hooks/useMCPServerForm.ts index 06492b19..392d4e36 100644 --- a/src/hooks/useMCPServerForm.ts +++ b/src/hooks/useMCPServerForm.ts @@ -361,6 +361,29 @@ export function useMCPServerForm( const isEditMode = Boolean(gatewayId); + // Defaults oauthRedirectUri to this deployment's own /oauth/callback proxy + // (server/src/routes/proxy/oauth-callback-url.ts) rather than leaving the + // field unset. Unset relies on mcpgateway's own APP_DOMAIN-based default, + // which only works when the gateway is independently browser-reachable — + // not the case in a split deployment where only this web UI is + // public-facing. Fetched (not derived from window.location.origin) for the + // 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 }>( + "/oauth/callback-url", + { enabled: authType === "oauth" && oauthGrantType === "authorization_code" }, + ); + + useEffect(() => { + // Only fills a genuinely empty field — a value already loaded from a + // stored gateway (the effect below) or restored some other way always + // wins, and this never overwrites it. + if (defaultOAuthRedirectUri?.redirectUri && !oauthRedirectUri) { + setOAuthRedirectUri(defaultOAuthRedirectUri.redirectUri); + } + }, [defaultOAuthRedirectUri, oauthRedirectUri]); + // Fetch server data when in edit mode // API response uses camelCase outer keys (via alias_generator), but oauth_config dict stays snake_case const { data: serverData, error: serverFetchError } = useQuery<{ diff --git a/src/i18n/locales/en-US/mcpServer.json b/src/i18n/locales/en-US/mcpServer.json index 3316e127..a2e74be5 100644 --- a/src/i18n/locales/en-US/mcpServer.json +++ b/src/i18n/locales/en-US/mcpServer.json @@ -194,7 +194,9 @@ "mcpServer.auth.oauth.redirectUriLabel": "Redirect URI", "mcpServer.auth.oauth.redirectUriCopy": "Copy to clipboard", "mcpServer.auth.oauth.redirectUriHelp": "Configure your OAuth app to use this redirect URI.", - "mcpServer.auth.oauth.redirectUriLocalWarning": "The server's public URL is not configured. Redirect URIs derived from localhost will not work for external OAuth providers.", + "mcpServer.auth.oauth.redirectUriAutoPlaceholder": "Determined automatically by the server", + "mcpServer.auth.oauth.redirectUriAutoHelp": "Set automatically from the server's public URL when authorization starts. Set one explicitly only if this gateway needs a different redirect URI registered with the provider.", + "mcpServer.auth.oauth.redirectUriLocalWarning": "This redirect URI points at localhost, which will not work for external OAuth providers.", "mcpServer.auth.oauth.usernameLabel": "Username", "mcpServer.auth.oauth.usernamePlaceholder": "e.g. service-account", "mcpServer.auth.oauth.passwordLabel": "Password", diff --git a/src/i18n/locales/es-ES/mcpServer.json b/src/i18n/locales/es-ES/mcpServer.json index 1ca2a95d..a91ce727 100644 --- a/src/i18n/locales/es-ES/mcpServer.json +++ b/src/i18n/locales/es-ES/mcpServer.json @@ -194,7 +194,9 @@ "mcpServer.auth.oauth.redirectUriLabel": "URI de redirección", "mcpServer.auth.oauth.redirectUriCopy": "Copiar al portapapeles", "mcpServer.auth.oauth.redirectUriHelp": "Configure su aplicación OAuth para usar esta URI de redirección.", - "mcpServer.auth.oauth.redirectUriLocalWarning": "La URL pública del servidor no está configurada. Las URI de redirección derivadas de localhost no funcionarán con proveedores OAuth externos.", + "mcpServer.auth.oauth.redirectUriAutoPlaceholder": "Determinado automáticamente por el servidor", + "mcpServer.auth.oauth.redirectUriAutoHelp": "Se configura automáticamente a partir de la URL pública del servidor cuando se inicia la autorización. Configure uno explícitamente solo si este gateway necesita una URI de redirección diferente registrada con el proveedor.", + "mcpServer.auth.oauth.redirectUriLocalWarning": "Esta URI de redirección apunta a localhost, lo cual no funcionará con proveedores OAuth externos.", "mcpServer.auth.oauth.usernameLabel": "Nombre de usuario", "mcpServer.auth.oauth.usernamePlaceholder": "p. ej. service-account", "mcpServer.auth.oauth.passwordLabel": "Contraseña", diff --git a/src/i18n/locales/pt-BR/mcpServer.json b/src/i18n/locales/pt-BR/mcpServer.json index e0687e77..4e069ae7 100644 --- a/src/i18n/locales/pt-BR/mcpServer.json +++ b/src/i18n/locales/pt-BR/mcpServer.json @@ -194,7 +194,9 @@ "mcpServer.auth.oauth.redirectUriLabel": "URI de redirecionamento", "mcpServer.auth.oauth.redirectUriCopy": "Copiar para a área de transferência", "mcpServer.auth.oauth.redirectUriHelp": "Configure seu aplicativo OAuth para usar esta URI de redirecionamento.", - "mcpServer.auth.oauth.redirectUriLocalWarning": "A URL pública do servidor não está configurada. URIs de redirecionamento derivadas de localhost não funcionarão com provedores OAuth externos.", + "mcpServer.auth.oauth.redirectUriAutoPlaceholder": "Determinado automaticamente pelo servidor", + "mcpServer.auth.oauth.redirectUriAutoHelp": "Definido automaticamente a partir da URL pública do servidor quando a autorização é iniciada. Defina um valor explicitamente apenas se este gateway precisar de uma URI de redirecionamento diferente registrada no provedor.", + "mcpServer.auth.oauth.redirectUriLocalWarning": "Esta URI de redirecionamento aponta para localhost, o que não funcionará com provedores OAuth externos.", "mcpServer.auth.oauth.usernameLabel": "Nome de usuário", "mcpServer.auth.oauth.usernamePlaceholder": "ex.: service-account", "mcpServer.auth.oauth.passwordLabel": "Senha",