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

Conversation

despairblue
Copy link
Contributor

@despairblue despairblue commented Jan 16, 2024

Summary

This allows configuring the session timeout and refresh via environment variables.

You can test it by starting n8n like this:

env N8N_USER_MANAGEMENT_JWT_DURATION_HOURS=0.005 N8N_USER_MANAGEMENT_JWT_REFRESH_TIMEOUT_HOURS=-1 pnpm dev

0.005 hours is 18 seconds and -1 means that the auto refresh of the cookie/JWT is disabled.
So you can log in, navigate around. Wait 18 seconds. Refresh and see that you're logged out.

If you want to test the auto refresh you can do this:

env N8N_USER_MANAGEMENT_JWT_DURATION_HOURS=0.005 N8N_USER_MANAGEMENT_JWT_REFRESH_TIMEOUT_HOURS=0.0025 pnpm dev

This will make the session last for 18 seconds and when less than 9 seconds are remaining it will issue a new cookie/JWT.


Reviewing this commit by commit can be easier. I mostly added tests before making changes, to make sure I don't break old behaviour.

Related tickets and issues

https://linear.app/n8n/issue/PAY-1182/custom-session-timeout-configuration
https://linear.app/n8n/issue/DOC-734/add-custom-session-timeout-setting-to-environment-variables-page
n8n-io/n8n-docs#1849

Review / Merge checklist

  • PR title and summary are descriptive. Remember, the title automatically goes into the changelog. Use (no-changelog) otherwise. (conventions)
  • Docs updated or follow-up ticket created.
  • Tests included.

    A bug is not considered fixed, unless a test is added to prevent it from happening again.
    A feature is not complete without tests.

@CLAassistant
Copy link

CLAassistant commented Jan 16, 2024

CLA assistant check
All committers have signed the CLA.

