From 81d8fbe77335c8905916c85e35f184f9b6cd0a7f Mon Sep 17 00:00:00 2001 From: Zach Cardoza <2280384+nerdoza@users.noreply.github.com> Date: Wed, 2 Apr 2025 09:58:15 -0700 Subject: [PATCH 01/11] fix: make session clear resilient --- src/utils/session.ts | 67 ++++++++++++++++++++++++++++++++++++++------ 1 file changed, 58 insertions(+), 9 deletions(-) diff --git a/src/utils/session.ts b/src/utils/session.ts index 30efe037e..a94df1b30 100644 --- a/src/utils/session.ts +++ b/src/utils/session.ts @@ -8,6 +8,20 @@ import { } from "./internal/session"; import { EmptyObject } from "./internal/obj"; +/** + * Get the session name from the config. + */ +function getSessionName(config: SessionConfig) { + return config.name || DEFAULT_SESSION_NAME; +} + +/** + * Generate the session id from the config. + */ +function generateId (config: SessionConfig) { + return config.generateId?.() ?? (config.crypto || crypto).randomUUID(); +} + /** * Create a session manager for the current request. * @@ -17,7 +31,7 @@ export async function useSession( config: SessionConfig, ) { // Create a synced wrapper around the session - const sessionName = config.name || DEFAULT_SESSION_NAME; + const sessionName = getSessionName(config); await getSession(event, config); // Force init const sessionManager = { get id() { @@ -45,7 +59,7 @@ export async function getSession( event: H3Event, config: SessionConfig, ): Promise> { - const sessionName = config.name || DEFAULT_SESSION_NAME; + const sessionName = getSessionName(config); // Return existing session if available if (!event.context.sessions) { @@ -97,8 +111,7 @@ export async function getSession( // New session store in response cookies if (!session.id) { - session.id = - config.generateId?.() ?? (config.crypto || crypto).randomUUID(); + session.id = generateId(config); session.createdAt = Date.now(); await updateSession(event, config); } @@ -106,6 +119,41 @@ export async function getSession( return session; } +/** + * Initialize a new empty session for the current request. + * + * This will create a new session object on the request context, + * but will not store it in the cookie. It is intended to be used + * when either clearing or updating the session, both of which + * will mutate the session cookie object. + */ +async function initializeSession( + event: H3Event, + config: SessionConfig, +): Promise> { + const sessionName = getSessionName(config); + + // Return existing session if available + if (!event.context.sessions) { + event.context.sessions = new EmptyObject(); + } + // Wait for existing session to load + const existingSession = event.context.sessions![sessionName] as Session; + if (existingSession) { + return existingSession[kGetSession] || existingSession; + } + + // Create a new session object and store in context + const session: Session = { + id: generateId(config), + createdAt: Date.now(), + data: new EmptyObject(), + }; + event.context.sessions![sessionName] = session; + + return session; +} + type SessionUpdate = | Partial> | ((oldData: SessionData) => Partial> | undefined); @@ -118,12 +166,12 @@ export async function updateSession( config: SessionConfig, update?: SessionUpdate, ): Promise> { - const sessionName = config.name || DEFAULT_SESSION_NAME; + const sessionName = getSessionName(config); // Access current session const session: Session = (event.context.sessions?.[sessionName] as Session) || - (await getSession(event, config)); + (await initializeSession(event, config)); // Update session data if provided if (typeof update === "function") { @@ -155,7 +203,7 @@ export async function sealSession( event: H3Event, config: SessionConfig, ) { - const sessionName = config.name || DEFAULT_SESSION_NAME; + const sessionName = getSessionName(config); // Access current session const session: Session = @@ -198,12 +246,13 @@ export async function unsealSession( */ export function clearSession( event: H3Event, - config: Partial, + config: SessionConfig, ): Promise { - const sessionName = config.name || DEFAULT_SESSION_NAME; + const sessionName = getSessionName(config); if (event.context.sessions?.[sessionName]) { delete event.context.sessions![sessionName]; } + initializeSession(event, config); setCookie(event, sessionName, "", { ...DEFAULT_SESSION_COOKIE, ...config.cookie, From 8dcb0f3323bdbd95346c39a79e6d27a79b1adbc4 Mon Sep 17 00:00:00 2001 From: Zach Cardoza <2280384+nerdoza@users.noreply.github.com> Date: Wed, 2 Apr 2025 10:09:26 -0700 Subject: [PATCH 02/11] refactor: consolidate TTL logic --- src/utils/session.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/utils/session.ts b/src/utils/session.ts index a94df1b30..e854b13e0 100644 --- a/src/utils/session.ts +++ b/src/utils/session.ts @@ -22,6 +22,13 @@ function generateId (config: SessionConfig) { return config.generateId?.() ?? (config.crypto || crypto).randomUUID(); } +/** + * Get the max age TTL in ms from the config. + */ +function getMaxAgeTTL(config: SessionConfig) { + return config.maxAge ? config.maxAge * 1000 : 0; +} + /** * Create a session manager for the current request. * @@ -187,7 +194,7 @@ export async function updateSession( setCookie(event, sessionName, sealed, { ...DEFAULT_SESSION_COOKIE, expires: config.maxAge - ? new Date(session.createdAt + config.maxAge * 1000) + ? new Date(session.createdAt + getMaxAgeTTL(config)) : undefined, ...config.cookie, }); @@ -212,7 +219,7 @@ export async function sealSession( const sealed = await seal(session, config.password, { ...sealDefaults, - ttl: config.maxAge ? config.maxAge * 1000 : 0, + ttl: getMaxAgeTTL(config), ...config.seal, }); @@ -229,12 +236,12 @@ export async function unsealSession( ) { const unsealed = (await unseal(sealed, config.password, { ...sealDefaults, - ttl: config.maxAge ? config.maxAge * 1000 : 0, + ttl: getMaxAgeTTL(config), ...config.seal, })) as Partial; if (config.maxAge) { const age = Date.now() - (unsealed.createdAt || Number.NEGATIVE_INFINITY); - if (age > config.maxAge * 1000) { + if (age > getMaxAgeTTL(config)) { throw new Error("Session expired!"); } } From 1abf6371ce6000799fa411caa4d15ea0290c1622 Mon Sep 17 00:00:00 2001 From: Zach Cardoza <2280384+nerdoza@users.noreply.github.com> Date: Wed, 2 Apr 2025 10:30:16 -0700 Subject: [PATCH 03/11] docs: add notes about session rotation --- docs/4.examples/handle-session.md | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/docs/4.examples/handle-session.md b/docs/4.examples/handle-session.md index 8713ab7c7..2b6bbd6e5 100644 --- a/docs/4.examples/handle-session.md +++ b/docs/4.examples/handle-session.md @@ -37,7 +37,7 @@ This will initialize a session and return an header `Set-Cookie` with a cookie n If the request contains a cookie named `h3` or a header named `x-h3-session`, the session will be initialized with the content of the cookie or the header. > [!NOTE] -> The header take precedence over the cookie. +> The header takes precedence over the cookie. ## Get Data from a Session @@ -86,6 +86,8 @@ We try to get a session from the request. If there is no session, a new one will Try to visit the page multiple times and you will see the number of times you visited the page. +If a `maxAge` is configured, the expiration date of the session will not be updated with a call to `update` on the session object, nor with a call using the `updateSession` utility. For details on how to update teh expiration date, see the [rotate session section](#rotate-a-session). + > [!NOTE] > If you use a CLI tool like `curl` to test this example, you will not see the number of times you visited the page because the CLI tool does not save cookies. You must get the cookie from the response and send it back to the server. @@ -109,6 +111,30 @@ app.use("/clear", async (event) => { h3 will send a header `Set-Cookie` with an empty cookie named `h3` to clear the session. +## Rotate a Session + +The session identifier and expiration date are immutable. If a `maxAge` is configured for the session, the expiration date is set when the session is created and there is no way to extend the session beyond the initially determined expiration date. + +If the session context needs to be extended, the session must be rotated. This means the current session must first be cleared and a new session must be created. A new session identifier will be created and the expiration date will be set to the current time plus the `maxAge` value. + +```js +import { useSession } from "h3"; + +app.use("/rotate", async (event) => { + const session = await useSession(event, { + password: "80d42cfb-1cd2-462c-8f17-e3237d9027e9", + maxAge: 60 * 60 * 24 * 7, // 7 days + }); + + const data = session.date; // Retrieve the current session data + await session.clear(); // Clear the current session + await session.update(data); // Create a new session with the original data + + return "Session rotated"; +}); + +``` + ## Options When to use `useSession`, you can pass an object with options as the second argument to configure the session: From 382ffe4df7ded484a51ec0b7a7620510899dca1e Mon Sep 17 00:00:00 2001 From: Zach Cardoza <2280384+nerdoza@users.noreply.github.com> Date: Wed, 2 Apr 2025 10:31:36 -0700 Subject: [PATCH 04/11] style: resolve lint issues --- docs/4.examples/handle-session.md | 5 ++--- src/utils/session.ts | 6 +++--- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/docs/4.examples/handle-session.md b/docs/4.examples/handle-session.md index 2b6bbd6e5..f458cedda 100644 --- a/docs/4.examples/handle-session.md +++ b/docs/4.examples/handle-session.md @@ -126,13 +126,12 @@ app.use("/rotate", async (event) => { maxAge: 60 * 60 * 24 * 7, // 7 days }); - const data = session.date; // Retrieve the current session data - await session.clear(); // Clear the current session + const data = session.date; // Retrieve the current session data + await session.clear(); // Clear the current session await session.update(data); // Create a new session with the original data return "Session rotated"; }); - ``` ## Options diff --git a/src/utils/session.ts b/src/utils/session.ts index e854b13e0..9ff9ecb7a 100644 --- a/src/utils/session.ts +++ b/src/utils/session.ts @@ -18,7 +18,7 @@ function getSessionName(config: SessionConfig) { /** * Generate the session id from the config. */ -function generateId (config: SessionConfig) { +function generateId(config: SessionConfig) { return config.generateId?.() ?? (config.crypto || crypto).randomUUID(); } @@ -128,10 +128,10 @@ export async function getSession( /** * Initialize a new empty session for the current request. - * + * * This will create a new session object on the request context, * but will not store it in the cookie. It is intended to be used - * when either clearing or updating the session, both of which + * when either clearing or updating the session, both of which * will mutate the session cookie object. */ async function initializeSession( From 9b5334393d803c665b47f0ca4938a1b4a96d14cf Mon Sep 17 00:00:00 2001 From: Zach Cardoza <2280384+nerdoza@users.noreply.github.com> Date: Wed, 2 Apr 2025 10:37:23 -0700 Subject: [PATCH 05/11] fix: use initialize for session sealing --- src/utils/session.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/session.ts b/src/utils/session.ts index 9ff9ecb7a..247856528 100644 --- a/src/utils/session.ts +++ b/src/utils/session.ts @@ -215,7 +215,7 @@ export async function sealSession( // Access current session const session: Session = (event.context.sessions?.[sessionName] as Session) || - (await getSession(event, config)); + (await initializeSession(event, config)); const sealed = await seal(session, config.password, { ...sealDefaults, From 73615372316118fba33da537779c12fe5ad10d6c Mon Sep 17 00:00:00 2001 From: Zach Cardoza <2280384+nerdoza@users.noreply.github.com> Date: Wed, 2 Apr 2025 10:52:45 -0700 Subject: [PATCH 06/11] revert: restore getSession calls in utility functions --- src/utils/session.ts | 54 +++++++++++++------------------------------- 1 file changed, 16 insertions(+), 38 deletions(-) diff --git a/src/utils/session.ts b/src/utils/session.ts index 247856528..e8676a440 100644 --- a/src/utils/session.ts +++ b/src/utils/session.ts @@ -126,41 +126,6 @@ export async function getSession( return session; } -/** - * Initialize a new empty session for the current request. - * - * This will create a new session object on the request context, - * but will not store it in the cookie. It is intended to be used - * when either clearing or updating the session, both of which - * will mutate the session cookie object. - */ -async function initializeSession( - event: H3Event, - config: SessionConfig, -): Promise> { - const sessionName = getSessionName(config); - - // Return existing session if available - if (!event.context.sessions) { - event.context.sessions = new EmptyObject(); - } - // Wait for existing session to load - const existingSession = event.context.sessions![sessionName] as Session; - if (existingSession) { - return existingSession[kGetSession] || existingSession; - } - - // Create a new session object and store in context - const session: Session = { - id: generateId(config), - createdAt: Date.now(), - data: new EmptyObject(), - }; - event.context.sessions![sessionName] = session; - - return session; -} - type SessionUpdate = | Partial> | ((oldData: SessionData) => Partial> | undefined); @@ -178,7 +143,7 @@ export async function updateSession( // Access current session const session: Session = (event.context.sessions?.[sessionName] as Session) || - (await initializeSession(event, config)); + (await getSession(event, config)); // Update session data if provided if (typeof update === "function") { @@ -215,7 +180,7 @@ export async function sealSession( // Access current session const session: Session = (event.context.sessions?.[sessionName] as Session) || - (await initializeSession(event, config)); + (await getSession(event, config)); const sealed = await seal(session, config.password, { ...sealDefaults, @@ -255,11 +220,24 @@ export function clearSession( event: H3Event, config: SessionConfig, ): Promise { + if (!event.context.sessions) { + event.context.sessions = new EmptyObject(); + } + + // Delete the current session const sessionName = getSessionName(config); if (event.context.sessions?.[sessionName]) { delete event.context.sessions![sessionName]; } - initializeSession(event, config); + + // Initialize a new empty session object + event.context.sessions![sessionName] = { + id: generateId(config), + createdAt: Date.now(), + data: new EmptyObject(), + } satisfies Session; + + // Set a cleared session cookie setCookie(event, sessionName, "", { ...DEFAULT_SESSION_COOKIE, ...config.cookie, From 16bb190aae2d6397316edaf40724d5a1b240d4e5 Mon Sep 17 00:00:00 2001 From: Zach Cardoza <2280384+nerdoza@users.noreply.github.com> Date: Wed, 2 Apr 2025 11:04:28 -0700 Subject: [PATCH 07/11] docs: correct typo in session docs --- docs/4.examples/handle-session.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/4.examples/handle-session.md b/docs/4.examples/handle-session.md index f458cedda..53d13a19e 100644 --- a/docs/4.examples/handle-session.md +++ b/docs/4.examples/handle-session.md @@ -86,7 +86,7 @@ We try to get a session from the request. If there is no session, a new one will Try to visit the page multiple times and you will see the number of times you visited the page. -If a `maxAge` is configured, the expiration date of the session will not be updated with a call to `update` on the session object, nor with a call using the `updateSession` utility. For details on how to update teh expiration date, see the [rotate session section](#rotate-a-session). +If a `maxAge` is configured, the expiration date of the session will not be updated with a call to `update` on the session object, nor with a call using the `updateSession` utility. For details on how to extend the session's expiration date, see the [Rotating a Session section](#rotating-a-session). > [!NOTE] > If you use a CLI tool like `curl` to test this example, you will not see the number of times you visited the page because the CLI tool does not save cookies. You must get the cookie from the response and send it back to the server. @@ -111,7 +111,7 @@ app.use("/clear", async (event) => { h3 will send a header `Set-Cookie` with an empty cookie named `h3` to clear the session. -## Rotate a Session +## Rotating a Session The session identifier and expiration date are immutable. If a `maxAge` is configured for the session, the expiration date is set when the session is created and there is no way to extend the session beyond the initially determined expiration date. From 370ce915e6fcf7271f5fdef8914ac4323374913a Mon Sep 17 00:00:00 2001 From: Zach Cardoza <2280384+nerdoza@users.noreply.github.com> Date: Wed, 2 Apr 2025 12:05:19 -0700 Subject: [PATCH 08/11] refactor: change to clobber session object --- src/utils/session.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/utils/session.ts b/src/utils/session.ts index e8676a440..50a24b7f1 100644 --- a/src/utils/session.ts +++ b/src/utils/session.ts @@ -220,17 +220,12 @@ export function clearSession( event: H3Event, config: SessionConfig, ): Promise { + const sessionName = getSessionName(config); if (!event.context.sessions) { event.context.sessions = new EmptyObject(); } - // Delete the current session - const sessionName = getSessionName(config); - if (event.context.sessions?.[sessionName]) { - delete event.context.sessions![sessionName]; - } - - // Initialize a new empty session object + // Clobber the original session with a new session event.context.sessions![sessionName] = { id: generateId(config), createdAt: Date.now(), From f5644e77d73c0bb940de4a9ba42881ed3bcc1b97 Mon Sep 17 00:00:00 2001 From: Zach Cardoza <2280384+nerdoza@users.noreply.github.com> Date: Tue, 15 Apr 2025 07:27:54 -0700 Subject: [PATCH 09/11] docs: remove notes about session rotation --- docs/4.examples/handle-session.md | 25 +------------------------ 1 file changed, 1 insertion(+), 24 deletions(-) diff --git a/docs/4.examples/handle-session.md b/docs/4.examples/handle-session.md index 53d13a19e..025ddd21d 100644 --- a/docs/4.examples/handle-session.md +++ b/docs/4.examples/handle-session.md @@ -86,7 +86,7 @@ We try to get a session from the request. If there is no session, a new one will Try to visit the page multiple times and you will see the number of times you visited the page. -If a `maxAge` is configured, the expiration date of the session will not be updated with a call to `update` on the session object, nor with a call using the `updateSession` utility. For details on how to extend the session's expiration date, see the [Rotating a Session section](#rotating-a-session). +If a `maxAge` is configured, the expiration date of the session will not be updated with a call to `update` on the session object, nor with a call using the `updateSession` utility. > [!NOTE] > If you use a CLI tool like `curl` to test this example, you will not see the number of times you visited the page because the CLI tool does not save cookies. You must get the cookie from the response and send it back to the server. @@ -111,29 +111,6 @@ app.use("/clear", async (event) => { h3 will send a header `Set-Cookie` with an empty cookie named `h3` to clear the session. -## Rotating a Session - -The session identifier and expiration date are immutable. If a `maxAge` is configured for the session, the expiration date is set when the session is created and there is no way to extend the session beyond the initially determined expiration date. - -If the session context needs to be extended, the session must be rotated. This means the current session must first be cleared and a new session must be created. A new session identifier will be created and the expiration date will be set to the current time plus the `maxAge` value. - -```js -import { useSession } from "h3"; - -app.use("/rotate", async (event) => { - const session = await useSession(event, { - password: "80d42cfb-1cd2-462c-8f17-e3237d9027e9", - maxAge: 60 * 60 * 24 * 7, // 7 days - }); - - const data = session.date; // Retrieve the current session data - await session.clear(); // Clear the current session - await session.update(data); // Create a new session with the original data - - return "Session rotated"; -}); -``` - ## Options When to use `useSession`, you can pass an object with options as the second argument to configure the session: From a4c10ade7b78127546169850c32d4a54ed297d5a Mon Sep 17 00:00:00 2001 From: Zach Cardoza <2280384+nerdoza@users.noreply.github.com> Date: Tue, 15 Apr 2025 07:42:01 -0700 Subject: [PATCH 10/11] feat: add rotate session capability --- src/utils/session.ts | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/src/utils/session.ts b/src/utils/session.ts index 50a24b7f1..72ad848cc 100644 --- a/src/utils/session.ts +++ b/src/utils/session.ts @@ -55,6 +55,10 @@ export async function useSession( clearSession(event, config); return Promise.resolve(sessionManager); }, + rotate: async () => { + await rotateSession(event, config); + return sessionManager; + }, }; return sessionManager; } @@ -168,6 +172,39 @@ export async function updateSession( return session; } +/** + * Rotate the session id and createdAt timestamp. + */ +export async function rotateSession( + event: H3Event, + config: SessionConfig, +): Promise> { + const sessionName = getSessionName(config); + + // Access current session + const session: Session = + (event.context.sessions?.[sessionName] as Session) || + (await getSession(event, config)); + + // Rotate session id and createdAt timestamp + session.id = generateId(config); + session.createdAt = Date.now(); + + // Seal and store in cookie + if (config.cookie !== false) { + const sealed = await sealSession(event, config); + setCookie(event, sessionName, sealed, { + ...DEFAULT_SESSION_COOKIE, + expires: config.maxAge + ? new Date(session.createdAt + getMaxAgeTTL(config)) + : undefined, + ...config.cookie, + }); + } + + return session; +} + /** * Encrypt and sign the session data for the current request. */ From 4c172f9d293f6e9c8ad71d4b55dbb0485fb63c31 Mon Sep 17 00:00:00 2001 From: Zach Cardoza <2280384+nerdoza@users.noreply.github.com> Date: Tue, 15 Apr 2025 07:53:35 -0700 Subject: [PATCH 11/11] docs: add session rotation documentation --- docs/4.examples/handle-session.md | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/docs/4.examples/handle-session.md b/docs/4.examples/handle-session.md index 025ddd21d..c062a6bbe 100644 --- a/docs/4.examples/handle-session.md +++ b/docs/4.examples/handle-session.md @@ -10,6 +10,7 @@ h3 provide many utilities to handle sessions: - `getSession` to initializes or gets the current user session. - `updateSession` to updates data of the current session. - `clearSession` to clears the current session. +- `rotateSession` to rotates the session setting a new ID and resetting the expiration date. Most of the time, you will use `useSession` to manipulate the session. @@ -86,7 +87,7 @@ We try to get a session from the request. If there is no session, a new one will Try to visit the page multiple times and you will see the number of times you visited the page. -If a `maxAge` is configured, the expiration date of the session will not be updated with a call to `update` on the session object, nor with a call using the `updateSession` utility. +If a `maxAge` is configured, the expiration date of the session will not be updated with a call to `update` on the session object, nor with a call using the `updateSession` utility. For details on how to extend the session's expiration date, see the [Rotating a Session section](#rotating-a-session). > [!NOTE] > If you use a CLI tool like `curl` to test this example, you will not see the number of times you visited the page because the CLI tool does not save cookies. You must get the cookie from the response and send it back to the server. @@ -111,6 +112,28 @@ app.use("/clear", async (event) => { h3 will send a header `Set-Cookie` with an empty cookie named `h3` to clear the session. +## Rotating a Session + +Session rotation is a way to reset the expiration date of the session and assign a new ID to the same session data. This effectively allows a session to be extended when a `maxAge` is configured. + +To rotate a session, we will still use `useSession`. Under the hood, it will use `rotateSession` to rotate the session. + +```js +import { useSession } from "h3"; + +app.use("/rotate", async (event) => { + const session = await useSession(event, { + password: "80d42cfb-1cd2-462c-8f17-e3237d9027e9", + }); + + await session.rotate(); + + return "Session rotated"; +}); +``` + +h3 will send a header `Set-Cookie` with a new cookie that includes the new ID and expiration date but the same session data. + ## Options When to use `useSession`, you can pass an object with options as the second argument to configure the session: