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

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

View File

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

View File

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

View File

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

View File

@@ -58,6 +58,10 @@ export class LicenseState {
return this.isLicensed('feat:saml');
}
isOidcLicensed() {
return this.isLicensed('feat:oidc');
}
isApiKeyScopesLicensed() {
return this.isLicensed('feat:apiKeyScopes');
}

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -101,7 +101,7 @@ const onUserAction = (user: IUser, action: string) =>
<N8nActionToggle
v-if="
!user.isOwner &&
user.signInType !== 'ldap' &&
!['ldap'].includes(user.signInType) &&
!readonly &&
getActions(user).length > 0 &&
actions.length > 0

View File

@@ -929,6 +929,7 @@
"forgotPassword.returnToSignIn": "Back to sign in",
"forgotPassword.sendingEmailError": "Problem sending email",
"forgotPassword.ldapUserPasswordResetUnavailable": "Please contact your LDAP administrator to reset your password",
"forgotPassword.oidcUserPasswordResetUnavailable": "Please contact your OIDC administrator to reset your password",
"forgotPassword.smtpErrorContactAdministrator": "Please contact your administrator (problem with your SMTP setup)",
"forgotPassword.tooManyRequests": "Youve reached the password reset limit. Please try again in a few minutes.",
"forms.resourceFiltersDropdown.filters": "Filters",
@@ -2716,6 +2717,7 @@
"settings.ldap.confirmMessage.beforeSaveForm.confirmButtonText": "Yes, disable it",
"settings.ldap.confirmMessage.beforeSaveForm.headline": "Are you sure you want to disable LDAP login?",
"settings.ldap.confirmMessage.beforeSaveForm.message": "If you do so, all LDAP users will be converted to email users.",
"settings.ldap.disabled.title": "Available on the Enterprise plan",
"settings.ldap.disabled.description": "LDAP is available as a paid feature. Learn more about it.",
"settings.ldap.disabled.buttonText": "See plans",
@@ -2775,8 +2777,8 @@
"settings.sso": "SSO",
"settings.sso.title": "Single Sign On",
"settings.sso.subtitle": "SAML 2.0 Configuration",
"settings.sso.info": "Activate SAML SSO to enable passwordless login via your existing user management tool and enhance security through unified authentication.",
"settings.sso.info.link": "Learn how to configure SAML 2.0.",
"settings.sso.info": "Activate SAML or OIDC to enable passwordless login via your existing user management tool and enhance security through unified authentication.",
"settings.sso.info.link": "Learn how to configure SAML or OIDC.",
"settings.sso.activation.tooltip": "You need to save the settings first before activating SAML",
"settings.sso.activated": "Activated",
"settings.sso.deactivated": "Deactivated",
@@ -2804,6 +2806,8 @@
"settings.sso.actionBox.title": "Available on the Enterprise plan",
"settings.sso.actionBox.description": "Use Single Sign On to consolidate authentication into a single platform to improve security and agility.",
"settings.sso.actionBox.buttonText": "See plans",
"settings.oidc.confirmMessage.beforeSaveForm.headline": "Are you sure you want to disable OIDC login?",
"settings.oidc.confirmMessage.beforeSaveForm.message": "If you do so, all OIDC users will be converted to email users.",
"settings.mfa.secret": "Secret {secret}",
"settings.mfa": "MFA",
"settings.mfa.title": "Multi-factor Authentication",

View File

