Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
168 changes: 168 additions & 0 deletions e2e/oauth-authorization.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
/**
* 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: `<!DOCTYPE html><html><body><script>
if (window.opener && !window.opener.closed) {
window.opener.postMessage(
{ type: "oauth_callback", status: "success", gatewayId: "${GATEWAY_ID}", gatewayName: "${GATEWAY_NAME}" },
"*"
);
}
window.close();
</script></body></html>`,
});
});

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(`**/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: `<!DOCTYPE html><html><body><script>
if (window.opener && !window.opener.closed) {
window.opener.postMessage(
{ type: "oauth_callback", status: "error", error: "access_denied", errorDescription: "User cancelled" },
"*"
);
}
window.close();
</script></body></html>`,
});
});

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 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();
});
});
11 changes: 11 additions & 0 deletions server/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,13 @@ 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")),

// 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
Expand Down Expand Up @@ -104,6 +111,10 @@ 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");
}

// 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
Expand Down
4 changes: 4 additions & 0 deletions server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ 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 oauthCallbackProxyRoute from "./routes/proxy/oauth-callback.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";
Expand All @@ -49,6 +51,8 @@ await fastify.register(sessionRoute);
await fastify.register(changePasswordRequiredRoute);
await fastify.register(sseRoutes);
await fastify.register(publicPasswordResetRoute);
await fastify.register(oauthAuthorizeProxyRoute);
await fastify.register(oauthCallbackProxyRoute);
await fastify.register(catchAllProxyRoute);
await fastify.register(appRoute);

Expand Down
67 changes: 67 additions & 0 deletions server/src/lib/oauth-upstream-forward.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// Location: ./client/server/src/lib/oauth-upstream-forward.ts
// Copyright contributors to the MCP-CONTEXT-FORGE project
// SPDX-License-Identifier: Apache-2.0
//
// Shared GET-and-forward for the two OAuth popup proxy routes
// (routes/proxy/oauth-authorize.ts, oauth-callback.ts): fetch upstream with
// a timeout, forward status/Location/Content-Type/body, 502 on network
// failure. Kept in one place so a fix to one hop's forwarding behavior
// (e.g. a missing header, the empty-body edge case) can't silently drift
// out of sync with the other's -- same rationale as catch-all.ts centralizing
// rewriteUpstreamLocation/stripInboundHeaders for the /api/* proxy.
//
// Location is forwarded whenever present regardless of caller: harmless for
// oauth-callback.ts (mcpgateway's GET /oauth/callback never redirects), and
// it's the whole point for oauth-authorize.ts (the 302 to the OAuth
// provider). Never rewritten -- unlike catch-all's rewriteUpstreamLocation,
// which only rewrites Location values pointing back at config.contextforgeUrl
// -- because both hops here only ever redirect to an external OAuth
// provider's own absolute URL.

import type { FastifyReply, FastifyRequest } from "fastify";

interface ForwardOAuthGetOptions {
/** Extra headers merged into the upstream request (e.g. the injected bearer token). */
headers?: Record<string, string>;
timeoutMs: number;
/** Included in the network-failure log line, e.g. "OAuth authorize". */
logLabel: string;
}

