Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
16 changes: 13 additions & 3 deletions src/utils/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,13 @@ export interface SessionManager<T extends SessionDataT = SessionDataT> {
}

export interface SessionConfig {
/** Private key used to encrypt session tokens */
password: string;
/**
* Private key used to encrypt session tokens.
*
* For password rotation, pass a record of `{ id: password }` pairs.
* The first key is used for sealing new sessions, all keys are tried for unsealing.
*/
password: string | Record<string, string>;
/** Session expiration time in seconds */
maxAge?: number;
/** default is h3 */
Expand Down Expand Up @@ -200,7 +205,12 @@ export async function sealSession<T extends SessionData = SessionData>(
const session: Session<T> =
(context.sessions?.[sessionName] as Session<T>) || (await getSession<T>(event, config));

const sealed = await seal(session, config.password, {
const sealPassword =
typeof config.password === "string"
? config.password
: { id: Object.keys(config.password)[0], secret: Object.values(config.password)[0] };

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

we are only takin first!


const sealed = await seal(session, sealPassword, {
Comment on lines +208 to +213

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Missing validation for empty password record.

If config.password is an empty object {}, this code will create { id: undefined, secret: undefined } and pass it to seal(), which will fail at runtime when trying to use an undefined password.

🛡️ Proposed fix to add validation
 const sealPassword =
     typeof config.password === "string"
       ? config.password
-      : { id: Object.keys(config.password)[0], secret: Object.values(config.password)[0] };
+      : (() => {
+          const keys = Object.keys(config.password);
+          if (keys.length === 0) {
+            throw new Error("Password record cannot be empty");
+          }
+          return { id: keys[0], secret: config.password[keys[0]] };
+        })();

Alternatively, use a simpler approach with Object.entries:

 const sealPassword =
     typeof config.password === "string"
       ? config.password
-      : { id: Object.keys(config.password)[0], secret: Object.values(config.password)[0] };
+      : (() => {
+          const [[id, secret]] = Object.entries(config.password);
+          if (!id) throw new Error("Password record cannot be empty");
+          return { id, secret };
+        })();
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const sealPassword =
typeof config.password === "string"
? config.password
: { id: Object.keys(config.password)[0], secret: Object.values(config.password)[0] };
const sealed = await seal(session, sealPassword, {
const sealPassword =
typeof config.password === "string"
? config.password
: (() => {
const keys = Object.keys(config.password);
if (keys.length === 0) {
throw new Error("Password record cannot be empty");
}
return { id: keys[0], secret: config.password[keys[0]] };
})();
const sealed = await seal(session, sealPassword, {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/utils/session.ts` around lines 208 - 213, The code builds sealPassword
from config.password and can produce {id: undefined, secret: undefined} when
config.password is an empty object, causing seal(session, sealPassword, ...) to
fail; add validation before constructing sealPassword to ensure config.password
is either a non-empty string or an object with at least one key/value (or use
Object.entries to extract a single [id, secret] pair) and throw or return a
clear error if it's empty/invalid, then only call seal(session, sealPassword,
...) when the validated id and secret are defined.

...sealDefaults,
ttl: config.maxAge ? config.maxAge * 1000 : 0,
...config.seal,
Expand Down
42 changes: 42 additions & 0 deletions test/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,4 +111,46 @@ describeMatrix("session", (t, { it, expect }) => {
const body = await res.json();
expect(body.session.data.token).toBe(token);
});

it("supports password rotation", async () => {
const oldPassword = "old_password_that_is_at_least_32_characters_long!";
const newPassword = "new_password_that_is_at_least_32_characters_long!";

// Create session with old password
const oldConfig: SessionConfig = {
name: "h3-rotation",
password: oldPassword,
generateId: () => "rotation-test",
};

t.app.post("/rotate/create", async (event) => {
const session = await useSession(event, oldConfig);
await session.update({ secret: "data" });
return { id: session.id };
});

// Read session with rotated passwords (new + old)
const rotatedConfig: SessionConfig = {
name: "h3-rotation",
password: { default: oldPassword, new: newPassword },
generateId: () => "rotation-test-2",
};

t.app.get("/rotate/read", async (event) => {
const session = await useSession(event, rotatedConfig);
return { id: session.id, data: session.data };
});

// Step 1: Create with old password
const createRes = await t.fetch("/rotate/create", { method: "POST" });
const oldCookie = createRes.headers.getSetCookie()[0];
expect((await createRes.json()).id).toBe("rotation-test");

// Step 2: Read with rotated config — old password should still unseal
const readRes = await t.fetch("/rotate/read", {
headers: { Cookie: oldCookie },
});
const readBody = await readRes.json();
expect(readBody.data.secret).toBe("data");
});
});
Loading