refactor(core): Port user management config (#18205)

This commit is contained in:
Iván Ovejero
2025-08-11 16:10:58 +02:00
committed by GitHub
parent 58df26c70b
commit f69d8efa04
13 changed files with 142 additions and 72 deletions

View File

@@ -0,0 +1,67 @@
import { Container } from '@n8n/di';
import { UserManagementConfig } from '../user-management.config';
describe('UserManagementConfig', () => {
beforeEach(() => {
Container.reset();
jest.clearAllMocks();
});
const originalEnv = process.env;
afterEach(() => {
process.env = originalEnv;
});
test('with refresh timout > session, sets refresh timout to `0`', () => {
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
process.env = {
N8N_USER_MANAGEMENT_JWT_DURATION_HOURS: '1',
N8N_USER_MANAGEMENT_JWT_REFRESH_TIMEOUT_HOURS: '2',
};
const config = Container.get(UserManagementConfig);
expect(config.jwtRefreshTimeoutHours).toBe(0);
expect(consoleWarnSpy).toHaveBeenCalledWith(
'N8N_USER_MANAGEMENT_JWT_REFRESH_TIMEOUT_HOURS needs to be smaller than N8N_USER_MANAGEMENT_JWT_DURATION_HOURS. Setting N8N_USER_MANAGEMENT_JWT_REFRESH_TIMEOUT_HOURS to 0.',
);
consoleWarnSpy.mockRestore();
});
test('with refresh timout == session, sets refresh timout to `0`', () => {
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
process.env = {
N8N_USER_MANAGEMENT_JWT_DURATION_HOURS: '1',
N8N_USER_MANAGEMENT_JWT_REFRESH_TIMEOUT_HOURS: '1',
};
const config = Container.get(UserManagementConfig);
expect(config.jwtRefreshTimeoutHours).toBe(0);
expect(consoleWarnSpy).toHaveBeenCalledWith(
'N8N_USER_MANAGEMENT_JWT_REFRESH_TIMEOUT_HOURS needs to be smaller than N8N_USER_MANAGEMENT_JWT_DURATION_HOURS. Setting N8N_USER_MANAGEMENT_JWT_REFRESH_TIMEOUT_HOURS to 0.',
);
consoleWarnSpy.mockRestore();
});
test('with refresh timout < session, keeps refresh timout intact', () => {
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
process.env = {
N8N_USER_MANAGEMENT_JWT_DURATION_HOURS: '10',
N8N_USER_MANAGEMENT_JWT_REFRESH_TIMEOUT_HOURS: '5',
};
const config = Container.get(UserManagementConfig);
expect(config.jwtRefreshTimeoutHours).toBe(5);
expect(consoleWarnSpy).not.toHaveBeenCalled();
consoleWarnSpy.mockRestore();
});
});

View File

@@ -86,8 +86,34 @@ class EmailConfig {
template: TemplateConfig;
}
const INVALID_JWT_REFRESH_TIMEOUT_WARNING =
'N8N_USER_MANAGEMENT_JWT_REFRESH_TIMEOUT_HOURS needs to be smaller than N8N_USER_MANAGEMENT_JWT_DURATION_HOURS. Setting N8N_USER_MANAGEMENT_JWT_REFRESH_TIMEOUT_HOURS to 0.';
@Config
export class UserManagementConfig {
@Nested
emails: EmailConfig;
/** JWT secret to use. If unset, n8n will generate its own. */
@Env('N8N_USER_MANAGEMENT_JWT_SECRET')
jwtSecret: string = '';
/** How long (in hours) before the JWT expires. */
@Env('N8N_USER_MANAGEMENT_JWT_DURATION_HOURS')
jwtSessionDurationHours: number = 168;
/**
* How long (in hours) before expiration to automatically refresh it.
* - `0` means 25% of `N8N_USER_MANAGEMENT_JWT_DURATION_HOURS`.
* - `-1` means it will never refresh. This forces users to log back in after expiration.
*/
@Env('N8N_USER_MANAGEMENT_JWT_REFRESH_TIMEOUT_HOURS')
jwtRefreshTimeoutHours: number = 0;
sanitize() {
if (this.jwtRefreshTimeoutHours >= this.jwtSessionDurationHours) {
console.warn(INVALID_JWT_REFRESH_TIMEOUT_WARNING);
this.jwtRefreshTimeoutHours = 0;
}
}
}

View File

@@ -81,6 +81,9 @@ export const Config: ClassDecorator = (ConfigClass: Class) => {
}
}
}
if (typeof config.sanitize === 'function') config.sanitize();
return config;
};
// eslint-disable-next-line @typescript-eslint/no-unsafe-return

