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:
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user