Skip to content
Closed
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
105 changes: 105 additions & 0 deletions packages/core/src/mcp/oauth-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,47 @@ describe('OAuthUtils', () => {
scopes: ['read', 'write'],
});
});

it('should validate against root resource when path metadata falls back to root discovery', async () => {
mockFetch
// path-based protected resource metadata is unavailable
.mockResolvedValueOnce({
ok: false,
})
// root-based protected resource metadata succeeds
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
resource: 'https://example.com',
authorization_servers: ['https://auth.example.com'],
bearer_methods_supported: ['header'],
}),
})
// discoverAuthorizationServerMetadata
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockAuthServerMetadata),
});

await expect(
OAuthUtils.discoverOAuthConfig('https://example.com/mcp'),
).resolves.toEqual({
authorizationUrl: 'https://auth.example.com/authorize',
issuer: 'https://auth.example.com',
tokenUrl: 'https://auth.example.com/token',
scopes: ['read', 'write'],
});

expect(mockFetch).nthCalledWith(
1,
'https://example.com/.well-known/oauth-protected-resource/mcp',
);
expect(mockFetch).nthCalledWith(
2,
'https://example.com/.well-known/oauth-protected-resource',
);
});
});

describe('metadataToOAuthConfig', () => {
Expand Down Expand Up @@ -401,6 +442,36 @@ describe('OAuthUtils', () => {
scopes: ['read', 'write'],
});
});

it('should validate against resource_metadata URL instead of MCP server path', async () => {
mockFetch
// fetchProtectedResourceMetadata(resource_metadata URL)
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
resource: 'https://example.com',
authorization_servers: ['https://auth.example.com'],
}),
})
// discoverAuthorizationServerMetadata(auth server well-known URL)
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockAuthServerMetadata),
});

await expect(
OAuthUtils.discoverOAuthFromWWWAuthenticate(
'Bearer realm="example", resource_metadata="https://example.com/.well-known/oauth-protected-resource"',
'https://example.com/mcp',
),
).resolves.toEqual({
authorizationUrl: 'https://auth.example.com/authorize',
issuer: 'https://auth.example.com',
tokenUrl: 'https://auth.example.com/token',
scopes: ['read', 'write'],
});
});
});

describe('extractBaseUrl', () => {
Expand Down Expand Up @@ -471,6 +542,40 @@ describe('OAuthUtils', () => {
});
});

describe('buildResourceParameterFromMetadataUrl', () => {
it('should infer a root resource from a root protected resource metadata URL', () => {
const result = OAuthUtils.buildResourceParameterFromMetadataUrl(
'https://example.com/.well-known/oauth-protected-resource',
);

expect(result).toBe('https://example.com/');
});

it('should infer a path resource from a path-based protected resource metadata URL', () => {
const result = OAuthUtils.buildResourceParameterFromMetadataUrl(
'https://example.com/.well-known/oauth-protected-resource/mcp/v1',
);

expect(result).toBe('https://example.com/mcp/v1');
});

it('should reject non-protected-resource metadata URLs', () => {
expect(() =>
OAuthUtils.buildResourceParameterFromMetadataUrl(
'https://example.com/.well-known/oauth-authorization-server',
),
).toThrow(/Invalid protected resource metadata URL/);
});

it('should reject protocol-relative resource paths', () => {
expect(() =>
OAuthUtils.buildResourceParameterFromMetadataUrl(
'https://example.com/.well-known/oauth-protected-resource//attacker.com/api',
),
).toThrow(/Invalid protected resource metadata URL/);
});
});

describe('parseTokenExpiry', () => {
it('should return the expiry time in milliseconds for a valid token', () => {
// Corresponds to a date of 2100-01-01T00:00:00Z
Expand Down
51 changes: 46 additions & 5 deletions packages/core/src/mcp/oauth-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,42 @@ export class OAuthUtils {
};
}

/**
* Extract the resource identifier prefix represented by a protected resource metadata URL.
*
* RFC 9728 §3.1 constructs metadata URLs by inserting
* /.well-known/oauth-protected-resource between the origin and resource path.
* RFC 9728 §7.3 then validates the resource metadata against that prefix.
*
* @param metadataUrl The protected resource metadata URL
* @returns The resource identifier prefix represented by the metadata URL
*/
static buildResourceParameterFromMetadataUrl(metadataUrl: string): string {
const url = new URL(metadataUrl);
const wellKnownPrefix = '/.well-known/oauth-protected-resource';

if (
url.pathname !== wellKnownPrefix &&
!url.pathname.startsWith(`${wellKnownPrefix}/`)
) {
throw new Error(
`Invalid protected resource metadata URL: ${metadataUrl}`,
);
}

const resourcePath = url.pathname.slice(wellKnownPrefix.length) || '/';
if (resourcePath.startsWith('//')) {
throw new Error(
`Invalid protected resource metadata URL: ${metadataUrl}`,
);
}

url.pathname = resourcePath;
url.search = '';
url.hash = '';
return this.buildResourceParameter(url.toString());
}

/**
* Fetch OAuth protected resource metadata.
*
Expand Down Expand Up @@ -236,9 +272,9 @@ export class OAuthUtils {
// RFC 9728 §3.1: Construct well-known URL by inserting /.well-known/oauth-protected-resource
// between the host and path. This is the RFC-compliant approach.
const wellKnownUrls = this.buildWellKnownUrls(serverUrl);
let resourceMetadata = await this.fetchProtectedResourceMetadata(
wellKnownUrls.protectedResource,
);
let resourceMetadataUrl = wellKnownUrls.protectedResource;
let resourceMetadata =
await this.fetchProtectedResourceMetadata(resourceMetadataUrl);

// Fallback: If path-based discovery fails and we have a path, try root-based discovery
// for backwards compatibility with servers that don't implement RFC 9728 path handling
Expand All @@ -249,14 +285,18 @@ export class OAuthUtils {
resourceMetadata = await this.fetchProtectedResourceMetadata(
rootBasedUrls.protectedResource,
);
if (resourceMetadata) {
resourceMetadataUrl = rootBasedUrls.protectedResource;
}
}
}

if (resourceMetadata) {
// RFC 9728 Section 7.3: The client MUST ensure that the resource identifier URL
// it is using as the prefix for the metadata request exactly matches the value
// of the resource metadata parameter in the protected resource metadata document.
const expectedResource = this.buildResourceParameter(serverUrl);
const expectedResource =
this.buildResourceParameterFromMetadataUrl(resourceMetadataUrl);
if (
!this.isEquivalentResourceIdentifier(
resourceMetadata.resource,
Expand Down Expand Up @@ -352,7 +392,8 @@ export class OAuthUtils {

if (resourceMetadata && mcpServerUrl) {
// Validate resource parameter per RFC 9728 Section 7.3
const expectedResource = this.buildResourceParameter(mcpServerUrl);
const expectedResource =
this.buildResourceParameterFromMetadataUrl(resourceMetadataUri);
if (
!this.isEquivalentResourceIdentifier(
resourceMetadata.resource,
Expand Down