Skip to content
Merged
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
256 changes: 256 additions & 0 deletions e2e/oauth-authorization.spec.ts
Original file line number Diff line number Diff line change
@@ -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: `<!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 }),
});
});

// 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: `<!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.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);
});
});
26 changes: 26 additions & 0 deletions server/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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);

Expand Down
7 changes: 7 additions & 0 deletions server/src/lib/memory-redis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,13 @@ export class MemoryRedis extends EventEmitter {
return "OK";
}

async getdel(key: string): Promise<string | null> {
const entry = store.get(key);
store.delete(key);
if (!entry || isExpired(entry)) return null;
return entry.value;
}

async del(key: string): Promise<number> {
return store.delete(key) ? 1 : 0;
}
Expand Down
59 changes: 59 additions & 0 deletions server/src/lib/oauth-authorize-nonce.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// 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<string> {
const nonce = randomUUID();
await redis.setex(nonceRedisKey(nonce), config.oauthAuthorizeNonceTtlSeconds, sessionId);
return nonce;
}

// GETDEL, not GET-then-DEL: reading and deleting must be one atomic op, or
// two concurrent requests for the same nonce can both read its session
// binding before either delete runs, letting both proceed. That deletes the
// nonce whether or not it matches -- a captured query string (browser
// history, a proxy access log, a copy-pasted URL) must not be replayable
// even by the same session that minted it.
export async function consumeOAuthAuthorizeNonce(
redis: RedisLike,
sessionId: string,
nonce: string | undefined,
): Promise<boolean> {
if (!nonce) return false;
const mintedForSession = await redis.getdel(nonceRedisKey(nonce));
return mintedForSession === sessionId;
}
Loading
Loading