export async function forwardOAuthGet(
request: FastifyRequest,
reply: FastifyReply,
upstreamUrl: string,
{ headers = {}, timeoutMs, logLabel }: ForwardOAuthGetOptions,
): Promise<FastifyReply> {
let upstreamResponse: Response;
try {
upstreamResponse = await fetch(upstreamUrl, {
method: "GET",
headers: {
accept: "text/html",
// Preserve real client IP for upstream audit logging, same as catch-all.ts.
"x-forwarded-for": request.ip,
"x-real-ip": request.ip,
...headers,
},
redirect: "manual",
signal: AbortSignal.timeout(timeoutMs),
});
} catch (err) {
request.log.error(
{ errorType: err instanceof Error ? err.name : typeof err },
`upstream ${logLabel} request failed`,
);
return reply.code(502).send({ error: "upstream_unavailable" });
}

const location = upstreamResponse.headers.get("location");
if (location) reply.header("location", location);

const contentType = upstreamResponse.headers.get("content-type");
if (contentType) reply.header("content-type", contentType);

const body = await upstreamResponse.text();
return reply.code(upstreamResponse.status).send(body || undefined);
}
77 changes: 77 additions & 0 deletions server/src/routes/proxy/oauth-authorize.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
// Location: ./client/server/src/routes/proxy/oauth-authorize.ts
// Copyright contributors to the MCP-CONTEXT-FORGE project
// SPDX-License-Identifier: Apache-2.0
//
// Authenticated proxy for the OAuth authorization-code popup's first hop.
//
// src/api/servers.ts's triggerOAuthAuthorization opens this path with a raw
// `window.open` navigation, not through the API client, so it carries no
// Authorization header the way /api/* calls do (find-my-way would otherwise
// never have matched this route at all -- with none registered, it fell
// through to static.ts's SPA-fallback 404 handler, which unconditionally
// serves index.html; see mcp-context-forge#6458). mcpgateway's
// GET /oauth/authorize/{id} requires an authenticated user -- it may run DCR
// registration and DB writes before it 302s to the OAuth provider -- so,
// unlike /oauth/callback, this hop has to be proxied through the BFF, which
// injects the bearer token from the session the same way catch-all.ts does
// for /api/*.
//
// See oauth-callback.ts for the second leg: mcpgateway's own callback
// endpoint, which -- unlike this one -- needs no session and is proxied for
// a different reason (making the browser-facing redirect_uri work when the
// gateway itself isn't independently internet-reachable).
//
// GET is a safe method, so catch-all.ts's csrfIfUnsafe wouldn't cover this
// route even if applied -- and window.open() can't set an X-CSRF-Token
// header anyway. The session cookie is SameSite=Lax (session-store.ts),
// which still rides along on a top-level cross-site navigation, and this
// route is not side-effect-free (DCR registration, DB writes) -- so a
// hostile page could force a logged-in victim's browser into
// window.open(`${victimOrigin}/oauth/authorize/<attacker-chosen-id>`) and
// have it execute with the victim's bearer token. isForbiddenCrossOrigin is
// the same guard login.ts and proxy-sse.ts already use for this exact
// category (cookie-authenticated, can't carry a CSRF token) -- see
// lib/origin-guard.ts.
//
// redirect: "manual" (in forwardOAuthGet) so mcpgateway's 302 Location (the
// OAuth provider's own absolute URL) is forwarded to the browser as-is
// rather than followed server-side -- undici's fetch would otherwise try to
// navigate through it, leaking nothing sensitive but pointlessly making a
// request meant for the browser.

import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";

import { config } from "../../config.js";
import { forwardOAuthGet } from "../../lib/oauth-upstream-forward.js";
import { isForbiddenCrossOrigin } from "../../lib/origin-guard.js";
import { setNoStore } from "../../lib/no-store.js";
import { upstreamAuthHeader } from "../../lib/upstream-auth.js";

interface AuthorizeParams {
gatewayId: string;
}

export default async function oauthAuthorizeProxyRoute(fastify: FastifyInstance): Promise<void> {
fastify.get<{ Params: AuthorizeParams }>(
"/oauth/authorize/:gatewayId",
{ preHandler: fastify.sessionAuth },
async (request: FastifyRequest<{ Params: AuthorizeParams }>, reply: FastifyReply) => {
setNoStore(reply);

if (isForbiddenCrossOrigin(request)) {
return reply.code(403).send({ error: "cross_site_request_forbidden" });
}

const bearerToken = request.session!.bearerToken;
const queryIndex = request.url.indexOf("?");
const query = queryIndex === -1 ? "" : request.url.slice(queryIndex);
const upstreamUrl = `${config.contextforgeUrl}/oauth/authorize/${encodeURIComponent(request.params.gatewayId)}${query}`;

return forwardOAuthGet(request, reply, upstreamUrl, {
headers: upstreamAuthHeader(bearerToken),
timeoutMs: config.oauthProxyTimeoutMs,
logLabel: "OAuth authorize",
});
},
);
}
Loading
Loading