Skip to content
Open
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
27 changes: 18 additions & 9 deletions packages/core/src/lib/actions/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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 {
Expand Down
108 changes: 94 additions & 14 deletions packages/core/test/actions/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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()
Expand Down Expand Up @@ -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<string, string>
)

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,
Expand All @@ -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<string, string>
)
// 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<string, string>
)
// 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",
Expand Down
Loading