From 29d398b57c02d99811ea69b8cad3ef345991d07e Mon Sep 17 00:00:00 2001 From: Ben Lambert Date: Tue, 19 May 2026 09:45:31 +0200 Subject: [PATCH] fix(auth-backend): harden default allowed patterns for CIMD and DCR (#34260) * fix(auth-backend): harden default allowed patterns for CIMD and DCR Signed-off-by: benjdlambert * address PR review feedback for OIDC defaults - narrow CLI client ID pattern to exact cli.json path - add BREAKING prefix to changeset - add IPv6 [::1] to docs examples - add loopback redirect URI tests for IPv6 and 127.0.0.1 Signed-off-by: benjdlambert * remove dead ['*'] fallback when features are disabled The restrictive defaults are now always used regardless of the enabled flag, since the patterns are only consulted on code paths that require the feature to be enabled. Signed-off-by: benjdlambert * add default pattern tests and fix docs cli example Signed-off-by: benjdlambert * use URL constructor for CLI client ID Signed-off-by: benjdlambert * use string templating for cliClientId to match OidcRouter Signed-off-by: benjdlambert * fix docs: remove misleading CLI client_id URL example Signed-off-by: benjdlambert --------- Signed-off-by: benjdlambert --- .changeset/harden-oidc-defaults.md | 37 ++ .../config/vocabularies/Backstage/accept.txt | 3 +- app-config.yaml | 7 - docs/ai/mcp-actions.md | 34 +- plugins/auth-backend/config.d.ts | 8 +- .../src/service/OidcRouter.test.ts | 6 + .../src/service/OidcService.test.ts | 371 +++++++++++++++--- .../auth-backend/src/service/OidcService.ts | 31 +- 8 files changed, 404 insertions(+), 93 deletions(-) create mode 100644 .changeset/harden-oidc-defaults.md diff --git a/.changeset/harden-oidc-defaults.md b/.changeset/harden-oidc-defaults.md new file mode 100644 index 0000000000..a3782f6615 --- /dev/null +++ b/.changeset/harden-oidc-defaults.md @@ -0,0 +1,37 @@ +--- +'@backstage/plugin-auth-backend': minor +--- + +**BREAKING**: Hardened the default allowed patterns for CIMD and DCR to replace the previous permissive `['*']` wildcards with specific defaults for known MCP clients. If you previously relied on the default `['*']` patterns, you will need to explicitly configure the patterns you need in your `app-config.yaml`. + +**CIMD (`experimentalClientIdMetadataDocuments`):** + +- `allowedClientIdPatterns` now defaults to Claude, VS Code, and the built-in Backstage CLI instead of `['*']` +- `allowedRedirectUriPatterns` now defaults to loopback addresses (localhost, 127.0.0.1, [::1]) instead of `['*']` + +**DCR (`experimentalDynamicClientRegistration`):** + +- `allowedRedirectUriPatterns` now defaults to Cursor and loopback addresses instead of `['*']` + +If you need to allow additional clients or redirect URIs, you can override these defaults in your `app-config.yaml`: + +```yaml +auth: + experimentalClientIdMetadataDocuments: + enabled: true + allowedClientIdPatterns: + - 'https://claude.ai/*' + - 'https://vscode.dev/*' + - 'https://my-custom-client.example.com/*' + allowedRedirectUriPatterns: + - 'http://localhost:*' + - 'http://127.0.0.1:*' + - 'https://my-app.example.com/callback' + experimentalDynamicClientRegistration: + enabled: true + allowedRedirectUriPatterns: + - 'cursor://*' + - 'http://localhost:*' + - 'http://127.0.0.1:*' + - 'myapp://*' +``` diff --git a/.github/vale/config/vocabularies/Backstage/accept.txt b/.github/vale/config/vocabularies/Backstage/accept.txt index 8549c058a6..a4406cf623 100644 --- a/.github/vale/config/vocabularies/Backstage/accept.txt +++ b/.github/vale/config/vocabularies/Backstage/accept.txt @@ -258,6 +258,7 @@ LocalStack lockdown lockfile lockfiles +loopback lookbehind lookup lookups @@ -593,4 +594,4 @@ Zhou zod Zolotusky zoomable -zsh \ No newline at end of file +zsh diff --git a/app-config.yaml b/app-config.yaml index 46774f3550..11f812bb3d 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -355,15 +355,8 @@ scaffolder: auth: experimentalDynamicClientRegistration: enabled: true - allowedRedirectUriPatterns: - - cursor://* - - http://localhost:* - - http://127.0.0.1:* experimentalClientIdMetadataDocuments: enabled: true - allowedRedirectUriPatterns: - - http://127.0.0.1:* - - http://localhost:* ### Add auth.keyStore.provider to more granularly control how to store JWK data when running # the auth-backend. diff --git a/docs/ai/mcp-actions.md b/docs/ai/mcp-actions.md index 210167e8ae..b170efde5c 100644 --- a/docs/ai/mcp-actions.md +++ b/docs/ai/mcp-actions.md @@ -200,14 +200,21 @@ This can be enabled in the `auth-backend` plugin by using the `auth.experimental auth: experimentalClientIdMetadataDocuments: enabled: true - # Optional: restrict which `client_id` URLs are allowed (defaults to ['*']) - allowedClientIdPatterns: - - 'https://example.com/*' - - 'https://*.trusted-domain.com/*' - # Optional: restrict which redirect URIs are allowed (defaults to ['*']) - allowedRedirectUriPatterns: - - 'http://localhost:*' - - 'https://*.example.com/*' + # Optional: override which client_id URLs are allowed. + # Defaults to Claude, VS Code, and the built-in Backstage CLI. + # Note: setting this replaces the defaults entirely. The built-in + # CLI pattern is derived from your auth backend's base URL and + # must be re-added manually if you override this list. + # allowedClientIdPatterns: + # - 'https://claude.ai/*' + # - 'https://vscode.dev/*' + # - 'https://my-custom-client.example.com/*' + # Optional: override which redirect URIs are allowed. + # Defaults to loopback addresses (localhost, 127.0.0.1, [::1]). + # allowedRedirectUriPatterns: + # - 'http://localhost:*' + # - 'http://127.0.0.1:*' + # - 'http://[::1]:*' ``` #### Dynamic Client Registration @@ -224,10 +231,13 @@ This can be enabled in the `auth-backend` plugin by using the `auth.experimental auth: experimentalDynamicClientRegistration: enabled: true - - # Optional: limit valid callback URLs for added security - allowedRedirectUriPatterns: - - cursor://* + # Optional: restrict which redirect URIs are allowed. + # Defaults to Cursor and loopback addresses (localhost, 127.0.0.1, [::1]). + # allowedRedirectUriPatterns: + # - 'cursor://*' + # - 'http://localhost:*' + # - 'http://127.0.0.1:*' + # - 'http://[::1]:*' ``` ## Configuring MCP Clients diff --git a/plugins/auth-backend/config.d.ts b/plugins/auth-backend/config.d.ts index 2ffe54f3f5..9f71c31de2 100644 --- a/plugins/auth-backend/config.d.ts +++ b/plugins/auth-backend/config.d.ts @@ -163,7 +163,8 @@ export interface Config { /** * A list of allowed URI patterns to use for redirect URIs during - * dynamic client registration. Defaults to '[*]' which allows any redirect URI. + * dynamic client registration. + * Defaults to Cursor and loopback addresses (localhost, 127.0.0.1, [::1]). */ allowedRedirectUriPatterns?: string[]; }; @@ -183,7 +184,8 @@ export interface Config { /** * A list of allowed URI patterns for client_id URLs. * Uses glob-style pattern matching where `*` matches any characters. - * Defaults to ['*'] which allows any client_id URL. + * Defaults to `['https://claude.ai/*', 'https://vscode.dev/*', '{baseUrl}/.well-known/oauth-client/cli.json']` + * where `{baseUrl}` is the auth backend's base URL. * * @example ['https://example.com/*', 'https://*.trusted-domain.com/*'] */ @@ -192,7 +194,7 @@ export interface Config { /** * A list of allowed URI patterns for redirect URIs. * Uses glob-style pattern matching where `*` matches any characters. - * Defaults to ['*'] which allows any redirect URI. + * Defaults to loopback addresses (localhost, 127.0.0.1, [::1]). * * @example ['http://localhost:*', 'http://127.0.0.1:*\/callback'] */ diff --git a/plugins/auth-backend/src/service/OidcRouter.test.ts b/plugins/auth-backend/src/service/OidcRouter.test.ts index 41882bf597..d1e4d5262a 100644 --- a/plugins/auth-backend/src/service/OidcRouter.test.ts +++ b/plugins/auth-backend/src/service/OidcRouter.test.ts @@ -98,6 +98,7 @@ describe('OidcRouter', () => { auth: { experimentalDynamicClientRegistration: { enabled: true, + allowedRedirectUriPatterns: ['*'], }, }, }, @@ -182,6 +183,7 @@ describe('OidcRouter', () => { auth: { experimentalDynamicClientRegistration: { enabled: true, + allowedRedirectUriPatterns: ['*'], }, experimentalRefreshToken: { enabled: true, @@ -1349,6 +1351,8 @@ describe('OidcRouter', () => { auth: { experimentalClientIdMetadataDocuments: { enabled: true, + allowedClientIdPatterns: ['*'], + allowedRedirectUriPatterns: ['*'], }, }, }, @@ -1441,6 +1445,8 @@ describe('OidcRouter', () => { auth: { experimentalClientIdMetadataDocuments: { enabled: true, + allowedClientIdPatterns: ['*'], + allowedRedirectUriPatterns: ['*'], }, // DCR is NOT enabled }, diff --git a/plugins/auth-backend/src/service/OidcService.test.ts b/plugins/auth-backend/src/service/OidcService.test.ts index c9bb0957ab..999fc166c0 100644 --- a/plugins/auth-backend/src/service/OidcService.test.ts +++ b/plugins/auth-backend/src/service/OidcService.test.ts @@ -224,7 +224,7 @@ describe('OidcService', () => { const client = await service.registerClient({ clientName: 'Test Client', - redirectUris: ['https://example.com/callback'], + redirectUris: ['http://localhost:8080/callback'], responseTypes: ['code'], grantTypes: ['authorization_code'], scope: 'openid', @@ -233,7 +233,7 @@ describe('OidcService', () => { expect(client).toEqual( expect.objectContaining({ clientName: 'Test Client', - redirectUris: ['https://example.com/callback'], + redirectUris: ['http://localhost:8080/callback'], responseTypes: ['code'], grantTypes: ['authorization_code'], scope: 'openid', @@ -287,6 +287,74 @@ describe('OidcService', () => { ); }); + it('should accept IPv6 loopback redirect URI', async () => { + const { service } = await createOidcService({ + databaseId, + config: { + auth: { + experimentalDynamicClientRegistration: { + allowedRedirectUriPatterns: [ + 'http://[::1]:*', + 'http://[::1]/*', + ], + }, + }, + }, + }); + + const client = await service.registerClient({ + clientName: 'Test Client', + redirectUris: ['http://[::1]:3000/callback'], + }); + + expect(client).toEqual( + expect.objectContaining({ + redirectUris: ['http://[::1]:3000/callback'], + }), + ); + }); + + it('should accept loopback redirect URIs with default patterns', async () => { + const { service } = await createOidcService({ databaseId }); + + const client = await service.registerClient({ + clientName: 'Test Client', + redirectUris: ['http://localhost:3000/callback'], + }); + + expect(client).toEqual( + expect.objectContaining({ + redirectUris: ['http://localhost:3000/callback'], + }), + ); + }); + + it('should accept cursor redirect URIs with default patterns', async () => { + const { service } = await createOidcService({ databaseId }); + + const client = await service.registerClient({ + clientName: 'Test Client', + redirectUris: ['cursor://callback'], + }); + + expect(client).toEqual( + expect.objectContaining({ + redirectUris: ['cursor://callback'], + }), + ); + }); + + it('should reject non-loopback redirect URIs with default patterns', async () => { + const { service } = await createOidcService({ databaseId }); + + await expect( + service.registerClient({ + clientName: 'Test Client', + redirectUris: ['https://example.com/callback'], + }), + ).rejects.toThrow('Invalid redirect_uri'); + }); + it('should reject redirect URIs containing userinfo', async () => { const { service } = await createOidcService({ databaseId, @@ -338,12 +406,12 @@ describe('OidcService', () => { const client = await service.registerClient({ clientName: 'Test Client', - redirectUris: ['https://example.com/callback'], + redirectUris: ['http://localhost:8080/callback'], }); const authSession = await service.createAuthorizationSession({ clientId: client.clientId, - redirectUri: 'https://example.com/callback', + redirectUri: 'http://localhost:8080/callback', responseType: 'code', scope: 'openid', state: 'test-state', @@ -353,7 +421,7 @@ describe('OidcService', () => { id: expect.any(String), clientName: 'Test Client', scope: 'openid', - redirectUri: 'https://example.com/callback', + redirectUri: 'http://localhost:8080/callback', }); }); @@ -363,7 +431,7 @@ describe('OidcService', () => { await expect( service.createAuthorizationSession({ clientId: 'invalid-client', - redirectUri: 'https://example.com/callback', + redirectUri: 'http://localhost:8080/callback', responseType: 'code', }), ).rejects.toThrow('Invalid client_id'); @@ -374,7 +442,7 @@ describe('OidcService', () => { const client = await service.registerClient({ clientName: 'Test Client', - redirectUris: ['https://example.com/callback'], + redirectUris: ['http://localhost:8080/callback'], }); await expect( @@ -391,13 +459,13 @@ describe('OidcService', () => { const client = await service.registerClient({ clientName: 'Test Client', - redirectUris: ['https://example.com/callback'], + redirectUris: ['http://localhost:8080/callback'], }); await expect( service.createAuthorizationSession({ clientId: client.clientId, - redirectUri: 'https://example.com/callback', + redirectUri: 'http://localhost:8080/callback', responseType: 'token', }), ).rejects.toThrow('Only authorization code flow is supported'); @@ -408,12 +476,12 @@ describe('OidcService', () => { const client = await service.registerClient({ clientName: 'Test Client', - redirectUris: ['https://example.com/callback'], + redirectUris: ['http://localhost:8080/callback'], }); const authSession = await service.createAuthorizationSession({ clientId: client.clientId, - redirectUri: 'https://example.com/callback', + redirectUri: 'http://localhost:8080/callback', responseType: 'code', codeChallenge: 'test-challenge', codeChallengeMethod: 'S256', @@ -427,13 +495,13 @@ describe('OidcService', () => { const client = await service.registerClient({ clientName: 'Test Client', - redirectUris: ['https://example.com/callback'], + redirectUris: ['http://localhost:8080/callback'], }); await expect( service.createAuthorizationSession({ clientId: client.clientId, - redirectUri: 'https://example.com/callback', + redirectUri: 'http://localhost:8080/callback', responseType: 'code', codeChallenge: 'test-challenge', codeChallengeMethod: 'invalid', @@ -448,12 +516,12 @@ describe('OidcService', () => { const client = await service.registerClient({ clientName: 'Test Client', - redirectUris: ['https://example.com/callback'], + redirectUris: ['http://localhost:8080/callback'], }); const authSession = await service.createAuthorizationSession({ clientId: client.clientId, - redirectUri: 'https://example.com/callback', + redirectUri: 'http://localhost:8080/callback', responseType: 'code', state: 'test-state', }); @@ -464,7 +532,7 @@ describe('OidcService', () => { }); expect(result.redirectUrl).toMatch( - /^https:\/\/example\.com\/callback\?code=.+&state=test-state$/, + /^http:\/\/localhost:8080\/callback\?code=.+&state=test-state$/, ); }); @@ -484,12 +552,12 @@ describe('OidcService', () => { const client = await service.registerClient({ clientName: 'Test Client', - redirectUris: ['https://example.com/callback'], + redirectUris: ['http://localhost:8080/callback'], }); const authSession = await service.createAuthorizationSession({ clientId: client.clientId, - redirectUri: 'https://example.com/callback', + redirectUri: 'http://localhost:8080/callback', responseType: 'code', }); @@ -511,12 +579,12 @@ describe('OidcService', () => { const client = await service.registerClient({ clientName: 'Test Client', - redirectUris: ['https://example.com/callback'], + redirectUris: ['http://localhost:8080/callback'], }); const authSession = await service.createAuthorizationSession({ clientId: client.clientId, - redirectUri: 'https://example.com/callback', + redirectUri: 'http://localhost:8080/callback', responseType: 'code', }); @@ -540,12 +608,12 @@ describe('OidcService', () => { const client = await service.registerClient({ clientName: 'Test Client', - redirectUris: ['https://example.com/callback'], + redirectUris: ['http://localhost:8080/callback'], }); const authSession = await service.createAuthorizationSession({ clientId: client.clientId, - redirectUri: 'https://example.com/callback', + redirectUri: 'http://localhost:8080/callback', responseType: 'code', scope: 'openid', state: 'test-state', @@ -560,7 +628,7 @@ describe('OidcService', () => { id: authSession.id, clientId: client.clientId, clientName: 'Test Client', - redirectUri: 'https://example.com/callback', + redirectUri: 'http://localhost:8080/callback', scope: 'openid', state: 'test-state', responseType: 'code', @@ -573,12 +641,12 @@ describe('OidcService', () => { const client = await service.registerClient({ clientName: 'Test Client', - redirectUris: ['https://example.com/callback'], + redirectUris: ['http://localhost:8080/callback'], }); const authSession = await service.createAuthorizationSession({ clientId: client.clientId, - redirectUri: 'https://example.com/callback', + redirectUri: 'http://localhost:8080/callback', responseType: 'code', }); @@ -599,12 +667,12 @@ describe('OidcService', () => { const client = await service.registerClient({ clientName: 'Test Client', - redirectUris: ['https://example.com/callback'], + redirectUris: ['http://localhost:8080/callback'], }); const authSession = await service.createAuthorizationSession({ clientId: client.clientId, - redirectUri: 'https://example.com/callback', + redirectUri: 'http://localhost:8080/callback', responseType: 'code', }); @@ -627,12 +695,12 @@ describe('OidcService', () => { const client = await service.registerClient({ clientName: 'Test Client', - redirectUris: ['https://example.com/callback'], + redirectUris: ['http://localhost:8080/callback'], }); const authSession = await service.createAuthorizationSession({ clientId: client.clientId, - redirectUri: 'https://example.com/callback', + redirectUri: 'http://localhost:8080/callback', responseType: 'code', }); @@ -664,12 +732,12 @@ describe('OidcService', () => { const client = await service.registerClient({ clientName: 'Test Client', - redirectUris: ['https://example.com/callback'], + redirectUris: ['http://localhost:8080/callback'], }); const authSession = await service.createAuthorizationSession({ clientId: client.clientId, - redirectUri: 'https://example.com/callback', + redirectUri: 'http://localhost:8080/callback', responseType: 'code', }); @@ -691,12 +759,12 @@ describe('OidcService', () => { const client = await service.registerClient({ clientName: 'Test Client', - redirectUris: ['https://example.com/callback'], + redirectUris: ['http://localhost:8080/callback'], }); const authSession = await service.createAuthorizationSession({ clientId: client.clientId, - redirectUri: 'https://example.com/callback', + redirectUri: 'http://localhost:8080/callback', responseType: 'code', }); @@ -722,12 +790,12 @@ describe('OidcService', () => { const client = await service.registerClient({ clientName: 'Test Client', - redirectUris: ['https://example.com/callback'], + redirectUris: ['http://localhost:8080/callback'], }); const authSession = await service.createAuthorizationSession({ clientId: client.clientId, - redirectUri: 'https://example.com/callback', + redirectUri: 'http://localhost:8080/callback', responseType: 'code', scope: 'openid', }); @@ -741,7 +809,7 @@ describe('OidcService', () => { const tokenResult = await service.exchangeCodeForToken({ code, - redirectUri: 'https://example.com/callback', + redirectUri: 'http://localhost:8080/callback', grantType: 'authorization_code', }); @@ -760,7 +828,7 @@ describe('OidcService', () => { await expect( service.exchangeCodeForToken({ code: 'test-code', - redirectUri: 'https://example.com/callback', + redirectUri: 'http://localhost:8080/callback', grantType: 'client_credentials', }), ).rejects.toThrow('Unsupported grant type'); @@ -773,7 +841,7 @@ describe('OidcService', () => { const client = await service.registerClient({ clientName: 'Test Client', - redirectUris: ['https://example.com/callback'], + redirectUris: ['http://localhost:8080/callback'], }); const codeVerifier = 'test-code-verifier'; @@ -784,7 +852,7 @@ describe('OidcService', () => { const authSession = await service.createAuthorizationSession({ clientId: client.clientId, - redirectUri: 'https://example.com/callback', + redirectUri: 'http://localhost:8080/callback', responseType: 'code', codeChallenge, codeChallengeMethod: 'S256', @@ -799,7 +867,7 @@ describe('OidcService', () => { const tokenResult = await service.exchangeCodeForToken({ code, - redirectUri: 'https://example.com/callback', + redirectUri: 'http://localhost:8080/callback', grantType: 'authorization_code', codeVerifier, }); @@ -812,13 +880,13 @@ describe('OidcService', () => { const client = await service.registerClient({ clientName: 'Test Client', - redirectUris: ['https://example.com/callback'], + redirectUris: ['http://localhost:8080/callback'], }); const codeChallenge = 'test-challenge'; const authSession = await service.createAuthorizationSession({ clientId: client.clientId, - redirectUri: 'https://example.com/callback', + redirectUri: 'http://localhost:8080/callback', responseType: 'code', codeChallenge, codeChallengeMethod: 'S256', @@ -834,7 +902,7 @@ describe('OidcService', () => { await expect( service.exchangeCodeForToken({ code, - redirectUri: 'https://example.com/callback', + redirectUri: 'http://localhost:8080/callback', grantType: 'authorization_code', codeVerifier: 'invalid-verifier', }), @@ -857,12 +925,12 @@ describe('OidcService', () => { const client = await service.registerClient({ clientName: 'Test Client', - redirectUris: ['https://example.com/callback'], + redirectUris: ['http://localhost:8080/callback'], }); const authSession = await service.createAuthorizationSession({ clientId: client.clientId, - redirectUri: 'https://example.com/callback', + redirectUri: 'http://localhost:8080/callback', responseType: 'code', scope: 'openid offline_access', }); @@ -876,7 +944,7 @@ describe('OidcService', () => { const tokenResult = await service.exchangeCodeForToken({ code, - redirectUri: 'https://example.com/callback', + redirectUri: 'http://localhost:8080/callback', grantType: 'authorization_code', }); @@ -921,7 +989,11 @@ describe('OidcService', () => { databaseId, config: { auth: { - experimentalClientIdMetadataDocuments: { enabled: true }, + experimentalClientIdMetadataDocuments: { + enabled: true, + allowedClientIdPatterns: ['*'], + allowedRedirectUriPatterns: ['*'], + }, }, }, }); @@ -950,12 +1022,68 @@ describe('OidcService', () => { }); describe('createAuthorizationSession with CIMD', () => { + it('should accept loopback redirect URIs with default CIMD patterns', async () => { + const { service } = await createOidcService({ + databaseId, + config: { + auth: { + experimentalClientIdMetadataDocuments: { + enabled: true, + allowedClientIdPatterns: ['*'], + }, + }, + }, + }); + + const authSession = await service.createAuthorizationSession({ + clientId: cimdClientId, + redirectUri: 'http://localhost:8080/callback', + responseType: 'code', + scope: 'openid', + ...pkceParams, + }); + + expect(authSession).toEqual({ + id: expect.any(String), + clientName: 'CIMD Test Client', + scope: 'openid', + redirectUri: 'http://localhost:8080/callback', + }); + }); + + it('should reject non-loopback redirect URIs with default CIMD patterns', async () => { + const { service } = await createOidcService({ + databaseId, + config: { + auth: { + experimentalClientIdMetadataDocuments: { + enabled: true, + allowedClientIdPatterns: ['*'], + }, + }, + }, + }); + + await expect( + service.createAuthorizationSession({ + clientId: cimdClientId, + redirectUri: 'https://example.com/callback', + responseType: 'code', + ...pkceParams, + }), + ).rejects.toThrow('Invalid redirect_uri'); + }); + it('should create authorization session for CIMD client', async () => { const { service } = await createOidcService({ databaseId, config: { auth: { - experimentalClientIdMetadataDocuments: { enabled: true }, + experimentalClientIdMetadataDocuments: { + enabled: true, + allowedClientIdPatterns: ['*'], + allowedRedirectUriPatterns: ['*'], + }, }, }, }); @@ -1054,7 +1182,11 @@ describe('OidcService', () => { databaseId, config: { auth: { - experimentalClientIdMetadataDocuments: { enabled: true }, + experimentalClientIdMetadataDocuments: { + enabled: true, + allowedClientIdPatterns: ['*'], + allowedRedirectUriPatterns: ['*'], + }, }, }, }); @@ -1075,6 +1207,7 @@ describe('OidcService', () => { auth: { experimentalClientIdMetadataDocuments: { enabled: true, + allowedClientIdPatterns: ['*'], allowedRedirectUriPatterns: ['https://*.example.com/*'], }, }, @@ -1100,7 +1233,11 @@ describe('OidcService', () => { databaseId, config: { auth: { - experimentalClientIdMetadataDocuments: { enabled: true }, + experimentalClientIdMetadataDocuments: { + enabled: true, + allowedClientIdPatterns: ['*'], + allowedRedirectUriPatterns: ['*'], + }, }, }, }); @@ -1131,7 +1268,11 @@ describe('OidcService', () => { databaseId, config: { auth: { - experimentalClientIdMetadataDocuments: { enabled: true }, + experimentalClientIdMetadataDocuments: { + enabled: true, + allowedClientIdPatterns: ['*'], + allowedRedirectUriPatterns: ['*'], + }, }, }, }); @@ -1146,6 +1287,82 @@ describe('OidcService', () => { ).rejects.toThrow('Redirect URI not registered'); }); + it('should accept IPv6 loopback redirect_uri with a different port per RFC 8252', async () => { + mockFetchCimdMetadata.mockResolvedValue({ + ...cimdMetadata, + redirectUris: ['http://[::1]/callback'], + }); + + const { service } = await createOidcService({ + databaseId, + config: { + auth: { + experimentalClientIdMetadataDocuments: { + enabled: true, + allowedClientIdPatterns: ['*'], + allowedRedirectUriPatterns: [ + 'http://[::1]:*', + 'http://[::1]/*', + ], + }, + }, + }, + }); + + const authSession = await service.createAuthorizationSession({ + clientId: cimdClientId, + redirectUri: 'http://[::1]:54321/callback', + responseType: 'code', + scope: 'openid', + ...pkceParams, + }); + + expect(authSession).toEqual({ + id: expect.any(String), + clientName: 'CIMD Test Client', + scope: 'openid', + redirectUri: 'http://[::1]:54321/callback', + }); + }); + + it('should accept 127.0.0.1 loopback redirect_uri with a different port per RFC 8252', async () => { + mockFetchCimdMetadata.mockResolvedValue({ + ...cimdMetadata, + redirectUris: ['http://127.0.0.1/callback'], + }); + + const { service } = await createOidcService({ + databaseId, + config: { + auth: { + experimentalClientIdMetadataDocuments: { + enabled: true, + allowedClientIdPatterns: ['*'], + allowedRedirectUriPatterns: [ + 'http://127.0.0.1:*', + 'http://127.0.0.1/*', + ], + }, + }, + }, + }); + + const authSession = await service.createAuthorizationSession({ + clientId: cimdClientId, + redirectUri: 'http://127.0.0.1:54321/callback', + responseType: 'code', + scope: 'openid', + ...pkceParams, + }); + + expect(authSession).toEqual({ + id: expect.any(String), + clientName: 'CIMD Test Client', + scope: 'openid', + redirectUri: 'http://127.0.0.1:54321/callback', + }); + }); + it('should reject redirect_uri when CIMD metadata uses wildcard patterns', async () => { mockFetchCimdMetadata.mockResolvedValue({ ...cimdMetadata, @@ -1158,6 +1375,7 @@ describe('OidcService', () => { auth: { experimentalClientIdMetadataDocuments: { enabled: true, + allowedClientIdPatterns: ['*'], allowedRedirectUriPatterns: ['http://localhost:*'], }, }, @@ -1181,6 +1399,7 @@ describe('OidcService', () => { auth: { experimentalClientIdMetadataDocuments: { enabled: true, + allowedClientIdPatterns: ['*'], allowedRedirectUriPatterns: ['http://localhost:*'], }, }, @@ -1202,7 +1421,11 @@ describe('OidcService', () => { databaseId, config: { auth: { - experimentalClientIdMetadataDocuments: { enabled: true }, + experimentalClientIdMetadataDocuments: { + enabled: true, + allowedClientIdPatterns: ['*'], + allowedRedirectUriPatterns: ['*'], + }, }, }, }); @@ -1223,7 +1446,11 @@ describe('OidcService', () => { databaseId, config: { auth: { - experimentalClientIdMetadataDocuments: { enabled: true }, + experimentalClientIdMetadataDocuments: { + enabled: true, + allowedClientIdPatterns: ['*'], + allowedRedirectUriPatterns: ['*'], + }, }, }, }); @@ -1260,7 +1487,11 @@ describe('OidcService', () => { databaseId, config: { auth: { - experimentalClientIdMetadataDocuments: { enabled: true }, + experimentalClientIdMetadataDocuments: { + enabled: true, + allowedClientIdPatterns: ['*'], + allowedRedirectUriPatterns: ['*'], + }, }, }, }); @@ -1308,7 +1539,11 @@ describe('OidcService', () => { databaseId, config: { auth: { - experimentalClientIdMetadataDocuments: { enabled: true }, + experimentalClientIdMetadataDocuments: { + enabled: true, + allowedClientIdPatterns: ['*'], + allowedRedirectUriPatterns: ['*'], + }, }, }, }); @@ -1357,8 +1592,15 @@ describe('OidcService', () => { databaseId, config: { auth: { - experimentalClientIdMetadataDocuments: { enabled: true }, - experimentalDynamicClientRegistration: { enabled: true }, + experimentalClientIdMetadataDocuments: { + enabled: true, + allowedClientIdPatterns: ['*'], + allowedRedirectUriPatterns: ['*'], + }, + experimentalDynamicClientRegistration: { + enabled: true, + allowedRedirectUriPatterns: ['*'], + }, }, }, }); @@ -1366,13 +1608,13 @@ describe('OidcService', () => { // Register a DCR client const dcrClient = await service.registerClient({ clientName: 'DCR Client', - redirectUris: ['https://example.com/callback'], + redirectUris: ['http://localhost:8080/callback'], }); // Create session with DCR client const authSession = await service.createAuthorizationSession({ clientId: dcrClient.clientId, - redirectUri: 'https://example.com/callback', + redirectUri: 'http://localhost:8080/callback', responseType: 'code', }); @@ -1385,8 +1627,15 @@ describe('OidcService', () => { databaseId, config: { auth: { - experimentalClientIdMetadataDocuments: { enabled: true }, - experimentalDynamicClientRegistration: { enabled: true }, + experimentalClientIdMetadataDocuments: { + enabled: true, + allowedClientIdPatterns: ['*'], + allowedRedirectUriPatterns: ['*'], + }, + experimentalDynamicClientRegistration: { + enabled: true, + allowedRedirectUriPatterns: ['*'], + }, }, }, }); diff --git a/plugins/auth-backend/src/service/OidcService.ts b/plugins/auth-backend/src/service/OidcService.ts index 98a06bc42f..ee78f7d564 100644 --- a/plugins/auth-backend/src/service/OidcService.ts +++ b/plugins/auth-backend/src/service/OidcService.ts @@ -46,6 +46,14 @@ function validateRedirectUri( } const LOOPBACK_HOSTS = ['localhost', '127.0.0.1', '[::1]']; +const LOOPBACK_REDIRECT_PATTERNS = [ + 'http://localhost:*', + 'http://localhost/*', + 'http://127.0.0.1:*', + 'http://127.0.0.1/*', + 'http://[::1]:*', + 'http://[::1]/*', +]; /** * RFC 8252 Section 7.3: For loopback redirect URIs, the authorization server @@ -213,7 +221,7 @@ export class OidcService { const allowedRedirectUriPatterns = this.config.getOptionalStringArray( 'auth.experimentalDynamicClientRegistration.allowedRedirectUriPatterns', - ) ?? ['*']; + ) ?? ['cursor://*', ...LOOPBACK_REDIRECT_PATTERNS]; for (const redirectUri of opts.redirectUris ?? []) { validateRedirectUri(redirectUri, allowedRedirectUriPatterns); @@ -297,17 +305,22 @@ export class OidcService { } private getCimdConfig() { + const enabled = + this.config.getOptionalBoolean( + 'auth.experimentalClientIdMetadataDocuments.enabled', + ) ?? false; + + const cliClientId = `${this.baseUrl}/.well-known/oauth-client/cli.json`; + return { - enabled: - this.config.getOptionalBoolean( - 'auth.experimentalClientIdMetadataDocuments.enabled', - ) ?? false, + enabled, allowedClientIdPatterns: this.config.getOptionalStringArray( 'auth.experimentalClientIdMetadataDocuments.allowedClientIdPatterns', - ) ?? ['*'], - allowedRedirectUriPatterns: this.config.getOptionalStringArray( - 'auth.experimentalClientIdMetadataDocuments.allowedRedirectUriPatterns', - ) ?? ['*'], + ) ?? ['https://claude.ai/*', 'https://vscode.dev/*', cliClientId], + allowedRedirectUriPatterns: + this.config.getOptionalStringArray( + 'auth.experimentalClientIdMetadataDocuments.allowedRedirectUriPatterns', + ) ?? LOOPBACK_REDIRECT_PATTERNS, }; }