diff --git a/EXAMPLES.md b/EXAMPLES.md index a4756335..fd703284 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -24,6 +24,7 @@ - [`beforeSessionSaved`](#beforesessionsaved) - [`onCallback`](#oncallback) - [Session configuration](#session-configuration) +- [Cookie Configuration](#cookie-configuration) - [Database sessions](#database-sessions) - [Back-Channel Logout](#back-channel-logout) - [Combining middleware](#combining-middleware) @@ -685,6 +686,67 @@ export const auth0 = new Auth0Client({ | absoluteDuration | `number` | The absolute duration after which the session will expire. The value must be specified in seconds. Default: `3 days`. | | inactivityDuration | `number` | The duration of inactivity after which the session will expire. The value must be specified in seconds. Default: `1 day`. | +## Cookie Configuration + +You can configure the session cookie attributes either through environment variables or directly in the SDK initialization. + +**1. Using Environment Variables:** + +Set the desired environment variables in your `.env.local` file or your deployment environment: + +``` +# .env.local +# ... other variables ... + +# Cookie Options +AUTH0_COOKIE_DOMAIN='.example.com' # Set cookie for subdomains +AUTH0_COOKIE_PATH='/app' # Limit cookie to /app path +AUTH0_COOKIE_TRANSIENT=true # Make cookie transient (session-only) +AUTH0_COOKIE_SECURE=true # Recommended for production +AUTH0_COOKIE_SAME_SITE='Lax' +``` + +The SDK will automatically pick up these values. Note that `httpOnly` is always set to `true` for security reasons and cannot be configured. + +**2. Using `Auth0ClientOptions`:** + +Configure the options directly when initializing the client: + +```typescript +import { Auth0Client } from "@auth0/nextjs-auth0/server" + +export const auth0 = new Auth0Client({ + session: { + cookie: { + domain: '.example.com', + path: '/app', + transient: true, + // httpOnly is always true and cannot be configured + secure: process.env.NODE_ENV === 'production', + sameSite: 'Lax', + // name: 'appSession', // Optional: custom cookie name, defaults to '__session' + }, + // ... other session options like absoluteDuration ... + }, + // ... other client options ... +}); +``` + +**Session Cookie Options:** + +* `domain` (String): Specifies the `Domain` attribute. +* `path` (String): Specifies the `Path` attribute. Defaults to `/`. +* `transient` (Boolean): If `true`, the `maxAge` attribute is omitted, making it a session cookie. Defaults to `false`. +* `secure` (Boolean): Specifies the `Secure` attribute. Defaults to `false` (or `true` if `AUTH0_COOKIE_SECURE=true` is set). +* `sameSite` ('Lax' | 'Strict' | 'None'): Specifies the `SameSite` attribute. Defaults to `Lax` (or the value of `AUTH0_COOKIE_SAME_SITE`). +* `name` (String): The name of the session cookie. Defaults to `__session`. + +> [!INFO] +> Options provided directly in `Auth0ClientOptions` take precedence over environment variables. The `httpOnly` attribute is always `true` regardless of configuration. + +> [!INFO] +> The `httpOnly` attribute for the session cookie is always set to `true` for security reasons and cannot be configured via options or environment variables. + ## Database sessions By default, the user's sessions are stored in encrypted cookies. You may choose to persist the sessions in your data store of choice. diff --git a/README.md b/README.md index 672db7a5..e34a59ef 100644 --- a/README.md +++ b/README.md @@ -137,7 +137,7 @@ You can customize the client by using the options below: | appBaseUrl | `string` | The URL of your application (e.g.: `http://localhost:3000`). If it's not specified, it will be loaded from the `APP_BASE_URL` environment variable. | | secret | `string` | A 32-byte, hex-encoded secret used for encrypting cookies. If it's not specified, it will be loaded from the `AUTH0_SECRET` environment variable. | | signInReturnToPath | `string` | The path to redirect the user to after successfully authenticating. Defaults to `/`. | -| session | `SessionConfiguration` | Configure the session timeouts and whether to use rolling sessions or not. See [Session configuration](https://github.com/auth0/nextjs-auth0/blob/main/EXAMPLES.md#session-configuration) for additional details. | +| session | `SessionConfiguration` | Configure the session timeouts and whether to use rolling sessions or not. See [Session configuration](https://github.com/auth0/nextjs-auth0/blob/main/EXAMPLES.md#session-configuration) for additional details. Also allows configuration of cookie attributes like `domain`, `path`, `secure`, `sameSite`, and `transient`. If not specified, these can be configured using `AUTH0_COOKIE_*` environment variables. Note: `httpOnly` is always `true`. See [Cookie Configuration](https://github.com/auth0/nextjs-auth0/blob/main/EXAMPLES.md#cookie-configuration) for details. | | beforeSessionSaved | `BeforeSessionSavedHook` | A method to manipulate the session before persisting it. See [beforeSessionSaved](https://github.com/auth0/nextjs-auth0/blob/main/EXAMPLES.md#beforesessionsaved) for additional details. | | onCallback | `OnCallbackHook` | A method to handle errors or manage redirects after attempting to authenticate. See [onCallback](https://github.com/auth0/nextjs-auth0/blob/main/EXAMPLES.md#oncallback) for additional details. | | sessionStore | `SessionStore` | A custom session store implementation used to persist sessions to a data store. See [Database sessions](https://github.com/auth0/nextjs-auth0/blob/main/EXAMPLES.md#database-sessions) for additional details. | @@ -147,6 +147,17 @@ You can customize the client by using the options below: | httpTimeout | `number` | Integer value for the HTTP timeout in milliseconds for authentication requests. Defaults to `5000` milliseconds | | enableTelemetry | `boolean` | Boolean value to opt-out of sending the library name and version to your authorization server via the `Auth0-Client` header. Defaults to `true`. | +## Session Cookie Configuration +You can specify the following environment variables to configure the session cookie: +```env +AUTH0_COOKIE_DOMAIN= +AUTH0_COOKIE_PATH= +AUTH0_COOKIE_TRANSIENT= +AUTH0_COOKIE_SECURE= +AUTH0_COOKIE_SAME_SITE= +``` +Respective counterparts are also available in the client configuration. See [Cookie Configuration](https://github.com/auth0/nextjs-auth0/blob/main/EXAMPLES.md#cookie-configuration) for more details. + ## Configuration Validation The SDK performs validation of required configuration options when initializing the `Auth0Client`. The following options are mandatory and must be provided either through constructor options or environment variables: diff --git a/src/server/chunked-cookies.test.ts b/src/server/chunked-cookies.test.ts index e17aec65..932a4edf 100644 --- a/src/server/chunked-cookies.test.ts +++ b/src/server/chunked-cookies.test.ts @@ -237,6 +237,198 @@ describe("Chunked Cookie Utils", () => { expect(reqCookies.delete).toHaveBeenCalledWith(name); }); + // New tests for domain and transient options + it("should set the domain property for a single cookie", () => { + const name = "domainCookie"; + const value = "small value"; + const options: CookieOptions = { + path: "/", + domain: "example.com", + httpOnly: true, + secure: true, + sameSite: "lax" + }; + + setChunkedCookie(name, value, options, reqCookies, resCookies); + + expect(resCookies.set).toHaveBeenCalledTimes(1); + expect(resCookies.set).toHaveBeenCalledWith( + name, + value, + expect.objectContaining({ domain: "example.com" }) + ); + }); + + it("should set the domain property for chunked cookies", () => { + const name = "largeDomainCookie"; + const largeValue = "a".repeat(8000); + const options: CookieOptions = { + path: "/", + domain: "example.com", + httpOnly: true, + secure: true, + sameSite: "lax" + }; + + setChunkedCookie(name, largeValue, options, reqCookies, resCookies); + + expect(resCookies.set).toHaveBeenCalledTimes(3); // 3 chunks + expect(resCookies.set).toHaveBeenNthCalledWith( + 1, + `${name}__0`, + expect.any(String), + expect.objectContaining({ domain: "example.com" }) + ); + expect(resCookies.set).toHaveBeenNthCalledWith( + 2, + `${name}__1`, + expect.any(String), + expect.objectContaining({ domain: "example.com" }) + ); + expect(resCookies.set).toHaveBeenNthCalledWith( + 3, + `${name}__2`, + expect.any(String), + expect.objectContaining({ domain: "example.com" }) + ); + }); + + it("should omit maxAge for a single transient cookie", () => { + const name = "transientCookie"; + const value = "small value"; + const options: CookieOptions = { + path: "/", + maxAge: 3600, + transient: true, + httpOnly: true, + secure: true, + sameSite: "lax" + }; + const expectedOptions = { ...options }; + delete expectedOptions.maxAge; // maxAge should be removed + delete expectedOptions.transient; // transient flag itself is not part of the cookie options + + setChunkedCookie(name, value, options, reqCookies, resCookies); + + expect(resCookies.set).toHaveBeenCalledTimes(1); + expect(resCookies.set).toHaveBeenCalledWith(name, value, expectedOptions); + expect(resCookies.set).not.toHaveBeenCalledWith( + name, + value, + expect.objectContaining({ maxAge: 3600 }) + ); + }); + + it("should omit maxAge for chunked transient cookies", () => { + const name = "largeTransientCookie"; + const largeValue = "a".repeat(8000); + const options: CookieOptions = { + path: "/", + maxAge: 3600, + transient: true, + httpOnly: true, + secure: true, + sameSite: "lax" + }; + const expectedOptions = { ...options }; + delete expectedOptions.maxAge; // maxAge should be removed + delete expectedOptions.transient; // transient flag itself is not part of the cookie options + + setChunkedCookie(name, largeValue, options, reqCookies, resCookies); + + expect(resCookies.set).toHaveBeenCalledTimes(3); // 3 chunks + expect(resCookies.set).toHaveBeenNthCalledWith( + 1, + `${name}__0`, + expect.any(String), + expectedOptions + ); + expect(resCookies.set).toHaveBeenNthCalledWith( + 2, + `${name}__1`, + expect.any(String), + expectedOptions + ); + expect(resCookies.set).toHaveBeenNthCalledWith( + 3, + `${name}__2`, + expect.any(String), + expectedOptions + ); + expect(resCookies.set).not.toHaveBeenCalledWith( + expect.any(String), + expect.any(String), + expect.objectContaining({ maxAge: 3600 }) + ); + }); + + it("should include maxAge for a single non-transient cookie", () => { + const name = "nonTransientCookie"; + const value = "small value"; + const options: CookieOptions = { + path: "/", + maxAge: 3600, + transient: false, + httpOnly: true, + secure: true, + sameSite: "lax" + }; + const expectedOptions = { ...options }; + delete expectedOptions.transient; // transient flag itself is not part of the cookie options + + setChunkedCookie(name, value, options, reqCookies, resCookies); + + expect(resCookies.set).toHaveBeenCalledTimes(1); + expect(resCookies.set).toHaveBeenCalledWith(name, value, expectedOptions); + expect(resCookies.set).toHaveBeenCalledWith( + name, + value, + expect.objectContaining({ maxAge: 3600 }) + ); + }); + + it("should include maxAge for chunked non-transient cookies", () => { + const name = "largeNonTransientCookie"; + const largeValue = "a".repeat(8000); + const options: CookieOptions = { + path: "/", + maxAge: 3600, + transient: false, + httpOnly: true, + secure: true, + sameSite: "lax" + }; + const expectedOptions = { ...options }; + delete expectedOptions.transient; // transient flag itself is not part of the cookie options + + setChunkedCookie(name, largeValue, options, reqCookies, resCookies); + + expect(resCookies.set).toHaveBeenCalledTimes(3); // 3 chunks + expect(resCookies.set).toHaveBeenNthCalledWith( + 1, + `${name}__0`, + expect.any(String), + expectedOptions + ); + expect(resCookies.set).toHaveBeenNthCalledWith( + 2, + `${name}__1`, + expect.any(String), + expectedOptions + ); + expect(resCookies.set).toHaveBeenNthCalledWith( + 3, + `${name}__2`, + expect.any(String), + expectedOptions + ); + expect(resCookies.set).toHaveBeenCalledWith( + expect.any(String), + expect.any(String), + expect.objectContaining({ maxAge: 3600 }) + ); + }); + describe("getChunkedCookie", () => { it("should return undefined when cookie does not exist", () => { const result = getChunkedCookie("nonexistent", reqCookies); diff --git a/src/server/client.ts b/src/server/client.ts index 393096c6..7403905c 100644 --- a/src/server/client.ts +++ b/src/server/client.ts @@ -197,9 +197,19 @@ export class Auth0Client { const sessionCookieOptions: SessionCookieOptions = { name: options.session?.cookie?.name ?? "__session", - secure: options.session?.cookie?.secure ?? false, - sameSite: options.session?.cookie?.sameSite ?? "lax", - path: options.session?.cookie?.path ?? "/" + secure: + options.session?.cookie?.secure ?? + process.env.AUTH0_COOKIE_SECURE === "true", + sameSite: + options.session?.cookie?.sameSite ?? + (process.env.AUTH0_COOKIE_SAME_SITE as "lax" | "strict" | "none") ?? + "lax", + path: + options.session?.cookie?.path ?? process.env.AUTH0_COOKIE_PATH ?? "/", + transient: + options.session?.cookie?.transient ?? + process.env.AUTH0_COOKIE_TRANSIENT === "true", + domain: options.session?.cookie?.domain ?? process.env.AUTH0_COOKIE_DOMAIN }; const transactionCookieOptions: TransactionCookieOptions = { diff --git a/src/server/cookies.ts b/src/server/cookies.ts index 3418540b..be31bbe7 100644 --- a/src/server/cookies.ts +++ b/src/server/cookies.ts @@ -113,6 +113,8 @@ export interface CookieOptions { secure: boolean; path: string; maxAge?: number; + domain?: string; + transient?: boolean; } export type ReadonlyRequestCookies = Omit< @@ -178,11 +180,18 @@ export function setChunkedCookie( reqCookies: RequestCookies, resCookies: ResponseCookies ): void { + 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, options); + resCookies.set(name, value, finalOptions); // to enable read-after-write in the same request for middleware reqCookies.set(name, value); @@ -203,7 +212,7 @@ export function setChunkedCookie( const chunk = value.slice(position, position + MAX_CHUNK_SIZE); const chunkName = `${name}${CHUNK_PREFIX}${chunkIndex}`; - resCookies.set(chunkName, chunk, options); + resCookies.set(chunkName, chunk, finalOptions); // to enable read-after-write in the same request for middleware reqCookies.set(chunkName, chunk); position += MAX_CHUNK_SIZE; diff --git a/src/server/session/abstract-session-store.ts b/src/server/session/abstract-session-store.ts index b052ac2c..9f530aa7 100644 --- a/src/server/session/abstract-session-store.ts +++ b/src/server/session/abstract-session-store.ts @@ -29,6 +29,16 @@ export interface SessionCookieOptions { * The path attribute of the session cookie. Will be set to '/' by default. */ path?: string; + /** + * Specifies the value for the {@link https://tools.ietf.org/html/rfc6265#section-5.2.3|Domain Set-Cookie attribute}. By default, no + * domain is set, and most clients will consider the cookie to apply to only + * the current domain. + */ + domain?: string; + /** + * The transient attribute of the session cookie. When true, the cookie will not persist beyond the current session. + */ + transient?: boolean; } export interface SessionConfiguration { @@ -107,7 +117,9 @@ export abstract class AbstractSessionStore { httpOnly: true, sameSite: cookieOptions?.sameSite ?? "lax", secure: cookieOptions?.secure ?? false, - path: cookieOptions?.path ?? "/" + path: cookieOptions?.path ?? "/", + domain: cookieOptions?.domain, + transient: cookieOptions?.transient }; }