fix(server/auth): RFC 8252 loopback port relaxation (#1738)
This commit is contained in:
parent
398dc70f64
commit
897bc25bfe
3 changed files with 148 additions and 2 deletions
6
.changeset/rfc8252-loopback-port-relaxation.md
Normal file
6
.changeset/rfc8252-loopback-port-relaxation.md
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
'@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).
|
||||
|
|
@ -15,6 +15,39 @@ export type AuthorizationHandlerOptions = {
|
|||
rateLimit?: Partial<RateLimitOptions> | 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 +111,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) {
|
||||
|
|
|
|||
|
|
@ -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<OAuthClientInformationFull | undefined> {
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue