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 <ben@blam.sh>
This commit is contained in:
Ben Lambert
2026-03-22 00:19:38 +01:00
committed by GitHub
parent a93b7fdafb
commit 2e753eefb6
2 changed files with 90 additions and 1 deletions
@@ -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,
@@ -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');
}
}