From df9d1f23c6b2f5f1be05c6731140f6179fe1d7b9 Mon Sep 17 00:00:00 2001 From: Chancellor Clark Date: Fri, 12 Jun 2026 12:21:51 -0600 Subject: [PATCH] fix(core): TRAC-814 Make session token cookies expire with the session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit authjs.session-token and authjs.anonymous-session-token must be session cookies (no Expires attribute) to comply with Essential cookie classification requirements. - Remove maxAge from authjs.anonymous-session-token in anonymousSignIn - Strip Expires from authjs.session-token in the withAuth middleware, where Auth.js refreshes the JWT on every non-API request - Strip Expires from authjs.session-token in the auth route handler, where Auth.js sets the cookie on sign-in (not covered by middleware) - Wrap signIn and updateSession in auth/index.ts with patchSessionTokenCookies, which re-sets session token cookies via cookies().set() without Expires after Auth.js sets them — this covers the server action code path where Auth.js calls cookies().set(name, value, { expires: ... }) directly Auth.js v5 unconditionally sets Expires on session cookies with no config option to disable it. signIn/updateSession use the Next.js cookies() API rather than response headers, so those must be intercepted separately. Fixes TRAC-814 Co-Authored-By: Claude --- core/auth/anonymous-session.ts | 3 --- core/auth/index.ts | 49 +++++++++++++++++++++++++++++++++- core/proxies/with-auth.ts | 26 +++++++++++++++++- 3 files changed, 73 insertions(+), 5 deletions(-) diff --git a/core/auth/anonymous-session.ts b/core/auth/anonymous-session.ts index c5a4f98ca9..3ed7fee2b6 100644 --- a/core/auth/anonymous-session.ts +++ b/core/auth/anonymous-session.ts @@ -31,9 +31,6 @@ export const anonymousSignIn = async (user: Partial = { cartId: n cookieJar.set(`${cookiePrefix}${anonymousCookieName}`, jwt, { secure: useSecureCookies, sameSite: 'lax', - // We set the maxAge to 7 days as a good default for anonymous sessions. - // This can be adjusted based on your application's needs. - maxAge: 60 * 60 * 24 * 7, // 7 days httpOnly: true, }); }; diff --git a/core/auth/index.ts b/core/auth/index.ts index bf902fa2a8..e7a7aa8a6e 100644 --- a/core/auth/index.ts +++ b/core/auth/index.ts @@ -1,4 +1,5 @@ import { decodeJwt } from 'jose'; +import { cookies } from 'next/headers'; import NextAuth, { type NextAuthConfig, User } from 'next-auth'; import 'next-auth/jwt'; import CredentialsProvider from 'next-auth/providers/credentials'; @@ -184,6 +185,7 @@ const config = { session: { strategy: 'jwt', }, + cookies: {}, pages: { signIn: '/login', signOut: '/logout', @@ -318,7 +320,52 @@ const config = { ], } satisfies NextAuthConfig; -export const { handlers, auth, signIn, signOut, unstable_update: updateSession } = NextAuth(config); +const SESSION_TOKEN_NAME_RE = /^(__Secure-)?authjs\.session-token(\.\d+)?$/; + +// Auth.js sets Expires on session token cookies via cookies().set() when signIn/updateSession +// are called from server actions. Re-set those cookies without Expires so they comply with +// Essential classification (session cookies that expire when the browser closes). +async function patchSessionTokenCookies() { + const cookieJar = await cookies(); + + cookieJar.getAll().forEach(({ name, value }) => { + if (SESSION_TOKEN_NAME_RE.test(name) && value) { + cookieJar.set(name, value, { + httpOnly: true, + sameSite: 'lax' as const, + path: '/', + secure: name.startsWith('__Secure-'), + }); + } + }); +} + +const { + handlers, + auth, + signIn: authSignIn, + signOut, + unstable_update: authUpdateSession, +} = NextAuth(config); + +export { handlers, auth, signOut }; + +export const signIn = async (...args: Parameters) => { + try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return await authSignIn(...args); + } finally { + await patchSessionTokenCookies(); + } +}; + +export const updateSession = async (...args: Parameters) => { + try { + return await authUpdateSession(...args); + } finally { + await patchSessionTokenCookies(); + } +}; export const getSessionCustomerAccessToken = async () => { try { diff --git a/core/proxies/with-auth.ts b/core/proxies/with-auth.ts index 2371adcffb..472f48a2f5 100644 --- a/core/proxies/with-auth.ts +++ b/core/proxies/with-auth.ts @@ -6,14 +6,36 @@ import { type ProxyFactory } from './compose-proxies'; // Path matcher for any routes that require authentication const protectedPathPattern = new URLPattern({ pathname: `{/:locale}?/(account)/*` }); +const SESSION_TOKEN_COOKIE_RE = /^(__Secure-)?authjs\.session-token(\.\d+)?=/; function redirectToLogin(url: string) { return NextResponse.redirect(new URL('/login', url), { status: 302 }); } +// Auth.js always sets Expires on session cookies based on session.maxAge. We strip it here +// so the cookie becomes a session cookie (expires when the browser closes), which is required +// for Essential cookie classification compliance. +function stripSessionCookieExpiry(response: Response): Response { + const setCookies = response.headers.getSetCookie(); + const stripped = setCookies.map((cookie) => + SESSION_TOKEN_COOKIE_RE.test(cookie) + ? cookie.replace(/;\s*(?:expires|max-age)=[^;]+/gi, '') + : cookie, + ); + + if (stripped.every((c, i) => c === setCookies[i])) return response; + + const modified = new Response(response.body, response); + + modified.headers.delete('set-cookie'); + stripped.forEach((cookie) => modified.headers.append('set-cookie', cookie)); + + return modified; +} + export const withAuth: ProxyFactory = (next) => { return async (request, event) => { - return auth(async (req) => { + const response = await auth(async (req) => { const anonymousSession = await getAnonymousSession(); const isProtectedRoute = protectedPathPattern.test(req.nextUrl.toString().toLowerCase()); const isGetRequest = req.method === 'GET'; @@ -45,5 +67,7 @@ export const withAuth: ProxyFactory = (next) => { // Continue the proxy chain return next(req, event); })(request, event); + + return response ? stripSessionCookieExpiry(response) : response; }; };