Skip to content

Commit 9a8f37b

Browse files
Default to client_secret_basic when server omits token_endpoint_auth_methods_supported (#1594)
Co-authored-by: Matt <77928207+mattzcarey@users.noreply.github.com>
1 parent 52186d1 commit 9a8f37b

File tree

3 files changed

+80
-26
lines changed

3 files changed

+80
-26
lines changed

packages/client/src/client/auth.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -257,21 +257,25 @@ const AUTHORIZATION_CODE_CHALLENGE_METHOD = 'S256';
257257
export function selectClientAuthMethod(clientInformation: OAuthClientInformationMixed, supportedMethods: string[]): ClientAuthMethod {
258258
const hasClientSecret = clientInformation.client_secret !== undefined;
259259

260-
// If server doesn't specify supported methods, use RFC 6749 defaults
261-
if (supportedMethods.length === 0) {
262-
return hasClientSecret ? 'client_secret_post' : 'none';
263-
}
264-
265-
// Prefer the method returned by the server during client registration if valid and supported
260+
// Prefer the method returned by the server during client registration, if valid.
261+
// When server metadata is present we also require the method to be listed as supported;
262+
// when supportedMethods is empty (metadata omitted the field) the DCR hint stands alone.
266263
if (
267264
'token_endpoint_auth_method' in clientInformation &&
268265
clientInformation.token_endpoint_auth_method &&
269266
isClientAuthMethod(clientInformation.token_endpoint_auth_method) &&
270-
supportedMethods.includes(clientInformation.token_endpoint_auth_method)
267+
(supportedMethods.length === 0 || supportedMethods.includes(clientInformation.token_endpoint_auth_method))
271268
) {
272269
return clientInformation.token_endpoint_auth_method;
273270
}
274271

272+
// If server metadata omits token_endpoint_auth_methods_supported, RFC 8414 §2 says the
273+
// default is client_secret_basic. RFC 6749 §2.3.1 also requires servers to support HTTP
274+
// Basic authentication for clients with a secret, making it the safest default.
275+
if (supportedMethods.length === 0) {
276+
return hasClientSecret ? 'client_secret_basic' : 'none';
277+
}
278+
275279
// Try methods in priority order (most secure first)
276280
if (hasClientSecret && supportedMethods.includes('client_secret_basic')) {
277281
return 'client_secret_basic';

packages/client/test/client/auth.test.ts

Lines changed: 45 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1276,6 +1276,27 @@ describe('OAuth Authorization', () => {
12761276
const authMethod = selectClientAuthMethod(clientInfo, supportedMethods);
12771277
expect(authMethod).toBe('none');
12781278
});
1279+
it('defaults to client_secret_basic when server omits token_endpoint_auth_methods_supported (RFC 8414 §2)', () => {
1280+
// RFC 8414 §2: if omitted, the default is client_secret_basic.
1281+
// RFC 6749 §2.3.1: servers MUST support HTTP Basic for clients with a secret.
1282+
const clientInfo = { client_id: 'test-client-id', client_secret: 'test-client-secret' };
1283+
const authMethod = selectClientAuthMethod(clientInfo, []);
1284+
expect(authMethod).toBe('client_secret_basic');
1285+
});
1286+
it('defaults to none for public clients when server omits token_endpoint_auth_methods_supported', () => {
1287+
const clientInfo = { client_id: 'test-client-id' };
1288+
const authMethod = selectClientAuthMethod(clientInfo, []);
1289+
expect(authMethod).toBe('none');
1290+
});
1291+
it('honors DCR-returned token_endpoint_auth_method even when server metadata omits supported methods', () => {
1292+
const clientInfo = {
1293+
client_id: 'test-client-id',
1294+
client_secret: 'test-client-secret',
1295+
token_endpoint_auth_method: 'client_secret_post'
1296+
};
1297+
const authMethod = selectClientAuthMethod(clientInfo, []);
1298+
expect(authMethod).toBe('client_secret_post');
1299+
});
12791300
});
12801301

12811302
describe('startAuthorization', () => {
@@ -1492,8 +1513,10 @@ describe('OAuth Authorization', () => {
14921513
expect(body.get('grant_type')).toBe('authorization_code');
14931514
expect(body.get('code')).toBe('code123');
14941515
expect(body.get('code_verifier')).toBe('verifier123');
1495-
expect(body.get('client_id')).toBe('client123');
1496-
expect(body.get('client_secret')).toBe('secret123');
1516+
// Default auth method is client_secret_basic when no metadata provided (RFC 8414 §2)
1517+
expect(body.get('client_id')).toBeNull();
1518+
expect(body.get('client_secret')).toBeNull();
1519+
expect(options.headers.get('Authorization')).toBe('Basic ' + btoa('client123:secret123'));
14971520
expect(body.get('redirect_uri')).toBe('http://localhost:3000/callback');
14981521
expect(body.get('resource')).toBe('https://api.example.com/mcp-server');
14991522
});
@@ -1531,8 +1554,10 @@ describe('OAuth Authorization', () => {
15311554
expect(body.get('grant_type')).toBe('authorization_code');
15321555
expect(body.get('code')).toBe('code123');
15331556
expect(body.get('code_verifier')).toBe('verifier123');
1534-
expect(body.get('client_id')).toBe('client123');
1535-
expect(body.get('client_secret')).toBe('secret123');
1557+
// Default auth method is client_secret_basic when no metadata provided (RFC 8414 §2)
1558+
expect(body.get('client_id')).toBeNull();
1559+
expect(body.get('client_secret')).toBeNull();
1560+
expect(options.headers.get('Authorization')).toBe('Basic ' + btoa('client123:secret123'));
15361561
expect(body.get('redirect_uri')).toBe('http://localhost:3000/callback');
15371562
expect(body.get('resource')).toBe('https://api.example.com/mcp-server');
15381563
});
@@ -1656,8 +1681,10 @@ describe('OAuth Authorization', () => {
16561681
expect(body.get('grant_type')).toBe('authorization_code');
16571682
expect(body.get('code')).toBe('code123');
16581683
expect(body.get('code_verifier')).toBe('verifier123');
1659-
expect(body.get('client_id')).toBe('client123');
1660-
expect(body.get('client_secret')).toBe('secret123');
1684+
// Default auth method is client_secret_basic when no metadata provided (RFC 8414 §2)
1685+
expect(body.get('client_id')).toBeNull();
1686+
expect(body.get('client_secret')).toBeNull();
1687+
expect((options.headers as Headers).get('Authorization')).toBe('Basic ' + btoa('client123:secret123'));
16611688
expect(body.get('redirect_uri')).toBe('http://localhost:3000/callback');
16621689
expect(body.get('resource')).toBe('https://api.example.com/mcp-server');
16631690
});
@@ -1716,8 +1743,10 @@ describe('OAuth Authorization', () => {
17161743
const body = mockFetch.mock.calls[0]![1].body as URLSearchParams;
17171744
expect(body.get('grant_type')).toBe('refresh_token');
17181745
expect(body.get('refresh_token')).toBe('refresh123');
1719-
expect(body.get('client_id')).toBe('client123');
1720-
expect(body.get('client_secret')).toBe('secret123');
1746+
// Default auth method is client_secret_basic when no metadata provided (RFC 8414 §2)
1747+
expect(body.get('client_id')).toBeNull();
1748+
expect(body.get('client_secret')).toBeNull();
1749+
expect(headers.get('Authorization')).toBe('Basic ' + btoa('client123:secret123'));
17211750
expect(body.get('resource')).toBe('https://api.example.com/mcp-server');
17221751
});
17231752

@@ -3076,7 +3105,7 @@ describe('OAuth Authorization', () => {
30763105
expect(body.get('client_secret')).toBeNull();
30773106
});
30783107

3079-
it('defaults to client_secret_post when no auth methods specified', async () => {
3108+
it('defaults to client_secret_basic when no auth methods specified (RFC 8414 §2)', async () => {
30803109
mockFetch.mockResolvedValueOnce({
30813110
ok: true,
30823111
status: 200,
@@ -3093,13 +3122,15 @@ describe('OAuth Authorization', () => {
30933122
expect(tokens).toEqual(validTokens);
30943123
const request = mockFetch.mock.calls[0]![1];
30953124

3096-
// Check headers
3097-
expect(request.headers.get('Content-Type')).toBe('application/x-www-form-urlencoded');
3098-
expect(request.headers.get('Authorization')).toBeNull();
3125+
// RFC 8414 §2: when token_endpoint_auth_methods_supported is omitted,
3126+
// the default is client_secret_basic (HTTP Basic auth, not body params)
3127+
const authHeader = request.headers.get('Authorization');
3128+
const expected = 'Basic ' + btoa('client123:secret123');
3129+
expect(authHeader).toBe(expected);
30993130

31003131
const body = request.body as URLSearchParams;
3101-
expect(body.get('client_id')).toBe('client123');
3102-
expect(body.get('client_secret')).toBe('secret123');
3132+
expect(body.get('client_id')).toBeNull();
3133+
expect(body.get('client_secret')).toBeNull();
31033134
});
31043135
});
31053136

packages/client/test/client/sse.test.ts

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,21 @@ import type { OAuthClientProvider } from '../../src/client/auth.js';
1111
import { UnauthorizedError } from '../../src/client/auth.js';
1212
import { SSEClientTransport } from '../../src/client/sse.js';
1313

14+
/**
15+
* Parses HTTP Basic auth from a request's Authorization header.
16+
* Returns the decoded client_id and client_secret, or undefined if the header is absent or malformed.
17+
* client_secret_basic is the default client auth method when server metadata omits
18+
* token_endpoint_auth_methods_supported (RFC 8414 §2).
19+
*/
20+
function parseBasicAuth(req: IncomingMessage): { clientId: string; clientSecret: string } | undefined {
21+
const auth = req.headers.authorization;
22+
if (!auth || !auth.startsWith('Basic ')) return undefined;
23+
const decoded = Buffer.from(auth.slice(6), 'base64').toString('utf8');
24+
const sep = decoded.indexOf(':');
25+
if (sep === -1) return undefined;
26+
return { clientId: decoded.slice(0, sep), clientSecret: decoded.slice(sep + 1) };
27+
}
28+
1429
describe('SSEClientTransport', () => {
1530
let resourceServer: Server;
1631
let authServer: Server;
@@ -673,11 +688,12 @@ describe('SSEClientTransport', () => {
673688
});
674689
req.on('end', () => {
675690
const params = new URLSearchParams(body);
691+
const basicAuth = parseBasicAuth(req);
676692
if (
677693
params.get('grant_type') === 'refresh_token' &&
678694
params.get('refresh_token') === 'refresh-token' &&
679-
params.get('client_id') === 'test-client-id' &&
680-
params.get('client_secret') === 'test-client-secret'
695+
basicAuth?.clientId === 'test-client-id' &&
696+
basicAuth?.clientSecret === 'test-client-secret'
681697
) {
682698
res.writeHead(200, { 'Content-Type': 'application/json' });
683699
res.end(
@@ -801,11 +817,12 @@ describe('SSEClientTransport', () => {
801817
});
802818
req.on('end', () => {
803819
const params = new URLSearchParams(body);
820+
const basicAuth = parseBasicAuth(req);
804821
if (
805822
params.get('grant_type') === 'refresh_token' &&
806823
params.get('refresh_token') === 'refresh-token' &&
807-
params.get('client_id') === 'test-client-id' &&
808-
params.get('client_secret') === 'test-client-secret'
824+
basicAuth?.clientId === 'test-client-id' &&
825+
basicAuth?.clientSecret === 'test-client-secret'
809826
) {
810827
res.writeHead(200, { 'Content-Type': 'application/json' });
811828
res.end(
@@ -1234,10 +1251,12 @@ describe('SSEClientTransport', () => {
12341251
});
12351252
req.on('end', () => {
12361253
const params = new URLSearchParams(body);
1254+
const basicAuth = parseBasicAuth(req);
12371255
if (
12381256
params.get('grant_type') === 'authorization_code' &&
12391257
params.get('code') === 'test-auth-code' &&
1240-
params.get('client_id') === 'test-client-id'
1258+
basicAuth?.clientId === 'test-client-id' &&
1259+
basicAuth?.clientSecret === 'test-client-secret'
12411260
) {
12421261
res.writeHead(200, { 'Content-Type': 'application/json' });
12431262
res.end(

0 commit comments

Comments
 (0)