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/client/authExtensions.ts b/packages/client/src/client/authExtensions.ts index 80efc0a12..cb476c12f 100644 --- a/packages/client/src/client/authExtensions.ts +++ b/packages/client/src/client/authExtensions.ts @@ -233,6 +233,16 @@ export interface PrivateKeyJwtProviderOptions { * Space-separated scopes values requested by the client. */ scope?: string; + + /** + * Optional custom claims to include in the JWT assertion. + * These are merged with the standard claims (`iss`, `sub`, `aud`, `exp`, `iat`, `jti`), + * with custom claims taking precedence for any overlapping keys. + * + * Useful for including additional claims that help scope the access token + * with finer granularity than what scopes alone allow. + */ + claims?: Record; } /** @@ -277,7 +287,8 @@ export class PrivateKeyJwtProvider implements OAuthClientProvider { subject: options.clientId, privateKey: options.privateKey, alg: options.algorithm, - lifetimeSeconds: options.jwtLifetimeSeconds + lifetimeSeconds: options.jwtLifetimeSeconds, + claims: options.claims }); } 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'], diff --git a/packages/client/test/client/authExtensions.test.ts b/packages/client/test/client/authExtensions.test.ts index f7737077e..16c3ea33e 100644 --- a/packages/client/test/client/authExtensions.test.ts +++ b/packages/client/test/client/authExtensions.test.ts @@ -424,6 +424,49 @@ describe('createPrivateKeyJwtAuth', () => { /Key for the RS256 algorithm must be one of type CryptoKey, KeyObject, or JSON Web Key/ ); }); + + it('includes custom claims in the signed JWT assertion', async () => { + const addClientAuth = createPrivateKeyJwtAuth({ + issuer: 'client-id', + subject: 'client-id', + privateKey: 'a-string-secret-at-least-256-bits-long', + alg: 'HS256', + claims: { tenant_id: 'org-123', role: 'admin' } + }); + + const params = new URLSearchParams(); + await addClientAuth(new Headers(), params, 'https://auth.example.com/token', undefined); + + const assertion = params.get('client_assertion'); + expect(assertion).toBeTruthy(); + + const jose = await import('jose'); + const decoded = jose.decodeJwt(assertion!); + expect(decoded.tenant_id).toBe('org-123'); + expect(decoded.role).toBe('admin'); + expect(decoded.iss).toBe('client-id'); + expect(decoded.sub).toBe('client-id'); + }); + + it('passes custom claims through PrivateKeyJwtProvider', async () => { + const provider = new PrivateKeyJwtProvider({ + clientId: 'client-id', + privateKey: 'a-string-secret-at-least-256-bits-long', + algorithm: 'HS256', + claims: { tenant_id: 'org-456' } + }); + + const params = new URLSearchParams(); + await provider.addClientAuthentication(new Headers(), params, 'https://auth.example.com/token', undefined); + + const assertion = params.get('client_assertion'); + expect(assertion).toBeTruthy(); + + const jose = await import('jose'); + const decoded = jose.decodeJwt(assertion!); + expect(decoded.tenant_id).toBe('org-456'); + expect(decoded.iss).toBe('client-id'); + }); }); describe('CrossAppAccessProvider', () => {