From f9e0ef8d187bfb2abf3c717445d60ce84e922375 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20Orb=C3=A1n?= Date: Mon, 15 Nov 2021 10:30:26 +0100 Subject: [PATCH] feat: introduce chunking when session cookie becomes too big (#3101) 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. --- app/pages/api/auth/[...nextauth].ts | 1 - package-lock.json | 5 +- package.json | 1 + src/core/index.ts | 20 ++- src/core/lib/cookie.ts | 266 +++++++++++++--------------- src/core/lib/oauth/pkce-handler.ts | 25 +-- src/core/routes/callback.ts | 66 +++---- src/core/routes/session.ts | 68 ++++--- src/core/routes/signout.ts | 25 ++- src/core/types.ts | 19 +- src/jwt/index.ts | 33 ++-- src/next/cookie.ts | 15 ++ src/next/index.ts | 26 +-- 13 files changed, 274 insertions(+), 296 deletions(-) create mode 100644 src/next/cookie.ts diff --git a/app/pages/api/auth/[...nextauth].ts b/app/pages/api/auth/[...nextauth].ts index fa90c6d8fe..c979e1635f 100644 --- a/app/pages/api/auth/[...nextauth].ts +++ b/app/pages/api/auth/[...nextauth].ts @@ -169,7 +169,6 @@ export const authOptions: NextAuthOptions = { }), ], jwt: { - encryption: true, secret: process.env.SECRET, }, debug: true, diff --git a/package-lock.json b/package-lock.json index 7f5634c0d3..5bd79a4649 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,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", @@ -4833,7 +4834,6 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.1.tgz", "integrity": "sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==", - "dev": true, "engines": { "node": ">= 0.6" } @@ -18593,8 +18593,7 @@ "cookie": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.1.tgz", - "integrity": "sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==", - "dev": true + "integrity": "sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==" }, "core-js-compat": { "version": "3.17.2", diff --git a/package.json b/package.json index 80fda3c5b7..9c0e834c56 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/core/index.ts b/src/core/index.ts index 17deb74a46..e9b36e158e 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -3,7 +3,7 @@ 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" @@ -11,7 +11,7 @@ export interface IncomingRequest { /** @default "http://localhost:3000" */ host: string method: string - cookies?: Record + cookies?: Record headers?: Record query?: Record body?: Record @@ -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 }) @@ -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" }], @@ -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 } @@ -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 } } @@ -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 } diff --git a/src/core/lib/cookie.ts b/src/core/lib/cookie.ts index adf0b18a95..cfaf26e7eb 100644 --- a/src/core/lib/cookie.ts +++ b/src/core/lib/cookie.ts @@ -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 @@ -20,140 +42,6 @@ export type SessionToken = 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 @@ -220,3 +108,105 @@ export function defaultCookies(useSecureCookies: boolean): CookiesOptions { export interface Cookie extends CookieOption { value: string } + +type Chunks = Record + +export class SessionStore { + #chunks: Chunks = {} + #option: CookieOption + #logger: LoggerInstance | Console + + constructor( + option: CookieOption, + req: { + cookies?: Record + headers?: Record | 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 { + const cleanedChunks: Record = {} + 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[] { + // Assume all cookies should be cleaned by default + const cookies: Record = 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()) + } +} diff --git a/src/core/lib/oauth/pkce-handler.ts b/src/core/lib/oauth/pkce-handler.ts index b122b77fad..be62ca1d9c 100644 --- a/src/core/lib/oauth/pkce-handler.ts +++ b/src/core/lib/oauth/pkce-handler.ts @@ -1,7 +1,7 @@ -import { Cookie } from "../cookie" import * as jwt from "../../../jwt" import { generators } from "openid-client" -import { InternalOptions } from "src/lib/types" +import type { InternalOptions } from "src/lib/types" +import type { Cookie } from "../cookie" const PKCE_CODE_CHALLENGE_METHOD = "S256" const PKCE_MAX_AGE = 60 * 15 // 15 minutes in seconds @@ -9,27 +9,16 @@ const PKCE_MAX_AGE = 60 * 15 // 15 minutes in seconds /** * Returns `code_challenge` and `code_challenge_method` * and saves them in a cookie. - * @type {import("src/lib/types").InternalOptions} - * @returns {Promise */ - -type PKCE = Promise< +export async function createPKCE(options: InternalOptions<"oauth">): Promise< | undefined | { code_challenge: string code_challenge_method: "S256" cookie: Cookie } -> - -export async function createPKCE(options: InternalOptions<"oauth">): PKCE { - const { cookies, logger } = options - /** @type {import("src/providers").OAuthConfig} */ - const provider = options.provider +> { + const { cookies, logger, provider } = options if (!provider.checks?.includes("pkce")) { // Provider does not support PKCE, return nothing. return @@ -41,7 +30,7 @@ export async function createPKCE(options: InternalOptions<"oauth">): PKCE { expires.setTime(expires.getTime() + PKCE_MAX_AGE * 1000) // Encrypt code_verifier and save it to an encrypted cookie - const encodedVerifier = await jwt.encode({ + const encryptedCodeVerifier = await jwt.encode({ ...options.jwt, maxAge: PKCE_MAX_AGE, token: { code_verifier }, @@ -59,7 +48,7 @@ export async function createPKCE(options: InternalOptions<"oauth">): PKCE { code_challenge_method: PKCE_CODE_CHALLENGE_METHOD, cookie: { name: cookies.pkceCodeVerifier.name, - value: encodedVerifier, + value: encryptedCodeVerifier, options: { ...cookies.pkceCodeVerifier.options, expires }, }, } diff --git a/src/core/routes/callback.ts b/src/core/routes/callback.ts index adbe874f63..1ae7950cb9 100644 --- a/src/core/routes/callback.ts +++ b/src/core/routes/callback.ts @@ -1,10 +1,11 @@ import oAuthCallback from "../lib/oauth/callback" import callbackHandler from "../lib/callback-handler" -import * as cookie from "../lib/cookie" import { hashToken } from "../lib/utils" -import { InternalOptions } from "../../lib/types" -import { IncomingRequest, OutgoingResponse } from ".." -import { User } from "../.." + +import type { InternalOptions } from "../../lib/types" +import type { IncomingRequest, OutgoingResponse } from ".." +import type { Cookie, SessionStore } from "../lib/cookie" +import type { User } from "../.." /** Handle callbacks from login services */ export default async function callback(params: { @@ -14,9 +15,9 @@ export default async function callback(params: { body: IncomingRequest["body"] headers: IncomingRequest["headers"] cookies: IncomingRequest["cookies"] - sessionToken?: string + sessionStore: SessionStore }): Promise { - const { options, query, body, method, headers, sessionToken } = params + const { options, query, body, method, headers, sessionStore } = params const { provider, adapter, @@ -30,7 +31,7 @@ export default async function callback(params: { logger, } = options - const cookies: cookie.Cookie[] = [] + const cookies: Cookie[] = [] const useJwtSession = sessionStrategy === "jwt" @@ -111,7 +112,7 @@ export default async function callback(params: { // Sign user in // @ts-expect-error const { user, session, isNewUser } = await callbackHandler({ - sessionToken, + sessionToken: sessionStore.value, profile, // @ts-expect-error account, @@ -134,29 +135,25 @@ export default async function callback(params: { isNewUser, }) - // Sign and encrypt token - const newEncodedJwt = await jwt.encode({ ...jwt, token }) + // Encode token + const newToken = await jwt.encode({ ...jwt, token }) // Set cookie expiry date const cookieExpires = new Date() cookieExpires.setTime(cookieExpires.getTime() + sessionMaxAge * 1000) - cookies.push({ - name: options.cookies.sessionToken.name, - value: newEncodedJwt, - options: { - expires: cookieExpires, - ...options.cookies.sessionToken.options, - }, + const sessionCookies = sessionStore.chunk(newToken, { + expires: cookieExpires, }) + cookies.push(...sessionCookies) } else { // Save Session Token in cookie cookies.push({ name: options.cookies.sessionToken.name, value: session.sessionToken, options: { - expires: session.expires, ...options.cookies.sessionToken.options, + expires: session.expires, }, }) } @@ -265,7 +262,7 @@ export default async function callback(params: { // Sign user in // @ts-expect-error const { user, session, isNewUser } = await callbackHandler({ - sessionToken, + sessionToken: sessionStore.value, // @ts-expect-error profile, // @ts-expect-error @@ -288,29 +285,25 @@ export default async function callback(params: { isNewUser, }) - // Sign and encrypt token - const newEncodedJwt = await jwt.encode({ ...jwt, token }) + // Encode token + const newToken = await jwt.encode({ ...jwt, token }) // Set cookie expiry date const cookieExpires = new Date() cookieExpires.setTime(cookieExpires.getTime() + sessionMaxAge * 1000) - cookies.push({ - name: options.cookies.sessionToken.name, - value: newEncodedJwt, - options: { - expires: cookieExpires, - ...options.cookies.sessionToken.options, - }, + const sessionCookies = sessionStore.chunk(newToken, { + expires: cookieExpires, }) + cookies.push(...sessionCookies) } else { // Save Session Token in cookie cookies.push({ name: options.cookies.sessionToken.name, value: session.sessionToken, options: { - expires: session.expires, ...options.cookies.sessionToken.options, + expires: session.expires, }, }) } @@ -444,22 +437,19 @@ export default async function callback(params: { isNewUser: false, }) - // Sign and encrypt token - const newEncodedJwt = await jwt.encode({ ...jwt, token }) + // Encode token + const newToken = await jwt.encode({ ...jwt, token }) // Set cookie expiry date const cookieExpires = new Date() cookieExpires.setTime(cookieExpires.getTime() + sessionMaxAge * 1000) - cookies.push({ - name: options.cookies.sessionToken.name, - value: newEncodedJwt, - options: { - expires: cookieExpires, - ...options.cookies.sessionToken.options, - }, + const sessionCookies = sessionStore.chunk(newToken, { + expires: cookieExpires, }) + cookies.push(...sessionCookies) + // @ts-expect-error await events.signIn?.({ user, account }) diff --git a/src/core/routes/session.ts b/src/core/routes/session.ts index 4d6221ace8..23c0a31034 100644 --- a/src/core/routes/session.ts +++ b/src/core/routes/session.ts @@ -1,12 +1,14 @@ -import { Adapter } from "../../adapters" -import { InternalOptions } from "../../lib/types" -import { OutgoingResponse } from ".." -import { Session } from "../.." import { fromDate } from "../lib/utils" +import type { Adapter } from "../../adapters" +import type { InternalOptions } from "../../lib/types" +import type { OutgoingResponse } from ".." +import type { Session } from "../.." +import type { SessionStore } from "../lib/cookie" + interface SessionParams { options: InternalOptions - sessionToken?: string + sessionStore: SessionStore } /** @@ -17,7 +19,7 @@ interface SessionParams { export default async function session( params: SessionParams ): Promise> { - const { options, sessionToken } = params + const { options, sessionStore } = params const { adapter, jwt, @@ -33,19 +35,22 @@ export default async function session( cookies: [], } + const sessionToken = sessionStore.value + if (!sessionToken) return response if (sessionStrategy === "jwt") { try { - // Decrypt and verify token - const decodedToken = await jwt.decode({ ...jwt, token: sessionToken }) + const decodedToken = await jwt.decode({ + ...jwt, + token: sessionToken, + }) - // Generate new session expiry date const newExpires = fromDate(sessionMaxAge) // By default, only exposes a limited subset of information to the client // as needed for presentation purposes (e.g. "you are logged in as..."). - const defaultSession = { + const session = { user: { name: decodedToken?.name, email: decodedToken?.email, @@ -54,41 +59,34 @@ export default async function session( expires: newExpires.toISOString(), } - // Pass Session and JSON Web Token through to the session callback // @ts-expect-error const token = await callbacks.jwt({ token: decodedToken }) // @ts-expect-error - const session = await callbacks.session({ - session: defaultSession, - token, - }) + const newSession = await callbacks.session({ session, token }) // Return session payload as response - response.body = session + response.body = newSession // Refresh JWT expiry by re-signing it, with an updated expiry date - const newToken = await jwt.encode({ ...jwt, token }) + const newToken = await jwt.encode({ + ...jwt, + token, + maxAge: options.session.maxAge, + }) // Set cookie, to also update expiry date on cookie - response.cookies?.push({ - name: options.cookies.sessionToken.name, - value: newToken, - options: { - expires: newExpires, - ...options.cookies.sessionToken.options, - }, + const sessionCookies = sessionStore.chunk(newToken, { + expires: newExpires, }) - await events.session?.({ session, token }) + response.cookies?.push(...sessionCookies) + + await events.session?.({ session: newSession, token }) } catch (error) { // If JWT not verifiable, make sure the cookie for it is removed and return empty object logger.error("JWT_SESSION_ERROR", error as Error) - response.cookies?.push({ - name: options.cookies.sessionToken.name, - value: "", - options: { ...options.cookies.sessionToken.options, maxAge: 0 }, - }) + response.cookies?.push(...sessionStore.clean()) } } else { try { @@ -148,21 +146,17 @@ export default async function session( name: options.cookies.sessionToken.name, value: sessionToken, options: { - expires: newExpires, ...options.cookies.sessionToken.options, + expires: newExpires, }, }) // @ts-expect-error await events.session?.({ session: sessionPayload }) } else if (sessionToken) { - // If sessionToken was found set but it's not valid for a session then + // If `sessionToken` was found set but it's not valid for a session then // remove the sessionToken cookie from browser. - response.cookies?.push({ - name: options.cookies.sessionToken.name, - value: "", - options: { ...options.cookies.sessionToken.options, maxAge: 0 }, - }) + response.cookies?.push(...sessionStore.clean()) } } catch (error) { logger.error("SESSION_ERROR", error as Error) diff --git a/src/core/routes/signout.ts b/src/core/routes/signout.ts index 2434a4c3cb..04dcb1cda8 100644 --- a/src/core/routes/signout.ts +++ b/src/core/routes/signout.ts @@ -1,17 +1,17 @@ -import { Adapter } from "src/adapters" -import { InternalOptions } from "../../lib/types" -import { OutgoingResponse } from ".." -import { Cookie } from "../lib/cookie" +import type { Adapter } from "../../adapters" +import type { InternalOptions } from "../../lib/types" +import type { OutgoingResponse } from ".." +import type { SessionStore } from "../lib/cookie" /** Handle requests to /api/auth/signout */ export default async function signout(params: { options: InternalOptions - sessionToken?: string + sessionStore: SessionStore }): Promise { - const { options, sessionToken } = params - const { adapter, cookies, events, jwt, callbackUrl, logger, session } = - options + const { options, sessionStore } = params + const { adapter, events, jwt, callbackUrl, logger, session } = options + const sessionToken = sessionStore?.value if (!sessionToken) { return { redirect: callbackUrl } } @@ -24,6 +24,7 @@ export default async function signout(params: { await events.signOut?.({ token: decodedJwt }) } catch (error) { // Do nothing if decoding the JWT fails + logger.error("SIGNOUT_ERROR", error) } } else { try { @@ -38,11 +39,7 @@ export default async function signout(params: { } // Remove Session Token - const sessionCookie: Cookie = { - name: cookies.sessionToken.name, - value: "", - options: { ...cookies.sessionToken.options, maxAge: 0 }, - } + const sessionCookies = sessionStore.clean() - return { redirect: callbackUrl, cookies: [sessionCookie] } + return { redirect: callbackUrl, cookies: sessionCookies } } diff --git a/src/core/types.ts b/src/core/types.ts index 5d2973c964..e8658d4a3d 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -1,8 +1,9 @@ -import { Adapter } from "../adapters" -import { Provider, CredentialInput, ProviderType } from "../providers" +import type { Adapter } from "../adapters" +import type { Provider, CredentialInput, ProviderType } from "../providers" import type { TokenSetParameters } from "openid-client" -import { JWT, JWTOptions } from "../jwt" -import { LoggerInstance } from "../lib/logger" +import type { JWT, JWTOptions } from "../jwt" +import type { LoggerInstance } from "../lib/logger" +import type { CookieSerializeOptions } from "cookie" export type Awaitable = T | PromiseLike @@ -338,15 +339,7 @@ export interface CallbacksOptions< /** [Documentation](https://next-auth.js.org/configuration/options#cookies) */ export interface CookieOption { name: string - options: { - httpOnly?: boolean - sameSite: true | "strict" | "lax" | "none" - path?: string - secure: boolean - maxAge?: number - domain?: string - expires?: Date | string - } + options: CookieSerializeOptions } /** [Documentation](https://next-auth.js.org/configuration/options#cookies) */ diff --git a/src/jwt/index.ts b/src/jwt/index.ts index 0e30147688..9a904e02bc 100644 --- a/src/jwt/index.ts +++ b/src/jwt/index.ts @@ -1,8 +1,10 @@ import { EncryptJWT, jwtDecrypt } from "jose" import hkdf from "@panva/hkdf" import { v4 as uuid } from "uuid" -import { NextApiRequest } from "next" -import type { JWT, JWTDecodeParams, JWTEncodeParams, JWTOptions } from "./types" +import { SessionStore } from "../core/lib/cookie" +import type { NextApiRequest } from "next" +import type { JWT, JWTDecodeParams, JWTEncodeParams } from "./types" +import type { LoggerInstance } from ".." export * from "./types" @@ -38,7 +40,7 @@ export async function decode({ return payload } -export type GetTokenParams = { +export interface GetTokenParams { /** The request containing the JWT either in the cookies or in the `Authorization` header. */ req: NextApiRequest /** @@ -53,7 +55,10 @@ export type GetTokenParams = { * @default false */ raw?: R -} & Pick + secret: string + decode?: typeof decode + logger?: LoggerInstance | Console +} /** * Takes a NextAuth.js request (`req`) and returns either the NextAuth.js issued JWT's payload, @@ -74,21 +79,23 @@ export async function getToken( : "next-auth.session-token", raw, decode: _decode = decode, + logger = console, } = params ?? {} if (!req) throw new Error("Must pass `req` to JWT getToken()") - let token = req.cookies[cookieName] + const sessionStore = new SessionStore( + { name: cookieName, options: { secure: secureCookie } }, + { cookies: req.cookies, headers: req.headers }, + logger + ) - if (!token && req.headers.authorization?.split(" ")[0] === "Bearer") { - const urlEncodedToken = req.headers.authorization.split(" ")[1] - token = decodeURIComponent(urlEncodedToken) - } + const token = sessionStore.value + // @ts-expect-error + if (!token) return null - if (raw) { - // @ts-expect-error - return token - } + // @ts-expect-error + if (raw) return token try { // @ts-expect-error diff --git a/src/next/cookie.ts b/src/next/cookie.ts new file mode 100644 index 0000000000..6e5769a10f --- /dev/null +++ b/src/next/cookie.ts @@ -0,0 +1,15 @@ +import { serialize } from "cookie" +import { Cookie } from "../core/lib/cookie" + +export function setCookie(res, cookie: Cookie) { + // 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] + } + const { name, value, options } = cookie + const cookieHeader = serialize(name, value, options) + setCookieHeader.push(cookieHeader) + res.setHeader("Set-Cookie", setCookieHeader) +} diff --git a/src/next/index.ts b/src/next/index.ts index a014031484..52fbbe5b45 100644 --- a/src/next/index.ts +++ b/src/next/index.ts @@ -1,13 +1,18 @@ -import { +import logger, { setLogger } from "../lib/logger" +import { NextAuthHandler } from "../core" + +import type { GetServerSidePropsContext, NextApiRequest, NextApiResponse, } from "next" -import { NextAuthOptions, Session } from ".." -import { NextAuthHandler } from "../core" -import { NextAuthAction, NextAuthRequest, NextAuthResponse } from "../lib/types" -import { set as setCookie } from "../core/lib/cookie" -import logger, { setLogger } from "../lib/logger" +import type { NextAuthOptions, Session } from ".." +import type { + NextAuthAction, + NextAuthRequest, + NextAuthResponse, +} from "../lib/types" +import { setCookie } from "./cookie" async function NextAuthNextHandler( req: NextApiRequest, @@ -51,9 +56,8 @@ async function NextAuthNextHandler( res.status(status) - cookies?.forEach((cookie) => { - setCookie(res, cookie.name, cookie.value, cookie.options) - }) + cookies?.forEach((cookie) => setCookie(res, cookie)) + headers?.forEach((header) => { res.setHeader(header.key, header.value) }) @@ -115,9 +119,7 @@ export async function getServerSession( const { body, cookies } = session - cookies?.forEach((cookie) => { - setCookie(context.res, cookie.name, cookie.value, cookie.options) - }) + cookies?.forEach((cookie) => setCookie(context.res, cookie)) if (body && Object.keys(body).length) return body as Session return null