feat(core): Add OIDC support for SSO (#15988)

Co-authored-by: Andreas Fitzek <andreas.fitzek@n8n.io>
This commit is contained in:
Ricardo Espinoza
2025-06-13 10:18:14 -04:00
committed by GitHub
parent 0d5ac1f822
commit 30148df7f3
40 changed files with 1358 additions and 197 deletions

View File

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

View File

@@ -204,6 +204,13 @@ export const schema = {
default: '',
},
},
oidc: {
loginEnabled: {
format: Boolean,
default: false,
doc: 'Whether to enable OIDC SSO.',
},
},
ldap: {
loginEnabled: {
format: Boolean,

View File

@@ -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> = {

View File

@@ -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) {

View File

@@ -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.`,
);
}

View File

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

View File

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

View File

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

View File

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

View 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';

View 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;
}
}

View 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('/');
}
}

View File

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

View File

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

View 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);
});
});
});