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
3 changes: 2 additions & 1 deletion packages/nextjs/src/server/actions/clearSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -31,7 +32,7 @@ type RequestCookies = Awaited<ReturnType<typeof cookies>>;
*/
const clearSession = async (): Promise<void> => {
const cookieStore: RequestCookies = await cookies();
cookieStore.delete(SessionManager.getSessionCookieName());
deleteChunkedCookie(cookieStore, SessionManager.getSessionCookieName());
cookieStore.delete(SessionManager.getTempSessionCookieName());
logger.debug('[clearSession] Session cookies cleared.');
};
Expand Down
3 changes: 2 additions & 1 deletion packages/nextjs/src/server/actions/getAccessToken.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReturnType<typeof cookies>>;
Expand All @@ -16,7 +17,7 @@ type RequestCookies = Awaited<ReturnType<typeof cookies>>;
const getAccessToken = async (): Promise<string | undefined> => {
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 {
Expand Down
3 changes: 2 additions & 1 deletion packages/nextjs/src/server/actions/getSessionId.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReturnType<typeof cookies>>;
Expand All @@ -17,7 +18,7 @@ type RequestCookies = Awaited<ReturnType<typeof cookies>>;
const getSessionId = async (): Promise<string | undefined> => {
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 {
Expand Down
3 changes: 2 additions & 1 deletion packages/nextjs/src/server/actions/getSessionPayload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReturnType<typeof cookies>>;
Expand All @@ -17,7 +18,7 @@ type RequestCookies = Awaited<ReturnType<typeof cookies>>;
const getSessionPayload = async (): Promise<SessionTokenPayload | undefined> => {
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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -114,7 +115,8 @@ const handleOAuthCallbackAction = async (
organizationId,
);

cookieStore.set(
setChunkedCookie(
cookieStore,
SessionManager.getSessionCookieName(),
sessionToken,
SessionManager.getSessionCookieOptions(sessionCookieExpiryTime),
Expand Down
8 changes: 5 additions & 3 deletions packages/nextjs/src/server/actions/refreshToken.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -41,7 +42,7 @@ export interface RefreshResult {
const refreshToken = async (): Promise<RefreshResult> => {
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(
Expand All @@ -64,7 +65,8 @@ const refreshToken = async (): Promise<RefreshResult> => {
});

try {
cookieStore.set(
setChunkedCookie(
cookieStore,
SessionManager.getSessionCookieName(),
result.newSessionToken,
SessionManager.getSessionCookieOptions(result.sessionCookieExpiryTime),
Expand Down Expand Up @@ -92,7 +94,7 @@ const refreshToken = async (): Promise<RefreshResult> => {
// 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.
Expand Down
9 changes: 7 additions & 2 deletions packages/nextjs/src/server/actions/signInAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -124,7 +128,8 @@ const signInAction = async (
organizationId,
);

cookieStore.set(
setChunkedCookie(
cookieStore,
SessionManager.getSessionCookieName(),
sessionToken,
SessionManager.getSessionCookieOptions(sessionCookieExpiryTime),
Expand Down
3 changes: 2 additions & 1 deletion packages/nextjs/src/server/actions/signOutAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -23,7 +24,7 @@ const signOutAction = async (): Promise<{data?: {afterSignOutUrl?: string}; erro
const clearSessionCookies = async (): Promise<void> => {
const cookieStore: RequestCookies = await cookies();

cookieStore.delete(SessionManager.getSessionCookieName());
deleteChunkedCookie(cookieStore, SessionManager.getSessionCookieName());
cookieStore.delete(SessionManager.getTempSessionCookieName());
};

Expand Down
64 changes: 64 additions & 0 deletions packages/nextjs/src/server/proxy/__tests__/thunderIDProxy.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading
Loading