@auth0/nextjs-auth0
Version:
Auth0 Next.js SDK
264 lines (263 loc) • 10.7 kB
JavaScript
import { RequestCookies, ResponseCookies } from "@edge-runtime/cookies";
import { hkdf } from "@panva/hkdf";
import * as jose from "jose";
const ENC = "A256GCM";
const ALG = "dir";
const DIGEST = "sha256";
const BYTE_LENGTH = 32;
const ENCRYPTION_INFO = "JWE CEK";
export async function encrypt(payload, secret, expiration, additionalHeaders) {
const encryptionSecret = await hkdf(DIGEST, secret, "", ENCRYPTION_INFO, BYTE_LENGTH);
const encryptedCookie = await new jose.EncryptJWT(payload)
.setProtectedHeader({ enc: ENC, alg: ALG, ...additionalHeaders })
.setExpirationTime(expiration)
.encrypt(encryptionSecret);
return encryptedCookie.toString();
}
export async function decrypt(cookieValue, secret, options) {
try {
const encryptionSecret = await hkdf(DIGEST, secret, "", ENCRYPTION_INFO, BYTE_LENGTH);
const cookie = await jose.jwtDecrypt(cookieValue, encryptionSecret, {
...options,
...{ clockTolerance: 15 }
});
return cookie;
}
catch (e) {
if (e.code === "ERR_JWT_EXPIRED") {
return null;
}
throw e;
}
}
/**
* Derive a signing key from a given secret.
* This method is used solely to migrate signed, legacy cookies to the new encrypted cookie format (v4+).
*/
const signingSecret = (secret) => hkdf("sha256", secret, "", "JWS Cookie Signing", BYTE_LENGTH);
/**
* Verify a signed cookie. If the cookie is valid, the value is returned. Otherwise, undefined is returned.
* This method is used solely to migrate signed, legacy cookies to the new encrypted cookie format (v4+).
*/
export async function verifySigned(k, v, secret) {
if (!v) {
return undefined;
}
const [value, signature] = v.split(".");
const flattenedJWS = {
protected: jose.base64url.encode(JSON.stringify({ alg: "HS256", b64: false, crit: ["b64"] })),
payload: `${k}=${value}`,
signature
};
const key = await signingSecret(secret);
try {
await jose.flattenedVerify(flattenedJWS, key, {
algorithms: ["HS256"]
});
return value;
}
catch (e) {
return undefined;
}
}
/**
* Sign a cookie value using a secret.
* This method is used solely to migrate signed, legacy cookies to the new encrypted cookie format (v4+).
*/
export async function sign(name, value, secret) {
const key = await signingSecret(secret);
const { signature } = await new jose.FlattenedSign(new TextEncoder().encode(`${name}=${value}`))
.setProtectedHeader({ alg: "HS256", b64: false, crit: ["b64"] })
.sign(key);
return `${value}.${signature}`;
}
export { ResponseCookies };
export { RequestCookies };
// Chunked cookies Configuration
const MAX_CHUNK_SIZE = 3500; // Slightly under 4KB
const CHUNK_PREFIX = "__";
const CHUNK_INDEX_REGEX = new RegExp(`${CHUNK_PREFIX}(\\d+)$`);
const LEGACY_CHUNK_INDEX_REGEX = /\.(\d+)$/;
/**
* Retrieves the index of a cookie based on its name.
* Supports current format `{name}__{index}` and legacy format `{name}.{index}`.
*
* @param name - The name of the cookie.
* @returns The index of the cookie. Returns undefined if no index is found.
*/
const getChunkedCookieIndex = (name, isLegacyCookie) => {
const match = isLegacyCookie
? LEGACY_CHUNK_INDEX_REGEX.exec(name)
: CHUNK_INDEX_REGEX.exec(name);
if (!match) {
return undefined;
}
return parseInt(match[1], 10);
};
/**
* Retrieves all cookies from the request that have names starting with a specific prefix.
*
* @param reqCookies - The cookies from the request.
* @param name - The base name of the cookies to retrieve.
* @returns An array of cookies that have names starting with the specified prefix.
*/
const getAllChunkedCookies = (reqCookies, name, isLegacyCookie) => {
const chunkedCookieRegex = new RegExp(isLegacyCookie
? `^${name}${LEGACY_CHUNK_INDEX_REGEX.source}$`
: `^${name}${CHUNK_PREFIX}\\d+$`);
return reqCookies
.getAll()
.filter((cookie) => chunkedCookieRegex.test(cookie.name));
};
/**
* Sets a cookie with the given name and value, splitting it into chunks if necessary.
*
* If the value exceeds the maximum chunk size, it will be split into multiple cookies
* with names suffixed by a chunk index.
*
* @param name - The name of the cookie.
* @param value - The value to be stored in the cookie.
* @param options - Options for setting the cookie.
* @param reqCookies - The request cookies object, used to enable read-after-write in the same request for middleware.
* @param resCookies - The response cookies object, used to set the cookies in the response.
*/
export function setChunkedCookie(name, value, options, reqCookies, resCookies) {
const { transient, ...restOptions } = options;
const finalOptions = { ...restOptions };
if (transient) {
delete finalOptions.maxAge;
}
const valueBytes = new TextEncoder().encode(value).length;
// If value fits in a single cookie, set it directly
if (valueBytes <= MAX_CHUNK_SIZE) {
resCookies.set(name, value, finalOptions);
// to enable read-after-write in the same request for middleware
reqCookies.set(name, value);
// When we are writing a non-chunked cookie, we should remove the chunked cookies
// Remove any previously stored chunks for this cookie name
getAllChunkedCookies(reqCookies, name).forEach((cookieChunk) => {
deleteCookie(resCookies, cookieChunk.name, {
path: finalOptions.path,
domain: finalOptions.domain
});
reqCookies.delete(cookieChunk.name);
});
return;
}
// Split value into chunks
let position = 0;
let chunkIndex = 0;
while (position < value.length) {
const chunk = value.slice(position, position + MAX_CHUNK_SIZE);
const chunkName = `${name}${CHUNK_PREFIX}${chunkIndex}`;
resCookies.set(chunkName, chunk, finalOptions);
// to enable read-after-write in the same request for middleware
reqCookies.set(chunkName, chunk);
position += MAX_CHUNK_SIZE;
chunkIndex++;
}
// clear unused chunks
const chunks = getAllChunkedCookies(reqCookies, name);
const chunksToRemove = chunks.length - chunkIndex;
if (chunksToRemove > 0) {
for (let i = 0; i < chunksToRemove; i++) {
const chunkIndexToRemove = chunkIndex + i;
const chunkName = `${name}${CHUNK_PREFIX}${chunkIndexToRemove}`;
deleteCookie(resCookies, chunkName, {
path: finalOptions.path,
domain: finalOptions.domain
});
reqCookies.delete(chunkName);
}
}
// When we have written chunked cookies, we should remove the non-chunked cookie
deleteCookie(resCookies, name, {
path: finalOptions.path,
domain: finalOptions.domain
});
reqCookies.delete(name);
}
/**
* Retrieves a chunked cookie by its name from the request cookies.
* If a regular cookie with the given name exists, it returns its value.
* Otherwise, it attempts to retrieve and combine all chunks of the cookie.
*
* @param name - The name of the cookie to retrieve.
* @param reqCookies - The request cookies object.
* @returns The combined value of the chunked cookie, or `undefined` if the cookie does not exist or is incomplete.
*/
export function getChunkedCookie(name, reqCookies, isLegacyCookie) {
// Check if regular cookie exists
const cookie = reqCookies.get(name);
if (cookie?.value) {
// If the base cookie exists, return its value (handles non-chunked case)
return cookie.value;
}
const chunks = getAllChunkedCookies(reqCookies, name, isLegacyCookie).sort(
// Extract index from cookie name and sort numerically
(first, second) => {
return (getChunkedCookieIndex(first.name, isLegacyCookie) -
getChunkedCookieIndex(second.name, isLegacyCookie));
});
if (chunks.length === 0) {
return undefined;
}
// Validate sequence integrity - check for missing chunks
const highestIndex = getChunkedCookieIndex(chunks[chunks.length - 1].name, isLegacyCookie);
if (chunks.length !== highestIndex + 1) {
console.warn(`Incomplete chunked cookie '${name}': Found ${chunks.length} chunks, expected ${highestIndex + 1}`);
return undefined;
}
// Combine all chunks
return chunks.map((c) => c.value).join("");
}
/**
* Deletes a chunked cookie and all its associated chunks from the response cookies.
*
* @param name - The name of the main cookie to delete.
* @param reqCookies - The request cookies object containing all cookies from the request.
* @param resCookies - The response cookies object to manipulate the cookies in the response.
* @param isLegacyCookie - Whether to handle legacy cookie format.
* @param options - Options for cookie deletion including domain and path.
*/
export function deleteChunkedCookie(name, reqCookies, resCookies, isLegacyCookie, options) {
// Delete main cookie
deleteCookie(resCookies, name, options);
getAllChunkedCookies(reqCookies, name, isLegacyCookie).forEach((cookie) => {
deleteCookie(resCookies, cookie.name, options); // Delete each filtered cookie
});
}
/**
* Unconditionally adds strict cache-control headers to the response.
*
* This ensures the response is not cached by CDNs or other shared caches.
* It is now the caller's responsibility to decide when to call this function.
*
* Usage:
* Call this function whenever a `Set-Cookie` header is being written
* for session management or any other sensitive data that must not be cached.
*/
export function addCacheControlHeadersForSession(res) {
res.headers.set("Cache-Control", "private, no-cache, no-store, must-revalidate, max-age=0");
res.headers.set("Pragma", "no-cache");
res.headers.set("Expires", "0");
}
/**
* Deletes a cookie from the response with optional domain and path specifications.
*
* @param resCookies - The response cookies object to manipulate.
* @param name - The name of the cookie to delete.
* @param options - Optional domain and path settings for cookie deletion.
*/
export function deleteCookie(resCookies, name, options) {
const deleteOptions = {
maxAge: 0 // Ensure the cookie is deleted immediately
};
if (options?.domain) {
deleteOptions.domain = options.domain;
}
if (options?.path) {
deleteOptions.path = options.path;
}
resCookies.set(name, "", deleteOptions);
}