@n8n-assistant n8n-assistant bot added core Enhancement outside /nodes-base and /editor-ui n8n team Authored by the n8n team labels Jan 16, 2024
Rename `JwtToken.expiresIn` to `JwtToken.expiresInSeconds` to make it clear what unit it's supposed to be.
Also it's now seconds and not milliseconds. Reason for that is that seconds are a base unit of the SI, which makes it a good default. Also it's what the JWTs expect in `expireIn` and what the cookie Max-Age property expects.
Until now the function returned milliseconds and that was passed into the Max-Age property in `auth/jwt.issueCookie`. This caused no real issue, but it meant that the JWT would always expire before the cookie.
no need to have it in the beforeEach
Additionally 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 the type needs account for the function being
async. Right now adding the type to the variable binding hides that
fact.
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`. To fix that I used `satisfied`.

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.

```ts
type Async<T extends (...args: unknown[]) => unknown> = (
	...args: Parameters<T>
) => Promise<ReturnType<T>>;
```
1. use Time constant for more readable time conversion
2. use jest-mock-extended to create users and requests
3. use type guards and jest.fail to remove unecessary assertions
I'll add this as a comment in the review, where we can agree on where the warning should be emitted.
@despairblue despairblue force-pushed the pay-1182-custom-session-timeout-configuration branch from f68fea3 to 24472c1 Compare January 19, 2024 14:08
@despairblue despairblue marked this pull request as ready for review January 19, 2024 14:08
// Can't use the logger here, because it depends on the config.
// I could validate it in `Start.init()`, but that feels like the wrong seperation of concerns
// I could also do it in `refreshExpiringCookie`, but that would then print the message on every request.
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.

* 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.

Comment on lines +50 to +56
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);
}
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.

// if cookie expires in < 3 days, renew it.
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.

@despairblue despairblue changed the title feat(cli): custom session timeout configuration feat(core): custom session timeout configuration Jan 19, 2024
@despairblue despairblue changed the title feat(core): custom session timeout configuration feat(core): custom session timeout and refresh configuration Jan 19, 2024
@despairblue despairblue changed the title feat(core): custom session timeout and refresh configuration feat(core): Custom session timeout and refresh configuration Jan 19, 2024
@@ -667,7 +667,7 @@ export interface ILicensePostResponse extends ILicenseReadResponse {

export interface JwtToken {
token: string;
expiresIn: number;
expiresInSeconds: number;
Copy link
Member

@netroy netroy Jan 19, 2024

Choose a reason for hiding this comment

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

in the context of auth tokens, I've never seen an expiresIn that's not in seconds.
While I do see the additional clarity of mentioning seconds here, I don't think we need to rename the property itself.
I've seen an uptick in the number of long descriptive variable names lately , and I feel like this makes code increasingly harder to read.
IMO variable names should try to be as simple as possible, and all additional relevant metadata should move to JSDocs.

In this particular case, I'd prefer if renamed this back, and add a more descriptive JSDoc instead. That was anyone inspecting the code will still know that this is in seconds.

Copy link
Contributor Author

@despairblue despairblue Jan 19, 2024

Choose a reason for hiding this comment

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

Happy to change it back, but some background before I do that.

I've never seen an expiresIn that's not in seconds.

I actually thought exactly the same thing. So I assumed this is in seconds:

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

And it is.

I also thought maxAge is in seconds:

res.cookie(AUTH_COOKIE_NAME, userData.token, {
maxAge: userData.expiresIn,
httpOnly: true,
sameSite: 'lax',
});

Because that's what it is in the browser: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#max-agenumber

And on master we currently pass milliseconds. So I thought I found a bug and fixed it in this PR.

Now I tried it out locally and it turns out express expects milliseconds and converts it internally:
https://expressjs.com/en/api.html#res.cookie
image


I'm happy to change it back, but I hope that shined some light on why I thought it may be helpful. If express would call their option maxAgeMilliseconds I would have not introduced a bug in this PR 😄

I'll fix the bug first that I have in the PR. Let me know what you think and if you still want me to revert the name change then I will.

Copy link
Contributor

Choose a reason for hiding this comment

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

I was about to report that there was a bug in the duration, glad you've already figured it out.

Copy link
Member

Choose a reason for hiding this comment

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

I get what you are saying and I agree that it's important to be clear about what unit a variable uses. My point was that we should use more JSDoc for that, and be leaner with our variable names. I've seen long and descriptive names get out of hand in old projects, and that makes code much harder to read because people put more details about what a variable does by putting all the details in the name.

We should instead send PRs (whenever possible) to add JSDoc on the packages we use to have the docs in our editors
image

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 agree. The JSDoc works well enough for me, so I changed the interface back and documented it via JSDoc.

Good thinking with amending the DT types for express.
I hope your DT PR goes through 🤞🏾

@despairblue despairblue requested review from krynble and netroy January 19, 2024 17:05
krynble
krynble previously approved these changes Jan 19, 2024
Copy link
Contributor

✅ All Cypress E2E specs passed

Copy link

cypress bot commented Jan 19, 2024

3 flaky tests on run #3854 ↗︎

0 338 5 0 Flakiness 3

Details:

🌳 🖥️ browsers:node18.12.0-chrome107 🤖 despairblue 🗃️ e2e/*
Project: n8n Commit: 01e15359f9
Status: Passed Duration: 03:33 💡
Started: Jan 22, 2024 8:43 AM Ended: Jan 22, 2024 8:47 AM
Flakiness  5-ndv.cy.ts • 1 flaky test

View Output Video

Test Artifacts
NDV > should not retrieve remote options when required params throw errors Test Replay Screenshots Video
Flakiness  17-sharing.cy.ts • 1 flaky test

View Output Video

Test Artifacts
Sharing > should work for admin role on credentials created by others (also can share it with themselves) Test Replay Screenshots Video
Flakiness  24-ndv-paired-item.cy.ts • 1 flaky test

View Output Video

Test Artifacts
NDV > resolves expression with default item when input node is not parent, while still pairing items Test Replay Screenshots Video

Review all test suite changes for PR #8342 ↗︎

Copy link
Contributor

✅ All Cypress E2E specs passed

@despairblue despairblue merged commit 07e6705 into master Jan 22, 2024
29 checks passed
@despairblue despairblue deleted the pay-1182-custom-session-timeout-configuration branch January 22, 2024 08:54
@github-actions github-actions bot mentioned this pull request Jan 24, 2024
@janober
Copy link
Member

janober commented Jan 24, 2024

Got released with [email protected]

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
core Enhancement outside /nodes-base and /editor-ui n8n team Authored by the n8n team Released
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants