diff --git a/.changeset/oauth-client-credentials-endpoint.md b/.changeset/oauth-client-credentials-endpoint.md new file mode 100644 index 0000000000..2dd323e69e --- /dev/null +++ b/.changeset/oauth-client-credentials-endpoint.md @@ -0,0 +1,6 @@ +--- +'@modelcontextprotocol/core': patch +'@modelcontextprotocol/client': patch +--- + +Accept RFC 8414 authorization-server metadata without an authorization endpoint for client-credentials-only servers, while producing a clear error if an authorization-code flow is attempted without an endpoint. diff --git a/examples/shared/src/clientCredentialsAuthServer.ts b/examples/shared/src/clientCredentialsAuthServer.ts index b4d87b0462..1925c5e74e 100644 --- a/examples/shared/src/clientCredentialsAuthServer.ts +++ b/examples/shared/src/clientCredentialsAuthServer.ts @@ -60,8 +60,6 @@ export function createClientCredentialsAuthServer(options: ClientCredentialsAuth const metadata: OAuthMetadata = { issuer, token_endpoint: `${issuer}/token`, - // Required by the RFC 8414 schema even though this AS doesn't implement the endpoint. - authorization_endpoint: `${issuer}/authorize`, response_types_supported: [], grant_types_supported: ['client_credentials'], token_endpoint_auth_methods_supported: ['client_secret_basic', 'client_secret_post'], diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index a95aca0a03..cf63bddddd 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -2095,11 +2095,13 @@ export async function startAuthorization( ): Promise<{ authorizationUrl: URL; codeVerifier: string }> { let authorizationUrl: URL; if (metadata) { - authorizationUrl = new URL(metadata.authorization_endpoint); - if (!metadata.response_types_supported.includes(AUTHORIZATION_CODE_RESPONSE_TYPE)) { throw new Error(`Incompatible auth server: does not support response type ${AUTHORIZATION_CODE_RESPONSE_TYPE}`); } + if (!metadata.authorization_endpoint) { + throw new Error('Incompatible auth server: missing authorization_endpoint'); + } + authorizationUrl = new URL(metadata.authorization_endpoint); if ( metadata.code_challenge_methods_supported && diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index a87cbd7d92..e822ba4ed8 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -597,6 +597,22 @@ describe('OAuth Authorization', () => { code_challenge_methods_supported: ['S256'] }; + it('accepts client-credentials-only OAuth metadata without an authorization endpoint', async () => { + const metadata = { + issuer: 'https://auth.example.com', + token_endpoint: 'https://auth.example.com/token', + grant_types_supported: ['client_credentials'], + response_types_supported: [] + }; + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => metadata + }); + + await expect(discoverOAuthMetadata('https://auth.example.com')).resolves.toEqual(metadata); + }); + it('returns metadata when discovery succeeds', async () => { mockFetch.mockResolvedValueOnce({ ok: true, @@ -1781,6 +1797,16 @@ describe('OAuth Authorization', () => { client_name: 'Test Client' }; + it('rejects authorization-code login when authorization_endpoint is absent', async () => { + await expect( + startAuthorization('https://auth.example.com', { + metadata: { ...validMetadata, authorization_endpoint: undefined }, + clientInformation: validClientInfo, + redirectUrl: 'http://localhost:3000/callback' + }) + ).rejects.toThrow('Incompatible auth server: missing authorization_endpoint'); + }); + it('generates authorization URL with PKCE challenge', async () => { const { authorizationUrl, codeVerifier } = await startAuthorization('https://auth.example.com', { metadata: undefined, diff --git a/packages/core-internal/test/shared/auth.test.ts b/packages/core-internal/test/shared/auth.test.ts index 4561d8fb11..73cfe569df 100644 --- a/packages/core-internal/test/shared/auth.test.ts +++ b/packages/core-internal/test/shared/auth.test.ts @@ -50,6 +50,17 @@ describe('OAuthMetadataSchema', () => { expect(() => OAuthMetadataSchema.parse(metadata)).not.toThrow(); }); + it('accepts client credentials metadata without an authorization endpoint', () => { + const metadata = { + issuer: 'https://auth.example.com', + token_endpoint: 'https://auth.example.com/oauth/token', + grant_types_supported: ['client_credentials'], + response_types_supported: [] + }; + + expect(OAuthMetadataSchema.parse(metadata)).toMatchObject(metadata); + }); + it('rejects metadata with javascript: URLs', () => { const metadata = { issuer: 'https://auth.example.com', @@ -85,6 +96,19 @@ describe('OpenIdProviderMetadataSchema', () => { expect(() => OpenIdProviderMetadataSchema.parse(metadata)).not.toThrow(); }); + it('continues to require an authorization endpoint for OpenID providers', () => { + const metadata = { + issuer: 'https://auth.example.com', + token_endpoint: 'https://auth.example.com/oauth/token', + jwks_uri: 'https://auth.example.com/.well-known/jwks.json', + response_types_supported: ['code'], + subject_types_supported: ['public'], + id_token_signing_alg_values_supported: ['RS256'] + }; + + expect(() => OpenIdProviderMetadataSchema.parse(metadata)).toThrow(); + }); + it('rejects metadata with javascript: in jwks_uri', () => { const metadata = { issuer: 'https://auth.example.com', diff --git a/packages/core/src/auth.ts b/packages/core/src/auth.ts index d7f7f8858e..e93965104b 100644 --- a/packages/core/src/auth.ts +++ b/packages/core/src/auth.ts @@ -49,7 +49,8 @@ export const OAuthProtectedResourceMetadataSchema = z.looseObject({ */ export const OAuthMetadataSchema = z.looseObject({ issuer: z.string(), - authorization_endpoint: SafeUrlSchema, + // RFC 8414 ยง2: client_credentials-only servers need no authorization endpoint. + authorization_endpoint: SafeUrlSchema.optional(), token_endpoint: SafeUrlSchema, registration_endpoint: SafeUrlSchema.optional(), scopes_supported: z.array(z.string()).optional(), diff --git a/packages/server-legacy/src/auth/router.ts b/packages/server-legacy/src/auth/router.ts index 866615540b..78d95ff01f 100644 --- a/packages/server-legacy/src/auth/router.ts +++ b/packages/server-legacy/src/auth/router.ts @@ -86,7 +86,7 @@ export const createOAuthMetadata = (options: { baseUrl?: URL; serviceDocumentationUrl?: URL; scopesSupported?: string[]; -}): OAuthMetadata => { +}): OAuthMetadata & { authorization_endpoint: string } => { const issuer = options.issuerUrl; const baseUrl = options.baseUrl; @@ -97,7 +97,7 @@ export const createOAuthMetadata = (options: { const registration_endpoint = options.provider.clientsStore.registerClient ? '/register' : undefined; const revocation_endpoint = options.provider.revokeToken ? '/revoke' : undefined; - const metadata: OAuthMetadata = { + const metadata: OAuthMetadata & { authorization_endpoint: string } = { issuer: issuer.href, service_documentation: options.serviceDocumentationUrl?.href,