diff --git a/packages/core/src/lib/actions/session.ts b/packages/core/src/lib/actions/session.ts index cd2d815cde..2922a5acf8 100644 --- a/packages/core/src/lib/actions/session.ts +++ b/packages/core/src/lib/actions/session.ts @@ -56,6 +56,15 @@ export async function session( const newExpires = fromDate(sessionMaxAge) if (token !== null) { + const sessionUpdateAge = options.session.updateAge + // Throttle JWT re-signing based on updateAge (mirrors the database branch). + // Formula: (token expiry − sessionMaxAge) + sessionUpdateAge ≤ now + const jwtIsDueToBeUpdated = + isUpdate || + !payload.exp || + payload.exp * 1000 - sessionMaxAge * 1000 + sessionUpdateAge * 1000 <= + Date.now() + // By default, only exposes a limited subset of information to the client // as needed for presentation purposes (e.g. "you are logged in as..."). const session = { @@ -68,15 +77,15 @@ export async function session( // Return session payload as response response.body = newSession - // Refresh JWT expiry by re-signing it, with an updated expiry date - const newToken = await jwt.encode({ ...jwt, token, salt }) - - // Set cookie, to also update expiry date on cookie - const sessionCookies = sessionStore.chunk(newToken, { - expires: newExpires, - }) - - response.cookies?.push(...sessionCookies) + if (jwtIsDueToBeUpdated) { + // Refresh JWT expiry by re-signing it, with an updated expiry date + const newToken = await jwt.encode({ ...jwt, token, salt }) + // Set cookie, to also update expiry date on cookie + const sessionCookies = sessionStore.chunk(newToken, { + expires: newExpires, + }) + response.cookies?.push(...sessionCookies) + } await events.session?.({ session: newSession, token }) } else { diff --git a/packages/core/test/actions/session.test.ts b/packages/core/test/actions/session.test.ts index 314d948116..713153b97c 100644 --- a/packages/core/test/actions/session.test.ts +++ b/packages/core/test/actions/session.test.ts @@ -4,7 +4,7 @@ import * as cookie from "../../src/lib/vendored/cookie.js" import { MemoryAdapter, initMemory } from "../memory-adapter.js" import { randomString } from "../../src/lib/utils/web.js" import type { AdapterUser } from "../../src/adapters.js" -import { decode, encode } from "../../src/jwt.js" +import { encode } from "../../src/jwt.js" import { callbacks, getExpires, @@ -27,7 +27,7 @@ describe("assert GET session action", () => { vi.restoreAllMocks() }) describe("JWT strategy", () => { - it("should return a valid JWT session response", async () => { + it("should return a valid JWT session response without re-signing a fresh token", async () => { const authConfig = testConfig() const expectedExpires = getExpires() @@ -64,24 +64,20 @@ describe("assert GET session action", () => { }) const actualBodySession = await response.json() - let cookies = response.headers.getSetCookie().reduce((acc, cookie) => { - return { ...acc, ...parseCookie(cookie) } - }, {}) - const sessionToken = cookies[SESSION_COOKIE_NAME] - const actualToken = await decode({ - salt: SESSION_COOKIE_NAME, - secret: AUTH_SECRET, - token: sessionToken, - }) - - const { exp, iat, jti, ...actualUser } = actualToken || {} + const cookies = response.headers + .getSetCookie() + .reduce( + (acc, cookie) => ({ ...acc, ...parseCookie(cookie) }), + {} as Record + ) const expectedSession = { user: expectedUserInBody, expires: expectedExpires.toISOString(), } - expect(actualUser).toEqual(expectedUser) + // A fresh token is not yet due for re-signing, so no new session cookie is set + expect(cookies[SESSION_COOKIE_NAME]).toBeUndefined() expect(actualBodySession).toEqual(expectedSession) expect(authConfig.events?.session).toHaveBeenCalledWith({ session: expectedSession, @@ -99,6 +95,90 @@ describe("assert GET session action", () => { assertNoCacheResponseHeaders(response) }) + it("should not re-sign the JWT token when within the updateAge window", async () => { + vi.useRealTimers() + vi.useFakeTimers() + const now = Date.now() + vi.setSystemTime(now) + + const updateAge = 60 * 60 // 1 hour + + const token = { + name: "test", + email: "test@test.com", + picture: "https://test.com/test.png", + } + const originalToken = await encode({ + salt: SESSION_COOKIE_NAME, + secret: AUTH_SECRET, + token, + }) + + const { response } = await makeAuthRequest({ + action: "session", + cookies: { [SESSION_COOKIE_NAME]: originalToken }, + config: { session: { updateAge } }, + }) + + const body = await response.json() + expect(body).not.toBeNull() + + const cookies = response.headers + .getSetCookie() + .reduce( + (acc, cookie) => ({ ...acc, ...parseCookie(cookie) }), + {} as Record + ) + // Token was created at `now`, updateAge is 1 hour — should not re-sign yet + expect(cookies[SESSION_COOKIE_NAME]).toBeUndefined() + + vi.useRealTimers() + }) + + it("should re-sign the JWT token after updateAge has elapsed", async () => { + vi.useRealTimers() + vi.useFakeTimers() + const now = Date.now() + vi.setSystemTime(now) + + const updateAge = 60 * 60 // 1 hour + + const token = { + name: "test", + email: "test@test.com", + picture: "https://test.com/test.png", + } + const originalToken = await encode({ + salt: SESSION_COOKIE_NAME, + secret: AUTH_SECRET, + token, + }) + + // Advance time past updateAge + vi.setSystemTime(now + (updateAge + 1) * 1000) + + const { response } = await makeAuthRequest({ + action: "session", + cookies: { [SESSION_COOKIE_NAME]: originalToken }, + config: { session: { updateAge } }, + }) + + const body = await response.json() + expect(body).not.toBeNull() + + const cookies = response.headers + .getSetCookie() + .reduce( + (acc, cookie) => ({ ...acc, ...parseCookie(cookie) }), + {} as Record + ) + // updateAge has elapsed — a new signed token should be in the Set-Cookie header + expect(cookies[SESSION_COOKIE_NAME]).toBeDefined() + expect(cookies[SESSION_COOKIE_NAME]).not.toEqual(originalToken) + + vi.useRealTimers() + }) + it("should return null if no JWT session in the requests cookies", async () => { const { response } = await makeAuthRequest({ action: "session",