mirror of
https://github.com/n8n-io/n8n.git
synced 2026-06-05 02:59:27 +02:00
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:
parent
203e816b82
commit
f459d73236
|
|
@ -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(''),
|
||||
}) {}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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]">
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user