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, }; }