mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-15 17:16:45 +00:00
feat(core): Add OIDC support for SSO (#15988)
Co-authored-by: Andreas Fitzek <andreas.fitzek@n8n.io>
This commit is contained in:
@@ -13,6 +13,17 @@ const tsJestOptions = {
|
||||
|
||||
const isCoverageEnabled = process.env.COVERAGE_ENABLED === 'true';
|
||||
|
||||
const esmDependencies = [
|
||||
'pdfjs-dist',
|
||||
'openid-client',
|
||||
'oauth4webapi',
|
||||
'jose',
|
||||
// Add other ESM dependencies that need to be transformed here
|
||||
];
|
||||
|
||||
const esmDependenciesPattern = esmDependencies.join('|');
|
||||
const esmDependenciesRegex = `node_modules/(${esmDependenciesPattern})/.+\\.m?js$`;
|
||||
|
||||
/** @type {import('jest').Config} */
|
||||
const config = {
|
||||
verbose: true,
|
||||
@@ -21,7 +32,7 @@ const config = {
|
||||
testPathIgnorePatterns: ['/dist/', '/node_modules/'],
|
||||
transform: {
|
||||
'^.+\\.ts$': ['ts-jest', tsJestOptions],
|
||||
'node_modules/pdfjs-dist/.+\\.mjs$': [
|
||||
[esmDependenciesRegex]: [
|
||||
'babel-jest',
|
||||
{
|
||||
presets: ['@babel/preset-env'],
|
||||
@@ -29,7 +40,7 @@ const config = {
|
||||
},
|
||||
],
|
||||
},
|
||||
transformIgnorePatterns: ['/dist/', '/node_modules/(?!.*pdfjs-dist/)'],
|
||||
transformIgnorePatterns: [`/node_modules/(?!${esmDependenciesPattern})/`],
|
||||
// This resolve the path mappings from the tsconfig relative to each jest.config.js
|
||||
moduleNameMapper: compilerOptions?.paths
|
||||
? pathsToModuleNameMapper(compilerOptions.paths, {
|
||||
|
||||
@@ -72,3 +72,5 @@ export { InsightsDateFilterDto } from './insights/date-filter.dto';
|
||||
|
||||
export { PaginationDto } from './pagination/pagination.dto';
|
||||
export { UsersListFilterDto } from './user/users-list-filter.dto';
|
||||
|
||||
export { OidcConfigDto } from './oidc/config.dto';
|
||||
|
||||
9
packages/@n8n/api-types/src/dto/oidc/config.dto.ts
Normal file
9
packages/@n8n/api-types/src/dto/oidc/config.dto.ts
Normal 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),
|
||||
}) {}
|
||||
@@ -18,7 +18,7 @@ export interface ITelemetrySettings {
|
||||
config?: ITelemetryClientConfig;
|
||||
}
|
||||
|
||||
export type AuthenticationMethod = 'email' | 'ldap' | 'saml';
|
||||
export type AuthenticationMethod = 'email' | 'ldap' | 'saml' | 'oidc';
|
||||
|
||||
export interface IUserManagementSettings {
|
||||
quota: number;
|
||||
@@ -84,6 +84,11 @@ export interface FrontendSettings {
|
||||
loginLabel: string;
|
||||
loginEnabled: boolean;
|
||||
};
|
||||
oidc: {
|
||||
loginEnabled: boolean;
|
||||
loginUrl: string;
|
||||
callbackUrl: string;
|
||||
};
|
||||
ldap: {
|
||||
loginLabel: string;
|
||||
loginEnabled: boolean;
|
||||
@@ -129,6 +134,7 @@ export interface FrontendSettings {
|
||||
sharing: boolean;
|
||||
ldap: boolean;
|
||||
saml: boolean;
|
||||
oidc: boolean;
|
||||
logStreaming: boolean;
|
||||
advancedExecutionFilters: boolean;
|
||||
variables: boolean;
|
||||
|
||||
@@ -58,6 +58,10 @@ export class LicenseState {
|
||||
return this.isLicensed('feat:saml');
|
||||
}
|
||||
|
||||
isOidcLicensed() {
|
||||
return this.isLicensed('feat:oidc');
|
||||
}
|
||||
|
||||
isApiKeyScopesLicensed() {
|
||||
return this.isLicensed('feat:apiKeyScopes');
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ export const LICENSE_FEATURES = {
|
||||
SHARING: 'feat:sharing',
|
||||
LDAP: 'feat:ldap',
|
||||
SAML: 'feat:saml',
|
||||
OIDC: 'feat:oidc',
|
||||
LOG_STREAMING: 'feat:logStreaming',
|
||||
ADVANCED_EXECUTION_FILTERS: 'feat:advancedExecutionFilters',
|
||||
VARIABLES: 'feat:variables',
|
||||
|
||||
@@ -269,7 +269,7 @@ export const enum StatisticsNames {
|
||||
dataLoaded = 'data_loaded',
|
||||
}
|
||||
|
||||
export type AuthProviderType = 'ldap' | 'email' | 'saml'; // | 'google';
|
||||
export type AuthProviderType = 'ldap' | 'email' | 'saml' | 'oidc'; // | 'google';
|
||||
|
||||
export type FolderWithWorkflowAndSubFolderCount = Folder & {
|
||||
workflowCount?: boolean;
|
||||
|
||||
@@ -25,6 +25,7 @@ export const RESOURCES = {
|
||||
workflow: ['share', 'execute', 'move', ...DEFAULT_OPERATIONS] as const,
|
||||
folder: [...DEFAULT_OPERATIONS, 'move'] as const,
|
||||
insights: ['list'] as const,
|
||||
oidc: ['manage'] as const,
|
||||
} as const;
|
||||
|
||||
export const API_KEY_RESOURCES = {
|
||||
|
||||
@@ -77,6 +77,7 @@ export const GLOBAL_OWNER_SCOPES: Scope[] = [
|
||||
'project:delete',
|
||||
'insights:list',
|
||||
'folder:move',
|
||||
'oidc:manage',
|
||||
];
|
||||
|
||||
export const GLOBAL_ADMIN_SCOPES = GLOBAL_OWNER_SCOPES.concat();
|
||||
|
||||
@@ -152,6 +152,7 @@
|
||||
"nodemailer": "6.9.9",
|
||||
"oauth-1.0a": "2.2.6",
|
||||
"open": "7.4.2",
|
||||
"openid-client": "6.5.0",
|
||||
"otpauth": "9.1.1",
|
||||
"p-cancelable": "2.1.1",
|
||||
"p-lazy": "3.1.0",
|
||||
|
||||
@@ -204,6 +204,13 @@ export const schema = {
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
oidc: {
|
||||
loginEnabled: {
|
||||
format: Boolean,
|
||||
default: false,
|
||||
doc: 'Whether to enable OIDC SSO.',
|
||||
},
|
||||
},
|
||||
ldap: {
|
||||
loginEnabled: {
|
||||
format: Boolean,
|
||||
|
||||
@@ -105,6 +105,7 @@ export class E2EController {
|
||||
[LICENSE_FEATURES.INSIGHTS_VIEW_DASHBOARD]: false,
|
||||
[LICENSE_FEATURES.INSIGHTS_VIEW_HOURLY_DATA]: false,
|
||||
[LICENSE_FEATURES.API_KEY_SCOPES]: false,
|
||||
[LICENSE_FEATURES.OIDC]: false,
|
||||
};
|
||||
|
||||
private static readonly numericFeaturesDefaults: Record<NumericLicenseFeature, number> = {
|
||||
|
||||
@@ -22,6 +22,11 @@ import { AuthenticatedRequest, MeRequest } from '@/requests';
|
||||
import { PasswordUtility } from '@/services/password.utility';
|
||||
import { UserService } from '@/services/user.service';
|
||||
import { isSamlLicensedAndEnabled } from '@/sso.ee/saml/saml-helpers';
|
||||
import {
|
||||
getCurrentAuthenticationMethod,
|
||||
isLdapCurrentAuthenticationMethod,
|
||||
isOidcCurrentAuthenticationMethod,
|
||||
} from '@/sso.ee/sso-helpers';
|
||||
|
||||
import { PersonalizationSurveyAnswersV4 } from './survey-answers.dto';
|
||||
@RestController('/me')
|
||||
@@ -46,10 +51,34 @@ export class MeController {
|
||||
res: Response,
|
||||
@Body payload: UserUpdateRequestDto,
|
||||
): Promise<PublicUser> {
|
||||
const { id: userId, email: currentEmail, mfaEnabled } = req.user;
|
||||
const {
|
||||
id: userId,
|
||||
email: currentEmail,
|
||||
mfaEnabled,
|
||||
firstName: currentFirstName,
|
||||
lastName: currentLastName,
|
||||
} = req.user;
|
||||
|
||||
const { email } = payload;
|
||||
const { email, firstName, lastName } = payload;
|
||||
const isEmailBeingChanged = email !== currentEmail;
|
||||
const isFirstNameChanged = firstName !== currentFirstName;
|
||||
const isLastNameChanged = lastName !== currentLastName;
|
||||
|
||||
if (
|
||||
(isLdapCurrentAuthenticationMethod() || isOidcCurrentAuthenticationMethod()) &&
|
||||
(isEmailBeingChanged || isFirstNameChanged || isLastNameChanged)
|
||||
) {
|
||||
this.logger.debug(
|
||||
`Request to update user failed because ${getCurrentAuthenticationMethod()} user may not change their profile information`,
|
||||
{
|
||||
userId,
|
||||
payload,
|
||||
},
|
||||
);
|
||||
throw new BadRequestError(
|
||||
` ${getCurrentAuthenticationMethod()} user may not change their profile information`,
|
||||
);
|
||||
}
|
||||
|
||||
// If SAML is enabled, we don't allow the user to change their email address
|
||||
if (isSamlLicensedAndEnabled() && isEmailBeingChanged) {
|
||||
|
||||
@@ -23,7 +23,10 @@ import { MfaService } from '@/mfa/mfa.service';
|
||||
import { AuthlessRequest } from '@/requests';
|
||||
import { PasswordUtility } from '@/services/password.utility';
|
||||
import { UserService } from '@/services/user.service';
|
||||
import { isSamlCurrentAuthenticationMethod } from '@/sso.ee/sso-helpers';
|
||||
import {
|
||||
isOidcCurrentAuthenticationMethod,
|
||||
isSamlCurrentAuthenticationMethod,
|
||||
} from '@/sso.ee/sso-helpers';
|
||||
import { UserManagementMailer } from '@/user-management/email';
|
||||
|
||||
@RestController()
|
||||
@@ -76,17 +79,15 @@ export class PasswordResetController {
|
||||
}
|
||||
|
||||
if (
|
||||
isSamlCurrentAuthenticationMethod() &&
|
||||
!(
|
||||
user &&
|
||||
(hasGlobalScope(user, 'user:resetPassword') || user.settings?.allowSSOManualLogin === true)
|
||||
)
|
||||
(isSamlCurrentAuthenticationMethod() || isOidcCurrentAuthenticationMethod()) &&
|
||||
!(hasGlobalScope(user, 'user:resetPassword') || user.settings?.allowSSOManualLogin === true)
|
||||
) {
|
||||
const currentAuthenticationMethod = isSamlCurrentAuthenticationMethod() ? 'SAML' : 'OIDC';
|
||||
this.logger.debug(
|
||||
'Request to send password reset email failed because login is handled by SAML',
|
||||
`Request to send password reset email failed because login is handled by ${currentAuthenticationMethod}`,
|
||||
);
|
||||
throw new ForbiddenError(
|
||||
'Login is handled by SAML. Please contact your Identity Provider to reset your password.',
|
||||
`Login is handled by ${currentAuthenticationMethod}. Please contact your Identity Provider to reset your password.`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ export class LdapService {
|
||||
throw new UnexpectedError(message);
|
||||
}
|
||||
|
||||
if (ldapConfig.loginEnabled && getCurrentAuthenticationMethod() === 'saml') {
|
||||
if (ldapConfig.loginEnabled && ['saml', 'oidc'].includes(getCurrentAuthenticationMethod())) {
|
||||
throw new BadRequestError('LDAP cannot be enabled if SSO in enabled');
|
||||
}
|
||||
|
||||
@@ -146,19 +146,19 @@ export class LdapService {
|
||||
|
||||
/** Set the LDAP login enabled to the configuration object */
|
||||
private async setLdapLoginEnabled(enabled: boolean): Promise<void> {
|
||||
if (isEmailCurrentAuthenticationMethod() || isLdapCurrentAuthenticationMethod()) {
|
||||
if (enabled) {
|
||||
config.set(LDAP_LOGIN_ENABLED, true);
|
||||
await setCurrentAuthenticationMethod('ldap');
|
||||
} else if (!enabled) {
|
||||
config.set(LDAP_LOGIN_ENABLED, false);
|
||||
await setCurrentAuthenticationMethod('email');
|
||||
}
|
||||
} else {
|
||||
const currentAuthenticationMethod = getCurrentAuthenticationMethod();
|
||||
if (enabled && !isEmailCurrentAuthenticationMethod() && !isLdapCurrentAuthenticationMethod()) {
|
||||
throw new InternalServerError(
|
||||
`Cannot switch LDAP login enabled state when an authentication method other than email or ldap is active (current: ${getCurrentAuthenticationMethod()})`,
|
||||
`Cannot switch LDAP login enabled state when an authentication method other than email or ldap is active (current: ${currentAuthenticationMethod})`,
|
||||
);
|
||||
}
|
||||
|
||||
config.set(LDAP_LOGIN_ENABLED, enabled);
|
||||
|
||||
const targetAuthenticationMethod =
|
||||
!enabled && currentAuthenticationMethod === 'ldap' ? 'email' : currentAuthenticationMethod;
|
||||
|
||||
await setCurrentAuthenticationMethod(enabled ? 'ldap' : targetAuthenticationMethod);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -149,6 +149,20 @@ export class Server extends AbstractServer {
|
||||
this.logger.warn(`SAML initialization failed: ${(error as Error).message}`);
|
||||
}
|
||||
|
||||
// ----------------------------------------
|
||||
// OIDC
|
||||
// ----------------------------------------
|
||||
|
||||
try {
|
||||
if (this.licenseState.isOidcLicensed()) {
|
||||
const { OidcService } = await import('@/sso.ee/oidc/oidc.service.ee');
|
||||
await Container.get(OidcService).init();
|
||||
await import('@/sso.ee/oidc/routes/oidc.controller.ee');
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.warn(`OIDC initialization failed: ${(error as Error).message}`);
|
||||
}
|
||||
|
||||
// ----------------------------------------
|
||||
// Source Control
|
||||
// ----------------------------------------
|
||||
|
||||
@@ -154,6 +154,11 @@ export class FrontendService {
|
||||
loginEnabled: false,
|
||||
loginLabel: '',
|
||||
},
|
||||
oidc: {
|
||||
loginEnabled: false,
|
||||
loginUrl: `${instanceBaseUrl}/${restEndpoint}/sso/oidc/login`,
|
||||
callbackUrl: `${instanceBaseUrl}/${restEndpoint}/sso/oidc/callback`,
|
||||
},
|
||||
},
|
||||
publicApi: {
|
||||
enabled: isApiEnabled(),
|
||||
@@ -189,6 +194,7 @@ export class FrontendService {
|
||||
sharing: false,
|
||||
ldap: false,
|
||||
saml: false,
|
||||
oidc: false,
|
||||
logStreaming: false,
|
||||
advancedExecutionFilters: false,
|
||||
variables: false,
|
||||
@@ -319,6 +325,7 @@ export class FrontendService {
|
||||
logStreaming: this.license.isLogStreamingEnabled(),
|
||||
ldap: this.license.isLdapEnabled(),
|
||||
saml: this.license.isSamlEnabled(),
|
||||
oidc: this.licenseState.isOidcLicensed(),
|
||||
advancedExecutionFilters: this.license.isAdvancedExecutionFiltersEnabled(),
|
||||
variables: this.license.isVariablesEnabled(),
|
||||
sourceControl: this.license.isSourceControlLicensed(),
|
||||
@@ -347,6 +354,12 @@ export class FrontendService {
|
||||
});
|
||||
}
|
||||
|
||||
if (this.licenseState.isOidcLicensed()) {
|
||||
Object.assign(this.settings.sso.oidc, {
|
||||
loginEnabled: config.getEnv('sso.oidc.loginEnabled'),
|
||||
});
|
||||
}
|
||||
|
||||
if (this.license.isVariablesEnabled()) {
|
||||
this.settings.variables.limit = this.license.getVariablesLimit();
|
||||
}
|
||||
|
||||
@@ -65,11 +65,11 @@ export class UserService {
|
||||
) {
|
||||
const { password, updatedAt, authIdentities, mfaRecoveryCodes, mfaSecret, ...rest } = user;
|
||||
|
||||
const ldapIdentity = authIdentities?.find((i) => i.providerType === 'ldap');
|
||||
const providerType = authIdentities?.[0]?.providerType;
|
||||
|
||||
let publicUser: PublicUser = {
|
||||
...rest,
|
||||
signInType: ldapIdentity ? 'ldap' : 'email',
|
||||
signInType: providerType ?? 'email',
|
||||
isOwner: user.role === 'global:owner',
|
||||
};
|
||||
|
||||
|
||||
4
packages/cli/src/sso.ee/oidc/constants.ts
Normal file
4
packages/cli/src/sso.ee/oidc/constants.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export const OIDC_PREFERENCES_DB_KEY = 'features.oidc';
|
||||
export const OIDC_LOGIN_ENABLED = 'sso.oidc.loginEnabled';
|
||||
export const OIDC_CLIENT_SECRET_REDACTED_VALUE =
|
||||
'__n8n_CLIENT_SECRET_VALUE_e5362baf-c777-4d57-a609-6eaf1f9e87f6';
|
||||
267
packages/cli/src/sso.ee/oidc/oidc.service.ee.ts
Normal file
267
packages/cli/src/sso.ee/oidc/oidc.service.ee.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
import type { OidcConfigDto } from '@n8n/api-types';
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
import { GlobalConfig } from '@n8n/config';
|
||||
import {
|
||||
AuthIdentity,
|
||||
AuthIdentityRepository,
|
||||
SettingsRepository,
|
||||
type User,
|
||||
UserRepository,
|
||||
} from '@n8n/db';
|
||||
import { Service } from '@n8n/di';
|
||||
import { Cipher } from 'n8n-core';
|
||||
import { jsonParse } from 'n8n-workflow';
|
||||
import * as client from 'openid-client';
|
||||
|
||||
import config from '@/config';
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
|
||||
import { InternalServerError } from '@/errors/response-errors/internal-server.error';
|
||||
import { UrlService } from '@/services/url.service';
|
||||
|
||||
import {
|
||||
OIDC_CLIENT_SECRET_REDACTED_VALUE,
|
||||
OIDC_LOGIN_ENABLED,
|
||||
OIDC_PREFERENCES_DB_KEY,
|
||||
} from './constants';
|
||||
import {
|
||||
getCurrentAuthenticationMethod,
|
||||
isEmailCurrentAuthenticationMethod,
|
||||
isOidcCurrentAuthenticationMethod,
|
||||
setCurrentAuthenticationMethod,
|
||||
} from '../sso-helpers';
|
||||
|
||||
const DEFAULT_OIDC_CONFIG: OidcConfigDto = {
|
||||
clientId: '',
|
||||
clientSecret: '',
|
||||
discoveryEndpoint: '',
|
||||
loginEnabled: false,
|
||||
};
|
||||
|
||||
type OidcRuntimeConfig = Pick<OidcConfigDto, 'clientId' | 'clientSecret' | 'loginEnabled'> & {
|
||||
discoveryEndpoint: URL;
|
||||
};
|
||||
|
||||
const DEFAULT_OIDC_RUNTIME_CONFIG: OidcRuntimeConfig = {
|
||||
...DEFAULT_OIDC_CONFIG,
|
||||
discoveryEndpoint: new URL('http://n8n.io/not-set'),
|
||||
};
|
||||
|
||||
@Service()
|
||||
export class OidcService {
|
||||
private oidcConfig: OidcRuntimeConfig = DEFAULT_OIDC_RUNTIME_CONFIG;
|
||||
|
||||
constructor(
|
||||
private readonly settingsRepository: SettingsRepository,
|
||||
private readonly authIdentityRepository: AuthIdentityRepository,
|
||||
private readonly urlService: UrlService,
|
||||
private readonly globalConfig: GlobalConfig,
|
||||
private readonly userRepository: UserRepository,
|
||||
private readonly cipher: Cipher,
|
||||
private readonly logger: Logger,
|
||||
) {}
|
||||
|
||||
async init() {
|
||||
this.oidcConfig = await this.loadConfig(true);
|
||||
console.log(`OIDC login is ${this.oidcConfig.loginEnabled ? 'enabled' : 'disabled'}.`);
|
||||
await this.setOidcLoginEnabled(this.oidcConfig.loginEnabled);
|
||||
}
|
||||
|
||||
getCallbackUrl(): string {
|
||||
return `${this.urlService.getInstanceBaseUrl()}/${this.globalConfig.endpoints.rest}/sso/oidc/callback`;
|
||||
}
|
||||
|
||||
getRedactedConfig(): OidcConfigDto {
|
||||
return {
|
||||
...this.oidcConfig,
|
||||
discoveryEndpoint: this.oidcConfig.discoveryEndpoint.toString(),
|
||||
clientSecret: OIDC_CLIENT_SECRET_REDACTED_VALUE,
|
||||
};
|
||||
}
|
||||
|
||||
async generateLoginUrl(): Promise<URL> {
|
||||
const configuration = await this.getOidcConfiguration();
|
||||
|
||||
const authorizationURL = client.buildAuthorizationUrl(configuration, {
|
||||
redirect_uri: this.getCallbackUrl(),
|
||||
response_type: 'code',
|
||||
scope: 'openid email profile',
|
||||
prompt: 'select_account',
|
||||
});
|
||||
|
||||
return authorizationURL;
|
||||
}
|
||||
|
||||
async loginUser(callbackUrl: URL): Promise<User> {
|
||||
const configuration = await this.getOidcConfiguration();
|
||||
|
||||
const tokens = await client.authorizationCodeGrant(configuration, callbackUrl);
|
||||
|
||||
const claims = tokens.claims();
|
||||
|
||||
if (!claims) {
|
||||
throw new ForbiddenError('No claims found in the OIDC token');
|
||||
}
|
||||
|
||||
const userInfo = await client.fetchUserInfo(configuration, tokens.access_token, claims.sub);
|
||||
|
||||
if (!userInfo.email) {
|
||||
throw new BadRequestError('An email is required');
|
||||
}
|
||||
|
||||
if (!userInfo.email_verified) {
|
||||
throw new BadRequestError('Email needs to be verified');
|
||||
}
|
||||
|
||||
const openidUser = await this.authIdentityRepository.findOne({
|
||||
where: { providerId: claims.sub, providerType: 'oidc' },
|
||||
relations: ['user'],
|
||||
});
|
||||
|
||||
if (openidUser) {
|
||||
return openidUser.user;
|
||||
}
|
||||
|
||||
const foundUser = await this.userRepository.findOneBy({ email: userInfo.email });
|
||||
|
||||
if (foundUser) {
|
||||
throw new BadRequestError('User already exist with that email.');
|
||||
}
|
||||
|
||||
return await this.userRepository.manager.transaction(async (trx) => {
|
||||
const { user } = await this.userRepository.createUserWithProject(
|
||||
{
|
||||
firstName: userInfo.given_name,
|
||||
lastName: userInfo.family_name,
|
||||
email: userInfo.email,
|
||||
authIdentities: [],
|
||||
role: 'global:member',
|
||||
password: 'no password set',
|
||||
},
|
||||
trx,
|
||||
);
|
||||
|
||||
await trx.save(
|
||||
trx.create(AuthIdentity, {
|
||||
providerId: claims.sub,
|
||||
providerType: 'oidc',
|
||||
userId: user.id,
|
||||
}),
|
||||
);
|
||||
|
||||
return user;
|
||||
});
|
||||
}
|
||||
|
||||
async loadConfig(decryptSecret = false): Promise<OidcRuntimeConfig> {
|
||||
const currentConfig = await this.settingsRepository.findOneBy({
|
||||
key: OIDC_PREFERENCES_DB_KEY,
|
||||
});
|
||||
|
||||
if (currentConfig) {
|
||||
try {
|
||||
const oidcConfig = jsonParse<OidcConfigDto>(currentConfig.value);
|
||||
const discoveryUrl = new URL(oidcConfig.discoveryEndpoint);
|
||||
|
||||
if (oidcConfig.clientSecret && decryptSecret) {
|
||||
oidcConfig.clientSecret = this.cipher.decrypt(oidcConfig.clientSecret);
|
||||
}
|
||||
return {
|
||||
...oidcConfig,
|
||||
discoveryEndpoint: discoveryUrl,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
'Failed to load OIDC configuration from database, falling back to default configuration.',
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
{ error },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await this.settingsRepository.save({
|
||||
key: OIDC_PREFERENCES_DB_KEY,
|
||||
value: JSON.stringify(DEFAULT_OIDC_CONFIG),
|
||||
loadOnStartup: true,
|
||||
});
|
||||
return DEFAULT_OIDC_RUNTIME_CONFIG;
|
||||
}
|
||||
|
||||
async updateConfig(newConfig: OidcConfigDto) {
|
||||
let discoveryEndpoint: URL;
|
||||
try {
|
||||
// Validating that discoveryEndpoint is a valid URL
|
||||
discoveryEndpoint = new URL(newConfig.discoveryEndpoint);
|
||||
} catch (error) {
|
||||
throw new BadRequestError('Provided discovery endpoint is not a valid URL');
|
||||
}
|
||||
if (newConfig.clientSecret === OIDC_CLIENT_SECRET_REDACTED_VALUE) {
|
||||
newConfig.clientSecret = this.oidcConfig.clientSecret;
|
||||
}
|
||||
await this.settingsRepository.update(
|
||||
{
|
||||
key: OIDC_PREFERENCES_DB_KEY,
|
||||
},
|
||||
{
|
||||
value: JSON.stringify({
|
||||
...newConfig,
|
||||
clientSecret: this.cipher.encrypt(newConfig.clientSecret),
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
// TODO: Discuss this in product
|
||||
// if (this.oidcConfig.loginEnabled && !newConfig.loginEnabled) {
|
||||
// await this.deleteAllOidcIdentities();
|
||||
// }
|
||||
|
||||
this.oidcConfig = {
|
||||
...newConfig,
|
||||
discoveryEndpoint,
|
||||
};
|
||||
|
||||
await this.setOidcLoginEnabled(this.oidcConfig.loginEnabled);
|
||||
}
|
||||
|
||||
private async setOidcLoginEnabled(enabled: boolean): Promise<void> {
|
||||
const currentAuthenticationMethod = getCurrentAuthenticationMethod();
|
||||
|
||||
if (enabled && !isEmailCurrentAuthenticationMethod() && !isOidcCurrentAuthenticationMethod()) {
|
||||
throw new InternalServerError(
|
||||
`Cannot switch OIDC login enabled state when an authentication method other than email or OIDC is active (current: ${currentAuthenticationMethod})`,
|
||||
);
|
||||
}
|
||||
|
||||
const targetAuthenticationMethod =
|
||||
!enabled && currentAuthenticationMethod === 'oidc' ? 'email' : currentAuthenticationMethod;
|
||||
|
||||
config.set(OIDC_LOGIN_ENABLED, enabled);
|
||||
await setCurrentAuthenticationMethod(enabled ? 'oidc' : targetAuthenticationMethod);
|
||||
}
|
||||
|
||||
private cachedOidcConfiguration:
|
||||
| {
|
||||
configuration: Promise<client.Configuration>;
|
||||
validTill: Date;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
private async getOidcConfiguration(): Promise<client.Configuration> {
|
||||
const now = Date.now();
|
||||
if (
|
||||
this.cachedOidcConfiguration === undefined ||
|
||||
now >= this.cachedOidcConfiguration.validTill.getTime()
|
||||
) {
|
||||
this.cachedOidcConfiguration = {
|
||||
configuration: client.discovery(
|
||||
this.oidcConfig.discoveryEndpoint,
|
||||
this.oidcConfig.clientId,
|
||||
this.oidcConfig.clientSecret,
|
||||
),
|
||||
validTill: new Date(Date.now() + 60 * 60 * 1000), // Cache for 1 hour
|
||||
};
|
||||
}
|
||||
|
||||
return await this.cachedOidcConfiguration.configuration;
|
||||
}
|
||||
}
|
||||
62
packages/cli/src/sso.ee/oidc/routes/oidc.controller.ee.ts
Normal file
62
packages/cli/src/sso.ee/oidc/routes/oidc.controller.ee.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { OidcConfigDto } from '@n8n/api-types';
|
||||
import { Body, Get, GlobalScope, Licensed, Post, RestController } from '@n8n/decorators';
|
||||
import { Request, Response } from 'express';
|
||||
|
||||
import { AuthService } from '@/auth/auth.service';
|
||||
import { AuthenticatedRequest } from '@/requests';
|
||||
import { UrlService } from '@/services/url.service';
|
||||
|
||||
import { OIDC_CLIENT_SECRET_REDACTED_VALUE } from '../constants';
|
||||
import { OidcService } from '../oidc.service.ee';
|
||||
|
||||
@RestController('/sso/oidc')
|
||||
export class OidcController {
|
||||
constructor(
|
||||
private readonly oidcService: OidcService,
|
||||
private readonly authService: AuthService,
|
||||
private readonly urlService: UrlService,
|
||||
) {}
|
||||
|
||||
@Get('/config')
|
||||
@Licensed('feat:oidc')
|
||||
@GlobalScope('oidc:manage')
|
||||
async retrieveConfiguration(_req: AuthenticatedRequest) {
|
||||
const config = await this.oidcService.loadConfig();
|
||||
if (config.clientSecret) {
|
||||
config.clientSecret = OIDC_CLIENT_SECRET_REDACTED_VALUE;
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
@Post('/config')
|
||||
@Licensed('feat:oidc')
|
||||
@GlobalScope('oidc:manage')
|
||||
async saveConfiguration(
|
||||
_req: AuthenticatedRequest,
|
||||
_res: Response,
|
||||
@Body payload: OidcConfigDto,
|
||||
) {
|
||||
await this.oidcService.updateConfig(payload);
|
||||
const config = this.oidcService.getRedactedConfig();
|
||||
return config;
|
||||
}
|
||||
|
||||
@Get('/login', { skipAuth: true })
|
||||
async redirectToAuthProvider(_req: Request, res: Response) {
|
||||
const authorizationURL = await this.oidcService.generateLoginUrl();
|
||||
|
||||
res.redirect(authorizationURL.toString());
|
||||
}
|
||||
|
||||
@Get('/callback', { skipAuth: true })
|
||||
async callbackHandler(req: Request, res: Response) {
|
||||
const fullUrl = `${this.urlService.getInstanceBaseUrl()}${req.originalUrl}`;
|
||||
const callbackUrl = new URL(fullUrl);
|
||||
|
||||
const user = await this.oidcService.loginUser(callbackUrl);
|
||||
|
||||
this.authService.issueCookie(res, user);
|
||||
|
||||
res.redirect('/');
|
||||
}
|
||||
}
|
||||
@@ -34,19 +34,18 @@ export function getSamlLoginLabel(): string {
|
||||
|
||||
// can only toggle between email and saml, not directly to e.g. ldap
|
||||
export async function setSamlLoginEnabled(enabled: boolean): Promise<void> {
|
||||
if (isEmailCurrentAuthenticationMethod() || isSamlCurrentAuthenticationMethod()) {
|
||||
if (enabled) {
|
||||
config.set(SAML_LOGIN_ENABLED, true);
|
||||
await setCurrentAuthenticationMethod('saml');
|
||||
} else if (!enabled) {
|
||||
config.set(SAML_LOGIN_ENABLED, false);
|
||||
await setCurrentAuthenticationMethod('email');
|
||||
}
|
||||
} else {
|
||||
const currentAuthenticationMethod = getCurrentAuthenticationMethod();
|
||||
if (enabled && !isEmailCurrentAuthenticationMethod() && !isSamlCurrentAuthenticationMethod()) {
|
||||
throw new InternalServerError(
|
||||
`Cannot switch SAML login enabled state when an authentication method other than email or saml is active (current: ${getCurrentAuthenticationMethod()})`,
|
||||
`Cannot switch SAML login enabled state when an authentication method other than email or saml is active (current: ${currentAuthenticationMethod})`,
|
||||
);
|
||||
}
|
||||
|
||||
const targetAuthenticationMethod =
|
||||
!enabled && currentAuthenticationMethod === 'saml' ? 'email' : currentAuthenticationMethod;
|
||||
|
||||
config.set(SAML_LOGIN_ENABLED, enabled);
|
||||
await setCurrentAuthenticationMethod(enabled ? 'saml' : targetAuthenticationMethod);
|
||||
}
|
||||
|
||||
export function setSamlLoginLabel(label: string): void {
|
||||
|
||||
@@ -35,6 +35,10 @@ export function isLdapCurrentAuthenticationMethod(): boolean {
|
||||
return getCurrentAuthenticationMethod() === 'ldap';
|
||||
}
|
||||
|
||||
export function isOidcCurrentAuthenticationMethod(): boolean {
|
||||
return getCurrentAuthenticationMethod() === 'oidc';
|
||||
}
|
||||
|
||||
export function isEmailCurrentAuthenticationMethod(): boolean {
|
||||
return getCurrentAuthenticationMethod() === 'email';
|
||||
}
|
||||
|
||||
380
packages/cli/test/integration/oidc/oidc.service.ee.test.ts
Normal file
380
packages/cli/test/integration/oidc/oidc.service.ee.test.ts
Normal file
@@ -0,0 +1,380 @@
|
||||
const discoveryMock = jest.fn();
|
||||
const authorizationCodeGrantMock = jest.fn();
|
||||
const fetchUserInfoMock = jest.fn();
|
||||
|
||||
jest.mock('openid-client', () => ({
|
||||
...jest.requireActual('openid-client'),
|
||||
discovery: discoveryMock,
|
||||
authorizationCodeGrant: authorizationCodeGrantMock,
|
||||
fetchUserInfo: fetchUserInfoMock,
|
||||
}));
|
||||
|
||||
import type { OidcConfigDto } from '@n8n/api-types';
|
||||
import { type User, UserRepository } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import type * as mocked_oidc_client from 'openid-client';
|
||||
const real_odic_client = jest.requireActual('openid-client');
|
||||
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
|
||||
import { OIDC_CLIENT_SECRET_REDACTED_VALUE } from '@/sso.ee/oidc/constants';
|
||||
import { OidcService } from '@/sso.ee/oidc/oidc.service.ee';
|
||||
import { createUser } from '@test-integration/db/users';
|
||||
|
||||
import * as testDb from '../shared/test-db';
|
||||
|
||||
beforeAll(async () => {
|
||||
await testDb.init();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await testDb.terminate();
|
||||
});
|
||||
|
||||
describe('OIDC service', () => {
|
||||
let oidcService: OidcService;
|
||||
let userRepository: UserRepository;
|
||||
let createdUser: User;
|
||||
|
||||
beforeAll(async () => {
|
||||
oidcService = Container.get(OidcService);
|
||||
userRepository = Container.get(UserRepository);
|
||||
await oidcService.init();
|
||||
|
||||
await createUser({
|
||||
email: 'user1@example.com',
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadConfig', () => {
|
||||
it('should initialize with default config', () => {
|
||||
expect(oidcService.getRedactedConfig()).toEqual({
|
||||
clientId: '',
|
||||
clientSecret: OIDC_CLIENT_SECRET_REDACTED_VALUE,
|
||||
discoveryEndpoint: 'http://n8n.io/not-set',
|
||||
loginEnabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should fallback to default configuration', async () => {
|
||||
const config = await oidcService.loadConfig();
|
||||
expect(config).toEqual({
|
||||
clientId: '',
|
||||
clientSecret: '',
|
||||
discoveryEndpoint: new URL('http://n8n.io/not-set'),
|
||||
loginEnabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should load and update OIDC configuration', async () => {
|
||||
const newConfig: OidcConfigDto = {
|
||||
clientId: 'test-client-id',
|
||||
clientSecret: 'test-client-secret',
|
||||
discoveryEndpoint: 'https://example.com/.well-known/openid-configuration',
|
||||
loginEnabled: true,
|
||||
};
|
||||
|
||||
await oidcService.updateConfig(newConfig);
|
||||
const loadedConfig = await oidcService.loadConfig();
|
||||
|
||||
expect(loadedConfig.clientId).toEqual('test-client-id');
|
||||
// The secret should be encrypted and not match the original value
|
||||
expect(loadedConfig.clientSecret).not.toEqual('test-client-secret');
|
||||
expect(loadedConfig.discoveryEndpoint.toString()).toEqual(
|
||||
'https://example.com/.well-known/openid-configuration',
|
||||
);
|
||||
expect(loadedConfig.loginEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should load and decrypt OIDC configuration', async () => {
|
||||
const newConfig: OidcConfigDto = {
|
||||
clientId: 'test-client-id',
|
||||
clientSecret: 'test-client-secret',
|
||||
discoveryEndpoint: 'https://example.com/.well-known/openid-configuration',
|
||||
loginEnabled: true,
|
||||
};
|
||||
|
||||
await oidcService.updateConfig(newConfig);
|
||||
const loadedConfig = await oidcService.loadConfig(true);
|
||||
|
||||
expect(loadedConfig.clientId).toEqual('test-client-id');
|
||||
// The secret should be encrypted and not match the original value
|
||||
expect(loadedConfig.clientSecret).toEqual('test-client-secret');
|
||||
expect(loadedConfig.discoveryEndpoint.toString()).toEqual(
|
||||
'https://example.com/.well-known/openid-configuration',
|
||||
);
|
||||
expect(loadedConfig.loginEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should throw an error if the discovery endpoint is invalid', async () => {
|
||||
const newConfig: OidcConfigDto = {
|
||||
clientId: 'test-client-id',
|
||||
clientSecret: 'test-client-secret',
|
||||
discoveryEndpoint: 'Not an url',
|
||||
loginEnabled: true,
|
||||
};
|
||||
|
||||
await expect(oidcService.updateConfig(newConfig)).rejects.toThrowError(BadRequestError);
|
||||
});
|
||||
|
||||
it('should keep current secret if redact value is given in update', async () => {
|
||||
const newConfig: OidcConfigDto = {
|
||||
clientId: 'test-client-id',
|
||||
clientSecret: OIDC_CLIENT_SECRET_REDACTED_VALUE,
|
||||
discoveryEndpoint: 'https://example.com/.well-known/openid-configuration',
|
||||
loginEnabled: true,
|
||||
};
|
||||
|
||||
await oidcService.updateConfig(newConfig);
|
||||
|
||||
const loadedConfig = await oidcService.loadConfig(true);
|
||||
|
||||
expect(loadedConfig.clientId).toEqual('test-client-id');
|
||||
// The secret should be encrypted and not match the original value
|
||||
expect(loadedConfig.clientSecret).toEqual('test-client-secret');
|
||||
expect(loadedConfig.discoveryEndpoint.toString()).toEqual(
|
||||
'https://example.com/.well-known/openid-configuration',
|
||||
);
|
||||
expect(loadedConfig.loginEnabled).toBe(true);
|
||||
});
|
||||
});
|
||||
it('should generate a valid callback URL', () => {
|
||||
const callbackUrl = oidcService.getCallbackUrl();
|
||||
expect(callbackUrl).toContain('/sso/oidc/callback');
|
||||
});
|
||||
|
||||
it('should generate a valid authentication URL', async () => {
|
||||
const mockConfiguration = new real_odic_client.Configuration(
|
||||
{
|
||||
issuer: 'https://example.com/auth/realms/n8n',
|
||||
client_id: 'test-client-id',
|
||||
redirect_uris: ['http://n8n.io/sso/oidc/callback'],
|
||||
response_types: ['code'],
|
||||
scopes: ['openid', 'profile', 'email'],
|
||||
authorization_endpoint: 'https://example.com/auth',
|
||||
},
|
||||
'test-client-id',
|
||||
);
|
||||
discoveryMock.mockResolvedValue(mockConfiguration);
|
||||
|
||||
const authUrl = await oidcService.generateLoginUrl();
|
||||
|
||||
expect(authUrl.pathname).toEqual('/auth');
|
||||
expect(authUrl.searchParams.get('client_id')).toEqual('test-client-id');
|
||||
expect(authUrl.searchParams.get('redirect_uri')).toEqual(
|
||||
'http://localhost:5678/rest/sso/oidc/callback',
|
||||
);
|
||||
expect(authUrl.searchParams.get('response_type')).toEqual('code');
|
||||
expect(authUrl.searchParams.get('scope')).toEqual('openid email profile');
|
||||
});
|
||||
|
||||
describe('loginUser', () => {
|
||||
it('should handle new user login with valid callback URL', async () => {
|
||||
const callbackUrl = new URL(
|
||||
'http://localhost:5678/rest/sso/oidc/callback?code=valid-code&state=valid-state',
|
||||
);
|
||||
|
||||
const mockTokens: mocked_oidc_client.TokenEndpointResponse &
|
||||
mocked_oidc_client.TokenEndpointResponseHelpers = {
|
||||
access_token: 'mock-access-token',
|
||||
id_token: 'mock-id-token',
|
||||
token_type: 'bearer',
|
||||
claims: () => {
|
||||
return {
|
||||
sub: 'mock-subject',
|
||||
iss: 'https://example.com/auth/realms/n8n',
|
||||
aud: 'test-client-id',
|
||||
iat: Math.floor(Date.now() / 1000) - 1000,
|
||||
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||
} as mocked_oidc_client.IDToken;
|
||||
},
|
||||
expiresIn: () => 3600,
|
||||
} as mocked_oidc_client.TokenEndpointResponse &
|
||||
mocked_oidc_client.TokenEndpointResponseHelpers;
|
||||
|
||||
authorizationCodeGrantMock.mockResolvedValueOnce(mockTokens);
|
||||
|
||||
fetchUserInfoMock.mockResolvedValueOnce({
|
||||
email_verified: true,
|
||||
email: 'user2@example.com',
|
||||
});
|
||||
|
||||
const user = await oidcService.loginUser(callbackUrl);
|
||||
expect(user).toBeDefined();
|
||||
expect(user.email).toEqual('user2@example.com');
|
||||
|
||||
createdUser = user;
|
||||
|
||||
const userFromDB = await userRepository.findOne({
|
||||
where: { email: 'user2@example.com' },
|
||||
});
|
||||
|
||||
expect(userFromDB).toBeDefined();
|
||||
expect(userFromDB!.id).toEqual(user.id);
|
||||
});
|
||||
|
||||
it('should handle existing user login with valid callback URL', async () => {
|
||||
const callbackUrl = new URL(
|
||||
'http://localhost:5678/rest/sso/oidc/callback?code=valid-code&state=valid-state',
|
||||
);
|
||||
|
||||
const mockTokens: mocked_oidc_client.TokenEndpointResponse &
|
||||
mocked_oidc_client.TokenEndpointResponseHelpers = {
|
||||
access_token: 'mock-access-token-1',
|
||||
id_token: 'mock-id-token-1',
|
||||
token_type: 'bearer',
|
||||
claims: () => {
|
||||
return {
|
||||
sub: 'mock-subject',
|
||||
iss: 'https://example.com/auth/realms/n8n',
|
||||
aud: 'test-client-id',
|
||||
iat: Math.floor(Date.now() / 1000) - 1000,
|
||||
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||
} as mocked_oidc_client.IDToken;
|
||||
},
|
||||
expiresIn: () => 3600,
|
||||
} as mocked_oidc_client.TokenEndpointResponse &
|
||||
mocked_oidc_client.TokenEndpointResponseHelpers;
|
||||
|
||||
authorizationCodeGrantMock.mockResolvedValueOnce(mockTokens);
|
||||
|
||||
fetchUserInfoMock.mockResolvedValueOnce({
|
||||
email_verified: true,
|
||||
email: 'user2@example.com',
|
||||
});
|
||||
|
||||
const user = await oidcService.loginUser(callbackUrl);
|
||||
expect(user).toBeDefined();
|
||||
expect(user.email).toEqual('user2@example.com');
|
||||
expect(user.id).toEqual(createdUser.id);
|
||||
});
|
||||
|
||||
it('should throw `BadRequestError` if user already exists out of OIDC system', async () => {
|
||||
const callbackUrl = new URL(
|
||||
'http://localhost:5678/rest/sso/oidc/callback?code=valid-code&state=valid-state',
|
||||
);
|
||||
|
||||
const mockTokens: mocked_oidc_client.TokenEndpointResponse &
|
||||
mocked_oidc_client.TokenEndpointResponseHelpers = {
|
||||
access_token: 'mock-access-token-2',
|
||||
id_token: 'mock-id-token-2',
|
||||
token_type: 'bearer',
|
||||
claims: () => {
|
||||
return {
|
||||
sub: 'mock-subject-1',
|
||||
iss: 'https://example.com/auth/realms/n8n',
|
||||
aud: 'test-client-id',
|
||||
iat: Math.floor(Date.now() / 1000) - 1000,
|
||||
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||
} as mocked_oidc_client.IDToken;
|
||||
},
|
||||
expiresIn: () => 3600,
|
||||
} as mocked_oidc_client.TokenEndpointResponse &
|
||||
mocked_oidc_client.TokenEndpointResponseHelpers;
|
||||
|
||||
authorizationCodeGrantMock.mockResolvedValueOnce(mockTokens);
|
||||
|
||||
// Simulate that the user already exists in the database
|
||||
fetchUserInfoMock.mockResolvedValueOnce({
|
||||
email_verified: true,
|
||||
email: 'user1@example.com',
|
||||
});
|
||||
|
||||
await expect(oidcService.loginUser(callbackUrl)).rejects.toThrowError(BadRequestError);
|
||||
});
|
||||
|
||||
it('should throw `BadRequestError` if OIDC Idp does not have email verified', async () => {
|
||||
const callbackUrl = new URL(
|
||||
'http://localhost:5678/rest/sso/oidc/callback?code=valid-code&state=valid-state',
|
||||
);
|
||||
|
||||
const mockTokens: mocked_oidc_client.TokenEndpointResponse &
|
||||
mocked_oidc_client.TokenEndpointResponseHelpers = {
|
||||
access_token: 'mock-access-token-2',
|
||||
id_token: 'mock-id-token-2',
|
||||
token_type: 'bearer',
|
||||
claims: () => {
|
||||
return {
|
||||
sub: 'mock-subject-3',
|
||||
iss: 'https://example.com/auth/realms/n8n',
|
||||
aud: 'test-client-id',
|
||||
iat: Math.floor(Date.now() / 1000) - 1000,
|
||||
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||
} as mocked_oidc_client.IDToken;
|
||||
},
|
||||
expiresIn: () => 3600,
|
||||
} as mocked_oidc_client.TokenEndpointResponse &
|
||||
mocked_oidc_client.TokenEndpointResponseHelpers;
|
||||
|
||||
authorizationCodeGrantMock.mockResolvedValueOnce(mockTokens);
|
||||
|
||||
// Simulate that the user already exists in the database
|
||||
fetchUserInfoMock.mockResolvedValueOnce({
|
||||
email_verified: false,
|
||||
email: 'user3@example.com',
|
||||
});
|
||||
|
||||
await expect(oidcService.loginUser(callbackUrl)).rejects.toThrowError(BadRequestError);
|
||||
});
|
||||
|
||||
it('should throw `BadRequestError` if OIDC Idp does not provide an email', async () => {
|
||||
const callbackUrl = new URL(
|
||||
'http://localhost:5678/rest/sso/oidc/callback?code=valid-code&state=valid-state',
|
||||
);
|
||||
|
||||
const mockTokens: mocked_oidc_client.TokenEndpointResponse &
|
||||
mocked_oidc_client.TokenEndpointResponseHelpers = {
|
||||
access_token: 'mock-access-token-2',
|
||||
id_token: 'mock-id-token-2',
|
||||
token_type: 'bearer',
|
||||
claims: () => {
|
||||
return {
|
||||
sub: 'mock-subject-3',
|
||||
iss: 'https://example.com/auth/realms/n8n',
|
||||
aud: 'test-client-id',
|
||||
iat: Math.floor(Date.now() / 1000) - 1000,
|
||||
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||
} as mocked_oidc_client.IDToken;
|
||||
},
|
||||
expiresIn: () => 3600,
|
||||
} as mocked_oidc_client.TokenEndpointResponse &
|
||||
mocked_oidc_client.TokenEndpointResponseHelpers;
|
||||
|
||||
authorizationCodeGrantMock.mockResolvedValueOnce(mockTokens);
|
||||
|
||||
// Simulate that the user already exists in the database
|
||||
fetchUserInfoMock.mockResolvedValueOnce({
|
||||
email_verified: true,
|
||||
});
|
||||
|
||||
await expect(oidcService.loginUser(callbackUrl)).rejects.toThrowError(BadRequestError);
|
||||
});
|
||||
|
||||
it('should throw `ForbiddenError` if OIDC token does not provide claims', async () => {
|
||||
const callbackUrl = new URL(
|
||||
'http://localhost:5678/rest/sso/oidc/callback?code=valid-code&state=valid-state',
|
||||
);
|
||||
|
||||
const mockTokens: mocked_oidc_client.TokenEndpointResponse &
|
||||
mocked_oidc_client.TokenEndpointResponseHelpers = {
|
||||
access_token: 'mock-access-token-2',
|
||||
id_token: 'mock-id-token-2',
|
||||
token_type: 'bearer',
|
||||
claims: () => {
|
||||
return undefined; // Simulating no claims
|
||||
},
|
||||
expiresIn: () => 3600,
|
||||
} as mocked_oidc_client.TokenEndpointResponse &
|
||||
mocked_oidc_client.TokenEndpointResponseHelpers;
|
||||
|
||||
authorizationCodeGrantMock.mockResolvedValueOnce(mockTokens);
|
||||
|
||||
// Simulate that the user already exists in the database
|
||||
fetchUserInfoMock.mockResolvedValueOnce({
|
||||
email_verified: true,
|
||||
});
|
||||
|
||||
await expect(oidcService.loginUser(callbackUrl)).rejects.toThrowError(ForbiddenError);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -101,7 +101,7 @@ const onUserAction = (user: IUser, action: string) =>
|
||||
<N8nActionToggle
|
||||
v-if="
|
||||
!user.isOwner &&
|
||||
user.signInType !== 'ldap' &&
|
||||
!['ldap'].includes(user.signInType) &&
|
||||
!readonly &&
|
||||
getActions(user).length > 0 &&
|
||||
actions.length > 0
|
||||
|
||||
@@ -929,6 +929,7 @@
|
||||
"forgotPassword.returnToSignIn": "Back to sign in",
|
||||
"forgotPassword.sendingEmailError": "Problem sending email",
|
||||
"forgotPassword.ldapUserPasswordResetUnavailable": "Please contact your LDAP administrator to reset your password",
|
||||
"forgotPassword.oidcUserPasswordResetUnavailable": "Please contact your OIDC administrator to reset your password",
|
||||
"forgotPassword.smtpErrorContactAdministrator": "Please contact your administrator (problem with your SMTP setup)",
|
||||
"forgotPassword.tooManyRequests": "You’ve reached the password reset limit. Please try again in a few minutes.",
|
||||
"forms.resourceFiltersDropdown.filters": "Filters",
|
||||
@@ -2716,6 +2717,7 @@
|
||||
"settings.ldap.confirmMessage.beforeSaveForm.confirmButtonText": "Yes, disable it",
|
||||
"settings.ldap.confirmMessage.beforeSaveForm.headline": "Are you sure you want to disable LDAP login?",
|
||||
"settings.ldap.confirmMessage.beforeSaveForm.message": "If you do so, all LDAP users will be converted to email users.",
|
||||
|
||||
"settings.ldap.disabled.title": "Available on the Enterprise plan",
|
||||
"settings.ldap.disabled.description": "LDAP is available as a paid feature. Learn more about it.",
|
||||
"settings.ldap.disabled.buttonText": "See plans",
|
||||
@@ -2775,8 +2777,8 @@
|
||||
"settings.sso": "SSO",
|
||||
"settings.sso.title": "Single Sign On",
|
||||
"settings.sso.subtitle": "SAML 2.0 Configuration",
|
||||
"settings.sso.info": "Activate SAML SSO to enable passwordless login via your existing user management tool and enhance security through unified authentication.",
|
||||
"settings.sso.info.link": "Learn how to configure SAML 2.0.",
|
||||
"settings.sso.info": "Activate SAML or OIDC to enable passwordless login via your existing user management tool and enhance security through unified authentication.",
|
||||
"settings.sso.info.link": "Learn how to configure SAML or OIDC.",
|
||||
"settings.sso.activation.tooltip": "You need to save the settings first before activating SAML",
|
||||
"settings.sso.activated": "Activated",
|
||||
"settings.sso.deactivated": "Deactivated",
|
||||
@@ -2804,6 +2806,8 @@
|
||||
"settings.sso.actionBox.title": "Available on the Enterprise plan",
|
||||
"settings.sso.actionBox.description": "Use Single Sign On to consolidate authentication into a single platform to improve security and agility.",
|
||||
"settings.sso.actionBox.buttonText": "See plans",
|
||||
"settings.oidc.confirmMessage.beforeSaveForm.headline": "Are you sure you want to disable OIDC login?",
|
||||
"settings.oidc.confirmMessage.beforeSaveForm.message": "If you do so, all OIDC users will be converted to email users.",
|
||||
"settings.mfa.secret": "Secret {secret}",
|
||||
"settings.mfa": "MFA",
|
||||
"settings.mfa.title": "Multi-factor Authentication",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { SamlPreferences, SamlToggleDto } from '@n8n/api-types';
|
||||
import type { OidcConfigDto, SamlPreferences, SamlToggleDto } from '@n8n/api-types';
|
||||
|
||||
import type { IRestApiContext } from '../types';
|
||||
import { makeRestApiRequest } from '../utils';
|
||||
@@ -39,3 +39,18 @@ export const toggleSamlConfig = async (
|
||||
export const testSamlConfig = async (context: IRestApiContext): Promise<string> => {
|
||||
return await makeRestApiRequest(context, 'GET', '/sso/saml/config/test');
|
||||
};
|
||||
|
||||
export const getOidcConfig = async (context: IRestApiContext): Promise<OidcConfigDto> => {
|
||||
return await makeRestApiRequest(context, 'GET', '/sso/oidc/config');
|
||||
};
|
||||
|
||||
export const saveOidcConfig = async (
|
||||
context: IRestApiContext,
|
||||
data: OidcConfigDto,
|
||||
): Promise<OidcConfigDto> => {
|
||||
return await makeRestApiRequest(context, 'POST', '/sso/oidc/config', data);
|
||||
};
|
||||
|
||||
export const initOidcLogin = async (context: IRestApiContext): Promise<string> => {
|
||||
return await makeRestApiRequest(context, 'GET', '/sso/oidc/login');
|
||||
};
|
||||
|
||||
@@ -608,6 +608,7 @@ export const enum UserManagementAuthenticationMethod {
|
||||
Email = 'email',
|
||||
Ldap = 'ldap',
|
||||
Saml = 'saml',
|
||||
Oidc = 'oidc',
|
||||
}
|
||||
|
||||
export interface IPermissionGroup {
|
||||
@@ -1423,6 +1424,7 @@ export type EnterpriseEditionFeatureKey =
|
||||
| 'LogStreaming'
|
||||
| 'Variables'
|
||||
| 'Saml'
|
||||
| 'Oidc'
|
||||
| 'SourceControl'
|
||||
| 'ExternalSecrets'
|
||||
| 'AuditLogs'
|
||||
|
||||
@@ -24,6 +24,7 @@ export const defaultSettings: FrontendSettings = {
|
||||
enterprise: {
|
||||
sharing: false,
|
||||
ldap: false,
|
||||
oidc: false,
|
||||
saml: false,
|
||||
logStreaming: false,
|
||||
debugInEditor: false,
|
||||
@@ -78,6 +79,7 @@ export const defaultSettings: FrontendSettings = {
|
||||
sso: {
|
||||
ldap: { loginEnabled: false, loginLabel: '' },
|
||||
saml: { loginEnabled: false, loginLabel: '' },
|
||||
oidc: { loginEnabled: false, loginUrl: '', callbackUrl: '' },
|
||||
},
|
||||
telemetry: {
|
||||
enabled: false,
|
||||
|
||||
@@ -220,6 +220,7 @@ export function createMockEnterpriseSettings(
|
||||
sharing: false,
|
||||
ldap: false,
|
||||
saml: false,
|
||||
oidc: false,
|
||||
logStreaming: false,
|
||||
advancedExecutionFilters: false,
|
||||
variables: false,
|
||||
|
||||
@@ -2,14 +2,19 @@
|
||||
import { useSSOStore } from '@/stores/sso.store';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
|
||||
const i18n = useI18n();
|
||||
const ssoStore = useSSOStore();
|
||||
const toast = useToast();
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
const onSSOLogin = async () => {
|
||||
try {
|
||||
window.location.href = await ssoStore.getSSORedirectUrl();
|
||||
const redirectUrl = ssoStore.isDefaultAuthenticationSaml
|
||||
? await ssoStore.getSSORedirectUrl()
|
||||
: settingsStore.settings.sso.oidc.loginUrl;
|
||||
window.location.href = redirectUrl;
|
||||
} catch (error) {
|
||||
toast.showError(error, 'Error', error.message);
|
||||
}
|
||||
|
||||
@@ -644,6 +644,7 @@ export const EnterpriseEditionFeature: Record<
|
||||
LogStreaming: 'logStreaming',
|
||||
Variables: 'variables',
|
||||
Saml: 'saml',
|
||||
Oidc: 'oidc',
|
||||
SourceControl: 'sourceControl',
|
||||
ExternalSecrets: 'externalSecrets',
|
||||
AuditLogs: 'auditLogs',
|
||||
@@ -698,6 +699,7 @@ export const CURL_IMPORT_NODES_PROTOCOLS: { [key: string]: string } = {
|
||||
export const enum SignInType {
|
||||
LDAP = 'ldap',
|
||||
EMAIL = 'email',
|
||||
OIDC = 'oidc',
|
||||
}
|
||||
|
||||
export const N8N_SALES_EMAIL = 'sales@n8n.io';
|
||||
|
||||
@@ -17,6 +17,7 @@ describe('permissions', () => {
|
||||
ldap: {},
|
||||
license: {},
|
||||
logStreaming: {},
|
||||
oidc: {},
|
||||
orchestration: {},
|
||||
project: {},
|
||||
saml: {},
|
||||
@@ -93,6 +94,7 @@ describe('permissions', () => {
|
||||
read: true,
|
||||
},
|
||||
saml: {},
|
||||
oidc: {},
|
||||
securityAudit: {},
|
||||
sourceControl: {},
|
||||
tag: {
|
||||
|
||||
@@ -33,6 +33,7 @@ export const useRBACStore = defineStore(STORES.RBAC, () => {
|
||||
license: {},
|
||||
logStreaming: {},
|
||||
saml: {},
|
||||
oidc: {},
|
||||
securityAudit: {},
|
||||
folder: {},
|
||||
insights: {},
|
||||
|
||||
@@ -46,6 +46,7 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
|
||||
});
|
||||
const ldap = ref({ loginLabel: '', loginEnabled: false });
|
||||
const saml = ref({ loginLabel: '', loginEnabled: false });
|
||||
const oidc = ref({ loginEnabled: false, loginUrl: '', callbackUrl: '' });
|
||||
const mfa = ref({ enabled: false });
|
||||
const folders = ref({ enabled: false });
|
||||
|
||||
@@ -95,6 +96,10 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
|
||||
|
||||
const isSamlLoginEnabled = computed(() => saml.value.loginEnabled);
|
||||
|
||||
const isOidcLoginEnabled = computed(() => oidc.value.loginEnabled);
|
||||
|
||||
const oidcCallBackUrl = computed(() => oidc.value.callbackUrl);
|
||||
|
||||
const isAiAssistantEnabled = computed(() => settings.value.aiAssistant?.enabled);
|
||||
|
||||
const isAskAiEnabled = computed(() => settings.value.askAi?.enabled);
|
||||
@@ -181,6 +186,10 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
|
||||
() => userManagement.value.authenticationMethod === UserManagementAuthenticationMethod.Saml,
|
||||
);
|
||||
|
||||
const isDefaultAuthenticationOidc = computed(
|
||||
() => userManagement.value.authenticationMethod === UserManagementAuthenticationMethod.Oidc,
|
||||
);
|
||||
|
||||
const permanentlyDismissedBanners = computed(() => settings.value.banners?.dismissed ?? []);
|
||||
|
||||
const isBelowUserQuota = computed(
|
||||
@@ -210,6 +219,12 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
|
||||
saml.value.loginLabel = settings.value.sso.saml.loginLabel;
|
||||
}
|
||||
|
||||
if (settings.value.sso?.oidc) {
|
||||
oidc.value.loginEnabled = settings.value.sso.oidc.loginEnabled;
|
||||
oidc.value.loginUrl = settings.value.sso.oidc.loginUrl || '';
|
||||
oidc.value.callbackUrl = settings.value.sso.oidc.callbackUrl || '';
|
||||
}
|
||||
|
||||
mfa.value.enabled = settings.value.mfa?.enabled;
|
||||
folders.value.enabled = settings.value.folders?.enabled;
|
||||
|
||||
@@ -420,6 +435,7 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
|
||||
isLdapLoginEnabled,
|
||||
ldapLoginLabel,
|
||||
isSamlLoginEnabled,
|
||||
isOidcLoginEnabled,
|
||||
showSetupPage,
|
||||
deploymentType,
|
||||
isCloudDeployment,
|
||||
@@ -444,6 +460,7 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
|
||||
isMultiMain,
|
||||
isWorkerViewAvailable,
|
||||
isDefaultAuthenticationSaml,
|
||||
isDefaultAuthenticationOidc,
|
||||
workflowCallerPolicyDefaultOption,
|
||||
permanentlyDismissedBanners,
|
||||
isBelowUserQuota,
|
||||
@@ -456,6 +473,8 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
|
||||
isAiCreditsEnabled,
|
||||
aiCreditsQuota,
|
||||
experimental__minZoomNodeSettingsInCanvas,
|
||||
partialExecutionVersion,
|
||||
oidcCallBackUrl,
|
||||
reset,
|
||||
testLdapConnection,
|
||||
getLdapConfig,
|
||||
@@ -470,6 +489,5 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
|
||||
getSettings,
|
||||
setSettings,
|
||||
initialize,
|
||||
partialExecutionVersion,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { SamlPreferences } from '@n8n/api-types';
|
||||
import type { OidcConfigDto } from '@n8n/api-types';
|
||||
import { type SamlPreferences } from '@n8n/api-types';
|
||||
import { computed, reactive } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { defineStore } from 'pinia';
|
||||
@@ -17,17 +18,13 @@ export const useSSOStore = defineStore('sso', () => {
|
||||
const route = useRoute();
|
||||
|
||||
const state = reactive({
|
||||
loading: false,
|
||||
samlConfig: undefined as (SamlPreferences & SamlPreferencesExtractedData) | undefined,
|
||||
oidcConfig: undefined as OidcConfigDto | undefined,
|
||||
});
|
||||
|
||||
const isLoading = computed(() => state.loading);
|
||||
|
||||
const samlConfig = computed(() => state.samlConfig);
|
||||
|
||||
const setLoading = (loading: boolean) => {
|
||||
state.loading = loading;
|
||||
};
|
||||
const oidcConfig = computed(() => state.oidcConfig);
|
||||
|
||||
const isSamlLoginEnabled = computed({
|
||||
get: () => settingsStore.isSamlLoginEnabled,
|
||||
@@ -45,15 +42,43 @@ export const useSSOStore = defineStore('sso', () => {
|
||||
void toggleLoginEnabled(value);
|
||||
},
|
||||
});
|
||||
|
||||
const isOidcLoginEnabled = computed({
|
||||
get: () => settingsStore.isOidcLoginEnabled,
|
||||
set: (value: boolean) => {
|
||||
settingsStore.setSettings({
|
||||
...settingsStore.settings,
|
||||
sso: {
|
||||
...settingsStore.settings.sso,
|
||||
oidc: {
|
||||
...settingsStore.settings.sso.oidc,
|
||||
loginEnabled: value,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const isEnterpriseSamlEnabled = computed(
|
||||
() => settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.Saml],
|
||||
);
|
||||
|
||||
const isEnterpriseOidcEnabled = computed(
|
||||
() => settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.Oidc],
|
||||
);
|
||||
|
||||
const isDefaultAuthenticationSaml = computed(() => settingsStore.isDefaultAuthenticationSaml);
|
||||
|
||||
const isDefaultAuthenticationOidc = computed(() => settingsStore.isDefaultAuthenticationOidc);
|
||||
|
||||
const showSsoLoginButton = computed(
|
||||
() =>
|
||||
isSamlLoginEnabled.value &&
|
||||
isEnterpriseSamlEnabled.value &&
|
||||
isDefaultAuthenticationSaml.value,
|
||||
(isSamlLoginEnabled.value &&
|
||||
isEnterpriseSamlEnabled.value &&
|
||||
isDefaultAuthenticationSaml.value) ||
|
||||
(isOidcLoginEnabled.value &&
|
||||
isEnterpriseOidcEnabled.value &&
|
||||
isDefaultAuthenticationOidc.value),
|
||||
);
|
||||
|
||||
const getSSORedirectUrl = async () =>
|
||||
@@ -66,6 +91,9 @@ export const useSSOStore = defineStore('sso', () => {
|
||||
await ssoApi.toggleSamlConfig(rootStore.restApiContext, { loginEnabled: enabled });
|
||||
|
||||
const getSamlMetadata = async () => await ssoApi.getSamlMetadata(rootStore.restApiContext);
|
||||
|
||||
// const getOidcRedirectLUrl = async () => await ssoApi)
|
||||
|
||||
const getSamlConfig = async () => {
|
||||
const samlConfig = await ssoApi.getSamlConfig(rootStore.restApiContext);
|
||||
state.samlConfig = samlConfig;
|
||||
@@ -83,20 +111,36 @@ export const useSSOStore = defineStore('sso', () => {
|
||||
|
||||
const userData = computed(() => usersStore.currentUser);
|
||||
|
||||
const getOidcConfig = async () => {
|
||||
const oidcConfig = await ssoApi.getOidcConfig(rootStore.restApiContext);
|
||||
state.oidcConfig = oidcConfig;
|
||||
return oidcConfig;
|
||||
};
|
||||
|
||||
const saveOidcConfig = async (config: OidcConfigDto) => {
|
||||
const savedConfig = await ssoApi.saveOidcConfig(rootStore.restApiContext, config);
|
||||
state.oidcConfig = savedConfig;
|
||||
return savedConfig;
|
||||
};
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
setLoading,
|
||||
isEnterpriseOidcEnabled,
|
||||
isSamlLoginEnabled,
|
||||
isOidcLoginEnabled,
|
||||
isEnterpriseSamlEnabled,
|
||||
isDefaultAuthenticationSaml,
|
||||
isDefaultAuthenticationOidc,
|
||||
showSsoLoginButton,
|
||||
samlConfig,
|
||||
oidcConfig,
|
||||
userData,
|
||||
getSSORedirectUrl,
|
||||
getSamlMetadata,
|
||||
getSamlConfig,
|
||||
saveSamlConfig,
|
||||
testSamlConfig,
|
||||
updateUser,
|
||||
userData,
|
||||
getOidcConfig,
|
||||
saveOidcConfig,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -72,7 +72,9 @@ const isExternalAuthEnabled = computed((): boolean => {
|
||||
settingsStore.settings.enterprise.ldap && currentUser.value?.signInType === 'ldap';
|
||||
const isSamlEnabled =
|
||||
settingsStore.isSamlLoginEnabled && settingsStore.isDefaultAuthenticationSaml;
|
||||
return isLdapEnabled || isSamlEnabled;
|
||||
const isOidcEnabled =
|
||||
settingsStore.settings.enterprise.oidc && currentUser.value?.signInType === 'oidc';
|
||||
return isLdapEnabled || isSamlEnabled || isOidcEnabled;
|
||||
});
|
||||
const isPersonalSecurityEnabled = computed((): boolean => {
|
||||
return usersStore.isInstanceOwner || !isExternalAuthEnabled.value;
|
||||
|
||||
@@ -65,6 +65,7 @@ describe('SettingsSso View', () => {
|
||||
const pinia = createTestingPinia();
|
||||
const ssoStore = mockedStore(useSSOStore);
|
||||
ssoStore.isEnterpriseSamlEnabled = false;
|
||||
ssoStore.isEnterpriseOidcEnabled = false;
|
||||
|
||||
const pageRedirectionHelper = usePageRedirectionHelper();
|
||||
|
||||
@@ -82,6 +83,7 @@ describe('SettingsSso View', () => {
|
||||
|
||||
const ssoStore = mockedStore(useSSOStore);
|
||||
ssoStore.isEnterpriseSamlEnabled = true;
|
||||
ssoStore.isEnterpriseOidcEnabled = true;
|
||||
|
||||
ssoStore.getSamlConfig.mockResolvedValue(samlConfig);
|
||||
|
||||
@@ -102,6 +104,7 @@ describe('SettingsSso View', () => {
|
||||
const ssoStore = mockedStore(useSSOStore);
|
||||
ssoStore.isEnterpriseSamlEnabled = true;
|
||||
ssoStore.isSamlLoginEnabled = false;
|
||||
ssoStore.isEnterpriseOidcEnabled = true;
|
||||
|
||||
ssoStore.getSamlConfig.mockResolvedValue(samlConfig);
|
||||
|
||||
@@ -126,6 +129,7 @@ describe('SettingsSso View', () => {
|
||||
|
||||
const ssoStore = mockedStore(useSSOStore);
|
||||
ssoStore.isEnterpriseSamlEnabled = true;
|
||||
ssoStore.isEnterpriseOidcEnabled = true;
|
||||
|
||||
const { getByTestId } = renderView({ pinia });
|
||||
|
||||
@@ -163,6 +167,7 @@ describe('SettingsSso View', () => {
|
||||
|
||||
const ssoStore = mockedStore(useSSOStore);
|
||||
ssoStore.isEnterpriseSamlEnabled = true;
|
||||
ssoStore.isEnterpriseOidcEnabled = true;
|
||||
|
||||
const { getByTestId } = renderView({ pinia });
|
||||
|
||||
@@ -199,6 +204,7 @@ describe('SettingsSso View', () => {
|
||||
|
||||
const ssoStore = mockedStore(useSSOStore);
|
||||
ssoStore.isEnterpriseSamlEnabled = true;
|
||||
ssoStore.isEnterpriseOidcEnabled = true;
|
||||
|
||||
const { getByTestId } = renderView({ pinia });
|
||||
|
||||
@@ -228,6 +234,7 @@ describe('SettingsSso View', () => {
|
||||
|
||||
const ssoStore = mockedStore(useSSOStore);
|
||||
ssoStore.isEnterpriseSamlEnabled = true;
|
||||
ssoStore.isEnterpriseOidcEnabled = true;
|
||||
|
||||
const { getByTestId } = renderView({ pinia });
|
||||
|
||||
@@ -258,6 +265,8 @@ describe('SettingsSso View', () => {
|
||||
const ssoStore = mockedStore(useSSOStore);
|
||||
ssoStore.isEnterpriseSamlEnabled = true;
|
||||
ssoStore.isSamlLoginEnabled = true;
|
||||
ssoStore.isEnterpriseOidcEnabled = true;
|
||||
ssoStore.isOidcLoginEnabled = false;
|
||||
|
||||
const error = new Error('Request failed with status code 404');
|
||||
ssoStore.getSamlConfig.mockRejectedValue(error);
|
||||
|
||||
@@ -9,17 +9,27 @@ import { useTelemetry } from '@/composables/useTelemetry';
|
||||
import { useDocumentTitle } from '@/composables/useDocumentTitle';
|
||||
import { useRootStore } from '@n8n/stores/useRootStore';
|
||||
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
|
||||
import { MODAL_CONFIRM } from '@/constants';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
|
||||
type SupportedProtocolType = (typeof SupportedProtocols)[keyof typeof SupportedProtocols];
|
||||
|
||||
const IdentityProviderSettingsType = {
|
||||
URL: 'url',
|
||||
XML: 'xml',
|
||||
};
|
||||
|
||||
const SupportedProtocols = {
|
||||
SAML: 'saml',
|
||||
OIDC: 'oidc',
|
||||
} as const;
|
||||
|
||||
const i18n = useI18n();
|
||||
const telemetry = useTelemetry();
|
||||
const rootStore = useRootStore();
|
||||
const ssoStore = useSSOStore();
|
||||
const message = useMessage();
|
||||
const settingsStore = useSettingsStore();
|
||||
const toast = useToast();
|
||||
const documentTitle = useDocumentTitle();
|
||||
const pageRedirectionHelper = usePageRedirectionHelper();
|
||||
@@ -29,11 +39,24 @@ const ssoActivatedLabel = computed(() =>
|
||||
? i18n.baseText('settings.sso.activated')
|
||||
: i18n.baseText('settings.sso.deactivated'),
|
||||
);
|
||||
|
||||
const oidcActivatedLabel = computed(() =>
|
||||
ssoStore.isOidcLoginEnabled
|
||||
? i18n.baseText('settings.sso.activated')
|
||||
: i18n.baseText('settings.sso.deactivated'),
|
||||
);
|
||||
|
||||
const ssoSettingsSaved = ref(false);
|
||||
|
||||
const redirectUrl = ref();
|
||||
const entityId = ref();
|
||||
|
||||
const clientId = ref('');
|
||||
const clientSecret = ref('');
|
||||
|
||||
const discoveryEndpoint = ref('');
|
||||
|
||||
const authProtocol = ref<SupportedProtocolType>(SupportedProtocols.SAML);
|
||||
|
||||
const ipsOptions = ref([
|
||||
{
|
||||
label: i18n.baseText('settings.sso.settings.ips.options.url'),
|
||||
@@ -49,6 +72,8 @@ const ipsType = ref(IdentityProviderSettingsType.URL);
|
||||
const metadataUrl = ref();
|
||||
const metadata = ref();
|
||||
|
||||
const redirectUrl = ref();
|
||||
|
||||
const isSaveEnabled = computed(() => {
|
||||
if (ipsType.value === IdentityProviderSettingsType.URL) {
|
||||
return !!metadataUrl.value && metadataUrl.value !== ssoStore.samlConfig?.metadataUrl;
|
||||
@@ -67,6 +92,17 @@ const isTestEnabled = computed(() => {
|
||||
return false;
|
||||
});
|
||||
|
||||
async function loadSamlConfig() {
|
||||
if (!ssoStore.isEnterpriseSamlEnabled) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await getSamlConfig();
|
||||
} catch (error) {
|
||||
toast.showError(error, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
const getSamlConfig = async () => {
|
||||
const config = await ssoStore.getSamlConfig();
|
||||
|
||||
@@ -167,39 +203,79 @@ const isToggleSsoDisabled = computed(() => {
|
||||
|
||||
onMounted(async () => {
|
||||
documentTitle.set(i18n.baseText('settings.sso.title'));
|
||||
if (!ssoStore.isEnterpriseSamlEnabled) {
|
||||
await Promise.all([loadSamlConfig(), loadOidcConfig()]);
|
||||
|
||||
if (ssoStore.isDefaultAuthenticationSaml) {
|
||||
authProtocol.value = SupportedProtocols.SAML;
|
||||
} else if (ssoStore.isDefaultAuthenticationOidc) {
|
||||
authProtocol.value = SupportedProtocols.OIDC;
|
||||
}
|
||||
});
|
||||
|
||||
const getOidcConfig = async () => {
|
||||
const config = await ssoStore.getOidcConfig();
|
||||
|
||||
clientId.value = config.clientId;
|
||||
clientSecret.value = config.clientSecret;
|
||||
discoveryEndpoint.value = config.discoveryEndpoint;
|
||||
};
|
||||
|
||||
async function loadOidcConfig() {
|
||||
if (!ssoStore.isEnterpriseOidcEnabled) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await getSamlConfig();
|
||||
await getOidcConfig();
|
||||
} catch (error) {
|
||||
toast.showError(error, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function onAuthProtocolUpdated(value: SupportedProtocolType) {
|
||||
authProtocol.value = value;
|
||||
}
|
||||
|
||||
const cannotSaveOidcSettings = computed(() => {
|
||||
return (
|
||||
ssoStore.oidcConfig?.clientId === clientId.value &&
|
||||
ssoStore.oidcConfig?.clientSecret === clientSecret.value &&
|
||||
ssoStore.oidcConfig?.discoveryEndpoint === discoveryEndpoint.value &&
|
||||
ssoStore.oidcConfig?.loginEnabled === ssoStore.isOidcLoginEnabled
|
||||
);
|
||||
});
|
||||
|
||||
async function onOidcSettingsSave() {
|
||||
if (ssoStore.oidcConfig?.loginEnabled && !ssoStore.isOidcLoginEnabled) {
|
||||
const confirmAction = await message.confirm(
|
||||
i18n.baseText('settings.oidc.confirmMessage.beforeSaveForm.message'),
|
||||
i18n.baseText('settings.oidc.confirmMessage.beforeSaveForm.headline'),
|
||||
{
|
||||
cancelButtonText: i18n.baseText(
|
||||
'settings.ldap.confirmMessage.beforeSaveForm.cancelButtonText',
|
||||
),
|
||||
confirmButtonText: i18n.baseText(
|
||||
'settings.ldap.confirmMessage.beforeSaveForm.confirmButtonText',
|
||||
),
|
||||
},
|
||||
);
|
||||
if (confirmAction !== MODAL_CONFIRM) return;
|
||||
}
|
||||
|
||||
const newConfig = await ssoStore.saveOidcConfig({
|
||||
clientId: clientId.value,
|
||||
clientSecret: clientSecret.value,
|
||||
discoveryEndpoint: discoveryEndpoint.value,
|
||||
loginEnabled: ssoStore.isOidcLoginEnabled,
|
||||
});
|
||||
|
||||
clientSecret.value = newConfig.clientSecret;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pb-3xl">
|
||||
<n8n-heading size="2xlarge">{{ i18n.baseText('settings.sso.title') }}</n8n-heading>
|
||||
<div :class="$style.top">
|
||||
<n8n-heading size="xlarge">{{ i18n.baseText('settings.sso.subtitle') }}</n8n-heading>
|
||||
<n8n-tooltip
|
||||
v-if="ssoStore.isEnterpriseSamlEnabled"
|
||||
:disabled="ssoStore.isSamlLoginEnabled || ssoSettingsSaved"
|
||||
>
|
||||
<template #content>
|
||||
<span>
|
||||
{{ i18n.baseText('settings.sso.activation.tooltip') }}
|
||||
</span>
|
||||
</template>
|
||||
<el-switch
|
||||
v-model="ssoStore.isSamlLoginEnabled"
|
||||
data-test-id="sso-toggle"
|
||||
:disabled="isToggleSsoDisabled"
|
||||
:class="$style.switch"
|
||||
:inactive-text="ssoActivatedLabel"
|
||||
/>
|
||||
</n8n-tooltip>
|
||||
<div class="pb-2xl">
|
||||
<div :class="$style.heading">
|
||||
<n8n-heading size="2xlarge">{{ i18n.baseText('settings.sso.title') }}</n8n-heading>
|
||||
</div>
|
||||
<n8n-info-tip>
|
||||
{{ i18n.baseText('settings.sso.info') }}
|
||||
@@ -209,67 +285,173 @@ onMounted(async () => {
|
||||
</n8n-info-tip>
|
||||
<div v-if="ssoStore.isEnterpriseSamlEnabled" data-test-id="sso-content-licensed">
|
||||
<div :class="$style.group">
|
||||
<label>{{ i18n.baseText('settings.sso.settings.redirectUrl.label') }}</label>
|
||||
<CopyInput
|
||||
:value="redirectUrl"
|
||||
:copy-button-text="i18n.baseText('generic.clickToCopy')"
|
||||
:toast-title="i18n.baseText('settings.sso.settings.redirectUrl.copied')"
|
||||
/>
|
||||
<small>{{ i18n.baseText('settings.sso.settings.redirectUrl.help') }}</small>
|
||||
</div>
|
||||
<div :class="$style.group">
|
||||
<label>{{ i18n.baseText('settings.sso.settings.entityId.label') }}</label>
|
||||
<CopyInput
|
||||
:value="entityId"
|
||||
:copy-button-text="i18n.baseText('generic.clickToCopy')"
|
||||
:toast-title="i18n.baseText('settings.sso.settings.entityId.copied')"
|
||||
/>
|
||||
<small>{{ i18n.baseText('settings.sso.settings.entityId.help') }}</small>
|
||||
</div>
|
||||
<div :class="$style.group">
|
||||
<label>{{ i18n.baseText('settings.sso.settings.ips.label') }}</label>
|
||||
<div class="mt-2xs mb-s">
|
||||
<n8n-radio-buttons v-model="ipsType" :options="ipsOptions" />
|
||||
<label>Select Authentication Protocol</label>
|
||||
<div>
|
||||
<N8nSelect
|
||||
filterable
|
||||
:model-value="authProtocol"
|
||||
:placeholder="i18n.baseText('parameterInput.select')"
|
||||
@update:model-value="onAuthProtocolUpdated"
|
||||
@keydown.stop
|
||||
>
|
||||
<N8nOption
|
||||
v-for="protocol in Object.values(SupportedProtocols)"
|
||||
:key="protocol"
|
||||
:value="protocol"
|
||||
:label="protocol.toUpperCase()"
|
||||
data-test-id="credential-select-option"
|
||||
>
|
||||
</N8nOption>
|
||||
</N8nSelect>
|
||||
</div>
|
||||
<div v-show="ipsType === IdentityProviderSettingsType.URL">
|
||||
<n8n-input
|
||||
v-model="metadataUrl"
|
||||
type="text"
|
||||
name="metadataUrl"
|
||||
</div>
|
||||
<div v-if="authProtocol === SupportedProtocols.SAML">
|
||||
<div :class="$style.group">
|
||||
<label>{{ i18n.baseText('settings.sso.settings.redirectUrl.label') }}</label>
|
||||
<CopyInput
|
||||
:value="redirectUrl"
|
||||
:copy-button-text="i18n.baseText('generic.clickToCopy')"
|
||||
:toast-title="i18n.baseText('settings.sso.settings.redirectUrl.copied')"
|
||||
/>
|
||||
<small>{{ i18n.baseText('settings.sso.settings.redirectUrl.help') }}</small>
|
||||
</div>
|
||||
<div :class="$style.group">
|
||||
<label>{{ i18n.baseText('settings.sso.settings.entityId.label') }}</label>
|
||||
<CopyInput
|
||||
:value="entityId"
|
||||
:copy-button-text="i18n.baseText('generic.clickToCopy')"
|
||||
:toast-title="i18n.baseText('settings.sso.settings.entityId.copied')"
|
||||
/>
|
||||
<small>{{ i18n.baseText('settings.sso.settings.entityId.help') }}</small>
|
||||
</div>
|
||||
<div :class="$style.group">
|
||||
<label>{{ i18n.baseText('settings.sso.settings.ips.label') }}</label>
|
||||
<div class="mt-2xs mb-s">
|
||||
<n8n-radio-buttons v-model="ipsType" :options="ipsOptions" />
|
||||
</div>
|
||||
<div v-show="ipsType === IdentityProviderSettingsType.URL">
|
||||
<n8n-input
|
||||
v-model="metadataUrl"
|
||||
type="text"
|
||||
name="metadataUrl"
|
||||
size="large"
|
||||
:placeholder="i18n.baseText('settings.sso.settings.ips.url.placeholder')"
|
||||
data-test-id="sso-provider-url"
|
||||
/>
|
||||
<small>{{ i18n.baseText('settings.sso.settings.ips.url.help') }}</small>
|
||||
</div>
|
||||
<div v-show="ipsType === IdentityProviderSettingsType.XML">
|
||||
<n8n-input
|
||||
v-model="metadata"
|
||||
type="textarea"
|
||||
name="metadata"
|
||||
:rows="4"
|
||||
data-test-id="sso-provider-xml"
|
||||
/>
|
||||
<small>{{ i18n.baseText('settings.sso.settings.ips.xml.help') }}</small>
|
||||
</div>
|
||||
<div :class="$style.group">
|
||||
<n8n-tooltip
|
||||
v-if="ssoStore.isEnterpriseSamlEnabled"
|
||||
:disabled="ssoStore.isSamlLoginEnabled || ssoSettingsSaved"
|
||||
>
|
||||
<template #content>
|
||||
<span>
|
||||
{{ i18n.baseText('settings.sso.activation.tooltip') }}
|
||||
</span>
|
||||
</template>
|
||||
<el-switch
|
||||
v-model="ssoStore.isSamlLoginEnabled"
|
||||
data-test-id="sso-toggle"
|
||||
:disabled="isToggleSsoDisabled"
|
||||
:class="$style.switch"
|
||||
:inactive-text="ssoActivatedLabel"
|
||||
/>
|
||||
</n8n-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div :class="$style.buttons">
|
||||
<n8n-button
|
||||
:disabled="!isSaveEnabled"
|
||||
size="large"
|
||||
:placeholder="i18n.baseText('settings.sso.settings.ips.url.placeholder')"
|
||||
data-test-id="sso-provider-url"
|
||||
/>
|
||||
<small>{{ i18n.baseText('settings.sso.settings.ips.url.help') }}</small>
|
||||
data-test-id="sso-save"
|
||||
@click="onSave"
|
||||
>
|
||||
{{ i18n.baseText('settings.sso.settings.save') }}
|
||||
</n8n-button>
|
||||
<n8n-button
|
||||
:disabled="!isTestEnabled"
|
||||
size="large"
|
||||
type="tertiary"
|
||||
data-test-id="sso-test"
|
||||
@click="onTest"
|
||||
>
|
||||
{{ i18n.baseText('settings.sso.settings.test') }}
|
||||
</n8n-button>
|
||||
</div>
|
||||
<div v-show="ipsType === IdentityProviderSettingsType.XML">
|
||||
<n8n-input
|
||||
v-model="metadata"
|
||||
type="textarea"
|
||||
name="metadata"
|
||||
:rows="4"
|
||||
data-test-id="sso-provider-xml"
|
||||
|
||||
<footer :class="$style.footer">
|
||||
{{ i18n.baseText('settings.sso.settings.footer.hint') }}
|
||||
</footer>
|
||||
</div>
|
||||
<div v-if="authProtocol === SupportedProtocols.OIDC">
|
||||
<div :class="$style.group">
|
||||
<label>Redirect URL</label>
|
||||
<CopyInput
|
||||
:value="settingsStore.oidcCallBackUrl"
|
||||
:copy-button-text="i18n.baseText('generic.clickToCopy')"
|
||||
toast-title="Redirect URL copied to clipboard"
|
||||
/>
|
||||
<small>{{ i18n.baseText('settings.sso.settings.ips.xml.help') }}</small>
|
||||
<small>Copy the Redirect URL to configure your OIDC provider </small>
|
||||
</div>
|
||||
<div :class="$style.group">
|
||||
<label>Discovery Endpoint</label>
|
||||
<N8nInput
|
||||
:model-value="discoveryEndpoint"
|
||||
type="text"
|
||||
placeholder="https://accounts.google.com/.well-known/openid-configuration"
|
||||
@update:model-value="(v: string) => (discoveryEndpoint = v)"
|
||||
/>
|
||||
<small>Paste here your discovery endpoint</small>
|
||||
</div>
|
||||
<div :class="$style.group">
|
||||
<label>Client ID</label>
|
||||
<N8nInput
|
||||
:model-value="clientId"
|
||||
type="text"
|
||||
@update:model-value="(v: string) => (clientId = v)"
|
||||
/>
|
||||
<small
|
||||
>The client ID you received when registering your application with your provider</small
|
||||
>
|
||||
</div>
|
||||
<div :class="$style.group">
|
||||
<label>Client Secret</label>
|
||||
<N8nInput
|
||||
:model-value="clientSecret"
|
||||
type="password"
|
||||
@update:model-value="(v: string) => (clientSecret = v)"
|
||||
/>
|
||||
<small
|
||||
>The client Secret you received when registering your application with your
|
||||
provider</small
|
||||
>
|
||||
</div>
|
||||
<div :class="$style.group">
|
||||
<el-switch
|
||||
v-model="ssoStore.isOidcLoginEnabled"
|
||||
data-test-id="sso-oidc-toggle"
|
||||
:class="$style.switch"
|
||||
:inactive-text="oidcActivatedLabel"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div :class="$style.buttons">
|
||||
<n8n-button size="large" :disabled="cannotSaveOidcSettings" @click="onOidcSettingsSave">
|
||||
{{ i18n.baseText('settings.sso.settings.save') }}
|
||||
</n8n-button>
|
||||
</div>
|
||||
</div>
|
||||
<div :class="$style.buttons">
|
||||
<n8n-button :disabled="!isSaveEnabled" size="large" data-test-id="sso-save" @click="onSave">
|
||||
{{ i18n.baseText('settings.sso.settings.save') }}
|
||||
</n8n-button>
|
||||
<n8n-button
|
||||
:disabled="!isTestEnabled"
|
||||
size="large"
|
||||
type="tertiary"
|
||||
data-test-id="sso-test"
|
||||
@click="onTest"
|
||||
>
|
||||
{{ i18n.baseText('settings.sso.settings.test') }}
|
||||
</n8n-button>
|
||||
</div>
|
||||
<footer :class="$style.footer">
|
||||
{{ i18n.baseText('settings.sso.settings.footer.hint') }}
|
||||
</footer>
|
||||
</div>
|
||||
<n8n-action-box
|
||||
v-else
|
||||
@@ -287,11 +469,8 @@ onMounted(async () => {
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--spacing-2xl) 0 var(--spacing-xl);
|
||||
.heading {
|
||||
margin-bottom: var(--spacing-s);
|
||||
}
|
||||
|
||||
.switch {
|
||||
|
||||
170
pnpm-lock.yaml
generated
170
pnpm-lock.yaml
generated
@@ -1319,6 +1319,9 @@ importers:
|
||||
open:
|
||||
specifier: 7.4.2
|
||||
version: 7.4.2
|
||||
openid-client:
|
||||
specifier: 6.5.0
|
||||
version: 6.5.0
|
||||
otpauth:
|
||||
specifier: 9.1.1
|
||||
version: 9.1.1
|
||||
@@ -1899,7 +1902,7 @@ importers:
|
||||
version: 0.19.0(@vue/compiler-sfc@3.5.13)
|
||||
unplugin-vue-components:
|
||||
specifier: ^0.27.2
|
||||
version: 0.27.3(@babel/parser@7.26.10)(rollup@4.35.0)(vue@3.5.13(typescript@5.8.2))
|
||||
version: 0.27.3(@babel/parser@7.27.5)(rollup@4.35.0)(vue@3.5.13(typescript@5.8.2))
|
||||
vite:
|
||||
specifier: catalog:frontend
|
||||
version: 6.3.5(@types/node@20.17.57)(jiti@1.21.0)(sass@1.64.1)(terser@5.16.1)(tsx@4.19.3)
|
||||
@@ -2392,7 +2395,7 @@ importers:
|
||||
version: 0.19.0(@vue/compiler-sfc@3.5.13)
|
||||
unplugin-vue-components:
|
||||
specifier: ^0.27.2
|
||||
version: 0.27.3(@babel/parser@7.26.10)(rollup@4.35.0)(vue@3.5.13(typescript@5.8.2))
|
||||
version: 0.27.3(@babel/parser@7.27.5)(rollup@4.35.0)(vue@3.5.13(typescript@5.8.2))
|
||||
vite:
|
||||
specifier: catalog:frontend
|
||||
version: 6.3.5(@types/node@20.17.57)(jiti@1.21.0)(sass@1.64.1)(terser@5.16.1)(tsx@4.19.3)
|
||||
@@ -3186,6 +3189,10 @@ packages:
|
||||
resolution: {integrity: sha512-oH5UPLMWR3L2wEFLnFJ1TZXqHufiTKAiLfqw5zkhS4dKXLJ10yVztfil/twG8EDTA4F/tvVNw9nOl4ZMslB8rQ==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/compat-data@7.27.5':
|
||||
resolution: {integrity: sha512-KiRAp/VoJaWkkte84TvUd9qjdbZAdiqyvMxrGl1N6vzFogKmaLgoM3L1kgtLicp2HP5fBJS8JrZKLVIZGVJAVg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/core@7.26.10':
|
||||
resolution: {integrity: sha512-vMqyb7XCDMPvJFFOaT9kxtiRh42GwlZEg1/uIgtZshS5a/8OaduUfCi7kynKgc3Tw/6Uo2D+db9qBttghhmxwQ==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
@@ -3202,6 +3209,10 @@ packages:
|
||||
resolution: {integrity: sha512-IXuyn5EkouFJscIDuFF5EsiSolseme1s0CZB+QxVugqJLYmKdxI1VfIBOst0SUu4rnk2Z7kqTwmoO1lp3HIfnA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/helper-compilation-targets@7.27.2':
|
||||
resolution: {integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/helper-create-class-features-plugin@7.26.9':
|
||||
resolution: {integrity: sha512-ubbUqCofvxPRurw5L8WTsCLSkQiVpov4Qx0WMA+jUN+nXBK8ADPlJO1grkFw5CWKC5+sZSOfuGMdX1aI1iT9Sg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
@@ -3241,6 +3252,10 @@ packages:
|
||||
resolution: {integrity: sha512-RS+jZcRdZdRFzMyr+wcsaqOmld1/EqTghfaBGQQd/WnRdzdlvSZ//kF7U8VQTxf1ynZ4cjUcYgjVGx13ewNPMg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/helper-plugin-utils@7.27.1':
|
||||
resolution: {integrity: sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/helper-remap-async-to-generator@7.25.9':
|
||||
resolution: {integrity: sha512-IZtukuUeBbhgOcaW2s06OXTzVNJR0ybm4W5xC1opWFFJMZbwRj5LCk+ByYH7WdZPZTt8KnFwA8pvjN2yqcPlgw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
@@ -3261,14 +3276,26 @@ packages:
|
||||
resolution: {integrity: sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/helper-string-parser@7.27.1':
|
||||
resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/helper-validator-identifier@7.25.9':
|
||||
resolution: {integrity: sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/helper-validator-identifier@7.27.1':
|
||||
resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/helper-validator-option@7.25.9':
|
||||
resolution: {integrity: sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/helper-validator-option@7.27.1':
|
||||
resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/helper-wrap-function@7.25.9':
|
||||
resolution: {integrity: sha512-ETzz9UTjQSTmw39GboatdymDq4XIQbR8ySgVrylRhPOFpsd+JrKHIuF0de7GCWmem+T4uC5z7EZguod7Wj4A4g==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
@@ -3282,6 +3309,11 @@ packages:
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
'@babel/parser@7.27.5':
|
||||
resolution: {integrity: sha512-OsQd175SxWkGlzbny8J3K8TnnDD0N3lrIUtB92xwyRpzaenGZhxDvxN/JgU00U3CDZNj9tPuDJ5H0WS4Nt3vKg==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
'@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.25.9':
|
||||
resolution: {integrity: sha512-ZkRyVkThtxQ/J6nv3JFYv1RYY+JT5BvU0y3k5bWrmuG4woXypRa4PXmm9RhOwodRkYFWqC0C0cqcJ4OqR7kW+g==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
@@ -3742,6 +3774,10 @@ packages:
|
||||
resolution: {integrity: sha512-emqcG3vHrpxUKTrxcblR36dcrcoRDvKmnL/dCL6ZsHaShW80qxCAcNhzQZrpeM765VzEos+xOi4s+r4IXzTwdQ==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/types@7.27.6':
|
||||
resolution: {integrity: sha512-ETyHEk2VHHvl9b9jZP5IHPavHYk57EhanlRRuae9XCpb/j5bDCbPPMOBfCWhnl/7EDJz0jEMCi/RhccCE8r1+Q==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@bcoe/v8-coverage@0.2.3':
|
||||
resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==}
|
||||
|
||||
@@ -10362,6 +10398,9 @@ packages:
|
||||
join-component@1.1.0:
|
||||
resolution: {integrity: sha512-bF7vcQxbODoGK1imE2P9GS9aw4zD0Sd+Hni68IMZLj7zRnquH7dXUmMw9hDI5S/Jzt7q+IyTXN0rSg2GI0IKhQ==}
|
||||
|
||||
jose@6.0.11:
|
||||
resolution: {integrity: sha512-QxG7EaliDARm1O1S8BGakqncGT9s25bKL1WSf6/oa17Tkqwi8D2ZNglqCF+DsYF88/rV66Q/Q2mFAy697E1DUg==}
|
||||
|
||||
joycon@3.1.1:
|
||||
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -11627,10 +11666,6 @@ packages:
|
||||
object-inspect@1.13.1:
|
||||
resolution: {integrity: sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==}
|
||||
|
||||
object-inspect@1.13.2:
|
||||
resolution: {integrity: sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
object-inspect@1.13.4:
|
||||
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -11727,6 +11762,9 @@ packages:
|
||||
openapi-types@12.1.3:
|
||||
resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==}
|
||||
|
||||
openid-client@6.5.0:
|
||||
resolution: {integrity: sha512-fAfYaTnOYE2kQCqEJGX9KDObW2aw7IQy4jWpU/+3D3WoCFLbix5Hg6qIPQ6Js9r7f8jDUmsnnguRNCSw4wU/IQ==}
|
||||
|
||||
option@0.2.4:
|
||||
resolution: {integrity: sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==}
|
||||
|
||||
@@ -12892,10 +12930,6 @@ packages:
|
||||
side-channel@1.0.4:
|
||||
resolution: {integrity: sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==}
|
||||
|
||||
side-channel@1.0.6:
|
||||
resolution: {integrity: sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
side-channel@1.1.0:
|
||||
resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -15698,6 +15732,8 @@ snapshots:
|
||||
|
||||
'@babel/compat-data@7.26.8': {}
|
||||
|
||||
'@babel/compat-data@7.27.5': {}
|
||||
|
||||
'@babel/core@7.26.10':
|
||||
dependencies:
|
||||
'@ampproject/remapping': 2.3.0
|
||||
@@ -15714,7 +15750,7 @@ snapshots:
|
||||
debug: 4.4.1(supports-color@8.1.1)
|
||||
gensync: 1.0.0-beta.2
|
||||
json5: 2.2.3
|
||||
semver: 7.6.0
|
||||
semver: 7.7.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -15738,6 +15774,14 @@ snapshots:
|
||||
lru-cache: 5.1.1
|
||||
semver: 7.7.2
|
||||
|
||||
'@babel/helper-compilation-targets@7.27.2':
|
||||
dependencies:
|
||||
'@babel/compat-data': 7.27.5
|
||||
'@babel/helper-validator-option': 7.27.1
|
||||
browserslist: 4.24.4
|
||||
lru-cache: 5.1.1
|
||||
semver: 7.7.2
|
||||
|
||||
'@babel/helper-create-class-features-plugin@7.26.9(@babel/core@7.26.10)':
|
||||
dependencies:
|
||||
'@babel/core': 7.26.10
|
||||
@@ -15761,8 +15805,8 @@ snapshots:
|
||||
'@babel/helper-define-polyfill-provider@0.6.3(@babel/core@7.26.10)':
|
||||
dependencies:
|
||||
'@babel/core': 7.26.10
|
||||
'@babel/helper-compilation-targets': 7.26.5
|
||||
'@babel/helper-plugin-utils': 7.26.5
|
||||
'@babel/helper-compilation-targets': 7.27.2
|
||||
'@babel/helper-plugin-utils': 7.27.1
|
||||
debug: 4.4.1(supports-color@8.1.1)
|
||||
lodash.debounce: 4.0.8
|
||||
resolve: 1.22.8
|
||||
@@ -15798,6 +15842,8 @@ snapshots:
|
||||
|
||||
'@babel/helper-plugin-utils@7.26.5': {}
|
||||
|
||||
'@babel/helper-plugin-utils@7.27.1': {}
|
||||
|
||||
'@babel/helper-remap-async-to-generator@7.25.9(@babel/core@7.26.10)':
|
||||
dependencies:
|
||||
'@babel/core': 7.26.10
|
||||
@@ -15825,10 +15871,16 @@ snapshots:
|
||||
|
||||
'@babel/helper-string-parser@7.25.9': {}
|
||||
|
||||
'@babel/helper-string-parser@7.27.1': {}
|
||||
|
||||
'@babel/helper-validator-identifier@7.25.9': {}
|
||||
|
||||
'@babel/helper-validator-identifier@7.27.1': {}
|
||||
|
||||
'@babel/helper-validator-option@7.25.9': {}
|
||||
|
||||
'@babel/helper-validator-option@7.27.1': {}
|
||||
|
||||
'@babel/helper-wrap-function@7.25.9':
|
||||
dependencies:
|
||||
'@babel/template': 7.26.9
|
||||
@@ -15846,6 +15898,10 @@ snapshots:
|
||||
dependencies:
|
||||
'@babel/types': 7.26.10
|
||||
|
||||
'@babel/parser@7.27.5':
|
||||
dependencies:
|
||||
'@babel/types': 7.27.6
|
||||
|
||||
'@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.25.9(@babel/core@7.26.10)':
|
||||
dependencies:
|
||||
'@babel/core': 7.26.10
|
||||
@@ -15974,7 +16030,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@babel/core': 7.26.10
|
||||
'@babel/helper-create-regexp-features-plugin': 7.26.3(@babel/core@7.26.10)
|
||||
'@babel/helper-plugin-utils': 7.26.5
|
||||
'@babel/helper-plugin-utils': 7.27.1
|
||||
|
||||
'@babel/plugin-transform-arrow-functions@7.25.9(@babel/core@7.26.10)':
|
||||
dependencies:
|
||||
@@ -16366,14 +16422,14 @@ snapshots:
|
||||
babel-plugin-polyfill-corejs3: 0.11.1(@babel/core@7.26.10)
|
||||
babel-plugin-polyfill-regenerator: 0.6.3(@babel/core@7.26.10)
|
||||
core-js-compat: 3.41.0
|
||||
semver: 7.6.0
|
||||
semver: 7.7.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@babel/preset-modules@0.1.6-no-external-plugins(@babel/core@7.26.10)':
|
||||
dependencies:
|
||||
'@babel/core': 7.26.10
|
||||
'@babel/helper-plugin-utils': 7.26.5
|
||||
'@babel/helper-plugin-utils': 7.27.1
|
||||
'@babel/types': 7.26.10
|
||||
esutils: 2.0.3
|
||||
|
||||
@@ -16404,6 +16460,11 @@ snapshots:
|
||||
'@babel/helper-string-parser': 7.25.9
|
||||
'@babel/helper-validator-identifier': 7.25.9
|
||||
|
||||
'@babel/types@7.27.6':
|
||||
dependencies:
|
||||
'@babel/helper-string-parser': 7.27.1
|
||||
'@babel/helper-validator-identifier': 7.27.1
|
||||
|
||||
'@bcoe/v8-coverage@0.2.3': {}
|
||||
|
||||
'@bcoe/v8-coverage@1.0.2': {}
|
||||
@@ -17685,7 +17746,7 @@ snapshots:
|
||||
lodash: 4.17.21
|
||||
minimatch: 3.0.8
|
||||
resolve: 1.22.8
|
||||
semver: 7.6.0
|
||||
semver: 7.7.2
|
||||
source-map: 0.6.1
|
||||
typescript: 5.8.2
|
||||
transitivePeerDependencies:
|
||||
@@ -19283,7 +19344,7 @@ snapshots:
|
||||
jsdoc-type-pratt-parser: 4.1.0
|
||||
process: 0.11.10
|
||||
recast: 0.23.6
|
||||
semver: 7.6.0
|
||||
semver: 7.7.2
|
||||
util: 0.12.5
|
||||
ws: 8.17.1
|
||||
optionalDependencies:
|
||||
@@ -19541,24 +19602,24 @@ snapshots:
|
||||
|
||||
'@types/babel__core@7.20.0':
|
||||
dependencies:
|
||||
'@babel/parser': 7.26.10
|
||||
'@babel/types': 7.26.10
|
||||
'@babel/parser': 7.27.5
|
||||
'@babel/types': 7.27.6
|
||||
'@types/babel__generator': 7.6.4
|
||||
'@types/babel__template': 7.4.1
|
||||
'@types/babel__traverse': 7.18.2
|
||||
|
||||
'@types/babel__generator@7.6.4':
|
||||
dependencies:
|
||||
'@babel/types': 7.26.10
|
||||
'@babel/types': 7.27.6
|
||||
|
||||
'@types/babel__template@7.4.1':
|
||||
dependencies:
|
||||
'@babel/parser': 7.26.10
|
||||
'@babel/types': 7.26.10
|
||||
'@babel/parser': 7.27.5
|
||||
'@babel/types': 7.27.6
|
||||
|
||||
'@types/babel__traverse@7.18.2':
|
||||
dependencies:
|
||||
'@babel/types': 7.26.10
|
||||
'@babel/types': 7.27.6
|
||||
|
||||
'@types/basic-auth@1.1.3':
|
||||
dependencies:
|
||||
@@ -20095,7 +20156,7 @@ snapshots:
|
||||
globby: 11.1.0
|
||||
is-glob: 4.0.3
|
||||
minimatch: 9.0.3
|
||||
semver: 7.6.0
|
||||
semver: 7.7.2
|
||||
ts-api-utils: 1.0.1(typescript@5.8.2)
|
||||
optionalDependencies:
|
||||
typescript: 5.8.2
|
||||
@@ -20111,7 +20172,7 @@ snapshots:
|
||||
'@typescript-eslint/types': 6.21.0
|
||||
'@typescript-eslint/typescript-estree': 6.21.0(typescript@5.8.2)
|
||||
eslint: 8.57.0
|
||||
semver: 7.6.0
|
||||
semver: 7.7.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
- typescript
|
||||
@@ -20125,7 +20186,7 @@ snapshots:
|
||||
'@typescript-eslint/types': 7.2.0
|
||||
'@typescript-eslint/typescript-estree': 7.2.0(typescript@5.8.2)
|
||||
eslint: 8.57.0
|
||||
semver: 7.6.0
|
||||
semver: 7.7.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
- typescript
|
||||
@@ -20328,7 +20389,7 @@ snapshots:
|
||||
|
||||
'@vue/compiler-sfc@3.5.13':
|
||||
dependencies:
|
||||
'@babel/parser': 7.26.10
|
||||
'@babel/parser': 7.27.5
|
||||
'@vue/compiler-core': 3.5.13
|
||||
'@vue/compiler-dom': 3.5.13
|
||||
'@vue/compiler-ssr': 3.5.13
|
||||
@@ -20949,13 +21010,13 @@ snapshots:
|
||||
babel-plugin-jest-hoist@29.5.0:
|
||||
dependencies:
|
||||
'@babel/template': 7.26.9
|
||||
'@babel/types': 7.26.10
|
||||
'@babel/types': 7.27.6
|
||||
'@types/babel__core': 7.20.0
|
||||
'@types/babel__traverse': 7.18.2
|
||||
|
||||
babel-plugin-polyfill-corejs2@0.4.12(@babel/core@7.26.10):
|
||||
dependencies:
|
||||
'@babel/compat-data': 7.26.8
|
||||
'@babel/compat-data': 7.27.5
|
||||
'@babel/core': 7.26.10
|
||||
'@babel/helper-define-polyfill-provider': 0.6.3(@babel/core@7.26.10)
|
||||
semver: 7.7.2
|
||||
@@ -22366,7 +22427,7 @@ snapshots:
|
||||
is-string: 1.0.7
|
||||
is-typed-array: 1.1.13
|
||||
is-weakref: 1.0.2
|
||||
object-inspect: 1.13.2
|
||||
object-inspect: 1.13.4
|
||||
object-keys: 1.1.1
|
||||
object.assign: 4.1.5
|
||||
regexp.prototype.flags: 1.5.3
|
||||
@@ -22389,7 +22450,7 @@ snapshots:
|
||||
es-abstract: 1.23.3
|
||||
es-errors: 1.3.0
|
||||
function-bind: 1.1.2
|
||||
globalthis: 1.0.3
|
||||
globalthis: 1.0.4
|
||||
has-property-descriptors: 1.0.2
|
||||
set-function-name: 2.0.2
|
||||
|
||||
@@ -22511,7 +22572,7 @@ snapshots:
|
||||
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.8.2))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0)
|
||||
object.assign: 4.1.5
|
||||
object.entries: 1.1.5
|
||||
semver: 7.6.0
|
||||
semver: 7.7.2
|
||||
|
||||
eslint-config-airbnb-typescript@18.0.0(@typescript-eslint/eslint-plugin@7.2.0(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.8.2))(eslint@8.57.0)(typescript@5.8.2))(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.8.2))(eslint-plugin-import@2.29.1)(eslint@8.57.0):
|
||||
dependencies:
|
||||
@@ -23814,7 +23875,7 @@ snapshots:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
hasown: 2.0.2
|
||||
side-channel: 1.0.6
|
||||
side-channel: 1.1.0
|
||||
|
||||
interpret@1.4.0: {}
|
||||
|
||||
@@ -24020,7 +24081,7 @@ snapshots:
|
||||
istanbul-lib-instrument@5.2.1:
|
||||
dependencies:
|
||||
'@babel/core': 7.26.10
|
||||
'@babel/parser': 7.26.10
|
||||
'@babel/parser': 7.27.5
|
||||
'@istanbuljs/schema': 0.1.3
|
||||
istanbul-lib-coverage: 3.2.2
|
||||
semver: 7.7.2
|
||||
@@ -24467,6 +24528,8 @@ snapshots:
|
||||
|
||||
join-component@1.1.0: {}
|
||||
|
||||
jose@6.0.11: {}
|
||||
|
||||
joycon@3.1.1: {}
|
||||
|
||||
js-base64@3.7.2: {}
|
||||
@@ -25036,8 +25099,8 @@ snapshots:
|
||||
|
||||
magicast@0.3.5:
|
||||
dependencies:
|
||||
'@babel/parser': 7.26.10
|
||||
'@babel/types': 7.26.10
|
||||
'@babel/parser': 7.27.5
|
||||
'@babel/types': 7.27.6
|
||||
source-map-js: 1.2.1
|
||||
|
||||
mailparser@3.6.7:
|
||||
@@ -25060,7 +25123,7 @@ snapshots:
|
||||
|
||||
make-dir@3.1.0:
|
||||
dependencies:
|
||||
semver: 7.6.0
|
||||
semver: 7.7.2
|
||||
|
||||
make-dir@4.0.0:
|
||||
dependencies:
|
||||
@@ -25707,7 +25770,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@tediousjs/connection-string': 0.5.0
|
||||
commander: 11.1.0
|
||||
debug: 4.3.6(supports-color@8.1.1)
|
||||
debug: 4.4.1(supports-color@8.1.1)
|
||||
rfdc: 1.3.0
|
||||
tarn: 3.0.2
|
||||
tedious: 16.7.1
|
||||
@@ -25833,7 +25896,7 @@ snapshots:
|
||||
nopt: 5.0.0
|
||||
npmlog: 6.0.2
|
||||
rimraf: 3.0.2
|
||||
semver: 7.6.0
|
||||
semver: 7.7.2
|
||||
tar: 6.2.1
|
||||
which: 2.0.2
|
||||
transitivePeerDependencies:
|
||||
@@ -25995,8 +26058,6 @@ snapshots:
|
||||
|
||||
object-inspect@1.13.1: {}
|
||||
|
||||
object-inspect@1.13.2: {}
|
||||
|
||||
object-inspect@1.13.4: {}
|
||||
|
||||
object-is@1.1.6:
|
||||
@@ -26116,6 +26177,11 @@ snapshots:
|
||||
|
||||
openapi-types@12.1.3: {}
|
||||
|
||||
openid-client@6.5.0:
|
||||
dependencies:
|
||||
jose: 6.0.11
|
||||
oauth4webapi: 3.5.1
|
||||
|
||||
option@0.2.4: {}
|
||||
|
||||
optionator@0.8.3:
|
||||
@@ -27461,13 +27527,6 @@ snapshots:
|
||||
get-intrinsic: 1.2.4
|
||||
object-inspect: 1.13.1
|
||||
|
||||
side-channel@1.0.6:
|
||||
dependencies:
|
||||
call-bind: 1.0.7
|
||||
es-errors: 1.3.0
|
||||
get-intrinsic: 1.3.0
|
||||
object-inspect: 1.13.1
|
||||
|
||||
side-channel@1.1.0:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
@@ -27508,7 +27567,7 @@ snapshots:
|
||||
|
||||
simple-update-notifier@2.0.0:
|
||||
dependencies:
|
||||
semver: 7.6.0
|
||||
semver: 7.7.2
|
||||
|
||||
simple-wcswidth@1.0.1: {}
|
||||
|
||||
@@ -27660,8 +27719,7 @@ snapshots:
|
||||
|
||||
sprintf-js@1.1.2: {}
|
||||
|
||||
sprintf-js@1.1.3:
|
||||
optional: true
|
||||
sprintf-js@1.1.3: {}
|
||||
|
||||
sqlite3@5.1.7:
|
||||
dependencies:
|
||||
@@ -28050,7 +28108,7 @@ snapshots:
|
||||
jsbi: 4.3.0
|
||||
native-duplexpair: 1.0.0
|
||||
node-abort-controller: 3.1.1
|
||||
sprintf-js: 1.1.2
|
||||
sprintf-js: 1.1.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -28560,7 +28618,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
unplugin-vue-components@0.27.3(@babel/parser@7.26.10)(rollup@4.35.0)(vue@3.5.13(typescript@5.8.2)):
|
||||
unplugin-vue-components@0.27.3(@babel/parser@7.27.5)(rollup@4.35.0)(vue@3.5.13(typescript@5.8.2)):
|
||||
dependencies:
|
||||
'@antfu/utils': 0.7.10
|
||||
'@rollup/pluginutils': 5.1.0(rollup@4.35.0)
|
||||
@@ -28574,7 +28632,7 @@ snapshots:
|
||||
unplugin: 1.11.0
|
||||
vue: 3.5.13(typescript@5.8.2)
|
||||
optionalDependencies:
|
||||
'@babel/parser': 7.26.10
|
||||
'@babel/parser': 7.27.5
|
||||
transitivePeerDependencies:
|
||||
- rollup
|
||||
- supports-color
|
||||
@@ -28627,7 +28685,7 @@ snapshots:
|
||||
|
||||
utf7@1.0.2:
|
||||
dependencies:
|
||||
semver: 7.6.0
|
||||
semver: 7.7.2
|
||||
|
||||
utf8@2.1.2: {}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user