diff --git a/.changeset/localhost-subdomains-loopback.md b/.changeset/localhost-subdomains-loopback.md new file mode 100644 index 0000000000..690a5c219d --- /dev/null +++ b/.changeset/localhost-subdomains-loopback.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/client': patch +--- + +Treat `.localhost` subdomains as loopback hosts for OAuth token endpoint TLS checks. diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index 4c37339297..52495ee3fb 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -862,7 +862,13 @@ export function applyPublicAuth(clientId: string, params: URLSearchParams): void /** Loopback hosts exempt from the in-transit `https:` requirement (RFC 8252 ยง7.3). */ function isLoopbackHost(hostname: string): boolean { - return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]' || hostname === '::1'; + return ( + hostname === 'localhost' || + hostname.endsWith('.localhost') || + hostname === '127.0.0.1' || + hostname === '[::1]' || + hostname === '::1' + ); } /** diff --git a/packages/client/src/client/authErrors.ts b/packages/client/src/client/authErrors.ts index e8925b2f86..5afa88413d 100644 --- a/packages/client/src/client/authErrors.ts +++ b/packages/client/src/client/authErrors.ts @@ -148,7 +148,7 @@ export class InsecureTokenEndpointError extends OAuthClientFlowError { constructor(tokenEndpoint: string) { super( `Refusing to send credentials to non-https token endpoint '${tokenEndpoint}'. ` + - `OAuth token requests MUST use TLS (localhost / 127.0.0.1 / ::1 are exempt).` + `OAuth token requests MUST use TLS (localhost / *.localhost / 127.0.0.1 / ::1 are exempt).` ); this.tokenEndpoint = tokenEndpoint; } diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index 3ac9c7ddff..ca25134934 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -2476,6 +2476,7 @@ describe('OAuth Authorization', () => { it('assertSecureTokenEndpoint: throws on non-loopback http, returns URL for loopback', () => { expect(() => assertSecureTokenEndpoint('http://10.0.0.5/token')).toThrow(InsecureTokenEndpointError); expect(assertSecureTokenEndpoint('http://127.0.0.1:3000/token')).toBeInstanceOf(URL); + expect(assertSecureTokenEndpoint('http://api.localhost:3000/token')).toBeInstanceOf(URL); }); it('rejects a non-https token_endpoint before sending credentials', async () => { @@ -2544,24 +2545,26 @@ describe('OAuth Authorization', () => { expect(mockFetch.mock.calls.some(c => c[0].toString().includes('/token'))).toBe(false); }); - it.each(['http://localhost:9001/token', 'http://127.0.0.1:9001/token', 'http://[::1]:9001/token'])( - 'permits loopback host %s', - async tokenEndpoint => { - mockFetch.mockResolvedValueOnce(Response.json({ access_token: 't', token_type: 'Bearer' })); - await expect( - refreshAuthorization('http://localhost:9001', { - metadata: { - issuer: 'http://localhost:9001', - authorization_endpoint: 'http://localhost:9001/authorize', - token_endpoint: tokenEndpoint, - response_types_supported: ['code'] - }, - clientInformation, - refreshToken: 'rt' - }) - ).resolves.toBeDefined(); - } - ); + it.each([ + 'http://localhost:9001/token', + 'http://api.localhost:9001/token', + 'http://127.0.0.1:9001/token', + 'http://[::1]:9001/token' + ])('permits loopback host %s', async tokenEndpoint => { + mockFetch.mockResolvedValueOnce(Response.json({ access_token: 't', token_type: 'Bearer' })); + await expect( + refreshAuthorization('http://localhost:9001', { + metadata: { + issuer: 'http://localhost:9001', + authorization_endpoint: 'http://localhost:9001/authorize', + token_endpoint: tokenEndpoint, + response_types_supported: ['code'] + }, + clientInformation, + refreshToken: 'rt' + }) + ).resolves.toBeDefined(); + }); }); // SEP-2207 verify-only: behaviors already correct at the v2 baseline, @@ -4647,6 +4650,7 @@ describe('OAuth Authorization', () => { describe('SEP-837: application_type heuristic default', () => { it.each([ ['http://localhost:3000/callback', 'native'], + ['http://foo.localhost:3000/callback', 'native'], ['http://127.0.0.1:8080/cb', 'native'], ['http://[::1]:8080/cb', 'native'], ['myapp://oauth/callback', 'native'],