From 6ab49a9eaf950890963b0687e8c6f05d4ac3caf3 Mon Sep 17 00:00:00 2001 From: Alice Poteat Date: Tue, 24 Mar 2026 13:53:03 -0700 Subject: [PATCH 1/2] fix(server/auth): apply RFC 8252 loopback port relaxation in authorize handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The authorize handler validated redirect_uri with a strict .includes() string match against the client's registered redirect_uris. This rejects native apps that register a portless loopback URI (e.g. http://localhost/callback via CIMD) but send an ephemeral-port URI at authorize time (http://localhost:53428/callback). Per RFC 8252 §7.3 and the OAuth 2.1 draft, authorization servers MUST allow any port for loopback IP redirect URIs to accommodate clients that obtain an available ephemeral port from the OS at request time. This adds redirectUriMatches() which applies port relaxation for localhost, 127.0.0.1, and [::1] hosts while preserving exact-match semantics for all other URIs. Scheme, host, path, and query must still match exactly — only the port is relaxed, and only on loopback. --- .../rfc8252-loopback-port-relaxation.md | 5 + src/server/auth/handlers/authorize.ts | 41 ++++++- test/server/auth/handlers/authorize.test.ts | 108 +++++++++++++++++- 3 files changed, 152 insertions(+), 2 deletions(-) create mode 100644 .changeset/rfc8252-loopback-port-relaxation.md diff --git a/.changeset/rfc8252-loopback-port-relaxation.md b/.changeset/rfc8252-loopback-port-relaxation.md new file mode 100644 index 0000000000..d160b9a7d6 --- /dev/null +++ b/.changeset/rfc8252-loopback-port-relaxation.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/sdk': patch +--- + +The authorization handler now applies RFC 8252 §7.3 loopback port relaxation when validating `redirect_uri` against a client's registered URIs. For `localhost`, `127.0.0.1`, and `[::1]` hosts, any port is accepted as long as scheme, host, path, and query match. This fixes native clients that obtain an ephemeral port from the OS but register a portless loopback URI (e.g., via CIMD / SEP-991). diff --git a/src/server/auth/handlers/authorize.ts b/src/server/auth/handlers/authorize.ts index dcb6c03ecf..f0d0612743 100644 --- a/src/server/auth/handlers/authorize.ts +++ b/src/server/auth/handlers/authorize.ts @@ -15,6 +15,44 @@ export type AuthorizationHandlerOptions = { rateLimit?: Partial | false; }; +const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']); + +/** + * Validates a requested redirect_uri against a registered one. + * + * Per RFC 8252 §7.3 (OAuth 2.0 for Native Apps), authorization servers MUST + * allow any port for loopback redirect URIs (localhost, 127.0.0.1, [::1]) to + * accommodate native clients that obtain an ephemeral port from the OS. For + * non-loopback URIs, exact match is required. + * + * @see https://datatracker.ietf.org/doc/html/rfc8252#section-7.3 + */ +export function redirectUriMatches(requested: string, registered: string): boolean { + if (requested === registered) { + return true; + } + let req: URL, reg: URL; + try { + req = new URL(requested); + reg = new URL(registered); + } catch { + return false; + } + // Port relaxation only applies when both URIs target a loopback host. + if (!LOOPBACK_HOSTS.has(req.hostname) || !LOOPBACK_HOSTS.has(reg.hostname)) { + return false; + } + // RFC 8252 relaxes the port only — scheme, host, path, and query must + // still match exactly. Note: hostname must match exactly too (the RFC + // does not allow localhost↔127.0.0.1 cross-matching). + return ( + req.protocol === reg.protocol && + req.hostname === reg.hostname && + req.pathname === reg.pathname && + req.search === reg.search + ); +} + // Parameters that must be validated in order to issue redirects. const ClientAuthorizationParamsSchema = z.object({ client_id: z.string(), @@ -78,7 +116,8 @@ export function authorizationHandler({ provider, rateLimit: rateLimitConfig }: A } if (redirect_uri !== undefined) { - if (!client.redirect_uris.includes(redirect_uri)) { + const requested = redirect_uri; + if (!client.redirect_uris.some(registered => redirectUriMatches(requested, registered))) { throw new InvalidRequestError('Unregistered redirect_uri'); } } else if (client.redirect_uris.length === 1) { diff --git a/test/server/auth/handlers/authorize.test.ts b/test/server/auth/handlers/authorize.test.ts index 0f831ae7de..f4d68d4df8 100644 --- a/test/server/auth/handlers/authorize.test.ts +++ b/test/server/auth/handlers/authorize.test.ts @@ -1,4 +1,4 @@ -import { authorizationHandler, AuthorizationHandlerOptions } from '../../../../src/server/auth/handlers/authorize.js'; +import { authorizationHandler, AuthorizationHandlerOptions, redirectUriMatches } from '../../../../src/server/auth/handlers/authorize.js'; import { OAuthServerProvider, AuthorizationParams } from '../../../../src/server/auth/provider.js'; import { OAuthRegisteredClientsStore } from '../../../../src/server/auth/clients.js'; import { OAuthClientInformationFull, OAuthTokens } from '../../../../src/shared/auth.js'; @@ -23,6 +23,14 @@ describe('Authorization Handler', () => { scope: 'profile email' }; + // Native app client with a portless loopback redirect (e.g., from CIMD / SEP-991) + const loopbackClient: OAuthClientInformationFull = { + client_id: 'loopback-client', + client_secret: 'valid-secret', + redirect_uris: ['http://localhost/callback', 'http://127.0.0.1/callback'], + scope: 'profile email' + }; + // Mock client store const mockClientStore: OAuthRegisteredClientsStore = { async getClient(clientId: string): Promise { @@ -30,6 +38,8 @@ describe('Authorization Handler', () => { return validClient; } else if (clientId === 'multi-redirect-client') { return multiRedirectClient; + } else if (clientId === 'loopback-client') { + return loopbackClient; } return undefined; } @@ -172,6 +182,102 @@ describe('Authorization Handler', () => { const location = new URL(response.header.location); expect(location.origin + location.pathname).toBe('https://example.com/callback'); }); + + // RFC 8252 §7.3: authorization servers MUST allow any port for loopback + // redirect URIs. Native apps obtain ephemeral ports from the OS. + it('accepts loopback redirect_uri with ephemeral port (RFC 8252)', async () => { + const response = await supertest(app).get('/authorize').query({ + client_id: 'loopback-client', + redirect_uri: 'http://localhost:53428/callback', + response_type: 'code', + code_challenge: 'challenge123', + code_challenge_method: 'S256' + }); + + expect(response.status).toBe(302); + const location = new URL(response.header.location); + expect(location.hostname).toBe('localhost'); + expect(location.port).toBe('53428'); + expect(location.pathname).toBe('/callback'); + }); + + it('accepts 127.0.0.1 loopback redirect_uri with ephemeral port (RFC 8252)', async () => { + const response = await supertest(app).get('/authorize').query({ + client_id: 'loopback-client', + redirect_uri: 'http://127.0.0.1:9000/callback', + response_type: 'code', + code_challenge: 'challenge123', + code_challenge_method: 'S256' + }); + + expect(response.status).toBe(302); + }); + + it('rejects loopback redirect_uri with different path', async () => { + const response = await supertest(app).get('/authorize').query({ + client_id: 'loopback-client', + redirect_uri: 'http://localhost:53428/evil', + response_type: 'code', + code_challenge: 'challenge123', + code_challenge_method: 'S256' + }); + + expect(response.status).toBe(400); + }); + + it('does not relax port for non-loopback redirect_uri', async () => { + const response = await supertest(app).get('/authorize').query({ + client_id: 'valid-client', + redirect_uri: 'https://example.com:8443/callback', + response_type: 'code', + code_challenge: 'challenge123', + code_challenge_method: 'S256' + }); + + expect(response.status).toBe(400); + }); + }); + + describe('redirectUriMatches (RFC 8252 §7.3)', () => { + it('exact match passes', () => { + expect(redirectUriMatches('https://example.com/cb', 'https://example.com/cb')).toBe(true); + }); + + it('loopback: any port matches portless registration', () => { + expect(redirectUriMatches('http://localhost:53428/callback', 'http://localhost/callback')).toBe(true); + expect(redirectUriMatches('http://127.0.0.1:8080/callback', 'http://127.0.0.1/callback')).toBe(true); + expect(redirectUriMatches('http://[::1]:9000/cb', 'http://[::1]/cb')).toBe(true); + }); + + it('loopback: any port matches ported registration', () => { + expect(redirectUriMatches('http://localhost:53428/callback', 'http://localhost:3118/callback')).toBe(true); + }); + + it('loopback: different path rejected', () => { + expect(redirectUriMatches('http://localhost:53428/evil', 'http://localhost/callback')).toBe(false); + }); + + it('loopback: different scheme rejected', () => { + expect(redirectUriMatches('https://localhost:53428/callback', 'http://localhost/callback')).toBe(false); + }); + + it('loopback: localhost↔127.0.0.1 cross-match rejected', () => { + // RFC 8252 relaxes port only, not host + expect(redirectUriMatches('http://127.0.0.1:53428/callback', 'http://localhost/callback')).toBe(false); + }); + + it('non-loopback: port must match exactly', () => { + expect(redirectUriMatches('https://example.com:8443/cb', 'https://example.com/cb')).toBe(false); + }); + + it('non-loopback: no relaxation for private IPs', () => { + expect(redirectUriMatches('http://192.168.1.1:8080/cb', 'http://192.168.1.1/cb')).toBe(false); + }); + + it('malformed URIs rejected', () => { + expect(redirectUriMatches('not a url', 'http://localhost/cb')).toBe(false); + expect(redirectUriMatches('http://localhost/cb', 'not a url')).toBe(false); + }); }); describe('Authorization request validation', () => { From c936bc45040dee5c91c7b8216c7a122c030e9c60 Mon Sep 17 00:00:00 2001 From: Alice Poteat Date: Tue, 24 Mar 2026 14:12:35 -0700 Subject: [PATCH 2/2] style: prettier formatting --- .changeset/rfc8252-loopback-port-relaxation.md | 3 ++- src/server/auth/handlers/authorize.ts | 7 +------ 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/.changeset/rfc8252-loopback-port-relaxation.md b/.changeset/rfc8252-loopback-port-relaxation.md index d160b9a7d6..83ce27aa6b 100644 --- a/.changeset/rfc8252-loopback-port-relaxation.md +++ b/.changeset/rfc8252-loopback-port-relaxation.md @@ -2,4 +2,5 @@ '@modelcontextprotocol/sdk': patch --- -The authorization handler now applies RFC 8252 §7.3 loopback port relaxation when validating `redirect_uri` against a client's registered URIs. For `localhost`, `127.0.0.1`, and `[::1]` hosts, any port is accepted as long as scheme, host, path, and query match. This fixes native clients that obtain an ephemeral port from the OS but register a portless loopback URI (e.g., via CIMD / SEP-991). +The authorization handler now applies RFC 8252 §7.3 loopback port relaxation when validating `redirect_uri` against a client's registered URIs. For `localhost`, `127.0.0.1`, and `[::1]` hosts, any port is accepted as long as scheme, host, path, and query match. This fixes native +clients that obtain an ephemeral port from the OS but register a portless loopback URI (e.g., via CIMD / SEP-991). diff --git a/src/server/auth/handlers/authorize.ts b/src/server/auth/handlers/authorize.ts index f0d0612743..4b9f3b327f 100644 --- a/src/server/auth/handlers/authorize.ts +++ b/src/server/auth/handlers/authorize.ts @@ -45,12 +45,7 @@ export function redirectUriMatches(requested: string, registered: string): boole // RFC 8252 relaxes the port only — scheme, host, path, and query must // still match exactly. Note: hostname must match exactly too (the RFC // does not allow localhost↔127.0.0.1 cross-matching). - return ( - req.protocol === reg.protocol && - req.hostname === reg.hostname && - req.pathname === reg.pathname && - req.search === reg.search - ); + return req.protocol === reg.protocol && req.hostname === reg.hostname && req.pathname === reg.pathname && req.search === reg.search; } // Parameters that must be validated in order to issue redirects.