diff --git a/docs/pages/guides/extending-the-session.mdx b/docs/pages/guides/extending-the-session.mdx index 73786fc858..321372c318 100644 --- a/docs/pages/guides/extending-the-session.mdx +++ b/docs/pages/guides/extending-the-session.mdx @@ -92,6 +92,87 @@ const providers: Provider[] = [ ] ``` +## Dynamic Session Duration + +You can now dynamically control session duration based on user preferences or roles. The `maxAge` option accepts a static value, `"session"` for browser session cookies, or a function that returns either dynamically. + +### Remember Me Feature + +```ts filename="auth.ts" +export default { + session: { + maxAge: async ({ token }) => { + // Check if user selected "Remember Me" during login + if (token?.rememberMe) { + return 30 * 24 * 60 * 60 // 30 days + } + return "session" // Browser session cookie + } + }, + callbacks: { + jwt({ token, user, trigger }) { + if (trigger === "signIn" && user) { + // Save rememberMe preference in JWT + token.rememberMe = user.rememberMe + } + return token + } + } +} +``` + +### Role-Based Session Duration + +```ts filename="auth.ts" +export default { + session: { + maxAge: async ({ token }) => { + // Different session durations based on user role + switch (token?.role) { + case "admin": + return 4 * 60 * 60 // 4 hours for admins + case "user": + return 24 * 60 * 60 // 24 hours for regular users + case "guest": + return "session" // Session cookie for guests + default: + return 30 * 24 * 60 * 60 // Default 30 days + } + } + } +} +``` + +### Device-Based Sessions + +```ts filename="auth.ts" +export default { + session: { + maxAge: async ({ token, trigger }) => { + // Shorter sessions on public/shared devices + if (token?.deviceType === "public") { + return 60 * 60 // 1 hour + } + + // Session cookies for sensitive operations + if (trigger === "update" && token?.isSensitiveOperation) { + return "session" + } + + return 7 * 24 * 60 * 60 // Default 7 days + } + } +} +``` + + + When using `"session"` as the maxAge value, the cookie will expire when the browser is closed. This is useful for public computers or when users don't want their session to persist. + + + + For database sessions, `"session"` cookies still require a database expiry. The database record will use the default maxAge for cleanup purposes, but the cookie itself will be a session cookie. + + ## Resources - [Concepts. Session strategies](/concepts/session-strategies) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index aef7a3b6ca..e778af35e1 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -253,11 +253,29 @@ export interface AuthConfig { */ strategy?: "jwt" | "database" /** - * Relative time from now in seconds when to expire the session + * Relative time from now in seconds when to expire the session, + * or "session" to create a session cookie that expires when the browser closes. + * Can also be a function that returns the value dynamically. * * @default 2592000 // 30 days + * @example + * ```ts + * // Static session cookie (browser session) + * maxAge: "session" + * + * // Dynamic based on user preferences + * maxAge: async ({ token }) => { + * return token?.rememberMe ? 30 * 24 * 60 * 60 : "session" + * } + * ``` */ - maxAge?: number + maxAge?: number | "session" | ((params: { + user?: import("./types.js").User + token?: import("./jwt.js").JWT + trigger?: "signIn" | "signUp" | "update" + isNewUser?: boolean + session?: any + }) => number | "session" | PromiseLike) /** * How often the session should be updated in seconds. * If set to `0`, session is updated every time. diff --git a/packages/core/src/lib/actions/callback/index.ts b/packages/core/src/lib/actions/callback/index.ts index d11048f7cb..de509b8f68 100644 --- a/packages/core/src/lib/actions/callback/index.ts +++ b/packages/core/src/lib/actions/callback/index.ts @@ -23,12 +23,33 @@ import type { User, } from "../../../types.js" import type { Cookie, SessionStore } from "../../utils/cookie.js" +import type { JWT } from "../../../jwt.js" import { assertInternalOptionsWebAuthn, verifyAuthenticate, verifyRegister, } from "../../utils/webauthn-utils.js" +/** + * Resolve the maxAge value for session cookies. + * Handles static values, "session" for session cookies, and dynamic functions. + */ +async function resolveMaxAge( + maxAge: InternalOptions["session"]["maxAge"], + params: { + user?: User + token?: JWT + trigger?: "signIn" | "signUp" | "update" + isNewUser?: boolean + session?: any + } +): Promise { + if (typeof maxAge === "function") { + return await maxAge(params) + } + return maxAge +} + /** Handle callbacks from login services */ export async function callback( request: RequestInternal, @@ -161,13 +182,23 @@ export async function callback( // Encode token const newToken = await jwt.encode({ ...jwt, token, salt }) - // Set cookie expiry date - const cookieExpires = new Date() - cookieExpires.setTime(cookieExpires.getTime() + sessionMaxAge * 1000) - - const sessionCookies = sessionStore.chunk(newToken, { - expires: cookieExpires, + // Resolve maxAge dynamically + const resolvedMaxAge = await resolveMaxAge(sessionMaxAge, { + user, + token, + trigger: isNewUser ? "signUp" : "signIn", + isNewUser, }) + + // Set cookie expiry date + const cookieOptions: { expires?: Date } = {} + if (resolvedMaxAge !== "session") { + const cookieExpires = new Date() + cookieExpires.setTime(cookieExpires.getTime() + resolvedMaxAge * 1000) + cookieOptions.expires = cookieExpires + } + + const sessionCookies = sessionStore.chunk(newToken, cookieOptions) cookies.push(...sessionCookies) } } else { @@ -285,13 +316,23 @@ export async function callback( // Encode token const newToken = await jwt.encode({ ...jwt, token, salt }) - // Set cookie expiry date - const cookieExpires = new Date() - cookieExpires.setTime(cookieExpires.getTime() + sessionMaxAge * 1000) - - const sessionCookies = sessionStore.chunk(newToken, { - expires: cookieExpires, + // Resolve maxAge dynamically + const resolvedMaxAge = await resolveMaxAge(sessionMaxAge, { + user, + token, + trigger: isNewUser ? "signUp" : "signIn", + isNewUser, }) + + // Set cookie expiry date + const cookieOptions: { expires?: Date } = {} + if (resolvedMaxAge !== "session") { + const cookieExpires = new Date() + cookieExpires.setTime(cookieExpires.getTime() + resolvedMaxAge * 1000) + cookieOptions.expires = cookieExpires + } + + const sessionCookies = sessionStore.chunk(newToken, cookieOptions) cookies.push(...sessionCookies) } } else { @@ -374,13 +415,23 @@ export async function callback( // Encode token const newToken = await jwt.encode({ ...jwt, token, salt }) + // Resolve maxAge dynamically + const resolvedMaxAge = await resolveMaxAge(sessionMaxAge, { + user: loggedInUser, + token, + trigger: isNewUser ? "signUp" : "signIn", + isNewUser, + }) + // Set cookie expiry date - const cookieExpires = new Date() - cookieExpires.setTime(cookieExpires.getTime() + sessionMaxAge * 1000) + const cookieOptions: { expires?: Date } = {} + if (resolvedMaxAge !== "session") { + const cookieExpires = new Date() + cookieExpires.setTime(cookieExpires.getTime() + resolvedMaxAge * 1000) + cookieOptions.expires = cookieExpires + } - const sessionCookies = sessionStore.chunk(newToken, { - expires: cookieExpires, - }) + const sessionCookies = sessionStore.chunk(newToken, cookieOptions) cookies.push(...sessionCookies) } @@ -482,13 +533,23 @@ export async function callback( // Encode token const newToken = await jwt.encode({ ...jwt, token, salt }) - // Set cookie expiry date - const cookieExpires = new Date() - cookieExpires.setTime(cookieExpires.getTime() + sessionMaxAge * 1000) - - const sessionCookies = sessionStore.chunk(newToken, { - expires: cookieExpires, + // Resolve maxAge dynamically + const resolvedMaxAge = await resolveMaxAge(sessionMaxAge, { + user, + token, + trigger: isNewUser ? "signUp" : "signIn", + isNewUser, }) + + // Set cookie expiry date + const cookieOptions: { expires?: Date } = {} + if (resolvedMaxAge !== "session") { + const cookieExpires = new Date() + cookieExpires.setTime(cookieExpires.getTime() + resolvedMaxAge * 1000) + cookieOptions.expires = cookieExpires + } + + const sessionCookies = sessionStore.chunk(newToken, cookieOptions) cookies.push(...sessionCookies) } } else { diff --git a/packages/core/src/lib/actions/session.ts b/packages/core/src/lib/actions/session.ts index cd2d815cde..574a9c2ae3 100644 --- a/packages/core/src/lib/actions/session.ts +++ b/packages/core/src/lib/actions/session.ts @@ -2,8 +2,29 @@ import { JWTSessionError, SessionTokenError } from "../../errors.js" import { fromDate } from "../utils/date.js" import type { Adapter } from "../../adapters.js" -import type { InternalOptions, ResponseInternal, Session } from "../../types.js" +import type { InternalOptions, ResponseInternal, Session, User } from "../../types.js" import type { Cookie, SessionStore } from "../utils/cookie.js" +import type { JWT } from "../../jwt.js" + +/** + * Resolve the maxAge value for session cookies. + * Handles static values, "session" for session cookies, and dynamic functions. + */ +async function resolveMaxAge( + maxAge: InternalOptions["session"]["maxAge"], + params: { + user?: User + token?: JWT + trigger?: "signIn" | "signUp" | "update" + isNewUser?: boolean + session?: any + } +): Promise { + if (typeof maxAge === "function") { + return await maxAge(params) + } + return maxAge +} /** Return a session object filtered via `callbacks.session` */ export async function session( @@ -53,7 +74,15 @@ export async function session( session: newSession, }) - const newExpires = fromDate(sessionMaxAge) + const resolvedMaxAge = await resolveMaxAge(sessionMaxAge, { + token, + trigger: isUpdate ? "update" : undefined, + session: newSession, + }) + + const newExpires = resolvedMaxAge === "session" + ? fromDate(30 * 24 * 60 * 60) // Use a default for internal calculations + : fromDate(resolvedMaxAge) if (token !== null) { // By default, only exposes a limited subset of information to the client @@ -72,9 +101,12 @@ export async function session( const newToken = await jwt.encode({ ...jwt, token, salt }) // Set cookie, to also update expiry date on cookie - const sessionCookies = sessionStore.chunk(newToken, { - expires: newExpires, - }) + const cookieOptions: { expires?: Date } = {} + if (resolvedMaxAge !== "session") { + cookieOptions.expires = newExpires + } + + const sessionCookies = sessionStore.chunk(newToken, cookieOptions) response.cookies?.push(...sessionCookies) @@ -110,15 +142,26 @@ export async function session( const { user, session } = userAndSession const sessionUpdateAge = options.session.updateAge + + // Resolve maxAge for database sessions + const resolvedMaxAge = await resolveMaxAge(sessionMaxAge, { + user, + trigger: isUpdate ? "update" : undefined, + session: newSession, + }) + + // For database sessions, we need a numeric value + const numericMaxAge = resolvedMaxAge === "session" ? sessionMaxAge : resolvedMaxAge + // Calculate last updated date to throttle write updates to database // Formula: ({expiry date} - sessionMaxAge) + sessionUpdateAge // e.g. ({expiry date} - 30 days) + 1 hour const sessionIsDueToBeUpdatedDate = session.expires.valueOf() - - sessionMaxAge * 1000 + + numericMaxAge * 1000 + sessionUpdateAge * 1000 - const newExpires = fromDate(sessionMaxAge) + const newExpires = fromDate(numericMaxAge) // Trigger update of session expiry date and write to database, only // if the session was last updated more than {sessionUpdateAge} ago if (sessionIsDueToBeUpdatedDate <= Date.now()) { @@ -143,13 +186,15 @@ export async function session( response.body = sessionPayload // Set cookie again to update expiry + const cookieOptions = { ...options.cookies.sessionToken.options } + if (resolvedMaxAge !== "session") { + cookieOptions.expires = newExpires + } + response.cookies?.push({ name: options.cookies.sessionToken.name, value: sessionToken, - options: { - ...options.cookies.sessionToken.options, - expires: newExpires, - }, + options: cookieOptions, }) // @ts-expect-error diff --git a/packages/core/src/lib/init.ts b/packages/core/src/lib/init.ts index 9e8ca122f6..8625b482d7 100644 --- a/packages/core/src/lib/init.ts +++ b/packages/core/src/lib/init.ts @@ -123,7 +123,7 @@ export async function init({ // JWT options jwt: { secret: config.secret!, // Asserted in assert.ts - maxAge: config.session?.maxAge ?? maxAge, // default to same as `session.maxAge` + maxAge: config.session?.maxAge ?? maxAge, // default to same as `session.maxAge` or function encode: jwt.encode, decode: jwt.decode, ...config.jwt, diff --git a/packages/core/test/actions/session.test.ts b/packages/core/test/actions/session.test.ts index 314d948116..3f1bc923e4 100644 --- a/packages/core/test/actions/session.test.ts +++ b/packages/core/test/actions/session.test.ts @@ -274,4 +274,156 @@ describe("assert GET session action", () => { assertNoCacheResponseHeaders(response) }) }) + + describe("Dynamic session maxAge", () => { + it("should create a session cookie when maxAge is 'session'", async () => { + const authConfig = testConfig({ + session: { + maxAge: "session" as const, + }, + }) + + const expectedUser = { + name: "test", + email: "test@test.com", + picture: "https://test.com/test.png", + } + + const expectedToken = { + ...expectedUser, + exp: expect.any(Number), + iat: expect.any(Number), + jti: expect.any(String), + sub: expect.any(String), + } + + const salt = SESSION_COOKIE_NAME + const encodedToken = await encode({ + salt, + secret: AUTH_SECRET, + token: expectedToken, + }) + + const { response } = await makeAuthRequest({ + action: "session", + cookies: { [SESSION_COOKIE_NAME]: encodedToken }, + config: authConfig, + }) + + const cookies = response.headers.getSetCookie() + const sessionCookie = cookies.find((c) => + c.includes(SESSION_COOKIE_NAME) + ) + + // Session cookie should not have Max-Age or Expires + expect(sessionCookie).toBeDefined() + expect(sessionCookie).not.toContain("Max-Age") + expect(sessionCookie).not.toContain("Expires") + }) + + it("should handle dynamic maxAge based on token data", async () => { + const dynamicMaxAge = vi.fn(async ({ token }) => { + return token?.rememberMe ? 30 * 24 * 60 * 60 : "session" + }) + + const authConfig = testConfig({ + session: { + maxAge: dynamicMaxAge, + }, + }) + + const expectedUser = { + name: "test", + email: "test@test.com", + picture: "https://test.com/test.png", + } + + const expectedToken = { + ...expectedUser, + rememberMe: false, + exp: expect.any(Number), + iat: expect.any(Number), + jti: expect.any(String), + sub: expect.any(String), + } + + const salt = SESSION_COOKIE_NAME + const encodedToken = await encode({ + salt, + secret: AUTH_SECRET, + token: expectedToken, + }) + + const { response } = await makeAuthRequest({ + action: "session", + cookies: { [SESSION_COOKIE_NAME]: encodedToken }, + config: authConfig, + }) + + const cookies = response.headers.getSetCookie() + const sessionCookie = cookies.find((c) => + c.includes(SESSION_COOKIE_NAME) + ) + + // Should create session cookie when rememberMe is false + expect(dynamicMaxAge).toHaveBeenCalledWith({ + token: expect.objectContaining({ rememberMe: false }), + trigger: undefined, + session: undefined, + }) + expect(sessionCookie).toBeDefined() + expect(sessionCookie).not.toContain("Max-Age") + expect(sessionCookie).not.toContain("Expires") + }) + + it("should handle dynamic maxAge with persistent cookie", async () => { + const dynamicMaxAge = vi.fn(async ({ token }) => { + return token?.rememberMe ? 30 * 24 * 60 * 60 : "session" + }) + + const authConfig = testConfig({ + session: { + maxAge: dynamicMaxAge, + }, + }) + + const expectedToken = { + name: "test", + email: "test@test.com", + picture: "https://test.com/test.png", + rememberMe: true, + exp: expect.any(Number), + iat: expect.any(Number), + jti: expect.any(String), + sub: expect.any(String), + } + + const salt = SESSION_COOKIE_NAME + const encodedToken = await encode({ + salt, + secret: AUTH_SECRET, + token: expectedToken, + }) + + const { response } = await makeAuthRequest({ + action: "session", + cookies: { [SESSION_COOKIE_NAME]: encodedToken }, + config: authConfig, + }) + + const cookies = response.headers.getSetCookie() + const sessionCookie = cookies.find((c) => + c.includes(SESSION_COOKIE_NAME) + ) + + // Should create persistent cookie when rememberMe is true + expect(dynamicMaxAge).toHaveBeenCalledWith({ + token: expect.objectContaining({ rememberMe: true }), + trigger: undefined, + session: undefined, + }) + expect(sessionCookie).toBeDefined() + expect(sessionCookie).toContain("Max-Age=2592000") // 30 days + }) + }) })