diff --git a/e2e/oauth-authorization.spec.ts b/e2e/oauth-authorization.spec.ts new file mode 100644 index 00000000..15d646a3 --- /dev/null +++ b/e2e/oauth-authorization.spec.ts @@ -0,0 +1,256 @@ +/** + * 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 }), + }); + }); + // Default happy-path stub for the redirect_uri default fetch (see the + // dedicated test below for the split-deployment value it actually + // returns). Submission is gated on this resolving (useMCPServerForm.ts's + // oauthRedirectUriUnresolved), so leaving it unmocked would leave + // "Connect server" permanently disabled here the way it correctly does + // for a real deployment where this fetch fails. + await page.route("**/oauth/callback-url", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ redirectUri: "https://app.example.com/oauth/callback" }), + }); + }); + }); + + 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"); + + // Defaulted from the beforeEach's /oauth/callback-url stub (the + // redirect_uri fix, mcp-context-forge#6458) -- never guessed from + // window.location.origin. + await expect(page.getByLabel(/Redirect URI/i)).toHaveValue( + "https://app.example.com/oauth/callback", + ); + + 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/memory-redis.ts b/server/src/lib/memory-redis.ts index 1ac29147..a8e5c284 100644 --- a/server/src/lib/memory-redis.ts +++ b/server/src/lib/memory-redis.ts @@ -54,6 +54,13 @@ export class MemoryRedis extends EventEmitter { return "OK"; } + async getdel(key: string): Promise+ {intl.formatMessage({ id: "mcpServer.auth.oauth.redirectUriHelp" })} +
+ > + ) : ( - -- {intl.formatMessage({ id: "mcpServer.auth.oauth.redirectUriHelp" })} -
+ )} + {!hasStoredRedirectUri && redirectUriError && ( ++ {intl.formatMessage({ id: "mcpServer.auth.oauth.redirectUriLoadError" })} +
+ {onRetryRedirectUri && ( + + )} ++ {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..a07d3547 100644
--- a/src/hooks/useMCPServerForm.ts
+++ b/src/hooks/useMCPServerForm.ts
@@ -212,6 +212,9 @@ export interface UseMCPServerFormReturn {
oauthGrantType: string;
oauthIssuerUrl: string;
oauthRedirectUri: string;
+ isOAuthRedirectUriLoading: boolean;
+ oauthRedirectUriError: string | undefined;
+ retryOAuthRedirectUri: () => Promise