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>
This commit is contained in:
parent
595652ceca
commit
6bec24a14c
5 changed files with 113 additions and 2 deletions
9
.changeset/fix-validate-client-metadata-url.md
Normal file
9
.changeset/fix-validate-client-metadata-url.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -34,7 +34,8 @@ export {
|
|||
selectClientAuthMethod,
|
||||
selectResourceURL,
|
||||
startAuthorization,
|
||||
UnauthorizedError
|
||||
UnauthorizedError,
|
||||
validateClientMetadataUrl
|
||||
} from './client/auth.js';
|
||||
export type {
|
||||
AssertionCallback,
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
Loading…
Reference in a new issue