Skip to content

Commit 738ef45

Browse files
LuisLuis
authored andcommitted
fix(auth): add validateClientMetadataUrl utility for fail-fast validation
Export validateClientMetadataUrl() so OAuthClientProvider implementations can validate clientMetadataUrl in their constructors. Demonstrate usage in InMemoryOAuthClientProvider example. Closes #1159
1 parent 6711ed9 commit 738ef45

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': patch
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
@@ -771,6 +771,28 @@ async function authInternal(
771771
return 'REDIRECT';
772772
}
773773

774+
/**
775+
* Validates that the given `clientMetadataUrl` is a valid HTTPS URL with a non-root pathname.
776+
*
777+
* No-op when `url` is `undefined` or empty (providers that do not use URL-based client IDs
778+
* are unaffected). When the value is defined but invalid, throws an {@linkcode OAuthError}
779+
* with code {@linkcode OAuthErrorCode.InvalidClientMetadata}.
780+
*
781+
* {@linkcode OAuthClientProvider} implementations that accept a `clientMetadataUrl` should
782+
* call this in their constructors for early validation.
783+
*
784+
* @param url - The `clientMetadataUrl` value to validate (from `OAuthClientProvider.clientMetadataUrl`)
785+
* @throws {OAuthError} When `url` is defined but is not a valid HTTPS URL with a non-root pathname
786+
*/
787+
export function validateClientMetadataUrl(url: string | undefined): void {
788+
if (url && !isHttpsUrl(url)) {
789+
throw new OAuthError(
790+
OAuthErrorCode.InvalidClientMetadata,
791+
`clientMetadataUrl must be a valid HTTPS URL with a non-root pathname, got: ${url}`
792+
);
793+
}
794+
}
795+
774796
/**
775797
* SEP-991: URL-based Client IDs
776798
* 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
@@ -17,7 +17,8 @@ import {
1717
refreshAuthorization,
1818
registerClient,
1919
selectClientAuthMethod,
20-
startAuthorization
20+
startAuthorization,
21+
validateClientMetadataUrl
2122
} from '../../src/client/auth.js';
2223
import { createPrivateKeyJwtAuth } from '../../src/client/authExtensions.js';
2324

@@ -3734,4 +3735,78 @@ describe('OAuth Authorization', () => {
37343735
});
37353736
});
37363737
});
3738+
3739+
describe('validateClientMetadataUrl', () => {
3740+
it('passes for valid HTTPS URL with path', () => {
3741+
expect(() => validateClientMetadataUrl('https://client.example.com/.well-known/oauth-client')).not.toThrow();
3742+
});
3743+
3744+
it('passes for valid HTTPS URL with multi-segment path', () => {
3745+
expect(() => validateClientMetadataUrl('https://example.com/clients/metadata.json')).not.toThrow();
3746+
});
3747+
3748+
it('throws OAuthError for HTTP URL', () => {
3749+
expect(() => validateClientMetadataUrl('http://client.example.com/.well-known/oauth-client')).toThrow(OAuthError);
3750+
try {
3751+
validateClientMetadataUrl('http://client.example.com/.well-known/oauth-client');
3752+
} catch (error) {
3753+
expect(error).toBeInstanceOf(OAuthError);
3754+
expect((error as OAuthError).code).toBe(OAuthErrorCode.InvalidClientMetadata);
3755+
expect((error as OAuthError).message).toContain('http://client.example.com/.well-known/oauth-client');
3756+
}
3757+
});
3758+
3759+
it('throws OAuthError for non-URL string', () => {
3760+
expect(() => validateClientMetadataUrl('not-a-url')).toThrow(OAuthError);
3761+
try {
3762+
validateClientMetadataUrl('not-a-url');
3763+
} catch (error) {
3764+
expect(error).toBeInstanceOf(OAuthError);
3765+
expect((error as OAuthError).code).toBe(OAuthErrorCode.InvalidClientMetadata);
3766+
expect((error as OAuthError).message).toContain('not-a-url');
3767+
}
3768+
});
3769+
3770+
it('passes silently for empty string', () => {
3771+
expect(() => validateClientMetadataUrl('')).not.toThrow();
3772+
});
3773+
3774+
it('throws OAuthError for root-path HTTPS URL with trailing slash', () => {
3775+
expect(() => validateClientMetadataUrl('https://client.example.com/')).toThrow(OAuthError);
3776+
try {
3777+
validateClientMetadataUrl('https://client.example.com/');
3778+
} catch (error) {
3779+
expect(error).toBeInstanceOf(OAuthError);
3780+
expect((error as OAuthError).code).toBe(OAuthErrorCode.InvalidClientMetadata);
3781+
expect((error as OAuthError).message).toContain('https://client.example.com/');
3782+
}
3783+
});
3784+
3785+
it('throws OAuthError for root-path HTTPS URL without trailing slash', () => {
3786+
expect(() => validateClientMetadataUrl('https://client.example.com')).toThrow(OAuthError);
3787+
try {
3788+
validateClientMetadataUrl('https://client.example.com');
3789+
} catch (error) {
3790+
expect(error).toBeInstanceOf(OAuthError);
3791+
expect((error as OAuthError).code).toBe(OAuthErrorCode.InvalidClientMetadata);
3792+
expect((error as OAuthError).message).toContain('https://client.example.com');
3793+
}
3794+
});
3795+
3796+
it('passes silently for undefined', () => {
3797+
expect(() => validateClientMetadataUrl(undefined)).not.toThrow();
3798+
});
3799+
3800+
it('error message matches expected format', () => {
3801+
expect(() => validateClientMetadataUrl('http://example.com/path')).toThrow(OAuthError);
3802+
try {
3803+
validateClientMetadataUrl('http://example.com/path');
3804+
} catch (error) {
3805+
expect(error).toBeInstanceOf(OAuthError);
3806+
expect((error as OAuthError).message).toBe(
3807+
'clientMetadataUrl must be a valid HTTPS URL with a non-root pathname, got: http://example.com/path'
3808+
);
3809+
}
3810+
});
3811+
});
37373812
});

0 commit comments

Comments
 (0)