From 8ed9968d93c47e68edde0c699ca21c6e7bc5d7b8 Mon Sep 17 00:00:00 2001 From: Andrew Qu Date: Sat, 3 Jan 2026 13:32:38 -0800 Subject: [PATCH] init --- src/auth/auth-metadata.ts | 38 +++++++++++----- src/auth/auth-wrapper.ts | 14 +++++- src/index.ts | 2 + src/lib/url.ts | 83 ++++++++++++++++++++++++++++++++++ tests/auth.test.ts | 93 ++++++++++++++++++++++++++++++++++++++- 5 files changed, 217 insertions(+), 13 deletions(-) create mode 100644 src/lib/url.ts diff --git a/src/auth/auth-metadata.ts b/src/auth/auth-metadata.ts index 30add88..d06b28d 100644 --- a/src/auth/auth-metadata.ts +++ b/src/auth/auth-metadata.ts @@ -1,4 +1,5 @@ import { OAuthProtectedResourceMetadata } from "@modelcontextprotocol/sdk/shared/auth.js"; +import { getPublicUrl } from "../lib/url"; /** * CORS headers for OAuth Protected Resource Metadata endpoint. @@ -14,27 +15,42 @@ const corsHeaders = { /** * OAuth 2.0 Protected Resource Metadata endpoint based on RFC 9728. * @see https://datatracker.ietf.org/doc/html/rfc9728 - * - * @param authServerUrls - Array of issuer URLs of the OAuth 2.0 Authorization Servers. - * These should match the "issuer" field in the authorization servers' + * + * @param authServerUrls - Array of issuer URLs of the OAuth 2.0 Authorization Servers. + * These should match the "issuer" field in the authorization servers' * OAuth metadata (RFC 8414). + * @param resourceUrl - Optional explicit resource URL override. When provided, this URL is + * used instead of deriving it from the request. Use this when running + * behind a proxy that doesn't set standard forwarding headers. + * If not provided, the URL is automatically detected from proxy headers + * (X-Forwarded-Host, X-Forwarded-Proto, Forwarded) or falls back to req.url. */ export function protectedResourceHandler({ authServerUrls, + resourceUrl: explicitResourceUrl, }: { authServerUrls: string[]; + resourceUrl?: string; }) { return (req: Request) => { - const resourceUrl = new URL(req.url); + let resource: string; + + if (explicitResourceUrl) { + // Use explicit override if provided + resource = explicitResourceUrl; + } else { + // Auto-detect from proxy headers or req.url + const publicUrl = getPublicUrl(req); - resourceUrl.pathname = resourceUrl.pathname - .replace(/^\/\.well-known\/[^\/]+/, ""); + publicUrl.pathname = publicUrl.pathname + .replace(/^\/\.well-known\/[^\/]+/, ""); - // The URL class does not allow for empty `pathname` and will replace it - // with "/". Here, we correct that. - const resource = resourceUrl.pathname === '/' - ? resourceUrl.toString().replace(/\/$/, '') - : resourceUrl.toString(); + // The URL class does not allow for empty `pathname` and will replace it + // with "/". Here, we correct that. + resource = publicUrl.pathname === '/' + ? publicUrl.toString().replace(/\/$/, '') + : publicUrl.toString(); + } const metadata = generateProtectedResourceMetadata({ authServerUrls, diff --git a/src/auth/auth-wrapper.ts b/src/auth/auth-wrapper.ts index b11751b..be7c096 100644 --- a/src/auth/auth-wrapper.ts +++ b/src/auth/auth-wrapper.ts @@ -5,6 +5,7 @@ import { ServerError, } from "@modelcontextprotocol/sdk/server/auth/errors.js"; import {withAuthContext} from "./auth-context"; +import {getPublicOrigin} from "../lib/url"; declare global { interface Request { @@ -22,14 +23,25 @@ export function withMcpAuth( required = false, resourceMetadataPath = "/.well-known/oauth-protected-resource", requiredScopes, + resourceUrl, }: { required?: boolean; resourceMetadataPath?: string; requiredScopes?: string[]; + /** + * Explicit resource URL override. When provided, this URL is used as the + * origin for constructing the resource_metadata URL. Use this when running + * behind a proxy that doesn't set standard forwarding headers, or when you + * need to specify a specific public URL. + * + * If not provided, the origin is automatically detected from proxy headers + * (X-Forwarded-Host, X-Forwarded-Proto, Forwarded) or falls back to req.url. + */ + resourceUrl?: string; } = {} ) { return async (req: Request) => { - const origin = new URL(req.url).origin; + const origin = resourceUrl ?? getPublicOrigin(req); const resourceMetadataUrl = `${origin}${resourceMetadataPath}`; const authHeader = req.headers.get("Authorization"); diff --git a/src/index.ts b/src/index.ts index 93b3235..7d4da9b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,3 +13,5 @@ export { generateProtectedResourceMetadata, metadataCorsOptionsRequestHandler, } from "./auth/auth-metadata"; + +export { getPublicOrigin, getPublicUrl } from "./lib/url"; diff --git a/src/lib/url.ts b/src/lib/url.ts new file mode 100644 index 0000000..c4ca247 --- /dev/null +++ b/src/lib/url.ts @@ -0,0 +1,83 @@ +/** + * Get the public-facing origin from a request, respecting proxy headers. + * + * When running behind a reverse proxy (e.g., nginx, Vercel, Cloudflare), + * the `req.url` typically reflects the internal URL (e.g., http://localhost:3000). + * This function reconstructs the public-facing origin using standard proxy headers. + * + * Header precedence: + * 1. X-Forwarded-Host + X-Forwarded-Proto (most common) + * 2. Forwarded header (RFC 7239) + * 3. Falls back to req.url origin + * + * @param req - The incoming request + * @returns The public-facing origin (e.g., "https://example.org") + */ +export function getPublicOrigin(req: Request): string { + const forwardedHost = req.headers.get("x-forwarded-host"); + const forwardedProto = req.headers.get("x-forwarded-proto"); + + // If we have X-Forwarded-Host, construct origin from forwarded headers + if (forwardedHost) { + // X-Forwarded-Host can contain multiple comma-separated values; use the first (leftmost) + const host = forwardedHost.split(",")[0].trim(); + // X-Forwarded-Proto can also be comma-separated + const proto = forwardedProto?.split(",")[0].trim() || "https"; + return `${proto}://${host}`; + } + + // Check RFC 7239 Forwarded header (less common but standardized) + const forwarded = req.headers.get("forwarded"); + if (forwarded) { + const parsed = parseForwardedHeader(forwarded); + if (parsed.host) { + const proto = parsed.proto || "https"; + return `${proto}://${parsed.host}`; + } + } + + // Fallback to req.url origin + return new URL(req.url).origin; +} + +/** + * Get the public-facing URL from a request, respecting proxy headers. + * + * @param req - The incoming request + * @returns The public-facing URL with the correct origin + */ +export function getPublicUrl(req: Request): URL { + const url = new URL(req.url); + const publicOrigin = getPublicOrigin(req); + + // Construct a new URL with the public origin but preserve pathname, search, and hash + const result = new URL(url.pathname + url.search + url.hash, publicOrigin); + return result; +} + +/** + * Parse the RFC 7239 Forwarded header. + * Example: "for=192.0.2.60;proto=https;host=example.com" + */ +function parseForwardedHeader( + forwarded: string +): { host?: string; proto?: string } { + const result: { host?: string; proto?: string } = {}; + + // The header can contain multiple comma-separated forwarded elements; use the first + const firstElement = forwarded.split(",")[0]; + + // Parse key=value pairs separated by semicolons + const pairs = firstElement.split(";"); + for (const pair of pairs) { + const [key, value] = pair.split("=").map((s) => s.trim().toLowerCase()); + if (key === "host" && value) { + // Remove surrounding quotes if present + result.host = value.replace(/^"|"$/g, ""); + } else if (key === "proto" && value) { + result.proto = value.replace(/^"|"$/g, ""); + } + } + + return result; +} diff --git a/tests/auth.test.ts b/tests/auth.test.ts index 685e785..86bd43e 100644 --- a/tests/auth.test.ts +++ b/tests/auth.test.ts @@ -40,11 +40,102 @@ describe("auth", () => { testCases.forEach(testCase => { it(`${testCase.resourceMetadata} → ${testCase.resource}`, async () => { const req = new Request(testCase.resourceMetadata); - const res = handler(req); + const res = handler(req); const json = await res.json(); expect(json.resource).toBe(testCase.resource); }); }); }); + + describe("proxy header support", () => { + const handler = protectedResourceHandler({ + authServerUrls: ["https://auth-server.com"], + }); + + it("uses X-Forwarded-Host and X-Forwarded-Proto headers", async () => { + const req = new Request("http://localhost:3000/.well-known/oauth-protected-resource", { + headers: { + "X-Forwarded-Host": "example.org", + "X-Forwarded-Proto": "https", + }, + }); + const res = handler(req); + const json = await res.json(); + expect(json.resource).toBe("https://example.org"); + }); + + it("handles X-Forwarded-Host with multiple values (uses first)", async () => { + const req = new Request("http://localhost:3000/.well-known/oauth-protected-resource", { + headers: { + "X-Forwarded-Host": "example.org, proxy1.internal, proxy2.internal", + "X-Forwarded-Proto": "https", + }, + }); + const res = handler(req); + const json = await res.json(); + expect(json.resource).toBe("https://example.org"); + }); + + it("defaults to https when X-Forwarded-Proto is missing", async () => { + const req = new Request("http://localhost:3000/.well-known/oauth-protected-resource", { + headers: { + "X-Forwarded-Host": "example.org", + }, + }); + const res = handler(req); + const json = await res.json(); + expect(json.resource).toBe("https://example.org"); + }); + + it("uses RFC 7239 Forwarded header", async () => { + const req = new Request("http://localhost:3000/.well-known/oauth-protected-resource", { + headers: { + "Forwarded": "host=example.org;proto=https", + }, + }); + const res = handler(req); + const json = await res.json(); + expect(json.resource).toBe("https://example.org"); + }); + + it("preserves path when using proxy headers", async () => { + const req = new Request("http://localhost:3000/.well-known/oauth-protected-resource/my-resource", { + headers: { + "X-Forwarded-Host": "example.org", + "X-Forwarded-Proto": "https", + }, + }); + const res = handler(req); + const json = await res.json(); + expect(json.resource).toBe("https://example.org/my-resource"); + }); + + it("falls back to req.url when no proxy headers present", async () => { + const req = new Request("https://direct-server.com/.well-known/oauth-protected-resource"); + const res = handler(req); + const json = await res.json(); + expect(json.resource).toBe("https://direct-server.com"); + }); + }); + + describe("explicit resourceUrl override", () => { + it("uses explicit resourceUrl when provided", async () => { + const handler = protectedResourceHandler({ + authServerUrls: ["https://auth-server.com"], + resourceUrl: "https://my-public-domain.com", + }); + + const req = new Request("http://localhost:3000/.well-known/oauth-protected-resource", { + headers: { + "X-Forwarded-Host": "different-proxy.org", + "X-Forwarded-Proto": "https", + }, + }); + const res = handler(req); + const json = await res.json(); + // Should use explicit override, ignoring both req.url and proxy headers + expect(json.resource).toBe("https://my-public-domain.com"); + }); + }); });