diff --git a/.changeset/fix-validate-client-metadata-url.md b/.changeset/fix-validate-client-metadata-url.md new file mode 100644 index 000000000..a460fca4c --- /dev/null +++ b/.changeset/fix-validate-client-metadata-url.md @@ -0,0 +1,9 @@ +--- +'@modelcontextprotocol/client': minor +--- + +Add `validateClientMetadataUrl()` utility for early validation of `clientMetadataUrl` + +Exports a `validateClientMetadataUrl()` function that `OAuthClientProvider` implementations +can call in their constructors to fail fast on invalid URL-based client IDs, instead of +discovering the error deep in the auth flow. diff --git a/examples/client/src/simpleOAuthClientProvider.ts b/examples/client/src/simpleOAuthClientProvider.ts index 96655c9f6..1ef08279f 100644 --- a/examples/client/src/simpleOAuthClientProvider.ts +++ b/examples/client/src/simpleOAuthClientProvider.ts @@ -1,4 +1,5 @@ import type { OAuthClientInformationMixed, OAuthClientMetadata, OAuthClientProvider, OAuthTokens } from '@modelcontextprotocol/client'; +import { validateClientMetadataUrl } from '@modelcontextprotocol/client'; /** * In-memory OAuth client provider for demonstration purposes @@ -15,6 +16,9 @@ export class InMemoryOAuthClientProvider implements OAuthClientProvider { onRedirect?: (url: URL) => void, public readonly clientMetadataUrl?: string ) { + // Validate clientMetadataUrl at construction time (fail-fast) + validateClientMetadataUrl(clientMetadataUrl); + this._onRedirect = onRedirect || (url => { diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index 93a03ece6..5f55fb7a0 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -804,6 +804,28 @@ async function authInternal( return 'REDIRECT'; } +/** + * Validates that the given `clientMetadataUrl` is a valid HTTPS URL with a non-root pathname. + * + * No-op when `url` is `undefined` or empty (providers that do not use URL-based client IDs + * are unaffected). When the value is defined but invalid, throws an {@linkcode OAuthError} + * with code {@linkcode OAuthErrorCode.InvalidClientMetadata}. + * + * {@linkcode OAuthClientProvider} implementations that accept a `clientMetadataUrl` should + * call this in their constructors for early validation. + * + * @param url - The `clientMetadataUrl` value to validate (from `OAuthClientProvider.clientMetadataUrl`) + * @throws {OAuthError} When `url` is defined but is not a valid HTTPS URL with a non-root pathname + */ +export function validateClientMetadataUrl(url: string | undefined): void { + if (url && !isHttpsUrl(url)) { + throw new OAuthError( + OAuthErrorCode.InvalidClientMetadata, + `clientMetadataUrl must be a valid HTTPS URL with a non-root pathname, got: ${url}` + ); + } +} + /** * SEP-991: URL-based Client IDs * Validate that the `client_id` is a valid URL with `https` scheme diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index be30382a7..48b79b5ce 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -34,7 +34,8 @@ export { selectClientAuthMethod, selectResourceURL, startAuthorization, - UnauthorizedError + UnauthorizedError, + validateClientMetadataUrl } from './client/auth.js'; export type { AssertionCallback, diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index 53263ad8c..04d7f4a3f 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -18,7 +18,8 @@ import { refreshAuthorization, registerClient, selectClientAuthMethod, - startAuthorization + startAuthorization, + validateClientMetadataUrl } from '../../src/client/auth.js'; import { createPrivateKeyJwtAuth } from '../../src/client/authExtensions.js'; @@ -3833,6 +3834,80 @@ describe('OAuth Authorization', () => { }); }); + describe('validateClientMetadataUrl', () => { + it('passes for valid HTTPS URL with path', () => { + expect(() => validateClientMetadataUrl('https://client.example.com/.well-known/oauth-client')).not.toThrow(); + }); + + it('passes for valid HTTPS URL with multi-segment path', () => { + expect(() => validateClientMetadataUrl('https://example.com/clients/metadata.json')).not.toThrow(); + }); + + it('throws OAuthError for HTTP URL', () => { + expect(() => validateClientMetadataUrl('http://client.example.com/.well-known/oauth-client')).toThrow(OAuthError); + try { + validateClientMetadataUrl('http://client.example.com/.well-known/oauth-client'); + } catch (error) { + expect(error).toBeInstanceOf(OAuthError); + expect((error as OAuthError).code).toBe(OAuthErrorCode.InvalidClientMetadata); + expect((error as OAuthError).message).toContain('http://client.example.com/.well-known/oauth-client'); + } + }); + + it('throws OAuthError for non-URL string', () => { + expect(() => validateClientMetadataUrl('not-a-url')).toThrow(OAuthError); + try { + validateClientMetadataUrl('not-a-url'); + } catch (error) { + expect(error).toBeInstanceOf(OAuthError); + expect((error as OAuthError).code).toBe(OAuthErrorCode.InvalidClientMetadata); + expect((error as OAuthError).message).toContain('not-a-url'); + } + }); + + it('passes silently for empty string', () => { + expect(() => validateClientMetadataUrl('')).not.toThrow(); + }); + + it('throws OAuthError for root-path HTTPS URL with trailing slash', () => { + expect(() => validateClientMetadataUrl('https://client.example.com/')).toThrow(OAuthError); + try { + validateClientMetadataUrl('https://client.example.com/'); + } catch (error) { + expect(error).toBeInstanceOf(OAuthError); + expect((error as OAuthError).code).toBe(OAuthErrorCode.InvalidClientMetadata); + expect((error as OAuthError).message).toContain('https://client.example.com/'); + } + }); + + it('throws OAuthError for root-path HTTPS URL without trailing slash', () => { + expect(() => validateClientMetadataUrl('https://client.example.com')).toThrow(OAuthError); + try { + validateClientMetadataUrl('https://client.example.com'); + } catch (error) { + expect(error).toBeInstanceOf(OAuthError); + expect((error as OAuthError).code).toBe(OAuthErrorCode.InvalidClientMetadata); + expect((error as OAuthError).message).toContain('https://client.example.com'); + } + }); + + it('passes silently for undefined', () => { + expect(() => validateClientMetadataUrl(undefined)).not.toThrow(); + }); + + it('error message matches expected format', () => { + expect(() => validateClientMetadataUrl('http://example.com/path')).toThrow(OAuthError); + try { + validateClientMetadataUrl('http://example.com/path'); + } catch (error) { + expect(error).toBeInstanceOf(OAuthError); + expect((error as OAuthError).message).toBe( + 'clientMetadataUrl must be a valid HTTPS URL with a non-root pathname, got: http://example.com/path' + ); + } + }); + }); + describe('determineScope', () => { const baseClientMetadata = { redirect_uris: ['http://localhost:3000/callback'],