View File

@@ -2,6 +2,7 @@ import { Container } from '@n8n/di';
import fs from 'fs';
import { mock } from 'jest-mock-extended';
import type { UserManagementConfig } from '../src/configs/user-management.config';
import { GlobalConfig } from '../src/index';
jest.mock('fs');
@@ -101,6 +102,9 @@ describe('GlobalConfig', () => {
},
},
userManagement: {
jwtSecret: '',
jwtSessionDurationHours: 168,
jwtRefreshTimeoutHours: 0,
emails: {
mode: 'smtp',
smtp: {
@@ -124,7 +128,7 @@ describe('GlobalConfig', () => {
'project-shared': '',
},
},
},
} as UserManagementConfig,
eventBus: {
checkUnsentInterval: 0,
crashRecoveryMode: 'extensive',

View File

@@ -11,15 +11,12 @@ import { mock } from 'jest-mock-extended';
import jwt from 'jsonwebtoken';
import { AuthService } from '@/auth/auth.service';
import config from '@/config';
import { AUTH_COOKIE_NAME } from '@/constants';
import type { MfaService } from '@/mfa/mfa.service';
import { JwtService } from '@/services/jwt.service';
import type { UrlService } from '@/services/url.service';
describe('AuthService', () => {
config.set('userManagement.jwtSecret', 'random-secret');
const browserId = 'test-browser-id';
const userData = {
id: '123',
@@ -29,8 +26,11 @@ describe('AuthService', () => {
mfaEnabled: false,
};
const user = mock<User>(userData);
const globalConfig = mock<GlobalConfig>({ auth: { cookie: { secure: true, samesite: 'lax' } } });
const jwtService = new JwtService(mock());
const globalConfig = mock<GlobalConfig>({
auth: { cookie: { secure: true, samesite: 'lax' } },
userManagement: { jwtSecret: 'random-secret' },
});
const jwtService = new JwtService(mock(), globalConfig);
const urlService = mock<UrlService>();
const userRepository = mock<UserRepository>();
const invalidAuthTokenRepository = mock<InvalidAuthTokenRepository>();
@@ -58,8 +58,8 @@ describe('AuthService', () => {
beforeEach(() => {
jest.resetAllMocks();
jest.setSystemTime(now);
config.set('userManagement.jwtSessionDurationHours', 168);
config.set('userManagement.jwtRefreshTimeoutHours', 0);
globalConfig.userManagement.jwtSessionDurationHours = 168;
globalConfig.userManagement.jwtRefreshTimeoutHours = 0;
globalConfig.auth.cookie = { secure: true, samesite: 'lax' };
});
@@ -253,7 +253,7 @@ describe('AuthService', () => {
const testDurationSeconds = testDurationHours * Time.hours.toSeconds;
it('should apply it to tokens', () => {
config.set('userManagement.jwtSessionDurationHours', testDurationHours);
globalConfig.userManagement.jwtSessionDurationHours = testDurationHours;
const token = authService.issueJWT(user, false, browserId);
const decodedToken = jwtService.verify(token);
@@ -376,7 +376,7 @@ describe('AuthService', () => {
});
it('should not refresh the cookie if jwtRefreshTimeoutHours is set to -1', async () => {
config.set('userManagement.jwtRefreshTimeoutHours', -1);
globalConfig.userManagement.jwtRefreshTimeoutHours = -1;
userRepository.findOne.mockResolvedValue(user);
expect(await authService.resolveJwt(validToken, req, res)).toEqual([

View File

@@ -268,9 +268,9 @@ export class AuthService {
return createHash('sha256').update(input).digest('base64');
}
/** How many **milliseconds** before expiration should a JWT be renewed */
/** How many **milliseconds** before expiration should a JWT be renewed. */
get jwtRefreshTimeout() {
const { jwtRefreshTimeoutHours, jwtSessionDurationHours } = config.get('userManagement');
const { jwtRefreshTimeoutHours, jwtSessionDurationHours } = this.globalConfig.userManagement;
if (jwtRefreshTimeoutHours === 0) {
return Math.floor(jwtSessionDurationHours * 0.25 * Time.hours.toMilliseconds);
} else {
@@ -278,8 +278,8 @@ export class AuthService {
}
}
/** How many **seconds** is an issued JWT valid for */
/** How many **seconds** is an issued JWT valid for. */
get jwtExpiration() {
return config.get('userManagement.jwtSessionDurationHours') * Time.hours.toSeconds;
return this.globalConfig.userManagement.jwtSessionDurationHours * Time.hours.toSeconds;
}
}

View File

@@ -1,9 +0,0 @@
describe('userManagement.jwtRefreshTimeoutHours', () => {
it("resets jwtRefreshTimeoutHours to 0 if it's greater than or equal to jwtSessionDurationHours", async () => {
process.env.N8N_USER_MANAGEMENT_JWT_DURATION_HOURS = '1';
process.env.N8N_USER_MANAGEMENT_JWT_REFRESH_TIMEOUT_HOURS = '1';
const { default: config } = await import('@/config');
expect(config.getEnv('userManagement.jwtRefreshTimeoutHours')).toBe(0);
});
});

View File

@@ -93,20 +93,6 @@ if (!inE2ETests && !inTest) {
});
}
// Validate Configuration
config.validate({
allowed: 'strict',
});
const userManagement = config.get('userManagement');
if (userManagement.jwtRefreshTimeoutHours >= userManagement.jwtSessionDurationHours) {
if (!inTest)
logger.warn(
'N8N_USER_MANAGEMENT_JWT_REFRESH_TIMEOUT_HOURS needs to smaller than N8N_USER_MANAGEMENT_JWT_DURATION_HOURS. Setting N8N_USER_MANAGEMENT_JWT_REFRESH_TIMEOUT_HOURS to 0 for now.',
);
config.set('userManagement.jwtRefreshTimeoutHours', 0);
}
setGlobalState({
defaultTimezone: globalConfig.generic.timezone,
});

View File

@@ -103,25 +103,6 @@ export const schema = {
},
userManagement: {
jwtSecret: {
doc: 'Set a specific JWT secret (optional - n8n can generate one)', // Generated @ start.ts
format: String,
default: '',
env: 'N8N_USER_MANAGEMENT_JWT_SECRET',
},
jwtSessionDurationHours: {
doc: 'Set a specific expiration date for the JWTs in hours.',
format: Number,
default: 168,
env: 'N8N_USER_MANAGEMENT_JWT_DURATION_HOURS',
},
jwtRefreshTimeoutHours: {
doc: 'How long before the JWT expires to automatically refresh it. 0 means 25% of N8N_USER_MANAGEMENT_JWT_DURATION_HOURS. -1 means it will never refresh, which forces users to login again after the defined period in N8N_USER_MANAGEMENT_JWT_DURATION_HOURS.',
format: Number,
default: 0,
env: 'N8N_USER_MANAGEMENT_JWT_REFRESH_TIMEOUT_HOURS',
},
/**
* @important Do not remove until after cloud hooks are updated to stop using convict config.
*/

View File

@@ -1,8 +1,8 @@
import type { GlobalConfig } from '@n8n/config';
import { mock } from 'jest-mock-extended';
import jwt from 'jsonwebtoken';
import type { InstanceSettings } from 'n8n-core';
import config from '@/config';
import { JwtService } from '@/services/jwt.service';
describe('JwtService', () => {
@@ -13,21 +13,29 @@ describe('JwtService', () => {
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjEsImlhdCI6MTY5OTk4NDMxM30.xNZOAmcidW5ovEF_mwIOzCWkJ70FEO6MFNLK2QRDOeQ';
const instanceSettings = mock<InstanceSettings>({ encryptionKey: 'test-key' });
let globalConfig: GlobalConfig;
beforeEach(() => {
jest.clearAllMocks();
globalConfig = mock<GlobalConfig>({
userManagement: {
jwtSecret: '',
jwtSessionDurationHours: 168,
jwtRefreshTimeoutHours: 0,
},
});
});
describe('secret initialization', () => {
it('should read the secret from config, when set', () => {
config.set('userManagement.jwtSecret', jwtSecret);
const jwtService = new JwtService(instanceSettings);
globalConfig.userManagement.jwtSecret = jwtSecret;
const jwtService = new JwtService(instanceSettings, globalConfig);
expect(jwtService.jwtSecret).toEqual(jwtSecret);
});
it('should derive the secret from encryption key when not set in config', () => {
config.set('userManagement.jwtSecret', '');
const jwtService = new JwtService(instanceSettings);
globalConfig.userManagement.jwtSecret = '';
const jwtService = new JwtService(instanceSettings, globalConfig);
expect(jwtService.jwtSecret).toEqual(
'e9e2975005eddefbd31b2c04a0b0f2d9c37d9d718cf3676cddf76d65dec555cb',
);
@@ -35,8 +43,7 @@ describe('JwtService', () => {
});
describe('with a secret set', () => {
config.set('userManagement.jwtSecret', jwtSecret);
const jwtService = new JwtService(instanceSettings);
let jwtService: JwtService;
beforeAll(() => {
jest.useFakeTimers().setSystemTime(new Date(iat * 1000));
@@ -44,6 +51,11 @@ describe('JwtService', () => {
afterAll(() => jest.useRealTimers());
beforeEach(() => {
globalConfig.userManagement.jwtSecret = jwtSecret;
jwtService = new JwtService(instanceSettings, globalConfig);
});
it('should sign', () => {
const token = jwtService.sign(payload);
expect(token).toEqual(signedToken);

View File

@@ -36,7 +36,7 @@ const securitySchema = mock<OpenAPIV3.ApiKeySecurityScheme>({
name: 'X-N8N-API-KEY',
});
const jwtService = new JwtService(instanceSettings);
const jwtService = new JwtService(instanceSettings, mock());
let userRepository: UserRepository;
let apiKeyRepository: ApiKeyRepository;

View File

@@ -1,16 +1,15 @@
import { Service } from '@n8n/di';
import { GlobalConfig } from '@n8n/config';
import { Container, Service } from '@n8n/di';
import { createHash } from 'crypto';
import jwt from 'jsonwebtoken';
import { InstanceSettings } from 'n8n-core';
import config from '@/config';
@Service()
export class JwtService {
readonly jwtSecret = config.getEnv('userManagement.jwtSecret');
jwtSecret: string = '';
constructor({ encryptionKey }: InstanceSettings) {
this.jwtSecret = config.getEnv('userManagement.jwtSecret');
constructor({ encryptionKey }: InstanceSettings, globalConfig: GlobalConfig) {
this.jwtSecret = globalConfig.userManagement.jwtSecret;
if (!this.jwtSecret) {
// If we don't have a JWT secret set, generate one based on encryption key.
// For a key off every other letter from encryption key
@@ -20,7 +19,7 @@ export class JwtService {
baseKey += encryptionKey[i];
}
this.jwtSecret = createHash('sha256').update(baseKey).digest('hex');
config.set('userManagement.jwtSecret', this.jwtSecret);
Container.get(GlobalConfig).userManagement.jwtSecret = this.jwtSecret;
}
}

View File

@@ -21,6 +21,7 @@ import { LicenseMocker } from '@test-integration/license';
import { PUBLIC_API_REST_PATH_SEGMENT, REST_PATH_SEGMENT } from '../constants';
import type { SetupProps, TestServer } from '../types';
import { GlobalConfig } from '@n8n/config';
/**
* Plugin to prefix a path segment into a request URL pathname.
@@ -127,7 +128,7 @@ export const setupTestServer = ({
if (modules) await testModules.loadModules(modules);
await testDb.init();
config.set('userManagement.jwtSecret', 'My JWT secret');
Container.get(GlobalConfig).userManagement.jwtSecret = 'My JWT secret';
config.set('userManagement.isInstanceOwnerSetUp', true);
testServer.license.mock(Container.get(License));