From db001019809ad6d2cf6cbd6f3a3f05fc2de0f00e Mon Sep 17 00:00:00 2001 From: Andreas Fitzek Date: Tue, 14 Oct 2025 13:31:49 +0200 Subject: [PATCH] chore(core): Send custom n8n scope in OIDC for provisioning if configured (#20757) --- .../cli/src/sso.ee/oidc/oidc.service.ee.ts | 11 ++- .../integration/oidc/oidc.service.ee.test.ts | 97 +++++++++++++++++++ 2 files changed, 107 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/sso.ee/oidc/oidc.service.ee.ts b/packages/cli/src/sso.ee/oidc/oidc.service.ee.ts index e4b0abb58c8..1cefba9ef07 100644 --- a/packages/cli/src/sso.ee/oidc/oidc.service.ee.ts +++ b/packages/cli/src/sso.ee/oidc/oidc.service.ee.ts @@ -176,10 +176,19 @@ export class OidcService { const prompt = this.oidcConfig.prompt; + const provisioning = this.globalConfig.sso.provisioning; + const provisioningEnabled = + provisioning.scopesProvisionInstanceRole || provisioning.scopesProvisionProjectRoles; + + // Include the custom n8n scope if provisioning is enabled + const scope = provisioningEnabled + ? `openid email profile ${provisioning.scopesName}` + : 'openid email profile'; + const authorizationURL = client.buildAuthorizationUrl(configuration, { redirect_uri: this.getCallbackUrl(), response_type: 'code', - scope: 'openid email profile', + scope, prompt, state: state.plaintext, nonce: nonce.plaintext, diff --git a/packages/cli/test/integration/oidc/oidc.service.ee.test.ts b/packages/cli/test/integration/oidc/oidc.service.ee.test.ts index 3ff02377277..dfa537964f6 100644 --- a/packages/cli/test/integration/oidc/oidc.service.ee.test.ts +++ b/packages/cli/test/integration/oidc/oidc.service.ee.test.ts @@ -23,6 +23,7 @@ import { OidcService } from '@/sso.ee/oidc/oidc.service.ee'; import { createUser } from '@test-integration/db/users'; import { UserError } from 'n8n-workflow'; import { JwtService } from '@/services/jwt.service'; +import { GlobalConfig } from '@n8n/config'; beforeAll(async () => { await testDb.init(); @@ -276,6 +277,102 @@ describe('OIDC service', () => { expect(authUrl.nonce).toBeDefined(); }); + describe('SSO provisioning', () => { + beforeAll(async () => { + const mockConfiguration = new real_odic_client.Configuration( + { + issuer: 'https://example.com/auth/realms/n8n', + client_id: 'test-client-id', + redirect_uris: ['http://n8n.io/sso/oidc/callback'], + response_types: ['code'], + scopes: ['openid', 'profile', 'email'], + authorization_endpoint: 'https://example.com/auth', + }, + 'test-client-id', + ); + discoveryMock.mockResolvedValue(mockConfiguration); + + const initialConfig: OidcConfigDto = { + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + discoveryEndpoint: 'https://example.com/.well-known/openid-configuration', + loginEnabled: true, + prompt: 'consent', + }; + + await oidcService.updateConfig(initialConfig); + }); + + let provisioningConfig: GlobalConfig['sso']['provisioning']; + + beforeEach(() => { + // safe original provisioning config, by making a copy + provisioningConfig = { + ...Container.get(GlobalConfig).sso.provisioning, + }; + }); + + afterEach(() => { + // restore original provisioning config + Container.get(GlobalConfig).sso.provisioning = provisioningConfig; + }); + + const validateUrl = (authUrl: Awaited>) => { + expect(authUrl.url.pathname).toEqual('/auth'); + expect(authUrl.url.searchParams.get('client_id')).toEqual('test-client-id'); + expect(authUrl.url.searchParams.get('redirect_uri')).toEqual( + 'http://localhost:5678/rest/sso/oidc/callback', + ); + expect(authUrl.url.searchParams.get('response_type')).toEqual('code'); + expect(authUrl.url.searchParams.get('prompt')).toBeDefined(); + expect(authUrl.url.searchParams.get('prompt')).toEqual('consent'); + expect(authUrl.url.searchParams.get('state')).toBeDefined(); + expect(authUrl.url.searchParams.get('state')?.startsWith('n8n_state:')).toBe(true); + + expect(authUrl.state).toBeDefined(); + expect(authUrl.nonce).toBeDefined(); + }; + + it('should not include the provisioning scope if no provisioning is enabled', async () => { + Container.get(GlobalConfig).sso.provisioning.scopesProvisionProjectRoles = false; + Container.get(GlobalConfig).sso.provisioning.scopesProvisionInstanceRole = false; + const authUrl = await oidcService.generateLoginUrl(); + + validateUrl(authUrl); + expect(authUrl.url.searchParams.get('scope')).toEqual('openid email profile'); + }); + + it('should include the provisioning scope if project provisioning is enabled', async () => { + Container.get(GlobalConfig).sso.provisioning.scopesProvisionProjectRoles = true; + Container.get(GlobalConfig).sso.provisioning.scopesProvisionInstanceRole = false; + Container.get(GlobalConfig).sso.provisioning.scopesName = 'n8n_test_scope'; + const authUrl = await oidcService.generateLoginUrl(); + + validateUrl(authUrl); + expect(authUrl.url.searchParams.get('scope')).toEqual('openid email profile n8n_test_scope'); + }); + + it('should include the provisioning scope if instance provisioning is enabled', async () => { + Container.get(GlobalConfig).sso.provisioning.scopesProvisionProjectRoles = false; + Container.get(GlobalConfig).sso.provisioning.scopesProvisionInstanceRole = true; + Container.get(GlobalConfig).sso.provisioning.scopesName = 'n8n_test_scope'; + const authUrl = await oidcService.generateLoginUrl(); + + validateUrl(authUrl); + expect(authUrl.url.searchParams.get('scope')).toEqual('openid email profile n8n_test_scope'); + }); + + it('should include the provisioning scope if project and instance provisioning is enabled', async () => { + Container.get(GlobalConfig).sso.provisioning.scopesProvisionProjectRoles = true; + Container.get(GlobalConfig).sso.provisioning.scopesProvisionInstanceRole = true; + Container.get(GlobalConfig).sso.provisioning.scopesName = 'n8n_test_scope'; + const authUrl = await oidcService.generateLoginUrl(); + + validateUrl(authUrl); + expect(authUrl.url.searchParams.get('scope')).toEqual('openid email profile n8n_test_scope'); + }); + }); + describe('loginUser', () => { it('should handle new user login with valid callback URL', async () => { const state = oidcService.generateState();