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
38 changes: 27 additions & 11 deletions src/auth/auth-metadata.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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,
Expand Down
14 changes: 13 additions & 1 deletion src/auth/auth-wrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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");
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,5 @@ export {
generateProtectedResourceMetadata,
metadataCorsOptionsRequestHandler,
} from "./auth/auth-metadata";

export { getPublicOrigin, getPublicUrl } from "./lib/url";
83 changes: 83 additions & 0 deletions src/lib/url.ts
Original file line number Diff line number Diff line change
@@ -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;
}
93 changes: 92 additions & 1 deletion tests/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
});