From 2e753eefb657d654ee7073886f936ce58cb21665 Mon Sep 17 00:00:00 2001 From: Ben Lambert Date: Sun, 22 Mar 2026 00:19:38 +0100 Subject: [PATCH] fix(auth-backend): allow any port for loopback redirect URIs per RFC 8252 (#33508) For CIMD clients using loopback redirect URIs (localhost/127.0.0.1), match by scheme, hostname, and path only, ignoring the port. Native CLI apps like Claude Code use ephemeral ports for OAuth callbacks. Signed-off-by: benjdlambert --- .../src/service/OidcService.test.ts | 56 +++++++++++++++++++ .../auth-backend/src/service/OidcService.ts | 35 +++++++++++- 2 files changed, 90 insertions(+), 1 deletion(-) diff --git a/plugins/auth-backend/src/service/OidcService.test.ts b/plugins/auth-backend/src/service/OidcService.test.ts index 37eed97092..c9bb0957ab 100644 --- a/plugins/auth-backend/src/service/OidcService.test.ts +++ b/plugins/auth-backend/src/service/OidcService.test.ts @@ -1090,6 +1090,62 @@ describe('OidcService', () => { ).rejects.toThrow('Invalid redirect_uri'); }); + it('should accept loopback redirect_uri with a different port per RFC 8252', async () => { + mockFetchCimdMetadata.mockResolvedValue({ + ...cimdMetadata, + redirectUris: ['http://localhost/callback'], + }); + + const { service } = await createOidcService({ + databaseId, + config: { + auth: { + experimentalClientIdMetadataDocuments: { enabled: true }, + }, + }, + }); + + const authSession = await service.createAuthorizationSession({ + clientId: cimdClientId, + redirectUri: 'http://localhost:60056/callback', + responseType: 'code', + scope: 'openid', + ...pkceParams, + }); + + expect(authSession).toEqual({ + id: expect.any(String), + clientName: 'CIMD Test Client', + scope: 'openid', + redirectUri: 'http://localhost:60056/callback', + }); + }); + + it('should reject non-loopback redirect_uri with a different port', async () => { + mockFetchCimdMetadata.mockResolvedValue({ + ...cimdMetadata, + redirectUris: ['https://example.com/callback'], + }); + + const { service } = await createOidcService({ + databaseId, + config: { + auth: { + experimentalClientIdMetadataDocuments: { enabled: true }, + }, + }, + }); + + await expect( + service.createAuthorizationSession({ + clientId: cimdClientId, + redirectUri: 'https://example.com:9999/callback', + responseType: 'code', + ...pkceParams, + }), + ).rejects.toThrow('Redirect URI not registered'); + }); + it('should reject redirect_uri when CIMD metadata uses wildcard patterns', async () => { mockFetchCimdMetadata.mockResolvedValue({ ...cimdMetadata, diff --git a/plugins/auth-backend/src/service/OidcService.ts b/plugins/auth-backend/src/service/OidcService.ts index 5943f7dd8d..98a06bc42f 100644 --- a/plugins/auth-backend/src/service/OidcService.ts +++ b/plugins/auth-backend/src/service/OidcService.ts @@ -45,6 +45,39 @@ function validateRedirectUri( } } +const LOOPBACK_HOSTS = ['localhost', '127.0.0.1', '[::1]']; + +/** + * RFC 8252 Section 7.3: For loopback redirect URIs, the authorization server + * MUST allow any port to be specified at the time of the request. This matches + * redirect URIs by scheme, hostname, and path only, ignoring the port. + */ +function matchesRedirectUri( + requestUri: string, + registeredUris: string[], +): boolean { + const requested = new URL(requestUri); + + if (!LOOPBACK_HOSTS.includes(requested.hostname)) { + return registeredUris.includes(requestUri); + } + + return registeredUris.some(registered => { + let reg: URL; + try { + reg = new URL(registered); + } catch { + return false; + } + return ( + LOOPBACK_HOSTS.includes(reg.hostname) && + reg.protocol === requested.protocol && + reg.hostname === requested.hostname && + reg.pathname === requested.pathname + ); + }); +} + export class OidcService { private readonly auth: AuthService; private readonly tokenIssuer: TokenIssuer; @@ -322,7 +355,7 @@ export class OidcService { if (opts.redirectUri) { validateRedirectUri(opts.redirectUri, cimd.allowedRedirectUriPatterns); - if (!cimdClient.redirectUris.includes(opts.redirectUri)) { + if (!matchesRedirectUri(opts.redirectUri, cimdClient.redirectUris)) { throw new InputError('Redirect URI not registered'); } }