diff --git a/.changeset/loopback-localhost-subdomains.md b/.changeset/loopback-localhost-subdomains.md new file mode 100644 index 0000000000..f79a09d5a2 --- /dev/null +++ b/.changeset/loopback-localhost-subdomains.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/client': patch +--- + +Treat hostnames ending in `.localhost` as loopback for the SEP-2207 token-endpoint https guard (RFC 6761 §6.3), so host-based multi-tenant local OAuth works. diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index 9ebc6fd251..2b4b4fa01f 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -829,9 +829,18 @@ export function applyPublicAuth(clientId: string, params: URLSearchParams): void params.set('client_id', clientId); } -/** Loopback hosts exempt from the in-transit `https:` requirement (RFC 8252 §7.3). */ +/** + * Loopback hosts exempt from the in-transit `https:` requirement (RFC 8252 §7.3). + * Includes bare `localhost` and any name ending in `.localhost` (RFC 6761 §6.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 62c6faed9a..90ca200fed 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -2462,6 +2462,8 @@ 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://tenant.example.localhost:3300/token')).toBeInstanceOf(URL); + expect(assertSecureTokenEndpoint('http://localhost/token')).toBeInstanceOf(URL); }); it('rejects a non-https token_endpoint before sending credentials', async () => { @@ -2530,24 +2532,27 @@ 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://127.0.0.1:9001/token', + 'http://[::1]:9001/token', + 'http://tenant.example.localhost:3300/token', + 'http://app.localhost: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, @@ -4368,6 +4373,7 @@ describe('OAuth Authorization', () => { describe('SEP-837: application_type heuristic default', () => { it.each([ ['http://localhost:3000/callback', 'native'], + ['http://tenant.example.localhost:3300/callback', 'native'], ['http://127.0.0.1:8080/cb', 'native'], ['http://[::1]:8080/cb', 'native'], ['myapp://oauth/callback', 'native'], diff --git a/test/e2e/requirements.ts b/test/e2e/requirements.ts index a3439e1ceb..dc3c4903dd 100644 --- a/test/e2e/requirements.ts +++ b/test/e2e/requirements.ts @@ -2345,7 +2345,7 @@ export const REQUIREMENTS: Record = { 'client-auth:token-endpoint:https-guard': { source: 'https://modelcontextprotocol.io/specification/draft/basic/authorization#refresh-token-grant', behavior: - "The token-exchange and refresh paths refuse to send credentials to a non-https token endpoint (localhost / 127.0.0.1 / ::1 exempt) by throwing InsecureTokenEndpointError, and auth()'s refresh branch surfaces it instead of falling through to a fresh /authorize redirect.", + "The token-exchange and refresh paths refuse to send credentials to a non-https token endpoint (localhost / *.localhost / 127.0.0.1 / ::1 exempt) by throwing InsecureTokenEndpointError, and auth()'s refresh branch surfaces it instead of falling through to a fresh /authorize redirect.", transports: ['streamableHttp'], addedInSpecVersion: '2026-07-28', note: 'This exercises the HTTP hosting/auth layer and OAuth client; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.'