Skip to content

Commit

Permalink
feat: introduce chunking when session cookie becomes too big (#3101)
Browse files Browse the repository at this point in the history
If the expected cookie size would exceed the 4096 bytes most browsers allow, we split up the cookie value and put the content into multiple cookies, then assemble it upon reading it back. This eliminates the need for a database or user-land solutions in case the user wants to save more data or is constrained by their IdP for certain fields.
  • Loading branch information
balazsorban44 authored Nov 15, 2021
1 parent 38cefdd commit f9e0ef8
Show file tree
Hide file tree
Showing 13 changed files with 274 additions and 296 deletions.
1 change: 0 additions & 1 deletion app/pages/api/auth/[...nextauth].ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,6 @@ export const authOptions: NextAuthOptions = {
}),
],
jwt: {
encryption: true,
secret: process.env.SECRET,
},
debug: true,
Expand Down
5 changes: 2 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
"dependencies": {
"@babel/runtime": "^7.15.4",
"@panva/hkdf": "^1.0.0",
"cookie": "^0.4.1",
"jose": "^4.1.2",
"oauth": "^0.9.15",
"openid-client": "^5.0.2",
Expand Down
20 changes: 11 additions & 9 deletions src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@ import * as routes from "./routes"
import renderPage from "./pages"
import type { NextAuthOptions } from "./types"
import { init } from "./init"
import { Cookie } from "./lib/cookie"
import { Cookie, SessionStore } from "./lib/cookie"

import { NextAuthAction } from "../lib/types"

export interface IncomingRequest {
/** @default "http://localhost:3000" */
host: string
method: string
cookies?: Record<string, any>
cookies?: Record<string, string>
headers?: Record<string, any>
query?: Record<string, any>
body?: Record<string, any>
Expand Down Expand Up @@ -57,9 +57,11 @@ export async function NextAuthHandler<
isPost: req.method === "POST",
})

const sessionToken =
req.cookies?.[options.cookies.sessionToken.name] ||
req.headers?.Authorization?.replace("Bearer ", "")
const sessionStore = new SessionStore(
options.cookies.sessionToken,
req,
options.logger
)

if (req.method === "GET") {
const render = renderPage({ options, query: req.query, cookies })
Expand All @@ -68,7 +70,7 @@ export async function NextAuthHandler<
case "providers":
return (await routes.providers(options.providers)) as any
case "session":
return (await routes.session({ options, sessionToken })) as any
return (await routes.session({ options, sessionStore })) as any
case "csrf":
return {
headers: [{ key: "Content-Type", value: "application/json" }],
Expand Down Expand Up @@ -98,7 +100,7 @@ export async function NextAuthHandler<
headers: req.headers,
cookies: req.cookies,
options,
sessionToken,
sessionStore,
})
if (callback.cookies) cookies.push(...callback.cookies)
return { ...callback, cookies }
Expand Down Expand Up @@ -158,7 +160,7 @@ export async function NextAuthHandler<
case "signout":
// Verified CSRF Token required for signout
if (options.csrfTokenVerified) {
const signout = await routes.signout({ options, sessionToken })
const signout = await routes.signout({ options, sessionStore })
if (signout.cookies) cookies.push(...signout.cookies)
return { ...signout, cookies }
}
Expand All @@ -180,7 +182,7 @@ export async function NextAuthHandler<
headers: req.headers,
cookies: req.cookies,
options,
sessionToken,
sessionStore,
})
if (callback.cookies) cookies.push(...callback.cookies)
return { ...callback, cookies }
Expand Down
266 changes: 128 additions & 138 deletions src/core/lib/cookie.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,30 @@
// REVIEW: Is there any way to defer two types of strings?
import type { NextAuthResponse } from "../../lib/types"
import type { IncomingHttpHeaders } from "http"
import type { CookiesOptions } from "../.."
import type { CookieOption, SessionStrategy } from "../types"
import type { ServerResponse } from "http"
import type { CookieOption, LoggerInstance, SessionStrategy } from "../types"

