Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
5 changes: 5 additions & 0 deletions .changeset/client-secret-basic-form-encoding.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@modelcontextprotocol/client': patch
---

`client_secret_basic` credentials are now percent-encoded before being joined with `:` and base64-encoded into the `Authorization` header. Each half uses the same RFC 3986 encoding as the Python SDK's `quote(value, safe="")`, so `~` stays as it is, a space becomes `%20`, and `+`, `%`, or `:` are escaped instead of corrupting what a token endpoint recovers. Previously both halves were sent raw, so a secret containing `+` reached a form-decoding endpoint as a space, a `%` broke its decoding, a `:` inside the client identifier moved the split, and any character outside Latin-1 made `btoa` throw before the request was sent. Credentials made only of unreserved characters, including `~`, are byte-identical on the wire.
11 changes: 10 additions & 1 deletion packages/client/src/client/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -849,6 +849,14 @@ export function applyClientAuthentication(
}
}

/** RFC 3986 percent-encoding matching the Python SDK's `quote(value, safe="")`, which leaves `~` alone. */
function percentEncode(value: string): string {
return encodeURIComponent(value).replaceAll(/[!'()*]/g, c => {
const codePoint = c.codePointAt(0) ?? 0;
return `%${codePoint.toString(16).toUpperCase()}`;
});
}

/**
* Applies HTTP Basic authentication (RFC 6749 Section 2.3.1)
*/
Expand All @@ -857,7 +865,8 @@ export function applyBasicAuth(clientId: string, clientSecret: string | undefine
throw new Error('client_secret_basic authentication requires a client_secret');
}

const credentials = btoa(`${clientId}:${clientSecret}`);
// RFC 6749 §2.3.1: both halves are encoded before being joined with ":".
const credentials = btoa(`${percentEncode(clientId)}:${percentEncode(clientSecret)}`);
headers.set('Authorization', `Basic ${credentials}`);
}

Expand Down
100 changes: 100 additions & 0 deletions packages/client/test/client/authBasicEncoding.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/**
* RFC 6749 §2.3.1: for `client_secret_basic`, the client identifier and the
* client password are each encoded before being joined with ":" and
* base64-encoded into the `Authorization` header. The encoding is the RFC 3986
* percent-encoding the Python SDK uses (`quote(value, safe="")`), so `~` stays
* as it is; a form-decoding token endpoint recovers both halves.
*/
import { describe, expect, it } from 'vitest';

import { applyBasicAuth, applyClientAuthentication, exchangeAuthorization } from '../../src/client/auth';

/** What a conformant authorization server recovers from a Basic header. */
function recoverCredentials(authorization: string): { username: string; password: string } {
const decoded = Buffer.from(authorization.replace(/^Basic /, ''), 'base64').toString('utf8');
const separator = decoded.indexOf(':');
const formDecode = (value: string): string => decodeURIComponent(value.replace(/\+/g, ' '));

return {
username: formDecode(decoded.slice(0, separator)),
password: formDecode(decoded.slice(separator + 1))
};
}

describe('client_secret_basic credential encoding (RFC 6749 §2.3.1)', () => {
it('percent-encodes both halves before base64-encoding them', () => {
const headers = new Headers();
applyBasicAuth('client id', 'sec:ret+with=special', headers);

const authorization = headers.get('Authorization');
expect(authorization).toBeTruthy();

const decoded = Buffer.from(authorization!.replace(/^Basic /, ''), 'base64').toString('utf8');

// space -> "%20", ":" -> "%3A", "+" -> "%2B", "=" -> "%3D".
expect(decoded).toBe('client%20id:sec%3Aret%2Bwith%3Dspecial');
});

it('leaves "~" unencoded like the Python SDK', () => {
const headers = new Headers();
applyBasicAuth('cid', 'Abc8Q~xYz.-_Q', headers);

const decoded = Buffer.from(headers.get('Authorization')!.replace(/^Basic /, ''), 'base64').toString('utf8');

// A form encoder would send "Q%7E"; existing secrets such as Entra's
// "Q~..." must keep their bytes.
expect(decoded).toBe('cid:Abc8Q~xYz.-_Q');
expect(recoverCredentials(headers.get('Authorization')!).password).toBe('Abc8Q~xYz.-_Q');
});

it('lets a conformant server recover the original credentials', () => {
const headers = new Headers();
applyClientAuthentication('client_secret_basic', { client_id: 'client id', client_secret: 'a+b' }, headers, new URLSearchParams());

expect(recoverCredentials(headers.get('Authorization')!)).toEqual({
username: 'client id',
password: 'a+b'
});
});

it('does not throw on credentials outside Latin-1', () => {
const headers = new Headers();

// `btoa` rejects any code point above U+00FF, which aborted the token
// request before it was sent; form-encoding leaves only ASCII behind.
expect(() => applyBasicAuth('cid', 'p€ss 中', headers)).not.toThrow();
expect(recoverCredentials(headers.get('Authorization')!).password).toBe('p€ss 中');
});

it('encodes the credentials on the token request itself', async () => {
const authorizationHeaders: string[] = [];
const fetchFn = async (url: string | URL, init?: RequestInit): Promise<Response> => {
authorizationHeaders.push(new Headers(init?.headers).get('Authorization') ?? '');
return new Response(JSON.stringify({ access_token: 'at', token_type: 'Bearer' }), {
status: 200,
headers: { 'content-type': 'application/json' }
});
};

await exchangeAuthorization('https://as.example.com', {
metadata: {
issuer: 'https://as.example.com',
authorization_endpoint: 'https://as.example.com/authorize',
token_endpoint: 'https://as.example.com/token',
response_types_supported: ['code'],
token_endpoint_auth_methods_supported: ['client_secret_basic']
},
clientInformation: { client_id: 'client id', client_secret: 'a+b' },
authorizationCode: 'the-code',
codeVerifier: 'the-verifier',
redirectUri: 'https://client.example.com/callback',
fetchFn
});

expect(authorizationHeaders).toHaveLength(1);
expect(recoverCredentials(authorizationHeaders[0]!)).toEqual({
username: 'client id',
password: 'a+b'
});
});
});
Loading