Skip to content

Commit 66b7cd2

Browse files
authored
Merge pull request #664 from modelcontextprotocol/pcarleton/auth-resource-validation2
[auth] adjust default validation for resource parameter in client flow, and server example
2 parents 1910358 + eff548c commit 66b7cd2

File tree

5 files changed

+150
-14
lines changed

5 files changed

+150
-14
lines changed

src/client/auth.test.ts

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1041,9 +1041,70 @@ describe("OAuth Authorization", () => {
10411041

10421042
// Verify custom validation method was called
10431043
expect(mockValidateResourceURL).toHaveBeenCalledWith(
1044-
"https://api.example.com/mcp-server",
1044+
new URL("https://api.example.com/mcp-server"),
10451045
"https://different-resource.example.com/mcp-server"
10461046
);
10471047
});
1048+
1049+
it("uses prefix of server URL from PRM resource as resource parameter", async () => {
1050+
// Mock successful metadata discovery with resource URL that is a prefix of requested URL
1051+
mockFetch.mockImplementation((url) => {
1052+
const urlString = url.toString();
1053+
1054+
if (urlString.includes("/.well-known/oauth-protected-resource")) {
1055+
return Promise.resolve({
1056+
ok: true,
1057+
status: 200,
1058+
json: async () => ({
1059+
// Resource is a prefix of the requested server URL
1060+
resource: "https://api.example.com/",
1061+
authorization_servers: ["https://auth.example.com"],
1062+
}),
1063+
});
1064+
} else if (urlString.includes("/.well-known/oauth-authorization-server")) {
1065+
return Promise.resolve({
1066+
ok: true,
1067+
status: 200,
1068+
json: async () => ({
1069+
issuer: "https://auth.example.com",
1070+
authorization_endpoint: "https://auth.example.com/authorize",
1071+
token_endpoint: "https://auth.example.com/token",
1072+
response_types_supported: ["code"],
1073+
code_challenge_methods_supported: ["S256"],
1074+
}),
1075+
});
1076+
}
1077+
1078+
return Promise.resolve({ ok: false, status: 404 });
1079+
});
1080+
1081+
// Mock provider methods
1082+
(mockProvider.clientInformation as jest.Mock).mockResolvedValue({
1083+
client_id: "test-client",
1084+
client_secret: "test-secret",
1085+
});
1086+
(mockProvider.tokens as jest.Mock).mockResolvedValue(undefined);
1087+
(mockProvider.saveCodeVerifier as jest.Mock).mockResolvedValue(undefined);
1088+
(mockProvider.redirectToAuthorization as jest.Mock).mockResolvedValue(undefined);
1089+
1090+
// Call auth with a URL that has the resource as prefix
1091+
const result = await auth(mockProvider, {
1092+
serverUrl: "https://api.example.com/mcp-server/endpoint",
1093+
});
1094+
1095+
expect(result).toBe("REDIRECT");
1096+
1097+
// Verify the authorization URL includes the resource parameter from PRM
1098+
expect(mockProvider.redirectToAuthorization).toHaveBeenCalledWith(
1099+
expect.objectContaining({
1100+
searchParams: expect.any(URLSearchParams),
1101+
})
1102+
);
1103+
1104+
const redirectCall = (mockProvider.redirectToAuthorization as jest.Mock).mock.calls[0];
1105+
const authUrl: URL = redirectCall[0];
1106+
// Should use the PRM's resource value, not the full requested URL
1107+
expect(authUrl.searchParams.get("resource")).toBe("https://api.example.com/");
1108+
});
10481109
});
10491110
});

src/client/auth.ts

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import pkceChallenge from "pkce-challenge";
22
import { LATEST_PROTOCOL_VERSION } from "../types.js";
33
import type { OAuthClientMetadata, OAuthClientInformation, OAuthTokens, OAuthMetadata, OAuthClientInformationFull, OAuthProtectedResourceMetadata } from "../shared/auth.js";
44
import { OAuthClientInformationFullSchema, OAuthMetadataSchema, OAuthProtectedResourceMetadataSchema, OAuthTokensSchema } from "../shared/auth.js";
5-
import { resourceUrlFromServerUrl } from "../shared/auth-utils.js";
5+
import { checkResourceAllowed, resourceUrlFromServerUrl } from "../shared/auth-utils.js";
66