// Uncomment to recalculate the estimated size
// of an empty session cookie
// import { serialize } from "cookie"
// console.log(
// "Cookie estimated to be ",
// serialize(`__Secure.next-auth.session-token.0`, "", {
// expires: new Date(),
// httpOnly: true,
// maxAge: Number.MAX_SAFE_INTEGER,
// path: "/",
// sameSite: "strict",
// secure: true,
// domain: "example.com",
// }).length,
// " bytes"
// )

const ALLOWED_COOKIE_SIZE = 4096
// Based on commented out section above
const ESTIMATED_EMPTY_COOKIE_SIZE = 163
const CHUNK_SIZE = ALLOWED_COOKIE_SIZE - ESTIMATED_EMPTY_COOKIE_SIZE

// REVIEW: Is there any way to defer two types of strings?

/** Stringified form of `JWT`. Extract the content with `jwt.decode` */
export type JWTString = string
Expand All @@ -20,140 +42,6 @@ export type SessionToken<T extends SessionStrategy = "jwt"> = T extends "jwt"
? JWTString
: string

/**
* Function to set cookies server side
*
* Credit to @huv1k and @jshttp contributors for the code which this is based on (MIT License).
* * https://github.com/jshttp/cookie/blob/master/index.js
* * https://github.com/zeit/next.js/blob/master/examples/api-routes-middleware/utils/cookies.js
*
* As only partial functionlity is required, only the code we need has been incorporated here
* (with fixes for specific issues) to keep dependancy size down.
*/
export function set(
res: NextAuthResponse | ServerResponse,
name: string,
value: unknown,
options: SetCookieOptions = {}
) {
const stringValue =
typeof value === "object" ? "j:" + JSON.stringify(value) : String(value)

if ("maxAge" in options) {
options.expires = new Date(Date.now() + (options.maxAge ?? 0))
options.maxAge = (options.maxAge ?? 0) / 1000
}

// Preserve any existing cookies that have already been set in the same session
let setCookieHeader = res.getHeader("Set-Cookie") ?? []
// If not an array (i.e. a string with a single cookie) convert it into an array
if (!Array.isArray(setCookieHeader)) {
setCookieHeader = [setCookieHeader.toString()]
}
setCookieHeader.push(_serialize(name, String(stringValue), options))
res.setHeader("Set-Cookie", setCookieHeader)
}

function _serialize(
name: string,
val: unknown,
options: SetCookieOptions = {}
) {
const fieldContentRegExp = /^[\u0009\u0020-\u007e\u0080-\u00ff]+$/ // eslint-disable-line no-control-regex

const opt = options || {}
const enc = opt.encode ?? encodeURIComponent

if (typeof enc !== "function") {
throw new TypeError("option encode is invalid")
}

if (!fieldContentRegExp.test(name)) {
throw new TypeError("argument name is invalid")
}

const value = enc(val as string)

if (value && !fieldContentRegExp.test(value)) {
throw new TypeError("argument val is invalid")
}

let str = `${name}=${value}`

if (opt.maxAge != null) {
const maxAge = opt.maxAge - 0

if (isNaN(maxAge) || !isFinite(maxAge)) {
throw new TypeError("option maxAge is invalid")
}

str += `; Max-Age=${Math.floor(maxAge)}`
}

if (opt.domain) {
if (!fieldContentRegExp.test(opt.domain)) {
throw new TypeError("option domain is invalid")
}

str += `; Domain=${opt.domain}`
}

if (opt.path) {
if (!fieldContentRegExp.test(opt.path)) {
throw new TypeError("option path is invalid")
}

str += `; Path=${opt.path}`
} else {
str += "; Path=/"
}

if (opt.expires) {
let expires: Date | string = opt.expires
if (typeof (opt.expires as Date).toUTCString === "function") {
expires = (opt.expires as Date).toUTCString()
} else {
const dateExpires = new Date(opt.expires)
expires = dateExpires.toUTCString()
}
str += `; Expires=${expires}`
}

if (opt.httpOnly) {
str += "; HttpOnly"
}

if (opt.secure) {
str += "; Secure"
}

if (opt.sameSite) {
const sameSite =
typeof opt.sameSite === "string"
? opt.sameSite.toLowerCase()
: opt.sameSite

switch (sameSite) {
case true:
str += "; SameSite=Strict"
break
case "lax":
str += "; SameSite=Lax"
break
case "strict":
str += "; SameSite=Strict"
break
case "none":
str += "; SameSite=None"
break
default:
throw new TypeError("option sameSite is invalid")
}
}

return str
}

