adjust default validation for resource parameter in client flow, and server example

This commit is contained in:
Paul Carleton 2025-06-19 17:52:24 +01:00
commit 87da0e0c3a
No known key found for this signature in database
2 changed files with 85 additions and 13 deletions

View file

@ -1,4 +1,4 @@
import { resourceUrlFromServerUrl } from './auth-utils.js';
import { resourceUrlFromServerUrl, checkResourceAllowed } from './auth-utils.js';
describe('auth-utils', () => {
describe('resourceUrlFromServerUrl', () => {
@ -27,4 +27,35 @@ describe('auth-utils', () => {
expect(resourceUrlFromServerUrl(new URL('https://example.com/path/')).href).toBe('https://example.com/path/');
});
});
});
describe('resourceMatches', () => {
it('should match identical URLs', () => {
expect(checkResourceAllowed({ requestedResource: 'https://example.com/path', configuredResource: 'https://example.com/path' })).toBe(true);
expect(checkResourceAllowed({ requestedResource: 'https://example.com/', configuredResource: 'https://example.com/' })).toBe(true);
});
it('should not match URLs with different paths', () => {
expect(checkResourceAllowed({ requestedResource: 'https://example.com/path1', configuredResource: 'https://example.com/path2' })).toBe(false);
expect(checkResourceAllowed({ requestedResource: 'https://example.com/', configuredResource: 'https://example.com/path' })).toBe(false);
});
it('should not match URLs with different domains', () => {
expect(checkResourceAllowed({ requestedResource: 'https://example.com/path', configuredResource: 'https://example.org/path' })).toBe(false);
});
it('should not match URLs with different ports', () => {
expect(checkResourceAllowed({ requestedResource: 'https://example.com:8080/path', configuredResource: 'https://example.com/path' })).toBe(false);
});
it('should not match URLs where one path is a sub-path of another', () => {
expect(checkResourceAllowed({ requestedResource: 'https://example.com/mcpxxxx', configuredResource: 'https://example.com/mcp' })).toBe(false);
expect(checkResourceAllowed({ requestedResource: 'https://example.com/folder', configuredResource: 'https://example.com/folder/subfolder' })).toBe(false);
expect(checkResourceAllowed({ requestedResource: 'https://example.com/api/v1', configuredResource: 'https://example.com/api' })).toBe(true);
});
it('should handle trailing slashes vs no trailing slashes', () => {
expect(checkResourceAllowed({ requestedResource: 'https://example.com/mcp/', configuredResource: 'https://example.com/mcp' })).toBe(true);
expect(checkResourceAllowed({ requestedResource: 'https://example.com/folder', configuredResource: 'https://example.com/folder/' })).toBe(false);
});
});
});

View file

@ -7,8 +7,48 @@
* RFC 8707 section 2 states that resource URIs "MUST NOT include a fragment component".
* Keeps everything else unchanged (scheme, domain, port, path, query).
*/
export function resourceUrlFromServerUrl(url: URL): URL {
const resourceURL = new URL(url.href);
export function resourceUrlFromServerUrl(url: URL | string ): URL {
const resourceURL = typeof url === "string" ? new URL(url) : new URL(url.href);
resourceURL.hash = ''; // Remove fragment
return resourceURL;
}
/**
* Checks if a requested resource URL matches a configured resource URL.
* A requested resource matches if it has the same scheme, domain, port,
* and its path starts with the configured resource's path.
*
* @param requestedResource The resource URL being requested
* @param configuredResource The resource URL that has been configured
* @returns true if the requested resource matches the configured resource, false otherwise
*/
export function checkResourceAllowed(
{ requestedResource, configuredResource }: {
requestedResource: URL | string;
configuredResource: URL | string
}
): boolean {
const requested = typeof requestedResource === "string" ? new URL(requestedResource) : new URL(requestedResource.href);
const configured = typeof configuredResource === "string" ? new URL(configuredResource) : new URL(configuredResource.href);
// Compare the origin (scheme, domain, and port)
if (requested.origin !== configured.origin) {
return false;
}
// Handle cases like requested=/foo and configured=/foo/
if (requested.pathname.length < configured.pathname.length) {
return false
}
// Check if the requested path starts with the configured path
// Ensure both paths end with / for proper comparison
// This ensures that if we have paths like "/api" and "/api/users",
// we properly detect that "/api/users" is a subpath of "/api"
// By adding a trailing slash if missing, we avoid false positives
// where paths like "/api123" would incorrectly match "/api"
const requestedPath = requested.pathname.endsWith('/') ? requested.pathname : requested.pathname + '/';
const configuredPath = configured.pathname.endsWith('/') ? configured.pathname : configured.pathname + '/';
return requestedPath.startsWith(configuredPath);
}