logto-io / js

šŸ¤“ Logto JS SDKs.
https://docs.logto.io/quick-starts/
MIT License
56 stars 38 forks source link

bug: NodeJS `encrypt` function dramtically increases size of cookie and can lead to it being discarded by the browser when over ~4000 bytes #779

Open jonsamwell opened 1 month ago

jonsamwell commented 1 month ago

Describe the bug

After logging in, the idToken, accessToken, and expiry information are encrypted and sent to the browser in a cookie. Before encryption, this data typically amounts to around 2000 bytes, although the exact size can vary. In this example, the user's data includes email, profile scopes, and roles across a few organizations.

After encryption, the size of the cookie can increase significantly, approaching the 4096-byte browser cookie soft limit. Beyond this limit, browsers may start to discard the cookie, leading to potential issues. This behavior was discovered during development in Svelte, where an error is thrown when this limit is exceeded. For more details, see the relevant code in the Svelte GitHub repository.

Expected behavior

The size of the cookie should be controlled to stay within the accepted limits that all modern browsers will accept. Maintaining a smaller cookie size ensures faster transfer of the cookie on each request to the issuing server. This optimization enhances performance and prevents potential issues related to exceeding the cookie size limit.

How to reproduce?

Use any JavaScript frontend with the appropriate Logto frontend package. Log in as a user who belongs to multiple organizations, each with several roles. Include the email and profile scopes in the login request. Specify a resource or two to ensure the accessToken is retrieved and populated. Observe the size of the cookie after encryption and storage.

This setup results in a comprehensive and realistic identity/access token. After encryption, the size of the cookie will significantly increase, potentially approaching or exceeding browser limits.

There are a few options we could try to address the issue of large cookie sizes (I am happy to do a PR for whichever route is deemed best):

1) Split the Cookie into Chunks Over a Certain Size

2) Compress the Plain Text Before Encryption (e.g., with gzip/brotli)

3) Compress the Encrypted Cookie (e.g., with gzip/brotli)

Context

wangsijie commented 1 month ago

Maybe the final solution is to use a external storage like memory, database or redis?

jonsamwell commented 1 month ago

I ended up implemented a chunked cookie custom storage adapter based off the sveltekit cookie adapter. I tried compressing the encrypted cookie but it didn't give much of a size decrease to the final value and it would still hit the browser cookie size limit. I think it is a probably a bad developer experience to hit this out of the box so it might be good to have a chunked cookie manager implementation as the default?

import { wrapSession, unwrapSession } from '@logto/node';
import { CookieStorage } from '@logto/sveltekit';

export class ChunkedCookieStorage extends CookieStorage {
  private readonly CHUNK_SIZE = 2500;

  public override async init() {
    const { encryptionKey } = this.config;
    const cookieValue = this.retrieveCookieChunks();
    this.sessionData = await unwrapSession(cookieValue, encryptionKey);
  }

  protected override async write(data = this.sessionData) {
    const { encryptionKey } = this.config;
    const rawInformation = await wrapSession(data, encryptionKey);
    this.storeCookieChunks(rawInformation);
  }

  private retrieveCookieChunks(): string {
    let cookieValue = '';
    let idx = 0;

    while (true) {
      const cookieKey = this.makeCookieKey(this.cookieKey, idx);
      const value = this.config.getCookie(cookieKey);
      if (value !== undefined) {
        cookieValue += value;
      } else {
        break;
      }
      idx++;
    }

    return cookieValue;
  }

  private storeCookieChunks(data: string): void {
    let idx = 0;

    for (const chunk of this.chunkData(data)) {
      this.config.setCookie(this.makeCookieKey(this.cookieKey, idx), chunk, this.cookieOptions);
      idx++;
    }
  }

  protected *chunkData(data: string): Generator<string> {
    const length = data.length;

    for (let i = 0; i < length; i += this.CHUNK_SIZE) {
      yield data.slice(i, i + this.CHUNK_SIZE);
    }
  }

  private makeCookieKey(key: string, chunk: number): string {
    return `${key}_${chunk}`;
  }
}