feat: Allow OIDC logins to sync instance roles (#21055)

Co-authored-by: konstantintieber <konstantin.tieber@n8n.io>
This commit is contained in:
Stephen Wright
2025-10-23 10:12:49 +01:00
committed by GitHub
co-authored by konstantintieber
parent 83d8b7e720
commit fcbf1433f6
5 changed files with 61 additions and 19 deletions
@@ -501,7 +501,7 @@ describe('ProvisioningService', () => {
expect(config).toEqual({ ...provisioningConfigDto, scopesProvisionInstanceRole: false });
expect(provisioningService.loadConfig).toHaveBeenCalledTimes(1);
expect(provisioningService.getConfig).toHaveBeenCalledTimes(2);
expect(settingsRepository.update).toHaveBeenCalledTimes(1);
expect(settingsRepository.upsert).toHaveBeenCalledTimes(1);
expect(publisher.publishCommand).toHaveBeenCalledTimes(1);
expect(publisher.publishCommand).toHaveBeenCalledWith({
command: 'reload-sso-provisioning-configuration',
@@ -254,13 +254,18 @@ export class ProvisioningService {
ProvisioningConfigDto.parse(updatedConfig);
await this.settingsRepository.update(
{ key: PROVISIONING_PREFERENCES_DB_KEY },
{ value: JSON.stringify(updatedConfig) },
await this.settingsRepository.upsert(
{
key: PROVISIONING_PREFERENCES_DB_KEY,
value: JSON.stringify(updatedConfig),
loadOnStartup: true,
},
{ conflictPaths: ['key'] },
);
await this.publisher.publishCommand({ command: 'reload-sso-provisioning-configuration' });
this.provisioningConfig = await this.loadConfig();
await this.publisher.publishCommand({ command: 'reload-sso-provisioning-configuration' });
return await this.getConfig();
}
@@ -16,6 +16,7 @@ import { OIDC_PREFERENCES_DB_KEY } from '../constants';
import { OidcService } from '../oidc.service.ee';
import { Publisher } from '@/scaling/pubsub/publisher.service';
import type { OidcConfigDto } from '@n8n/api-types';
import { type ProvisioningService } from '@/modules/provisioning.ee/provisioning.service.ee';
describe('OidcService', () => {
let oidcService: OidcService;
@@ -25,6 +26,7 @@ describe('OidcService', () => {
let cipher: Cipher;
let logger: Logger;
let jwtService: JwtService;
let provisioningService: ProvisioningService;
const mockOidcConfig = {
clientId: 'test-client-id',
@@ -56,6 +58,7 @@ describe('OidcService', () => {
cipher = mock<Cipher>();
logger = mockLogger();
jwtService = mock<JwtService>();
provisioningService = mock<ProvisioningService>();
jest
.spyOn(ssoHelpers, 'setCurrentAuthenticationMethod')
@@ -71,6 +74,7 @@ describe('OidcService', () => {
logger,
jwtService,
instanceSettings,
provisioningService,
);
await oidcService.init();
@@ -31,6 +31,7 @@ import {
} from '../sso-helpers';
import { OIDC_CLIENT_SECRET_REDACTED_VALUE, OIDC_PREFERENCES_DB_KEY } from './constants';
import { OnPubSubEvent } from '@n8n/decorators';
import { ProvisioningService } from '@/modules/provisioning.ee/provisioning.service.ee';
const DEFAULT_OIDC_CONFIG: OidcConfigDto = {
clientId: '',
@@ -66,6 +67,7 @@ export class OidcService {
private readonly logger: Logger,
private readonly jwtService: JwtService,
private readonly instanceSettings: InstanceSettings,
private readonly provisioningService: ProvisioningService,
) {}
async init() {
@@ -176,13 +178,14 @@ export class OidcService {
const prompt = this.oidcConfig.prompt;
const provisioning = this.globalConfig.sso.provisioning;
const provisioningConfig = await this.provisioningService.getConfig();
const provisioningEnabled =
provisioning.scopesProvisionInstanceRole || provisioning.scopesProvisionProjectRoles;
provisioningConfig.scopesProvisionInstanceRole ||
provisioningConfig.scopesProvisionProjectRoles;
// Include the custom n8n scope if provisioning is enabled
const scope = provisioningEnabled
? `openid email profile ${provisioning.scopesName}`
? `openid email profile ${provisioningConfig.scopesName}`
: 'openid email profile';
const authorizationURL = client.buildAuthorizationUrl(configuration, {
@@ -242,6 +245,11 @@ export class OidcService {
throw new BadRequestError('Invalid email format');
}
const provisioningConfig = await this.provisioningService.getConfig();
const provisioningEnabled =
provisioningConfig.scopesProvisionInstanceRole ||
provisioningConfig.scopesProvisionProjectRoles;
const openidUser = await this.authIdentityRepository.findOne({
where: { providerId: claims.sub, providerType: 'oidc' },
relations: {
@@ -252,6 +260,13 @@ export class OidcService {
});
if (openidUser) {
if (provisioningEnabled) {
await this.provisioningService.provisionInstanceRoleForUser(
openidUser.user,
claims.n8n_instance_role,
);
}
return openidUser.user;
}
@@ -297,6 +312,10 @@ export class OidcService {
}),
);
if (provisioningEnabled) {
await this.provisioningService.provisionInstanceRoleForUser(user, claims.n8n_instance_role);
}
return user;
});
}
@@ -24,6 +24,7 @@ import { createUser } from '@test-integration/db/users';
import { UserError } from 'n8n-workflow';
import { JwtService } from '@/services/jwt.service';
import { GlobalConfig } from '@n8n/config';
import { ProvisioningService } from '@/modules/provisioning.ee/provisioning.service.ee';
beforeAll(async () => {
await testDb.init();
@@ -334,8 +335,12 @@ describe('OIDC service', () => {
};
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;
// @ts-expect-error - provisioningConfig is private and only accessible within the class
Container.get(ProvisioningService).provisioningConfig.scopesProvisionProjectRoles = false;
// @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.scopesName = 'n8n_test_scope';
const authUrl = await oidcService.generateLoginUrl();
validateUrl(authUrl);
@@ -343,9 +348,12 @@ describe('OIDC service', () => {
});
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';
// @ts-expect-error - provisioningConfig is private and only accessible within the class
Container.get(ProvisioningService).provisioningConfig.scopesProvisionProjectRoles = true;
// @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.scopesName = 'n8n_test_scope';
const authUrl = await oidcService.generateLoginUrl();
validateUrl(authUrl);
@@ -353,9 +361,12 @@ describe('OIDC service', () => {
});
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';
// @ts-expect-error - provisioningConfig is private and only accessible within the class
Container.get(ProvisioningService).provisioningConfig.scopesProvisionProjectRoles = false;
// @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';
const authUrl = await oidcService.generateLoginUrl();
validateUrl(authUrl);
@@ -363,9 +374,12 @@ describe('OIDC service', () => {
});
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';
// @ts-expect-error - provisioningConfig is private and only accessible within the class
Container.get(ProvisioningService).provisioningConfig.scopesProvisionProjectRoles = true;
// @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';
const authUrl = await oidcService.generateLoginUrl();
validateUrl(authUrl);