Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 11 additions & 7 deletions packages/client/src/client/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
59 changes: 45 additions & 14 deletions packages/client/test/client/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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');
});
Expand Down Expand Up @@ -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');
});
Expand Down Expand Up @@ -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');
});
Expand Down Expand Up @@ -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');
});

Expand Down Expand Up @@ -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,
Expand All @@ -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();
});
});

Expand Down
29 changes: 24 additions & 5 deletions packages/client/test/client/sse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
Loading