/**
* Use secure cookies if the site uses HTTPS
* This being conditional allows cookies to work non-HTTPS development URLs
Expand Down Expand Up @@ -220,3 +108,105 @@ export function defaultCookies(useSecureCookies: boolean): CookiesOptions {
export interface Cookie extends CookieOption {
value: string
}

type Chunks = Record<string, string>

export class SessionStore {
#chunks: Chunks = {}
#option: CookieOption
#logger: LoggerInstance | Console

constructor(
option: CookieOption,
req: {
cookies?: Record<string, string>
headers?: Record<string, string> | IncomingHttpHeaders
},
logger: LoggerInstance | Console
) {
this.#logger = logger
this.#option = option

if (!req) return

for (const name in req.cookies) {
if (name.startsWith(option.name)) {
this.#chunks[name] = req.cookies[name]
}
}
}

get value() {
return Object.values(this.#chunks)?.join("")
}

/** Given a cookie, return a list of cookies, chunked to fit the allowed cookie size. */
#chunk(cookie: Cookie): Cookie[] {
const chunkCount = Math.ceil(cookie.value.length / CHUNK_SIZE)

if (chunkCount === 1) {
this.#chunks[cookie.name] = cookie.value
return [cookie]
}

const cookies: Cookie[] = []
for (let i = 0; i < chunkCount; i++) {
const name = `${cookie.name}.${i}`
const value = cookie.value.substr(i * CHUNK_SIZE, CHUNK_SIZE)
cookies.push({ ...cookie, name, value })
this.#chunks[name] = value
}

this.#logger.debug("CHUNKING_SESSION_COOKIE", {
message: `Session cookie exceeds allowed ${ALLOWED_COOKIE_SIZE} bytes.`,
emptyCookieSize: ESTIMATED_EMPTY_COOKIE_SIZE,
valueSize: cookie.value.length,
chunks: cookies.map((c) => c.value.length + ESTIMATED_EMPTY_COOKIE_SIZE),
})

return cookies
}

/** Returns cleaned cookie chunks. */
#clean(): Record<string, Cookie> {
const cleanedChunks: Record<string, Cookie> = {}
for (const name in this.#chunks) {
delete this.#chunks?.[name]
cleanedChunks[name] = {
name,
value: "",
options: { ...this.#option.options, maxAge: 0 },
}
}
return cleanedChunks
}

/**
* Given a cookie value, return new cookies, chunked, to fit the allowed cookie size.
* If the cookie has changed from chunked to unchunked or vice versa,
* it deletes the old cookies as well.
*/
chunk(value: string, options: Partial<Cookie["options"]>): Cookie[] {
// Assume all cookies should be cleaned by default
const cookies: Record<string, Cookie> = this.#clean()

// Calculate new chunks
const chunked = this.#chunk({
name: this.#option.name,
value,
options: { ...this.#option.options, ...options },
})

// Update stored chunks / cookies
for (const chunk of chunked) {
cookies[chunk.name] = chunk
}

return Object.values(cookies)
}

/** Returns a list of cookies that should be cleaned. */
clean(): Cookie[] {
return Object.values(this.#clean())
}
}
Loading

0 comments on commit f9e0ef8

Please sign in to comment.