@@ -1,4 +1,4 @@
import type { SamlPreferences, SamlToggleDto } from '@n8n/api-types';
import type { OidcConfigDto, SamlPreferences, SamlToggleDto } from '@n8n/api-types';
import type { IRestApiContext } from '../types';
import { makeRestApiRequest } from '../utils';
@@ -39,3 +39,18 @@ export const toggleSamlConfig = async (
export const testSamlConfig = async (context: IRestApiContext): Promise<string> => {
return await makeRestApiRequest(context, 'GET', '/sso/saml/config/test');
};
export const getOidcConfig = async (context: IRestApiContext): Promise<OidcConfigDto> => {
return await makeRestApiRequest(context, 'GET', '/sso/oidc/config');
};
export const saveOidcConfig = async (
context: IRestApiContext,
data: OidcConfigDto,
): Promise<OidcConfigDto> => {
return await makeRestApiRequest(context, 'POST', '/sso/oidc/config', data);
};
export const initOidcLogin = async (context: IRestApiContext): Promise<string> => {
return await makeRestApiRequest(context, 'GET', '/sso/oidc/login');
};

View File

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

View File

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

View File

@@ -220,6 +220,7 @@ export function createMockEnterpriseSettings(
sharing: false,
ldap: false,
saml: false,
oidc: false,
logStreaming: false,
advancedExecutionFilters: false,
variables: false,

View File

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

View File

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

View File

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

View File

@@ -33,6 +33,7 @@ export const useRBACStore = defineStore(STORES.RBAC, () => {
license: {},
logStreaming: {},
saml: {},
oidc: {},
securityAudit: {},
folder: {},
insights: {},

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<SupportedProtocolType>(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;
}
</script>
<template>
<div class="pb-3xl">
<n8n-heading size="2xlarge">{{ i18n.baseText('settings.sso.title') }}</n8n-heading>
<div :class="$style.top">
<n8n-heading size="xlarge">{{ i18n.baseText('settings.sso.subtitle') }}</n8n-heading>
<n8n-tooltip
v-if="ssoStore.isEnterpriseSamlEnabled"
:disabled="ssoStore.isSamlLoginEnabled || ssoSettingsSaved"
>
<template #content>
<span>
{{ i18n.baseText('settings.sso.activation.tooltip') }}
</span>
</template>
<el-switch
v-model="ssoStore.isSamlLoginEnabled"
data-test-id="sso-toggle"
:disabled="isToggleSsoDisabled"
:class="$style.switch"
:inactive-text="ssoActivatedLabel"
/>
</n8n-tooltip>
<div class="pb-2xl">
<div :class="$style.heading">
<n8n-heading size="2xlarge">{{ i18n.baseText('settings.sso.title') }}</n8n-heading>
</div>
<n8n-info-tip>
{{ i18n.baseText('settings.sso.info') }}
@@ -209,67 +285,173 @@ onMounted(async () => {
</n8n-info-tip>
<div v-if="ssoStore.isEnterpriseSamlEnabled" data-test-id="sso-content-licensed">
<div :class="$style.group">
<label>{{ i18n.baseText('settings.sso.settings.redirectUrl.label') }}</label>
<CopyInput
:value="redirectUrl"
:copy-button-text="i18n.baseText('generic.clickToCopy')"
:toast-title="i18n.baseText('settings.sso.settings.redirectUrl.copied')"
/>
<small>{{ i18n.baseText('settings.sso.settings.redirectUrl.help') }}</small>
</div>
<div :class="$style.group">
<label>{{ i18n.baseText('settings.sso.settings.entityId.label') }}</label>
<CopyInput
:value="entityId"
:copy-button-text="i18n.baseText('generic.clickToCopy')"
:toast-title="i18n.baseText('settings.sso.settings.entityId.copied')"
/>
<small>{{ i18n.baseText('settings.sso.settings.entityId.help') }}</small>
</div>
<div :class="$style.group">
<label>{{ i18n.baseText('settings.sso.settings.ips.label') }}</label>
<div class="mt-2xs mb-s">
<n8n-radio-buttons v-model="ipsType" :options="ipsOptions" />
<label>Select Authentication Protocol</label>
<div>
<N8nSelect
filterable
:model-value="authProtocol"
:placeholder="i18n.baseText('parameterInput.select')"
@update:model-value="onAuthProtocolUpdated"
@keydown.stop
>
<N8nOption
v-for="protocol in Object.values(SupportedProtocols)"
:key="protocol"
:value="protocol"
:label="protocol.toUpperCase()"
data-test-id="credential-select-option"
>
</N8nOption>
</N8nSelect>
</div>
<div v-show="ipsType === IdentityProviderSettingsType.URL">
<n8n-input
v-model="metadataUrl"
type="text"
name="metadataUrl"
</div>
<div v-if="authProtocol === SupportedProtocols.SAML">
<div :class="$style.group">
<label>{{ i18n.baseText('settings.sso.settings.redirectUrl.label') }}</label>
<CopyInput
:value="redirectUrl"
:copy-button-text="i18n.baseText('generic.clickToCopy')"
:toast-title="i18n.baseText('settings.sso.settings.redirectUrl.copied')"
/>
<small>{{ i18n.baseText('settings.sso.settings.redirectUrl.help') }}</small>
</div>
<div :class="$style.group">
<label>{{ i18n.baseText('settings.sso.settings.entityId.label') }}</label>
<CopyInput
:value="entityId"
:copy-button-text="i18n.baseText('generic.clickToCopy')"
:toast-title="i18n.baseText('settings.sso.settings.entityId.copied')"
/>
<small>{{ i18n.baseText('settings.sso.settings.entityId.help') }}</small>
</div>
<div :class="$style.group">
<label>{{ i18n.baseText('settings.sso.settings.ips.label') }}</label>
<div class="mt-2xs mb-s">
<n8n-radio-buttons v-model="ipsType" :options="ipsOptions" />
</div>
<div v-show="ipsType === IdentityProviderSettingsType.URL">
<n8n-input
v-model="metadataUrl"
type="text"
name="metadataUrl"
size="large"
:placeholder="i18n.baseText('settings.sso.settings.ips.url.placeholder')"
data-test-id="sso-provider-url"
/>
<small>{{ i18n.baseText('settings.sso.settings.ips.url.help') }}</small>
</div>
<div v-show="ipsType === IdentityProviderSettingsType.XML">
<n8n-input
v-model="metadata"
type="textarea"
name="metadata"
:rows="4"
data-test-id="sso-provider-xml"
/>
<small>{{ i18n.baseText('settings.sso.settings.ips.xml.help') }}</small>
</div>
<div :class="$style.group">
<n8n-tooltip
v-if="ssoStore.isEnterpriseSamlEnabled"
:disabled="ssoStore.isSamlLoginEnabled || ssoSettingsSaved"
>
<template #content>
<span>
{{ i18n.baseText('settings.sso.activation.tooltip') }}
</span>
</template>
<el-switch
v-model="ssoStore.isSamlLoginEnabled"
data-test-id="sso-toggle"
:disabled="isToggleSsoDisabled"
:class="$style.switch"
:inactive-text="ssoActivatedLabel"
/>
</n8n-tooltip>
</div>
</div>
<div :class="$style.buttons">
<n8n-button
:disabled="!isSaveEnabled"
size="large"
:placeholder="i18n.baseText('settings.sso.settings.ips.url.placeholder')"
data-test-id="sso-provider-url"
/>
<small>{{ i18n.baseText('settings.sso.settings.ips.url.help') }}</small>
data-test-id="sso-save"
@click="onSave"
>
{{ i18n.baseText('settings.sso.settings.save') }}
</n8n-button>
<n8n-button
:disabled="!isTestEnabled"
size="large"
type="tertiary"
data-test-id="sso-test"
@click="onTest"
>
{{ i18n.baseText('settings.sso.settings.test') }}
</n8n-button>
</div>
<div v-show="ipsType === IdentityProviderSettingsType.XML">
<n8n-input
v-model="metadata"
type="textarea"
name="metadata"
:rows="4"
data-test-id="sso-provider-xml"
<footer :class="$style.footer">
{{ i18n.baseText('settings.sso.settings.footer.hint') }}
</footer>
</div>
<div v-if="authProtocol === SupportedProtocols.OIDC">
<div :class="$style.group">
<label>Redirect URL</label>
<CopyInput
:value="settingsStore.oidcCallBackUrl"
:copy-button-text="i18n.baseText('generic.clickToCopy')"
toast-title="Redirect URL copied to clipboard"
/>
<small>{{ i18n.baseText('settings.sso.settings.ips.xml.help') }}</small>
<small>Copy the Redirect URL to configure your OIDC provider </small>
</div>
<div :class="$style.group">
<label>Discovery Endpoint</label>
<N8nInput
:model-value="discoveryEndpoint"
type="text"
placeholder="https://accounts.google.com/.well-known/openid-configuration"
@update:model-value="(v: string) => (discoveryEndpoint = v)"
/>
<small>Paste here your discovery endpoint</small>
</div>
<div :class="$style.group">
<label>Client ID</label>
<N8nInput
:model-value="clientId"
type="text"
@update:model-value="(v: string) => (clientId = v)"
/>
<small
>The client ID you received when registering your application with your provider</small
>
</div>
<div :class="$style.group">
<label>Client Secret</label>
<N8nInput
:model-value="clientSecret"
type="password"
@update:model-value="(v: string) => (clientSecret = v)"
/>
<small
>The client Secret you received when registering your application with your
provider</small
>
</div>
<div :class="$style.group">
<el-switch
v-model="ssoStore.isOidcLoginEnabled"
data-test-id="sso-oidc-toggle"
:class="$style.switch"
:inactive-text="oidcActivatedLabel"
/>
</div>
<div :class="$style.buttons">
<n8n-button size="large" :disabled="cannotSaveOidcSettings" @click="onOidcSettingsSave">
{{ i18n.baseText('settings.sso.settings.save') }}
</n8n-button>
</div>
</div>
<div :class="$style.buttons">
<n8n-button :disabled="!isSaveEnabled" size="large" data-test-id="sso-save" @click="onSave">
{{ i18n.baseText('settings.sso.settings.save') }}
</n8n-button>
<n8n-button
:disabled="!isTestEnabled"
size="large"
type="tertiary"
data-test-id="sso-test"
@click="onTest"
>
{{ i18n.baseText('settings.sso.settings.test') }}
</n8n-button>
</div>
<footer :class="$style.footer">
{{ i18n.baseText('settings.sso.settings.footer.hint') }}
</footer>
</div>
<n8n-action-box
v-else
@@ -287,11 +469,8 @@ onMounted(async () => {
</template>
<style lang="scss" module>
.top {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-2xl) 0 var(--spacing-xl);
.heading {
margin-bottom: var(--spacing-s);
}
.switch {

170
pnpm-lock.yaml generated
View File

@@ -1319,6 +1319,9 @@ importers:
open:
specifier: 7.4.2
version: 7.4.2
openid-client:
specifier: 6.5.0
version: 6.5.0
otpauth:
specifier: 9.1.1
version: 9.1.1
@@ -1899,7 +1902,7 @@ importers:
version: 0.19.0(@vue/compiler-sfc@3.5.13)
unplugin-vue-components:
specifier: ^0.27.2
version: 0.27.3(@babel/parser@7.26.10)(rollup@4.35.0)(vue@3.5.13(typescript@5.8.2))
version: 0.27.3(@babel/parser@7.27.5)(rollup@4.35.0)(vue@3.5.13(typescript@5.8.2))
vite:
specifier: catalog:frontend
version: 6.3.5(@types/node@20.17.57)(jiti@1.21.0)(sass@1.64.1)(terser@5.16.1)(tsx@4.19.3)
@@ -2392,7 +2395,7 @@ importers:
version: 0.19.0(@vue/compiler-sfc@3.5.13)
unplugin-vue-components:
specifier: ^0.27.2
version: 0.27.3(@babel/parser@7.26.10)(rollup@4.35.0)(vue@3.5.13(typescript@5.8.2))
version: 0.27.3(@babel/parser@7.27.5)(rollup@4.35.0)(vue@3.5.13(typescript@5.8.2))
vite:
specifier: catalog:frontend
version: 6.3.5(@types/node@20.17.57)(jiti@1.21.0)(sass@1.64.1)(terser@5.16.1)(tsx@4.19.3)
@@ -3186,6 +3189,10 @@ packages:
resolution: {integrity: sha512-oH5UPLMWR3L2wEFLnFJ1TZXqHufiTKAiLfqw5zkhS4dKXLJ10yVztfil/twG8EDTA4F/tvVNw9nOl4ZMslB8rQ==}
engines: {node: '>=6.9.0'}
'@babel/compat-data@7.27.5':
resolution: {integrity: sha512-KiRAp/VoJaWkkte84TvUd9qjdbZAdiqyvMxrGl1N6vzFogKmaLgoM3L1kgtLicp2HP5fBJS8JrZKLVIZGVJAVg==}
engines: {node: '>=6.9.0'}
'@babel/core@7.26.10':
resolution: {integrity: sha512-vMqyb7XCDMPvJFFOaT9kxtiRh42GwlZEg1/uIgtZshS5a/8OaduUfCi7kynKgc3Tw/6Uo2D+db9qBttghhmxwQ==}
engines: {node: '>=6.9.0'}
@@ -3202,6 +3209,10 @@ packages:
resolution: {integrity: sha512-IXuyn5EkouFJscIDuFF5EsiSolseme1s0CZB+QxVugqJLYmKdxI1VfIBOst0SUu4rnk2Z7kqTwmoO1lp3HIfnA==}
engines: {node: '>=6.9.0'}
'@babel/helper-compilation-targets@7.27.2':
resolution: {integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==}
engines: {node: '>=6.9.0'}
'@babel/helper-create-class-features-plugin@7.26.9':
resolution: {integrity: sha512-ubbUqCofvxPRurw5L8WTsCLSkQiVpov4Qx0WMA+jUN+nXBK8ADPlJO1grkFw5CWKC5+sZSOfuGMdX1aI1iT9Sg==}
engines: {node: '>=6.9.0'}
@@ -3241,6 +3252,10 @@ packages:
resolution: {integrity: sha512-RS+jZcRdZdRFzMyr+wcsaqOmld1/EqTghfaBGQQd/WnRdzdlvSZ//kF7U8VQTxf1ynZ4cjUcYgjVGx13ewNPMg==}
engines: {node: '>=6.9.0'}
'@babel/helper-plugin-utils@7.27.1':
resolution: {integrity: sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==}
engines: {node: '>=6.9.0'}
'@babel/helper-remap-async-to-generator@7.25.9':
resolution: {integrity: sha512-IZtukuUeBbhgOcaW2s06OXTzVNJR0ybm4W5xC1opWFFJMZbwRj5LCk+ByYH7WdZPZTt8KnFwA8pvjN2yqcPlgw==}
engines: {node: '>=6.9.0'}
@@ -3261,14 +3276,26 @@ packages:
resolution: {integrity: sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==}
engines: {node: '>=6.9.0'}
'@babel/helper-string-parser@7.27.1':
resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
engines: {node: '>=6.9.0'}
'@babel/helper-validator-identifier@7.25.9':
resolution: {integrity: sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==}
engines: {node: '>=6.9.0'}
'@babel/helper-validator-identifier@7.27.1':
resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==}
engines: {node: '>=6.9.0'}
'@babel/helper-validator-option@7.25.9':
resolution: {integrity: sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==}
engines: {node: '>=6.9.0'}
'@babel/helper-validator-option@7.27.1':
resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==}
engines: {node: '>=6.9.0'}
'@babel/helper-wrap-function@7.25.9':
resolution: {integrity: sha512-ETzz9UTjQSTmw39GboatdymDq4XIQbR8ySgVrylRhPOFpsd+JrKHIuF0de7GCWmem+T4uC5z7EZguod7Wj4A4g==}
engines: {node: '>=6.9.0'}
@@ -3282,6 +3309,11 @@ packages:
engines: {node: '>=6.0.0'}
hasBin: true
'@babel/parser@7.27.5':
resolution: {integrity: sha512-OsQd175SxWkGlzbny8J3K8TnnDD0N3lrIUtB92xwyRpzaenGZhxDvxN/JgU00U3CDZNj9tPuDJ5H0WS4Nt3vKg==}
engines: {node: '>=6.0.0'}
hasBin: true
'@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.25.9':
resolution: {integrity: sha512-ZkRyVkThtxQ/J6nv3JFYv1RYY+JT5BvU0y3k5bWrmuG4woXypRa4PXmm9RhOwodRkYFWqC0C0cqcJ4OqR7kW+g==}
engines: {node: '>=6.9.0'}
@@ -3742,6 +3774,10 @@ packages:
resolution: {integrity: sha512-emqcG3vHrpxUKTrxcblR36dcrcoRDvKmnL/dCL6ZsHaShW80qxCAcNhzQZrpeM765VzEos+xOi4s+r4IXzTwdQ==}
engines: {node: '>=6.9.0'}
'@babel/types@7.27.6':
resolution: {integrity: sha512-ETyHEk2VHHvl9b9jZP5IHPavHYk57EhanlRRuae9XCpb/j5bDCbPPMOBfCWhnl/7EDJz0jEMCi/RhccCE8r1+Q==}
engines: {node: '>=6.9.0'}
'@bcoe/v8-coverage@0.2.3':
resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==}
@@ -10362,6 +10398,9 @@ packages:
join-component@1.1.0:
resolution: {integrity: sha512-bF7vcQxbODoGK1imE2P9GS9aw4zD0Sd+Hni68IMZLj7zRnquH7dXUmMw9hDI5S/Jzt7q+IyTXN0rSg2GI0IKhQ==}
jose@6.0.11:
resolution: {integrity: sha512-QxG7EaliDARm1O1S8BGakqncGT9s25bKL1WSf6/oa17Tkqwi8D2ZNglqCF+DsYF88/rV66Q/Q2mFAy697E1DUg==}
joycon@3.1.1:
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
engines: {node: '>=10'}
@@ -11627,10 +11666,6 @@ packages:
object-inspect@1.13.1:
resolution: {integrity: sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==}
object-inspect@1.13.2:
resolution: {integrity: sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==}
engines: {node: '>= 0.4'}
object-inspect@1.13.4:
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
engines: {node: '>= 0.4'}
@@ -11727,6 +11762,9 @@ packages:
openapi-types@12.1.3:
resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==}
openid-client@6.5.0:
resolution: {integrity: sha512-fAfYaTnOYE2kQCqEJGX9KDObW2aw7IQy4jWpU/+3D3WoCFLbix5Hg6qIPQ6Js9r7f8jDUmsnnguRNCSw4wU/IQ==}
option@0.2.4:
resolution: {integrity: sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==}
@@ -12892,10 +12930,6 @@ packages:
side-channel@1.0.4:
resolution: {integrity: sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==}
side-channel@1.0.6:
resolution: {integrity: sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==}
engines: {node: '>= 0.4'}
side-channel@1.1.0:
resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
engines: {node: '>= 0.4'}
@@ -15698,6 +15732,8 @@ snapshots:
'@babel/compat-data@7.26.8': {}
'@babel/compat-data@7.27.5': {}
'@babel/core@7.26.10':
dependencies:
'@ampproject/remapping': 2.3.0
@@ -15714,7 +15750,7 @@ snapshots:
debug: 4.4.1(supports-color@8.1.1)
gensync: 1.0.0-beta.2
json5: 2.2.3
semver: 7.6.0
semver: 7.7.2
transitivePeerDependencies:
- supports-color
@@ -15738,6 +15774,14 @@ snapshots:
lru-cache: 5.1.1
semver: 7.7.2
'@babel/helper-compilation-targets@7.27.2':
dependencies:
'@babel/compat-data': 7.27.5
'@babel/helper-validator-option': 7.27.1
browserslist: 4.24.4
lru-cache: 5.1.1
semver: 7.7.2
'@babel/helper-create-class-features-plugin@7.26.9(@babel/core@7.26.10)':
dependencies:
'@babel/core': 7.26.10
@@ -15761,8 +15805,8 @@ snapshots:
'@babel/helper-define-polyfill-provider@0.6.3(@babel/core@7.26.10)':
dependencies:
'@babel/core': 7.26.10
'@babel/helper-compilation-targets': 7.26.5
'@babel/helper-plugin-utils': 7.26.5
'@babel/helper-compilation-targets': 7.27.2
'@babel/helper-plugin-utils': 7.27.1
debug: 4.4.1(supports-color@8.1.1)
lodash.debounce: 4.0.8
resolve: 1.22.8
@@ -15798,6 +15842,8 @@ snapshots:
'@babel/helper-plugin-utils@7.26.5': {}
'@babel/helper-plugin-utils@7.27.1': {}
'@babel/helper-remap-async-to-generator@7.25.9(@babel/core@7.26.10)':
dependencies:
'@babel/core': 7.26.10
@@ -15825,10 +15871,16 @@ snapshots:
'@babel/helper-string-parser@7.25.9': {}
'@babel/helper-string-parser@7.27.1': {}
'@babel/helper-validator-identifier@7.25.9': {}
'@babel/helper-validator-identifier@7.27.1': {}
'@babel/helper-validator-option@7.25.9': {}
'@babel/helper-validator-option@7.27.1': {}
'@babel/helper-wrap-function@7.25.9':
dependencies:
'@babel/template': 7.26.9
@@ -15846,6 +15898,10 @@ snapshots:
dependencies:
'@babel/types': 7.26.10
'@babel/parser@7.27.5':
dependencies:
'@babel/types': 7.27.6
'@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.25.9(@babel/core@7.26.10)':
dependencies:
'@babel/core': 7.26.10
@@ -15974,7 +16030,7 @@ snapshots:
dependencies:
'@babel/core': 7.26.10
'@babel/helper-create-regexp-features-plugin': 7.26.3(@babel/core@7.26.10)
'@babel/helper-plugin-utils': 7.26.5
'@babel/helper-plugin-utils': 7.27.1
'@babel/plugin-transform-arrow-functions@7.25.9(@babel/core@7.26.10)':
dependencies:
@@ -16366,14 +16422,14 @@ snapshots:
babel-plugin-polyfill-corejs3: 0.11.1(@babel/core@7.26.10)
babel-plugin-polyfill-regenerator: 0.6.3(@babel/core@7.26.10)
core-js-compat: 3.41.0
semver: 7.6.0
semver: 7.7.2
transitivePeerDependencies:
- supports-color
'@babel/preset-modules@0.1.6-no-external-plugins(@babel/core@7.26.10)':
dependencies:
'@babel/core': 7.26.10
'@babel/helper-plugin-utils': 7.26.5
'@babel/helper-plugin-utils': 7.27.1
'@babel/types': 7.26.10
esutils: 2.0.3
@@ -16404,6 +16460,11 @@ snapshots:
'@babel/helper-string-parser': 7.25.9
'@babel/helper-validator-identifier': 7.25.9
'@babel/types@7.27.6':
dependencies:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.27.1
'@bcoe/v8-coverage@0.2.3': {}
'@bcoe/v8-coverage@1.0.2': {}
@@ -17685,7 +17746,7 @@ snapshots:
lodash: 4.17.21
minimatch: 3.0.8
resolve: 1.22.8
semver: 7.6.0
semver: 7.7.2
source-map: 0.6.1
typescript: 5.8.2
transitivePeerDependencies:
@@ -19283,7 +19344,7 @@ snapshots:
jsdoc-type-pratt-parser: 4.1.0
process: 0.11.10
recast: 0.23.6
semver: 7.6.0
semver: 7.7.2
util: 0.12.5
ws: 8.17.1
optionalDependencies:
@@ -19541,24 +19602,24 @@ snapshots:
'@types/babel__core@7.20.0':
dependencies:
'@babel/parser': 7.26.10
'@babel/types': 7.26.10
'@babel/parser': 7.27.5
'@babel/types': 7.27.6
'@types/babel__generator': 7.6.4
'@types/babel__template': 7.4.1
'@types/babel__traverse': 7.18.2
'@types/babel__generator@7.6.4':
dependencies:
'@babel/types': 7.26.10
'@babel/types': 7.27.6
'@types/babel__template@7.4.1':
dependencies:
'@babel/parser': 7.26.10
'@babel/types': 7.26.10
'@babel/parser': 7.27.5
'@babel/types': 7.27.6
'@types/babel__traverse@7.18.2':
dependencies:
'@babel/types': 7.26.10
'@babel/types': 7.27.6
'@types/basic-auth@1.1.3':
dependencies:
@@ -20095,7 +20156,7 @@ snapshots:
globby: 11.1.0
is-glob: 4.0.3
minimatch: 9.0.3
semver: 7.6.0
semver: 7.7.2
ts-api-utils: 1.0.1(typescript@5.8.2)
optionalDependencies:
typescript: 5.8.2
@@ -20111,7 +20172,7 @@ snapshots:
'@typescript-eslint/types': 6.21.0
'@typescript-eslint/typescript-estree': 6.21.0(typescript@5.8.2)
eslint: 8.57.0
semver: 7.6.0
semver: 7.7.2
transitivePeerDependencies:
- supports-color
- typescript
@@ -20125,7 +20186,7 @@ snapshots:
'@typescript-eslint/types': 7.2.0
'@typescript-eslint/typescript-estree': 7.2.0(typescript@5.8.2)
eslint: 8.57.0
semver: 7.6.0
semver: 7.7.2
transitivePeerDependencies:
- supports-color
- typescript
@@ -20328,7 +20389,7 @@ snapshots:
'@vue/compiler-sfc@3.5.13':
dependencies:
'@babel/parser': 7.26.10
'@babel/parser': 7.27.5
'@vue/compiler-core': 3.5.13
'@vue/compiler-dom': 3.5.13
'@vue/compiler-ssr': 3.5.13
@@ -20949,13 +21010,13 @@ snapshots:
babel-plugin-jest-hoist@29.5.0:
dependencies:
'@babel/template': 7.26.9
'@babel/types': 7.26.10
'@babel/types': 7.27.6
'@types/babel__core': 7.20.0
'@types/babel__traverse': 7.18.2
babel-plugin-polyfill-corejs2@0.4.12(@babel/core@7.26.10):
dependencies:
'@babel/compat-data': 7.26.8
'@babel/compat-data': 7.27.5
'@babel/core': 7.26.10
'@babel/helper-define-polyfill-provider': 0.6.3(@babel/core@7.26.10)
semver: 7.7.2
@@ -22366,7 +22427,7 @@ snapshots:
is-string: 1.0.7
is-typed-array: 1.1.13
is-weakref: 1.0.2
object-inspect: 1.13.2
object-inspect: 1.13.4
object-keys: 1.1.1
object.assign: 4.1.5
regexp.prototype.flags: 1.5.3
@@ -22389,7 +22450,7 @@ snapshots:
es-abstract: 1.23.3
es-errors: 1.3.0
function-bind: 1.1.2
globalthis: 1.0.3
globalthis: 1.0.4
has-property-descriptors: 1.0.2
set-function-name: 2.0.2
@@ -22511,7 +22572,7 @@ snapshots:
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.8.2))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0)
object.assign: 4.1.5
object.entries: 1.1.5
semver: 7.6.0
semver: 7.7.2
eslint-config-airbnb-typescript@18.0.0(@typescript-eslint/eslint-plugin@7.2.0(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.8.2))(eslint@8.57.0)(typescript@5.8.2))(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.8.2))(eslint-plugin-import@2.29.1)(eslint@8.57.0):
dependencies:
@@ -23814,7 +23875,7 @@ snapshots:
dependencies:
es-errors: 1.3.0
hasown: 2.0.2
side-channel: 1.0.6
side-channel: 1.1.0
interpret@1.4.0: {}
@@ -24020,7 +24081,7 @@ snapshots:
istanbul-lib-instrument@5.2.1:
dependencies:
'@babel/core': 7.26.10
'@babel/parser': 7.26.10
'@babel/parser': 7.27.5
'@istanbuljs/schema': 0.1.3
istanbul-lib-coverage: 3.2.2
semver: 7.7.2
@@ -24467,6 +24528,8 @@ snapshots:
join-component@1.1.0: {}
jose@6.0.11: {}
joycon@3.1.1: {}
js-base64@3.7.2: {}
@@ -25036,8 +25099,8 @@ snapshots:
magicast@0.3.5:
dependencies:
'@babel/parser': 7.26.10
'@babel/types': 7.26.10
'@babel/parser': 7.27.5
'@babel/types': 7.27.6
source-map-js: 1.2.1
mailparser@3.6.7:
@@ -25060,7 +25123,7 @@ snapshots:
make-dir@3.1.0:
dependencies:
semver: 7.6.0
semver: 7.7.2
make-dir@4.0.0:
dependencies:
@@ -25707,7 +25770,7 @@ snapshots:
dependencies:
'@tediousjs/connection-string': 0.5.0
commander: 11.1.0
debug: 4.3.6(supports-color@8.1.1)
debug: 4.4.1(supports-color@8.1.1)
rfdc: 1.3.0
tarn: 3.0.2
tedious: 16.7.1
@@ -25833,7 +25896,7 @@ snapshots:
nopt: 5.0.0
npmlog: 6.0.2
rimraf: 3.0.2
semver: 7.6.0
semver: 7.7.2
tar: 6.2.1
which: 2.0.2
transitivePeerDependencies:
@@ -25995,8 +26058,6 @@ snapshots:
object-inspect@1.13.1: {}
object-inspect@1.13.2: {}
object-inspect@1.13.4: {}
object-is@1.1.6:
@@ -26116,6 +26177,11 @@ snapshots:
openapi-types@12.1.3: {}
openid-client@6.5.0:
dependencies:
jose: 6.0.11
oauth4webapi: 3.5.1
option@0.2.4: {}
optionator@0.8.3:
@@ -27461,13 +27527,6 @@ snapshots:
get-intrinsic: 1.2.4
object-inspect: 1.13.1
side-channel@1.0.6:
dependencies:
call-bind: 1.0.7
es-errors: 1.3.0
get-intrinsic: 1.3.0
object-inspect: 1.13.1
side-channel@1.1.0:
dependencies:
es-errors: 1.3.0
@@ -27508,7 +27567,7 @@ snapshots:
simple-update-notifier@2.0.0:
dependencies:
semver: 7.6.0
semver: 7.7.2
simple-wcswidth@1.0.1: {}
@@ -27660,8 +27719,7 @@ snapshots:
sprintf-js@1.1.2: {}
sprintf-js@1.1.3:
optional: true
sprintf-js@1.1.3: {}
sqlite3@5.1.7:
dependencies:
@@ -28050,7 +28108,7 @@ snapshots:
jsbi: 4.3.0
native-duplexpair: 1.0.0
node-abort-controller: 3.1.1
sprintf-js: 1.1.2
sprintf-js: 1.1.3
transitivePeerDependencies:
- supports-color
@@ -28560,7 +28618,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
unplugin-vue-components@0.27.3(@babel/parser@7.26.10)(rollup@4.35.0)(vue@3.5.13(typescript@5.8.2)):
unplugin-vue-components@0.27.3(@babel/parser@7.27.5)(rollup@4.35.0)(vue@3.5.13(typescript@5.8.2)):
dependencies:
'@antfu/utils': 0.7.10
'@rollup/pluginutils': 5.1.0(rollup@4.35.0)
@@ -28574,7 +28632,7 @@ snapshots:
unplugin: 1.11.0
vue: 3.5.13(typescript@5.8.2)
optionalDependencies:
'@babel/parser': 7.26.10
'@babel/parser': 7.27.5
transitivePeerDependencies:
- rollup
- supports-color
@@ -28627,7 +28685,7 @@ snapshots:
utf7@1.0.2:
dependencies:
semver: 7.6.0
semver: 7.7.2
utf8@2.1.2: {}