diff --git a/EXAMPLES.md b/EXAMPLES.md index f0629f40..c16b0ccf 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) @@ -626,6 +627,40 @@ 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 session cookie attributes directly in the client options. These options take precedence over environment variables (`AUTH0_COOKIE_*`). + +```ts +import { Auth0Client } from "@auth0/nextjs-auth0/server" + +export const auth0 = new Auth0Client({ + session: { + cookie: { + domain: ".example.com", // Set cookie for subdomains + path: "/app", // Limit cookie to /app path + transient: true, // Make cookie transient (session-only, ignores maxAge) + httpOnly: true, + 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 ... +}) +``` + +**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`. +* `httpOnly` (Boolean): Specifies the `HttpOnly` attribute. Defaults to `true`. +* `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`. + ## 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..587aded1 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`, `httpOnly`, and `transient`. If not specified, these can be configured using `AUTH0_COOKIE_*` environment variables. 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,18 @@ 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_HTTP_ONLY= +AUTH0_COOKIE_SECURE= +AUTH0_COOKIE_SAME_SITE= +``` +Respsective 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/auth-client.test.ts b/src/server/auth-client.test.ts index daf49634..459088b4 100644 --- a/src/server/auth-client.test.ts +++ b/src/server/auth-client.test.ts @@ -394,7 +394,7 @@ ca/T0LLtgmbMmxSv/MmzIg== // When a route doesn't match, the handler returns a NextResponse.next() with status 200 expect(response.status).toBe(200); }); - + it("should use the default value (true) for enableAccessTokenEndpoint when not explicitly provided", async () => { const secret = await generateSecret(32); const transactionStore = new TransactionStore({ @@ -4374,34 +4374,42 @@ ca/T0LLtgmbMmxSv/MmzIg== const authClient = await createAuthClient({ signInReturnToPath: defaultReturnTo }); - + // Mock the transactionStore.save method to verify the saved state - const originalSave = authClient['transactionStore'].save; - authClient['transactionStore'].save = vi.fn(async (cookies, state) => { + const originalSave = authClient["transactionStore"].save; + authClient["transactionStore"].save = vi.fn(async (cookies, state) => { expect(state.returnTo).toBe(defaultReturnTo); - return originalSave.call(authClient['transactionStore'], cookies, state); + return originalSave.call( + authClient["transactionStore"], + cookies, + state + ); }); await authClient.startInteractiveLogin(); - - expect(authClient['transactionStore'].save).toHaveBeenCalled(); + + expect(authClient["transactionStore"].save).toHaveBeenCalled(); }); it("should sanitize and use the provided returnTo parameter", async () => { const authClient = await createAuthClient(); const returnTo = "/custom-return-path"; - + // Mock the transactionStore.save method to verify the saved state - const originalSave = authClient['transactionStore'].save; - authClient['transactionStore'].save = vi.fn(async (cookies, state) => { + const originalSave = authClient["transactionStore"].save; + authClient["transactionStore"].save = vi.fn(async (cookies, state) => { // The full URL is saved, not just the path expect(state.returnTo).toBe("https://example.com/custom-return-path"); - return originalSave.call(authClient['transactionStore'], cookies, state); + return originalSave.call( + authClient["transactionStore"], + cookies, + state + ); }); await authClient.startInteractiveLogin({ returnTo }); - - expect(authClient['transactionStore'].save).toHaveBeenCalled(); + + expect(authClient["transactionStore"].save).toHaveBeenCalled(); }); it("should reject unsafe returnTo URLs", async () => { @@ -4409,18 +4417,22 @@ ca/T0LLtgmbMmxSv/MmzIg== signInReturnToPath: "/safe-path" }); const unsafeReturnTo = "https://malicious-site.com"; - + // Mock the transactionStore.save method to verify the saved state - const originalSave = authClient['transactionStore'].save; - authClient['transactionStore'].save = vi.fn(async (cookies, state) => { + const originalSave = authClient["transactionStore"].save; + authClient["transactionStore"].save = vi.fn(async (cookies, state) => { // Should use the default safe path instead of the malicious one expect(state.returnTo).toBe("/safe-path"); - return originalSave.call(authClient['transactionStore'], cookies, state); + return originalSave.call( + authClient["transactionStore"], + cookies, + state + ); }); await authClient.startInteractiveLogin({ returnTo: unsafeReturnTo }); - - expect(authClient['transactionStore'].save).toHaveBeenCalled(); + + expect(authClient["transactionStore"].save).toHaveBeenCalled(); }); it("should pass authorization parameters to the authorization URL", async () => { @@ -4429,10 +4441,10 @@ ca/T0LLtgmbMmxSv/MmzIg== audience: "https://api.example.com", scope: "openid profile email custom_scope" }; - + // Spy on the authorizationUrl method to verify the passed params - const originalAuthorizationUrl = authClient['authorizationUrl']; - authClient['authorizationUrl'] = vi.fn(async (params) => { + const originalAuthorizationUrl = authClient["authorizationUrl"]; + authClient["authorizationUrl"] = vi.fn(async (params) => { // Verify the audience is set correctly expect(params.get("audience")).toBe(authorizationParameters.audience); // Verify the scope is set correctly @@ -4441,8 +4453,8 @@ ca/T0LLtgmbMmxSv/MmzIg== }); await authClient.startInteractiveLogin({ authorizationParameters }); - - expect(authClient['authorizationUrl']).toHaveBeenCalled(); + + expect(authClient["authorizationUrl"]).toHaveBeenCalled(); }); it("should handle pushed authorization requests (PAR) correctly", async () => { @@ -4452,11 +4464,11 @@ ca/T0LLtgmbMmxSv/MmzIg== parRequestCalled = true; } }); - + const secret = await generateSecret(32); const transactionStore = new TransactionStore({ secret }); const sessionStore = new StatelessSessionStore({ secret }); - + const authClient = new AuthClient({ transactionStore, sessionStore, @@ -4471,33 +4483,41 @@ ca/T0LLtgmbMmxSv/MmzIg== }, fetch: mockFetch }); - + await authClient.startInteractiveLogin(); - + // Verify that PAR was used expect(parRequestCalled).toBe(true); }); - + it("should save the transaction state with correct values", async () => { const authClient = await createAuthClient(); const returnTo = "/custom-path"; - + // Instead of mocking the oauth functions, we'll just check the structure of the transaction state - const originalSave = authClient['transactionStore'].save; - authClient['transactionStore'].save = vi.fn(async (cookies, transactionState) => { - expect(transactionState).toEqual(expect.objectContaining({ - nonce: expect.any(String), - codeVerifier: expect.any(String), - responseType: "code", - state: expect.any(String), - returnTo: "https://example.com/custom-path" - })); - return originalSave.call(authClient['transactionStore'], cookies, transactionState); - }); + const originalSave = authClient["transactionStore"].save; + authClient["transactionStore"].save = vi.fn( + async (cookies, transactionState) => { + expect(transactionState).toEqual( + expect.objectContaining({ + nonce: expect.any(String), + codeVerifier: expect.any(String), + responseType: "code", + state: expect.any(String), + returnTo: "https://example.com/custom-path" + }) + ); + return originalSave.call( + authClient["transactionStore"], + cookies, + transactionState + ); + } + ); await authClient.startInteractiveLogin({ returnTo }); - - expect(authClient['transactionStore'].save).toHaveBeenCalled(); + + expect(authClient["transactionStore"].save).toHaveBeenCalled(); }); it("should merge configuration authorizationParameters with method arguments", async () => { @@ -4509,13 +4529,13 @@ ca/T0LLtgmbMmxSv/MmzIg== audience: configAudience } }); - + const methodScope = "openid profile email custom_scope"; const methodAudience = "https://custom-api.example.com"; - + // Spy on the authorizationUrl method to verify the passed params - const originalAuthorizationUrl = authClient['authorizationUrl']; - authClient['authorizationUrl'] = vi.fn(async (params) => { + const originalAuthorizationUrl = authClient["authorizationUrl"]; + authClient["authorizationUrl"] = vi.fn(async (params) => { // Method's authorization parameters should override config expect(params.get("audience")).toBe(methodAudience); expect(params.get("scope")).toBe(methodScope); @@ -4528,14 +4548,14 @@ ca/T0LLtgmbMmxSv/MmzIg== audience: methodAudience } }); - - expect(authClient['authorizationUrl']).toHaveBeenCalled(); + + expect(authClient["authorizationUrl"]).toHaveBeenCalled(); }); // Add tests for handleLogin method it("should create correct options in handleLogin with returnTo parameter", async () => { const authClient = await createAuthClient(); - + // Mock startInteractiveLogin to check what options are passed to it const originalStartInteractiveLogin = authClient.startInteractiveLogin; authClient.startInteractiveLogin = vi.fn(async (options) => { @@ -4546,11 +4566,13 @@ ca/T0LLtgmbMmxSv/MmzIg== return originalStartInteractiveLogin.call(authClient, options); }); - const reqUrl = new URL("https://example.com/auth/login?foo=bar&returnTo=custom-return"); + const reqUrl = new URL( + "https://example.com/auth/login?foo=bar&returnTo=custom-return" + ); const req = new NextRequest(reqUrl, { method: "GET" }); - + await authClient.handleLogin(req); - + expect(authClient.startInteractiveLogin).toHaveBeenCalled(); }); @@ -4558,7 +4580,7 @@ ca/T0LLtgmbMmxSv/MmzIg== const authClient = await createAuthClient({ pushedAuthorizationRequests: true }); - + // Mock startInteractiveLogin to check what options are passed to it const originalStartInteractiveLogin = authClient.startInteractiveLogin; authClient.startInteractiveLogin = vi.fn(async (options) => { @@ -4569,11 +4591,13 @@ ca/T0LLtgmbMmxSv/MmzIg== return originalStartInteractiveLogin.call(authClient, options); }); - const reqUrl = new URL("https://example.com/auth/login?foo=bar&returnTo=custom-return"); + const reqUrl = new URL( + "https://example.com/auth/login?foo=bar&returnTo=custom-return" + ); const req = new NextRequest(reqUrl, { method: "GET" }); - + await authClient.handleLogin(req); - + expect(authClient.startInteractiveLogin).toHaveBeenCalled(); }); }); diff --git a/src/server/auth-client.ts b/src/server/auth-client.ts index 78a4344e..44b67630 100644 --- a/src/server/auth-client.ts +++ b/src/server/auth-client.ts @@ -6,21 +6,21 @@ import packageJson from "../../package.json"; import { AccessTokenError, AccessTokenErrorCode, + AccessTokenForConnectionError, + AccessTokenForConnectionErrorCode, AuthorizationCodeGrantError, AuthorizationError, BackchannelLogoutError, DiscoveryError, - AccessTokenForConnectionError, - AccessTokenForConnectionErrorCode, InvalidStateError, MissingStateError, OAuth2Error, SdkError } from "../errors"; import { + AccessTokenForConnectionOptions, AuthorizationParameters, ConnectionTokenSet, - AccessTokenForConnectionOptions, LogoutToken, SessionData, StartInteractiveLoginOptions, @@ -65,7 +65,6 @@ const DEFAULT_SCOPES = ["openid", "profile", "email", "offline_access"].join( " " ); - /** * A constant representing the grant type for federated connection access token exchange. * @@ -1016,19 +1015,20 @@ export class AuthClient { tokenSet: TokenSet, connectionTokenSet: ConnectionTokenSet | undefined, options: AccessTokenForConnectionOptions - ): Promise<[AccessTokenForConnectionError, null] | [null, ConnectionTokenSet]> { + ): Promise< + [AccessTokenForConnectionError, null] | [null, ConnectionTokenSet] + > { // If we do not have a refresh token // and we do not have a connection token set in the cache or the one we have is expired, // there is noting to retrieve and we return an error. if ( !tokenSet.refreshToken && - (!connectionTokenSet || - connectionTokenSet.expiresAt <= Date.now() / 1000) + (!connectionTokenSet || connectionTokenSet.expiresAt <= Date.now() / 1000) ) { return [ new AccessTokenForConnectionError( AccessTokenForConnectionErrorCode.MISSING_REFRESH_TOKEN, - "A refresh token was not present, Connection Access Token requires a refresh token. The user needs to re-authenticate.", + "A refresh token was not present, Connection Access Token requires a refresh token. The user needs to re-authenticate." ), null ]; @@ -1039,8 +1039,7 @@ export class AuthClient { // we need to exchange the refresh token for a connection access token. if ( tokenSet.refreshToken && - (!connectionTokenSet || - connectionTokenSet.expiresAt <= Date.now() / 1000) + (!connectionTokenSet || connectionTokenSet.expiresAt <= Date.now() / 1000) ) { const params = new URLSearchParams(); @@ -1111,10 +1110,7 @@ export class AuthClient { ]; } - return [null, connectionTokenSet] as [ - null, - ConnectionTokenSet - ]; + return [null, connectionTokenSet] as [null, ConnectionTokenSet]; } } diff --git a/src/server/chunked-cookies.test.ts b/src/server/chunked-cookies.test.ts index aba497bf..932a4edf 100644 --- a/src/server/chunked-cookies.test.ts +++ b/src/server/chunked-cookies.test.ts @@ -231,12 +231,204 @@ describe("Chunked Cookie Utils", () => { // It is called 3 times. // 2 times for the chunks // 1 time for the non chunked cookie - expect(reqCookies.delete).toHaveBeenCalledTimes(3); + expect(reqCookies.delete).toHaveBeenCalledTimes(3); expect(reqCookies.delete).toHaveBeenCalledWith(`${name}__3`); expect(reqCookies.delete).toHaveBeenCalledWith(`${name}__4`); 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 8bcc3a21..32a84dd8 100644 --- a/src/server/client.ts +++ b/src/server/client.ts @@ -197,9 +197,22 @@ 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 ?? "/", + httpOnly: + options.session?.cookie?.httpOnly ?? + process.env.AUTH0_COOKIE_HTTP_ONLY !== "false", + 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 635942d9..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,16 +180,23 @@ 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); // When we are writing a non-chunked cookie, we should remove the chunked cookies - getAllChunkedCookies(reqCookies, name).forEach(cookieChunk => { + getAllChunkedCookies(reqCookies, name).forEach((cookieChunk) => { resCookies.delete(cookieChunk.name); reqCookies.delete(cookieChunk.name); }); @@ -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; @@ -223,9 +232,9 @@ export function setChunkedCookie( } } - // When we have written chunked cookies, we should remove the non-chunked cookie - resCookies.delete(name); - reqCookies.delete(name); + // When we have written chunked cookies, we should remove the non-chunked cookie + resCookies.delete(name); + reqCookies.delete(name); } /** diff --git a/src/server/session/abstract-session-store.ts b/src/server/session/abstract-session-store.ts index b052ac2c..4f5c78a5 100644 --- a/src/server/session/abstract-session-store.ts +++ b/src/server/session/abstract-session-store.ts @@ -29,6 +29,20 @@ 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 httpOnly attribute of the session cookie. When true, the cookie is not accessible via JavaScript. + */ + httpOnly?: boolean; + /** + * 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 +121,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 }; } diff --git a/src/server/session/stateful-session-store.test.ts b/src/server/session/stateful-session-store.test.ts index fab837bc..6e690756 100644 --- a/src/server/session/stateful-session-store.test.ts +++ b/src/server/session/stateful-session-store.test.ts @@ -690,7 +690,6 @@ describe("Stateful Session Store", async () => { }); }); - it("should remove the legacy cookie if it exists", async () => { const currentTime = Date.now(); const createdAt = Math.floor(currentTime / 1000); @@ -718,7 +717,7 @@ describe("Stateful Session Store", async () => { const sessionStore = new StatefulSessionStore({ secret, - store, + store }); vi.spyOn(requestCookies, "has").mockReturnValue(true); diff --git a/src/server/session/stateless-session-store.test.ts b/src/server/session/stateless-session-store.test.ts index 24a427ed..23f853d7 100644 --- a/src/server/session/stateless-session-store.test.ts +++ b/src/server/session/stateless-session-store.test.ts @@ -334,7 +334,7 @@ describe("Stateless Session Store", async () => { const responseCookies = new ResponseCookies(new Headers()); const sessionStore = new StatelessSessionStore({ - secret, + secret }); vi.spyOn(responseCookies, "delete"); @@ -365,19 +365,23 @@ describe("Stateless Session Store", async () => { const responseCookies = new ResponseCookies(new Headers()); const sessionStore = new StatelessSessionStore({ - secret, + secret }); vi.spyOn(responseCookies, "delete"); vi.spyOn(requestCookies, "getAll").mockReturnValue([ - { name: `${LEGACY_COOKIE_NAME}__0`, value: '' }, - { name: `${LEGACY_COOKIE_NAME}__1`, value: '' } + { name: `${LEGACY_COOKIE_NAME}__0`, value: "" }, + { name: `${LEGACY_COOKIE_NAME}__1`, value: "" } ]); await sessionStore.set(requestCookies, responseCookies, session); - expect(responseCookies.delete).toHaveBeenCalledWith(`${LEGACY_COOKIE_NAME}__0`); - expect(responseCookies.delete).toHaveBeenCalledWith(`${LEGACY_COOKIE_NAME}__1`); + expect(responseCookies.delete).toHaveBeenCalledWith( + `${LEGACY_COOKIE_NAME}__0` + ); + expect(responseCookies.delete).toHaveBeenCalledWith( + `${LEGACY_COOKIE_NAME}__1` + ); }); }); @@ -516,7 +520,7 @@ describe("Stateless Session Store", async () => { const sessionStore = new StatelessSessionStore({ secret, cookieOptions: { - path: '/custom-path' + path: "/custom-path" } }); await sessionStore.set(requestCookies, responseCookies, session); diff --git a/src/server/session/stateless-session-store.ts b/src/server/session/stateless-session-store.ts index bdf628aa..2e804830 100644 --- a/src/server/session/stateless-session-store.ts +++ b/src/server/session/stateless-session-store.ts @@ -1,7 +1,6 @@ -import { CookieOptions, ConnectionTokenSet, SessionData } from "../../types"; - import type { JWTPayload } from "jose"; +import { ConnectionTokenSet, CookieOptions, SessionData } from "../../types"; import * as cookies from "../cookies"; import { AbstractSessionStore, @@ -55,17 +54,14 @@ export class StatelessSessionStore extends AbstractSessionStore { SessionData | LegacySessionPayload >(cookieValue, this.secret); - const normalizedStatelessSession = normalizeStatelessSession(originalSession); + const normalizedStatelessSession = + normalizeStatelessSession(originalSession); // As connection access tokens are stored in seperate cookies, // we need to get all cookies and only use those that are prefixed with `this.connectionTokenSetsCookieName` const connectionTokenSets = await Promise.all( - this.getConnectionTokenSetsCookies(reqCookies).map( - (cookie) => - cookies.decrypt( - cookie.value, - this.secret - ) + this.getConnectionTokenSetsCookies(reqCookies).map((cookie) => + cookies.decrypt(cookie.value, this.secret) ) ); @@ -73,7 +69,11 @@ export class StatelessSessionStore extends AbstractSessionStore { ...normalizedStatelessSession, // Ensure that when there are no connection token sets, we omit the property. ...(connectionTokenSets.length - ? { connectionTokenSets: connectionTokenSets.map(tokenSet => tokenSet.payload) } + ? { + connectionTokenSets: connectionTokenSets.map( + (tokenSet) => tokenSet.payload + ) + } : {}) }; } @@ -117,7 +117,7 @@ export class StatelessSessionStore extends AbstractSessionStore { ) ); } - + // Any existing v3 cookie can be deleted as soon as we have set a v4 cookie. // In stateless sessions, we do have to ensure we delete all chunks. cookies.deleteChunkedCookie(LEGACY_COOKIE_NAME, reqCookies, resCookies); @@ -127,11 +127,7 @@ export class StatelessSessionStore extends AbstractSessionStore { reqCookies: cookies.RequestCookies, resCookies: cookies.ResponseCookies ) { - cookies.deleteChunkedCookie( - this.sessionCookieName, - reqCookies, - resCookies - ); + cookies.deleteChunkedCookie(this.sessionCookieName, reqCookies, resCookies); this.getConnectionTokenSetsCookies(reqCookies).forEach((cookie) => resCookies.delete(cookie.name) @@ -177,7 +173,6 @@ export class StatelessSessionStore extends AbstractSessionStore { "You can use a stateful session implementation to store the session data in a data store." ); } - } }