mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 01:56:46 +00:00
feat(core): Add OIDC support for SSO (#15988)
Co-authored-by: Andreas Fitzek <andreas.fitzek@n8n.io>
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -204,6 +204,13 @@ export const schema = {
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
oidc: {
|
||||
loginEnabled: {
|
||||
format: Boolean,
|
||||
default: false,
|
||||
doc: 'Whether to enable OIDC SSO.',
|
||||
},
|
||||
},
|
||||
ldap: {
|
||||
loginEnabled: {
|
||||
format: Boolean,
|
||||
|
||||
@@ -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<NumericLicenseFeature, number> = {
|
||||
|
||||
@@ -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<PublicUser> {
|
||||
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) {
|
||||
|
||||
@@ -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.`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
// ----------------------------------------
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
|
||||
4
packages/cli/src/sso.ee/oidc/constants.ts
Normal file
4
packages/cli/src/sso.ee/oidc/constants.ts
Normal file
@@ -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';
|
||||
267
packages/cli/src/sso.ee/oidc/oidc.service.ee.ts
Normal file
267
packages/cli/src/sso.ee/oidc/oidc.service.ee.ts
Normal file
@@ -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<OidcConfigDto, 'clientId' | 'clientSecret' | 'loginEnabled'> & {
|
||||
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<URL> {
|
||||
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<User> {
|
||||
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<OidcRuntimeConfig> {
|
||||
const currentConfig = await this.settingsRepository.findOneBy({
|
||||
key: OIDC_PREFERENCES_DB_KEY,
|
||||
});
|
||||
|
||||
if (currentConfig) {
|
||||
try {
|
||||
const oidcConfig = jsonParse<OidcConfigDto>(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<void> {
|
||||
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<client.Configuration>;
|
||||
validTill: Date;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
private async getOidcConfiguration(): Promise<client.Configuration> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
62
packages/cli/src/sso.ee/oidc/routes/oidc.controller.ee.ts
Normal file
62
packages/cli/src/sso.ee/oidc/routes/oidc.controller.ee.ts
Normal file
@@ -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('/');
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
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 {
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
380
packages/cli/test/integration/oidc/oidc.service.ee.test.ts
Normal file
380
packages/cli/test/integration/oidc/oidc.service.ee.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user