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