diff --git a/packages/nuxt/src/runtime/server/routes/auth/session/signout.post.ts b/packages/nuxt/src/runtime/server/routes/auth/session/signout.post.ts index bc17c831..f8b79806 100644 --- a/packages/nuxt/src/runtime/server/routes/auth/session/signout.post.ts +++ b/packages/nuxt/src/runtime/server/routes/auth/session/signout.post.ts @@ -11,6 +11,7 @@ import { getTempSessionCookieName, getTempSessionCookieOptions, } from '../../../utils/session'; +import {deleteChunkedCookie} from '../../../utils/chunkedCookie'; import {useRuntimeConfig} from '#imports'; /** @@ -30,7 +31,7 @@ export default defineEventHandler(async (event: H3Event): Promise<{redirectUrl: const fallbackUrl: string = (publicConfig as any).afterSignOutUrl || '/'; const clearCookies = (): void => { - deleteCookie(event, getSessionCookieName(), getSessionCookieOptions()); + deleteChunkedCookie(event, getSessionCookieName(), getSessionCookieOptions()); deleteCookie(event, getTempSessionCookieName(), getTempSessionCookieOptions()); }; diff --git a/packages/nuxt/src/runtime/server/utils/chunkedCookie.ts b/packages/nuxt/src/runtime/server/utils/chunkedCookie.ts new file mode 100644 index 00000000..256cc4a0 --- /dev/null +++ b/packages/nuxt/src/runtime/server/utils/chunkedCookie.ts @@ -0,0 +1,107 @@ +// Copyright 2025 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {deleteCookie, getCookie, parseCookies, setCookie} from 'h3'; +import type {H3Event} from 'h3'; + +// Mirrors next-auth's session cookie chunking constants +// (packages/core/src/lib/utils/cookie.ts): browsers reject a `Set-Cookie` +// once the full `name=value; attributes` line exceeds ~4096 bytes, so the +// payload budget per chunk reserves headroom for the cookie's own name and +// attributes (Path, HttpOnly, SameSite, Max-Age, ...). +const ALLOWED_COOKIE_SIZE = 4096; +const ESTIMATED_EMPTY_COOKIE_SIZE = 160; +const CHUNK_SIZE = ALLOWED_COOKIE_SIZE - ESTIMATED_EMPTY_COOKIE_SIZE; + +interface ChunkedCookieOptions { + httpOnly: boolean; + maxAge: number; + path: string; + sameSite: 'lax'; + secure: boolean; +} + +function chunkName(name: string, index: number): string { + return `${name}.${index}`; +} + +/** + * Every cookie name in the current request belonging to `name` — the + * unchunked base cookie and/or any numbered `${name}.0`, `${name}.1`, ... + * chunks left over from a previous write. + */ +function findExistingCookieNames(event: H3Event, name: string): string[] { + const all: Record = parseCookies(event); + const prefix = `${name}.`; + return Object.keys(all).filter((key: string) => key === name || key.startsWith(prefix)); +} + +/** + * 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(event: H3Event, name: string): string | undefined { + const unchunked: string | undefined = getCookie(event, name); + if (unchunked !== undefined) return unchunked; + + const chunks: string[] = []; + for (let i = 0; ; i += 1) { + const chunk: string | undefined = getCookie(event, chunkName(name, i)); + if (chunk === undefined) break; + chunks.push(chunk); + } + + return chunks.length > 0 ? chunks.join('') : undefined; +} + +/** + * 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}. + * Mirrors next-auth's session cookie chunking. + * + * 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). + */ +export function setChunkedCookie(event: H3Event, name: string, value: string, options: ChunkedCookieOptions): void { + const existing: string[] = findExistingCookieNames(event, name); + const chunkCount: number = Math.max(1, Math.ceil(value.length / CHUNK_SIZE)); + const newNames: Set = + chunkCount === 1 + ? new Set([name]) + : new Set(Array.from({length: chunkCount}, (_, i: number) => chunkName(name, i))); + + for (const existingName of existing) { + if (!newNames.has(existingName)) { + deleteCookie(event, existingName, options); + } + } + + if (chunkCount === 1) { + setCookie(event, name, value, options); + return; + } + + for (let i = 0; i < chunkCount; i += 1) { + setCookie(event, chunkName(name, i), value.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE), options); + } +} + +/** + * Delete a cookie that may have been chunked — clears the base cookie name + * and every numbered chunk present in the current request. + */ +export function deleteChunkedCookie(event: H3Event, name: string, options: ChunkedCookieOptions): void { + const existing: string[] = findExistingCookieNames(event, name); + + if (existing.length === 0) { + deleteCookie(event, name, options); + return; + } + + for (const existingName of existing) { + deleteCookie(event, existingName, options); + } +} diff --git a/packages/nuxt/src/runtime/server/utils/serverSession.ts b/packages/nuxt/src/runtime/server/utils/serverSession.ts index fa02e227..98155e3a 100644 --- a/packages/nuxt/src/runtime/server/utils/serverSession.ts +++ b/packages/nuxt/src/runtime/server/utils/serverSession.ts @@ -2,8 +2,9 @@ // SPDX-License-Identifier: Apache-2.0 import type {H3Event} from 'h3'; -import {getCookie, createError} from 'h3'; +import {createError} from 'h3'; import {verifySessionToken, getSessionCookieName} from './session'; +import {getChunkedCookie} from './chunkedCookie'; import type {ThunderIDSessionPayload} from '../../types'; import ThunderIDNuxtClient from '../ThunderIDNuxtClient'; import {useRuntimeConfig} from '#imports'; @@ -30,7 +31,7 @@ export async function useServerSession(event: H3Event): Promise = useRuntimeConfig(); const sessionSecret: string | undefined = config.thunderid?.sessionSecret; - const sessionCookie: string | undefined = getCookie(event, getSessionCookieName()); + const sessionCookie: string | undefined = getChunkedCookie(event, getSessionCookieName()); if (!sessionCookie) { return null; } @@ -82,7 +83,7 @@ export async function verifyAndRehydrateSession( event: H3Event, sessionSecret?: string, ): Promise { - const sessionCookie: string | undefined = getCookie(event, getSessionCookieName()); + const sessionCookie: string | undefined = getChunkedCookie(event, getSessionCookieName()); if (!sessionCookie) { return null; } diff --git a/packages/nuxt/src/runtime/server/utils/session.ts b/packages/nuxt/src/runtime/server/utils/session.ts index 90aa54fb..5c0e4644 100644 --- a/packages/nuxt/src/runtime/server/utils/session.ts +++ b/packages/nuxt/src/runtime/server/utils/session.ts @@ -3,10 +3,10 @@ import {CookieConfig} from '@thunderid/node'; import type {IdToken, TokenResponse} from '@thunderid/node'; -import {setCookie} from 'h3'; import type {H3Event} from 'h3'; import {SignJWT, jwtVerify} from 'jose'; import type {ThunderIDSessionPayload} from '../../types'; +import {setChunkedCookie} from './chunkedCookie'; const DEFAULT_EXPIRY_SECONDS = 3600; @@ -211,5 +211,5 @@ export async function issueSessionCookie( sessionSecret, ); - setCookie(event, getSessionCookieName(), sessionToken, getSessionCookieOptions()); + setChunkedCookie(event, getSessionCookieName(), sessionToken, getSessionCookieOptions()); } diff --git a/packages/nuxt/src/runtime/server/utils/token-refresh.ts b/packages/nuxt/src/runtime/server/utils/token-refresh.ts index 0f2807ab..a4896380 100644 --- a/packages/nuxt/src/runtime/server/utils/token-refresh.ts +++ b/packages/nuxt/src/runtime/server/utils/token-refresh.ts @@ -1,9 +1,10 @@ // Copyright 2025 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import {createError, setCookie, type H3Event} from 'h3'; +import {createError, type H3Event} from 'h3'; import {requireServerSession} from './serverSession'; import {createSessionToken, getSessionCookieName, getSessionCookieOptions} from './session'; +import {setChunkedCookie} from './chunkedCookie'; import type {ThunderIDSessionPayload} from '../../types'; import {useRuntimeConfig} from '#imports'; @@ -120,7 +121,7 @@ export async function getValidAccessToken(event: H3Event): Promise { privateConfig?.sessionSecret, ); - setCookie(event, getSessionCookieName(), newSessionToken, getSessionCookieOptions()); + setChunkedCookie(event, getSessionCookieName(), newSessionToken, getSessionCookieOptions()); return refreshed.access_token; } diff --git a/packages/nuxt/tests/unit/chunked-cookie.test.ts b/packages/nuxt/tests/unit/chunked-cookie.test.ts new file mode 100644 index 00000000..6dceaf72 --- /dev/null +++ b/packages/nuxt/tests/unit/chunked-cookie.test.ts @@ -0,0 +1,206 @@ +// Copyright 2025 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/* eslint-disable @typescript-eslint/typedef, sort-keys, @typescript-eslint/explicit-function-return-type */ + +import type {H3Event} from 'h3'; +import {describe, it, expect} from 'vitest'; +import {deleteChunkedCookie, getChunkedCookie, setChunkedCookie} from '../../src/runtime/server/utils/chunkedCookie'; + +const OPTIONS = {httpOnly: true, maxAge: 3600, path: '/', sameSite: 'lax' as const, secure: false}; + +/** + * A minimal fake H3Event, backed by real Node-response-shaped `req`/`res` + * objects, so the tests exercise h3's real `getCookie`/`setCookie`/ + * `deleteCookie`/`parseCookies` rather than a hand-rolled mock. + */ +function createMockEvent(cookieHeader = ''): H3Event & {responseHeaders: Record} { + const responseHeaders: Record = {}; + + const res = { + appendHeader: (name: string, value: string): void => { + const key = name.toLowerCase(); + const existing = responseHeaders[key]; + if (existing === undefined) responseHeaders[key] = value; + else if (Array.isArray(existing)) existing.push(value); + else responseHeaders[key] = [existing, value]; + }, + getHeader: (name: string): string | string[] | undefined => responseHeaders[name.toLowerCase()], + removeHeader: (name: string): void => { + delete responseHeaders[name.toLowerCase()]; + }, + setHeader: (name: string, value: string | string[]): void => { + responseHeaders[name.toLowerCase()] = value; + }, + }; + + const req = {headers: {cookie: cookieHeader}}; + + return {node: {req, res}, responseHeaders} as unknown as H3Event & { + responseHeaders: Record; + }; +} + +/** Extracts the `Set-Cookie` values written to a mock event's response. */ +function getSetCookieHeaders(event: H3Event & {responseHeaders: Record}): string[] { + const header = event.responseHeaders['set-cookie']; + if (!header) return []; + return Array.isArray(header) ? header : [header]; +} + +/** + * Applies a mock event's `Set-Cookie` response headers onto a cookie jar, + * simulating a browser: `Max-Age=0` deletes, everything else is stored (or + * overwritten). Returns the jar serialized as a `Cookie` request header. + */ +function applyResponseCookies( + event: H3Event & {responseHeaders: Record}, + jar: Record = {}, +): Record { + const next: Record = {...jar}; + + for (const raw of getSetCookieHeaders(event)) { + const [pair, ...attrs] = raw.split(';').map((s: string) => s.trim()); + const eqIndex = pair.indexOf('='); + const name = pair.slice(0, eqIndex); + const value = pair.slice(eqIndex + 1); + const maxAgeAttr = attrs.find((a: string) => a.toLowerCase().startsWith('max-age=')); + const maxAge = maxAgeAttr ? Number(maxAgeAttr.split('=')[1]) : undefined; + + if (maxAge === 0) { + delete next[name]; + } else { + next[name] = value; + } + } + + return next; +} + +function toCookieHeader(jar: Record): string { + return Object.entries(jar) + .map(([name, value]) => `${name}=${value}`) + .join('; '); +} + +describe('setChunkedCookie / getChunkedCookie', () => { + it('writes a small value as a single unchunked cookie', () => { + const event = createMockEvent(); + setChunkedCookie(event, 'session', 'small-value', OPTIONS); + + const cookies = getSetCookieHeaders(event); + expect(cookies).toHaveLength(1); + expect(cookies[0].startsWith('session=small-value;')).toBe(true); + }); + + it('round-trips a small value through the next request', () => { + const writeEvent = createMockEvent(); + setChunkedCookie(writeEvent, 'session', 'small-value', OPTIONS); + + const jar = applyResponseCookies(writeEvent); + const readEvent = createMockEvent(toCookieHeader(jar)); + + expect(getChunkedCookie(readEvent, 'session')).toBe('small-value'); + }); + + it('splits an oversized value across numbered chunk cookies and reassembles it', () => { + const largeValue = 'x'.repeat(10_000); + + const writeEvent = createMockEvent(); + setChunkedCookie(writeEvent, 'session', largeValue, OPTIONS); + + const cookies = getSetCookieHeaders(writeEvent); + // Must be split — a single 10,000-byte cookie would never fit in one. + expect(cookies.length).toBeGreaterThan(1); + expect(cookies.every((c: string) => /^session\.\d+=/.test(c))).toBe(true); + expect(cookies.some((c: string) => c.startsWith('session='))).toBe(false); + + const jar = applyResponseCookies(writeEvent); + const readEvent = createMockEvent(toCookieHeader(jar)); + + expect(getChunkedCookie(readEvent, 'session')).toBe(largeValue); + }); + + it('clears stale higher-numbered chunks when a re-issued value shrinks', () => { + const largeValue = 'x'.repeat(12_000); + const smallValue = 'small-value'; + + // First write: chunked into several cookies. + const firstWrite = createMockEvent(); + setChunkedCookie(firstWrite, 'session', largeValue, OPTIONS); + let jar = applyResponseCookies(firstWrite); + expect(Object.keys(jar).filter((k: string) => k.startsWith('session.')).length).toBeGreaterThan(1); + + // Second write, from a request carrying the first write's chunk cookies: + // re-issuing a much smaller value should collapse back to one cookie and + // clear every leftover numbered chunk. + const secondWrite = createMockEvent(toCookieHeader(jar)); + setChunkedCookie(secondWrite, 'session', smallValue, OPTIONS); + jar = applyResponseCookies(secondWrite, jar); + + expect(jar.session).toBe(smallValue); + expect(Object.keys(jar).some((k: string) => k.startsWith('session.'))).toBe(false); + + const readEvent = createMockEvent(toCookieHeader(jar)); + expect(getChunkedCookie(readEvent, 'session')).toBe(smallValue); + }); + + it('clears the prior unchunked cookie when a re-issued value grows past the chunk threshold', () => { + const smallValue = 'small-value'; + const largeValue = 'y'.repeat(10_000); + + const firstWrite = createMockEvent(); + setChunkedCookie(firstWrite, 'session', smallValue, OPTIONS); + let jar = applyResponseCookies(firstWrite); + expect(jar.session).toBe(smallValue); + + const secondWrite = createMockEvent(toCookieHeader(jar)); + setChunkedCookie(secondWrite, 'session', largeValue, OPTIONS); + jar = applyResponseCookies(secondWrite, jar); + + expect(jar.session).toBeUndefined(); + expect(Object.keys(jar).filter((k: string) => k.startsWith('session.')).length).toBeGreaterThan(1); + + const readEvent = createMockEvent(toCookieHeader(jar)); + expect(getChunkedCookie(readEvent, 'session')).toBe(largeValue); + }); + + it('returns undefined when no cookie is present', () => { + const event = createMockEvent(); + expect(getChunkedCookie(event, 'session')).toBeUndefined(); + }); +}); + +describe('deleteChunkedCookie', () => { + it('clears an unchunked cookie', () => { + const writeEvent = createMockEvent(); + setChunkedCookie(writeEvent, 'session', 'small-value', OPTIONS); + let jar = applyResponseCookies(writeEvent); + + const deleteEvent = createMockEvent(toCookieHeader(jar)); + deleteChunkedCookie(deleteEvent, 'session', OPTIONS); + jar = applyResponseCookies(deleteEvent, jar); + + expect(jar.session).toBeUndefined(); + }); + + it('clears every numbered chunk present in the request', () => { + const largeValue = 'z'.repeat(12_000); + + const writeEvent = createMockEvent(); + setChunkedCookie(writeEvent, 'session', largeValue, OPTIONS); + let jar = applyResponseCookies(writeEvent); + expect(Object.keys(jar).filter((k: string) => k.startsWith('session.')).length).toBeGreaterThan(1); + + const deleteEvent = createMockEvent(toCookieHeader(jar)); + deleteChunkedCookie(deleteEvent, 'session', OPTIONS); + jar = applyResponseCookies(deleteEvent, jar); + + expect(Object.keys(jar).some((k: string) => k === 'session' || k.startsWith('session.'))).toBe(false); + }); + + it('is a no-op-safe call when nothing is present', () => { + const event = createMockEvent(); + expect(() => deleteChunkedCookie(event, 'session', OPTIONS)).not.toThrow(); + }); +}); diff --git a/packages/nuxt/tests/unit/token-refresh.test.ts b/packages/nuxt/tests/unit/token-refresh.test.ts index 9de93dd7..5d9ccbf6 100644 --- a/packages/nuxt/tests/unit/token-refresh.test.ts +++ b/packages/nuxt/tests/unit/token-refresh.test.ts @@ -10,15 +10,15 @@ * - requireServerSession(event) — reads the session from the JWT cookie * - useRuntimeConfig(event) — reads Nuxt runtime config * - fetch — calls the OIDC token endpoint - * - setCookie — re-issues the session cookie on refresh + * - setChunkedCookie — re-issues the session cookie on refresh * * All four are mocked so no HTTP calls or real Nuxt context is needed. */ -import {setCookie} from 'h3'; import {describe, it, expect, vi, beforeEach} from 'vitest'; import {requireServerSession} from '../../src/runtime/server/utils/serverSession'; import {verifySessionToken, getSessionCookieName} from '../../src/runtime/server/utils/session'; +import {setChunkedCookie} from '../../src/runtime/server/utils/chunkedCookie'; import {getValidAccessToken} from '../../src/runtime/server/utils/token-refresh'; import {useRuntimeConfig} from '#imports'; @@ -30,15 +30,10 @@ vi.mock('#imports', () => ({ useRuntimeConfig: vi.fn(), })); -// ─── Mock h3 (setCookie) ────────────────────────────────────────────────── -vi.mock('h3', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - setCookie: vi.fn(), - createError: actual.createError, - }; -}); +// ─── Mock chunkedCookie (setChunkedCookie) ──────────────────────────────── +vi.mock('../../src/runtime/server/utils/chunkedCookie', () => ({ + setChunkedCookie: vi.fn(), +})); // ─── Mock serverSession (requireServerSession) ──────────────────────────── vi.mock('../../src/runtime/server/utils/serverSession', () => ({ @@ -96,7 +91,7 @@ describe('getValidAccessToken — token still fresh', () => { const token = await getValidAccessToken(fakeEvent); expect(token).toBe('at_original'); - expect(setCookie).not.toHaveBeenCalled(); + expect(setChunkedCookie).not.toHaveBeenCalled(); }); it('returns the stored token when well before expiry', async () => { @@ -106,7 +101,7 @@ describe('getValidAccessToken — token still fresh', () => { const token = await getValidAccessToken(fakeEvent); expect(token).toBe('at_original'); - expect(setCookie).not.toHaveBeenCalled(); + expect(setChunkedCookie).not.toHaveBeenCalled(); }); it('returns the stored token when exactly at the 60 s skew boundary', async () => { @@ -209,9 +204,9 @@ describe('getValidAccessToken — successful refresh', () => { await getValidAccessToken(fakeEvent); - expect(setCookie).toHaveBeenCalledOnce(); + expect(setChunkedCookie).toHaveBeenCalledOnce(); // First arg is the event, second is the cookie name - expect(vi.mocked(setCookie).mock.calls[0][1]).toBe(getSessionCookieName()); + expect(vi.mocked(setChunkedCookie).mock.calls[0][1]).toBe(getSessionCookieName()); vi.unstubAllGlobals(); }); @@ -235,7 +230,7 @@ describe('getValidAccessToken — successful refresh', () => { // Verify the new session cookie contains the original refresh token by // decoding the JWT written to the cookie. - const cookieCall = vi.mocked(setCookie).mock.calls[0]; + const cookieCall = vi.mocked(setChunkedCookie).mock.calls[0]; const cookieValue = cookieCall[2] as string; const payload = await verifySessionToken(cookieValue, TEST_SECRET); expect(payload.refreshToken).toBe('rt_kept');