diff --git a/src/utils/session.ts b/src/utils/session.ts index fc2aaf3aa..b6e41056b 100644 --- a/src/utils/session.ts +++ b/src/utils/session.ts @@ -29,8 +29,13 @@ export interface SessionManager { } 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; /** Session expiration time in seconds */ maxAge?: number; /** default is h3 */ @@ -200,7 +205,12 @@ export async function sealSession( const session: Session = (context.sessions?.[sessionName] as Session) || (await getSession(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] }; + + const sealed = await seal(session, sealPassword, { ...sealDefaults, ttl: config.maxAge ? config.maxAge * 1000 : 0, ...config.seal, diff --git a/test/session.test.ts b/test/session.test.ts index 9f5104179..8c95a7afc 100644 --- a/test/session.test.ts +++ b/test/session.test.ts @@ -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"); + }); });