77
/**
88
* Implements an end-to-end OAuth client to be used with one MCP server.
@@ -197,14 +197,17 @@ export async function auth(
197197
return "REDIRECT";
198198
}
199199

200-
async function selectResourceURL(serverUrl: string| URL, provider: OAuthClientProvider, resourceMetadata?: OAuthProtectedResourceMetadata): Promise<URL | undefined> {
200+
export async function selectResourceURL(serverUrl: string| URL, provider: OAuthClientProvider, resourceMetadata?: OAuthProtectedResourceMetadata): Promise<URL | undefined> {
201+
let resource = resourceUrlFromServerUrl(serverUrl);
201202
if (provider.validateResourceURL) {
202-
return await provider.validateResourceURL(serverUrl, resourceMetadata?.resource);
203-
}
204-
205-
const resource = resourceUrlFromServerUrl(typeof serverUrl === "string" ? new URL(serverUrl) : serverUrl);
206-
if (resourceMetadata && resourceMetadata.resource !== resource.href) {
207-
throw new Error(`Protected resource ${resourceMetadata.resource} does not match expected ${resource}`);
203+
return await provider.validateResourceURL(resource, resourceMetadata?.resource);
204+
} else if (resourceMetadata) {
205+
if (checkResourceAllowed({ requestedResource: resource, configuredResource: resourceMetadata.resource })) {
206+
// If the resource mentioned in metadata is valid, prefer it since it is what the server is telling us to request.
207+
resource = new URL(resourceMetadata.resource);
208+
} else {
209+
throw new Error(`Protected resource ${resourceMetadata.resource} does not match expected ${resource} (or origin)`);
210+
}
208211
}
209212

210213
return resource;

src/examples/server/simpleStreamableHttp.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { CallToolResult, GetPromptResult, isInitializeRequest, PrimitiveSchemaDe
99
import { InMemoryEventStore } from '../shared/inMemoryEventStore.js';
1010
import { setupAuthServer } from './demoInMemoryOAuthProvider.js';
1111
import { OAuthMetadata } from 'src/shared/auth.js';
12+
import { checkResourceAllowed } from 'src/shared/auth-utils.js';
1213

1314
// Check for OAuth flag
1415
const useOAuth = process.argv.includes('--oauth');
@@ -463,7 +464,7 @@ if (useOAuth) {
463464
if (!data.aud) {
464465
throw new Error(`Resource Indicator (RFC8707) missing`);
465466
}
466-
if (data.aud !== mcpServerUrl.href) {
467+
if (!checkResourceAllowed({ requestedResource: data.aud, configuredResource: mcpServerUrl })) {
467468
throw new Error(`Expected resource indicator ${mcpServerUrl}, got: ${data.aud}`);
468469
}
469470
}

src/shared/auth-utils.test.ts

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { resourceUrlFromServerUrl } from './auth-utils.js';
1+
import { resourceUrlFromServerUrl, checkResourceAllowed } from './auth-utils.js';
22

33
describe('auth-utils', () => {
44
describe('resourceUrlFromServerUrl', () => {
@@ -27,4 +27,35 @@ describe('auth-utils', () => {
2727
expect(resourceUrlFromServerUrl(new URL('https://example.com/path/')).href).toBe('https://example.com/path/');
2828
});
2929
});
30-
});
30+
31+
describe('resourceMatches', () => {
32+
it('should match identical URLs', () => {
33+
expect(checkResourceAllowed({ requestedResource: 'https://example.com/path', configuredResource: 'https://example.com/path' })).toBe(true);
34+
expect(checkResourceAllowed({ requestedResource: 'https://example.com/', configuredResource: 'https://example.com/' })).toBe(true);
35+
});
36+
37+
it('should not match URLs with different paths', () => {
38+
expect(checkResourceAllowed({ requestedResource: 'https://example.com/path1', configuredResource: 'https://example.com/path2' })).toBe(false);
39+
expect(checkResourceAllowed({ requestedResource: 'https://example.com/', configuredResource: 'https://example.com/path' })).toBe(false);
40+
});
41+
42+
it('should not match URLs with different domains', () => {
43+
expect(checkResourceAllowed({ requestedResource: 'https://example.com/path', configuredResource: 'https://example.org/path' })).toBe(false);
44+
});
45+
46+
it('should not match URLs with different ports', () => {
47+
expect(checkResourceAllowed({ requestedResource: 'https://example.com:8080/path', configuredResource: 'https://example.com/path' })).toBe(false);
48+
});
49+
50+
it('should not match URLs where one path is a sub-path of another', () => {
51+
expect(checkResourceAllowed({ requestedResource: 'https://example.com/mcpxxxx', configuredResource: 'https://example.com/mcp' })).toBe(false);
52+
expect(checkResourceAllowed({ requestedResource: 'https://example.com/folder', configuredResource: 'https://example.com/folder/subfolder' })).toBe(false);
53+
expect(checkResourceAllowed({ requestedResource: 'https://example.com/api/v1', configuredResource: 'https://example.com/api' })).toBe(true);
54+
});
55+
56+
it('should handle trailing slashes vs no trailing slashes', () => {
57+
expect(checkResourceAllowed({ requestedResource: 'https://example.com/mcp/', configuredResource: 'https://example.com/mcp' })).toBe(true);
58+
expect(checkResourceAllowed({ requestedResource: 'https://example.com/folder', configuredResource: 'https://example.com/folder/' })).toBe(false);
59+
});
60+
});
61+
});

src/shared/auth-utils.ts

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,48 @@
77
* RFC 8707 section 2 states that resource URIs "MUST NOT include a fragment component".
88
* Keeps everything else unchanged (scheme, domain, port, path, query).
99
*/
10-
export function resourceUrlFromServerUrl(url: URL): URL {
11-
const resourceURL = new URL(url.href);
10+
export function resourceUrlFromServerUrl(url: URL | string ): URL {
11+
const resourceURL = typeof url === "string" ? new URL(url) : new URL(url.href);
1212
resourceURL.hash = ''; // Remove fragment
1313
return resourceURL;
1414
}
15+
16+
/**
17+
* Checks if a requested resource URL matches a configured resource URL.
18+
* A requested resource matches if it has the same scheme, domain, port,
19+
* and its path starts with the configured resource's path.
20+
*
21+
* @param requestedResource The resource URL being requested
22+
* @param configuredResource The resource URL that has been configured
23+
* @returns true if the requested resource matches the configured resource, false otherwise
24+
*/
25+
export function checkResourceAllowed(
26+
{ requestedResource, configuredResource }: {
27+
requestedResource: URL | string;
28+
configuredResource: URL | string
29+
}
30+
): boolean {
31+
const requested = typeof requestedResource === "string" ? new URL(requestedResource) : new URL(requestedResource.href);
32+
const configured = typeof configuredResource === "string" ? new URL(configuredResource) : new URL(configuredResource.href);
33+
34+
// Compare the origin (scheme, domain, and port)
35+
if (requested.origin !== configured.origin) {
36+
return false;
37+
}
38+
39+
// Handle cases like requested=/foo and configured=/foo/
40+
if (requested.pathname.length < configured.pathname.length) {
41+
return false
42+
}
43+
44+
// Check if the requested path starts with the configured path
45+
// Ensure both paths end with / for proper comparison
46+
// This ensures that if we have paths like "/api" and "/api/users",
47+
// we properly detect that "/api/users" is a subpath of "/api"
48+
// By adding a trailing slash if missing, we avoid false positives
49+
// where paths like "/api123" would incorrectly match "/api"
50+
const requestedPath = requested.pathname.endsWith('/') ? requested.pathname : requested.pathname + '/';
51+
const configuredPath = configured.pathname.endsWith('/') ? configured.pathname : configured.pathname + '/';
52+
53+
return requestedPath.startsWith(configuredPath);
54+
}

0 commit comments

Comments
 (0)