Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
getTempSessionCookieName,
getTempSessionCookieOptions,
} from '../../../utils/session';
import {deleteChunkedCookie} from '../../../utils/chunkedCookie';
import {useRuntimeConfig} from '#imports';

/**
Expand All @@ -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());
};

Expand Down
107 changes: 107 additions & 0 deletions packages/nuxt/src/runtime/server/utils/chunkedCookie.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = parseCookies(event);
const prefix = `${name}.`;
return Object.keys(all).filter((key: string) => key === name || key.startsWith(prefix));
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* 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<string> =
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);
}
}
7 changes: 4 additions & 3 deletions packages/nuxt/src/runtime/server/utils/serverSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -30,7 +31,7 @@ export async function useServerSession(event: H3Event): Promise<ThunderIDSession
const config: ReturnType<typeof useRuntimeConfig> = 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;
}
Expand Down Expand Up @@ -82,7 +83,7 @@ export async function verifyAndRehydrateSession(
event: H3Event,
sessionSecret?: string,
): Promise<ThunderIDSessionPayload | null> {
const sessionCookie: string | undefined = getCookie(event, getSessionCookieName());
const sessionCookie: string | undefined = getChunkedCookie(event, getSessionCookieName());
if (!sessionCookie) {
return null;
}
Expand Down
4 changes: 2 additions & 2 deletions packages/nuxt/src/runtime/server/utils/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -211,5 +211,5 @@ export async function issueSessionCookie(
sessionSecret,
);

setCookie(event, getSessionCookieName(), sessionToken, getSessionCookieOptions());
setChunkedCookie(event, getSessionCookieName(), sessionToken, getSessionCookieOptions());
}
5 changes: 3 additions & 2 deletions packages/nuxt/src/runtime/server/utils/token-refresh.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -120,7 +121,7 @@ export async function getValidAccessToken(event: H3Event): Promise<string> {
privateConfig?.sessionSecret,
);

setCookie(event, getSessionCookieName(), newSessionToken, getSessionCookieOptions());
setChunkedCookie(event, getSessionCookieName(), newSessionToken, getSessionCookieOptions());

return refreshed.access_token;
}
Loading
Loading