Skip to content

Commit d1ebf06

Browse files
committed
fix(client): treat *.localhost as loopback for OAuth token endpoints
RFC 6761 §6.3 reserves names ending in .localhost as loopback. Exempt them from the SEP-2207 https token-endpoint guard (same as localhost / 127.0.0.1 / ::1) so host-based multi-tenant local dev works. Fixes #2591
1 parent cc4b416 commit d1ebf06

4 files changed

Lines changed: 37 additions & 22 deletions

File tree

packages/client/src/client/auth.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -829,9 +829,18 @@ export function applyPublicAuth(clientId: string, params: URLSearchParams): void
829829
params.set('client_id', clientId);
830830
}
831831

832-
/** Loopback hosts exempt from the in-transit `https:` requirement (RFC 8252 §7.3). */
832+
/**
833+
* Loopback hosts exempt from the in-transit `https:` requirement (RFC 8252 §7.3).
834+
* Includes bare `localhost` and any name ending in `.localhost` (RFC 6761 §6.3).
835+
*/
833836
function isLoopbackHost(hostname: string): boolean {
834-
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]' || hostname === '::1';
837+
return (
838+
hostname === 'localhost' ||
839+
hostname.endsWith('.localhost') ||
840+
hostname === '127.0.0.1' ||
841+
hostname === '[::1]' ||
842+
hostname === '::1'
843+
);
835844
}
836845

837846
/**

packages/client/src/client/authErrors.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ export class InsecureTokenEndpointError extends OAuthClientFlowError {
148148
constructor(tokenEndpoint: string) {
149149
super(
150150
`Refusing to send credentials to non-https token endpoint '${tokenEndpoint}'. ` +
151-
`OAuth token requests MUST use TLS (localhost / 127.0.0.1 / ::1 are exempt).`
151+
`OAuth token requests MUST use TLS (localhost / *.localhost / 127.0.0.1 / ::1 are exempt).`
152152
);
153153
this.tokenEndpoint = tokenEndpoint;
154154
}

packages/client/test/client/auth.test.ts

Lines changed: 24 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2462,6 +2462,8 @@ describe('OAuth Authorization', () => {
24622462
it('assertSecureTokenEndpoint: throws on non-loopback http, returns URL for loopback', () => {
24632463
expect(() => assertSecureTokenEndpoint('http://10.0.0.5/token')).toThrow(InsecureTokenEndpointError);
24642464
expect(assertSecureTokenEndpoint('http://127.0.0.1:3000/token')).toBeInstanceOf(URL);
2465+
expect(assertSecureTokenEndpoint('http://tenant.example.localhost:3300/token')).toBeInstanceOf(URL);
2466+
expect(assertSecureTokenEndpoint('http://localhost/token')).toBeInstanceOf(URL);
24652467
});
24662468

24672469
it('rejects a non-https token_endpoint before sending credentials', async () => {
@@ -2530,24 +2532,27 @@ describe('OAuth Authorization', () => {
25302532
expect(mockFetch.mock.calls.some(c => c[0].toString().includes('/token'))).toBe(false);
25312533
});
25322534

2533-
it.each(['http://localhost:9001/token', 'http://127.0.0.1:9001/token', 'http://[::1]:9001/token'])(
2534-
'permits loopback host %s',
2535-
async tokenEndpoint => {
2536-
mockFetch.mockResolvedValueOnce(Response.json({ access_token: 't', token_type: 'Bearer' }));
2537-
await expect(
2538-
refreshAuthorization('http://localhost:9001', {
2539-
metadata: {
2540-
issuer: 'http://localhost:9001',
2541-
authorization_endpoint: 'http://localhost:9001/authorize',
2542-
token_endpoint: tokenEndpoint,
2543-
response_types_supported: ['code']
2544-
},
2545-
clientInformation,
2546-
refreshToken: 'rt'
2547-
})
2548-
).resolves.toBeDefined();
2549-
}
2550-
);
2535+
it.each([
2536+
'http://localhost:9001/token',
2537+
'http://127.0.0.1:9001/token',
2538+
'http://[::1]:9001/token',
2539+
'http://tenant.example.localhost:3300/token',
2540+
'http://app.localhost:9001/token'
2541+
])('permits loopback host %s', async tokenEndpoint => {
2542+
mockFetch.mockResolvedValueOnce(Response.json({ access_token: 't', token_type: 'Bearer' }));
2543+
await expect(
2544+
refreshAuthorization('http://localhost:9001', {
2545+
metadata: {
2546+
issuer: 'http://localhost:9001',
2547+
authorization_endpoint: 'http://localhost:9001/authorize',
2548+
token_endpoint: tokenEndpoint,
2549+
response_types_supported: ['code']
2550+
},
2551+
clientInformation,
2552+
refreshToken: 'rt'
2553+
})
2554+
).resolves.toBeDefined();
2555+
});
25512556
});
25522557

25532558
// SEP-2207 verify-only: behaviors already correct at the v2 baseline,
@@ -4368,6 +4373,7 @@ describe('OAuth Authorization', () => {
43684373
describe('SEP-837: application_type heuristic default', () => {
43694374
it.each([
43704375
['http://localhost:3000/callback', 'native'],
4376+
['http://tenant.example.localhost:3300/callback', 'native'],
43714377
['http://127.0.0.1:8080/cb', 'native'],
43724378
['http://[::1]:8080/cb', 'native'],
43734379
['myapp://oauth/callback', 'native'],

test/e2e/requirements.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2345,7 +2345,7 @@ export const REQUIREMENTS: Record<string, Requirement> = {
23452345
'client-auth:token-endpoint:https-guard': {
23462346
source: 'https://modelcontextprotocol.io/specification/draft/basic/authorization#refresh-token-grant',
23472347
behavior:
2348-
"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.",
2348+
"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.",
23492349
transports: ['streamableHttp'],
23502350
addedInSpecVersion: '2026-07-28',
23512351
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.'

0 commit comments

Comments
 (0)