From 30148df7f3fd7b49660d1f4635a577d9bb80e964 Mon Sep 17 00:00:00 2001 From: Ricardo Espinoza Date: Fri, 13 Jun 2025 10:18:14 -0400 Subject: [PATCH] feat(core): Add OIDC support for SSO (#15988) Co-authored-by: Andreas Fitzek --- jest.config.js | 15 +- packages/@n8n/api-types/src/dto/index.ts | 2 + .../@n8n/api-types/src/dto/oidc/config.dto.ts | 9 + .../@n8n/api-types/src/frontend-settings.ts | 8 +- .../@n8n/backend-common/src/license-state.ts | 4 + packages/@n8n/constants/src/index.ts | 1 + packages/@n8n/db/src/entities/types-db.ts | 2 +- packages/@n8n/permissions/src/constants.ee.ts | 1 + .../src/roles/scopes/global-scopes.ee.ts | 1 + packages/cli/package.json | 1 + packages/cli/src/config/schema.ts | 7 + .../cli/src/controllers/e2e.controller.ts | 1 + packages/cli/src/controllers/me.controller.ts | 33 +- .../controllers/password-reset.controller.ts | 17 +- packages/cli/src/ldap.ee/ldap.service.ee.ts | 22 +- packages/cli/src/server.ts | 14 + packages/cli/src/services/frontend.service.ts | 13 + packages/cli/src/services/user.service.ts | 4 +- packages/cli/src/sso.ee/oidc/constants.ts | 4 + .../cli/src/sso.ee/oidc/oidc.service.ee.ts | 267 ++++++++++++ .../sso.ee/oidc/routes/oidc.controller.ee.ts | 62 +++ packages/cli/src/sso.ee/saml/saml-helpers.ts | 19 +- packages/cli/src/sso.ee/sso-helpers.ts | 4 + .../integration/oidc/oidc.service.ee.test.ts | 380 ++++++++++++++++++ .../src/components/N8nUsersList/UsersList.vue | 2 +- .../frontend/@n8n/i18n/src/locales/en.json | 8 +- .../@n8n/rest-api-client/src/api/sso.ts | 17 +- packages/frontend/editor-ui/src/Interface.ts | 2 + .../editor-ui/src/__tests__/defaults.ts | 2 + .../frontend/editor-ui/src/__tests__/mocks.ts | 1 + .../editor-ui/src/components/SSOLogin.vue | 7 +- packages/frontend/editor-ui/src/constants.ts | 2 + .../editor-ui/src/permissions.test.ts | 2 + .../editor-ui/src/stores/rbac.store.ts | 1 + .../editor-ui/src/stores/settings.store.ts | 20 +- .../editor-ui/src/stores/sso.store.ts | 70 +++- .../src/views/SettingsPersonalView.vue | 4 +- .../editor-ui/src/views/SettingsSso.test.ts | 9 + .../editor-ui/src/views/SettingsSso.vue | 347 ++++++++++++---- pnpm-lock.yaml | 170 +++++--- 40 files changed, 1358 insertions(+), 197 deletions(-) create mode 100644 packages/@n8n/api-types/src/dto/oidc/config.dto.ts create mode 100644 packages/cli/src/sso.ee/oidc/constants.ts create mode 100644 packages/cli/src/sso.ee/oidc/oidc.service.ee.ts create mode 100644 packages/cli/src/sso.ee/oidc/routes/oidc.controller.ee.ts create mode 100644 packages/cli/test/integration/oidc/oidc.service.ee.test.ts diff --git a/jest.config.js b/jest.config.js index 5aa8805d4b..d7cfdc4e7b 100644 --- a/jest.config.js +++ b/jest.config.js @@ -13,6 +13,17 @@ const tsJestOptions = { const isCoverageEnabled = process.env.COVERAGE_ENABLED === 'true'; +const esmDependencies = [ + 'pdfjs-dist', + 'openid-client', + 'oauth4webapi', + 'jose', + // Add other ESM dependencies that need to be transformed here +]; + +const esmDependenciesPattern = esmDependencies.join('|'); +const esmDependenciesRegex = `node_modules/(${esmDependenciesPattern})/.+\\.m?js$`; + /** @type {import('jest').Config} */ const config = { verbose: true, @@ -21,7 +32,7 @@ const config = { testPathIgnorePatterns: ['/dist/', '/node_modules/'], transform: { '^.+\\.ts$': ['ts-jest', tsJestOptions], - 'node_modules/pdfjs-dist/.+\\.mjs$': [ + [esmDependenciesRegex]: [ 'babel-jest', { presets: ['@babel/preset-env'], @@ -29,7 +40,7 @@ const config = { }, ], }, - transformIgnorePatterns: ['/dist/', '/node_modules/(?!.*pdfjs-dist/)'], + transformIgnorePatterns: [`/node_modules/(?!${esmDependenciesPattern})/`], // This resolve the path mappings from the tsconfig relative to each jest.config.js moduleNameMapper: compilerOptions?.paths ? pathsToModuleNameMapper(compilerOptions.paths, { diff --git a/packages/@n8n/api-types/src/dto/index.ts b/packages/@n8n/api-types/src/dto/index.ts index 5808ab88ae..9cf9ab7307 100644 --- a/packages/@n8n/api-types/src/dto/index.ts +++ b/packages/@n8n/api-types/src/dto/index.ts @@ -72,3 +72,5 @@ export { InsightsDateFilterDto } from './insights/date-filter.dto'; export { PaginationDto } from './pagination/pagination.dto'; export { UsersListFilterDto } from './user/users-list-filter.dto'; + +export { OidcConfigDto } from './oidc/config.dto'; diff --git a/packages/@n8n/api-types/src/dto/oidc/config.dto.ts b/packages/@n8n/api-types/src/dto/oidc/config.dto.ts new file mode 100644 index 0000000000..64dc2af609 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/oidc/config.dto.ts @@ -0,0 +1,9 @@ +import { z } from 'zod'; +import { Z } from 'zod-class'; + +export class OidcConfigDto extends Z.class({ + clientId: z.string().min(1), + clientSecret: z.string().min(1), + discoveryEndpoint: z.string().url(), + loginEnabled: z.boolean().optional().default(false), +}) {} diff --git a/packages/@n8n/api-types/src/frontend-settings.ts b/packages/@n8n/api-types/src/frontend-settings.ts index 3bd9400a5c..9d9153659e 100644 --- a/packages/@n8n/api-types/src/frontend-settings.ts +++ b/packages/@n8n/api-types/src/frontend-settings.ts @@ -18,7 +18,7 @@ export interface ITelemetrySettings { config?: ITelemetryClientConfig; } -export type AuthenticationMethod = 'email' | 'ldap' | 'saml'; +export type AuthenticationMethod = 'email' | 'ldap' | 'saml' | 'oidc'; export interface IUserManagementSettings { quota: number; @@ -84,6 +84,11 @@ export interface FrontendSettings { loginLabel: string; loginEnabled: boolean; }; + oidc: { + loginEnabled: boolean; + loginUrl: string; + callbackUrl: string; + }; ldap: { loginLabel: string; loginEnabled: boolean; @@ -129,6 +134,7 @@ export interface FrontendSettings { sharing: boolean; ldap: boolean; saml: boolean; + oidc: boolean; logStreaming: boolean; advancedExecutionFilters: boolean; variables: boolean; diff --git a/packages/@n8n/backend-common/src/license-state.ts b/packages/@n8n/backend-common/src/license-state.ts index b2ad56f844..b891b7af85 100644 --- a/packages/@n8n/backend-common/src/license-state.ts +++ b/packages/@n8n/backend-common/src/license-state.ts @@ -58,6 +58,10 @@ export class LicenseState { return this.isLicensed('feat:saml'); } + isOidcLicensed() { + return this.isLicensed('feat:oidc'); + } + isApiKeyScopesLicensed() { return this.isLicensed('feat:apiKeyScopes'); } diff --git a/packages/@n8n/constants/src/index.ts b/packages/@n8n/constants/src/index.ts index fa469ebeff..d977cdea74 100644 --- a/packages/@n8n/constants/src/index.ts +++ b/packages/@n8n/constants/src/index.ts @@ -8,6 +8,7 @@ export const LICENSE_FEATURES = { SHARING: 'feat:sharing', LDAP: 'feat:ldap', SAML: 'feat:saml', + OIDC: 'feat:oidc', LOG_STREAMING: 'feat:logStreaming', ADVANCED_EXECUTION_FILTERS: 'feat:advancedExecutionFilters', VARIABLES: 'feat:variables', diff --git a/packages/@n8n/db/src/entities/types-db.ts b/packages/@n8n/db/src/entities/types-db.ts index 83a8b4400f..3e4552ef59 100644 --- a/packages/@n8n/db/src/entities/types-db.ts +++ b/packages/@n8n/db/src/entities/types-db.ts @@ -269,7 +269,7 @@ export const enum StatisticsNames { dataLoaded = 'data_loaded', } -export type AuthProviderType = 'ldap' | 'email' | 'saml'; // | 'google'; +export type AuthProviderType = 'ldap' | 'email' | 'saml' | 'oidc'; // | 'google'; export type FolderWithWorkflowAndSubFolderCount = Folder & { workflowCount?: boolean; diff --git a/packages/@n8n/permissions/src/constants.ee.ts b/packages/@n8n/permissions/src/constants.ee.ts index 3e984b26d3..b4ea62fe46 100644 --- a/packages/@n8n/permissions/src/constants.ee.ts +++ b/packages/@n8n/permissions/src/constants.ee.ts @@ -25,6 +25,7 @@ export const RESOURCES = { workflow: ['share', 'execute', 'move', ...DEFAULT_OPERATIONS] as const, folder: [...DEFAULT_OPERATIONS, 'move'] as const, insights: ['list'] as const, + oidc: ['manage'] as const, } as const; export const API_KEY_RESOURCES = { diff --git a/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts b/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts index e8713057f2..c3e25aae26 100644 --- a/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts +++ b/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts @@ -77,6 +77,7 @@ export const GLOBAL_OWNER_SCOPES: Scope[] = [ 'project:delete', 'insights:list', 'folder:move', + 'oidc:manage', ]; export const GLOBAL_ADMIN_SCOPES = GLOBAL_OWNER_SCOPES.concat(); diff --git a/packages/cli/package.json b/packages/cli/package.json index 19fcaf44e4..2e540e37d6 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -152,6 +152,7 @@ "nodemailer": "6.9.9", "oauth-1.0a": "2.2.6", "open": "7.4.2", + "openid-client": "6.5.0", "otpauth": "9.1.1", "p-cancelable": "2.1.1", "p-lazy": "3.1.0", diff --git a/packages/cli/src/config/schema.ts b/packages/cli/src/config/schema.ts index ae30e0655e..be8cec3b7a 100644 --- a/packages/cli/src/config/schema.ts +++ b/packages/cli/src/config/schema.ts @@ -204,6 +204,13 @@ export const schema = { default: '', }, }, + oidc: { + loginEnabled: { + format: Boolean, + default: false, + doc: 'Whether to enable OIDC SSO.', + }, + }, ldap: { loginEnabled: { format: Boolean, diff --git a/packages/cli/src/controllers/e2e.controller.ts b/packages/cli/src/controllers/e2e.controller.ts index 50d55923a4..5cbf71a409 100644 --- a/packages/cli/src/controllers/e2e.controller.ts +++ b/packages/cli/src/controllers/e2e.controller.ts @@ -105,6 +105,7 @@ export class E2EController { [LICENSE_FEATURES.INSIGHTS_VIEW_DASHBOARD]: false, [LICENSE_FEATURES.INSIGHTS_VIEW_HOURLY_DATA]: false, [LICENSE_FEATURES.API_KEY_SCOPES]: false, + [LICENSE_FEATURES.OIDC]: false, }; private static readonly numericFeaturesDefaults: Record = { diff --git a/packages/cli/src/controllers/me.controller.ts b/packages/cli/src/controllers/me.controller.ts index d5c0f2d94e..5e0513b900 100644 --- a/packages/cli/src/controllers/me.controller.ts +++ b/packages/cli/src/controllers/me.controller.ts @@ -22,6 +22,11 @@ import { AuthenticatedRequest, MeRequest } from '@/requests'; import { PasswordUtility } from '@/services/password.utility'; import { UserService } from '@/services/user.service'; import { isSamlLicensedAndEnabled } from '@/sso.ee/saml/saml-helpers'; +import { + getCurrentAuthenticationMethod, + isLdapCurrentAuthenticationMethod, + isOidcCurrentAuthenticationMethod, +} from '@/sso.ee/sso-helpers'; import { PersonalizationSurveyAnswersV4 } from './survey-answers.dto'; @RestController('/me') @@ -46,10 +51,34 @@ export class MeController { res: Response, @Body payload: UserUpdateRequestDto, ): Promise { - const { id: userId, email: currentEmail, mfaEnabled } = req.user; + const { + id: userId, + email: currentEmail, + mfaEnabled, + firstName: currentFirstName, + lastName: currentLastName, + } = req.user; - const { email } = payload; + const { email, firstName, lastName } = payload; const isEmailBeingChanged = email !== currentEmail; + const isFirstNameChanged = firstName !== currentFirstName; + const isLastNameChanged = lastName !== currentLastName; + + if ( + (isLdapCurrentAuthenticationMethod() || isOidcCurrentAuthenticationMethod()) && + (isEmailBeingChanged || isFirstNameChanged || isLastNameChanged) + ) { + this.logger.debug( + `Request to update user failed because ${getCurrentAuthenticationMethod()} user may not change their profile information`, + { + userId, + payload, + }, + ); + throw new BadRequestError( + ` ${getCurrentAuthenticationMethod()} user may not change their profile information`, + ); + } // If SAML is enabled, we don't allow the user to change their email address if (isSamlLicensedAndEnabled() && isEmailBeingChanged) { diff --git a/packages/cli/src/controllers/password-reset.controller.ts b/packages/cli/src/controllers/password-reset.controller.ts index c3894f1c94..c68c997158 100644 --- a/packages/cli/src/controllers/password-reset.controller.ts +++ b/packages/cli/src/controllers/password-reset.controller.ts @@ -23,7 +23,10 @@ import { MfaService } from '@/mfa/mfa.service'; import { AuthlessRequest } from '@/requests'; import { PasswordUtility } from '@/services/password.utility'; import { UserService } from '@/services/user.service'; -import { isSamlCurrentAuthenticationMethod } from '@/sso.ee/sso-helpers'; +import { + isOidcCurrentAuthenticationMethod, + isSamlCurrentAuthenticationMethod, +} from '@/sso.ee/sso-helpers'; import { UserManagementMailer } from '@/user-management/email'; @RestController() @@ -76,17 +79,15 @@ export class PasswordResetController { } if ( - isSamlCurrentAuthenticationMethod() && - !( - user && - (hasGlobalScope(user, 'user:resetPassword') || user.settings?.allowSSOManualLogin === true) - ) + (isSamlCurrentAuthenticationMethod() || isOidcCurrentAuthenticationMethod()) && + !(hasGlobalScope(user, 'user:resetPassword') || user.settings?.allowSSOManualLogin === true) ) { + const currentAuthenticationMethod = isSamlCurrentAuthenticationMethod() ? 'SAML' : 'OIDC'; this.logger.debug( - 'Request to send password reset email failed because login is handled by SAML', + `Request to send password reset email failed because login is handled by ${currentAuthenticationMethod}`, ); throw new ForbiddenError( - 'Login is handled by SAML. Please contact your Identity Provider to reset your password.', + `Login is handled by ${currentAuthenticationMethod}. Please contact your Identity Provider to reset your password.`, ); } diff --git a/packages/cli/src/ldap.ee/ldap.service.ee.ts b/packages/cli/src/ldap.ee/ldap.service.ee.ts index c32ef8a44d..38edfb1032 100644 --- a/packages/cli/src/ldap.ee/ldap.service.ee.ts +++ b/packages/cli/src/ldap.ee/ldap.service.ee.ts @@ -95,7 +95,7 @@ export class LdapService { throw new UnexpectedError(message); } - if (ldapConfig.loginEnabled && getCurrentAuthenticationMethod() === 'saml') { + if (ldapConfig.loginEnabled && ['saml', 'oidc'].includes(getCurrentAuthenticationMethod())) { throw new BadRequestError('LDAP cannot be enabled if SSO in enabled'); } @@ -146,19 +146,19 @@ export class LdapService { /** Set the LDAP login enabled to the configuration object */ private async setLdapLoginEnabled(enabled: boolean): Promise { - if (isEmailCurrentAuthenticationMethod() || isLdapCurrentAuthenticationMethod()) { - if (enabled) { - config.set(LDAP_LOGIN_ENABLED, true); - await setCurrentAuthenticationMethod('ldap'); - } else if (!enabled) { - config.set(LDAP_LOGIN_ENABLED, false); - await setCurrentAuthenticationMethod('email'); - } - } else { + const currentAuthenticationMethod = getCurrentAuthenticationMethod(); + if (enabled && !isEmailCurrentAuthenticationMethod() && !isLdapCurrentAuthenticationMethod()) { throw new InternalServerError( - `Cannot switch LDAP login enabled state when an authentication method other than email or ldap is active (current: ${getCurrentAuthenticationMethod()})`, + `Cannot switch LDAP login enabled state when an authentication method other than email or ldap is active (current: ${currentAuthenticationMethod})`, ); } + + config.set(LDAP_LOGIN_ENABLED, enabled); + + const targetAuthenticationMethod = + !enabled && currentAuthenticationMethod === 'ldap' ? 'email' : currentAuthenticationMethod; + + await setCurrentAuthenticationMethod(enabled ? 'ldap' : targetAuthenticationMethod); } /** diff --git a/packages/cli/src/server.ts b/packages/cli/src/server.ts index 26022a663c..e4ac66b602 100644 --- a/packages/cli/src/server.ts +++ b/packages/cli/src/server.ts @@ -149,6 +149,20 @@ export class Server extends AbstractServer { this.logger.warn(`SAML initialization failed: ${(error as Error).message}`); } + // ---------------------------------------- + // OIDC + // ---------------------------------------- + + try { + if (this.licenseState.isOidcLicensed()) { + const { OidcService } = await import('@/sso.ee/oidc/oidc.service.ee'); + await Container.get(OidcService).init(); + await import('@/sso.ee/oidc/routes/oidc.controller.ee'); + } + } catch (error) { + this.logger.warn(`OIDC initialization failed: ${(error as Error).message}`); + } + // ---------------------------------------- // Source Control // ---------------------------------------- diff --git a/packages/cli/src/services/frontend.service.ts b/packages/cli/src/services/frontend.service.ts index 6903715fe1..a6b8959dd0 100644 --- a/packages/cli/src/services/frontend.service.ts +++ b/packages/cli/src/services/frontend.service.ts @@ -154,6 +154,11 @@ export class FrontendService { loginEnabled: false, loginLabel: '', }, + oidc: { + loginEnabled: false, + loginUrl: `${instanceBaseUrl}/${restEndpoint}/sso/oidc/login`, + callbackUrl: `${instanceBaseUrl}/${restEndpoint}/sso/oidc/callback`, + }, }, publicApi: { enabled: isApiEnabled(), @@ -189,6 +194,7 @@ export class FrontendService { sharing: false, ldap: false, saml: false, + oidc: false, logStreaming: false, advancedExecutionFilters: false, variables: false, @@ -319,6 +325,7 @@ export class FrontendService { logStreaming: this.license.isLogStreamingEnabled(), ldap: this.license.isLdapEnabled(), saml: this.license.isSamlEnabled(), + oidc: this.licenseState.isOidcLicensed(), advancedExecutionFilters: this.license.isAdvancedExecutionFiltersEnabled(), variables: this.license.isVariablesEnabled(), sourceControl: this.license.isSourceControlLicensed(), @@ -347,6 +354,12 @@ export class FrontendService { }); } + if (this.licenseState.isOidcLicensed()) { + Object.assign(this.settings.sso.oidc, { + loginEnabled: config.getEnv('sso.oidc.loginEnabled'), + }); + } + if (this.license.isVariablesEnabled()) { this.settings.variables.limit = this.license.getVariablesLimit(); } diff --git a/packages/cli/src/services/user.service.ts b/packages/cli/src/services/user.service.ts index b23d12dfd8..be9b6e1705 100644 --- a/packages/cli/src/services/user.service.ts +++ b/packages/cli/src/services/user.service.ts @@ -65,11 +65,11 @@ export class UserService { ) { const { password, updatedAt, authIdentities, mfaRecoveryCodes, mfaSecret, ...rest } = user; - const ldapIdentity = authIdentities?.find((i) => i.providerType === 'ldap'); + const providerType = authIdentities?.[0]?.providerType; let publicUser: PublicUser = { ...rest, - signInType: ldapIdentity ? 'ldap' : 'email', + signInType: providerType ?? 'email', isOwner: user.role === 'global:owner', }; diff --git a/packages/cli/src/sso.ee/oidc/constants.ts b/packages/cli/src/sso.ee/oidc/constants.ts new file mode 100644 index 0000000000..44d07a2d24 --- /dev/null +++ b/packages/cli/src/sso.ee/oidc/constants.ts @@ -0,0 +1,4 @@ +export const OIDC_PREFERENCES_DB_KEY = 'features.oidc'; +export const OIDC_LOGIN_ENABLED = 'sso.oidc.loginEnabled'; +export const OIDC_CLIENT_SECRET_REDACTED_VALUE = + '__n8n_CLIENT_SECRET_VALUE_e5362baf-c777-4d57-a609-6eaf1f9e87f6'; diff --git a/packages/cli/src/sso.ee/oidc/oidc.service.ee.ts b/packages/cli/src/sso.ee/oidc/oidc.service.ee.ts new file mode 100644 index 0000000000..fa66d44f1b --- /dev/null +++ b/packages/cli/src/sso.ee/oidc/oidc.service.ee.ts @@ -0,0 +1,267 @@ +import type { OidcConfigDto } from '@n8n/api-types'; +import { Logger } from '@n8n/backend-common'; +import { GlobalConfig } from '@n8n/config'; +import { + AuthIdentity, + AuthIdentityRepository, + SettingsRepository, + type User, + UserRepository, +} from '@n8n/db'; +import { Service } from '@n8n/di'; +import { Cipher } from 'n8n-core'; +import { jsonParse } from 'n8n-workflow'; +import * as client from 'openid-client'; + +import config from '@/config'; +import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { InternalServerError } from '@/errors/response-errors/internal-server.error'; +import { UrlService } from '@/services/url.service'; + +import { + OIDC_CLIENT_SECRET_REDACTED_VALUE, + OIDC_LOGIN_ENABLED, + OIDC_PREFERENCES_DB_KEY, +} from './constants'; +import { + getCurrentAuthenticationMethod, + isEmailCurrentAuthenticationMethod, + isOidcCurrentAuthenticationMethod, + setCurrentAuthenticationMethod, +} from '../sso-helpers'; + +const DEFAULT_OIDC_CONFIG: OidcConfigDto = { + clientId: '', + clientSecret: '', + discoveryEndpoint: '', + loginEnabled: false, +}; + +type OidcRuntimeConfig = Pick & { + discoveryEndpoint: URL; +}; + +const DEFAULT_OIDC_RUNTIME_CONFIG: OidcRuntimeConfig = { + ...DEFAULT_OIDC_CONFIG, + discoveryEndpoint: new URL('http://n8n.io/not-set'), +}; + +@Service() +export class OidcService { + private oidcConfig: OidcRuntimeConfig = DEFAULT_OIDC_RUNTIME_CONFIG; + + constructor( + private readonly settingsRepository: SettingsRepository, + private readonly authIdentityRepository: AuthIdentityRepository, + private readonly urlService: UrlService, + private readonly globalConfig: GlobalConfig, + private readonly userRepository: UserRepository, + private readonly cipher: Cipher, + private readonly logger: Logger, + ) {} + + async init() { + this.oidcConfig = await this.loadConfig(true); + console.log(`OIDC login is ${this.oidcConfig.loginEnabled ? 'enabled' : 'disabled'}.`); + await this.setOidcLoginEnabled(this.oidcConfig.loginEnabled); + } + + getCallbackUrl(): string { + return `${this.urlService.getInstanceBaseUrl()}/${this.globalConfig.endpoints.rest}/sso/oidc/callback`; + } + + getRedactedConfig(): OidcConfigDto { + return { + ...this.oidcConfig, + discoveryEndpoint: this.oidcConfig.discoveryEndpoint.toString(), + clientSecret: OIDC_CLIENT_SECRET_REDACTED_VALUE, + }; + } + + async generateLoginUrl(): Promise { + const configuration = await this.getOidcConfiguration(); + + const authorizationURL = client.buildAuthorizationUrl(configuration, { + redirect_uri: this.getCallbackUrl(), + response_type: 'code', + scope: 'openid email profile', + prompt: 'select_account', + }); + + return authorizationURL; + } + + async loginUser(callbackUrl: URL): Promise { + const configuration = await this.getOidcConfiguration(); + + const tokens = await client.authorizationCodeGrant(configuration, callbackUrl); + + const claims = tokens.claims(); + + if (!claims) { + throw new ForbiddenError('No claims found in the OIDC token'); + } + + const userInfo = await client.fetchUserInfo(configuration, tokens.access_token, claims.sub); + + if (!userInfo.email) { + throw new BadRequestError('An email is required'); + } + + if (!userInfo.email_verified) { + throw new BadRequestError('Email needs to be verified'); + } + + const openidUser = await this.authIdentityRepository.findOne({ + where: { providerId: claims.sub, providerType: 'oidc' }, + relations: ['user'], + }); + + if (openidUser) { + return openidUser.user; + } + + const foundUser = await this.userRepository.findOneBy({ email: userInfo.email }); + + if (foundUser) { + throw new BadRequestError('User already exist with that email.'); + } + + return await this.userRepository.manager.transaction(async (trx) => { + const { user } = await this.userRepository.createUserWithProject( + { + firstName: userInfo.given_name, + lastName: userInfo.family_name, + email: userInfo.email, + authIdentities: [], + role: 'global:member', + password: 'no password set', + }, + trx, + ); + + await trx.save( + trx.create(AuthIdentity, { + providerId: claims.sub, + providerType: 'oidc', + userId: user.id, + }), + ); + + return user; + }); + } + + async loadConfig(decryptSecret = false): Promise { + const currentConfig = await this.settingsRepository.findOneBy({ + key: OIDC_PREFERENCES_DB_KEY, + }); + + if (currentConfig) { + try { + const oidcConfig = jsonParse(currentConfig.value); + const discoveryUrl = new URL(oidcConfig.discoveryEndpoint); + + if (oidcConfig.clientSecret && decryptSecret) { + oidcConfig.clientSecret = this.cipher.decrypt(oidcConfig.clientSecret); + } + return { + ...oidcConfig, + discoveryEndpoint: discoveryUrl, + }; + } catch (error) { + this.logger.warn( + 'Failed to load OIDC configuration from database, falling back to default configuration.', + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + { error }, + ); + } + } + + await this.settingsRepository.save({ + key: OIDC_PREFERENCES_DB_KEY, + value: JSON.stringify(DEFAULT_OIDC_CONFIG), + loadOnStartup: true, + }); + return DEFAULT_OIDC_RUNTIME_CONFIG; + } + + async updateConfig(newConfig: OidcConfigDto) { + let discoveryEndpoint: URL; + try { + // Validating that discoveryEndpoint is a valid URL + discoveryEndpoint = new URL(newConfig.discoveryEndpoint); + } catch (error) { + throw new BadRequestError('Provided discovery endpoint is not a valid URL'); + } + if (newConfig.clientSecret === OIDC_CLIENT_SECRET_REDACTED_VALUE) { + newConfig.clientSecret = this.oidcConfig.clientSecret; + } + await this.settingsRepository.update( + { + key: OIDC_PREFERENCES_DB_KEY, + }, + { + value: JSON.stringify({ + ...newConfig, + clientSecret: this.cipher.encrypt(newConfig.clientSecret), + }), + }, + ); + + // TODO: Discuss this in product + // if (this.oidcConfig.loginEnabled && !newConfig.loginEnabled) { + // await this.deleteAllOidcIdentities(); + // } + + this.oidcConfig = { + ...newConfig, + discoveryEndpoint, + }; + + await this.setOidcLoginEnabled(this.oidcConfig.loginEnabled); + } + + private async setOidcLoginEnabled(enabled: boolean): Promise { + const currentAuthenticationMethod = getCurrentAuthenticationMethod(); + + if (enabled && !isEmailCurrentAuthenticationMethod() && !isOidcCurrentAuthenticationMethod()) { + throw new InternalServerError( + `Cannot switch OIDC login enabled state when an authentication method other than email or OIDC is active (current: ${currentAuthenticationMethod})`, + ); + } + + const targetAuthenticationMethod = + !enabled && currentAuthenticationMethod === 'oidc' ? 'email' : currentAuthenticationMethod; + + config.set(OIDC_LOGIN_ENABLED, enabled); + await setCurrentAuthenticationMethod(enabled ? 'oidc' : targetAuthenticationMethod); + } + + private cachedOidcConfiguration: + | { + configuration: Promise; + validTill: Date; + } + | undefined; + + private async getOidcConfiguration(): Promise { + const now = Date.now(); + if ( + this.cachedOidcConfiguration === undefined || + now >= this.cachedOidcConfiguration.validTill.getTime() + ) { + this.cachedOidcConfiguration = { + configuration: client.discovery( + this.oidcConfig.discoveryEndpoint, + this.oidcConfig.clientId, + this.oidcConfig.clientSecret, + ), + validTill: new Date(Date.now() + 60 * 60 * 1000), // Cache for 1 hour + }; + } + + return await this.cachedOidcConfiguration.configuration; + } +} diff --git a/packages/cli/src/sso.ee/oidc/routes/oidc.controller.ee.ts b/packages/cli/src/sso.ee/oidc/routes/oidc.controller.ee.ts new file mode 100644 index 0000000000..55f4276f0c --- /dev/null +++ b/packages/cli/src/sso.ee/oidc/routes/oidc.controller.ee.ts @@ -0,0 +1,62 @@ +import { OidcConfigDto } from '@n8n/api-types'; +import { Body, Get, GlobalScope, Licensed, Post, RestController } from '@n8n/decorators'; +import { Request, Response } from 'express'; + +import { AuthService } from '@/auth/auth.service'; +import { AuthenticatedRequest } from '@/requests'; +import { UrlService } from '@/services/url.service'; + +import { OIDC_CLIENT_SECRET_REDACTED_VALUE } from '../constants'; +import { OidcService } from '../oidc.service.ee'; + +@RestController('/sso/oidc') +export class OidcController { + constructor( + private readonly oidcService: OidcService, + private readonly authService: AuthService, + private readonly urlService: UrlService, + ) {} + + @Get('/config') + @Licensed('feat:oidc') + @GlobalScope('oidc:manage') + async retrieveConfiguration(_req: AuthenticatedRequest) { + const config = await this.oidcService.loadConfig(); + if (config.clientSecret) { + config.clientSecret = OIDC_CLIENT_SECRET_REDACTED_VALUE; + } + return config; + } + + @Post('/config') + @Licensed('feat:oidc') + @GlobalScope('oidc:manage') + async saveConfiguration( + _req: AuthenticatedRequest, + _res: Response, + @Body payload: OidcConfigDto, + ) { + await this.oidcService.updateConfig(payload); + const config = this.oidcService.getRedactedConfig(); + return config; + } + + @Get('/login', { skipAuth: true }) + async redirectToAuthProvider(_req: Request, res: Response) { + const authorizationURL = await this.oidcService.generateLoginUrl(); + + res.redirect(authorizationURL.toString()); + } + + @Get('/callback', { skipAuth: true }) + async callbackHandler(req: Request, res: Response) { + const fullUrl = `${this.urlService.getInstanceBaseUrl()}${req.originalUrl}`; + const callbackUrl = new URL(fullUrl); + + const user = await this.oidcService.loginUser(callbackUrl); + + this.authService.issueCookie(res, user); + + res.redirect('/'); + } +} diff --git a/packages/cli/src/sso.ee/saml/saml-helpers.ts b/packages/cli/src/sso.ee/saml/saml-helpers.ts index 26ebd71e90..3e2330f01f 100644 --- a/packages/cli/src/sso.ee/saml/saml-helpers.ts +++ b/packages/cli/src/sso.ee/saml/saml-helpers.ts @@ -34,19 +34,18 @@ export function getSamlLoginLabel(): string { // can only toggle between email and saml, not directly to e.g. ldap export async function setSamlLoginEnabled(enabled: boolean): Promise { - if (isEmailCurrentAuthenticationMethod() || isSamlCurrentAuthenticationMethod()) { - if (enabled) { - config.set(SAML_LOGIN_ENABLED, true); - await setCurrentAuthenticationMethod('saml'); - } else if (!enabled) { - config.set(SAML_LOGIN_ENABLED, false); - await setCurrentAuthenticationMethod('email'); - } - } else { + const currentAuthenticationMethod = getCurrentAuthenticationMethod(); + if (enabled && !isEmailCurrentAuthenticationMethod() && !isSamlCurrentAuthenticationMethod()) { throw new InternalServerError( - `Cannot switch SAML login enabled state when an authentication method other than email or saml is active (current: ${getCurrentAuthenticationMethod()})`, + `Cannot switch SAML login enabled state when an authentication method other than email or saml is active (current: ${currentAuthenticationMethod})`, ); } + + const targetAuthenticationMethod = + !enabled && currentAuthenticationMethod === 'saml' ? 'email' : currentAuthenticationMethod; + + config.set(SAML_LOGIN_ENABLED, enabled); + await setCurrentAuthenticationMethod(enabled ? 'saml' : targetAuthenticationMethod); } export function setSamlLoginLabel(label: string): void { diff --git a/packages/cli/src/sso.ee/sso-helpers.ts b/packages/cli/src/sso.ee/sso-helpers.ts index 1e5ad54e96..e7fd6d8120 100644 --- a/packages/cli/src/sso.ee/sso-helpers.ts +++ b/packages/cli/src/sso.ee/sso-helpers.ts @@ -35,6 +35,10 @@ export function isLdapCurrentAuthenticationMethod(): boolean { return getCurrentAuthenticationMethod() === 'ldap'; } +export function isOidcCurrentAuthenticationMethod(): boolean { + return getCurrentAuthenticationMethod() === 'oidc'; +} + export function isEmailCurrentAuthenticationMethod(): boolean { return getCurrentAuthenticationMethod() === 'email'; } diff --git a/packages/cli/test/integration/oidc/oidc.service.ee.test.ts b/packages/cli/test/integration/oidc/oidc.service.ee.test.ts new file mode 100644 index 0000000000..6d70bc8336 --- /dev/null +++ b/packages/cli/test/integration/oidc/oidc.service.ee.test.ts @@ -0,0 +1,380 @@ +const discoveryMock = jest.fn(); +const authorizationCodeGrantMock = jest.fn(); +const fetchUserInfoMock = jest.fn(); + +jest.mock('openid-client', () => ({ + ...jest.requireActual('openid-client'), + discovery: discoveryMock, + authorizationCodeGrant: authorizationCodeGrantMock, + fetchUserInfo: fetchUserInfoMock, +})); + +import type { OidcConfigDto } from '@n8n/api-types'; +import { type User, UserRepository } from '@n8n/db'; +import { Container } from '@n8n/di'; +import type * as mocked_oidc_client from 'openid-client'; +const real_odic_client = jest.requireActual('openid-client'); + +import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { OIDC_CLIENT_SECRET_REDACTED_VALUE } from '@/sso.ee/oidc/constants'; +import { OidcService } from '@/sso.ee/oidc/oidc.service.ee'; +import { createUser } from '@test-integration/db/users'; + +import * as testDb from '../shared/test-db'; + +beforeAll(async () => { + await testDb.init(); +}); + +afterAll(async () => { + await testDb.terminate(); +}); + +describe('OIDC service', () => { + let oidcService: OidcService; + let userRepository: UserRepository; + let createdUser: User; + + beforeAll(async () => { + oidcService = Container.get(OidcService); + userRepository = Container.get(UserRepository); + await oidcService.init(); + + await createUser({ + email: 'user1@example.com', + }); + }); + + describe('loadConfig', () => { + it('should initialize with default config', () => { + expect(oidcService.getRedactedConfig()).toEqual({ + clientId: '', + clientSecret: OIDC_CLIENT_SECRET_REDACTED_VALUE, + discoveryEndpoint: 'http://n8n.io/not-set', + loginEnabled: false, + }); + }); + + it('should fallback to default configuration', async () => { + const config = await oidcService.loadConfig(); + expect(config).toEqual({ + clientId: '', + clientSecret: '', + discoveryEndpoint: new URL('http://n8n.io/not-set'), + loginEnabled: false, + }); + }); + + it('should load and update OIDC configuration', async () => { + const newConfig: OidcConfigDto = { + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + discoveryEndpoint: 'https://example.com/.well-known/openid-configuration', + loginEnabled: true, + }; + + await oidcService.updateConfig(newConfig); + const loadedConfig = await oidcService.loadConfig(); + + expect(loadedConfig.clientId).toEqual('test-client-id'); + // The secret should be encrypted and not match the original value + expect(loadedConfig.clientSecret).not.toEqual('test-client-secret'); + expect(loadedConfig.discoveryEndpoint.toString()).toEqual( + 'https://example.com/.well-known/openid-configuration', + ); + expect(loadedConfig.loginEnabled).toBe(true); + }); + + it('should load and decrypt OIDC configuration', async () => { + const newConfig: OidcConfigDto = { + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + discoveryEndpoint: 'https://example.com/.well-known/openid-configuration', + loginEnabled: true, + }; + + await oidcService.updateConfig(newConfig); + const loadedConfig = await oidcService.loadConfig(true); + + expect(loadedConfig.clientId).toEqual('test-client-id'); + // The secret should be encrypted and not match the original value + expect(loadedConfig.clientSecret).toEqual('test-client-secret'); + expect(loadedConfig.discoveryEndpoint.toString()).toEqual( + 'https://example.com/.well-known/openid-configuration', + ); + expect(loadedConfig.loginEnabled).toBe(true); + }); + + it('should throw an error if the discovery endpoint is invalid', async () => { + const newConfig: OidcConfigDto = { + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + discoveryEndpoint: 'Not an url', + loginEnabled: true, + }; + + await expect(oidcService.updateConfig(newConfig)).rejects.toThrowError(BadRequestError); + }); + + it('should keep current secret if redact value is given in update', async () => { + const newConfig: OidcConfigDto = { + clientId: 'test-client-id', + clientSecret: OIDC_CLIENT_SECRET_REDACTED_VALUE, + discoveryEndpoint: 'https://example.com/.well-known/openid-configuration', + loginEnabled: true, + }; + + await oidcService.updateConfig(newConfig); + + const loadedConfig = await oidcService.loadConfig(true); + + expect(loadedConfig.clientId).toEqual('test-client-id'); + // The secret should be encrypted and not match the original value + expect(loadedConfig.clientSecret).toEqual('test-client-secret'); + expect(loadedConfig.discoveryEndpoint.toString()).toEqual( + 'https://example.com/.well-known/openid-configuration', + ); + expect(loadedConfig.loginEnabled).toBe(true); + }); + }); + it('should generate a valid callback URL', () => { + const callbackUrl = oidcService.getCallbackUrl(); + expect(callbackUrl).toContain('/sso/oidc/callback'); + }); + + it('should generate a valid authentication 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); + + const authUrl = await oidcService.generateLoginUrl(); + + expect(authUrl.pathname).toEqual('/auth'); + expect(authUrl.searchParams.get('client_id')).toEqual('test-client-id'); + expect(authUrl.searchParams.get('redirect_uri')).toEqual( + 'http://localhost:5678/rest/sso/oidc/callback', + ); + expect(authUrl.searchParams.get('response_type')).toEqual('code'); + expect(authUrl.searchParams.get('scope')).toEqual('openid email profile'); + }); + + describe('loginUser', () => { + it('should handle new user login with valid callback URL', async () => { + const callbackUrl = new URL( + 'http://localhost:5678/rest/sso/oidc/callback?code=valid-code&state=valid-state', + ); + + const mockTokens: mocked_oidc_client.TokenEndpointResponse & + mocked_oidc_client.TokenEndpointResponseHelpers = { + access_token: 'mock-access-token', + id_token: 'mock-id-token', + token_type: 'bearer', + claims: () => { + return { + sub: 'mock-subject', + iss: 'https://example.com/auth/realms/n8n', + aud: 'test-client-id', + iat: Math.floor(Date.now() / 1000) - 1000, + exp: Math.floor(Date.now() / 1000) + 3600, + } as mocked_oidc_client.IDToken; + }, + expiresIn: () => 3600, + } as mocked_oidc_client.TokenEndpointResponse & + mocked_oidc_client.TokenEndpointResponseHelpers; + + authorizationCodeGrantMock.mockResolvedValueOnce(mockTokens); + + fetchUserInfoMock.mockResolvedValueOnce({ + email_verified: true, + email: 'user2@example.com', + }); + + const user = await oidcService.loginUser(callbackUrl); + expect(user).toBeDefined(); + expect(user.email).toEqual('user2@example.com'); + + createdUser = user; + + const userFromDB = await userRepository.findOne({ + where: { email: 'user2@example.com' }, + }); + + expect(userFromDB).toBeDefined(); + expect(userFromDB!.id).toEqual(user.id); + }); + + it('should handle existing user login with valid callback URL', async () => { + const callbackUrl = new URL( + 'http://localhost:5678/rest/sso/oidc/callback?code=valid-code&state=valid-state', + ); + + const mockTokens: mocked_oidc_client.TokenEndpointResponse & + mocked_oidc_client.TokenEndpointResponseHelpers = { + access_token: 'mock-access-token-1', + id_token: 'mock-id-token-1', + token_type: 'bearer', + claims: () => { + return { + sub: 'mock-subject', + iss: 'https://example.com/auth/realms/n8n', + aud: 'test-client-id', + iat: Math.floor(Date.now() / 1000) - 1000, + exp: Math.floor(Date.now() / 1000) + 3600, + } as mocked_oidc_client.IDToken; + }, + expiresIn: () => 3600, + } as mocked_oidc_client.TokenEndpointResponse & + mocked_oidc_client.TokenEndpointResponseHelpers; + + authorizationCodeGrantMock.mockResolvedValueOnce(mockTokens); + + fetchUserInfoMock.mockResolvedValueOnce({ + email_verified: true, + email: 'user2@example.com', + }); + + const user = await oidcService.loginUser(callbackUrl); + expect(user).toBeDefined(); + expect(user.email).toEqual('user2@example.com'); + expect(user.id).toEqual(createdUser.id); + }); + + it('should throw `BadRequestError` if user already exists out of OIDC system', async () => { + const callbackUrl = new URL( + 'http://localhost:5678/rest/sso/oidc/callback?code=valid-code&state=valid-state', + ); + + const mockTokens: mocked_oidc_client.TokenEndpointResponse & + mocked_oidc_client.TokenEndpointResponseHelpers = { + access_token: 'mock-access-token-2', + id_token: 'mock-id-token-2', + token_type: 'bearer', + claims: () => { + return { + sub: 'mock-subject-1', + iss: 'https://example.com/auth/realms/n8n', + aud: 'test-client-id', + iat: Math.floor(Date.now() / 1000) - 1000, + exp: Math.floor(Date.now() / 1000) + 3600, + } as mocked_oidc_client.IDToken; + }, + expiresIn: () => 3600, + } as mocked_oidc_client.TokenEndpointResponse & + mocked_oidc_client.TokenEndpointResponseHelpers; + + authorizationCodeGrantMock.mockResolvedValueOnce(mockTokens); + + // Simulate that the user already exists in the database + fetchUserInfoMock.mockResolvedValueOnce({ + email_verified: true, + email: 'user1@example.com', + }); + + await expect(oidcService.loginUser(callbackUrl)).rejects.toThrowError(BadRequestError); + }); + + it('should throw `BadRequestError` if OIDC Idp does not have email verified', async () => { + const callbackUrl = new URL( + 'http://localhost:5678/rest/sso/oidc/callback?code=valid-code&state=valid-state', + ); + + const mockTokens: mocked_oidc_client.TokenEndpointResponse & + mocked_oidc_client.TokenEndpointResponseHelpers = { + access_token: 'mock-access-token-2', + id_token: 'mock-id-token-2', + token_type: 'bearer', + claims: () => { + return { + sub: 'mock-subject-3', + iss: 'https://example.com/auth/realms/n8n', + aud: 'test-client-id', + iat: Math.floor(Date.now() / 1000) - 1000, + exp: Math.floor(Date.now() / 1000) + 3600, + } as mocked_oidc_client.IDToken; + }, + expiresIn: () => 3600, + } as mocked_oidc_client.TokenEndpointResponse & + mocked_oidc_client.TokenEndpointResponseHelpers; + + authorizationCodeGrantMock.mockResolvedValueOnce(mockTokens); + + // Simulate that the user already exists in the database + fetchUserInfoMock.mockResolvedValueOnce({ + email_verified: false, + email: 'user3@example.com', + }); + + await expect(oidcService.loginUser(callbackUrl)).rejects.toThrowError(BadRequestError); + }); + + it('should throw `BadRequestError` if OIDC Idp does not provide an email', async () => { + const callbackUrl = new URL( + 'http://localhost:5678/rest/sso/oidc/callback?code=valid-code&state=valid-state', + ); + + const mockTokens: mocked_oidc_client.TokenEndpointResponse & + mocked_oidc_client.TokenEndpointResponseHelpers = { + access_token: 'mock-access-token-2', + id_token: 'mock-id-token-2', + token_type: 'bearer', + claims: () => { + return { + sub: 'mock-subject-3', + iss: 'https://example.com/auth/realms/n8n', + aud: 'test-client-id', + iat: Math.floor(Date.now() / 1000) - 1000, + exp: Math.floor(Date.now() / 1000) + 3600, + } as mocked_oidc_client.IDToken; + }, + expiresIn: () => 3600, + } as mocked_oidc_client.TokenEndpointResponse & + mocked_oidc_client.TokenEndpointResponseHelpers; + + authorizationCodeGrantMock.mockResolvedValueOnce(mockTokens); + + // Simulate that the user already exists in the database + fetchUserInfoMock.mockResolvedValueOnce({ + email_verified: true, + }); + + await expect(oidcService.loginUser(callbackUrl)).rejects.toThrowError(BadRequestError); + }); + + it('should throw `ForbiddenError` if OIDC token does not provide claims', async () => { + const callbackUrl = new URL( + 'http://localhost:5678/rest/sso/oidc/callback?code=valid-code&state=valid-state', + ); + + const mockTokens: mocked_oidc_client.TokenEndpointResponse & + mocked_oidc_client.TokenEndpointResponseHelpers = { + access_token: 'mock-access-token-2', + id_token: 'mock-id-token-2', + token_type: 'bearer', + claims: () => { + return undefined; // Simulating no claims + }, + expiresIn: () => 3600, + } as mocked_oidc_client.TokenEndpointResponse & + mocked_oidc_client.TokenEndpointResponseHelpers; + + authorizationCodeGrantMock.mockResolvedValueOnce(mockTokens); + + // Simulate that the user already exists in the database + fetchUserInfoMock.mockResolvedValueOnce({ + email_verified: true, + }); + + await expect(oidcService.loginUser(callbackUrl)).rejects.toThrowError(ForbiddenError); + }); + }); +}); diff --git a/packages/frontend/@n8n/design-system/src/components/N8nUsersList/UsersList.vue b/packages/frontend/@n8n/design-system/src/components/N8nUsersList/UsersList.vue index a4d61d2db3..d4bbdfe91e 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nUsersList/UsersList.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nUsersList/UsersList.vue @@ -101,7 +101,7 @@ const onUserAction = (user: IUser, action: string) => => { return await makeRestApiRequest(context, 'GET', '/sso/saml/config/test'); }; + +export const getOidcConfig = async (context: IRestApiContext): Promise => { + return await makeRestApiRequest(context, 'GET', '/sso/oidc/config'); +}; + +export const saveOidcConfig = async ( + context: IRestApiContext, + data: OidcConfigDto, +): Promise => { + return await makeRestApiRequest(context, 'POST', '/sso/oidc/config', data); +}; + +export const initOidcLogin = async (context: IRestApiContext): Promise => { + return await makeRestApiRequest(context, 'GET', '/sso/oidc/login'); +}; diff --git a/packages/frontend/editor-ui/src/Interface.ts b/packages/frontend/editor-ui/src/Interface.ts index b2b7c063df..32f7703672 100644 --- a/packages/frontend/editor-ui/src/Interface.ts +++ b/packages/frontend/editor-ui/src/Interface.ts @@ -608,6 +608,7 @@ export const enum UserManagementAuthenticationMethod { Email = 'email', Ldap = 'ldap', Saml = 'saml', + Oidc = 'oidc', } export interface IPermissionGroup { @@ -1423,6 +1424,7 @@ export type EnterpriseEditionFeatureKey = | 'LogStreaming' | 'Variables' | 'Saml' + | 'Oidc' | 'SourceControl' | 'ExternalSecrets' | 'AuditLogs' diff --git a/packages/frontend/editor-ui/src/__tests__/defaults.ts b/packages/frontend/editor-ui/src/__tests__/defaults.ts index 65749920fb..a1cc98eec9 100644 --- a/packages/frontend/editor-ui/src/__tests__/defaults.ts +++ b/packages/frontend/editor-ui/src/__tests__/defaults.ts @@ -24,6 +24,7 @@ export const defaultSettings: FrontendSettings = { enterprise: { sharing: false, ldap: false, + oidc: false, saml: false, logStreaming: false, debugInEditor: false, @@ -78,6 +79,7 @@ export const defaultSettings: FrontendSettings = { sso: { ldap: { loginEnabled: false, loginLabel: '' }, saml: { loginEnabled: false, loginLabel: '' }, + oidc: { loginEnabled: false, loginUrl: '', callbackUrl: '' }, }, telemetry: { enabled: false, diff --git a/packages/frontend/editor-ui/src/__tests__/mocks.ts b/packages/frontend/editor-ui/src/__tests__/mocks.ts index 155352b3a5..15566f5ba4 100644 --- a/packages/frontend/editor-ui/src/__tests__/mocks.ts +++ b/packages/frontend/editor-ui/src/__tests__/mocks.ts @@ -220,6 +220,7 @@ export function createMockEnterpriseSettings( sharing: false, ldap: false, saml: false, + oidc: false, logStreaming: false, advancedExecutionFilters: false, variables: false, diff --git a/packages/frontend/editor-ui/src/components/SSOLogin.vue b/packages/frontend/editor-ui/src/components/SSOLogin.vue index 47fdde98b1..abe8361f08 100644 --- a/packages/frontend/editor-ui/src/components/SSOLogin.vue +++ b/packages/frontend/editor-ui/src/components/SSOLogin.vue @@ -2,14 +2,19 @@ import { useSSOStore } from '@/stores/sso.store'; import { useI18n } from '@n8n/i18n'; import { useToast } from '@/composables/useToast'; +import { useSettingsStore } from '@/stores/settings.store'; const i18n = useI18n(); const ssoStore = useSSOStore(); const toast = useToast(); +const settingsStore = useSettingsStore(); const onSSOLogin = async () => { try { - window.location.href = await ssoStore.getSSORedirectUrl(); + const redirectUrl = ssoStore.isDefaultAuthenticationSaml + ? await ssoStore.getSSORedirectUrl() + : settingsStore.settings.sso.oidc.loginUrl; + window.location.href = redirectUrl; } catch (error) { toast.showError(error, 'Error', error.message); } diff --git a/packages/frontend/editor-ui/src/constants.ts b/packages/frontend/editor-ui/src/constants.ts index effa8c7346..7ac655d39d 100644 --- a/packages/frontend/editor-ui/src/constants.ts +++ b/packages/frontend/editor-ui/src/constants.ts @@ -644,6 +644,7 @@ export const EnterpriseEditionFeature: Record< LogStreaming: 'logStreaming', Variables: 'variables', Saml: 'saml', + Oidc: 'oidc', SourceControl: 'sourceControl', ExternalSecrets: 'externalSecrets', AuditLogs: 'auditLogs', @@ -698,6 +699,7 @@ export const CURL_IMPORT_NODES_PROTOCOLS: { [key: string]: string } = { export const enum SignInType { LDAP = 'ldap', EMAIL = 'email', + OIDC = 'oidc', } export const N8N_SALES_EMAIL = 'sales@n8n.io'; diff --git a/packages/frontend/editor-ui/src/permissions.test.ts b/packages/frontend/editor-ui/src/permissions.test.ts index 3b7e622a11..82f00503a1 100644 --- a/packages/frontend/editor-ui/src/permissions.test.ts +++ b/packages/frontend/editor-ui/src/permissions.test.ts @@ -17,6 +17,7 @@ describe('permissions', () => { ldap: {}, license: {}, logStreaming: {}, + oidc: {}, orchestration: {}, project: {}, saml: {}, @@ -93,6 +94,7 @@ describe('permissions', () => { read: true, }, saml: {}, + oidc: {}, securityAudit: {}, sourceControl: {}, tag: { diff --git a/packages/frontend/editor-ui/src/stores/rbac.store.ts b/packages/frontend/editor-ui/src/stores/rbac.store.ts index 6b3f74a85a..7c3a7bf89f 100644 --- a/packages/frontend/editor-ui/src/stores/rbac.store.ts +++ b/packages/frontend/editor-ui/src/stores/rbac.store.ts @@ -33,6 +33,7 @@ export const useRBACStore = defineStore(STORES.RBAC, () => { license: {}, logStreaming: {}, saml: {}, + oidc: {}, securityAudit: {}, folder: {}, insights: {}, diff --git a/packages/frontend/editor-ui/src/stores/settings.store.ts b/packages/frontend/editor-ui/src/stores/settings.store.ts index 77e8214a10..9bbe8791b5 100644 --- a/packages/frontend/editor-ui/src/stores/settings.store.ts +++ b/packages/frontend/editor-ui/src/stores/settings.store.ts @@ -46,6 +46,7 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => { }); const ldap = ref({ loginLabel: '', loginEnabled: false }); const saml = ref({ loginLabel: '', loginEnabled: false }); + const oidc = ref({ loginEnabled: false, loginUrl: '', callbackUrl: '' }); const mfa = ref({ enabled: false }); const folders = ref({ enabled: false }); @@ -95,6 +96,10 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => { const isSamlLoginEnabled = computed(() => saml.value.loginEnabled); + const isOidcLoginEnabled = computed(() => oidc.value.loginEnabled); + + const oidcCallBackUrl = computed(() => oidc.value.callbackUrl); + const isAiAssistantEnabled = computed(() => settings.value.aiAssistant?.enabled); const isAskAiEnabled = computed(() => settings.value.askAi?.enabled); @@ -181,6 +186,10 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => { () => userManagement.value.authenticationMethod === UserManagementAuthenticationMethod.Saml, ); + const isDefaultAuthenticationOidc = computed( + () => userManagement.value.authenticationMethod === UserManagementAuthenticationMethod.Oidc, + ); + const permanentlyDismissedBanners = computed(() => settings.value.banners?.dismissed ?? []); const isBelowUserQuota = computed( @@ -210,6 +219,12 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => { saml.value.loginLabel = settings.value.sso.saml.loginLabel; } + if (settings.value.sso?.oidc) { + oidc.value.loginEnabled = settings.value.sso.oidc.loginEnabled; + oidc.value.loginUrl = settings.value.sso.oidc.loginUrl || ''; + oidc.value.callbackUrl = settings.value.sso.oidc.callbackUrl || ''; + } + mfa.value.enabled = settings.value.mfa?.enabled; folders.value.enabled = settings.value.folders?.enabled; @@ -420,6 +435,7 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => { isLdapLoginEnabled, ldapLoginLabel, isSamlLoginEnabled, + isOidcLoginEnabled, showSetupPage, deploymentType, isCloudDeployment, @@ -444,6 +460,7 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => { isMultiMain, isWorkerViewAvailable, isDefaultAuthenticationSaml, + isDefaultAuthenticationOidc, workflowCallerPolicyDefaultOption, permanentlyDismissedBanners, isBelowUserQuota, @@ -456,6 +473,8 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => { isAiCreditsEnabled, aiCreditsQuota, experimental__minZoomNodeSettingsInCanvas, + partialExecutionVersion, + oidcCallBackUrl, reset, testLdapConnection, getLdapConfig, @@ -470,6 +489,5 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => { getSettings, setSettings, initialize, - partialExecutionVersion, }; }); diff --git a/packages/frontend/editor-ui/src/stores/sso.store.ts b/packages/frontend/editor-ui/src/stores/sso.store.ts index 635bb3f205..62f138021e 100644 --- a/packages/frontend/editor-ui/src/stores/sso.store.ts +++ b/packages/frontend/editor-ui/src/stores/sso.store.ts @@ -1,4 +1,5 @@ -import type { SamlPreferences } from '@n8n/api-types'; +import type { OidcConfigDto } from '@n8n/api-types'; +import { type SamlPreferences } from '@n8n/api-types'; import { computed, reactive } from 'vue'; import { useRoute } from 'vue-router'; import { defineStore } from 'pinia'; @@ -17,17 +18,13 @@ export const useSSOStore = defineStore('sso', () => { const route = useRoute(); const state = reactive({ - loading: false, samlConfig: undefined as (SamlPreferences & SamlPreferencesExtractedData) | undefined, + oidcConfig: undefined as OidcConfigDto | undefined, }); - const isLoading = computed(() => state.loading); - const samlConfig = computed(() => state.samlConfig); - const setLoading = (loading: boolean) => { - state.loading = loading; - }; + const oidcConfig = computed(() => state.oidcConfig); const isSamlLoginEnabled = computed({ get: () => settingsStore.isSamlLoginEnabled, @@ -45,15 +42,43 @@ export const useSSOStore = defineStore('sso', () => { void toggleLoginEnabled(value); }, }); + + const isOidcLoginEnabled = computed({ + get: () => settingsStore.isOidcLoginEnabled, + set: (value: boolean) => { + settingsStore.setSettings({ + ...settingsStore.settings, + sso: { + ...settingsStore.settings.sso, + oidc: { + ...settingsStore.settings.sso.oidc, + loginEnabled: value, + }, + }, + }); + }, + }); + const isEnterpriseSamlEnabled = computed( () => settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.Saml], ); + + const isEnterpriseOidcEnabled = computed( + () => settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.Oidc], + ); + const isDefaultAuthenticationSaml = computed(() => settingsStore.isDefaultAuthenticationSaml); + + const isDefaultAuthenticationOidc = computed(() => settingsStore.isDefaultAuthenticationOidc); + const showSsoLoginButton = computed( () => - isSamlLoginEnabled.value && - isEnterpriseSamlEnabled.value && - isDefaultAuthenticationSaml.value, + (isSamlLoginEnabled.value && + isEnterpriseSamlEnabled.value && + isDefaultAuthenticationSaml.value) || + (isOidcLoginEnabled.value && + isEnterpriseOidcEnabled.value && + isDefaultAuthenticationOidc.value), ); const getSSORedirectUrl = async () => @@ -66,6 +91,9 @@ export const useSSOStore = defineStore('sso', () => { await ssoApi.toggleSamlConfig(rootStore.restApiContext, { loginEnabled: enabled }); const getSamlMetadata = async () => await ssoApi.getSamlMetadata(rootStore.restApiContext); + + // const getOidcRedirectLUrl = async () => await ssoApi) + const getSamlConfig = async () => { const samlConfig = await ssoApi.getSamlConfig(rootStore.restApiContext); state.samlConfig = samlConfig; @@ -83,20 +111,36 @@ export const useSSOStore = defineStore('sso', () => { const userData = computed(() => usersStore.currentUser); + const getOidcConfig = async () => { + const oidcConfig = await ssoApi.getOidcConfig(rootStore.restApiContext); + state.oidcConfig = oidcConfig; + return oidcConfig; + }; + + const saveOidcConfig = async (config: OidcConfigDto) => { + const savedConfig = await ssoApi.saveOidcConfig(rootStore.restApiContext, config); + state.oidcConfig = savedConfig; + return savedConfig; + }; + return { - isLoading, - setLoading, + isEnterpriseOidcEnabled, isSamlLoginEnabled, + isOidcLoginEnabled, isEnterpriseSamlEnabled, isDefaultAuthenticationSaml, + isDefaultAuthenticationOidc, showSsoLoginButton, samlConfig, + oidcConfig, + userData, getSSORedirectUrl, getSamlMetadata, getSamlConfig, saveSamlConfig, testSamlConfig, updateUser, - userData, + getOidcConfig, + saveOidcConfig, }; }); diff --git a/packages/frontend/editor-ui/src/views/SettingsPersonalView.vue b/packages/frontend/editor-ui/src/views/SettingsPersonalView.vue index 4fe8f14dfc..1bd4f43368 100644 --- a/packages/frontend/editor-ui/src/views/SettingsPersonalView.vue +++ b/packages/frontend/editor-ui/src/views/SettingsPersonalView.vue @@ -72,7 +72,9 @@ const isExternalAuthEnabled = computed((): boolean => { settingsStore.settings.enterprise.ldap && currentUser.value?.signInType === 'ldap'; const isSamlEnabled = settingsStore.isSamlLoginEnabled && settingsStore.isDefaultAuthenticationSaml; - return isLdapEnabled || isSamlEnabled; + const isOidcEnabled = + settingsStore.settings.enterprise.oidc && currentUser.value?.signInType === 'oidc'; + return isLdapEnabled || isSamlEnabled || isOidcEnabled; }); const isPersonalSecurityEnabled = computed((): boolean => { return usersStore.isInstanceOwner || !isExternalAuthEnabled.value; diff --git a/packages/frontend/editor-ui/src/views/SettingsSso.test.ts b/packages/frontend/editor-ui/src/views/SettingsSso.test.ts index 750aced232..1693fdfc00 100644 --- a/packages/frontend/editor-ui/src/views/SettingsSso.test.ts +++ b/packages/frontend/editor-ui/src/views/SettingsSso.test.ts @@ -65,6 +65,7 @@ describe('SettingsSso View', () => { const pinia = createTestingPinia(); const ssoStore = mockedStore(useSSOStore); ssoStore.isEnterpriseSamlEnabled = false; + ssoStore.isEnterpriseOidcEnabled = false; const pageRedirectionHelper = usePageRedirectionHelper(); @@ -82,6 +83,7 @@ describe('SettingsSso View', () => { const ssoStore = mockedStore(useSSOStore); ssoStore.isEnterpriseSamlEnabled = true; + ssoStore.isEnterpriseOidcEnabled = true; ssoStore.getSamlConfig.mockResolvedValue(samlConfig); @@ -102,6 +104,7 @@ describe('SettingsSso View', () => { const ssoStore = mockedStore(useSSOStore); ssoStore.isEnterpriseSamlEnabled = true; ssoStore.isSamlLoginEnabled = false; + ssoStore.isEnterpriseOidcEnabled = true; ssoStore.getSamlConfig.mockResolvedValue(samlConfig); @@ -126,6 +129,7 @@ describe('SettingsSso View', () => { const ssoStore = mockedStore(useSSOStore); ssoStore.isEnterpriseSamlEnabled = true; + ssoStore.isEnterpriseOidcEnabled = true; const { getByTestId } = renderView({ pinia }); @@ -163,6 +167,7 @@ describe('SettingsSso View', () => { const ssoStore = mockedStore(useSSOStore); ssoStore.isEnterpriseSamlEnabled = true; + ssoStore.isEnterpriseOidcEnabled = true; const { getByTestId } = renderView({ pinia }); @@ -199,6 +204,7 @@ describe('SettingsSso View', () => { const ssoStore = mockedStore(useSSOStore); ssoStore.isEnterpriseSamlEnabled = true; + ssoStore.isEnterpriseOidcEnabled = true; const { getByTestId } = renderView({ pinia }); @@ -228,6 +234,7 @@ describe('SettingsSso View', () => { const ssoStore = mockedStore(useSSOStore); ssoStore.isEnterpriseSamlEnabled = true; + ssoStore.isEnterpriseOidcEnabled = true; const { getByTestId } = renderView({ pinia }); @@ -258,6 +265,8 @@ describe('SettingsSso View', () => { const ssoStore = mockedStore(useSSOStore); ssoStore.isEnterpriseSamlEnabled = true; ssoStore.isSamlLoginEnabled = true; + ssoStore.isEnterpriseOidcEnabled = true; + ssoStore.isOidcLoginEnabled = false; const error = new Error('Request failed with status code 404'); ssoStore.getSamlConfig.mockRejectedValue(error); diff --git a/packages/frontend/editor-ui/src/views/SettingsSso.vue b/packages/frontend/editor-ui/src/views/SettingsSso.vue index bcb75a8e90..35c00628ab 100644 --- a/packages/frontend/editor-ui/src/views/SettingsSso.vue +++ b/packages/frontend/editor-ui/src/views/SettingsSso.vue @@ -9,17 +9,27 @@ import { useTelemetry } from '@/composables/useTelemetry'; import { useDocumentTitle } from '@/composables/useDocumentTitle'; import { useRootStore } from '@n8n/stores/useRootStore'; import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper'; +import { MODAL_CONFIRM } from '@/constants'; +import { useSettingsStore } from '@/stores/settings.store'; + +type SupportedProtocolType = (typeof SupportedProtocols)[keyof typeof SupportedProtocols]; const IdentityProviderSettingsType = { URL: 'url', XML: 'xml', }; +const SupportedProtocols = { + SAML: 'saml', + OIDC: 'oidc', +} as const; + const i18n = useI18n(); const telemetry = useTelemetry(); const rootStore = useRootStore(); const ssoStore = useSSOStore(); const message = useMessage(); +const settingsStore = useSettingsStore(); const toast = useToast(); const documentTitle = useDocumentTitle(); const pageRedirectionHelper = usePageRedirectionHelper(); @@ -29,11 +39,24 @@ const ssoActivatedLabel = computed(() => ? i18n.baseText('settings.sso.activated') : i18n.baseText('settings.sso.deactivated'), ); + +const oidcActivatedLabel = computed(() => + ssoStore.isOidcLoginEnabled + ? i18n.baseText('settings.sso.activated') + : i18n.baseText('settings.sso.deactivated'), +); + const ssoSettingsSaved = ref(false); -const redirectUrl = ref(); const entityId = ref(); +const clientId = ref(''); +const clientSecret = ref(''); + +const discoveryEndpoint = ref(''); + +const authProtocol = ref(SupportedProtocols.SAML); + const ipsOptions = ref([ { label: i18n.baseText('settings.sso.settings.ips.options.url'), @@ -49,6 +72,8 @@ const ipsType = ref(IdentityProviderSettingsType.URL); const metadataUrl = ref(); const metadata = ref(); +const redirectUrl = ref(); + const isSaveEnabled = computed(() => { if (ipsType.value === IdentityProviderSettingsType.URL) { return !!metadataUrl.value && metadataUrl.value !== ssoStore.samlConfig?.metadataUrl; @@ -67,6 +92,17 @@ const isTestEnabled = computed(() => { return false; }); +async function loadSamlConfig() { + if (!ssoStore.isEnterpriseSamlEnabled) { + return; + } + try { + await getSamlConfig(); + } catch (error) { + toast.showError(error, 'error'); + } +} + const getSamlConfig = async () => { const config = await ssoStore.getSamlConfig(); @@ -167,39 +203,79 @@ const isToggleSsoDisabled = computed(() => { onMounted(async () => { documentTitle.set(i18n.baseText('settings.sso.title')); - if (!ssoStore.isEnterpriseSamlEnabled) { + await Promise.all([loadSamlConfig(), loadOidcConfig()]); + + if (ssoStore.isDefaultAuthenticationSaml) { + authProtocol.value = SupportedProtocols.SAML; + } else if (ssoStore.isDefaultAuthenticationOidc) { + authProtocol.value = SupportedProtocols.OIDC; + } +}); + +const getOidcConfig = async () => { + const config = await ssoStore.getOidcConfig(); + + clientId.value = config.clientId; + clientSecret.value = config.clientSecret; + discoveryEndpoint.value = config.discoveryEndpoint; +}; + +async function loadOidcConfig() { + if (!ssoStore.isEnterpriseOidcEnabled) { return; } try { - await getSamlConfig(); + await getOidcConfig(); } catch (error) { toast.showError(error, 'error'); } +} + +function onAuthProtocolUpdated(value: SupportedProtocolType) { + authProtocol.value = value; +} + +const cannotSaveOidcSettings = computed(() => { + return ( + ssoStore.oidcConfig?.clientId === clientId.value && + ssoStore.oidcConfig?.clientSecret === clientSecret.value && + ssoStore.oidcConfig?.discoveryEndpoint === discoveryEndpoint.value && + ssoStore.oidcConfig?.loginEnabled === ssoStore.isOidcLoginEnabled + ); }); + +async function onOidcSettingsSave() { + if (ssoStore.oidcConfig?.loginEnabled && !ssoStore.isOidcLoginEnabled) { + const confirmAction = await message.confirm( + i18n.baseText('settings.oidc.confirmMessage.beforeSaveForm.message'), + i18n.baseText('settings.oidc.confirmMessage.beforeSaveForm.headline'), + { + cancelButtonText: i18n.baseText( + 'settings.ldap.confirmMessage.beforeSaveForm.cancelButtonText', + ), + confirmButtonText: i18n.baseText( + 'settings.ldap.confirmMessage.beforeSaveForm.confirmButtonText', + ), + }, + ); + if (confirmAction !== MODAL_CONFIRM) return; + } + + const newConfig = await ssoStore.saveOidcConfig({ + clientId: clientId.value, + clientSecret: clientSecret.value, + discoveryEndpoint: discoveryEndpoint.value, + loginEnabled: ssoStore.isOidcLoginEnabled, + }); + + clientSecret.value = newConfig.clientSecret; +}