From 500dd7ffbc1d46f75eb14bdc8cbf815411ac9308 Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Thu, 26 Feb 2026 13:44:07 -0500 Subject: [PATCH 1/2] Default to client_secret_basic when server omits token_endpoint_auth_methods_supported MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per RFC 8414 §2, when an authorization server's metadata omits token_endpoint_auth_methods_supported, the default is client_secret_basic, not client_secret_post. RFC 6749 §2.3.1 also requires servers to support HTTP Basic authentication for clients with a secret, making it the safest default. Also: honor the DCR-returned token_endpoint_auth_method even when supportedMethods is empty — previously the early return swallowed the DCR hint in this scenario. Related to #951 (PR #1022 fixed the DCR-preference case when metadata IS present, but left this RFC-default case untouched). --- packages/client/src/client/auth.ts | 18 +++++--- packages/client/test/client/auth.test.ts | 59 ++++++++++++++++++------ 2 files changed, 56 insertions(+), 21 deletions(-) diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index cfba29d85..e6e81f6bb 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -257,21 +257,25 @@ const AUTHORIZATION_CODE_CHALLENGE_METHOD = 'S256'; export function selectClientAuthMethod(clientInformation: OAuthClientInformationMixed, supportedMethods: string[]): ClientAuthMethod { const hasClientSecret = clientInformation.client_secret !== undefined; - // If server doesn't specify supported methods, use RFC 6749 defaults - if (supportedMethods.length === 0) { - return hasClientSecret ? 'client_secret_post' : 'none'; - } - - // Prefer the method returned by the server during client registration if valid and supported + // Prefer the method returned by the server during client registration, if valid. + // When server metadata is present we also require the method to be listed as supported; + // when supportedMethods is empty (metadata omitted the field) the DCR hint stands alone. if ( 'token_endpoint_auth_method' in clientInformation && clientInformation.token_endpoint_auth_method && isClientAuthMethod(clientInformation.token_endpoint_auth_method) && - supportedMethods.includes(clientInformation.token_endpoint_auth_method) + (supportedMethods.length === 0 || supportedMethods.includes(clientInformation.token_endpoint_auth_method)) ) { return clientInformation.token_endpoint_auth_method; } + // If server metadata omits token_endpoint_auth_methods_supported, RFC 8414 §2 says the + // default is client_secret_basic. RFC 6749 §2.3.1 also requires servers to support HTTP + // Basic authentication for clients with a secret, making it the safest default. + if (supportedMethods.length === 0) { + return hasClientSecret ? 'client_secret_basic' : 'none'; + } + // Try methods in priority order (most secure first) if (hasClientSecret && supportedMethods.includes('client_secret_basic')) { return 'client_secret_basic'; diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index 742dbc143..c854b8dff 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -1276,6 +1276,27 @@ describe('OAuth Authorization', () => { const authMethod = selectClientAuthMethod(clientInfo, supportedMethods); expect(authMethod).toBe('none'); }); + it('defaults to client_secret_basic when server omits token_endpoint_auth_methods_supported (RFC 8414 §2)', () => { + // RFC 8414 §2: if omitted, the default is client_secret_basic. + // RFC 6749 §2.3.1: servers MUST support HTTP Basic for clients with a secret. + const clientInfo = { client_id: 'test-client-id', client_secret: 'test-client-secret' }; + const authMethod = selectClientAuthMethod(clientInfo, []); + expect(authMethod).toBe('client_secret_basic'); + }); + it('defaults to none for public clients when server omits token_endpoint_auth_methods_supported', () => { + const clientInfo = { client_id: 'test-client-id' }; + const authMethod = selectClientAuthMethod(clientInfo, []); + expect(authMethod).toBe('none'); + }); + it('honors DCR-returned token_endpoint_auth_method even when server metadata omits supported methods', () => { + const clientInfo = { + client_id: 'test-client-id', + client_secret: 'test-client-secret', + token_endpoint_auth_method: 'client_secret_post' + }; + const authMethod = selectClientAuthMethod(clientInfo, []); + expect(authMethod).toBe('client_secret_post'); + }); }); describe('startAuthorization', () => { @@ -1492,8 +1513,10 @@ describe('OAuth Authorization', () => { expect(body.get('grant_type')).toBe('authorization_code'); expect(body.get('code')).toBe('code123'); expect(body.get('code_verifier')).toBe('verifier123'); - expect(body.get('client_id')).toBe('client123'); - expect(body.get('client_secret')).toBe('secret123'); + // Default auth method is client_secret_basic when no metadata provided (RFC 8414 §2) + expect(body.get('client_id')).toBeNull(); + expect(body.get('client_secret')).toBeNull(); + expect(options.headers.get('Authorization')).toBe('Basic ' + btoa('client123:secret123')); expect(body.get('redirect_uri')).toBe('http://localhost:3000/callback'); expect(body.get('resource')).toBe('https://api.example.com/mcp-server'); }); @@ -1531,8 +1554,10 @@ describe('OAuth Authorization', () => { expect(body.get('grant_type')).toBe('authorization_code'); expect(body.get('code')).toBe('code123'); expect(body.get('code_verifier')).toBe('verifier123'); - expect(body.get('client_id')).toBe('client123'); - expect(body.get('client_secret')).toBe('secret123'); + // Default auth method is client_secret_basic when no metadata provided (RFC 8414 §2) + expect(body.get('client_id')).toBeNull(); + expect(body.get('client_secret')).toBeNull(); + expect(options.headers.get('Authorization')).toBe('Basic ' + btoa('client123:secret123')); expect(body.get('redirect_uri')).toBe('http://localhost:3000/callback'); expect(body.get('resource')).toBe('https://api.example.com/mcp-server'); }); @@ -1656,8 +1681,10 @@ describe('OAuth Authorization', () => { expect(body.get('grant_type')).toBe('authorization_code'); expect(body.get('code')).toBe('code123'); expect(body.get('code_verifier')).toBe('verifier123'); - expect(body.get('client_id')).toBe('client123'); - expect(body.get('client_secret')).toBe('secret123'); + // Default auth method is client_secret_basic when no metadata provided (RFC 8414 §2) + expect(body.get('client_id')).toBeNull(); + expect(body.get('client_secret')).toBeNull(); + expect((options.headers as Headers).get('Authorization')).toBe('Basic ' + btoa('client123:secret123')); expect(body.get('redirect_uri')).toBe('http://localhost:3000/callback'); expect(body.get('resource')).toBe('https://api.example.com/mcp-server'); }); @@ -1716,8 +1743,10 @@ describe('OAuth Authorization', () => { const body = mockFetch.mock.calls[0]![1].body as URLSearchParams; expect(body.get('grant_type')).toBe('refresh_token'); expect(body.get('refresh_token')).toBe('refresh123'); - expect(body.get('client_id')).toBe('client123'); - expect(body.get('client_secret')).toBe('secret123'); + // Default auth method is client_secret_basic when no metadata provided (RFC 8414 §2) + expect(body.get('client_id')).toBeNull(); + expect(body.get('client_secret')).toBeNull(); + expect(headers.get('Authorization')).toBe('Basic ' + btoa('client123:secret123')); expect(body.get('resource')).toBe('https://api.example.com/mcp-server'); }); @@ -3076,7 +3105,7 @@ describe('OAuth Authorization', () => { expect(body.get('client_secret')).toBeNull(); }); - it('defaults to client_secret_post when no auth methods specified', async () => { + it('defaults to client_secret_basic when no auth methods specified (RFC 8414 §2)', async () => { mockFetch.mockResolvedValueOnce({ ok: true, status: 200, @@ -3093,13 +3122,15 @@ describe('OAuth Authorization', () => { expect(tokens).toEqual(validTokens); const request = mockFetch.mock.calls[0]![1]; - // Check headers - expect(request.headers.get('Content-Type')).toBe('application/x-www-form-urlencoded'); - expect(request.headers.get('Authorization')).toBeNull(); + // RFC 8414 §2: when token_endpoint_auth_methods_supported is omitted, + // the default is client_secret_basic (HTTP Basic auth, not body params) + const authHeader = request.headers.get('Authorization'); + const expected = 'Basic ' + btoa('client123:secret123'); + expect(authHeader).toBe(expected); const body = request.body as URLSearchParams; - expect(body.get('client_id')).toBe('client123'); - expect(body.get('client_secret')).toBe('secret123'); + expect(body.get('client_id')).toBeNull(); + expect(body.get('client_secret')).toBeNull(); }); }); From cd053b70db633831666f50120f08591ccd8297a1 Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Thu, 26 Feb 2026 14:19:59 -0500 Subject: [PATCH 2/2] Update sse.test.ts mock auth servers to accept HTTP Basic auth The mock auth servers in sse.test.ts validated client credentials by checking body params (client_secret_post). With the default changed to client_secret_basic, credentials now arrive in the Authorization header instead. Updated the three affected mock servers to parse and validate HTTP Basic auth headers. --- packages/client/test/client/sse.test.ts | 29 ++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/packages/client/test/client/sse.test.ts b/packages/client/test/client/sse.test.ts index d4391c279..0b0aff67b 100644 --- a/packages/client/test/client/sse.test.ts +++ b/packages/client/test/client/sse.test.ts @@ -11,6 +11,21 @@ import type { OAuthClientProvider } from '../../src/client/auth.js'; import { UnauthorizedError } from '../../src/client/auth.js'; import { SSEClientTransport } from '../../src/client/sse.js'; +/** + * Parses HTTP Basic auth from a request's Authorization header. + * Returns the decoded client_id and client_secret, or undefined if the header is absent or malformed. + * client_secret_basic is the default client auth method when server metadata omits + * token_endpoint_auth_methods_supported (RFC 8414 §2). + */ +function parseBasicAuth(req: IncomingMessage): { clientId: string; clientSecret: string } | undefined { + const auth = req.headers.authorization; + if (!auth || !auth.startsWith('Basic ')) return undefined; + const decoded = Buffer.from(auth.slice(6), 'base64').toString('utf8'); + const sep = decoded.indexOf(':'); + if (sep === -1) return undefined; + return { clientId: decoded.slice(0, sep), clientSecret: decoded.slice(sep + 1) }; +} + describe('SSEClientTransport', () => { let resourceServer: Server; let authServer: Server; @@ -673,11 +688,12 @@ describe('SSEClientTransport', () => { }); req.on('end', () => { const params = new URLSearchParams(body); + const basicAuth = parseBasicAuth(req); if ( params.get('grant_type') === 'refresh_token' && params.get('refresh_token') === 'refresh-token' && - params.get('client_id') === 'test-client-id' && - params.get('client_secret') === 'test-client-secret' + basicAuth?.clientId === 'test-client-id' && + basicAuth?.clientSecret === 'test-client-secret' ) { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end( @@ -801,11 +817,12 @@ describe('SSEClientTransport', () => { }); req.on('end', () => { const params = new URLSearchParams(body); + const basicAuth = parseBasicAuth(req); if ( params.get('grant_type') === 'refresh_token' && params.get('refresh_token') === 'refresh-token' && - params.get('client_id') === 'test-client-id' && - params.get('client_secret') === 'test-client-secret' + basicAuth?.clientId === 'test-client-id' && + basicAuth?.clientSecret === 'test-client-secret' ) { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end( @@ -1234,10 +1251,12 @@ describe('SSEClientTransport', () => { }); req.on('end', () => { const params = new URLSearchParams(body); + const basicAuth = parseBasicAuth(req); if ( params.get('grant_type') === 'authorization_code' && params.get('code') === 'test-auth-code' && - params.get('client_id') === 'test-client-id' + basicAuth?.clientId === 'test-client-id' && + basicAuth?.clientSecret === 'test-client-secret' ) { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(