feat(core): Add "Additional scopes" field to OIDC SSO setup (#31708)

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Savelii 2026-06-04 12:49:36 +02:00 committed by GitHub
parent 203e816b82
commit f459d73236
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 240 additions and 12 deletions

View File

@ -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(''),
}) {}

View File

@ -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();
});

View File

@ -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',

View File

@ -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();

View File

@ -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.</small
>
</div>
<div :class="$style.group">
<label
>Additional scopes
<span :class="$style.optional">(Optional)</span>
</label>
<N8nInput
:model-value="additionalScopes"
:disabled="isSsoManagedByEnv"
type="text"
data-test-id="oidc-additional-scopes"
placeholder="e.g. groups roles"
@update:model-value="(v: string) => (additionalScopes = v)"
/>
<small v-if="isAdditionalScopesInvalid" :class="$style.fieldError"
>Use spaces to separate scopes. Commas and semicolons are not allowed.</small
>
<small v-else
>By default n8n requests <code>openid</code>, <code>profile</code> and <code>email</code>.
If you need other scopes, define them here space separated.</small
>
</div>
</div>
<div :class="$style.card">
<div :class="[$style.settingsItem, $style.settingsItemNoBorder]">

View File

@ -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();

View File

@ -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;