Skip to content
Merged
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
1 change: 1 addition & 0 deletions packages/node/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export type {default as AuthURLCallback} from './models/AuthURLCallback';
export {default as MemoryCacheStore} from './stores/MemoryCacheStore';

// Utils
export {default as CookieChunking} from './utils/CookieChunking';
export {default as NodeCryptoUtils} from './utils/NodeCryptoUtils';
export {default as SessionUtils} from './utils/SessionUtils';
export {default as generateSessionId} from './utils/generateSessionId';
Expand Down
86 changes: 86 additions & 0 deletions packages/node/src/utils/CookieChunking.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// Copyright 2025 The ThunderID Authors
// SPDX-License-Identifier: Apache-2.0

/**
* Framework-agnostic cookie chunking. Browsers reject a `Set-Cookie` once
* the full `name=value; attributes` line exceeds ~4096 bytes, so a session
* cookie carrying a JWT (access/id/refresh tokens) can overflow that limit.
* Mirrors next-auth's session cookie chunking (packages/core/src/lib/utils/cookie.ts):
* split an oversized value across numbered `${name}.0`, `${name}.1`, ...
* cookies and reassemble on read.
*
* This class only computes chunk names/values — reading, writing, and
* deleting cookies is framework-specific (h3, Next.js, Express, ...), so
* callers plug in their own cookie access via the `getCookie` callback and
* by writing/deleting the names this class returns.
*/
class CookieChunking {
// eslint-disable-next-line @typescript-eslint/no-empty-function
private constructor() {}

static readonly ALLOWED_COOKIE_SIZE: number = 4096;

static readonly ESTIMATED_EMPTY_COOKIE_SIZE: number = 160;

static readonly CHUNK_SIZE: number = CookieChunking.ALLOWED_COOKIE_SIZE - CookieChunking.ESTIMATED_EMPTY_COOKIE_SIZE;

/**
* Builds the cookie name for chunk `index` of a chunked cookie `name`.
*/
static getChunkName(name: string, index: number): string {
return `${name}.${index}`;
}

/**
* Filters `cookieNames` down to those belonging to `name`: the unchunked
* base cookie and/or any numbered `${name}.0`, `${name}.1`, ... chunks.
*/
static filterChunkNames(name: string, cookieNames: string[]): string[] {
const prefix = `${name}.`;
return cookieNames.filter((cookieName: string) => cookieName === name || cookieName.startsWith(prefix));
}

/**
* Splits `value` into the cookie name/value pairs that should be written
* for `name`: a single `{[name]: value}` entry when it fits in one
* cookie, or numbered `${name}.0`, `${name}.1`, ... entries once it would
* exceed the ~4KB per-cookie limit.
*/
static split(name: string, value: string): Record<string, string> {
const chunkCount: number = Math.max(1, Math.ceil(value.length / CookieChunking.CHUNK_SIZE));

if (chunkCount === 1) {
return {[name]: value};
}

const chunks: Record<string, string> = {};
for (let i = 0; i < chunkCount; i += 1) {
chunks[CookieChunking.getChunkName(name, i)] = value.slice(
i * CookieChunking.CHUNK_SIZE,
(i + 1) * CookieChunking.CHUNK_SIZE,
);
}
return chunks;
}

/**
* Reassembles a cookie value that may have been split via {@link split}.
* Calls `getCookie` with `name` first, then `${name}.0`, `${name}.1`, ...
* until a lookup returns `undefined`.
*/
static join(name: string, getCookie: (cookieName: string) => string | undefined): string | undefined {
const unchunked: string | undefined = getCookie(name);
if (unchunked !== undefined) return unchunked;

const chunks: string[] = [];
for (let i = 0; ; i += 1) {
const chunk: string | undefined = getCookie(CookieChunking.getChunkName(name, i));
if (chunk === undefined) break;
chunks.push(chunk);
}

return chunks.length > 0 ? chunks.join('') : undefined;
}
}

export default CookieChunking;
46 changes: 46 additions & 0 deletions packages/node/src/utils/__tests__/CookieChunking.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// Copyright 2025 The ThunderID Authors
// SPDX-License-Identifier: Apache-2.0

import {describe, it, expect} from 'vitest';
import CookieChunking from '../CookieChunking';

describe('CookieChunking.split', () => {
it('returns a single unchunked entry for a small value', () => {
expect(CookieChunking.split('session', 'small-value')).toEqual({session: 'small-value'});
});

it('splits an oversized value into numbered chunk entries', () => {
const largeValue = 'x'.repeat(10_000);
const chunks = CookieChunking.split('session', largeValue);

const names = Object.keys(chunks);
expect(names.length).toBeGreaterThan(1);
expect(names.every((name: string) => /^session\.\d+$/.test(name))).toBe(true);
expect(Object.values(chunks).join('')).toBe(largeValue);
});
});

describe('CookieChunking.join', () => {
it('returns the unchunked value when present', () => {
const jar: Record<string, string> = {session: 'small-value'};
expect(CookieChunking.join('session', (name: string) => jar[name])).toBe('small-value');
});

it('reassembles numbered chunks in order', () => {
const largeValue = 'x'.repeat(10_000);
const jar = CookieChunking.split('session', largeValue);

expect(CookieChunking.join('session', (name: string) => jar[name])).toBe(largeValue);
});

it('returns undefined when nothing is present', () => {
expect(CookieChunking.join('session', () => undefined)).toBeUndefined();
});
});

describe('CookieChunking.filterChunkNames', () => {
it('matches the base name and its numbered chunks only', () => {
const names = ['session', 'session.0', 'session.1', 'other', 'session-other'];
expect(CookieChunking.filterChunkNames('session', names)).toEqual(['session', 'session.0', 'session.1']);
});
});
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
65 changes: 65 additions & 0 deletions packages/nuxt/src/runtime/server/utils/chunkedCookie.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// Copyright 2025 The ThunderID Authors
// SPDX-License-Identifier: Apache-2.0

import {CookieChunking} from '@thunderid/node';
import {deleteCookie, getCookie, parseCookies, setCookie} from 'h3';
import type {H3Event} from 'h3';

interface ChunkedCookieOptions {
httpOnly: boolean;
maxAge: number;
path: string;
sameSite: 'lax';
secure: boolean;
}

/**
* 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 {
return CookieChunking.join(name, (cookieName: string) => getCookie(event, cookieName));
}

/**
* 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).
*/
export function setChunkedCookie(event: H3Event, name: string, value: string, options: ChunkedCookieOptions): void {
const existing: string[] = CookieChunking.filterChunkNames(name, Object.keys(parseCookies(event)));
const newChunks: Record<string, string> = CookieChunking.split(name, value);
const newNames = new Set<string>(Object.keys(newChunks));

for (const existingName of existing) {
if (!newNames.has(existingName)) {
deleteCookie(event, existingName, options);
}
}

for (const [chunkCookieName, chunkValue] of Object.entries(newChunks)) {
setCookie(event, chunkCookieName, chunkValue, 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[] = CookieChunking.filterChunkNames(name, Object.keys(parseCookies(event)));

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