Repository: auth0/nextjs-auth0
Pull request #301 (2021-02-18 11:45:49)

Chunked cookies should not exceed browser max
### Description

We currently allow 96 characters leeway for cookie attribute size ([browser cookie max size (4096 Bytes)](https://tools.ietf.org/html/rfc6265#section-6.1) minus [CHUNK_BYTE_SIZE](https://github.com/auth0/nextjs-auth0/blob/v1.0.0/src/auth0-session/cookie-store.ts#L12))

This is not enough for scenarios where the user provides many cookie options or longer cookie domain or path options.

Since the cookie attributes are dynamic and can be quite long, we can calculate the cookie attributes length from serializing an empty cookie with the same options and measuring it.

### References

fixes: #296 

### Testing

- Set a bunch of really long claims in your session (> 2000 chars) using the [afterCallback](https://auth0.github.io/nextjs-auth0/modules/handlers_callback.html#aftercallback) hook
- Specify a bunch of cookie options that will exceed 96 chars (eg a long cookie domain/path)
- Login (you'll need to login on the same domain/path) and check the `Size` of your chunked `appSession.{n}` cookies in Chrome Devtools > Application > Cookie
- They should not exceed 4095 (Chrome Devtools doesn't inlclude the `=`)

- [ ] This change adds test coverage for new/changed/fixed functionality

### Checklist

- [ ] All active GitHub checks for tests, formatting, and security are passing
- [ ] The correct base branch is being used, if not `main`



Patch:
diff --git a/src/auth0-session/cookie-store.ts b/src/auth0-session/cookie-store.ts
index 971dca01..780f1461 100644
--- a/src/auth0-session/cookie-store.ts
+++ b/src/auth0-session/cookie-store.ts
@@ -5,11 +5,11 @@ import { encryption as deriveKey } from './utils/hkdf';
 import createDebug from './utils/debug';
 import { getAll as getCookies, clear as clearCookie, set as setCookie } from './utils/cookies';
 import { Config } from './config';
-import { CookieSerializeOptions } from 'cookie';
+import { CookieSerializeOptions, serialize } from 'cookie';
 
 const debug = createDebug('cookie-store');
 const epoch = (): number => (Date.now() / 1000) | 0; // eslint-disable-line no-bitwise
-const CHUNK_BYTE_SIZE = 4000;
+const MAX_COOKIE_SIZE = 4096;
 const alg = 'dir';
 const enc = 'A256GCM';
 
@@ -20,6 +20,8 @@ export default class CookieStore {
 
   private currentKey: JWK.OctKey | undefined;
 
+  private chunkSize: number;
+
   constructor(public config: Config) {
     const secrets = Array.isArray(config.secret) ? config.secret : [config.secret];
     this.keystore = new JWKS.KeyStore();
@@ -31,6 +33,20 @@ export default class CookieStore {
       }
       this.keystore.add(key);
     });
+
+    const {
+      cookie: { transient, ...cookieConfig },
+      name: sessionName
+    } = this.config.session;
+    const cookieOptions: CookieSerializeOptions = {
+      ...cookieConfig
+    };
+    if (!transient) {
+      cookieOptions.expires = new Date();
+    }
+
+    const emptyCookie = serialize(`${sessionName}.0`, '', cookieOptions);
+    this.chunkSize = MAX_COOKIE_SIZE - emptyCookie.length;
   }
 
   private encrypt(payload: string, headers: { [key: string]: any }): string {
@@ -172,11 +188,11 @@ export default class CookieStore {
     debug('found session, creating signed session cookie(s) with name %o(.i)', sessionName);
     const value = this.encrypt(JSON.stringify(session), { iat, uat, exp });
 
-    const chunkCount = Math.ceil(value.length / CHUNK_BYTE_SIZE);
+    const chunkCount = Math.ceil(value.length / this.chunkSize);
     if (chunkCount > 1) {
-      debug('cookie size greater than %d, chunking', CHUNK_BYTE_SIZE);
+      debug('cookie size greater than %d, chunking', this.chunkSize);
       for (let i = 0; i < chunkCount; i++) {
-        const chunkValue = value.slice(i * CHUNK_BYTE_SIZE, (i + 1) * CHUNK_BYTE_SIZE);
+        const chunkValue = value.slice(i * this.chunkSize, (i + 1) * this.chunkSize);
         const chunkCookieName = `${sessionName}.${i}`;
         setCookie(res, chunkCookieName, chunkValue, cookieOptions);
       }


Test patch:
diff --git a/tests/auth0-session/cookie-store.test.ts b/tests/auth0-session/cookie-store.test.ts
index caed2071..7948c8fe 100644
--- a/tests/auth0-session/cookie-store.test.ts
+++ b/tests/auth0-session/cookie-store.test.ts
@@ -101,6 +101,25 @@ describe('CookieStore', () => {
     expect(session.claims).toHaveProperty('big_claim');
   });
 
+  it('should limit total cookie size to 4096 Bytes', async () => {
+    const path =
+      '/some-really-really-really-really-really-really-really-really-really-really-really-really-really-long-path';
+    const baseURL = await setup({ ...defaultConfig, session: { cookie: { path } } });
+    const appSession = encrypted({
+      big_claim: randomBytes(5000).toString('base64')
+    });
+    expect(appSession.length).toBeGreaterThan(4096);
+    const cookieJar = toCookieJar({ appSession }, baseURL);
+    await get(baseURL, '/session', { cookieJar });
+    const cookies: { [key: string]: string } = cookieJar
+      .getCookiesSync(`${baseURL}${path}`)
+      .reduce((obj, value) => Object.assign(obj, { [value.key]: value + '' }), {});
+    expect(cookies['appSession.0']).toHaveLength(4096);
+    expect(cookies['appSession.1']).toHaveLength(4096);
+    expect(cookies['appSession.2']).toHaveLength(4096);
+    expect(cookies['appSession.3']).toHaveLength(1568);
+  });
+
   it('should handle unordered chunked cookies', async () => {
     const baseURL = await setup(defaultConfig);
     const appSession = encrypted({ sub: '__chunked_sub__' });