Skip to content

Commit 6bec24a

Browse files
rechedev9Luisfelixweinberger
authored
fix: validate clientMetadataUrl at construction time (fail-fast) (#1653)
Co-authored-by: Luis <reche@Luiss-MacBook-Pro.local> Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com>
1 parent 595652c commit 6bec24a

File tree

5 files changed

+113
-2
lines changed

5 files changed

+113
-2
lines changed
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@modelcontextprotocol/client': minor
3+
---
4+
5+
Add `validateClientMetadataUrl()` utility for early validation of `clientMetadataUrl`
6+
7+
Exports a `validateClientMetadataUrl()` function that `OAuthClientProvider` implementations
8+
can call in their constructors to fail fast on invalid URL-based client IDs, instead of
9+
discovering the error deep in the auth flow.

examples/client/src/simpleOAuthClientProvider.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { OAuthClientInformationMixed, OAuthClientMetadata, OAuthClientProvider, OAuthTokens } from '@modelcontextprotocol/client';
2+
import { validateClientMetadataUrl } from '@modelcontextprotocol/client';
23

34
/**
45
* In-memory OAuth client provider for demonstration purposes
@@ -15,6 +16,9 @@ export class InMemoryOAuthClientProvider implements OAuthClientProvider {
1516
onRedirect?: (url: URL) => void,
1617
public readonly clientMetadataUrl?: string
1718
) {
19+
// Validate clientMetadataUrl at construction time (fail-fast)
20+
validateClientMetadataUrl(clientMetadataUrl);
21+
1822
this._onRedirect =
1923
onRedirect ||
2024
(url => {

packages/client/src/client/auth.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -804,6 +804,28 @@ async function authInternal(
804804
return 'REDIRECT';
805805
}
806806

807+
/**
808+
* Validates that the given `clientMetadataUrl` is a valid HTTPS URL with a non-root pathname.
809+
*
810+
* No-op when `url` is `undefined` or empty (providers that do not use URL-based client IDs
811+
* are unaffected). When the value is defined but invalid, throws an {@linkcode OAuthError}
812+
* with code {@linkcode OAuthErrorCode.InvalidClientMetadata}.
813+
*
814+
* {@linkcode OAuthClientProvider} implementations that accept a `clientMetadataUrl` should
815+
* call this in their constructors for early validation.
816+
*
817+
* @param url - The `clientMetadataUrl` value to validate (from `OAuthClientProvider.clientMetadataUrl`)
818+
* @throws {OAuthError} When `url` is defined but is not a valid HTTPS URL with a non-root pathname
819+
*/
820+
export function validateClientMetadataUrl(url: string | undefined): void {
821+
if (url && !isHttpsUrl(url)) {
822+
throw new OAuthError(
823+
OAuthErrorCode.InvalidClientMetadata,
824+
`clientMetadataUrl must be a valid HTTPS URL with a non-root pathname, got: ${url}`
825+
);
826+
}
827+
}
828+
807829
/**
808830
* SEP-991: URL-based Client IDs
809831
* Validate that the `client_id` is a valid URL with `https` scheme

packages/client/src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@ export {
3434
selectClientAuthMethod,
3535
selectResourceURL,
3636
startAuthorization,
37-
UnauthorizedError
37+
UnauthorizedError,
38+
validateClientMetadataUrl
3839
} from './client/auth.js';
3940
export type {
4041
AssertionCallback,

packages/client/test/client/auth.test.ts

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ import {
1818
refreshAuthorization,
1919
registerClient,
2020
selectClientAuthMethod,
21-
startAuthorization
21+
startAuthorization,
22+
validateClientMetadataUrl
2223
} from '../../src/client/auth.js';
2324
import { createPrivateKeyJwtAuth } from '../../src/client/authExtensions.js';
2425

@@ -3833,6 +3834,80 @@ describe('OAuth Authorization', () => {
38333834
});
38343835
});
38353836

3837+
describe('validateClientMetadataUrl', () => {
3838+
it('passes for valid HTTPS URL with path', () => {
3839+
expect(() => validateClientMetadataUrl('https://client.example.com/.well-known/oauth-client')).not.toThrow();
3840+
});
3841+
3842+
it('passes for valid HTTPS URL with multi-segment path', () => {
3843+
expect(() => validateClientMetadataUrl('https://example.com/clients/metadata.json')).not.toThrow();
3844+
});
3845+
3846+
it('throws OAuthError for HTTP URL', () => {
3847+
expect(() => validateClientMetadataUrl('http://client.example.com/.well-known/oauth-client')).toThrow(OAuthError);
3848+
try {
3849+
validateClientMetadataUrl('http://client.example.com/.well-known/oauth-client');
3850+
} catch (error) {
3851+
expect(error).toBeInstanceOf(OAuthError);
3852+
expect((error as OAuthError).code).toBe(OAuthErrorCode.InvalidClientMetadata);
3853+
expect((error as OAuthError).message).toContain('http://client.example.com/.well-known/oauth-client');
3854+
}
3855+
});
3856+
3857+
it('throws OAuthError for non-URL string', () => {
3858+
expect(() => validateClientMetadataUrl('not-a-url')).toThrow(OAuthError);
3859+
try {
3860+
validateClientMetadataUrl('not-a-url');
3861+
} catch (error) {
3862+
expect(error).toBeInstanceOf(OAuthError);
3863+
expect((error as OAuthError).code).toBe(OAuthErrorCode.InvalidClientMetadata);
3864+
expect((error as OAuthError).message).toContain('not-a-url');
3865+
}
3866+
});
3867+
3868+
it('passes silently for empty string', () => {
3869+
expect(() => validateClientMetadataUrl('')).not.toThrow();
3870+
});
3871+
3872+
it('throws OAuthError for root-path HTTPS URL with trailing slash', () => {
3873+
expect(() => validateClientMetadataUrl('https://client.example.com/')).toThrow(OAuthError);
3874+
try {
3875+
validateClientMetadataUrl('https://client.example.com/');
3876+
} catch (error) {
3877+
expect(error).toBeInstanceOf(OAuthError);
3878+
expect((error as OAuthError).code).toBe(OAuthErrorCode.InvalidClientMetadata);
3879+
expect((error as OAuthError).message).toContain('https://client.example.com/');
3880+
}
3881+
});
3882+
3883+
it('throws OAuthError for root-path HTTPS URL without trailing slash', () => {
3884+
expect(() => validateClientMetadataUrl('https://client.example.com')).toThrow(OAuthError);
3885+
try {
3886+
validateClientMetadataUrl('https://client.example.com');
3887+
} catch (error) {
3888+
expect(error).toBeInstanceOf(OAuthError);
3889+
expect((error as OAuthError).code).toBe(OAuthErrorCode.InvalidClientMetadata);
3890+
expect((error as OAuthError).message).toContain('https://client.example.com');
3891+
}
3892+
});
3893+
3894+
it('passes silently for undefined', () => {
3895+
expect(() => validateClientMetadataUrl(undefined)).not.toThrow();
3896+
});
3897+
3898+
it('error message matches expected format', () => {
3899+
expect(() => validateClientMetadataUrl('http://example.com/path')).toThrow(OAuthError);
3900+
try {
3901+
validateClientMetadataUrl('http://example.com/path');
3902+
} catch (error) {
3903+
expect(error).toBeInstanceOf(OAuthError);
3904+
expect((error as OAuthError).message).toBe(
3905+
'clientMetadataUrl must be a valid HTTPS URL with a non-root pathname, got: http://example.com/path'
3906+
);
3907+
}
3908+
});
3909+
});
3910+
38363911
describe('determineScope', () => {
38373912
const baseClientMetadata = {
38383913
redirect_uris: ['http://localhost:3000/callback'],

0 commit comments

Comments
 (0)