feat: Reduce unauthentication information in settings endpoint (#21133)

This commit is contained in:
Irénée
2025-10-27 10:31:01 +00:00
committed by GitHub
parent 0a82e8d23b
commit c859f4e793
12 changed files with 805 additions and 238 deletions
@@ -31,7 +31,37 @@ export interface IUserManagementSettings {
authenticationMethod: AuthenticationMethod;
}
export interface IEnterpriseSettings {
sharing: boolean;
ldap: boolean;
saml: boolean;
oidc: boolean;
mfaEnforcement: boolean;
logStreaming: boolean;
advancedExecutionFilters: boolean;
variables: boolean;
sourceControl: boolean;
auditLogs: boolean;
externalSecrets: boolean;
showNonProdBanner: boolean;
debugInEditor: boolean;
binaryDataS3: boolean;
workflowHistory: boolean;
workerView: boolean;
advancedPermissions: boolean;
apiKeyScopes: boolean;
workflowDiffs: boolean;
provisioning: boolean;
projects: {
team: {
limit: number;
};
};
customRoles: boolean;
}
export interface FrontendSettings {
settingsMode?: 'public' | 'authenticated';
inE2ETests: boolean;
isDocker: boolean;
databaseType: 'sqlite' | 'mariadb' | 'mysqldb' | 'postgresdb';
@@ -145,34 +175,7 @@ export interface FrontendSettings {
builtIn?: string[];
external?: string[];
};
enterprise: {
sharing: boolean;
ldap: boolean;
saml: boolean;
oidc: boolean;
mfaEnforcement: boolean;
logStreaming: boolean;
advancedExecutionFilters: boolean;
variables: boolean;
sourceControl: boolean;
auditLogs: boolean;
externalSecrets: boolean;
showNonProdBanner: boolean;
debugInEditor: boolean;
binaryDataS3: boolean;
workflowHistory: boolean;
workerView: boolean;
advancedPermissions: boolean;
apiKeyScopes: boolean;
workflowDiffs: boolean;
provisioning: boolean;
projects: {
team: {
limit: number;
};
};
customRoles: boolean;
};
enterprise: IEnterpriseSettings;
hideUsagePage: boolean;
license: {
planName?: string;
@@ -99,7 +99,7 @@ describe('AuthService', () => {
});
});
describe('authMiddleware', () => {
describe('createAuthMiddleware', () => {
const mockReq = () =>
mock<AuthenticatedRequest>({
cookies: {},
@@ -331,6 +331,180 @@ describe('AuthService', () => {
expect(res.status).not.toHaveBeenCalled();
});
});
describe('allowUnauthenticated', () => {
it('should populate the user info if the token is valid', async () => {
const req = mockReq();
req.cookies[AUTH_COOKIE_NAME] = validToken;
invalidAuthTokenRepository.existsBy.mockResolvedValue(false);
userRepository.findOne.mockResolvedValue(user);
const middleware = authService.createAuthMiddleware({
allowSkipMFA: false,
allowUnauthenticated: true,
});
await middleware(req, res, next);
expect(invalidAuthTokenRepository.existsBy).toHaveBeenCalled();
expect(userRepository.findOne).toHaveBeenCalled();
expect(req.user).toBe(user);
expect(next).toHaveBeenCalled();
expect(res.clearCookie).not.toHaveBeenCalled();
});
it('should clear the cookie if the token is expired', async () => {
const req = mockReq();
req.cookies[AUTH_COOKIE_NAME] = validToken;
invalidAuthTokenRepository.existsBy.mockResolvedValue(false);
jest.advanceTimersByTime(365 * Time.days.toMilliseconds);
const middleware = authService.createAuthMiddleware({
allowSkipMFA: false,
allowUnauthenticated: true,
});
await middleware(req, res, next);
expect(invalidAuthTokenRepository.existsBy).toHaveBeenCalled();
expect(userRepository.findOne).not.toHaveBeenCalled();
expect(req.user).toBeUndefined();
expect(next).toHaveBeenCalled();
expect(res.clearCookie).toHaveBeenCalledWith(AUTH_COOKIE_NAME);
});
it('should clear the cookie if the token has been invalidated', async () => {
const req = mockReq();
req.cookies[AUTH_COOKIE_NAME] = validToken;
invalidAuthTokenRepository.existsBy.mockResolvedValue(true);
const middleware = authService.createAuthMiddleware({
allowSkipMFA: false,
allowUnauthenticated: true,
});
await middleware(req, res, next);
expect(invalidAuthTokenRepository.existsBy).toHaveBeenCalled();
expect(userRepository.findOne).not.toHaveBeenCalled();
expect(req.user).toBeUndefined();
expect(next).toHaveBeenCalled();
expect(res.clearCookie).toHaveBeenCalledWith(AUTH_COOKIE_NAME);
});
it('should not populate the user info if the token is invalid', async () => {
const req = mockReq();
req.cookies[AUTH_COOKIE_NAME] = 'invalid-token';
invalidAuthTokenRepository.existsBy.mockResolvedValue(false);
const middleware = authService.createAuthMiddleware({
allowSkipMFA: false,
allowUnauthenticated: true,
});
await middleware(req, res, next);
expect(invalidAuthTokenRepository.existsBy).toHaveBeenCalled();
expect(userRepository.findOne).not.toHaveBeenCalled();
expect(req.user).toBeUndefined();
expect(next).toHaveBeenCalled();
expect(res.clearCookie).toHaveBeenCalledWith(AUTH_COOKIE_NAME);
});
it('should not populate the user info if the token is not set', async () => {
const req = mockReq();
req.cookies[AUTH_COOKIE_NAME] = undefined;
const middleware = authService.createAuthMiddleware({
allowSkipMFA: false,
allowUnauthenticated: true,
});
await middleware(req, res, next);
expect(invalidAuthTokenRepository.existsBy).not.toHaveBeenCalled();
expect(userRepository.findOne).not.toHaveBeenCalled();
expect(req.user).toBeUndefined();
expect(next).toHaveBeenCalled();
expect(res.clearCookie).not.toHaveBeenCalled();
});
it('should clear cookie if MFA required and not used', async () => {
const userWithMfa = mock<User>({ ...userData, mfaEnabled: true, mfaSecret: 'secret' });
const req = mockReq();
req.cookies[AUTH_COOKIE_NAME] = validToken; // validToken has usedMfa: false
invalidAuthTokenRepository.existsBy.mockResolvedValue(false);
userRepository.findOne.mockResolvedValue(userWithMfa);
mfaService.isMFAEnforced.mockReturnValue(true);
const middleware = authService.createAuthMiddleware({
allowSkipMFA: false,
allowUnauthenticated: true,
});
await middleware(req, res, next);
expect(invalidAuthTokenRepository.existsBy).toHaveBeenCalled();
expect(userRepository.findOne).toHaveBeenCalled();
expect(req.user).toBeUndefined();
expect(next).toHaveBeenCalled();
expect(res.clearCookie).toHaveBeenCalledWith(AUTH_COOKIE_NAME);
});
it('should skip user when MFA enforced and user has no MFA', async () => {
const req = mockReq();
req.cookies[AUTH_COOKIE_NAME] = validToken; // validToken has usedMfa: false
invalidAuthTokenRepository.existsBy.mockResolvedValue(false);
userRepository.findOne.mockResolvedValue(user); // user has mfaEnabled: false
mfaService.isMFAEnforced.mockReturnValue(true);
const middleware = authService.createAuthMiddleware({
allowSkipMFA: false,
allowUnauthenticated: true,
});
await middleware(req, res, next);
expect(invalidAuthTokenRepository.existsBy).toHaveBeenCalled();
expect(userRepository.findOne).toHaveBeenCalled();
expect(req.user).toBeUndefined();
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
expect(res.clearCookie).not.toHaveBeenCalled();
});
it('should work correctly when both allowUnauthenticated and allowSkipPreviewAuth are true in preview mode', async () => {
const originalPreviewMode = process.env.N8N_PREVIEW_MODE;
process.env.N8N_PREVIEW_MODE = 'true';
const req = mockReq();
req.cookies[AUTH_COOKIE_NAME] = undefined;
const middleware = authService.createAuthMiddleware({
allowSkipMFA: false,
allowUnauthenticated: true,
allowSkipPreviewAuth: true,
});
await middleware(req, res, next);
expect(invalidAuthTokenRepository.existsBy).not.toHaveBeenCalled();
expect(userRepository.findOne).not.toHaveBeenCalled();
expect(req.user).toBeUndefined();
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
// Restore original value
if (originalPreviewMode === undefined) {
delete process.env.N8N_PREVIEW_MODE;
} else {
process.env.N8N_PREVIEW_MODE = originalPreviewMode;
}
});
});
});
describe('issueCookie', () => {
+19 -2
View File
@@ -47,6 +47,12 @@ interface CreateAuthMiddlewareOptions {
* If true, authentication becomes optional in preview mode
*/
allowSkipPreviewAuth?: boolean;
/**
* If true, the middleware will not throw an error if authentication fails
* and will instead call next() regardless of authentication status.
* Use this for endpoints that should return different data for authenticated vs unauthenticated users.
*/
allowUnauthenticated?: boolean;
}
@Service()
@@ -83,21 +89,32 @@ export class AuthService {
];
}
createAuthMiddleware({ allowSkipMFA, allowSkipPreviewAuth }: CreateAuthMiddlewareOptions) {
createAuthMiddleware({
allowSkipMFA,
allowSkipPreviewAuth,
allowUnauthenticated,
}: CreateAuthMiddlewareOptions) {
return async (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
const token = req.cookies[AUTH_COOKIE_NAME];
if (token) {
try {
const isInvalid = await this.invalidAuthTokenRepository.existsBy({ token });
if (isInvalid) throw new AuthError('Unauthorized');
const [user, { usedMfa }] = await this.resolveJwt(token, req, res);
const mfaEnforced = this.mfaService.isMFAEnforced();
if (mfaEnforced && !usedMfa && !allowSkipMFA) {
// If MFA is enforced, we need to check if the user has MFA enabled and used it during authentication
if (user.mfaEnabled) {
// If the user has MFA enforced, but did not use it during authentication, we need to throw an error
throw new AuthError('MFA not used during authentication');
} else {
if (allowUnauthenticated) {
return next();
}
// In this case we don't want to clear the cookie, to allow for MFA setup
res.status(401).json({ status: 'error', message: 'Unauthorized', mfaRequired: true });
return;
@@ -118,7 +135,7 @@ export class AuthService {
}
const isPreviewMode = process.env.N8N_PREVIEW_MODE === 'true';
const shouldSkipAuth = allowSkipPreviewAuth && isPreviewMode;
const shouldSkipAuth = (allowSkipPreviewAuth && isPreviewMode) || allowUnauthenticated;
if (req.user) next();
else if (shouldSkipAuth) next();
+18 -12
View File
@@ -1,7 +1,7 @@
import { inDevelopment, inProduction } from '@n8n/backend-common';
import { SecurityConfig } from '@n8n/config';
import { Time } from '@n8n/constants';
import type { APIRequest } from '@n8n/db';
import type { APIRequest, AuthenticatedRequest } from '@n8n/db';
import { Container, Service } from '@n8n/di';
import cookieParser from 'cookie-parser';
import express from 'express';
@@ -269,17 +269,7 @@ export class Server extends AbstractServer {
res.sendFile(tzDataFile, { dotfiles: 'allow' }),
);
// ----------------------------------------
// Settings
// ----------------------------------------
if (frontendService) {
// Returns the current settings for the UI
this.app.get(
`/${this.restEndpoint}/settings`,
ResponseHelper.send(async () => frontendService.getSettings()),
);
}
this.configureSettingsRoute();
// ----------------------------------------
// EventBus Setup
@@ -482,6 +472,22 @@ export class Server extends AbstractServer {
installGlobalProxyAgent();
}
private configureSettingsRoute() {
const { frontendService } = this;
const authService = Container.get(AuthService);
if (frontendService) {
// Returns the current settings for the UI
this.app.get(
`/${this.restEndpoint}/settings`,
authService.createAuthMiddleware({ allowSkipMFA: false, allowUnauthenticated: true }),
ResponseHelper.send(async (req: AuthenticatedRequest) => {
return req.user ? frontendService.getSettings() : frontendService.getPublicSettings();
}),
);
}
}
protected setupPushServer(): void {
const { restEndpoint, server, app } = this;
Container.get(Push).setupPushServer(restEndpoint, server, app);
@@ -1,8 +1,9 @@
import type { Logger, LicenseState, ModuleRegistry } from '@n8n/backend-common';
import { N8N_VERSION } from '@/constants';
import type { LicenseState, Logger, ModuleRegistry } from '@n8n/backend-common';
import type { GlobalConfig, SecurityConfig } from '@n8n/config';
import { Container } from '@n8n/di';
import { mock } from 'jest-mock-extended';
import type { InstanceSettings, BinaryDataConfig } from 'n8n-core';
import type { BinaryDataConfig, InstanceSettings } from 'n8n-core';
import type { CredentialTypes } from '@/credential-types';
import type { CredentialsOverwrites } from '@/credentials-overwrites';
@@ -11,50 +12,139 @@ import type { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
import type { MfaService } from '@/mfa/mfa.service';
import { CommunityPackagesConfig } from '@/modules/community-packages/community-packages.config';
import type { PushConfig } from '@/push/push.config';
import { FrontendService } from '@/services/frontend.service';
import { FrontendService, type PublicFrontendSettings } from '@/services/frontend.service';
import type { UrlService } from '@/services/url.service';
import type { UserManagementMailer } from '@/user-management/email';
describe('FrontendService', () => {
let originalEnv: NodeJS.ProcessEnv;
const globalConfig = mock<GlobalConfig>({
database: { type: 'sqlite' },
endpoints: { rest: 'rest' },
diagnostics: { enabled: false },
templates: { enabled: false, host: '' },
nodes: {},
tags: { disabled: false },
logging: { level: 'info' },
hiringBanner: { enabled: false },
versionNotifications: {
enabled: false,
endpoint: '',
whatsNewEnabled: false,
whatsNewEndpoint: '',
infoUrl: '',
},
personalization: { enabled: false },
defaultLocale: 'en',
auth: { cookie: { secure: false } },
generic: { releaseChannel: 'stable', timezone: 'UTC' },
publicApi: { path: 'api', swaggerUiDisabled: false },
workflows: { callerPolicyDefaultOption: 'workflowsFromSameOwner' },
executions: { pruneData: false, pruneDataMaxAge: 336, pruneDataMaxCount: 10000 },
hideUsagePage: false,
license: { tenantId: 1 },
mfa: { enabled: false },
deployment: { type: 'default' },
workflowHistory: { enabled: false },
path: '',
sso: {
ldap: { loginEnabled: false },
saml: { loginEnabled: false },
oidc: { loginEnabled: false },
},
});
const instanceSettings = mock<InstanceSettings>({
isDocker: false,
instanceId: 'test-instance',
isMultiMain: false,
hostId: 'test-host',
staticCacheDir: '/tmp/test-cache',
});
const logger = mock<Logger>();
const loadNodesAndCredentials = mock<LoadNodesAndCredentials>({
addPostProcessor: jest.fn(),
types: {
credentials: [],
nodes: [],
},
});
const binaryDataConfig = mock<BinaryDataConfig>({
mode: 'default',
availableModes: ['default'],
});
const credentialTypes = mock<CredentialTypes>({
getParentTypes: jest.fn().mockReturnValue([]),
});
const credentialsOverwrites = mock<CredentialsOverwrites>({
getAll: jest.fn().mockReturnValue({}),
});
const license = mock<License>({
getUsersLimit: jest.fn().mockReturnValue(100),
getPlanName: jest.fn().mockReturnValue('Community'),
getConsumerId: jest.fn().mockReturnValue('test-consumer'),
isSharingEnabled: jest.fn().mockReturnValue(false),
isLogStreamingEnabled: jest.fn().mockReturnValue(false),
isLdapEnabled: jest.fn().mockReturnValue(false),
isSamlEnabled: jest.fn().mockReturnValue(false),
isAdvancedExecutionFiltersEnabled: jest.fn().mockReturnValue(false),
isVariablesEnabled: jest.fn().mockReturnValue(false),
isSourceControlLicensed: jest.fn().mockReturnValue(false),
isExternalSecretsEnabled: jest.fn().mockReturnValue(false),
isLicensed: jest.fn().mockReturnValue(false),
isDebugInEditorLicensed: jest.fn().mockReturnValue(false),
isWorkflowHistoryLicensed: jest.fn().mockReturnValue(false),
isWorkerViewLicensed: jest.fn().mockReturnValue(false),
isAdvancedPermissionsLicensed: jest.fn().mockReturnValue(false),
isApiKeyScopesEnabled: jest.fn().mockReturnValue(false),
getVariablesLimit: jest.fn().mockReturnValue(0),
getTeamProjectLimit: jest.fn().mockReturnValue(0),
isBinaryDataS3Licensed: jest.fn().mockReturnValue(false),
isAiAssistantEnabled: jest.fn().mockReturnValue(false),
isAskAiEnabled: jest.fn().mockReturnValue(false),
isAiCreditsEnabled: jest.fn().mockReturnValue(false),
getAiCredits: jest.fn().mockReturnValue(0),
isFoldersEnabled: jest.fn().mockReturnValue(false),
});
const mailer = mock<UserManagementMailer>({
isEmailSetUp: false,
});
const urlService = mock<UrlService>({
getInstanceBaseUrl: jest.fn().mockReturnValue('http://localhost:5678'),
getWebhookBaseUrl: jest.fn().mockReturnValue('http://localhost:5678'),
});
const securityConfig = mock<SecurityConfig>({
blockFileAccessToN8nFiles: false,
});
const pushConfig = mock<PushConfig>({
backend: 'websocket',
});
const licenseState = mock<LicenseState>({
isOidcLicensed: jest.fn().mockReturnValue(false),
isMFAEnforcementLicensed: jest.fn().mockReturnValue(false),
getMaxWorkflowsWithEvaluations: jest.fn().mockReturnValue(0),
});
const moduleRegistry = mock<ModuleRegistry>({
getActiveModules: jest.fn().mockReturnValue([]),
});
const mfaService = mock<MfaService>({
isMFAEnforced: jest.fn().mockReturnValue(false),
});
const createMockService = () => {
const globalConfig = mock<GlobalConfig>({
database: { type: 'sqlite' },
endpoints: { rest: 'rest' },
diagnostics: { enabled: false },
templates: { enabled: false, host: '' },
nodes: {},
tags: { disabled: false },
logging: { level: 'info' },
hiringBanner: { enabled: false },
versionNotifications: {
enabled: false,
endpoint: '',
whatsNewEnabled: false,
whatsNewEndpoint: '',
infoUrl: '',
},
personalization: { enabled: false },
defaultLocale: 'en',
auth: { cookie: { secure: false } },
generic: { releaseChannel: 'stable', timezone: 'UTC' },
publicApi: { path: 'api', swaggerUiDisabled: false },
workflows: { callerPolicyDefaultOption: 'workflowsFromSameOwner' },
executions: { pruneData: false, pruneDataMaxAge: 336, pruneDataMaxCount: 10000 },
hideUsagePage: false,
license: { tenantId: 1 },
mfa: { enabled: false },
deployment: { type: 'default' },
workflowHistory: { enabled: false },
path: '',
sso: {
ldap: { loginEnabled: false },
saml: { loginEnabled: false },
oidc: { loginEnabled: false },
},
});
Container.set(
CommunityPackagesConfig,
mock<CommunityPackagesConfig>({
@@ -62,95 +152,6 @@ describe('FrontendService', () => {
}),
);
const logger = mock<Logger>();
const instanceSettings = mock<InstanceSettings>({
isDocker: false,
instanceId: 'test-instance',
isMultiMain: false,
hostId: 'test-host',
staticCacheDir: '/tmp/test-cache',
});
const loadNodesAndCredentials = mock<LoadNodesAndCredentials>({
addPostProcessor: jest.fn(),
types: {
credentials: [],
nodes: [],
},
});
const binaryDataConfig = mock<BinaryDataConfig>({
mode: 'default',
availableModes: ['default'],
});
const credentialTypes = mock<CredentialTypes>({
getParentTypes: jest.fn().mockReturnValue([]),
});
const credentialsOverwrites = mock<CredentialsOverwrites>({
getAll: jest.fn().mockReturnValue({}),
});
const license = mock<License>({
getUsersLimit: jest.fn().mockReturnValue(100),
getPlanName: jest.fn().mockReturnValue('Community'),
getConsumerId: jest.fn().mockReturnValue('test-consumer'),
isSharingEnabled: jest.fn().mockReturnValue(false),
isLogStreamingEnabled: jest.fn().mockReturnValue(false),
isLdapEnabled: jest.fn().mockReturnValue(false),
isSamlEnabled: jest.fn().mockReturnValue(false),
isAdvancedExecutionFiltersEnabled: jest.fn().mockReturnValue(false),
isVariablesEnabled: jest.fn().mockReturnValue(false),
isSourceControlLicensed: jest.fn().mockReturnValue(false),
isExternalSecretsEnabled: jest.fn().mockReturnValue(false),
isLicensed: jest.fn().mockReturnValue(false),
isDebugInEditorLicensed: jest.fn().mockReturnValue(false),
isWorkflowHistoryLicensed: jest.fn().mockReturnValue(false),
isWorkerViewLicensed: jest.fn().mockReturnValue(false),
isAdvancedPermissionsLicensed: jest.fn().mockReturnValue(false),
isApiKeyScopesEnabled: jest.fn().mockReturnValue(false),
getVariablesLimit: jest.fn().mockReturnValue(0),
getTeamProjectLimit: jest.fn().mockReturnValue(0),
isBinaryDataS3Licensed: jest.fn().mockReturnValue(false),
isAiAssistantEnabled: jest.fn().mockReturnValue(false),
isAskAiEnabled: jest.fn().mockReturnValue(false),
isAiCreditsEnabled: jest.fn().mockReturnValue(false),
getAiCredits: jest.fn().mockReturnValue(0),
isFoldersEnabled: jest.fn().mockReturnValue(false),
});
const mailer = mock<UserManagementMailer>({
isEmailSetUp: false,
});
const urlService = mock<UrlService>({
getInstanceBaseUrl: jest.fn().mockReturnValue('http://localhost:5678'),
getWebhookBaseUrl: jest.fn().mockReturnValue('http://localhost:5678'),
});
const securityConfig = mock<SecurityConfig>({
blockFileAccessToN8nFiles: false,
});
const pushConfig = mock<PushConfig>({
backend: 'websocket',
});
const licenseState = mock<LicenseState>({
isOidcLicensed: jest.fn().mockReturnValue(false),
isMFAEnforcementLicensed: jest.fn().mockReturnValue(false),
getMaxWorkflowsWithEvaluations: jest.fn().mockReturnValue(0),
});
const moduleRegistry = mock<ModuleRegistry>({
getActiveModules: jest.fn().mockReturnValue([]),
});
const mfaService = mock<MfaService>({
isMFAEnforced: jest.fn().mockReturnValue(false),
});
return {
service: new FrontendService(
globalConfig,
@@ -182,6 +183,68 @@ describe('FrontendService', () => {
process.env = originalEnv;
});
describe('getSettings', () => {
it('should return frontend settings', () => {
const { service } = createMockService();
const settings = service.getSettings();
expect(settings).toEqual(
expect.objectContaining({
settingsMode: 'authenticated',
}),
);
});
});
describe('getPublicSettings', () => {
it('should return public settings', () => {
const expectedPublicSettings: PublicFrontendSettings = {
settingsMode: 'public',
instanceId: instanceSettings.instanceId,
defaultLocale: globalConfig.defaultLocale,
versionCli: N8N_VERSION,
releaseChannel: globalConfig.generic.releaseChannel,
versionNotifications: {
enabled: globalConfig.versionNotifications.enabled,
endpoint: globalConfig.versionNotifications.endpoint,
whatsNewEnabled: globalConfig.versionNotifications.whatsNewEnabled,
whatsNewEndpoint: globalConfig.versionNotifications.whatsNewEndpoint,
infoUrl: globalConfig.versionNotifications.infoUrl,
},
userManagement: {
quota: 100,
smtpSetup: false,
showSetupOnFirstLoad: true,
authenticationMethod: 'email',
},
sso: {
saml: { loginEnabled: false, loginLabel: '' },
ldap: { loginEnabled: false, loginLabel: '' },
oidc: {
loginEnabled: false,
loginUrl: 'http://localhost:5678/rest/sso/oidc/login',
callbackUrl: 'http://localhost:5678/rest/sso/oidc/callback',
},
},
mfa: { enabled: false, enforced: false },
authCookie: { secure: false },
oauthCallbackUrls: {
oauth1: 'http://localhost:5678/rest/oauth1-credential/callback',
oauth2: 'http://localhost:5678/rest/oauth2-credential/callback',
},
banners: { dismissed: [] },
previewMode: false,
telemetry: { enabled: false },
enterprise: { saml: false, ldap: false, oidc: false, showNonProdBanner: false },
};
const { service } = createMockService();
const settings = service.getPublicSettings();
expect(settings).toEqual(expectedPublicSettings);
});
});
describe('envFeatureFlags functionality', () => {
describe('collectEnvFeatureFlags', () => {
it('should collect environment variables with N8N_ENV_FEAT_ prefix', () => {
+74 -1
View File
@@ -1,4 +1,9 @@
import type { FrontendSettings, ITelemetrySettings, N8nEnvFeatFlags } from '@n8n/api-types';
import type {
FrontendSettings,
IEnterpriseSettings,
ITelemetrySettings,
N8nEnvFeatFlags,
} from '@n8n/api-types';
import { LicenseState, Logger, ModuleRegistry } from '@n8n/backend-common';
import { GlobalConfig, SecurityConfig } from '@n8n/config';
import { LICENSE_FEATURES } from '@n8n/constants';
@@ -32,6 +37,31 @@ import {
import { UrlService } from './url.service';
export type PublicEnterpriseSettings = Pick<
IEnterpriseSettings,
'saml' | 'ldap' | 'oidc' | 'showNonProdBanner'
>;
export type PublicFrontendSettings = Pick<
FrontendSettings,
| 'settingsMode'
| 'instanceId'
| 'defaultLocale'
| 'versionCli'
| 'releaseChannel'
| 'versionNotifications'
| 'userManagement'
| 'sso'
| 'mfa'
| 'authCookie'
| 'oauthCallbackUrls'
| 'banners'
| 'previewMode'
| 'telemetry'
> & {
enterprise: PublicEnterpriseSettings;
};
@Service()
export class FrontendService {
settings: FrontendSettings;
@@ -105,6 +135,7 @@ export class FrontendService {
}
this.settings = {
settingsMode: 'authenticated',
inE2ETests,
isDocker: this.instanceSettings.isDocker,
databaseType: this.globalConfig.database.type,
@@ -458,6 +489,48 @@ export class FrontendService {
return this.settings;
}
/**
* Only add settings that are absolutely necessary for non-authenticated pages
* @returns Public settings for unauthenticated users
*/
getPublicSettings(): PublicFrontendSettings {
// Get full settings to ensure all required properties are initialized
const {
instanceId,
defaultLocale,
versionCli,
releaseChannel,
versionNotifications,
userManagement,
sso,
mfa,
authCookie,
oauthCallbackUrls,
banners,
previewMode,
telemetry,
enterprise: { saml, ldap, oidc, showNonProdBanner },
} = this.getSettings();
return {
settingsMode: 'public',
instanceId,
defaultLocale,
versionCli,
releaseChannel,
versionNotifications,
userManagement,
sso,
mfa,
authCookie,
oauthCallbackUrls,
banners,
previewMode,
telemetry,
enterprise: { saml, ldap, oidc, showNonProdBanner },
};
}
getModuleSettings() {
return Object.fromEntries(this.moduleRegistry.settings);
}
@@ -2,16 +2,20 @@ import type { CurrentUserResponse } from '@n8n/rest-api-client/api/users';
import { useUsersStore } from './users.store';
import { createPinia, setActivePinia } from 'pinia';
const { loginCurrentUser, inviteUsers } = vi.hoisted(() => {
const { loginCurrentUser, inviteUsers, login, logout } = vi.hoisted(() => {
return {
loginCurrentUser: vi.fn(),
identify: vi.fn(),
inviteUsers: vi.fn(),
login: vi.fn(),
logout: vi.fn(),
};
});
vi.mock('@n8n/rest-api-client/api/users', () => ({
loginCurrentUser,
login,
logout,
}));
vi.mock('./invitation.api', () => ({
@@ -149,4 +153,100 @@ describe('users.store', () => {
});
});
});
describe('loggingHooks', () => {
it('should run all registered loginHooks', async () => {
const usersStore = useUsersStore();
loginCurrentUser.mockResolvedValueOnce(mockUser);
const hook1 = vi.fn(async () => {});
const hook2 = vi.fn(async () => {});
const hook3 = vi.fn(async () => {});
usersStore.registerLoginHook(hook1);
usersStore.registerLoginHook(hook2);
usersStore.registerLoginHook(hook3);
await usersStore.loginWithCookie();
expect(hook1).toHaveBeenCalled();
expect(hook2).toHaveBeenCalled();
expect(hook3).toHaveBeenCalled();
});
it('should fail silently if a login hook fails', async () => {
const usersStore = useUsersStore();
login.mockResolvedValueOnce(mockUser);
const errorHook = vi.fn(() => {
throw new Error('Hook failed');
});
const errorAsyncHook = vi.fn(async () => {
throw new Error('Hook failed');
});
const successAsyncHook = vi.fn(async () => {});
const successHook = vi.fn();
usersStore.registerLoginHook(errorHook);
usersStore.registerLoginHook(errorAsyncHook);
usersStore.registerLoginHook(successAsyncHook);
usersStore.registerLoginHook(successHook);
await usersStore.loginWithCreds({
emailOrLdapLoginId: 'test@n8n.io',
password: 'test-password',
});
expect(errorHook).toHaveBeenCalled();
expect(errorAsyncHook).toHaveBeenCalled();
expect(successAsyncHook).toHaveBeenCalled();
expect(successHook).toHaveBeenCalled();
});
});
describe('logoutHooks', () => {
it('should run all registered logoutHooks', async () => {
const usersStore = useUsersStore();
const hook1 = vi.fn();
const hook2 = vi.fn(async () => {});
const hook3 = vi.fn(async () => {});
usersStore.registerLogoutHook(hook1);
usersStore.registerLogoutHook(hook2);
usersStore.registerLogoutHook(hook3);
await usersStore.logout();
expect(hook1).toHaveBeenCalled();
expect(hook2).toHaveBeenCalled();
expect(hook3).toHaveBeenCalled();
});
it('should fail silently if a logout hook fails', async () => {
const usersStore = useUsersStore();
logout.mockResolvedValueOnce(mockUser);
const errorHook = vi.fn(() => {
throw new Error('Hook failed');
});
const errorAsyncHook = vi.fn(async () => {
throw new Error('Hook failed');
});
const successAsyncHook = vi.fn(async () => {});
const successHook = vi.fn();
usersStore.registerLogoutHook(errorHook);
usersStore.registerLogoutHook(errorAsyncHook);
usersStore.registerLogoutHook(successAsyncHook);
usersStore.registerLogoutHook(successHook);
await usersStore.logout();
expect(errorHook).toHaveBeenCalled();
expect(errorAsyncHook).toHaveBeenCalled();
expect(successAsyncHook).toHaveBeenCalled();
expect(successHook).toHaveBeenCalled();
});
});
});
@@ -38,8 +38,8 @@ const _isDefaultUser = (user: IUserResponse | null) =>
_isInstanceOwner(user) && _isPendingUser(user);
const _isAdmin = (user: IUserResponse | null) => user?.role === ROLE.Admin;
export type LoginHook = (user: CurrentUserResponse) => void;
type LogoutHook = () => void;
export type LoginHook = (user: CurrentUserResponse) => void | Promise<void>;
type LogoutHook = () => void | Promise<void>;
export const useUsersStore = defineStore(STORES.USERS, () => {
const initialized = ref(false);
@@ -147,13 +147,13 @@ export const useUsersStore = defineStore(STORES.USERS, () => {
});
};
const setCurrentUser = (user: CurrentUserResponse) => {
const setCurrentUser = async (user: CurrentUserResponse) => {
addUsers([user]);
currentUserId.value = user.id;
for (const hook of loginHooks.value) {
try {
hook(user);
await hook(user);
} catch (error) {
console.error('Error executing login hook:', error);
}
@@ -166,7 +166,7 @@ export const useUsersStore = defineStore(STORES.USERS, () => {
return;
}
setCurrentUser(user);
await setCurrentUser(user);
};
const initialize = async (options: { quota?: number } = {}) => {
@@ -213,7 +213,7 @@ export const useUsersStore = defineStore(STORES.USERS, () => {
return;
}
setCurrentUser(user);
await setCurrentUser(user);
};
const registerLoginHook = (hook: LoginHook) => {
@@ -231,7 +231,7 @@ export const useUsersStore = defineStore(STORES.USERS, () => {
for (const hook of logoutHooks.value) {
try {
hook();
await hook();
} catch (error) {
console.error('Error executing logout hook:', error);
}
@@ -248,7 +248,7 @@ export const useUsersStore = defineStore(STORES.USERS, () => {
}) => {
const user = await usersApi.setupOwner(rootStore.restApiContext, params);
if (user) {
setCurrentUser(user);
await setCurrentUser(user);
settingsStore.stopShowingSetupPage();
}
};
@@ -266,7 +266,7 @@ export const useUsersStore = defineStore(STORES.USERS, () => {
}) => {
const user = await invitationsApi.acceptInvitation(rootStore.restApiContext, params);
if (user) {
setCurrentUser(user);
await setCurrentUser(user);
}
};
+3 -3
View File
@@ -120,9 +120,9 @@ describe('Init', () => {
it('should correctly identify the user for telemetry', async () => {
const telemetryIdentifySpy = vi.spyOn(telemetry, 'identify');
usersStore.registerLoginHook.mockImplementation((hook) =>
hook(mock<CurrentUserResponse>({ id: 'userId' })),
);
usersStore.registerLoginHook.mockImplementation(async (hook) => {
await hook(mock<CurrentUserResponse>({ id: 'userId' }));
});
rootStore.instanceId = 'testInstanceId';
rootStore.versionCli = '1.102.0';
+3 -1
View File
@@ -226,7 +226,9 @@ function registerAuthenticationHooks() {
const RBACStore = useRBACStore();
const settingsStore = useSettingsStore();
usersStore.registerLoginHook((user) => {
usersStore.registerLoginHook(async (user) => {
await settingsStore.getSettings();
RBACStore.setGlobalScopes(user.globalScopes ?? []);
telemetry.identify(rootStore.instanceId, user.id, rootStore.versionCli);
postHogStore.init(user.featureFlags);
@@ -3,8 +3,32 @@ import { createPinia, setActivePinia } from 'pinia';
import { mock } from 'vitest-mock-extended';
import { useSettingsStore } from './settings.store';
const { getSettings } = vi.hoisted(() => ({
const mockRootStore = {
restApiContext: {},
setUrlBaseWebhook: vi.fn(),
setUrlBaseEditor: vi.fn(),
setEndpointForm: vi.fn(),
setEndpointFormTest: vi.fn(),
setEndpointFormWaiting: vi.fn(),
setEndpointWebhook: vi.fn(),
setEndpointWebhookTest: vi.fn(),
setEndpointWebhookWaiting: vi.fn(),
setEndpointMcp: vi.fn(),
setEndpointMcpTest: vi.fn(),
setTimezone: vi.fn(),
setExecutionTimeout: vi.fn(),
setMaxExecutionTimeout: vi.fn(),
setInstanceId: vi.fn(),
setOauthCallbackUrls: vi.fn(),
setN8nMetadata: vi.fn(),
setDefaultLocale: vi.fn(),
setBinaryDataMode: vi.fn(),
setVersionCli: vi.fn(),
};
const { getSettings, useRootStore } = vi.hoisted(() => ({
getSettings: vi.fn(),
useRootStore: vi.fn(() => mockRootStore),
}));
const { sessionStarted } = vi.hoisted(() => ({
@@ -20,28 +44,7 @@ vi.mock('@n8n/rest-api-client/api/events', () => ({
}));
vi.mock('@n8n/stores/useRootStore', () => ({
useRootStore: vi.fn(() => ({
restApiContext: {},
setUrlBaseWebhook: vi.fn(),
setUrlBaseEditor: vi.fn(),
setEndpointForm: vi.fn(),
setEndpointFormTest: vi.fn(),
setEndpointFormWaiting: vi.fn(),
setEndpointWebhook: vi.fn(),
setEndpointWebhookTest: vi.fn(),
setEndpointWebhookWaiting: vi.fn(),
setEndpointMcp: vi.fn(),
setEndpointMcpTest: vi.fn(),
setTimezone: vi.fn(),
setExecutionTimeout: vi.fn(),
setMaxExecutionTimeout: vi.fn(),
setInstanceId: vi.fn(),
setOauthCallbackUrls: vi.fn(),
setN8nMetadata: vi.fn(),
setDefaultLocale: vi.fn(),
setBinaryDataMode: vi.fn(),
setVersionCli: vi.fn(),
})),
useRootStore,
}));
vi.mock('@/stores/versions.store', () => ({
@@ -61,48 +64,149 @@ vi.mock('@vueuse/core', async () => {
});
const mockSettings = mock<FrontendSettings>({
releaseChannel: 'stable',
authCookie: { secure: true },
oauthCallbackUrls: {
oauth1: 'https://oauth1.example.com',
oauth2: 'https://oauth2.example.com',
},
defaultLocale: 'en',
instanceId: '1234567890',
telemetry: {
enabled: false,
},
});
describe('settings.store', () => {
beforeEach(() => {
vi.restoreAllMocks();
vi.clearAllMocks();
setActivePinia(createPinia());
});
describe('getSettings', () => {
it('should fetch settings and call sessionStarted if telemetry is enabled', async () => {
const settingsStore = useSettingsStore();
describe('telemetry', () => {
it('should fetch settings and call sessionStarted if telemetry is enabled', async () => {
const settingsStore = useSettingsStore();
getSettings.mockResolvedValueOnce({
...mockSettings,
telemetry: {
enabled: true,
config: {
url: 'https://telemetry.example.com',
key: 'telemetry-key',
getSettings.mockResolvedValueOnce({
...mockSettings,
telemetry: {
enabled: true,
config: {
url: 'https://telemetry.example.com',
key: 'telemetry-key',
},
},
},
});
await settingsStore.getSettings();
expect(getSettings).toHaveBeenCalled();
expect(sessionStarted).toHaveBeenCalled();
});
await settingsStore.getSettings();
expect(getSettings).toHaveBeenCalled();
expect(sessionStarted).toHaveBeenCalled();
it('should fetch settings and skip calling sessionStarted if telemetry is disabled', async () => {
const settingsStore = useSettingsStore();
getSettings.mockResolvedValueOnce({
...mockSettings,
telemetry: {
enabled: false,
},
});
await settingsStore.getSettings();
expect(getSettings).toHaveBeenCalled();
expect(sessionStarted).not.toHaveBeenCalled();
});
});
it('should fetch settings and skip calling sessionStarted if telemetry is disabled', async () => {
const settingsStore = useSettingsStore();
describe('settingsMode', () => {
it('should only set public settings if settingsMode is "public"', async () => {
getSettings.mockResolvedValueOnce({
...mockSettings,
settingsMode: 'public',
telemetry: {
enabled: true,
config: {
url: 'https://telemetry.example.com',
key: 'telemetry-key',
},
},
});
const settingsStore = useSettingsStore();
getSettings.mockResolvedValueOnce({
...mockSettings,
telemetry: {
enabled: false,
},
await settingsStore.getSettings();
// ensure that settings store is also initialized
expect(settingsStore.settings.releaseChannel).toEqual(mockSettings.releaseChannel);
// root store
expect(mockRootStore.setOauthCallbackUrls).toHaveBeenCalledWith(
mockSettings.oauthCallbackUrls,
);
expect(mockRootStore.setDefaultLocale).toHaveBeenCalledWith(mockSettings.defaultLocale);
expect(mockRootStore.setInstanceId).toHaveBeenCalledWith(mockSettings.instanceId);
// non-minimal settings are not set on root store
expect(mockRootStore.setUrlBaseWebhook).not.toHaveBeenCalled();
expect(mockRootStore.setUrlBaseEditor).not.toHaveBeenCalled();
expect(mockRootStore.setEndpointForm).not.toHaveBeenCalled();
expect(mockRootStore.setEndpointFormTest).not.toHaveBeenCalled();
expect(mockRootStore.setEndpointFormWaiting).not.toHaveBeenCalled();
expect(mockRootStore.setEndpointWebhook).not.toHaveBeenCalled();
expect(mockRootStore.setEndpointWebhookTest).not.toHaveBeenCalled();
expect(mockRootStore.setEndpointWebhookWaiting).not.toHaveBeenCalled();
expect(mockRootStore.setEndpointMcp).not.toHaveBeenCalled();
expect(mockRootStore.setEndpointMcpTest).not.toHaveBeenCalled();
expect(mockRootStore.setTimezone).not.toHaveBeenCalled();
expect(mockRootStore.setExecutionTimeout).not.toHaveBeenCalled();
expect(mockRootStore.setMaxExecutionTimeout).not.toHaveBeenCalled();
expect(mockRootStore.setN8nMetadata).not.toHaveBeenCalled();
expect(mockRootStore.setBinaryDataMode).not.toHaveBeenCalled();
// side effects
expect(sessionStarted).toHaveBeenCalled();
});
await settingsStore.getSettings();
expect(getSettings).toHaveBeenCalled();
expect(sessionStarted).not.toHaveBeenCalled();
it('should store full settings if settingsMode is not "minimal"', async () => {
getSettings.mockResolvedValueOnce({
...mockSettings,
telemetry: {
enabled: true,
config: {
url: 'https://telemetry.example.com',
key: 'telemetry-key',
},
},
});
const settingsStore = useSettingsStore();
await settingsStore.getSettings();
// root store
expect(mockRootStore.setUrlBaseWebhook).toHaveBeenCalled();
expect(mockRootStore.setUrlBaseEditor).toHaveBeenCalled();
expect(mockRootStore.setEndpointForm).toHaveBeenCalled();
expect(mockRootStore.setEndpointFormTest).toHaveBeenCalled();
expect(mockRootStore.setEndpointFormWaiting).toHaveBeenCalled();
expect(mockRootStore.setEndpointWebhook).toHaveBeenCalled();
expect(mockRootStore.setEndpointWebhookTest).toHaveBeenCalled();
expect(mockRootStore.setEndpointWebhookWaiting).toHaveBeenCalled();
expect(mockRootStore.setEndpointMcp).toHaveBeenCalled();
expect(mockRootStore.setEndpointMcpTest).toHaveBeenCalled();
expect(mockRootStore.setTimezone).toHaveBeenCalled();
expect(mockRootStore.setExecutionTimeout).toHaveBeenCalled();
expect(mockRootStore.setMaxExecutionTimeout).toHaveBeenCalled();
expect(mockRootStore.setInstanceId).toHaveBeenCalled();
expect(mockRootStore.setOauthCallbackUrls).toHaveBeenCalled();
expect(mockRootStore.setN8nMetadata).toHaveBeenCalled();
expect(mockRootStore.setDefaultLocale).toHaveBeenCalled();
expect(mockRootStore.setBinaryDataMode).toHaveBeenCalled();
// side effects
expect(sessionStarted).toHaveBeenCalled();
});
});
});
});
@@ -151,13 +151,11 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
const isHiringBannerEnabled = computed(() => settings.value.hiringBannerEnabled);
const isTemplatesEnabled = computed(() =>
Boolean(settings.value.templates && settings.value.templates.enabled),
);
const isTemplatesEnabled = computed(() => Boolean(settings.value.templates?.enabled));
const isTemplatesEndpointReachable = computed(() => templatesEndpointHealthy.value);
const templatesHost = computed(() => settings.value.templates.host);
const templatesHost = computed(() => settings.value.templates?.host ?? '');
const pushBackend = computed(() => settings.value.pushBackend);
@@ -192,7 +190,11 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
userManagement.value.showSetupOnFirstLoad =
!!settings.value.userManagement.showSetupOnFirstLoad;
}
api.value = settings.value.publicApi;
if (settings.value.publicApi) {
api.value = settings.value.publicApi;
}
mfa.value.enabled = settings.value.mfa?.enabled;
folders.value.enabled = settings.value.folders?.enabled;
@@ -232,9 +234,32 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
saveDataProgressExecution.value = newValue;
};
const setPublicSettings = (fetchedSettings: FrontendSettings) => {
const rootStore = useRootStore();
setSettings(fetchedSettings);
isMFAEnforced.value = settings.value.mfa?.enforced ?? false;
rootStore.setOauthCallbackUrls(fetchedSettings.oauthCallbackUrls);
rootStore.setDefaultLocale(fetchedSettings.defaultLocale);
rootStore.setInstanceId(fetchedSettings.instanceId);
if (fetchedSettings.telemetry.enabled) {
void eventsApi.sessionStarted(rootStore.restApiContext);
}
};
const getSettings = async () => {
const rootStore = useRootStore();
const fetchedSettings = await settingsApi.getSettings(rootStore.restApiContext);
if (fetchedSettings.settingsMode === 'public') {
// public settings mode is typically used for unauthenticated users
// when public settings are returned only critical setup is needed
setPublicSettings(fetchedSettings);
return;
}
setSettings(fetchedSettings);
settings.value.communityNodesEnabled = fetchedSettings.communityNodesEnabled;
settings.value.unverifiedCommunityNodesEnabled =