diff --git a/packages/@n8n/api-types/src/frontend-settings.ts b/packages/@n8n/api-types/src/frontend-settings.ts index 3fb3e67e480..7015cc4e6ac 100644 --- a/packages/@n8n/api-types/src/frontend-settings.ts +++ b/packages/@n8n/api-types/src/frontend-settings.ts @@ -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; diff --git a/packages/cli/src/auth/__tests__/auth.service.test.ts b/packages/cli/src/auth/__tests__/auth.service.test.ts index f5f7607d736..d1e89284fc8 100644 --- a/packages/cli/src/auth/__tests__/auth.service.test.ts +++ b/packages/cli/src/auth/__tests__/auth.service.test.ts @@ -99,7 +99,7 @@ describe('AuthService', () => { }); }); - describe('authMiddleware', () => { + describe('createAuthMiddleware', () => { const mockReq = () => mock({ 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({ ...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', () => { diff --git a/packages/cli/src/auth/auth.service.ts b/packages/cli/src/auth/auth.service.ts index c03726f7972..f122baf1167 100644 --- a/packages/cli/src/auth/auth.service.ts +++ b/packages/cli/src/auth/auth.service.ts @@ -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(); diff --git a/packages/cli/src/server.ts b/packages/cli/src/server.ts index 17586f343ef..9de2ea84899 100644 --- a/packages/cli/src/server.ts +++ b/packages/cli/src/server.ts @@ -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); diff --git a/packages/cli/src/services/__tests__/frontend.service.test.ts b/packages/cli/src/services/__tests__/frontend.service.test.ts index 42eeaa85952..89cffe20f19 100644 --- a/packages/cli/src/services/__tests__/frontend.service.test.ts +++ b/packages/cli/src/services/__tests__/frontend.service.test.ts @@ -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({ + 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({ + isDocker: false, + instanceId: 'test-instance', + isMultiMain: false, + hostId: 'test-host', + staticCacheDir: '/tmp/test-cache', + }); + + const logger = mock(); + + const loadNodesAndCredentials = mock({ + addPostProcessor: jest.fn(), + types: { + credentials: [], + nodes: [], + }, + }); + + const binaryDataConfig = mock({ + mode: 'default', + availableModes: ['default'], + }); + + const credentialTypes = mock({ + getParentTypes: jest.fn().mockReturnValue([]), + }); + + const credentialsOverwrites = mock({ + getAll: jest.fn().mockReturnValue({}), + }); + + const license = mock({ + 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({ + isEmailSetUp: false, + }); + + const urlService = mock({ + getInstanceBaseUrl: jest.fn().mockReturnValue('http://localhost:5678'), + getWebhookBaseUrl: jest.fn().mockReturnValue('http://localhost:5678'), + }); + + const securityConfig = mock({ + blockFileAccessToN8nFiles: false, + }); + + const pushConfig = mock({ + backend: 'websocket', + }); + + const licenseState = mock({ + isOidcLicensed: jest.fn().mockReturnValue(false), + isMFAEnforcementLicensed: jest.fn().mockReturnValue(false), + getMaxWorkflowsWithEvaluations: jest.fn().mockReturnValue(0), + }); + + const moduleRegistry = mock({ + getActiveModules: jest.fn().mockReturnValue([]), + }); + + const mfaService = mock({ + isMFAEnforced: jest.fn().mockReturnValue(false), + }); const createMockService = () => { - const globalConfig = mock({ - 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({ @@ -62,95 +152,6 @@ describe('FrontendService', () => { }), ); - const logger = mock(); - const instanceSettings = mock({ - isDocker: false, - instanceId: 'test-instance', - isMultiMain: false, - hostId: 'test-host', - staticCacheDir: '/tmp/test-cache', - }); - - const loadNodesAndCredentials = mock({ - addPostProcessor: jest.fn(), - types: { - credentials: [], - nodes: [], - }, - }); - - const binaryDataConfig = mock({ - mode: 'default', - availableModes: ['default'], - }); - - const credentialTypes = mock({ - getParentTypes: jest.fn().mockReturnValue([]), - }); - - const credentialsOverwrites = mock({ - getAll: jest.fn().mockReturnValue({}), - }); - - const license = mock({ - 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({ - isEmailSetUp: false, - }); - - const urlService = mock({ - getInstanceBaseUrl: jest.fn().mockReturnValue('http://localhost:5678'), - getWebhookBaseUrl: jest.fn().mockReturnValue('http://localhost:5678'), - }); - - const securityConfig = mock({ - blockFileAccessToN8nFiles: false, - }); - - const pushConfig = mock({ - backend: 'websocket', - }); - - const licenseState = mock({ - isOidcLicensed: jest.fn().mockReturnValue(false), - isMFAEnforcementLicensed: jest.fn().mockReturnValue(false), - getMaxWorkflowsWithEvaluations: jest.fn().mockReturnValue(0), - }); - - const moduleRegistry = mock({ - getActiveModules: jest.fn().mockReturnValue([]), - }); - - const mfaService = mock({ - 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', () => { diff --git a/packages/cli/src/services/frontend.service.ts b/packages/cli/src/services/frontend.service.ts index 6a308de68d5..f9741bb345d 100644 --- a/packages/cli/src/services/frontend.service.ts +++ b/packages/cli/src/services/frontend.service.ts @@ -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); } diff --git a/packages/frontend/editor-ui/src/features/settings/users/users.store.test.ts b/packages/frontend/editor-ui/src/features/settings/users/users.store.test.ts index e3f5e808772..a84b3e610c7 100644 --- a/packages/frontend/editor-ui/src/features/settings/users/users.store.test.ts +++ b/packages/frontend/editor-ui/src/features/settings/users/users.store.test.ts @@ -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(); + }); + }); }); diff --git a/packages/frontend/editor-ui/src/features/settings/users/users.store.ts b/packages/frontend/editor-ui/src/features/settings/users/users.store.ts index db4084e9dd4..c56b22ac3ff 100644 --- a/packages/frontend/editor-ui/src/features/settings/users/users.store.ts +++ b/packages/frontend/editor-ui/src/features/settings/users/users.store.ts @@ -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; +type LogoutHook = () => void | Promise; 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); } }; diff --git a/packages/frontend/editor-ui/src/init.test.ts b/packages/frontend/editor-ui/src/init.test.ts index 9fa9b9eec19..b1560029a0a 100644 --- a/packages/frontend/editor-ui/src/init.test.ts +++ b/packages/frontend/editor-ui/src/init.test.ts @@ -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({ id: 'userId' })), - ); + usersStore.registerLoginHook.mockImplementation(async (hook) => { + await hook(mock({ id: 'userId' })); + }); rootStore.instanceId = 'testInstanceId'; rootStore.versionCli = '1.102.0'; diff --git a/packages/frontend/editor-ui/src/init.ts b/packages/frontend/editor-ui/src/init.ts index 69385b48d99..6567d7a547e 100644 --- a/packages/frontend/editor-ui/src/init.ts +++ b/packages/frontend/editor-ui/src/init.ts @@ -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); diff --git a/packages/frontend/editor-ui/src/stores/settings.store.test.ts b/packages/frontend/editor-ui/src/stores/settings.store.test.ts index f646113d0da..5eeea027992 100644 --- a/packages/frontend/editor-ui/src/stores/settings.store.test.ts +++ b/packages/frontend/editor-ui/src/stores/settings.store.test.ts @@ -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({ + 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(); + }); }); }); }); diff --git a/packages/frontend/editor-ui/src/stores/settings.store.ts b/packages/frontend/editor-ui/src/stores/settings.store.ts index d023d3c35ac..8c259073fa8 100644 --- a/packages/frontend/editor-ui/src/stores/settings.store.ts +++ b/packages/frontend/editor-ui/src/stores/settings.store.ts @@ -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 =