diff --git a/packages/@n8n/api-types/src/dto/oidc/config.dto.ts b/packages/@n8n/api-types/src/dto/oidc/config.dto.ts index b71a78f0c75..1323cd5008e 100644 --- a/packages/@n8n/api-types/src/dto/oidc/config.dto.ts +++ b/packages/@n8n/api-types/src/dto/oidc/config.dto.ts @@ -11,4 +11,5 @@ export class OidcConfigDto extends Z.class({ loginEnabled: z.boolean().optional().default(false), prompt: z.enum(OIDC_PROMPT_VALUES).optional().default('select_account'), authenticationContextClassReference: z.array(z.string()).default([]), + additionalScopes: z.string().default(''), }) {} diff --git a/packages/cli/src/modules/sso-oidc/__tests__/oidc.service.ee.test.ts b/packages/cli/src/modules/sso-oidc/__tests__/oidc.service.ee.test.ts index 1c81d4eec53..605eae1dce6 100644 --- a/packages/cli/src/modules/sso-oidc/__tests__/oidc.service.ee.test.ts +++ b/packages/cli/src/modules/sso-oidc/__tests__/oidc.service.ee.test.ts @@ -189,6 +189,7 @@ describe('OidcService', () => { prompt: 'select_account', discoveryEndpoint: expect.any(URL), authenticationContextClassReference: expect.any(Array), + additionalScopes: '', }); }); @@ -208,6 +209,7 @@ describe('OidcService', () => { prompt: 'select_account', discoveryEndpoint: expect.any(URL), authenticationContextClassReference: [], + additionalScopes: '', }); }); @@ -302,6 +304,7 @@ describe('OidcService', () => { prompt: 'select_account', discoveryEndpoint: expect.any(URL), authenticationContextClassReference: expect.any(Array), + additionalScopes: '', }); expect(logger.warn).not.toHaveBeenCalled(); }); diff --git a/packages/cli/src/modules/sso-oidc/oidc.service.ee.ts b/packages/cli/src/modules/sso-oidc/oidc.service.ee.ts index a032506b6e8..013e425888d 100644 --- a/packages/cli/src/modules/sso-oidc/oidc.service.ee.ts +++ b/packages/cli/src/modules/sso-oidc/oidc.service.ee.ts @@ -42,11 +42,17 @@ const DEFAULT_OIDC_CONFIG: OidcConfigDto = { loginEnabled: false, prompt: 'select_account', authenticationContextClassReference: [], + additionalScopes: '', }; type OidcRuntimeConfig = Pick< OidcConfigDto, - 'clientId' | 'clientSecret' | 'loginEnabled' | 'prompt' | 'authenticationContextClassReference' + | 'clientId' + | 'clientSecret' + | 'loginEnabled' + | 'prompt' + | 'authenticationContextClassReference' + | 'additionalScopes' > & { discoveryEndpoint: URL; }; @@ -207,10 +213,13 @@ export class OidcService { provisioningConfig.scopesProvisionProjectRoles; // Include the custom n8n scope if provisioning is enabled - const scope = provisioningEnabled + const baseScope = provisioningEnabled ? `openid email profile ${provisioningConfig.scopesName}` : 'openid email profile'; + const additionalScopes = this.oidcConfig.additionalScopes.trim(); + const scope = additionalScopes ? `${baseScope} ${additionalScopes}` : baseScope; + const authorizationURL = this.openidClient.buildAuthorizationUrl(configuration, { redirect_uri: this.getCallbackUrl(), response_type: 'code', @@ -383,10 +392,13 @@ export class OidcService { provisioningConfig.scopesProvisionInstanceRole || provisioningConfig.scopesProvisionProjectRoles; - const scope = provisioningEnabled + const baseScope = provisioningEnabled ? `openid email profile ${provisioningConfig.scopesName}` : 'openid email profile'; + const additionalScopes = config.additionalScopes.trim(); + const scope = additionalScopes ? `${baseScope} ${additionalScopes}` : baseScope; + const authorizationURL = this.openidClient.buildAuthorizationUrl(configuration, { redirect_uri: this.getCallbackUrl(), response_type: 'code', 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 7ee7a88f702..45065edc64b 100644 --- a/packages/cli/test/integration/oidc/oidc.service.ee.test.ts +++ b/packages/cli/test/integration/oidc/oidc.service.ee.test.ts @@ -58,6 +58,7 @@ describe('OIDC service', () => { loginEnabled: false, prompt: 'select_account', authenticationContextClassReference: [], + additionalScopes: '', }); }); @@ -70,6 +71,7 @@ describe('OIDC service', () => { loginEnabled: false, prompt: 'select_account', authenticationContextClassReference: [], + additionalScopes: '', }); }); @@ -81,6 +83,7 @@ describe('OIDC service', () => { loginEnabled: true, prompt: 'select_account', authenticationContextClassReference: ['mfa', 'phrh', 'pwd'], + additionalScopes: '', }; await oidcService.updateConfig(newConfig); @@ -104,6 +107,7 @@ describe('OIDC service', () => { loginEnabled: true, prompt: 'select_account', authenticationContextClassReference: ['mfa', 'phrh', 'pwd'], + additionalScopes: '', }; await oidcService.updateConfig(newConfig); @@ -126,6 +130,7 @@ describe('OIDC service', () => { loginEnabled: true, prompt: 'select_account', authenticationContextClassReference: ['mfa', 'phrh', 'pwd'], + additionalScopes: '', }; await expect(oidcService.updateConfig(newConfig)).rejects.toThrowError(UserError); @@ -139,6 +144,7 @@ describe('OIDC service', () => { loginEnabled: true, prompt: 'select_account', authenticationContextClassReference: ['mfa', 'phrh', 'pwd'], + additionalScopes: '', }; await oidcService.updateConfig(newConfig); @@ -162,6 +168,7 @@ describe('OIDC service', () => { loginEnabled: true, prompt: 'select_account', authenticationContextClassReference: ['mfa', 'phrh', 'pwd'], + additionalScopes: '', }; discoveryMock.mockRejectedValueOnce(new Error('Discovery failed')); @@ -183,6 +190,7 @@ describe('OIDC service', () => { loginEnabled: true, prompt: 'select_account', authenticationContextClassReference: ['mfa', 'phrh', 'pwd'], + additionalScopes: '', }; const mockConfiguration = new real_odic_client.Configuration( @@ -214,6 +222,7 @@ describe('OIDC service', () => { loginEnabled: true, prompt: 'select_account', authenticationContextClassReference: ['mfa', 'phrh', 'pwd'], + additionalScopes: '', }; const newMockConfiguration = new real_odic_client.Configuration( @@ -266,6 +275,7 @@ describe('OIDC service', () => { loginEnabled: true, prompt: 'consent', authenticationContextClassReference: ['mfa', 'phrh', 'pwd'], + additionalScopes: '', }; await oidcService.updateConfig(initialConfig); @@ -310,6 +320,7 @@ describe('OIDC service', () => { loginEnabled: true, prompt: 'consent', authenticationContextClassReference: ['mfa', 'phrh', 'pwd'], + additionalScopes: '', }; await oidcService.updateConfig(initialConfig); @@ -398,6 +409,164 @@ describe('OIDC service', () => { }); }); + describe('additionalScopes', () => { + 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', + ); + + const baseConfig: OidcConfigDto = { + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + discoveryEndpoint: 'https://example.com/.well-known/openid-configuration', + loginEnabled: true, + prompt: 'select_account', + authenticationContextClassReference: [], + additionalScopes: '', + }; + + let provisioningConfig: GlobalConfig['sso']['provisioning']; + + beforeEach(() => { + discoveryMock.mockResolvedValue(mockConfiguration); + provisioningConfig = { ...Container.get(GlobalConfig).sso.provisioning }; + // @ts-expect-error - provisioningConfig is private and only accessible within the class + Container.get(ProvisioningService).provisioningConfig.scopesProvisionInstanceRole = false; + // @ts-expect-error - provisioningConfig is private and only accessible within the class + Container.get(ProvisioningService).provisioningConfig.scopesProvisionProjectRoles = false; + }); + + afterEach(() => { + Container.get(GlobalConfig).sso.provisioning = provisioningConfig; + }); + + it('should include additional scopes in the authorization URL', async () => { + await oidcService.updateConfig({ ...baseConfig, additionalScopes: 'groups b2xroles' }); + + const authUrl = await oidcService.generateLoginUrl(); + + expect(authUrl.url.searchParams.get('scope')).toEqual('openid email profile groups b2xroles'); + }); + + it('should only use default scopes when additionalScopes is empty', async () => { + await oidcService.updateConfig({ ...baseConfig, additionalScopes: '' }); + + const authUrl = await oidcService.generateLoginUrl(); + + expect(authUrl.url.searchParams.get('scope')).toEqual('openid email profile'); + }); + + it('should trim whitespace from additionalScopes', async () => { + await oidcService.updateConfig({ ...baseConfig, additionalScopes: ' groups ' }); + + const authUrl = await oidcService.generateLoginUrl(); + + expect(authUrl.url.searchParams.get('scope')).toEqual('openid email profile groups'); + }); + + it('should include additional scopes alongside provisioning scope', async () => { + // @ts-expect-error - provisioningConfig is private and only accessible within the class + Container.get(ProvisioningService).provisioningConfig.scopesProvisionInstanceRole = true; + // @ts-expect-error - provisioningConfig is private and only accessible within the class + Container.get(ProvisioningService).provisioningConfig.scopesName = 'n8n_test_scope'; + + await oidcService.updateConfig({ ...baseConfig, additionalScopes: 'groups' }); + + const authUrl = await oidcService.generateLoginUrl(); + + expect(authUrl.url.searchParams.get('scope')).toEqual( + 'openid email profile n8n_test_scope groups', + ); + + // @ts-expect-error - provisioningConfig is private and only accessible within the class + Container.get(ProvisioningService).provisioningConfig.scopesProvisionInstanceRole = false; + }); + + it('should URL-encode special characters in additionalScopes preventing injection', async () => { + await oidcService.updateConfig({ + ...baseConfig, + additionalScopes: 'groups&redirect_uri=https://evil.com', + }); + + const authUrl = await oidcService.generateLoginUrl(); + const scopeParam = authUrl.url.searchParams.get('scope'); + + // The scope value should contain the raw string (URL-encoded by the URL object) + expect(scopeParam).toContain('groups&redirect_uri=https://evil.com'); + // There must be no extra redirect_uri parameter injected into the URL + const urlString = authUrl.url.toString(); + const redirectUriMatches = [...urlString.matchAll(/redirect_uri=/g)]; + expect(redirectUriMatches).toHaveLength(1); + }); + }); + + describe('generateTestLoginUrl', () => { + it('should include additional scopes in the test authorization URL', 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); + + await oidcService.updateConfig({ + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + discoveryEndpoint: 'https://example.com/.well-known/openid-configuration', + loginEnabled: true, + prompt: 'select_account', + authenticationContextClassReference: [], + additionalScopes: 'groups', + }); + + const authUrl = await oidcService.generateTestLoginUrl(); + + expect(authUrl.url.searchParams.get('scope')).toEqual('openid email profile groups'); + }); + + it('should only use default scopes when additionalScopes is empty in test URL', 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); + + await oidcService.updateConfig({ + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + discoveryEndpoint: 'https://example.com/.well-known/openid-configuration', + loginEnabled: true, + prompt: 'select_account', + authenticationContextClassReference: [], + additionalScopes: '', + }); + + const authUrl = await oidcService.generateTestLoginUrl(); + + expect(authUrl.url.searchParams.get('scope')).toEqual('openid email profile'); + }); + }); + describe('loginUser', () => { it('should handle new user login with valid callback URL', async () => { const state = oidcService.generateState(); diff --git a/packages/frontend/editor-ui/src/features/settings/sso/components/OidcSettingsForm.vue b/packages/frontend/editor-ui/src/features/settings/sso/components/OidcSettingsForm.vue index f14cbf901e9..04b35845d15 100644 --- a/packages/frontend/editor-ui/src/features/settings/sso/components/OidcSettingsForm.vue +++ b/packages/frontend/editor-ui/src/features/settings/sso/components/OidcSettingsForm.vue @@ -69,6 +69,10 @@ const promptDescriptions: PromptDescription[] = [ ]; const authenticationContextClassReference = ref(''); +const additionalScopes = ref(''); +const isAdditionalScopesInvalid = computed(() => + [',', ';'].some((c) => additionalScopes.value.includes(c)), +); const getOidcConfig = async () => { const config = await ssoStore.getOidcConfig(); @@ -79,6 +83,7 @@ const getOidcConfig = async () => { prompt.value = config.prompt ?? 'select_account'; authenticationContextClassReference.value = config.authenticationContextClassReference?.join(',') || ''; + additionalScopes.value = config.additionalScopes ?? ''; }; const loadOidcConfig = async () => { @@ -104,15 +109,17 @@ const cannotSaveOidcSettings = computed(() => { const isRuleMappingDirty = roleMappingRuleEditorRef.value?.isDirty ?? false; return ( - ssoStore.oidcConfig?.clientId === clientId.value && - ssoStore.oidcConfig?.clientSecret === clientSecret.value && - ssoStore.oidcConfig?.discoveryEndpoint === discoveryEndpoint.value && - ssoStore.oidcConfig?.loginEnabled === ssoStore.isOidcLoginEnabled && - ssoStore.oidcConfig?.prompt === prompt.value && - !isUserRoleProvisioningChanged.value && - !isRuleMappingDirty && - storedAcrString === authenticationContextClassReference.value && - currentAcrString === storedAcrString + isAdditionalScopesInvalid.value || + (ssoStore.oidcConfig?.clientId === clientId.value && + ssoStore.oidcConfig?.clientSecret === clientSecret.value && + ssoStore.oidcConfig?.discoveryEndpoint === discoveryEndpoint.value && + ssoStore.oidcConfig?.loginEnabled === ssoStore.isOidcLoginEnabled && + ssoStore.oidcConfig?.prompt === prompt.value && + ssoStore.oidcConfig?.additionalScopes === additionalScopes.value && + !isUserRoleProvisioningChanged.value && + !isRuleMappingDirty && + storedAcrString === authenticationContextClassReference.value && + currentAcrString === storedAcrString) ); }); @@ -158,6 +165,7 @@ async function onOidcSettingsSave(provisioningChangesConfirmed: boolean = false) prompt: prompt.value, loginEnabled: ssoStore.isOidcLoginEnabled, authenticationContextClassReference: acrArray, + additionalScopes: additionalScopes.value, }); const provisioningResult = await saveProvisioningConfig(isDisablingOidcLogin); @@ -349,6 +357,27 @@ onMounted(async () => { commas in order of preference. +
+ + + Use spaces to separate scopes. Commas and semicolons are not allowed. + By default n8n requests openid, profile and email. + If you need other scopes, define them here space separated. +
diff --git a/packages/frontend/editor-ui/src/features/settings/sso/sso.test.ts b/packages/frontend/editor-ui/src/features/settings/sso/sso.test.ts index fd5abe36eb0..d31b18404ce 100644 --- a/packages/frontend/editor-ui/src/features/settings/sso/sso.test.ts +++ b/packages/frontend/editor-ui/src/features/settings/sso/sso.test.ts @@ -194,6 +194,7 @@ describe('SSO store', () => { loginEnabled: true, prompt: 'select_account', authenticationContextClassReference: [], + additionalScopes: '', }; vi.mocked(ssoApi.getOidcConfig).mockResolvedValue(oidcConfig); @@ -225,6 +226,7 @@ describe('SSO store', () => { loginEnabled: false, prompt: 'select_account', authenticationContextClassReference: [], + additionalScopes: '', }); await ssoStore.getOidcConfig(); diff --git a/packages/frontend/editor-ui/src/features/settings/sso/styles/sso-form.module.scss b/packages/frontend/editor-ui/src/features/settings/sso/styles/sso-form.module.scss index 1e6fd8e27c6..d24aecd0bdf 100644 --- a/packages/frontend/editor-ui/src/features/settings/sso/styles/sso-form.module.scss +++ b/packages/frontend/editor-ui/src/features/settings/sso/styles/sso-form.module.scss @@ -185,6 +185,18 @@ } } +.group small.fieldError { + display: block; + padding: var(--spacing--2xs) 0 0; + font-size: var(--font-size--2xs); + color: var(--color--text--danger); +} + +.optional { + font-weight: var(--font-weight--regular); + color: var(--color--text--tint-1); +} + .greenDot { display: inline-block; width: 8px;