Skip to content

Commit

Permalink
feat(core): Custom session timeout and refresh configuration (#8342)
Browse files Browse the repository at this point in the history
  • Loading branch information
despairblue authored Jan 22, 2024
1 parent f4f496a commit 07e6705
Show file tree
Hide file tree
Showing 9 changed files with 299 additions and 13 deletions.
1 change: 1 addition & 0 deletions packages/cli/src/Interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -667,6 +667,7 @@ export interface ILicensePostResponse extends ILicenseReadResponse {

export interface JwtToken {
token: string;
/** The amount of seconds after which the JWT will expire. **/
expiresIn: number;
}

Expand Down
12 changes: 7 additions & 5 deletions packages/cli/src/auth/jwt.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { Response } from 'express';
import { createHash } from 'crypto';
import { AUTH_COOKIE_NAME, RESPONSE_ERROR_MESSAGES } from '@/constants';
import { AUTH_COOKIE_NAME, RESPONSE_ERROR_MESSAGES, Time } from '@/constants';
import type { JwtPayload, JwtToken } from '@/Interfaces';
import type { User } from '@db/entities/User';
import config from '@/config';
Expand All @@ -14,7 +14,9 @@ import { ApplicationError } from 'n8n-workflow';

export function issueJWT(user: User): JwtToken {
const { id, email, password } = user;
const expiresIn = 7 * 86400000; // 7 days
const expiresInHours = config.getEnv('userManagement.jwtSessionDurationHours');
const expiresInSeconds = expiresInHours * Time.hours.toSeconds;

const isWithinUsersLimit = Container.get(License).isWithinUsersLimit();

const payload: JwtPayload = {
Expand All @@ -37,12 +39,12 @@ export function issueJWT(user: User): JwtToken {
}

const signedToken = Container.get(JwtService).sign(payload, {
expiresIn: expiresIn / 1000 /* in seconds */,
expiresIn: expiresInSeconds,
});

return {
token: signedToken,
expiresIn,
expiresIn: expiresInSeconds,
};
}

Expand Down Expand Up @@ -86,7 +88,7 @@ export async function resolveJwt(token: string): Promise<User> {
export async function issueCookie(res: Response, user: User): Promise<void> {
const userData = issueJWT(user);
res.cookie(AUTH_COOKIE_NAME, userData.token, {
maxAge: userData.expiresIn,
maxAge: userData.expiresIn * Time.seconds.toMilliseconds,
httpOnly: true,
sameSite: 'lax',
});
Expand Down
9 changes: 9 additions & 0 deletions packages/cli/src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,18 @@ if (!inE2ETests && !inTest) {
});
}

// Validate Configuration
config.validate({
allowed: 'strict',
});
const userManagement = config.get('userManagement');
if (userManagement.jwtRefreshTimeoutHours >= userManagement.jwtSessionDurationHours) {
console.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: config.getEnv('generic.timezone'),
Expand Down
12 changes: 9 additions & 3 deletions packages/cli/src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -762,11 +762,17 @@ export const schema = {
default: '',
env: 'N8N_USER_MANAGEMENT_JWT_SECRET',
},
jwtDuration: {
doc: 'Set a specific JWT secret (optional - n8n can generate one)', // Generated @ start.ts
jwtSessionDurationHours: {
doc: 'Set a specific expiration date for the JWTs in hours.',
format: Number,
default: 168,
env: 'N8N_USER_MANAGEMENT_JWT_DURATION',
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',
},
isInstanceOwnerSetUp: {
// n8n loads this setting from DB on startup
Expand Down
23 changes: 23 additions & 0 deletions packages/cli/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ export const UM_FIX_INSTRUCTION =

/**
* Units of time in milliseconds
* @deprecated Please use constants.Time instead.
*/
export const TIME = {
SECOND: 1000,
Expand All @@ -111,6 +112,28 @@ export const TIME = {
DAY: 24 * 60 * 60 * 1000,
} as const;

/**
* Convert time from any unit to any other unit
*
* Please amend conversions as necessary.
* Eventually this will superseed `TIME` above
*/
export const Time = {
seconds: {
toMilliseconds: 1000,
},
minutes: {
toMilliseconds: 60 * 1000,
},
hours: {
toMilliseconds: 60 * 60 * 1000,
toSeconds: 60 * 60,
},
days: {
toSeconds: 24 * 60 * 60,
},
};

export const MIN_PASSWORD_CHAR_LENGTH = 8;

export const MAX_PASSWORD_CHAR_LENGTH = 64;
Expand Down
23 changes: 18 additions & 5 deletions packages/cli/src/middlewares/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { issueCookie, resolveJwtContent } from '@/auth/jwt';
import { canSkipAuth } from '@/decorators/registerController';
import { Logger } from '@/Logger';
import { JwtService } from '@/services/jwt.service';
import config from '@/config';

const jwtFromRequest = (req: Request) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
Expand Down Expand Up @@ -41,17 +42,29 @@ const userManagementJwtAuth = (): RequestHandler => {
/**
* middleware to refresh cookie before it expires
*/
const refreshExpiringCookie: RequestHandler = async (req: AuthenticatedRequest, res, next) => {
export const refreshExpiringCookie = (async (req: AuthenticatedRequest, res, next) => {
const jwtRefreshTimeoutHours = config.get('userManagement.jwtRefreshTimeoutHours');

let jwtRefreshTimeoutMilliSeconds: number;

if (jwtRefreshTimeoutHours === 0) {
const jwtSessionDurationHours = config.get('userManagement.jwtSessionDurationHours');

jwtRefreshTimeoutMilliSeconds = Math.floor(jwtSessionDurationHours * 0.25 * 60 * 60 * 1000);
} else {
jwtRefreshTimeoutMilliSeconds = Math.floor(jwtRefreshTimeoutHours * 60 * 60 * 1000);
}

const cookieAuth = jwtFromRequest(req);
if (cookieAuth && req.user) {

if (cookieAuth && req.user && jwtRefreshTimeoutHours !== -1) {
const cookieContents = jwt.decode(cookieAuth) as JwtPayload & { exp: number };
if (cookieContents.exp * 1000 - Date.now() < 259200000) {
// if cookie expires in < 3 days, renew it.
if (cookieContents.exp * 1000 - Date.now() < jwtRefreshTimeoutMilliSeconds) {
await issueCookie(res, req.user);
}
}
next();
};
}) satisfies RequestHandler;

const passportMiddleware = passport.authenticate('jwt', { session: false }) as RequestHandler;

Expand Down
61 changes: 61 additions & 0 deletions packages/cli/test/unit/auth/jwt.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { Container } from 'typedi';
import { mock } from 'jest-mock-extended';

import config from '@/config';
import { JwtService } from '@/services/jwt.service';
import { License } from '@/License';
import { Time } from '@/constants';
import { issueJWT } from '@/auth/jwt';

import { mockInstance } from '../../shared/mocking';

import type { User } from '@db/entities/User';

mockInstance(License);

describe('jwt.issueJWT', () => {
const jwtService = Container.get(JwtService);

describe('when not setting userManagement.jwtSessionDuration', () => {
it('should default to expire in 7 days', () => {
const defaultInSeconds = 7 * Time.days.toSeconds;
const mockUser = mock<User>({ password: 'passwordHash' });
const { token, expiresIn } = issueJWT(mockUser);

expect(expiresIn).toBe(defaultInSeconds);
const decodedToken = jwtService.verify(token);
if (decodedToken.exp === undefined || decodedToken.iat === undefined) {
fail('Expected exp and iat to be defined');
}

expect(decodedToken.exp - decodedToken.iat).toBe(defaultInSeconds);
});
});

describe('when setting userManagement.jwtSessionDuration', () => {
const oldDuration = config.get('userManagement.jwtSessionDurationHours');
const testDurationHours = 1;
const testDurationSeconds = testDurationHours * Time.hours.toSeconds;

beforeEach(() => {
mockInstance(License);
config.set('userManagement.jwtSessionDurationHours', testDurationHours);
});

afterEach(() => {
config.set('userManagement.jwtSessionDuration', oldDuration);
});

it('should apply it to tokens', () => {
const mockUser = mock<User>({ password: 'passwordHash' });
const { token, expiresIn } = issueJWT(mockUser);

expect(expiresIn).toBe(testDurationSeconds);
const decodedToken = jwtService.verify(token);
if (decodedToken.exp === undefined || decodedToken.iat === undefined) {
fail('Expected exp and iat to be defined on decodedToken');
}
expect(decodedToken.exp - decodedToken.iat).toBe(testDurationSeconds);
});
});
});
9 changes: 9 additions & 0 deletions packages/cli/test/unit/config/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
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);
});
});
Loading

0 comments on commit 07e6705

Please sign in to comment.