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
5 changes: 5 additions & 0 deletions .changeset/localhost-subdomains-loopback.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@modelcontextprotocol/client': patch
---

Treat `.localhost` subdomains as loopback hosts for OAuth token endpoint TLS checks.
8 changes: 7 additions & 1 deletion packages/client/src/client/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
);
}

/**
Expand Down
2 changes: 1 addition & 1 deletion packages/client/src/client/authErrors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
40 changes: 22 additions & 18 deletions packages/client/test/client/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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'],
Expand Down
Loading