Skip to content
Closed
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
147 changes: 147 additions & 0 deletions go/lib/session/chunked-iron-session.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import {
IronSession,
SessionOptions,
sealData,
unsealData,
} from "iron-session"

// Minimal subset of next/headers' cookie store that we depend on.
interface CookieStore {
get(name: string): { name: string; value: string } | undefined
set(name: string, value: string, options?: Record<string, unknown>): void
}

// Browsers reject Set-Cookie payloads larger than ~4096 bytes. We size each
// chunk well below that to leave headroom for the cookie name and attributes.
const DEFAULT_CHUNK_SIZE = 3500
const MAX_COOKIE_SIZE = 4096

const chunkName = (cookieName: string, index: number) => `${cookieName}.${index}`

const readSealFromChunks = (cookieStore: CookieStore, cookieName: string): string => {
// Sessions that fit in a single cookie keep the original name, so prefer it.
const direct = cookieStore.get(cookieName)?.value
if (direct) return direct

const chunks: string[] = []
for (let i = 0; ; i++) {
const value = cookieStore.get(chunkName(cookieName, i))?.value
if (!value) break
chunks.push(value)
}
return chunks.join("")
}

const clearChunks = (
cookieStore: CookieStore,
cookieName: string,
cookieOptions: Record<string, unknown>
) => {
for (let i = 0; ; i++) {
const name = chunkName(cookieName, i)
if (!cookieStore.get(name)?.value) break
cookieStore.set(name, "", { ...cookieOptions, maxAge: 0 })
}
}

const splitIntoChunks = (value: string, chunkSize: number): string[] => {
const chunks: string[] = []
for (let i = 0; i < value.length; i += chunkSize) {
chunks.push(value.slice(i, i + chunkSize))
}
return chunks
}

export interface ChunkingOptions {
chunkSize?: number
}

/**
* Drop-in replacement for iron-session's `getIronSession(cookieStore, …)` that
* transparently splits oversized seals across multiple cookies
* (`{cookieName}.0`, `{cookieName}.1`, …) and reassembles them on read.
*
* Mirrors PR vvo/iron-session#937 until upstream merges.
*/
export async function getChunkedIronSession<T extends object>(
cookieStore: CookieStore,
sessionOptions: SessionOptions,
chunking: ChunkingOptions = {}
): Promise<IronSession<T>> {
const ttl = sessionOptions.ttl ?? 60 * 60 * 24 * 14
// Match iron-session: cookie expires 60s before the seal so the browser
// never sends a cookie we'd reject as expired.
const cookieOptions: Record<string, unknown> = {
httpOnly: true,
secure: true,
sameSite: "lax",
path: "/",
...sessionOptions.cookieOptions,
...(ttl === 0 ? {} : { maxAge: ttl - 60 }),
}
const chunkSize = chunking.chunkSize ?? DEFAULT_CHUNK_SIZE

const seal = readSealFromChunks(cookieStore, sessionOptions.cookieName)
let data: T = {} as T
if (seal) {
try {
data = await unsealData<T>(seal, {
password: sessionOptions.password,
ttl,
})
} catch {
// Corrupt/expired seal — start with an empty session, same as iron-session.
data = {} as T
}
}

const save = async () => {
const newSeal = await sealData(data, {
password: sessionOptions.password,
ttl,
})

// Always purge any existing chunks before deciding the new layout, so we
// never leak stale chunks across a chunked→single (or larger→smaller)
// transition.
clearChunks(cookieStore, sessionOptions.cookieName, cookieOptions)

const singleCookieSize =
sessionOptions.cookieName.length +
newSeal.length +
JSON.stringify(cookieOptions).length

if (singleCookieSize <= MAX_COOKIE_SIZE) {
cookieStore.set(sessionOptions.cookieName, newSeal, cookieOptions)
return
}

// Seal won't fit in one cookie — clear the main cookie and write chunks.
cookieStore.set(sessionOptions.cookieName, "", { ...cookieOptions, maxAge: 0 })
splitIntoChunks(newSeal, chunkSize).forEach((chunk, index) => {
cookieStore.set(chunkName(sessionOptions.cookieName, index), chunk, cookieOptions)
})
}

const destroy = () => {
for (const key of Object.keys(data)) {
delete (data as Record<string, unknown>)[key]
}
cookieStore.set(sessionOptions.cookieName, "", { ...cookieOptions, maxAge: 0 })
clearChunks(cookieStore, sessionOptions.cookieName, cookieOptions)
}

// Methods are non-enumerable so JSON.stringify/Object.assign/sealData
// don't accidentally serialize them into the cookie.
Object.defineProperties(data, {
save: { value: save },
destroy: { value: destroy },
updateConfig: {
value: () => {
// Not used in this codebase; left as a no-op for IronSession parity.
},
},
})

return data as IronSession<T>
}
5 changes: 3 additions & 2 deletions go/lib/session/session.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { add, isPast, sub } from "date-fns"
import { IronSession, SessionOptions, getIronSession } from "iron-session"
import { IronSession, SessionOptions } from "iron-session"
import { unstable_rethrow } from "next/navigation"
import { NextResponse, connection } from "next/server"

Expand All @@ -12,6 +12,7 @@ import { isBuildingGoApp } from "../helpers/next-phase"
import { loadPatronServerSide } from "../helpers/service-layer"
import { userIsAnonymous } from "../helpers/user"
import { TSessionType, TUniloginTokenSet } from "../types/session"
import { getChunkedIronSession } from "./chunked-iron-session"

export const getSessionOptions = (): SessionOptions => {
const sessionSecret = getServerEnv("GO_SESSION_SECRET")
Expand Down Expand Up @@ -76,7 +77,7 @@ export async function getSession(): Promise<IronSession<TSessionData>> {
const { cookies } = await import("next/headers")
const cookieStore = await cookies()
const libraryToken = cookieStore.get(goConfig("library-token.cookie-name"))?.value
const session = await getIronSession<TSessionData>(cookieStore, sessionOptions)
const session = await getChunkedIronSession<TSessionData>(cookieStore, sessionOptions)

if (!session?.isLoggedIn) {
// Return the default session if the session is not logged in.
Expand Down
Loading