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 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} */ /** @type {import('jest').Config} */
const config = { const config = {
verbose: true, verbose: true,
@@ -21,7 +32,7 @@ const config = {
testPathIgnorePatterns: ['/dist/', '/node_modules/'], testPathIgnorePatterns: ['/dist/', '/node_modules/'],
transform: { transform: {
'^.+\\.ts$': ['ts-jest', tsJestOptions], '^.+\\.ts$': ['ts-jest', tsJestOptions],
'node_modules/pdfjs-dist/.+\\.mjs$': [ [esmDependenciesRegex]: [
'babel-jest', 'babel-jest',
{ {
presets: ['@babel/preset-env'], 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 // This resolve the path mappings from the tsconfig relative to each jest.config.js
moduleNameMapper: compilerOptions?.paths moduleNameMapper: compilerOptions?.paths
? pathsToModuleNameMapper(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 { PaginationDto } from './pagination/pagination.dto';
export { UsersListFilterDto } from './user/users-list-filter.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; config?: ITelemetryClientConfig;
} }
export type AuthenticationMethod = 'email' | 'ldap' | 'saml'; export type AuthenticationMethod = 'email' | 'ldap' | 'saml' | 'oidc';
export interface IUserManagementSettings { export interface IUserManagementSettings {
quota: number; quota: number;
@@ -84,6 +84,11 @@ export interface FrontendSettings {
loginLabel: string; loginLabel: string;
loginEnabled: boolean; loginEnabled: boolean;
}; };
oidc: {
loginEnabled: boolean;
loginUrl: string;
callbackUrl: string;
};
ldap: { ldap: {
loginLabel: string; loginLabel: string;
loginEnabled: boolean; loginEnabled: boolean;
@@ -129,6 +134,7 @@ export interface FrontendSettings {
sharing: boolean; sharing: boolean;
ldap: boolean; ldap: boolean;
saml: boolean; saml: boolean;
oidc: boolean;
logStreaming: boolean; logStreaming: boolean;
advancedExecutionFilters: boolean; advancedExecutionFilters: boolean;
variables: boolean; variables: boolean;

View File

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

View File

@@ -8,6 +8,7 @@ export const LICENSE_FEATURES = {
SHARING: 'feat:sharing', SHARING: 'feat:sharing',
LDAP: 'feat:ldap', LDAP: 'feat:ldap',
SAML: 'feat:saml', SAML: 'feat:saml',
OIDC: 'feat:oidc',
LOG_STREAMING: 'feat:logStreaming', LOG_STREAMING: 'feat:logStreaming',
ADVANCED_EXECUTION_FILTERS: 'feat:advancedExecutionFilters', ADVANCED_EXECUTION_FILTERS: 'feat:advancedExecutionFilters',
VARIABLES: 'feat:variables', VARIABLES: 'feat:variables',

View File

@@ -269,7 +269,7 @@ export const enum StatisticsNames {
dataLoaded = 'data_loaded', dataLoaded = 'data_loaded',
} }
export type AuthProviderType = 'ldap' | 'email' | 'saml'; // | 'google'; export type AuthProviderType = 'ldap' | 'email' | 'saml' | 'oidc'; // | 'google';
export type FolderWithWorkflowAndSubFolderCount = Folder & { export type FolderWithWorkflowAndSubFolderCount = Folder & {
workflowCount?: boolean; workflowCount?: boolean;

View File

@@ -25,6 +25,7 @@ export const RESOURCES = {
workflow: ['share', 'execute', 'move', ...DEFAULT_OPERATIONS] as const, workflow: ['share', 'execute', 'move', ...DEFAULT_OPERATIONS] as const,
folder: [...DEFAULT_OPERATIONS, 'move'] as const, folder: [...DEFAULT_OPERATIONS, 'move'] as const,
insights: ['list'] as const, insights: ['list'] as const,
oidc: ['manage'] as const,
} as const; } as const;
export const API_KEY_RESOURCES = { export const API_KEY_RESOURCES = {

View File

@@ -77,6 +77,7 @@ export const GLOBAL_OWNER_SCOPES: Scope[] = [
'project:delete', 'project:delete',
'insights:list', 'insights:list',
'folder:move', 'folder:move',
'oidc:manage',
]; ];
export const GLOBAL_ADMIN_SCOPES = GLOBAL_OWNER_SCOPES.concat(); export const GLOBAL_ADMIN_SCOPES = GLOBAL_OWNER_SCOPES.concat();

View File

@@ -152,6 +152,7 @@
"nodemailer": "6.9.9", "nodemailer": "6.9.9",
"oauth-1.0a": "2.2.6", "oauth-1.0a": "2.2.6",
"open": "7.4.2", "open": "7.4.2",
"openid-client": "6.5.0",
"otpauth": "9.1.1", "otpauth": "9.1.1",
"p-cancelable": "2.1.1", "p-cancelable": "2.1.1",
"p-lazy": "3.1.0", "p-lazy": "3.1.0",

View File

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

View File

@@ -105,6 +105,7 @@ export class E2EController {
[LICENSE_FEATURES.INSIGHTS_VIEW_DASHBOARD]: false, [LICENSE_FEATURES.INSIGHTS_VIEW_DASHBOARD]: false,
[LICENSE_FEATURES.INSIGHTS_VIEW_HOURLY_DATA]: false, [LICENSE_FEATURES.INSIGHTS_VIEW_HOURLY_DATA]: false,
[LICENSE_FEATURES.API_KEY_SCOPES]: false, [LICENSE_FEATURES.API_KEY_SCOPES]: false,
[LICENSE_FEATURES.OIDC]: false,
}; };
private static readonly numericFeaturesDefaults: Record<NumericLicenseFeature, number> = { 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 { PasswordUtility } from '@/services/password.utility';
import { UserService } from '@/services/user.service'; import { UserService } from '@/services/user.service';
import { isSamlLicensedAndEnabled } from '@/sso.ee/saml/saml-helpers'; import { isSamlLicensedAndEnabled } from '@/sso.ee/saml/saml-helpers';
import {
getCurrentAuthenticationMethod,
isLdapCurrentAuthenticationMethod,
isOidcCurrentAuthenticationMethod,
} from '@/sso.ee/sso-helpers';
import { PersonalizationSurveyAnswersV4 } from './survey-answers.dto'; import { PersonalizationSurveyAnswersV4 } from './survey-answers.dto';
@RestController('/me') @RestController('/me')
@@ -46,10 +51,34 @@ export class MeController {
res: Response, res: Response,
@Body payload: UserUpdateRequestDto, @Body payload: UserUpdateRequestDto,
): Promise<PublicUser> { ): 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 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 SAML is enabled, we don't allow the user to change their email address
if (isSamlLicensedAndEnabled() && isEmailBeingChanged) { if (isSamlLicensedAndEnabled() && isEmailBeingChanged) {

View File

@@ -23,7 +23,10 @@ import { MfaService } from '@/mfa/mfa.service';
import { AuthlessRequest } from '@/requests'; import { AuthlessRequest } from '@/requests';
import { PasswordUtility } from '@/services/password.utility'; import { PasswordUtility } from '@/services/password.utility';
import { UserService } from '@/services/user.service'; 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'; import { UserManagementMailer } from '@/user-management/email';
@RestController() @RestController()
@@ -76,17 +79,15 @@ export class PasswordResetController {
} }
if ( if (
isSamlCurrentAuthenticationMethod() && (isSamlCurrentAuthenticationMethod() || isOidcCurrentAuthenticationMethod()) &&
!( !(hasGlobalScope(user, 'user:resetPassword') || user.settings?.allowSSOManualLogin === true)
user &&
(hasGlobalScope(user, 'user:resetPassword') || user.settings?.allowSSOManualLogin === true)
)
) { ) {
const currentAuthenticationMethod = isSamlCurrentAuthenticationMethod() ? 'SAML' : 'OIDC';
this.logger.debug( 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( 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); 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'); 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 */ /** Set the LDAP login enabled to the configuration object */
private async setLdapLoginEnabled(enabled: boolean): Promise<void> { private async setLdapLoginEnabled(enabled: boolean): Promise<void> {
if (isEmailCurrentAuthenticationMethod() || isLdapCurrentAuthenticationMethod()) { const currentAuthenticationMethod = getCurrentAuthenticationMethod();
if (enabled) { if (enabled && !isEmailCurrentAuthenticationMethod() && !isLdapCurrentAuthenticationMethod()) {
config.set(LDAP_LOGIN_ENABLED, true);
await setCurrentAuthenticationMethod('ldap');
} else if (!enabled) {
config.set(LDAP_LOGIN_ENABLED, false);
await setCurrentAuthenticationMethod('email');
}
} else {
throw new InternalServerError( 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}`); 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 // Source Control
// ---------------------------------------- // ----------------------------------------

View File

@@ -154,6 +154,11 @@ export class FrontendService {
loginEnabled: false, loginEnabled: false,
loginLabel: '', loginLabel: '',
}, },
oidc: {
loginEnabled: false,
loginUrl: `${instanceBaseUrl}/${restEndpoint}/sso/oidc/login`,
callbackUrl: `${instanceBaseUrl}/${restEndpoint}/sso/oidc/callback`,
},
}, },
publicApi: { publicApi: {
enabled: isApiEnabled(), enabled: isApiEnabled(),
@@ -189,6 +194,7 @@ export class FrontendService {
sharing: false, sharing: false,
ldap: false, ldap: false,
saml: false, saml: false,
oidc: false,
logStreaming: false, logStreaming: false,
advancedExecutionFilters: false, advancedExecutionFilters: false,
variables: false, variables: false,
@@ -319,6 +325,7 @@ export class FrontendService {
logStreaming: this.license.isLogStreamingEnabled(), logStreaming: this.license.isLogStreamingEnabled(),
ldap: this.license.isLdapEnabled(), ldap: this.license.isLdapEnabled(),
saml: this.license.isSamlEnabled(), saml: this.license.isSamlEnabled(),
oidc: this.licenseState.isOidcLicensed(),
advancedExecutionFilters: this.license.isAdvancedExecutionFiltersEnabled(), advancedExecutionFilters: this.license.isAdvancedExecutionFiltersEnabled(),
variables: this.license.isVariablesEnabled(), variables: this.license.isVariablesEnabled(),
sourceControl: this.license.isSourceControlLicensed(), 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()) { if (this.license.isVariablesEnabled()) {
this.settings.variables.limit = this.license.getVariablesLimit(); 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 { password, updatedAt, authIdentities, mfaRecoveryCodes, mfaSecret, ...rest } = user;
const ldapIdentity = authIdentities?.find((i) => i.providerType === 'ldap'); const providerType = authIdentities?.[0]?.providerType;
let publicUser: PublicUser = { let publicUser: PublicUser = {
...rest, ...rest,
signInType: ldapIdentity ? 'ldap' : 'email', signInType: providerType ?? 'email',
isOwner: user.role === 'global:owner', 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 // can only toggle between email and saml, not directly to e.g. ldap
export async function setSamlLoginEnabled(enabled: boolean): Promise<void> { export async function setSamlLoginEnabled(enabled: boolean): Promise<void> {
if (isEmailCurrentAuthenticationMethod() || isSamlCurrentAuthenticationMethod()) { const currentAuthenticationMethod = getCurrentAuthenticationMethod();
if (enabled) { if (enabled && !isEmailCurrentAuthenticationMethod() && !isSamlCurrentAuthenticationMethod()) {
config.set(SAML_LOGIN_ENABLED, true);
await setCurrentAuthenticationMethod('saml');
} else if (!enabled) {
config.set(SAML_LOGIN_ENABLED, false);
await setCurrentAuthenticationMethod('email');
}
} else {
throw new InternalServerError( 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 { export function setSamlLoginLabel(label: string): void {

View File

@@ -35,6 +35,10 @@ export function isLdapCurrentAuthenticationMethod(): boolean {
return getCurrentAuthenticationMethod() === 'ldap'; return getCurrentAuthenticationMethod() === 'ldap';
} }
export function isOidcCurrentAuthenticationMethod(): boolean {
return getCurrentAuthenticationMethod() === 'oidc';
}
export function isEmailCurrentAuthenticationMethod(): boolean { export function isEmailCurrentAuthenticationMethod(): boolean {
return getCurrentAuthenticationMethod() === 'email'; 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 <N8nActionToggle
v-if=" v-if="
!user.isOwner && !user.isOwner &&
user.signInType !== 'ldap' && !['ldap'].includes(user.signInType) &&
!readonly && !readonly &&
getActions(user).length > 0 && getActions(user).length > 0 &&
actions.length > 0 actions.length > 0

View File

@@ -929,6 +929,7 @@
"forgotPassword.returnToSignIn": "Back to sign in", "forgotPassword.returnToSignIn": "Back to sign in",
"forgotPassword.sendingEmailError": "Problem sending email", "forgotPassword.sendingEmailError": "Problem sending email",
"forgotPassword.ldapUserPasswordResetUnavailable": "Please contact your LDAP administrator to reset your password", "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.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.", "forgotPassword.tooManyRequests": "Youve reached the password reset limit. Please try again in a few minutes.",
"forms.resourceFiltersDropdown.filters": "Filters", "forms.resourceFiltersDropdown.filters": "Filters",
@@ -2716,6 +2717,7 @@
"settings.ldap.confirmMessage.beforeSaveForm.confirmButtonText": "Yes, disable it", "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.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.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.title": "Available on the Enterprise plan",
"settings.ldap.disabled.description": "LDAP is available as a paid feature. Learn more about it.", "settings.ldap.disabled.description": "LDAP is available as a paid feature. Learn more about it.",
"settings.ldap.disabled.buttonText": "See plans", "settings.ldap.disabled.buttonText": "See plans",
@@ -2775,8 +2777,8 @@
"settings.sso": "SSO", "settings.sso": "SSO",
"settings.sso.title": "Single Sign On", "settings.sso.title": "Single Sign On",
"settings.sso.subtitle": "SAML 2.0 Configuration", "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": "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 2.0.", "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.activation.tooltip": "You need to save the settings first before activating SAML",
"settings.sso.activated": "Activated", "settings.sso.activated": "Activated",
"settings.sso.deactivated": "Deactivated", "settings.sso.deactivated": "Deactivated",
@@ -2804,6 +2806,8 @@
"settings.sso.actionBox.title": "Available on the Enterprise plan", "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.description": "Use Single Sign On to consolidate authentication into a single platform to improve security and agility.",
"settings.sso.actionBox.buttonText": "See plans", "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.secret": "Secret {secret}",
"settings.mfa": "MFA", "settings.mfa": "MFA",
"settings.mfa.title": "Multi-factor Authentication", "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 type { IRestApiContext } from '../types';
import { makeRestApiRequest } from '../utils'; import { makeRestApiRequest } from '../utils';
@@ -39,3 +39,18 @@ export const toggleSamlConfig = async (
export const testSamlConfig = async (context: IRestApiContext): Promise<string> => { export const testSamlConfig = async (context: IRestApiContext): Promise<string> => {
return await makeRestApiRequest(context, 'GET', '/sso/saml/config/test'); 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', Email = 'email',
Ldap = 'ldap', Ldap = 'ldap',
Saml = 'saml', Saml = 'saml',
Oidc = 'oidc',
} }
export interface IPermissionGroup { export interface IPermissionGroup {
@@ -1423,6 +1424,7 @@ export type EnterpriseEditionFeatureKey =
| 'LogStreaming' | 'LogStreaming'
| 'Variables' | 'Variables'
| 'Saml' | 'Saml'
| 'Oidc'
| 'SourceControl' | 'SourceControl'
| 'ExternalSecrets' | 'ExternalSecrets'
| 'AuditLogs' | 'AuditLogs'

View File

@@ -24,6 +24,7 @@ export const defaultSettings: FrontendSettings = {
enterprise: { enterprise: {
sharing: false, sharing: false,
ldap: false, ldap: false,
oidc: false,
saml: false, saml: false,
logStreaming: false, logStreaming: false,
debugInEditor: false, debugInEditor: false,
@@ -78,6 +79,7 @@ export const defaultSettings: FrontendSettings = {
sso: { sso: {
ldap: { loginEnabled: false, loginLabel: '' }, ldap: { loginEnabled: false, loginLabel: '' },
saml: { loginEnabled: false, loginLabel: '' }, saml: { loginEnabled: false, loginLabel: '' },
oidc: { loginEnabled: false, loginUrl: '', callbackUrl: '' },
}, },
telemetry: { telemetry: {
enabled: false, enabled: false,

View File

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

View File

@@ -2,14 +2,19 @@
import { useSSOStore } from '@/stores/sso.store'; import { useSSOStore } from '@/stores/sso.store';
import { useI18n } from '@n8n/i18n'; import { useI18n } from '@n8n/i18n';
import { useToast } from '@/composables/useToast'; import { useToast } from '@/composables/useToast';
import { useSettingsStore } from '@/stores/settings.store';
const i18n = useI18n(); const i18n = useI18n();
const ssoStore = useSSOStore(); const ssoStore = useSSOStore();
const toast = useToast(); const toast = useToast();
const settingsStore = useSettingsStore();
const onSSOLogin = async () => { const onSSOLogin = async () => {
try { 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) { } catch (error) {
toast.showError(error, 'Error', error.message); toast.showError(error, 'Error', error.message);
} }

View File

@@ -644,6 +644,7 @@ export const EnterpriseEditionFeature: Record<
LogStreaming: 'logStreaming', LogStreaming: 'logStreaming',
Variables: 'variables', Variables: 'variables',
Saml: 'saml', Saml: 'saml',
Oidc: 'oidc',
SourceControl: 'sourceControl', SourceControl: 'sourceControl',
ExternalSecrets: 'externalSecrets', ExternalSecrets: 'externalSecrets',
AuditLogs: 'auditLogs', AuditLogs: 'auditLogs',
@@ -698,6 +699,7 @@ export const CURL_IMPORT_NODES_PROTOCOLS: { [key: string]: string } = {
export const enum SignInType { export const enum SignInType {
LDAP = 'ldap', LDAP = 'ldap',
EMAIL = 'email', EMAIL = 'email',
OIDC = 'oidc',
} }
export const N8N_SALES_EMAIL = 'sales@n8n.io'; export const N8N_SALES_EMAIL = 'sales@n8n.io';

View File

@@ -17,6 +17,7 @@ describe('permissions', () => {
ldap: {}, ldap: {},
license: {}, license: {},
logStreaming: {}, logStreaming: {},
oidc: {},
orchestration: {}, orchestration: {},
project: {}, project: {},
saml: {}, saml: {},
@@ -93,6 +94,7 @@ describe('permissions', () => {
read: true, read: true,
}, },
saml: {}, saml: {},
oidc: {},
securityAudit: {}, securityAudit: {},
sourceControl: {}, sourceControl: {},
tag: { tag: {

View File

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

View File

@@ -46,6 +46,7 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
}); });
const ldap = ref({ loginLabel: '', loginEnabled: false }); const ldap = ref({ loginLabel: '', loginEnabled: false });
const saml = ref({ loginLabel: '', loginEnabled: false }); const saml = ref({ loginLabel: '', loginEnabled: false });
const oidc = ref({ loginEnabled: false, loginUrl: '', callbackUrl: '' });
const mfa = ref({ enabled: false }); const mfa = ref({ enabled: false });
const folders = 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 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 isAiAssistantEnabled = computed(() => settings.value.aiAssistant?.enabled);
const isAskAiEnabled = computed(() => settings.value.askAi?.enabled); const isAskAiEnabled = computed(() => settings.value.askAi?.enabled);
@@ -181,6 +186,10 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
() => userManagement.value.authenticationMethod === UserManagementAuthenticationMethod.Saml, () => userManagement.value.authenticationMethod === UserManagementAuthenticationMethod.Saml,
); );
const isDefaultAuthenticationOidc = computed(
() => userManagement.value.authenticationMethod === UserManagementAuthenticationMethod.Oidc,
);
const permanentlyDismissedBanners = computed(() => settings.value.banners?.dismissed ?? []); const permanentlyDismissedBanners = computed(() => settings.value.banners?.dismissed ?? []);
const isBelowUserQuota = computed( const isBelowUserQuota = computed(
@@ -210,6 +219,12 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
saml.value.loginLabel = settings.value.sso.saml.loginLabel; 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; mfa.value.enabled = settings.value.mfa?.enabled;
folders.value.enabled = settings.value.folders?.enabled; folders.value.enabled = settings.value.folders?.enabled;
@@ -420,6 +435,7 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
isLdapLoginEnabled, isLdapLoginEnabled,
ldapLoginLabel, ldapLoginLabel,
isSamlLoginEnabled, isSamlLoginEnabled,
isOidcLoginEnabled,
showSetupPage, showSetupPage,
deploymentType, deploymentType,
isCloudDeployment, isCloudDeployment,
@@ -444,6 +460,7 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
isMultiMain, isMultiMain,
isWorkerViewAvailable, isWorkerViewAvailable,
isDefaultAuthenticationSaml, isDefaultAuthenticationSaml,
isDefaultAuthenticationOidc,
workflowCallerPolicyDefaultOption, workflowCallerPolicyDefaultOption,
permanentlyDismissedBanners, permanentlyDismissedBanners,
isBelowUserQuota, isBelowUserQuota,
@@ -456,6 +473,8 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
isAiCreditsEnabled, isAiCreditsEnabled,
aiCreditsQuota, aiCreditsQuota,
experimental__minZoomNodeSettingsInCanvas, experimental__minZoomNodeSettingsInCanvas,
partialExecutionVersion,
oidcCallBackUrl,
reset, reset,
testLdapConnection, testLdapConnection,
getLdapConfig, getLdapConfig,
@@ -470,6 +489,5 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
getSettings, getSettings,
setSettings, setSettings,
initialize, 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 { computed, reactive } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
@@ -17,17 +18,13 @@ export const useSSOStore = defineStore('sso', () => {
const route = useRoute(); const route = useRoute();
const state = reactive({ const state = reactive({
loading: false,
samlConfig: undefined as (SamlPreferences & SamlPreferencesExtractedData) | undefined, samlConfig: undefined as (SamlPreferences & SamlPreferencesExtractedData) | undefined,
oidcConfig: undefined as OidcConfigDto | undefined,
}); });
const isLoading = computed(() => state.loading);
const samlConfig = computed(() => state.samlConfig); const samlConfig = computed(() => state.samlConfig);
const setLoading = (loading: boolean) => { const oidcConfig = computed(() => state.oidcConfig);
state.loading = loading;
};
const isSamlLoginEnabled = computed({ const isSamlLoginEnabled = computed({
get: () => settingsStore.isSamlLoginEnabled, get: () => settingsStore.isSamlLoginEnabled,
@@ -45,15 +42,43 @@ export const useSSOStore = defineStore('sso', () => {
void toggleLoginEnabled(value); 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( const isEnterpriseSamlEnabled = computed(
() => settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.Saml], () => settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.Saml],
); );
const isEnterpriseOidcEnabled = computed(
() => settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.Oidc],
);
const isDefaultAuthenticationSaml = computed(() => settingsStore.isDefaultAuthenticationSaml); const isDefaultAuthenticationSaml = computed(() => settingsStore.isDefaultAuthenticationSaml);
const isDefaultAuthenticationOidc = computed(() => settingsStore.isDefaultAuthenticationOidc);
const showSsoLoginButton = computed( const showSsoLoginButton = computed(
() => () =>
isSamlLoginEnabled.value && (isSamlLoginEnabled.value &&
isEnterpriseSamlEnabled.value && isEnterpriseSamlEnabled.value &&
isDefaultAuthenticationSaml.value, isDefaultAuthenticationSaml.value) ||
(isOidcLoginEnabled.value &&
isEnterpriseOidcEnabled.value &&
isDefaultAuthenticationOidc.value),
); );
const getSSORedirectUrl = async () => const getSSORedirectUrl = async () =>
@@ -66,6 +91,9 @@ export const useSSOStore = defineStore('sso', () => {
await ssoApi.toggleSamlConfig(rootStore.restApiContext, { loginEnabled: enabled }); await ssoApi.toggleSamlConfig(rootStore.restApiContext, { loginEnabled: enabled });
const getSamlMetadata = async () => await ssoApi.getSamlMetadata(rootStore.restApiContext); const getSamlMetadata = async () => await ssoApi.getSamlMetadata(rootStore.restApiContext);
// const getOidcRedirectLUrl = async () => await ssoApi)
const getSamlConfig = async () => { const getSamlConfig = async () => {
const samlConfig = await ssoApi.getSamlConfig(rootStore.restApiContext); const samlConfig = await ssoApi.getSamlConfig(rootStore.restApiContext);
state.samlConfig = samlConfig; state.samlConfig = samlConfig;
@@ -83,20 +111,36 @@ export const useSSOStore = defineStore('sso', () => {
const userData = computed(() => usersStore.currentUser); 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 { return {
isLoading, isEnterpriseOidcEnabled,
setLoading,
isSamlLoginEnabled, isSamlLoginEnabled,
isOidcLoginEnabled,
isEnterpriseSamlEnabled, isEnterpriseSamlEnabled,
isDefaultAuthenticationSaml, isDefaultAuthenticationSaml,
isDefaultAuthenticationOidc,
showSsoLoginButton, showSsoLoginButton,
samlConfig, samlConfig,
oidcConfig,
userData,
getSSORedirectUrl, getSSORedirectUrl,
getSamlMetadata, getSamlMetadata,
getSamlConfig, getSamlConfig,
saveSamlConfig, saveSamlConfig,
testSamlConfig, testSamlConfig,
updateUser, updateUser,
userData, getOidcConfig,
saveOidcConfig,
}; };
}); });

View File

@@ -72,7 +72,9 @@ const isExternalAuthEnabled = computed((): boolean => {
settingsStore.settings.enterprise.ldap && currentUser.value?.signInType === 'ldap'; settingsStore.settings.enterprise.ldap && currentUser.value?.signInType === 'ldap';
const isSamlEnabled = const isSamlEnabled =
settingsStore.isSamlLoginEnabled && settingsStore.isDefaultAuthenticationSaml; 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 => { const isPersonalSecurityEnabled = computed((): boolean => {
return usersStore.isInstanceOwner || !isExternalAuthEnabled.value; return usersStore.isInstanceOwner || !isExternalAuthEnabled.value;

View File

@@ -65,6 +65,7 @@ describe('SettingsSso View', () => {
const pinia = createTestingPinia(); const pinia = createTestingPinia();
const ssoStore = mockedStore(useSSOStore); const ssoStore = mockedStore(useSSOStore);
ssoStore.isEnterpriseSamlEnabled = false; ssoStore.isEnterpriseSamlEnabled = false;
ssoStore.isEnterpriseOidcEnabled = false;
const pageRedirectionHelper = usePageRedirectionHelper(); const pageRedirectionHelper = usePageRedirectionHelper();
@@ -82,6 +83,7 @@ describe('SettingsSso View', () => {
const ssoStore = mockedStore(useSSOStore); const ssoStore = mockedStore(useSSOStore);
ssoStore.isEnterpriseSamlEnabled = true; ssoStore.isEnterpriseSamlEnabled = true;
ssoStore.isEnterpriseOidcEnabled = true;
ssoStore.getSamlConfig.mockResolvedValue(samlConfig); ssoStore.getSamlConfig.mockResolvedValue(samlConfig);
@@ -102,6 +104,7 @@ describe('SettingsSso View', () => {
const ssoStore = mockedStore(useSSOStore); const ssoStore = mockedStore(useSSOStore);
ssoStore.isEnterpriseSamlEnabled = true; ssoStore.isEnterpriseSamlEnabled = true;
ssoStore.isSamlLoginEnabled = false; ssoStore.isSamlLoginEnabled = false;
ssoStore.isEnterpriseOidcEnabled = true;
ssoStore.getSamlConfig.mockResolvedValue(samlConfig); ssoStore.getSamlConfig.mockResolvedValue(samlConfig);
@@ -126,6 +129,7 @@ describe('SettingsSso View', () => {
const ssoStore = mockedStore(useSSOStore); const ssoStore = mockedStore(useSSOStore);
ssoStore.isEnterpriseSamlEnabled = true; ssoStore.isEnterpriseSamlEnabled = true;
ssoStore.isEnterpriseOidcEnabled = true;
const { getByTestId } = renderView({ pinia }); const { getByTestId } = renderView({ pinia });
@@ -163,6 +167,7 @@ describe('SettingsSso View', () => {
const ssoStore = mockedStore(useSSOStore); const ssoStore = mockedStore(useSSOStore);
ssoStore.isEnterpriseSamlEnabled = true; ssoStore.isEnterpriseSamlEnabled = true;
ssoStore.isEnterpriseOidcEnabled = true;
const { getByTestId } = renderView({ pinia }); const { getByTestId } = renderView({ pinia });
@@ -199,6 +204,7 @@ describe('SettingsSso View', () => {
const ssoStore = mockedStore(useSSOStore); const ssoStore = mockedStore(useSSOStore);
ssoStore.isEnterpriseSamlEnabled = true; ssoStore.isEnterpriseSamlEnabled = true;
ssoStore.isEnterpriseOidcEnabled = true;
const { getByTestId } = renderView({ pinia }); const { getByTestId } = renderView({ pinia });
@@ -228,6 +234,7 @@ describe('SettingsSso View', () => {
const ssoStore = mockedStore(useSSOStore); const ssoStore = mockedStore(useSSOStore);
ssoStore.isEnterpriseSamlEnabled = true; ssoStore.isEnterpriseSamlEnabled = true;
ssoStore.isEnterpriseOidcEnabled = true;
const { getByTestId } = renderView({ pinia }); const { getByTestId } = renderView({ pinia });
@@ -258,6 +265,8 @@ describe('SettingsSso View', () => {
const ssoStore = mockedStore(useSSOStore); const ssoStore = mockedStore(useSSOStore);
ssoStore.isEnterpriseSamlEnabled = true; ssoStore.isEnterpriseSamlEnabled = true;
ssoStore.isSamlLoginEnabled = true; ssoStore.isSamlLoginEnabled = true;
ssoStore.isEnterpriseOidcEnabled = true;
ssoStore.isOidcLoginEnabled = false;
const error = new Error('Request failed with status code 404'); const error = new Error('Request failed with status code 404');
ssoStore.getSamlConfig.mockRejectedValue(error); ssoStore.getSamlConfig.mockRejectedValue(error);

View File

@@ -9,17 +9,27 @@ import { useTelemetry } from '@/composables/useTelemetry';
import { useDocumentTitle } from '@/composables/useDocumentTitle'; import { useDocumentTitle } from '@/composables/useDocumentTitle';
import { useRootStore } from '@n8n/stores/useRootStore'; import { useRootStore } from '@n8n/stores/useRootStore';
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper'; 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 = { const IdentityProviderSettingsType = {
URL: 'url', URL: 'url',
XML: 'xml', XML: 'xml',
}; };
const SupportedProtocols = {
SAML: 'saml',
OIDC: 'oidc',
} as const;
const i18n = useI18n(); const i18n = useI18n();
const telemetry = useTelemetry(); const telemetry = useTelemetry();
const rootStore = useRootStore(); const rootStore = useRootStore();
const ssoStore = useSSOStore(); const ssoStore = useSSOStore();
const message = useMessage(); const message = useMessage();
const settingsStore = useSettingsStore();
const toast = useToast(); const toast = useToast();
const documentTitle = useDocumentTitle(); const documentTitle = useDocumentTitle();
const pageRedirectionHelper = usePageRedirectionHelper(); const pageRedirectionHelper = usePageRedirectionHelper();
@@ -29,11 +39,24 @@ const ssoActivatedLabel = computed(() =>
? i18n.baseText('settings.sso.activated') ? i18n.baseText('settings.sso.activated')
: i18n.baseText('settings.sso.deactivated'), : 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 ssoSettingsSaved = ref(false);
const redirectUrl = ref();
const entityId = ref(); const entityId = ref();
const clientId = ref('');
const clientSecret = ref('');
const discoveryEndpoint = ref('');
const authProtocol = ref<SupportedProtocolType>(SupportedProtocols.SAML);
const ipsOptions = ref([ const ipsOptions = ref([
{ {
label: i18n.baseText('settings.sso.settings.ips.options.url'), label: i18n.baseText('settings.sso.settings.ips.options.url'),
@@ -49,6 +72,8 @@ const ipsType = ref(IdentityProviderSettingsType.URL);
const metadataUrl = ref(); const metadataUrl = ref();
const metadata = ref(); const metadata = ref();
const redirectUrl = ref();
const isSaveEnabled = computed(() => { const isSaveEnabled = computed(() => {
if (ipsType.value === IdentityProviderSettingsType.URL) { if (ipsType.value === IdentityProviderSettingsType.URL) {
return !!metadataUrl.value && metadataUrl.value !== ssoStore.samlConfig?.metadataUrl; return !!metadataUrl.value && metadataUrl.value !== ssoStore.samlConfig?.metadataUrl;
@@ -67,6 +92,17 @@ const isTestEnabled = computed(() => {
return false; return false;
}); });
async function loadSamlConfig() {
if (!ssoStore.isEnterpriseSamlEnabled) {
return;
}
try {
await getSamlConfig();
} catch (error) {
toast.showError(error, 'error');
}
}
const getSamlConfig = async () => { const getSamlConfig = async () => {
const config = await ssoStore.getSamlConfig(); const config = await ssoStore.getSamlConfig();
@@ -167,39 +203,79 @@ const isToggleSsoDisabled = computed(() => {
onMounted(async () => { onMounted(async () => {
documentTitle.set(i18n.baseText('settings.sso.title')); 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; return;
} }
try { try {
await getSamlConfig(); await getOidcConfig();
} catch (error) { } catch (error) {
toast.showError(error, '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> </script>
<template> <template>
<div class="pb-3xl"> <div class="pb-2xl">
<n8n-heading size="2xlarge">{{ i18n.baseText('settings.sso.title') }}</n8n-heading> <div :class="$style.heading">
<div :class="$style.top"> <n8n-heading size="2xlarge">{{ i18n.baseText('settings.sso.title') }}</n8n-heading>
<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> </div>
<n8n-info-tip> <n8n-info-tip>
{{ i18n.baseText('settings.sso.info') }} {{ i18n.baseText('settings.sso.info') }}
@@ -209,67 +285,173 @@ onMounted(async () => {
</n8n-info-tip> </n8n-info-tip>
<div v-if="ssoStore.isEnterpriseSamlEnabled" data-test-id="sso-content-licensed"> <div v-if="ssoStore.isEnterpriseSamlEnabled" data-test-id="sso-content-licensed">
<div :class="$style.group"> <div :class="$style.group">
<label>{{ i18n.baseText('settings.sso.settings.redirectUrl.label') }}</label> <label>Select Authentication Protocol</label>
<CopyInput <div>
:value="redirectUrl" <N8nSelect
:copy-button-text="i18n.baseText('generic.clickToCopy')" filterable
:toast-title="i18n.baseText('settings.sso.settings.redirectUrl.copied')" :model-value="authProtocol"
/> :placeholder="i18n.baseText('parameterInput.select')"
<small>{{ i18n.baseText('settings.sso.settings.redirectUrl.help') }}</small> @update:model-value="onAuthProtocolUpdated"
</div> @keydown.stop
<div :class="$style.group"> >
<label>{{ i18n.baseText('settings.sso.settings.entityId.label') }}</label> <N8nOption
<CopyInput v-for="protocol in Object.values(SupportedProtocols)"
:value="entityId" :key="protocol"
:copy-button-text="i18n.baseText('generic.clickToCopy')" :value="protocol"
:toast-title="i18n.baseText('settings.sso.settings.entityId.copied')" :label="protocol.toUpperCase()"
/> data-test-id="credential-select-option"
<small>{{ i18n.baseText('settings.sso.settings.entityId.help') }}</small> >
</div> </N8nOption>
<div :class="$style.group"> </N8nSelect>
<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>
<div v-show="ipsType === IdentityProviderSettingsType.URL"> </div>
<n8n-input <div v-if="authProtocol === SupportedProtocols.SAML">
v-model="metadataUrl" <div :class="$style.group">
type="text" <label>{{ i18n.baseText('settings.sso.settings.redirectUrl.label') }}</label>
name="metadataUrl" <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" size="large"
:placeholder="i18n.baseText('settings.sso.settings.ips.url.placeholder')" data-test-id="sso-save"
data-test-id="sso-provider-url" @click="onSave"
/> >
<small>{{ i18n.baseText('settings.sso.settings.ips.url.help') }}</small> {{ 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>
<div v-show="ipsType === IdentityProviderSettingsType.XML">
<n8n-input <footer :class="$style.footer">
v-model="metadata" {{ i18n.baseText('settings.sso.settings.footer.hint') }}
type="textarea" </footer>
name="metadata" </div>
:rows="4" <div v-if="authProtocol === SupportedProtocols.OIDC">
data-test-id="sso-provider-xml" <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> </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> </div>
<n8n-action-box <n8n-action-box
v-else v-else
@@ -287,11 +469,8 @@ onMounted(async () => {
</template> </template>
<style lang="scss" module> <style lang="scss" module>
.top { .heading {
display: flex; margin-bottom: var(--spacing-s);
align-items: center;
justify-content: space-between;
padding: var(--spacing-2xl) 0 var(--spacing-xl);
} }
.switch { .switch {

170
pnpm-lock.yaml generated
View File

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