Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(core): Custom session timeout and refresh configuration #8342

Merged
merged 21 commits into from
Jan 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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(
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can't use the `Container.get(Logger) here, because it depends on the config, which creates a dependency cycle.

Alternative:

  1. I could validate it in Start.init(), but that feels like the wrong separation of concerns
  2. I could also do it in refreshExpiringCookie, but that would then print the message on every request.

'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 = {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I talked with @ivov about this and he likes it.
I would make a refactor PR removing TIME and replacing it with this.

TIME only allowed converting everything into milliseconds, but I was working with JWT and cookies which all expect seconds, so I thought this here is a bit more versatile.

We can also add a library for this, but the problem seems a bit too trivial to justify adding third party code to make the conversions more readable.

Feel free to disagree and comment.

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);
}
Comment on lines +50 to +56
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Happy to use a ternary here instead. Just let me know.


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;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had to wrangle the types a bit. I want to call
await refreshExpiringCookie(...) in the unit tests, but to do this
without TS complaining about awaiting a non-promise the type needs show that this function is async.

Right now adding the type to the variable binding hides that fact. RequestHandler is synchronous.

My workaround for that is to let TS infer the type of refreshExpiringCookie. That lead to TS not being able to infer the types of res and next anymore. So to fix that I used satisfied, which allows TS to infer the types of the arguments and also infer the type of the function as a whole without the need to create a new type for async request handlers.

Happy to apply other solutions to this like extending the RequestHandler
type and creating an AsyncRequestHandler or adding an Async type
helper and using that.

type Async<T extends (...args: unknown[]) => unknown> = (
	...args: Parameters<T>
) => Promise<ReturnType<T>>;

Let me know what you think.


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
Loading