diff --git a/packages/nextjs/src/server/actions/clearSession.ts b/packages/nextjs/src/server/actions/clearSession.ts index add7912e..9dda8895 100644 --- a/packages/nextjs/src/server/actions/clearSession.ts +++ b/packages/nextjs/src/server/actions/clearSession.ts @@ -4,6 +4,7 @@ 'use server'; import {cookies} from 'next/headers'; +import {deleteChunkedCookie} from '../../utils/chunkedCookie'; import logger from '../../utils/logger'; import SessionManager from '../../utils/SessionManager'; @@ -31,7 +32,7 @@ type RequestCookies = Awaited>; */ const clearSession = async (): Promise => { const cookieStore: RequestCookies = await cookies(); - cookieStore.delete(SessionManager.getSessionCookieName()); + deleteChunkedCookie(cookieStore, SessionManager.getSessionCookieName()); cookieStore.delete(SessionManager.getTempSessionCookieName()); logger.debug('[clearSession] Session cookies cleared.'); }; diff --git a/packages/nextjs/src/server/actions/getAccessToken.ts b/packages/nextjs/src/server/actions/getAccessToken.ts index 16edcc7d..fbbb8a3c 100644 --- a/packages/nextjs/src/server/actions/getAccessToken.ts +++ b/packages/nextjs/src/server/actions/getAccessToken.ts @@ -4,6 +4,7 @@ 'use server'; import {cookies} from 'next/headers'; +import {getChunkedCookie} from '../../utils/chunkedCookie'; import SessionManager, {SessionTokenPayload} from '../../utils/SessionManager'; type RequestCookies = Awaited>; @@ -16,7 +17,7 @@ type RequestCookies = Awaited>; const getAccessToken = async (): Promise => { const cookieStore: RequestCookies = await cookies(); - const sessionToken: string | undefined = cookieStore.get(SessionManager.getSessionCookieName())?.value; + const sessionToken: string | undefined = getChunkedCookie(cookieStore, SessionManager.getSessionCookieName()); if (sessionToken) { try { diff --git a/packages/nextjs/src/server/actions/getSessionId.ts b/packages/nextjs/src/server/actions/getSessionId.ts index 624ed6ec..a99eb09b 100644 --- a/packages/nextjs/src/server/actions/getSessionId.ts +++ b/packages/nextjs/src/server/actions/getSessionId.ts @@ -4,6 +4,7 @@ 'use server'; import {cookies} from 'next/headers'; +import {getChunkedCookie} from '../../utils/chunkedCookie'; import SessionManager, {SessionTokenPayload} from '../../utils/SessionManager'; type RequestCookies = Awaited>; @@ -17,7 +18,7 @@ type RequestCookies = Awaited>; const getSessionId = async (): Promise => { const cookieStore: RequestCookies = await cookies(); - const sessionToken: string | undefined = cookieStore.get(SessionManager.getSessionCookieName())?.value; + const sessionToken: string | undefined = getChunkedCookie(cookieStore, SessionManager.getSessionCookieName()); if (sessionToken) { try { diff --git a/packages/nextjs/src/server/actions/getSessionPayload.ts b/packages/nextjs/src/server/actions/getSessionPayload.ts index c10b7e89..2d014729 100644 --- a/packages/nextjs/src/server/actions/getSessionPayload.ts +++ b/packages/nextjs/src/server/actions/getSessionPayload.ts @@ -4,6 +4,7 @@ 'use server'; import {cookies} from 'next/headers'; +import {getChunkedCookie} from '../../utils/chunkedCookie'; import SessionManager, {SessionTokenPayload} from '../../utils/SessionManager'; type RequestCookies = Awaited>; @@ -17,7 +18,7 @@ type RequestCookies = Awaited>; const getSessionPayload = async (): Promise => { const cookieStore: RequestCookies = await cookies(); - const sessionToken: string | undefined = cookieStore.get(SessionManager.getSessionCookieName())?.value; + const sessionToken: string | undefined = getChunkedCookie(cookieStore, SessionManager.getSessionCookieName()); if (!sessionToken) { return undefined; } diff --git a/packages/nextjs/src/server/actions/handleOAuthCallbackAction.ts b/packages/nextjs/src/server/actions/handleOAuthCallbackAction.ts index 580d9056..701c3a0b 100644 --- a/packages/nextjs/src/server/actions/handleOAuthCallbackAction.ts +++ b/packages/nextjs/src/server/actions/handleOAuthCallbackAction.ts @@ -6,6 +6,7 @@ import {IdToken} from '@thunderid/node'; import {cookies} from 'next/headers'; import {ThunderIDNextConfig} from '../../models/config'; +import {setChunkedCookie} from '../../utils/chunkedCookie'; import logger from '../../utils/logger'; import SessionManager from '../../utils/SessionManager'; import getClient from '../getClient'; @@ -114,7 +115,8 @@ const handleOAuthCallbackAction = async ( organizationId, ); - cookieStore.set( + setChunkedCookie( + cookieStore, SessionManager.getSessionCookieName(), sessionToken, SessionManager.getSessionCookieOptions(sessionCookieExpiryTime), diff --git a/packages/nextjs/src/server/actions/refreshToken.ts b/packages/nextjs/src/server/actions/refreshToken.ts index 7ccd2fc5..a8147189 100644 --- a/packages/nextjs/src/server/actions/refreshToken.ts +++ b/packages/nextjs/src/server/actions/refreshToken.ts @@ -6,6 +6,7 @@ import {ThunderIDAPIError, logger} from '@thunderid/node'; import {cookies} from 'next/headers'; import {ThunderIDNextConfig} from '../../models/config'; +import {deleteChunkedCookie, getChunkedCookie, setChunkedCookie} from '../../utils/chunkedCookie'; import handleRefreshToken, {HandleRefreshTokenResult} from '../../utils/handleRefreshToken'; import SessionManager, {SessionTokenPayload} from '../../utils/SessionManager'; import getClient from '../getClient'; @@ -41,7 +42,7 @@ export interface RefreshResult { const refreshToken = async (): Promise => { try { const cookieStore: RequestCookies = await cookies(); - const sessionToken: string | undefined = cookieStore.get(SessionManager.getSessionCookieName())?.value; + const sessionToken: string | undefined = getChunkedCookie(cookieStore, SessionManager.getSessionCookieName()); if (!sessionToken) { throw new ThunderIDAPIError( @@ -64,7 +65,8 @@ const refreshToken = async (): Promise => { }); try { - cookieStore.set( + setChunkedCookie( + cookieStore, SessionManager.getSessionCookieName(), result.newSessionToken, SessionManager.getSessionCookieOptions(result.sessionCookieExpiryTime), @@ -92,7 +94,7 @@ const refreshToken = async (): Promise => { // path covers that case on the next request. try { const cookieStore: RequestCookies = await cookies(); - cookieStore.delete(SessionManager.getSessionCookieName()); + deleteChunkedCookie(cookieStore, SessionManager.getSessionCookieName()); logger.debug('[refreshToken] Cleared session cookie after refresh failure.'); } catch { // Intentionally swallowed — middleware handles cleanup when mutation is blocked. diff --git a/packages/nextjs/src/server/actions/signInAction.ts b/packages/nextjs/src/server/actions/signInAction.ts index 23201d88..5f24a698 100644 --- a/packages/nextjs/src/server/actions/signInAction.ts +++ b/packages/nextjs/src/server/actions/signInAction.ts @@ -12,6 +12,7 @@ import { } from '@thunderid/node'; import {cookies} from 'next/headers'; import {ThunderIDNextConfig} from '../../models/config'; +import {getChunkedCookie, setChunkedCookie} from '../../utils/chunkedCookie'; import logger from '../../utils/logger'; import SessionManager, {SessionTokenPayload} from '../../utils/SessionManager'; import getClient from '../getClient'; @@ -45,7 +46,10 @@ const signInAction = async ( let sessionId: string | undefined; - const existingSessionToken: string | undefined = cookieStore.get(SessionManager.getSessionCookieName())?.value; + const existingSessionToken: string | undefined = getChunkedCookie( + cookieStore, + SessionManager.getSessionCookieName(), + ); if (existingSessionToken) { try { @@ -124,7 +128,8 @@ const signInAction = async ( organizationId, ); - cookieStore.set( + setChunkedCookie( + cookieStore, SessionManager.getSessionCookieName(), sessionToken, SessionManager.getSessionCookieOptions(sessionCookieExpiryTime), diff --git a/packages/nextjs/src/server/actions/signOutAction.ts b/packages/nextjs/src/server/actions/signOutAction.ts index b36bc98c..3381ac41 100644 --- a/packages/nextjs/src/server/actions/signOutAction.ts +++ b/packages/nextjs/src/server/actions/signOutAction.ts @@ -5,6 +5,7 @@ import {cookies} from 'next/headers'; import getSessionId from './getSessionId'; +import {deleteChunkedCookie} from '../../utils/chunkedCookie'; import logger from '../../utils/logger'; import SessionManager from '../../utils/SessionManager'; import getClient from '../getClient'; @@ -23,7 +24,7 @@ const signOutAction = async (): Promise<{data?: {afterSignOutUrl?: string}; erro const clearSessionCookies = async (): Promise => { const cookieStore: RequestCookies = await cookies(); - cookieStore.delete(SessionManager.getSessionCookieName()); + deleteChunkedCookie(cookieStore, SessionManager.getSessionCookieName()); cookieStore.delete(SessionManager.getTempSessionCookieName()); }; diff --git a/packages/nextjs/src/server/proxy/__tests__/thunderIDProxy.test.ts b/packages/nextjs/src/server/proxy/__tests__/thunderIDProxy.test.ts new file mode 100644 index 00000000..37d8c87f --- /dev/null +++ b/packages/nextjs/src/server/proxy/__tests__/thunderIDProxy.test.ts @@ -0,0 +1,64 @@ +// Copyright 2025 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {describe, it, expect} from 'vitest'; +import {removeChunkedCookieFromHeader, replaceChunkedCookieInHeader} from '../thunderIDProxy'; + +describe('removeChunkedCookieFromHeader', () => { + it('removes a single unchunked cookie, leaving others untouched', () => { + const header = 'session=abc123; theme=dark'; + expect(removeChunkedCookieFromHeader(header, 'session')).toBe('theme=dark'); + }); + + it('removes every numbered chunk of a chunked cookie, leaving others untouched', () => { + const header = 'theme=dark; session.0=aaa; session.1=bbb; session.2=ccc; locale=en'; + expect(removeChunkedCookieFromHeader(header, 'session')).toBe('theme=dark; locale=en'); + }); + + it('does not remove an unrelated cookie that merely shares a prefix', () => { + const header = 'session=abc123; session-other=xyz'; + expect(removeChunkedCookieFromHeader(header, 'session')).toBe('session-other=xyz'); + }); + + it('returns an empty string for an empty header', () => { + expect(removeChunkedCookieFromHeader('', 'session')).toBe(''); + }); +}); + +describe('replaceChunkedCookieInHeader', () => { + it('appends the cookie when it is not already present', () => { + const header = 'theme=dark'; + expect(replaceChunkedCookieInHeader(header, 'session', 'small-value')).toBe('theme=dark; session=small-value'); + }); + + it('replaces a small unchunked value in place', () => { + const header = 'theme=dark; session=old-value; locale=en'; + expect(replaceChunkedCookieInHeader(header, 'session', 'new-value')).toBe( + 'theme=dark; locale=en; session=new-value', + ); + }); + + it('splits an oversized value into numbered chunk entries, dropping the unchunked entry', () => { + const header = 'theme=dark; session=old-value'; + const largeValue = 'x'.repeat(10_000); + + const result = replaceChunkedCookieInHeader(header, 'session', largeValue); + const parts = result.split('; '); + + expect(parts[0]).toBe('theme=dark'); + expect(parts.slice(1).every((p) => /^session\.\d+=/.test(p))).toBe(true); + + // Round-trip: reassembling the chunk values should reproduce the original. + const reassembled = parts + .slice(1) + .map((p) => p.slice(p.indexOf('=') + 1)) + .join(''); + expect(reassembled).toBe(largeValue); + }); + + it('collapses stale numbered chunks back into a single entry when the new value shrinks', () => { + const header = 'theme=dark; session.0=aaa; session.1=bbb; session.2=ccc'; + + expect(replaceChunkedCookieInHeader(header, 'session', 'small-value')).toBe('theme=dark; session=small-value'); + }); +}); diff --git a/packages/nextjs/src/server/proxy/thunderIDProxy.ts b/packages/nextjs/src/server/proxy/thunderIDProxy.ts index 60561950..8e7d2bd7 100644 --- a/packages/nextjs/src/server/proxy/thunderIDProxy.ts +++ b/packages/nextjs/src/server/proxy/thunderIDProxy.ts @@ -1,9 +1,17 @@ // Copyright 2025 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 +import {CookieChunking} from '@thunderid/node'; import {NextRequest, NextResponse} from 'next/server'; import {REFRESH_BUFFER_SECONDS} from '../../constants/sessionConstants'; import {ThunderIDNextConfig} from '../../models/config'; +import { + ChunkedCookieOptions, + ChunkedCookieWriter, + deleteChunkedCookie, + getChunkedCookie, + setChunkedCookie, +} from '../../utils/chunkedCookie'; import decorateConfigWithNextEnv from '../../utils/decorateConfigWithNextEnv'; import handleRefreshToken from '../../utils/handleRefreshToken'; import SessionManager, {SessionTokenPayload} from '../../utils/SessionManager'; @@ -36,47 +44,75 @@ type ThunderIDProxyHandler = ( ) => Promise | NextResponse | void; /** - * Removes a named cookie from a raw Cookie header string. + * Splits a raw Cookie header string into its `{name, part}` entries, where + * `part` is the original `name=value` text (trimmed, never empty). */ -const removeCookieFromHeader = (cookieHeader: string, name: string): string => +const parseCookieHeaderParts = (cookieHeader: string): {name: string; part: string}[] => cookieHeader .split(';') .map((p: string) => p.trim()) - .filter((p: string) => { - const eqIdx: number = p.indexOf('='); - const partName: string = eqIdx === -1 ? p : p.slice(0, eqIdx).trim(); - return partName !== name; - }) - .join('; '); + .filter(Boolean) + .map((part: string) => { + const eqIdx: number = part.indexOf('='); + const name: string = eqIdx === -1 ? part : part.slice(0, eqIdx).trim(); + return {name, part}; + }); /** - * Replaces the value of a named cookie inside a raw Cookie header string. - * If the cookie does not already appear in the header it is appended. + * Removes a cookie from a raw Cookie header string — the unchunked `name` + * entry and/or any numbered `${name}.0`, `${name}.1`, ... chunk entries. */ -const replaceCookieInHeader = (cookieHeader: string, name: string, value: string): string => { - const parts: string[] = cookieHeader - .split(';') - .map((p: string) => p.trim()) - .filter(Boolean); - - let found = false; - const updated: string[] = parts.map((part: string) => { - const eqIdx: number = part.indexOf('='); - const partName: string = eqIdx === -1 ? part : part.slice(0, eqIdx).trim(); - if (partName === name) { - found = true; - return `${name}=${value}`; - } - return part; - }); - - if (!found) { - updated.push(`${name}=${value}`); - } +export const removeChunkedCookieFromHeader = (cookieHeader: string, name: string): string => { + const entries: {name: string; part: string}[] = parseCookieHeaderParts(cookieHeader); + const toRemove = new Set( + CookieChunking.filterChunkNames( + name, + entries.map((e) => e.name), + ), + ); + + return entries + .filter((e) => !toRemove.has(e.name)) + .map((e) => e.part) + .join('; '); +}; - return updated.join('; '); +/** + * Replaces a cookie's value inside a raw Cookie header string, splitting it + * across numbered `${name}.0`, `${name}.1`, ... chunk entries once it would + * exceed the ~4KB per-cookie limit browsers enforce. Any entry the cookie + * doesn't appear in yet is appended; stale chunk entries a shrunk value no + * longer needs are dropped. + */ +export const replaceChunkedCookieInHeader = (cookieHeader: string, name: string, value: string): string => { + const remaining: string[] = parseCookieHeaderParts(removeChunkedCookieFromHeader(cookieHeader, name)).map( + (e) => e.part, + ); + const newParts: string[] = Object.entries(CookieChunking.split(name, value)).map( + ([chunkName, chunkValue]: [string, string]) => `${chunkName}=${chunkValue}`, + ); + + return [...remaining, ...newParts].join('; '); }; +/** + * Adapts a request/response pair into a {@link ChunkedCookieWriter}: existing + * chunk names are discovered from the incoming request's cookies (what the + * browser currently holds), while writes/deletes apply to the response. + */ +const toChunkedCookieWriter = ( + request: NextRequest, + responseCookies: NextResponse['cookies'], +): ChunkedCookieWriter => ({ + delete: (name: string): void => { + responseCookies.delete(name); + }, + getAll: (): {name: string}[] => request.cookies.getAll(), + set: (name: string, value: string, options: ChunkedCookieOptions): void => { + responseCookies.set(name, value, options); + }, +}); + /** * ThunderID proxy that integrates authentication into your Next.js application. * Similar to Clerk's clerkMiddleware pattern. @@ -168,7 +204,7 @@ const thunderIDProxy = // new session JWT. let expiredSession: SessionTokenPayload | undefined; if (!verifiedSession) { - const rawToken: string | undefined = request.cookies.get(SessionManager.getSessionCookieName())?.value; + const rawToken: string | undefined = getChunkedCookie(request.cookies, SessionManager.getSessionCookieName()); if (rawToken) { try { const decoded: SessionTokenPayload = await SessionManager.verifySessionTokenForRefresh(rawToken); @@ -220,7 +256,10 @@ const thunderIDProxy = // ── Session cleanup detection ───────────────────────────────────────────── // Mark stale cookies for deletion when the session is irrecoverable. Skipped // during OAuth callbacks where a session cookie may not exist yet. - const rawSessionCookie: string | undefined = request.cookies.get(SessionManager.getSessionCookieName())?.value; + const rawSessionCookie: string | undefined = getChunkedCookie( + request.cookies, + SessionManager.getSessionCookieName(), + ); let shouldClearCookie = false; @@ -279,17 +318,18 @@ const thunderIDProxy = if (handlerResponse) { // Handler returned a response (e.g. a redirect from protectRoute). - // Attach the deletion so the browser discards the stale cookie. - handlerResponse.cookies.delete(cookieName); + // Attach the deletion so the browser discards the stale cookie (and + // every chunk of it). + deleteChunkedCookie(toChunkedCookieWriter(request, handlerResponse.cookies), cookieName); return handlerResponse; } // Pass-through: strip the dead cookie from the forwarded request headers // so the same-request Server Component render sees no session at all. const requestHeaders: Headers = new Headers(request.headers); - requestHeaders.set('cookie', removeCookieFromHeader(request.headers.get('cookie') ?? '', cookieName)); + requestHeaders.set('cookie', removeChunkedCookieFromHeader(request.headers.get('cookie') ?? '', cookieName)); const cleanResponse: NextResponse = NextResponse.next({request: {headers: requestHeaders}}); - cleanResponse.cookies.delete(cookieName); + deleteChunkedCookie(toChunkedCookieWriter(request, cleanResponse.cookies), cookieName); return cleanResponse; } @@ -308,14 +348,19 @@ const thunderIDProxy = if (handlerResponse) { // Handler returned a response (e.g. a redirect from protectRoute). // Attach the refresh cookie so the browser receives it even on redirects. - handlerResponse.cookies.set(cookieName, refreshCookieUpdate.token, cookieOptions); + setChunkedCookie( + toChunkedCookieWriter(request, handlerResponse.cookies), + cookieName, + refreshCookieUpdate.token, + cookieOptions, + ); return handlerResponse; } // Default pass-through: update both the response cookie and the request // Cookie header so the downstream Server Component render is not stale. const requestHeaders: Headers = new Headers(request.headers); - const updatedCookieHeader: string = replaceCookieInHeader( + const updatedCookieHeader: string = replaceChunkedCookieInHeader( request.headers.get('cookie') ?? '', cookieName, refreshCookieUpdate.token, @@ -323,7 +368,12 @@ const thunderIDProxy = requestHeaders.set('cookie', updatedCookieHeader); const response: NextResponse = NextResponse.next({request: {headers: requestHeaders}}); - response.cookies.set(cookieName, refreshCookieUpdate.token, cookieOptions); + setChunkedCookie( + toChunkedCookieWriter(request, response.cookies), + cookieName, + refreshCookieUpdate.token, + cookieOptions, + ); return response; }; diff --git a/packages/nextjs/src/utils/__tests__/chunkedCookie.test.ts b/packages/nextjs/src/utils/__tests__/chunkedCookie.test.ts new file mode 100644 index 00000000..c8ff43a4 --- /dev/null +++ b/packages/nextjs/src/utils/__tests__/chunkedCookie.test.ts @@ -0,0 +1,107 @@ +// Copyright 2025 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {describe, it, expect} from 'vitest'; +import {ChunkedCookieOptions, deleteChunkedCookie, getChunkedCookie, setChunkedCookie} from '../chunkedCookie'; + +const OPTIONS: ChunkedCookieOptions = {httpOnly: true, maxAge: 3600, path: '/', sameSite: 'lax', secure: false}; + +/** A minimal in-memory cookie jar satisfying the `ChunkedCookieWriter` shape. */ +class FakeCookieJar { + private readonly jar = new Map(); + + delete(name: string): void { + this.jar.delete(name); + } + + get(name: string): {value: string} | undefined { + const value: string | undefined = this.jar.get(name); + return value === undefined ? undefined : {value}; + } + + getAll(): {name: string}[] { + return Array.from(this.jar.keys()).map((name: string) => ({name})); + } + + set(name: string, value: string): void { + this.jar.set(name, value); + } +} + +describe('setChunkedCookie / getChunkedCookie', () => { + it('writes and reads back a small value as a single unchunked cookie', () => { + const jar = new FakeCookieJar(); + setChunkedCookie(jar, 'session', 'small-value', OPTIONS); + + expect(jar.getAll()).toEqual([{name: 'session'}]); + expect(getChunkedCookie(jar, 'session')).toBe('small-value'); + }); + + it('splits an oversized value across numbered chunk cookies and reassembles it', () => { + const jar = new FakeCookieJar(); + const largeValue = 'x'.repeat(10_000); + + setChunkedCookie(jar, 'session', largeValue, OPTIONS); + + const names = jar.getAll().map((c) => c.name); + expect(names.length).toBeGreaterThan(1); + expect(names.every((name: string) => /^session\.\d+$/.test(name))).toBe(true); + expect(getChunkedCookie(jar, 'session')).toBe(largeValue); + }); + + it('clears stale chunk cookies when a re-issued value shrinks back to one cookie', () => { + const jar = new FakeCookieJar(); + setChunkedCookie(jar, 'session', 'x'.repeat(12_000), OPTIONS); + expect(jar.getAll().length).toBeGreaterThan(1); + + setChunkedCookie(jar, 'session', 'small-value', OPTIONS); + + expect(jar.getAll()).toEqual([{name: 'session'}]); + expect(getChunkedCookie(jar, 'session')).toBe('small-value'); + }); + + it('clears the prior unchunked cookie when a re-issued value grows past the chunk threshold', () => { + const jar = new FakeCookieJar(); + setChunkedCookie(jar, 'session', 'small-value', OPTIONS); + expect(jar.getAll()).toEqual([{name: 'session'}]); + + const largeValue = 'y'.repeat(10_000); + setChunkedCookie(jar, 'session', largeValue, OPTIONS); + + const names = jar.getAll().map((c) => c.name); + expect(names).not.toContain('session'); + expect(names.length).toBeGreaterThan(1); + expect(getChunkedCookie(jar, 'session')).toBe(largeValue); + }); + + it('returns undefined when no cookie is present', () => { + const jar = new FakeCookieJar(); + expect(getChunkedCookie(jar, 'session')).toBeUndefined(); + }); +}); + +describe('deleteChunkedCookie', () => { + it('clears an unchunked cookie', () => { + const jar = new FakeCookieJar(); + setChunkedCookie(jar, 'session', 'small-value', OPTIONS); + + deleteChunkedCookie(jar, 'session'); + + expect(jar.getAll()).toEqual([]); + }); + + it('clears every numbered chunk', () => { + const jar = new FakeCookieJar(); + setChunkedCookie(jar, 'session', 'z'.repeat(12_000), OPTIONS); + expect(jar.getAll().length).toBeGreaterThan(1); + + deleteChunkedCookie(jar, 'session'); + + expect(jar.getAll()).toEqual([]); + }); + + it('is a no-op-safe call when nothing is present', () => { + const jar = new FakeCookieJar(); + expect(() => deleteChunkedCookie(jar, 'session')).not.toThrow(); + }); +}); diff --git a/packages/nextjs/src/utils/chunkedCookie.ts b/packages/nextjs/src/utils/chunkedCookie.ts new file mode 100644 index 00000000..4a1bf153 --- /dev/null +++ b/packages/nextjs/src/utils/chunkedCookie.ts @@ -0,0 +1,89 @@ +// Copyright 2025 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {CookieChunking} from '@thunderid/node'; + +export interface ChunkedCookieOptions { + httpOnly: boolean; + maxAge: number; + path: string; + sameSite: 'lax'; + secure: boolean; +} + +/** Minimal shape shared by `cookies()` (next/headers) and `NextRequest.cookies`. */ +export interface ChunkedCookieReader { + get(name: string): {value: string} | undefined; +} + +/** Minimal shape shared by `cookies()` (next/headers) and `NextResponse.cookies`. */ +export interface ChunkedCookieWriter { + delete(name: string): void; + getAll(): {name: string}[]; + set(name: string, value: string, options: ChunkedCookieOptions): void; +} + +/** + * Read a cookie that may have been split across `${name}.0`, `${name}.1`, + * ... chunks, reassembling it into the original value. Falls back to the + * unchunked `name` cookie when the value fit in a single cookie. + */ +export function getChunkedCookie(store: ChunkedCookieReader, name: string): string | undefined { + return CookieChunking.join(name, (cookieName: string) => store.get(cookieName)?.value); +} + +/** + * Write a cookie value, splitting it across numbered `${name}.0`, + * `${name}.1`, ... chunks once it would exceed the ~4KB per-cookie limit + * browsers enforce, and reassembling transparently via {@link getChunkedCookie}. + * + * Clears any cookie names the previous value needed but the new one doesn't + * (e.g. a smaller re-issued session that now fits in fewer chunks, or in a + * single unchunked cookie). `store.getAll()` is used to discover those stale + * names, so pass the store that reflects the cookies already present (for + * `cookies()` that's the same store being written to; for middleware it + * should be an adapter backed by the incoming `NextRequest.cookies`). + */ +export function setChunkedCookie( + store: ChunkedCookieWriter, + name: string, + value: string, + options: ChunkedCookieOptions, +): void { + const existing: string[] = CookieChunking.filterChunkNames( + name, + store.getAll().map((cookie: {name: string}) => cookie.name), + ); + const newChunks: Record = CookieChunking.split(name, value); + const newNames = new Set(Object.keys(newChunks)); + + for (const existingName of existing) { + if (!newNames.has(existingName)) { + store.delete(existingName); + } + } + + for (const [chunkName, chunkValue] of Object.entries(newChunks)) { + store.set(chunkName, chunkValue, options); + } +} + +/** + * Delete a cookie that may have been chunked — clears the base cookie name + * and every numbered chunk present in `store`. + */ +export function deleteChunkedCookie(store: ChunkedCookieWriter, name: string): void { + const existing: string[] = CookieChunking.filterChunkNames( + name, + store.getAll().map((cookie: {name: string}) => cookie.name), + ); + + if (existing.length === 0) { + store.delete(name); + return; + } + + for (const existingName of existing) { + store.delete(existingName); + } +} diff --git a/packages/nextjs/src/utils/sessionUtils.ts b/packages/nextjs/src/utils/sessionUtils.ts index b23ac97b..7dd7c9bf 100644 --- a/packages/nextjs/src/utils/sessionUtils.ts +++ b/packages/nextjs/src/utils/sessionUtils.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import {NextRequest} from 'next/server'; +import {getChunkedCookie} from './chunkedCookie'; import SessionManager, {SessionTokenPayload} from './SessionManager'; /** @@ -13,7 +14,7 @@ import SessionManager, {SessionTokenPayload} from './SessionManager'; */ export const hasValidSession = async (request: NextRequest): Promise => { try { - const sessionToken: string | undefined = request.cookies.get(SessionManager.getSessionCookieName())?.value; + const sessionToken: string | undefined = getChunkedCookie(request.cookies, SessionManager.getSessionCookieName()); if (!sessionToken) { return false; } @@ -34,7 +35,7 @@ export const hasValidSession = async (request: NextRequest): Promise => */ export const getSessionFromRequest = async (request: NextRequest): Promise => { try { - const sessionToken: string | undefined = request.cookies.get(SessionManager.getSessionCookieName())?.value; + const sessionToken: string | undefined = getChunkedCookie(request.cookies, SessionManager.getSessionCookieName()); if (!sessionToken) { return undefined; }