Skip to content

Commit 52e599a

Browse files
authored
init (#136)
1 parent 8c51d39 commit 52e599a

5 files changed

Lines changed: 217 additions & 13 deletions

File tree

src/auth/auth-metadata.ts

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { OAuthProtectedResourceMetadata } from "@modelcontextprotocol/sdk/shared/auth.js";
2+
import { getPublicUrl } from "../lib/url";
23

34
/**
45
* CORS headers for OAuth Protected Resource Metadata endpoint.
@@ -14,27 +15,42 @@ const corsHeaders = {
1415
/**
1516
* OAuth 2.0 Protected Resource Metadata endpoint based on RFC 9728.
1617
* @see https://datatracker.ietf.org/doc/html/rfc9728
17-
*
18-
* @param authServerUrls - Array of issuer URLs of the OAuth 2.0 Authorization Servers.
19-
* These should match the "issuer" field in the authorization servers'
18+
*
19+
* @param authServerUrls - Array of issuer URLs of the OAuth 2.0 Authorization Servers.
20+
* These should match the "issuer" field in the authorization servers'
2021
* OAuth metadata (RFC 8414).
22+
* @param resourceUrl - Optional explicit resource URL override. When provided, this URL is
23+
* used instead of deriving it from the request. Use this when running
24+
* behind a proxy that doesn't set standard forwarding headers.
25+
* If not provided, the URL is automatically detected from proxy headers
26+
* (X-Forwarded-Host, X-Forwarded-Proto, Forwarded) or falls back to req.url.
2127
*/
2228
export function protectedResourceHandler({
2329
authServerUrls,
30+
resourceUrl: explicitResourceUrl,
2431
}: {
2532
authServerUrls: string[];
33+
resourceUrl?: string;
2634
}) {
2735
return (req: Request) => {
28-
const resourceUrl = new URL(req.url);
36+
let resource: string;
37+
38+
if (explicitResourceUrl) {
39+
// Use explicit override if provided
40+
resource = explicitResourceUrl;
41+
} else {
42+
// Auto-detect from proxy headers or req.url
43+
const publicUrl = getPublicUrl(req);
2944

30-
resourceUrl.pathname = resourceUrl.pathname
31-
.replace(/^\/\.well-known\/[^\/]+/, "");
45+
publicUrl.pathname = publicUrl.pathname
46+
.replace(/^\/\.well-known\/[^\/]+/, "");
3247

33-
// The URL class does not allow for empty `pathname` and will replace it
34-
// with "/". Here, we correct that.
35-
const resource = resourceUrl.pathname === '/'
36-
? resourceUrl.toString().replace(/\/$/, '')
37-
: resourceUrl.toString();
48+
// The URL class does not allow for empty `pathname` and will replace it
49+
// with "/". Here, we correct that.
50+
resource = publicUrl.pathname === '/'
51+
? publicUrl.toString().replace(/\/$/, '')
52+
: publicUrl.toString();
53+
}
3854

3955
const metadata = generateProtectedResourceMetadata({
4056
authServerUrls,

src/auth/auth-wrapper.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
ServerError,
66
} from "@modelcontextprotocol/sdk/server/auth/errors.js";
77
import {withAuthContext} from "./auth-context";
8+
import {getPublicOrigin} from "../lib/url";
89

910
declare global {
1011
interface Request {
@@ -22,14 +23,25 @@ export function withMcpAuth(
2223
required = false,
2324
resourceMetadataPath = "/.well-known/oauth-protected-resource",
2425
requiredScopes,
26+
resourceUrl,
2527
}: {
2628
required?: boolean;
2729
resourceMetadataPath?: string;
2830
requiredScopes?: string[];
31+
/**
32+
* Explicit resource URL override. When provided, this URL is used as the
33+
* origin for constructing the resource_metadata URL. Use this when running
34+
* behind a proxy that doesn't set standard forwarding headers, or when you
35+
* need to specify a specific public URL.
36+
*
37+
* If not provided, the origin is automatically detected from proxy headers
38+
* (X-Forwarded-Host, X-Forwarded-Proto, Forwarded) or falls back to req.url.
39+
*/
40+
resourceUrl?: string;
2941
} = {}
3042
) {
3143
return async (req: Request) => {
32-
const origin = new URL(req.url).origin;
44+
const origin = resourceUrl ?? getPublicOrigin(req);
3345
const resourceMetadataUrl = `${origin}${resourceMetadataPath}`;
3446

3547
const authHeader = req.headers.get("Authorization");

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,5 @@ export {
1313
generateProtectedResourceMetadata,
1414
metadataCorsOptionsRequestHandler,
1515
} from "./auth/auth-metadata";
16+
17+
export { getPublicOrigin, getPublicUrl } from "./lib/url";

src/lib/url.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/**
2+
* Get the public-facing origin from a request, respecting proxy headers.
3+
*
4+
* When running behind a reverse proxy (e.g., nginx, Vercel, Cloudflare),
5+
* the `req.url` typically reflects the internal URL (e.g., http://localhost:3000).
6+
* This function reconstructs the public-facing origin using standard proxy headers.
7+
*
8+
* Header precedence:
9+
* 1. X-Forwarded-Host + X-Forwarded-Proto (most common)
10+
* 2. Forwarded header (RFC 7239)
11+
* 3. Falls back to req.url origin
12+
*
13+
* @param req - The incoming request
14+
* @returns The public-facing origin (e.g., "https://example.org")
15+
*/
16+
export function getPublicOrigin(req: Request): string {
17+
const forwardedHost = req.headers.get("x-forwarded-host");
18+
const forwardedProto = req.headers.get("x-forwarded-proto");
19+
20+
// If we have X-Forwarded-Host, construct origin from forwarded headers
21+
if (forwardedHost) {
22+
// X-Forwarded-Host can contain multiple comma-separated values; use the first (leftmost)
23+
const host = forwardedHost.split(",")[0].trim();
24+
// X-Forwarded-Proto can also be comma-separated
25+
const proto = forwardedProto?.split(",")[0].trim() || "https";
26+
return `${proto}://${host}`;
27+
}
28+
29+
// Check RFC 7239 Forwarded header (less common but standardized)
30+
const forwarded = req.headers.get("forwarded");
31+
if (forwarded) {
32+
const parsed = parseForwardedHeader(forwarded);
33+
if (parsed.host) {
34+
const proto = parsed.proto || "https";
35+
return `${proto}://${parsed.host}`;
36+
}
37+
}
38+
39+
// Fallback to req.url origin
40+
return new URL(req.url).origin;
41+
}
42+
43+
/**
44+
* Get the public-facing URL from a request, respecting proxy headers.
45+
*
46+
* @param req - The incoming request
47+
* @returns The public-facing URL with the correct origin
48+
*/
49+
export function getPublicUrl(req: Request): URL {
50+
const url = new URL(req.url);
51+
const publicOrigin = getPublicOrigin(req);
52+
53+
// Construct a new URL with the public origin but preserve pathname, search, and hash
54+
const result = new URL(url.pathname + url.search + url.hash, publicOrigin);
55+
return result;
56+
}
57+
58+
/**
59+
* Parse the RFC 7239 Forwarded header.
60+
* Example: "for=192.0.2.60;proto=https;host=example.com"
61+
*/
62+
function parseForwardedHeader(
63+
forwarded: string
64+
): { host?: string; proto?: string } {
65+
const result: { host?: string; proto?: string } = {};
66+
67+
// The header can contain multiple comma-separated forwarded elements; use the first
68+
const firstElement = forwarded.split(",")[0];
69+
70+
// Parse key=value pairs separated by semicolons
71+
const pairs = firstElement.split(";");
72+
for (const pair of pairs) {
73+
const [key, value] = pair.split("=").map((s) => s.trim().toLowerCase());
74+
if (key === "host" && value) {
75+
// Remove surrounding quotes if present
76+
result.host = value.replace(/^"|"$/g, "");
77+
} else if (key === "proto" && value) {
78+
result.proto = value.replace(/^"|"$/g, "");
79+
}
80+
}
81+
82+
return result;
83+
}

tests/auth.test.ts

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,102 @@ describe("auth", () => {
4040
testCases.forEach(testCase => {
4141
it(`${testCase.resourceMetadata}${testCase.resource}`, async () => {
4242
const req = new Request(testCase.resourceMetadata);
43-
const res = handler(req);
43+
const res = handler(req);
4444
const json = await res.json();
4545
expect(json.resource).toBe(testCase.resource);
4646
});
4747
});
4848
});
49+
50+
describe("proxy header support", () => {
51+
const handler = protectedResourceHandler({
52+
authServerUrls: ["https://auth-server.com"],
53+
});
54+
55+
it("uses X-Forwarded-Host and X-Forwarded-Proto headers", async () => {
56+
const req = new Request("http://localhost:3000/.well-known/oauth-protected-resource", {
57+
headers: {
58+
"X-Forwarded-Host": "example.org",
59+
"X-Forwarded-Proto": "https",
60+
},
61+
});
62+
const res = handler(req);
63+
const json = await res.json();
64+
expect(json.resource).toBe("https://example.org");
65+
});
66+
67+
it("handles X-Forwarded-Host with multiple values (uses first)", async () => {
68+
const req = new Request("http://localhost:3000/.well-known/oauth-protected-resource", {
69+
headers: {
70+
"X-Forwarded-Host": "example.org, proxy1.internal, proxy2.internal",
71+
"X-Forwarded-Proto": "https",
72+
},
73+
});
74+
const res = handler(req);
75+
const json = await res.json();
76+
expect(json.resource).toBe("https://example.org");
77+
});
78+
79+
it("defaults to https when X-Forwarded-Proto is missing", async () => {
80+
const req = new Request("http://localhost:3000/.well-known/oauth-protected-resource", {
81+
headers: {
82+
"X-Forwarded-Host": "example.org",
83+
},
84+
});
85+
const res = handler(req);
86+
const json = await res.json();
87+
expect(json.resource).toBe("https://example.org");
88+
});
89+
90+
it("uses RFC 7239 Forwarded header", async () => {
91+
const req = new Request("http://localhost:3000/.well-known/oauth-protected-resource", {
92+
headers: {
93+
"Forwarded": "host=example.org;proto=https",
94+
},
95+
});
96+
const res = handler(req);
97+
const json = await res.json();
98+
expect(json.resource).toBe("https://example.org");
99+
});
100+
101+
it("preserves path when using proxy headers", async () => {
102+
const req = new Request("http://localhost:3000/.well-known/oauth-protected-resource/my-resource", {
103+
headers: {
104+
"X-Forwarded-Host": "example.org",
105+
"X-Forwarded-Proto": "https",
106+
},
107+
});
108+
const res = handler(req);
109+
const json = await res.json();
110+
expect(json.resource).toBe("https://example.org/my-resource");
111+
});
112+
113+
it("falls back to req.url when no proxy headers present", async () => {
114+
const req = new Request("https://direct-server.com/.well-known/oauth-protected-resource");
115+
const res = handler(req);
116+
const json = await res.json();
117+
expect(json.resource).toBe("https://direct-server.com");
118+
});
119+
});
120+
121+
describe("explicit resourceUrl override", () => {
122+
it("uses explicit resourceUrl when provided", async () => {
123+
const handler = protectedResourceHandler({
124+
authServerUrls: ["https://auth-server.com"],
125+
resourceUrl: "https://my-public-domain.com",
126+
});
127+
128+
const req = new Request("http://localhost:3000/.well-known/oauth-protected-resource", {
129+
headers: {
130+
"X-Forwarded-Host": "different-proxy.org",
131+
"X-Forwarded-Proto": "https",
132+
},
133+
});
134+
const res = handler(req);
135+
const json = await res.json();
136+
// Should use explicit override, ignoring both req.url and proxy headers
137+
expect(json.resource).toBe("https://my-public-domain.com");
138+
});
139+
});
49140
});
50141

0 commit comments

Comments
 (0)