diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 38b6818..8611477 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -94,7 +94,7 @@ jobs: if [ "$RELEASE_CANDIDATE" == "true" ] then DIST_TAG=rc - elif [ "$GITHUB_REF" == "refs/heads/main" ] && [ "$GITHUB_REF" == "refs/heads/master" ] + elif [ "$GITHUB_REF" == "refs/heads/main" ] || [ "$GITHUB_REF" == "refs/heads/master" ] then # This is the main branch and it's not a prerelease, so the dist-tag should be `latest`. DIST_TAG=latest diff --git a/.npmignore b/.npmignore index 37f34e4..1c54a78 100644 --- a/.npmignore +++ b/.npmignore @@ -4,3 +4,4 @@ *.spec.js *.spec.js.map *.spec.ts +__snapshots__ diff --git a/docs/design.md b/docs/design.md new file mode 100644 index 0000000..3d8fc14 --- /dev/null +++ b/docs/design.md @@ -0,0 +1,332 @@ +# Design + +This document should help clarify how this library works internally and why +certain choices were made. + +## Data flows + +Supabase Auth encodes a user's session using an access token (a JWT, +symmetrically signed) and a refresh token (a unique string that can be only +used once to issue a new access token). + +In Single Page Applications (SPA) these are stored in local storage. For +applications where Server-Side Rendering frameworks are used, the access and +refresh token need to also be accessible by the server. + +This is traditionally done using browser cookies [Cookies](). By storing the +access token and refresh token in cookies, the browser will send them over to +the server on every page load. Then, the server can take them from the request +headers and render HTML (i.e. server-rendered React) based on the user's +session. + +It's important to note that when the user visits a SSR page for the first time, +the request (and therefore cookies) are sent _well before any JavaScript runs +on the page._ In fact, JavaScript can only run after the response from the +server is received. This means that the access token is very likely expired +when sent to the server, and it's the server's job to use the refresh token (as +an extension of the usser's agent) to obtain a new access token. + +Since a refresh token can only be used once, the server must send back the new +access token it received as `Set-Cookie` headers. + +## Persisting the session information in Cookies + +Cookies have significant limitations, as they are a technology invented many +years ago. They can [only hold US ASCII characters **not including** `"`, `,`, +`;`, `\`, `\n`, `\r`, and other +whitespace](https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1). As +such JSON **is not permitted** (though appears to be somewhat allowed in +real-life by most servers). + +Browsers tend to limit the size of individual cookies. Experimental results +show that individual cookies longer than 3180 bytes will not be sent to the +server, or may not even be saved at all. + +For this reason, a _cookie chunking_ strategy is used to split a single value +over multiple cookies. + +### Cookie chunking strategy + +This library uses this cookie chunking strategy: + +1. If the value to be stored is <= 3180 bytes, then it's stored under the full + cookie name. +2. If the value is >= 3180 bytes, it's split in chunks of 3180 bytes. + The name of the cookie takes the form `key.chunk_index` where `key` is the + key for storing the value and `chunk_index` is the 0-based index of the + chunk. + +The operation for reading a stored item with the key `key` is as follows: + +1. If there's a cookie with a full name `key`, use its value. +2. For each index starting at `0` if there's a `key.index` cookie, join its + value with the previous index. If there's no value, stop processing. + +Note: These algorithms were introduced in versions <= 0.3.0 and are kept for +their simplicity. + +Because of these algorithms, it's important for the library to ensure handling +these state changes with regards to a stored item's value: + +1. _Non-chunked to chunked._ If a value for an item previously fit in a + non-chunked cookie, but now it needs to be split amongst multiple cookies: + - The non-chunked cookie must be _removed_ (i.e. set to `Max-Age=0`). +2. _More chunks to less chunks._ If a value for an item previously fit in 3 + chunks but now needs to fit in 2 chunks: + - The chunks from the end, e.g. `key.2` must be _removed_ (i.e. set to + `Max-Age=0`). +3. _Chunked to non-chunked._ If a value for an item previously fit in at least + 2 chunks, but now can fit in one cookie: + - All of the chunks need to be _removed_ (i.e. set to `Max-Age=0`) and only + the full cookie be set to the value. + +If these state changes are not implemented correctly, it can lead to issues in +the Supabase Auth library such as: + +- Reading garbled data (reading stale chunks). +- Reading stale data (as the non-chunked version is preferential, failing to + remove it when moving to chunked data can cause the library to read old + data). + +#### Deprecation of `get`, `set` and `remove` in favor of `getAll` and `setAll` + +To ensure the correct implementation of the state changes described above, it +was necessary to deprecate the `get`, `set` and `remove` cookie access methods +starting in version 0.4.0 in favor of `getAll` and `setAll`. + +This is because when a storage item needs to be set, all cookies that have +chunk-like names need to be properly set and cleared. These cannot be known in +advance, so `get` is not sufficient for solving the problem. + +To illustrate with an example, suppose a request comes in with the following +cookies: + +```typescript +{ + 'storage-item': 'value', + 'storage-item.0': 'value', + 'storage-item.1': 'value', + 'storage-item.5': 'value', +} +``` + +The client library cannot know that there exist 4 different versions of the +same cookies so it can `get` them. It must use a function like `getAll` with +which it can inspect the full state of the request. + +Let's assume that the new state of the `storage-item` is to set two chunks `.0` +and `.1` such as: + +```typescript +{ + 'storage-item.0': 'val', + 'storage-item.1': 'ue', +} +``` + +These need to be translated into the following `Set-Cookie` headers (commands): + +```http +Set-Cookie: storage-item.0=val; Max-Age= +Set-Cookie: storage-item.1=ue; Max-Age= +Set-Cookie: storage-item=; Max-Age=0 +Set-Cookie: storage-item.5=; Max-Age=0 +``` + +Notice the last two commands that clear the stale `storage-key` and +`storage-item.5` cookies. + +Starting version 0.4.0 if `get`, `set` and `remove` are used, in an effort to +maintain some reliability of the state represented by cookies, the client +library will test for the storage item and its first 5 chunks and clear them if +necessary. This should suffice for most situations, but not all. + +Regardless, all users must switch to `getAll` and `setAll`, as in the next +major version the individual `get`, `set` and `remove` methods will not be +supported. + +### Cookies as a database (Max-Age option explained) + +Cookies are like a very primitive key-value store. You can only query by cookie +name, and the database will give you back a value. It won't give you back its +metadata. + +To write to the database, you have to use the `Set-Cookie` header, which is +like the `INSERT` or `UPDATE` commands. + +Since the Supabase Auth library uses cookies only to store the session, the +`Max-Age` option of a cookie (as a chunk or otherwise) must be set to a very +high number. This ensures that the browser will always send the value to the +server, and not "delete it." + +Conversely, when a cookie is **removed** the `Max-Age` option should be set to +`0`. This is equivalent to a `DELETE` command. This is **extremely important** +as failing to send these commands can result in stale data remaining in the +browser. + +### Encoding cookie values + +As mentioned previously, cookies can [only hold US ASCII characters **not including** `"`, `,`, +`;`, `\`, `\n`, `\r`, and other +whitespace](https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1). But, +Supabase Auth's library encodes stored items as JSON. + +This means that, technically, these values must not be used as-is as cookies +and some transformation to the JSON needs to be made to conform to the +restriction. + +This is because: + +- JSON is full of `"`, which appear to be banned and can be mis-interpreted. +- JSON can hold any UTF-8 sequence, which is not US ASCII. This means that if + the stored session holds any character (like a Chinese, Japanese or Cyrillic + user name) it should technically be not accepted and is open to + mis-interpretation. + +#### Versions at or before 0.3.0 + +Up to version 0.3.0 this limitation for cookies was ignored, and likely +contributed to confused servers, browsers, developers and users. + +Therefore, the raw JSON values (chunked or not) were split up without regards +to the limitations and set as cookies. + +#### Versions after 0.3.0 + +To force the library's behavior into compliance, after version 0.3.0 a new +encoding strategy is developed for cookie values. + +It utilizes Base64-URL encoding in the following manner: + +1. The value is prefixed with `base64-` which allows the library to detect the + encoding used. +2. The value is encoded using Base64-URL and appended to the prefix, without + any white space or padding (`=`) characters. +3. If the whole prefix + encoded value needs to be chunked, it's chunked as a + whole string. + +Therefore to read a value from cookies, the library uses this algorithm: + +1. If the value starts with `base64-`, read the rest of it, decode it from Base64 + and return it. +2. If the value does not start with `base64-` and there is another prefix + defined then attempt to use the indicated encoding algorithm. If that + algorithm is not supported, either return an error or return a null value. +3. Finally, the value does not seem to be an encoded value, so try to read it + as-is (raw) and return it. + +This algorithm allows for backward and forward compatibility between versions +0.3.0 and above including the introduction of new/different encoding +strategies. + +## SSR framework patterns + +All SSR frameworks today can be described as having the following patterns: + +1. **Middleware.** This is a function that runs on the server _before_ any + rendering is done. It has access to the whole request, including headers, + cookies and other infromation. Usually these functions have the right to + change the response headers as well, such as for setting cookies. They are + often used to: + - Redirect to other pages (like to `/sign-in` to ask the user to sign in, or + `/verify-mfa` to ask them to go through MFA) + - Return responses (such as 401, 403 and others) +2. **Routes or APIs.** These are functions that help developers implement APIs + for their applications without needing to build a separate API server. These + are often useful with traditional [HTML forms](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/form) or + simply for offloading slow or privileged tasks. These functions have access to + the full request context + and are able to return any response, including setting headers. +3. **Server pages and components.** These are React components (sometimes + organized as pages) which can be rendered on the server. Most React features + that enable interactivity, such as click handlers, `useEffect`, `useContext` or + React Query are **not available.** Usually only basic `fetch` is allowed, with + some form of additional caching provided. In most SSR frameworks when a page or + component is rendered on the server **accessing request information is + limited or not available, with the exception of access to cookies**. It is + not possible to **set cookies**. +4. **Client (browser) pages and components.** These are React components that are + [hydrated](https://react.dev/reference/react-dom/client/hydrateRoot) into + life after the server has returned the rendered response for the page. They run + inside the browser's runtime, and have full access to the + [`document.cookie`](https://developer.mozilla.org/en-US/docs/Web/API/Document/cookie) + API for reading and writing cookies. + +As you can see, patterns 1 and 2 allow full access to cookies on the server, +while pattern 3 allows for read-only access on the server. This means that any +Supabase Client object on the server must be able to conditionally "set" +cookies and always allow access to reading them. + +As the cookie access method per framework (or version of framework) varies, the +`createServerClient` function exposes an interface for getting and optionally +setting cookies: + +- `getAll` a function that returns _all_ cookies associated with the request as + an array of `{ name: string; value: string }` objects. It's important to + return all cookies, as the server may need to "delete" cookies by setting them + to `Max-Age=0` such as when moving from more chunks to less chunks. +- `setAll` a function whose first argument is an array of cookie objects `{ +name: string; value: string; options: CookieOptions }`. Each of those _must_ + be set **both on the request (when available, usually in middlewares) and response**. If the client is used in server-rendered pages and components (pattern 3) and setting of cookies is not possible, the library must emit a warning that setting of cookies is required but not available. This is a developer aid to help identify mutations in server-rendering which is a code smell. + +On the browser (client) the `createBrowserClient` function will use the +underlying `document.cookie` API automatically. If this is not supported for +some reason, **both `getAll` and `setAll` must be specified.** The client must +always be able to set cookies, as access tokens and refresh tokens are +continuously issued while the user is interacting with the page. + +It is expected that `getAll` sees the changes created by `setAll`! + +### When does the server `setAll`? + +Server-side rendering frameworks attempt to make it easy to generate HTML on +the server, which improves important web metrics like (Time to first byte, +First contentful paint, etc.). + +It's important to notice that server-rendering _primarily_ comes into play on +fresh page loads. Once a page has been rendered and hydrated in the browser, +client React compoenents take over. When using the Supabase Auth library in the +browser in such a way, the user's session (access and refresh tokens) are +proactively and ahead-of-time refreshed, meaning that they are continuously set +as cookies well ahead of their expiry time. + +From this it naturally follows that the most critical user session refresh +point is when the user has not interacted with the page in a while, such as +opening a new tab after a full night of partying. + +Say the website `app.example.com` is developed in an SSR framework. What +happens when a user opens a brand new tab after a while and types +`app.example.com` in the address bar is this: + +1. The browser sends a request to `https://app.example.com` with all the + cookies in its store. +2. The middleware (pattern 1) is invoked. +3. The server client is created with a `getAll` that retrieves the cookies. +4. The server client notices that the access token stored in the cookies has + been expired for hours or days. +5. It calls the `POST /token?grant_type=refresh_token` endpoint of Supabase + Auth to get a new access token (or to detect that the user has been signed + out due to session termination). +6. Finally calls `setAll` with the new cookies that need to be set or cleared. + +Once this process is complete, and the effect of `setAll` is returned to the +browser as `Set-Cookie` headers in the response, both browser and server are +in-sync with regards to the user session. + +So long as the user continues interacting with the website, the browser client +will keep the access token up-to-date so any future server-side rendering is +unlikely to need to refresh the user's session. + +There are two key points to identify from this about the behavior of +`createServerClient`: + +1. **Using the middleware pattern is mandatory. Session refresh happens in the + middleware.** Not using a middleware function means that the session will + likely not be properly refreshed, given that server pages and components don't + always get to set cookies. +2. **Cookies are set when the storage values change. Set-Cookie headers should + not be sent out if there is no change.** Therefore cookies are set only on + these `onAuthStateChange` events: + - `TOKEN_REFRESHED` -- when the access token was expired + - `USER_UPDATED` -- usually only in pattern 3 -- routes or APIs that call the `updateUser()` API + - `SIGNED_OUT` when the session expired or was terminated, such as the user signing out from another device diff --git a/package-lock.json b/package-lock.json index 0bbc353..81ad278 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,22 +1,20 @@ { "name": "@supabase/ssr", - "version": "0.3.0", + "version": "0.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@supabase/ssr", - "version": "0.3.0", + "version": "0.0.0", "license": "MIT", "dependencies": { - "cookie": "^0.6.0", - "ramda": "^0.30.0" + "cookie": "^0.6.0" }, "devDependencies": { "@eslint/js": "^9.3.0", "@supabase/supabase-js": "^2.43.4", "@types/cookie": "^0.6.0", - "@types/ramda": "^0.30.0", "@vitest/coverage-v8": "^1.6.0", "eslint": "^8.57.0", "prettier": "^3.2.5", @@ -399,15 +397,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/ramda": { - "version": "0.30.0", - "resolved": "https://registry.npmjs.org/@types/ramda/-/ramda-0.30.0.tgz", - "integrity": "sha512-DQtfqUbSB18iM9NHbQ++kVUDuBWHMr6T2FpW1XTiksYRGjq4WnNPZLt712OEHEBJs7aMyJ68Mf2kGMOP1srVVw==", - "dev": true, - "dependencies": { - "types-ramda": "^0.30.0" - } - }, "node_modules/@types/ws": { "version": "8.5.10", "dev": true, @@ -2087,15 +2076,6 @@ ], "license": "MIT" }, - "node_modules/ramda": { - "version": "0.30.0", - "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.30.0.tgz", - "integrity": "sha512-13Y0iMhIQuAm/wNGBL/9HEqIfRGmNmjKnTPlKWfA9f7dnDkr8d45wQ+S7+ZLh/Pq9PdcGxkqKUEA7ySu1QSd9Q==", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/ramda" - } - }, "node_modules/react-is": { "version": "18.3.1", "dev": true, @@ -2389,12 +2369,6 @@ "typescript": ">=4.2.0" } }, - "node_modules/ts-toolbelt": { - "version": "9.6.0", - "resolved": "https://registry.npmjs.org/ts-toolbelt/-/ts-toolbelt-9.6.0.tgz", - "integrity": "sha512-nsZd8ZeNUzukXPlJmTBwUAuABDe/9qtVDelJeT/qW0ow3ZS3BsQJtNkan1802aM9Uf68/Y8ljw86Hu0h5IUW3w==", - "dev": true - }, "node_modules/type-check": { "version": "0.4.0", "dev": true, @@ -2425,15 +2399,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/types-ramda": { - "version": "0.30.0", - "resolved": "https://registry.npmjs.org/types-ramda/-/types-ramda-0.30.0.tgz", - "integrity": "sha512-oVPw/KHB5M0Du0txTEKKM8xZOG9cZBRdCVXvwHYuNJUVkAiJ9oWyqkA+9Bj2gjMsHgkkhsYevobQBWs8I2/Xvw==", - "dev": true, - "dependencies": { - "ts-toolbelt": "^9.6.0" - } - }, "node_modules/typescript": { "version": "5.4.5", "dev": true, diff --git a/package.json b/package.json index 36496f8..6402310 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,4 @@ { - "private": true, "name": "@supabase/ssr", "version": "0.0.0", "description": "Use the Supabase JavaScript library in popular server-side rendering (SSR) frameworks.", @@ -36,7 +35,6 @@ "@eslint/js": "^9.3.0", "@supabase/supabase-js": "^2.43.4", "@types/cookie": "^0.6.0", - "@types/ramda": "^0.30.0", "@vitest/coverage-v8": "^1.6.0", "eslint": "^8.57.0", "prettier": "^3.2.5", @@ -51,7 +49,6 @@ "@rollup/rollup-linux-x64-gnu": "^4.9.5" }, "dependencies": { - "cookie": "^0.6.0", - "ramda": "^0.30.0" + "cookie": "^0.6.0" } } diff --git a/src/__snapshots__/createServerClient.spec.ts.snap b/src/__snapshots__/createServerClient.spec.ts.snap new file mode 100644 index 0000000..7d5a489 --- /dev/null +++ b/src/__snapshots__/createServerClient.spec.ts.snap @@ -0,0 +1,235 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`createServerClient > use cases > should not set cookies if the session does not need to be refreshed (storage key = custom-storage-key) 1`] = ` +{ + "id": "", +} +`; + +exports[`createServerClient > use cases > should not set cookies if the session does not need to be refreshed (storage key = null) 1`] = ` +{ + "id": "", +} +`; + +exports[`createServerClient > use cases > should refresh session correctly as typically used in middlewares (storage key = custom-storage-key) 1`] = ` +{ + "id": "", +} +`; + +exports[`createServerClient > use cases > should refresh session correctly as typically used in middlewares (storage key = custom-storage-key) 2`] = ` +[ + { + "name": "custom-storage-key", + "options": { + "httpOnly": false, + "maxAge": 0, + "path": "/", + "sameSite": "lax", + }, + "value": "", + }, + { + "name": "custom-storage-key.0", + "options": { + "httpOnly": false, + "maxAge": 31536000000, + "path": "/", + "sameSite": "lax", + }, + "value": "base64-eyJleHBpcmVzX2luIjozNjAwLCJleHBpcmVzX2F0IjoyMTE0MzgwODAwLCJhY2Nlc3NfdG9rZW4iOiI8YWNjZXNzLXRva2VuPiIsInJlZnJlc2hfdG9rZW4iOiI8cmVmcmVzaC10b2tlbj4iLCJ1c2VyIjp7ImlkIjoiPHVzZXItaWQtcmVmcmVzaC10b2tlbj4iLCJlbWFpbCI6Inh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4e", + }, + { + "name": "custom-storage-key.1", + "options": { + "httpOnly": false, + "maxAge": 31536000000, + "path": "/", + "sameSite": "lax", + }, + "value": "Hh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eEBleGFtcGxlLmNvbSJ9fQ", + }, +] +`; + +exports[`createServerClient > use cases > should refresh session correctly as typically used in middlewares (storage key = null) 1`] = ` +{ + "id": "", +} +`; + +exports[`createServerClient > use cases > should refresh session correctly as typically used in middlewares (storage key = null) 2`] = ` +[ + { + "name": "sb-project-ref-auth-token", + "options": { + "httpOnly": false, + "maxAge": 0, + "path": "/", + "sameSite": "lax", + }, + "value": "", + }, + { + "name": "sb-project-ref-auth-token.0", + "options": { + "httpOnly": false, + "maxAge": 31536000000, + "path": "/", + "sameSite": "lax", + }, + "value": "base64-eyJleHBpcmVzX2luIjozNjAwLCJleHBpcmVzX2F0IjoyMTE0MzgwODAwLCJhY2Nlc3NfdG9rZW4iOiI8YWNjZXNzLXRva2VuPiIsInJlZnJlc2hfdG9rZW4iOiI8cmVmcmVzaC10b2tlbj4iLCJ1c2VyIjp7ImlkIjoiPHVzZXItaWQtcmVmcmVzaC10b2tlbj4iLCJlbWFpbCI6Inh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4e", + }, + { + "name": "sb-project-ref-auth-token.1", + "options": { + "httpOnly": false, + "maxAge": 31536000000, + "path": "/", + "sameSite": "lax", + }, + "value": "Hh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eEBleGFtcGxlLmNvbSJ9fQ", + }, +] +`; + +exports[`createServerClient > use cases > should set PKCE code verifier correctly (storage key = custom-storage-key) 1`] = ` +[ + { + "name": "custom-storage-key-code-verifier", + "options": { + "httpOnly": false, + "maxAge": 31536000000, + "path": "/", + "sameSite": "lax", + }, + "value": "", + }, +] +`; + +exports[`createServerClient > use cases > should set PKCE code verifier correctly (storage key = null) 1`] = ` +[ + { + "name": "sb-project-ref-auth-token-code-verifier", + "options": { + "httpOnly": false, + "maxAge": 31536000000, + "path": "/", + "sameSite": "lax", + }, + "value": "", + }, +] +`; + +exports[`createServerClient > use cases > should set exchange PKCE code for session correctly (storage key = custom-storage-key) 1`] = ` +[ + { + "name": "custom-storage-key-code-verifier", + "options": { + "httpOnly": false, + "maxAge": 0, + "path": "/", + "sameSite": "lax", + }, + "value": "", + }, + { + "name": "custom-storage-key.0", + "options": { + "httpOnly": false, + "maxAge": 31536000000, + "path": "/", + "sameSite": "lax", + }, + "value": "base64-eyJleHBpcmVzX2luIjozNjAwLCJleHBpcmVzX2F0IjoyMTE0MzgwODAwLCJhY2Nlc3NfdG9rZW4iOiI8YWNjZXNzLXRva2VuPiIsInJlZnJlc2hfdG9rZW4iOiI8cmVmcmVzaC10b2tlbj4iLCJ1c2VyIjp7ImlkIjoiPHVzZXItaWQtcmVmcmVzaC10b2tlbj4iLCJlbWFpbCI6Inh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4e", + }, + { + "name": "custom-storage-key.1", + "options": { + "httpOnly": false, + "maxAge": 31536000000, + "path": "/", + "sameSite": "lax", + }, + "value": "Hh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eEBleGFtcGxlLmNvbSJ9fQ", + }, +] +`; + +exports[`createServerClient > use cases > should set exchange PKCE code for session correctly (storage key = custom-storage-key) 2`] = ` +{ + "redirectType": null, + "session": { + "access_token": "", + "expires_at": 2114380800, + "expires_in": 3600, + "refresh_token": "", + "user": { + "email": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx@example.com", + "id": "", + }, + }, + "user": { + "email": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx@example.com", + "id": "", + }, +} +`; + +exports[`createServerClient > use cases > should set exchange PKCE code for session correctly (storage key = null) 1`] = ` +[ + { + "name": "sb-project-ref-auth-token-code-verifier", + "options": { + "httpOnly": false, + "maxAge": 0, + "path": "/", + "sameSite": "lax", + }, + "value": "", + }, + { + "name": "sb-project-ref-auth-token.0", + "options": { + "httpOnly": false, + "maxAge": 31536000000, + "path": "/", + "sameSite": "lax", + }, + "value": "base64-eyJleHBpcmVzX2luIjozNjAwLCJleHBpcmVzX2F0IjoyMTE0MzgwODAwLCJhY2Nlc3NfdG9rZW4iOiI8YWNjZXNzLXRva2VuPiIsInJlZnJlc2hfdG9rZW4iOiI8cmVmcmVzaC10b2tlbj4iLCJ1c2VyIjp7ImlkIjoiPHVzZXItaWQtcmVmcmVzaC10b2tlbj4iLCJlbWFpbCI6Inh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4e", + }, + { + "name": "sb-project-ref-auth-token.1", + "options": { + "httpOnly": false, + "maxAge": 31536000000, + "path": "/", + "sameSite": "lax", + }, + "value": "Hh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eEBleGFtcGxlLmNvbSJ9fQ", + }, +] +`; + +exports[`createServerClient > use cases > should set exchange PKCE code for session correctly (storage key = null) 2`] = ` +{ + "redirectType": null, + "session": { + "access_token": "", + "expires_at": 2114380800, + "expires_in": 3600, + "refresh_token": "", + "user": { + "email": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx@example.com", + "id": "", + }, + }, + "user": { + "email": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx@example.com", + "id": "", + }, +} +`; diff --git a/src/cookies.spec.ts b/src/cookies.spec.ts new file mode 100644 index 0000000..b5d30c1 --- /dev/null +++ b/src/cookies.spec.ts @@ -0,0 +1,1072 @@ +import { describe, expect, it, beforeEach, afterEach } from "vitest"; + +import { isBrowser, DEFAULT_COOKIE_OPTIONS, MAX_CHUNK_SIZE } from "./utils"; +import { CookieOptions } from "./types"; + +import { createStorageFromOptions, applyServerStorage } from "./cookies"; + +describe("createStorageFromOptions in browser without cookie methods", () => { + beforeEach(() => { + const cookies: { [name: string]: { value: string; options: string[] } } = + {}; + + const doc = new Proxy({} as any, { + get: (target, prop) => { + if (prop === "cookie") { + return Object.keys(cookies) + .map((key) => `${key}=${cookies[key].value}`) + .join(";"); + } + + return (target as any)[prop]; + }, + + set: (target, prop, setValue) => { + if (prop === "cookie") { + const [cookie, ...options] = setValue.split(/\s*;\s*/); + + const [name, value] = cookie.split("="); + + if (options.indexOf("Max-Age=0") > -1) { + delete cookies[name]; + } else { + cookies[name] = { value, options }; + } + + return true; + } + + (target as any)[prop] = setValue; + + return true; + }, + }); + + globalThis.window = { + document: doc, + } as any; + + (globalThis as any).document = doc; + }); + + afterEach(() => { + delete (globalThis as any).window; + delete (globalThis as any).document; + }); + + it("should setup mocks correctly", () => { + expect(isBrowser()).toEqual(true); + expect(document.cookie).toEqual(""); + + document.cookie = "name-a=value-a; max-age=123"; + document.cookie = "name-b=value-b; max-age=123"; + + expect(document.cookie).toEqual("name-a=value-a;name-b=value-b"); + + document.cookie = "name-a=delete; Max-Age=0"; + + expect(document.cookie).toEqual("name-b=value-b"); + }); + + it("should access cookies with various uses of getItem, setItem and removeItem", async () => { + const { storage } = createStorageFromOptions( + { + cookieEncoding: "raw", // to help test readability + }, + false, + ); + + [ + { name: "storage-key.0", value: "val" }, + { name: "storage-key.1", value: "ue" }, + { name: "storage-key.4", value: "leftover" }, + { name: "random-cookie", value: "random" }, + ].forEach(({ name, value }) => { + document.cookie = `${name}=${value}; Max-Age=123`; + }); + + const value = await storage.getItem("storage-key"); + expect(value).toEqual("value"); + + await storage.setItem("storage-key", "value"); + + expect(document.cookie).toEqual("random-cookie=random;storage-key=value"); + + let newChunkedValue = Array.from( + { length: MAX_CHUNK_SIZE + 1 }, + () => "x", + ).join(""); + + await storage.setItem("storage-key", newChunkedValue); + await storage.removeItem("non-existent-item"); + + expect(document.cookie).toEqual( + `random-cookie=random;storage-key.0=${newChunkedValue.substring(0, MAX_CHUNK_SIZE)};storage-key.1=${newChunkedValue.substring(MAX_CHUNK_SIZE)}`, + ); + + document.cookie = "storage-key=value; Max-Age=123"; + + await storage.removeItem("storage-key"); + + expect(document.cookie).toEqual("random-cookie=random"); + + newChunkedValue = Array.from( + { length: 2 * MAX_CHUNK_SIZE + 1 }, + () => "x", + ).join(""); + + await storage.setItem("storage-key", newChunkedValue); + + expect(document.cookie).toEqual( + `random-cookie=random;storage-key.0=${newChunkedValue.substring(0, MAX_CHUNK_SIZE)};storage-key.1=${newChunkedValue.substring(MAX_CHUNK_SIZE, 2 * MAX_CHUNK_SIZE)};storage-key.2=${newChunkedValue.substring(2 * MAX_CHUNK_SIZE)}`, + ); + + newChunkedValue = Array.from( + { length: MAX_CHUNK_SIZE + 1 }, + () => "x", + ).join(""); + + await storage.setItem("storage-key", newChunkedValue); + + expect(document.cookie).toEqual( + `random-cookie=random;storage-key.0=${newChunkedValue.substring(0, MAX_CHUNK_SIZE)};storage-key.1=${newChunkedValue.substring(MAX_CHUNK_SIZE)}`, + ); + }); +}); + +describe("createStorageFromOptions for createServerClient", () => { + describe("storage without setAll or without set / remove cookie methods", () => { + let warnings: any[][] = []; + + beforeEach(() => { + (console as any).originalWarn = console.warn; + console.warn = (...args: any[]) => { + warnings.push(args); + }; + }); + + afterEach(() => { + warnings = []; + console.warn = (console as any).originalWarn; + delete (console as any).originalWarn; + }); + + it("should log a warning when only getAll is configured", async () => { + const { setAll } = createStorageFromOptions( + { + cookieEncoding: "raw", // to help test readability + cookies: { + getAll: async () => { + return []; + }, + }, + }, + true, + ); + + await setAll([ + { + name: "cookie", + value: "value", + options: { ...DEFAULT_COOKIE_OPTIONS }, + }, + ]); + + expect(warnings).toEqual([ + [ + "@supabase/ssr: createServerClient was configured without the setAll cookie method, but the client needs to set cookies. This can lead to issues such as random logouts, early session termination or increased token refresh requests. If in NextJS, check your middleware.ts file, route handlers and server actions for correctness.", + ], + ]); + }); + + it("should log a warning when only get is configured", async () => { + const { setAll } = createStorageFromOptions( + { + cookieEncoding: "raw", // to help test readability + cookies: { + get: async () => { + return null; + }, + }, + }, + true, + ); + + await setAll([ + { + name: "cookie", + value: "value", + options: { ...DEFAULT_COOKIE_OPTIONS }, + }, + ]); + + expect(warnings).toEqual([ + [ + "@supabase/ssr: createServerClient was configured without set and remove cookie methods, but the client needs to set cookies. This can lead to issues such as random logouts, early session termination or increased token refresh requests. If in NextJS, check your middleware.ts file, route handlers and server actions for correctness. Consider switching to the getAll and setAll cookie methods instead of get, set and remove which are deprecated and can be difficult to use correctly.", + ], + ]); + }); + }); + + describe("storage with getAll, setAll", () => { + it("should not call setAll on setItem", async () => { + let setAllCalled = false; + + const { storage, setItems, removedItems } = createStorageFromOptions( + { + cookieEncoding: "raw", // to help test readability + cookies: { + getAll: async () => { + return []; + }, + + setAll: async () => { + setAllCalled = true; + }, + }, + }, + true, + ); + + await storage.setItem("storage-key", "value"); + + expect(setAllCalled).toBeFalsy(); + expect(setItems).toEqual({ "storage-key": "value" }); + expect(removedItems).toEqual({}); + }); + + it("should not call setAll on removeItem", async () => { + let setAllCalled = false; + + const { storage, setItems, removedItems } = createStorageFromOptions( + { + cookieEncoding: "raw", // to help test readability + cookies: { + getAll: async () => { + return []; + }, + + setAll: async () => { + setAllCalled = true; + }, + }, + }, + true, + ); + + await storage.removeItem("storage-key"); + + expect(setAllCalled).toBeFalsy(); + expect(setItems).toEqual({}); + expect(removedItems).toEqual({ "storage-key": true }); + }); + + it("should not call getAll if item has already been set", async () => { + let getAllCalled = false; + + const { storage } = createStorageFromOptions( + { + cookieEncoding: "raw", // to help test readability + cookies: { + getAll: async () => { + getAllCalled = true; + + return []; + }, + + setAll: async () => {}, + }, + }, + true, + ); + + await storage.setItem("storage-key", "value"); + + const value = await storage.getItem("storage-key"); + + expect(value).toEqual("value"); + expect(getAllCalled).toBeFalsy(); + }); + + it("should not call getAll if item has already been removed", async () => { + let getAllCalled = false; + + const { storage } = createStorageFromOptions( + { + cookieEncoding: "raw", // to help test readability + cookies: { + getAll: async () => { + getAllCalled = true; + + return []; + }, + + setAll: async () => {}, + }, + }, + true, + ); + + await storage.removeItem("storage-key"); + + const value = await storage.getItem("storage-key"); + + expect(value).toBeNull(); + expect(getAllCalled).toBeFalsy(); + }); + + it("should call getAll each time getItem is called until setItem or removeItem", async () => { + let getAllCalled = 0; + + const { storage } = createStorageFromOptions( + { + cookieEncoding: "raw", // to help test readability + cookies: { + getAll: async () => { + getAllCalled += 1; + + return []; + }, + + setAll: async () => {}, + }, + }, + true, + ); + + await storage.getItem("storage-key"); + + expect(getAllCalled).toEqual(1); + + await storage.getItem("storage-key"); + + expect(getAllCalled).toEqual(2); + + await storage.setItem("storage-key", "value"); + + await storage.getItem("storage-key"); + + expect(getAllCalled).toEqual(2); + }); + + it("should return item value from getAll without chunks", async () => { + const { storage } = createStorageFromOptions( + { + cookieEncoding: "raw", // to help test readability + cookies: { + getAll: async () => { + return [ + { + name: "storage-key", + value: "value", + }, + { + name: "other-cookie", + value: "other-value", + }, + { + name: "storage-key.0", + value: "leftover-chunk-value", + }, + ]; + }, + + setAll: async () => {}, + }, + }, + true, + ); + + const value = await storage.getItem("storage-key"); + + expect(value).toEqual("value"); + }); + + it("should return item value from getAll with chunks", async () => { + const { storage } = createStorageFromOptions( + { + cookieEncoding: "raw", // to help test readability + cookies: { + getAll: async () => { + return [ + { + name: "other-cookie", + value: "other-value", + }, + { + name: "storage-key.0", + value: "val", + }, + { + name: "storage-key.1", + value: "ue", + }, + { + name: "storage-key.2", + value: "", + }, + { + name: "storage-key.3", + value: "leftover-chunk-value", + }, + ]; + }, + + setAll: async () => {}, + }, + }, + true, + ); + + const value = await storage.getItem("storage-key"); + + expect(value).toEqual("value"); + }); + }); + + describe("storage with get, set, remove", () => { + it("should call get multiple times for the storage key and its chunks", async () => { + const getNames: string[] = []; + + const { storage } = createStorageFromOptions( + { + cookieEncoding: "raw", // to help test readability + cookies: { + get: async (name: string) => { + getNames.push(name); + + if (name === "storage-key") { + return "value"; + } + + return null; + }, + set: async () => {}, + remove: async () => {}, + }, + }, + true, + ); + + const value = await storage.getItem("storage-key"); + + expect(value).toEqual("value"); + + expect(getNames).toEqual([ + "storage-key", + "storage-key.0", + "storage-key.1", + "storage-key.2", + "storage-key.3", + "storage-key.4", + ]); + }); + + it("should reconstruct storage value from chunks", async () => { + const { storage } = createStorageFromOptions( + { + cookieEncoding: "raw", // to help test readability + cookies: { + get: async (name: string) => { + if (name === "storage-key.0") { + return "val"; + } + + if (name === "storage-key.1") { + return "ue"; + } + + if (name === "storage-key.3") { + return "leftover-chunk-value"; + } + + return null; + }, + set: async () => {}, + remove: async () => {}, + }, + }, + true, + ); + + const value = await storage.getItem("storage-key"); + + expect(value).toEqual("value"); + }); + }); + + describe("setAll when using set, remove", () => { + it("should call set and remove depending on the values sent to setAll", async () => { + const setCalls: { name: string; value: string }[] = []; + const removeCalls: string[] = []; + + const { setAll } = createStorageFromOptions( + { + cookieEncoding: "raw", // to help test readability + cookies: { + get: async (name: string) => { + return null; + }, + set: async (name, value) => { + setCalls.push({ name, value }); + }, + remove: async (name) => { + removeCalls.push(name); + }, + }, + }, + true, + ); + + await setAll([ + { + name: "a", + value: "b", + options: { maxAge: 10 }, + }, + { + name: "b", + value: "c", + options: { maxAge: 10 }, + }, + { + name: "c", + value: "", + options: { maxAge: 0 }, + }, + ]); + + expect(setCalls).toEqual([ + { name: "a", value: "b" }, + { name: "b", value: "c" }, + ]); + + expect(removeCalls).toEqual(["c"]); + }); + }); +}); + +describe("createStorageFromOptions for createBrowserClient", () => { + describe("storage with getAll, setAll", () => { + it("should call getAll on each getItem", async () => { + let getAllCalls = 0; + + const { storage } = createStorageFromOptions( + { + cookieEncoding: "raw", // to help test readability + cookies: { + getAll: async () => { + getAllCalls += 1; + + return [ + { + name: "random-cookie", + value: "random-value", + }, + { name: "storage-key", value: "value" }, + { name: "storage-key.4", value: "leftover-chunk-value" }, + ]; + }, + + setAll: async () => {}, + }, + }, + false, + ); + + const value = await storage.getItem("storage-key"); + + expect(value).toEqual("value"); + expect(getAllCalls).toEqual(1); + + const nonExistingValue = await storage.getItem("whatever"); + expect(nonExistingValue).toBeNull(); + expect(getAllCalls).toEqual(2); + }); + + it("should call getAll, setAll on each setItem", async () => { + let getAllCalls = 0; + let setAllCalls = 0; + + const { storage } = createStorageFromOptions( + { + cookieEncoding: "raw", // to help test readability + cookies: { + getAll: async () => { + getAllCalls += 1; + + return []; + }, + + setAll: async () => { + setAllCalls += 1; + }, + }, + }, + false, + ); + + await storage.setItem("storage-key", "value"); + + expect(getAllCalls).toEqual(1); + expect(setAllCalls).toEqual(1); + }); + + it("should call getAll, setAll on each removeItem", async () => { + let getAllCalls = 0; + let setAllCalls = 0; + + const { storage } = createStorageFromOptions( + { + cookieEncoding: "raw", // to help test readability + cookies: { + getAll: async () => { + getAllCalls += 1; + + return [ + { + name: "storage-key", + value: "value", + }, + ]; + }, + + setAll: async () => { + setAllCalls += 1; + }, + }, + }, + false, + ); + + await storage.removeItem("storage-key"); + + expect(getAllCalls).toEqual(1); + expect(setAllCalls).toEqual(1); + }); + + it("should do chunk management with setAll (non-chunked => chunked case)", async () => { + const setAllCalls: { + name: string; + value: string; + options: CookieOptions; + }[] = []; + + const { storage } = createStorageFromOptions( + { + cookieEncoding: "raw", // to help test readability + cookies: { + getAll: async () => { + return [ + { + name: "random-cookie", + value: "random-value", + }, + { + name: "storage-key", + value: "value", + }, + { + name: "storage-key.4", + value: "leftover-chunk-value", + }, + ]; + }, + + setAll: async (setCookies) => { + setAllCalls.push(...setCookies); + }, + }, + }, + false, + ); + + const chunkedValue = Array.from( + { length: MAX_CHUNK_SIZE + 1 }, + () => "x", + ).join(""); + + await storage.setItem("storage-key", chunkedValue); + + expect(setAllCalls).toEqual([ + { + name: "storage-key", + options: { ...DEFAULT_COOKIE_OPTIONS, maxAge: 0 }, + value: "", + }, + { + name: "storage-key.4", + options: { ...DEFAULT_COOKIE_OPTIONS, maxAge: 0 }, + value: "", + }, + { + name: "storage-key.0", + options: { ...DEFAULT_COOKIE_OPTIONS }, + value: chunkedValue.substring(0, MAX_CHUNK_SIZE), + }, + { + name: "storage-key.1", + options: { ...DEFAULT_COOKIE_OPTIONS }, + value: chunkedValue.substring(MAX_CHUNK_SIZE), + }, + ]); + }); + + it("should do chunk management with setAll (less chunks => more chunks case)", async () => { + const setAllCalls: { + name: string; + value: string; + options: CookieOptions; + }[] = []; + + const { storage } = createStorageFromOptions( + { + cookieEncoding: "raw", // to help test readability + cookies: { + getAll: async () => { + return [ + { + name: "random-cookie", + value: "random-value", + }, + { + name: "storage-key.0", + value: "val", + }, + { + name: "storage-key.1", + value: "ue", + }, + { + name: "storage-key.4", + value: "leftover-chunk-value", + }, + ]; + }, + + setAll: async (setCookies) => { + setAllCalls.push(...setCookies); + }, + }, + }, + false, + ); + + const chunkedValue = Array.from( + { length: 2 * MAX_CHUNK_SIZE + 1 }, + () => "x", + ).join(""); + + await storage.setItem("storage-key", chunkedValue); + + expect(setAllCalls).toEqual([ + { + name: "storage-key.4", + options: { ...DEFAULT_COOKIE_OPTIONS, maxAge: 0 }, + value: "", + }, + { + name: "storage-key.0", + options: { ...DEFAULT_COOKIE_OPTIONS }, + value: chunkedValue.substring(0, MAX_CHUNK_SIZE), + }, + { + name: "storage-key.1", + options: { ...DEFAULT_COOKIE_OPTIONS }, + value: chunkedValue.substring(MAX_CHUNK_SIZE, 2 * MAX_CHUNK_SIZE), + }, + { + name: "storage-key.2", + options: { ...DEFAULT_COOKIE_OPTIONS }, + value: chunkedValue.substring(2 * MAX_CHUNK_SIZE), + }, + ]); + }); + + it("should do chunk management with setAll (more chunks => less chunks case)", async () => { + const setAllCalls: { + name: string; + value: string; + options: CookieOptions; + }[] = []; + + const { storage } = createStorageFromOptions( + { + cookieEncoding: "raw", // to help test readability + cookies: { + getAll: async () => { + return [ + { + name: "random-cookie", + value: "random-value", + }, + { + name: "storage-key.0", + value: "va", + }, + { + name: "storage-key.1", + value: "lu", + }, + { + name: "storage-key.2", + value: "e", + }, + { + name: "storage-key.4", + value: "leftover-chunk-value", + }, + ]; + }, + + setAll: async (setCookies) => { + setAllCalls.push(...setCookies); + }, + }, + }, + false, + ); + + const chunkedValue = Array.from( + { length: MAX_CHUNK_SIZE + 1 }, + () => "x", + ).join(""); + + await storage.setItem("storage-key", chunkedValue); + + expect(setAllCalls).toEqual([ + { + name: "storage-key.2", + options: { ...DEFAULT_COOKIE_OPTIONS, maxAge: 0 }, + value: "", + }, + { + name: "storage-key.4", + options: { ...DEFAULT_COOKIE_OPTIONS, maxAge: 0 }, + value: "", + }, + { + name: "storage-key.0", + options: { ...DEFAULT_COOKIE_OPTIONS }, + value: chunkedValue.substring(0, MAX_CHUNK_SIZE), + }, + { + name: "storage-key.1", + options: { ...DEFAULT_COOKIE_OPTIONS }, + value: chunkedValue.substring(MAX_CHUNK_SIZE), + }, + ]); + }); + + it("should do chunk management with setAll (chunked => non-chunked case)", async () => { + const setAllCalls: { + name: string; + value: string; + options: CookieOptions; + }[] = []; + + const { storage } = createStorageFromOptions( + { + cookieEncoding: "raw", // to help test readability + cookies: { + getAll: async () => { + return [ + { + name: "random-cookie", + value: "random-value", + }, + { + name: "storage-key.0", + value: "va", + }, + { + name: "storage-key.1", + value: "lu", + }, + { + name: "storage-key.2", + value: "e", + }, + { + name: "storage-key.4", + value: "leftover-chunk-value", + }, + ]; + }, + + setAll: async (setCookies) => { + setAllCalls.push(...setCookies); + }, + }, + }, + false, + ); + + await storage.setItem("storage-key", "value"); + + expect(setAllCalls).toEqual([ + { + name: "storage-key.0", + options: { ...DEFAULT_COOKIE_OPTIONS, maxAge: 0 }, + value: "", + }, + { + name: "storage-key.1", + options: { ...DEFAULT_COOKIE_OPTIONS, maxAge: 0 }, + value: "", + }, + { + name: "storage-key.2", + options: { ...DEFAULT_COOKIE_OPTIONS, maxAge: 0 }, + value: "", + }, + { + name: "storage-key.4", + options: { ...DEFAULT_COOKIE_OPTIONS, maxAge: 0 }, + value: "", + }, + { + name: "storage-key", + options: { ...DEFAULT_COOKIE_OPTIONS }, + value: "value", + }, + ]); + }); + }); +}); + +describe("applyServerStorage", () => { + it("should call setAll with the correct cookies for a variety of changes to the storage state", async () => { + const setAllCalls: { + name: string; + value: string; + options: CookieOptions; + }[] = []; + + const { storage, getAll, setAll, setItems, removedItems } = + createStorageFromOptions( + { + cookieEncoding: "raw", // to help test readability + cookies: { + getAll: async () => { + return [ + { + name: "random-cookie", + value: "random-value", + }, + { + name: "storage-key.0", + value: "va", + }, + { + name: "storage-key.1", + value: "lu", + }, + { + name: "storage-key.2", + value: "e", + }, + { + name: "storage-key.4", + value: "leftover-chunk-value", + }, + { + name: "non-chunked", + value: "non-chunked-value", + }, + { + name: "remove-value", + value: "remove", + }, + { + name: "remove-value.0", + value: "remove", + }, + { + name: "remove-value.2", + value: "remove", + }, + ]; + }, + + setAll: async (setCookies) => { + setAllCalls.push(...setCookies); + }, + }, + }, + true, + ); + + const newChunkedValue = Array.from( + { length: MAX_CHUNK_SIZE + 1 }, + () => "x", + ).join(""); + + await storage.setItem("storage-key", newChunkedValue); + await storage.setItem("new-chunked-value", newChunkedValue); + await storage.setItem("new-value", "value"); + await storage.removeItem("remove-value"); + await storage.removeItem("non-existent-value"); + + await applyServerStorage( + { getAll, setAll, setItems, removedItems }, + { + cookieEncoding: "raw", // to help test readability + }, + ); + + expect(setAllCalls).toEqual([ + { + name: "remove-value", + value: "", + options: { ...DEFAULT_COOKIE_OPTIONS, maxAge: 0 }, + }, + { + name: "remove-value.0", + value: "", + options: { ...DEFAULT_COOKIE_OPTIONS, maxAge: 0 }, + }, + { + name: "remove-value.2", + value: "", + options: { ...DEFAULT_COOKIE_OPTIONS, maxAge: 0 }, + }, + { + name: "storage-key.2", + value: "", + options: { ...DEFAULT_COOKIE_OPTIONS, maxAge: 0 }, + }, + { + name: "storage-key.4", + value: "", + options: { ...DEFAULT_COOKIE_OPTIONS, maxAge: 0 }, + }, + + { + name: "storage-key.0", + value: newChunkedValue.substring(0, MAX_CHUNK_SIZE), + options: { ...DEFAULT_COOKIE_OPTIONS }, + }, + { + name: "storage-key.1", + value: newChunkedValue.substring(MAX_CHUNK_SIZE), + options: { ...DEFAULT_COOKIE_OPTIONS }, + }, + { + name: "new-chunked-value.0", + value: newChunkedValue.substring(0, MAX_CHUNK_SIZE), + options: { ...DEFAULT_COOKIE_OPTIONS }, + }, + { + name: "new-chunked-value.1", + value: newChunkedValue.substring(MAX_CHUNK_SIZE), + options: { ...DEFAULT_COOKIE_OPTIONS }, + }, + { + name: "new-value", + value: "value", + options: { ...DEFAULT_COOKIE_OPTIONS }, + }, + ]); + }); +}); diff --git a/src/cookies.ts b/src/cookies.ts new file mode 100644 index 0000000..b520ee1 --- /dev/null +++ b/src/cookies.ts @@ -0,0 +1,470 @@ +import { parse, serialize } from "cookie"; + +import { + DEFAULT_COOKIE_OPTIONS, + combineChunks, + createChunks, + deleteChunks, + isBrowser, + isChunkLike, + stringFromBase64URL, + stringToBase64URL, +} from "./utils"; + +import type { + CookieMethodsServer, + CookieMethodsServerDeprecated, + CookieMethodsBrowser, + CookieMethodsBrowserDeprecated, + CookieOptions, + CookieOptionsWithName, + GetAllCookies, + SetAllCookies, +} from "./types"; + +const BASE64_PREFIX = "base64-"; + +/** + * Creates a storage client that handles cookies correctly for browser and + * server clients with or without properly provided cookie methods. + * + * @param options The options passed to createBrowserClient or createServer client. + * + * @param isServerClient Whether it's called from createServerClient. + */ +export function createStorageFromOptions( + options: { + cookieEncoding: "raw" | "base64url"; + cookies?: + | CookieMethodsBrowser + | CookieMethodsBrowserDeprecated + | CookieMethodsServer + | CookieMethodsServerDeprecated; + cookieOptions?: CookieOptionsWithName; + }, + isServerClient: boolean, +) { + const cookies = options.cookies ?? null; + const cookieEncoding = options.cookieEncoding; + + const setItems: { [key: string]: string } = {}; + const removedItems: { [key: string]: boolean } = {}; + + let getAll: (keyHints: string[]) => ReturnType; + let setAll: SetAllCookies; + + if (cookies) { + if ("get" in cookies) { + // Just get is not enough, because the client needs to see what cookies + // are already set and unset them if necessary. To attempt to fix this + // behavior for most use cases, we pass "hints" which is the keys of the + // storage items. They are then converted to their corresponding cookie + // chunk names and are fetched with get. Only 5 chunks are fetched, which + // should be enough for the majority of use cases, but does not solve + // those with very large sessions. + + const getWithHints = async (keyHints: string[]) => { + // optimistically find the first 5 potential chunks for the specified key + const chunkNames = keyHints.flatMap((keyHint) => [ + keyHint, + ...Array.from({ length: 5 }).map((_, i) => `${keyHint}.${i}`), + ]); + + const chunks: ReturnType = []; + + for (let i = 0; i < chunkNames.length; i += 1) { + const value = await cookies.get(chunkNames[i]); + + if (!value && typeof value !== "string") { + continue; + } + + chunks.push({ name: chunkNames[i], value }); + } + + // TODO: detect and log stale chunks error + + return chunks; + }; + + getAll = async (keyHints: string[]) => await getWithHints(keyHints); + + if ("set" in cookies && "remove" in cookies) { + setAll = async (setCookies) => { + for (let i = 0; i < setCookies.length; i += 1) { + const { name, value, options } = setCookies[i]; + + if (value) { + await cookies.set!(name, value, options); + } else { + await cookies.remove!(name, options); + } + } + }; + } else if (isServerClient) { + setAll = async () => { + console.warn( + "@supabase/ssr: createServerClient was configured without set and remove cookie methods, but the client needs to set cookies. This can lead to issues such as random logouts, early session termination or increased token refresh requests. If in NextJS, check your middleware.ts file, route handlers and server actions for correctness. Consider switching to the getAll and setAll cookie methods instead of get, set and remove which are deprecated and can be difficult to use correctly.", + ); + }; + } else { + throw new Error( + "@supabase/ssr: createBrowserClient requires configuring a getAll and setAll cookie method (deprecated: alternatively both get, set and remove can be used)", + ); + } + } else if ("getAll" in cookies) { + getAll = async () => await cookies.getAll!(); + + if ("setAll" in cookies) { + setAll = cookies.setAll!; + } else if (isServerClient) { + setAll = async () => { + console.warn( + "@supabase/ssr: createServerClient was configured without the setAll cookie method, but the client needs to set cookies. This can lead to issues such as random logouts, early session termination or increased token refresh requests. If in NextJS, check your middleware.ts file, route handlers and server actions for correctness.", + ); + }; + } else { + throw new Error( + "@supabase/ssr: createBrowserClient requires configuring both getAll and setAll cookie methods (deprecated: alternatively both get, set and remove can be used)", + ); + } + } else { + // neither get nor getAll is present on cookies, only will occur if pure JavaScript is used, but cookies is an object + throw new Error( + `@supabase/ssr: ${isServerClient ? "createServerClient" : "createBrowserClient"} requires configuring getAll and setAll cookie methods (deprecated: alternatively use get, set and remove).${isBrowser() ? " As this is called in a browser runtime, consider removing the cookies option object to use the document.cookie API automatically." : ""}`, + ); + } + } else if (!isServerClient && isBrowser()) { + // The environment is browser, so use the document.cookie API to implement getAll and setAll. + + const noHintGetAll = () => { + const parsed = parse(document.cookie); + + return Object.keys(parsed).map((name) => ({ name, value: parsed[name] })); + }; + + getAll = () => noHintGetAll(); + + setAll = (setCookies) => { + setCookies.forEach(({ name, value, options }) => { + document.cookie = serialize(name, value, options); + }); + }; + } else if (isServerClient) { + throw new Error( + "@supabase/ssr: createServerClient must be initialized with cookie options that specify getAll and setAll functions (deprecated, not recommended: alternatively use get, set and remove)", + ); + } else { + throw new Error( + "@supabase/ssr: createBrowserClient in non-browser runtimes must be initialized with cookie options that specify getAll and setAll functions (deprecated: alternatively use get, set and remove)", + ); + } + + if (!isServerClient) { + // This is the storage client to be used in browsers. It only + // works on the cookies abstraction, unlike the server client + // which only uses cookies to read the initial state. When an + // item is set, cookies are both cleared and set to values so + // that stale chunks are not left remaining. + return { + getAll, // for type consistency + setAll, // for type consistency + setItems, // for type consistency + removedItems, // for type consistency + storage: { + isServer: false, + getItem: async (key: string) => { + const allCookies = await getAll([key]); + const chunkedCookie = await combineChunks( + key, + async (chunkName: string) => { + const cookie = + allCookies?.find(({ name }) => name === chunkName) || null; + + if (!cookie) { + return null; + } + + return cookie.value; + }, + ); + + if (!chunkedCookie) { + return null; + } + + let decoded = chunkedCookie; + + if (chunkedCookie.startsWith(BASE64_PREFIX)) { + decoded = stringFromBase64URL( + chunkedCookie.substring(BASE64_PREFIX.length), + ); + } + + return decoded; + }, + setItem: async (key: string, value: string) => { + const allCookies = await getAll([key]); + const cookieNames = allCookies?.map(({ name }) => name) || []; + + const removeCookies = new Set( + cookieNames.filter((name) => isChunkLike(name, key)), + ); + + let encoded = value; + + if (cookieEncoding === "base64url") { + encoded = BASE64_PREFIX + stringToBase64URL(value); + } + + const setCookies = createChunks(key, encoded); + + setCookies.forEach(({ name }) => { + removeCookies.delete(name); + }); + + const removeCookieOptions = { + ...DEFAULT_COOKIE_OPTIONS, + ...options?.cookieOptions, + maxAge: 0, + }; + const setCookieOptions = { + ...DEFAULT_COOKIE_OPTIONS, + ...options?.cookieOptions, + maxAge: DEFAULT_COOKIE_OPTIONS.maxAge, + }; + + // the NextJS cookieStore API can get confused if the `name` from + // options.cookieOptions leaks + delete removeCookieOptions.name; + delete setCookieOptions.name; + + const allToSet = [ + ...[...removeCookies].map((name) => ({ + name, + value: "", + options: removeCookieOptions, + })), + ...setCookies.map(({ name, value }) => ({ + name, + value, + options: setCookieOptions, + })), + ]; + + if (allToSet.length > 0) { + await setAll(allToSet); + } + }, + removeItem: async (key: string) => { + const allCookies = await getAll([key]); + const cookieNames = allCookies?.map(({ name }) => name) || []; + const removeCookies = cookieNames.filter((name) => + isChunkLike(name, key), + ); + + const removeCookieOptions = { + ...DEFAULT_COOKIE_OPTIONS, + ...options?.cookieOptions, + maxAge: 0, + }; + + // the NextJS cookieStore API can get confused if the `name` from + // options.cookieOptions leaks + delete removeCookieOptions.name; + + if (removeCookies.length > 0) { + await setAll( + removeCookies.map((name) => ({ + name, + value: "", + options: removeCookieOptions, + })), + ); + } + }, + }, + }; + } + + // This is the server client. It only uses getAll to read the initial + // state. Any subsequent changes to the items is persisted in the + // setItems and removedItems objects. createServerClient *must* use + // getAll, setAll and the values in setItems and removedItems to + // persist the changes *at once* when appropriate (usually only when + // the TOKEN_REFRESHED, USER_UPDATED or SIGNED_OUT events are fired by + // the Supabase Auth client). + return { + getAll, + setAll, + setItems, + removedItems, + storage: { + // to signal to the libraries that these cookies are + // coming from a server environment and their value + // should not be trusted + isServer: true, + getItem: async (key: string) => { + if (typeof setItems[key] === "string") { + return setItems[key]; + } + + if (removedItems[key]) { + return null; + } + + const allCookies = await getAll([key]); + const chunkedCookie = await combineChunks( + key, + async (chunkName: string) => { + const cookie = + allCookies?.find(({ name }) => name === chunkName) || null; + + if (!cookie) { + return null; + } + + return cookie.value; + }, + ); + + if (!chunkedCookie) { + return null; + } + + let decoded = chunkedCookie; + + if (chunkedCookie.startsWith(BASE64_PREFIX)) { + decoded = stringFromBase64URL( + chunkedCookie.substring(BASE64_PREFIX.length), + ); + } + + return decoded; + }, + setItem: async (key: string, value: string) => { + // We don't have an `onAuthStateChange` event that can let us know that + // the PKCE code verifier is being set. Therefore, if we see it being + // set, we need to apply the storage (call `setAll` so the cookie is + // set properly). + if (key.endsWith("-code-verifier")) { + await applyServerStorage( + { + getAll, + setAll, + // pretend only that the code verifier was set + setItems: { [key]: value }, + // pretend that nothing was removed + removedItems: {}, + }, + { + cookieOptions: options?.cookieOptions ?? null, + cookieEncoding, + }, + ); + } + + setItems[key] = value; + delete removedItems[key]; + }, + removeItem: async (key: string) => { + // Intentionally not applying the storage when the key is the PKCE code + // verifier, as usually right after it's removed other items are set, + // so application of the storage will be handled by the + // `onAuthStateChange` callback that follows removal -- usually as part + // of the `exchangeCodeForSession` call. + delete setItems[key]; + removedItems[key] = true; + }, + }, + }; +} + +/** + * When createServerClient needs to apply the created storage to cookies, it + * should call this function which handles correcly setting cookies for stored + * and removed items in the storage. + */ +export async function applyServerStorage( + { + getAll, + setAll, + setItems, + removedItems, + }: { + getAll: (keyHints: string[]) => ReturnType; + setAll: SetAllCookies; + setItems: { [name: string]: string }; + removedItems: { [name: string]: boolean }; + }, + options: { + cookieEncoding: "raw" | "base64url"; + cookieOptions?: CookieOptions | null; + }, +) { + const cookieEncoding = options.cookieEncoding; + const cookieOptions = options.cookieOptions ?? null; + + const allCookies = await getAll([ + ...(setItems ? (Object.keys(setItems) as string[]) : []), + ...(removedItems ? (Object.keys(removedItems) as string[]) : []), + ]); + const cookieNames = allCookies?.map(({ name }) => name) || []; + + const removeCookies: string[] = Object.keys(removedItems).flatMap( + (itemName) => { + return cookieNames.filter((name) => isChunkLike(name, itemName)); + }, + ); + + const setCookies = Object.keys(setItems).flatMap((itemName) => { + const removeExistingCookiesForItem = new Set( + cookieNames.filter((name) => isChunkLike(name, itemName)), + ); + + let encoded = setItems[itemName]; + + if (cookieEncoding === "base64url") { + encoded = BASE64_PREFIX + stringToBase64URL(encoded); + } + + const chunks = createChunks(itemName, encoded); + + chunks.forEach((chunk) => { + removeExistingCookiesForItem.delete(chunk.name); + }); + + removeCookies.push(...removeExistingCookiesForItem); + + return chunks; + }); + + const removeCookieOptions = { + ...DEFAULT_COOKIE_OPTIONS, + ...cookieOptions, + maxAge: 0, + }; + const setCookieOptions = { + ...DEFAULT_COOKIE_OPTIONS, + ...cookieOptions, + maxAge: DEFAULT_COOKIE_OPTIONS.maxAge, + }; + + // the NextJS cookieStore API can get confused if the `name` from + // options.cookieOptions leaks + delete (removeCookieOptions as any).name; + delete (setCookieOptions as any).name; + + await setAll([ + ...removeCookies.map((name) => ({ + name, + value: "", + options: removeCookieOptions, + })), + ...setCookies.map(({ name, value }) => ({ + name, + value, + options: setCookieOptions, + })), + ]); +} diff --git a/src/createBrowserClient.spec.ts b/src/createBrowserClient.spec.ts new file mode 100644 index 0000000..e6ab92e --- /dev/null +++ b/src/createBrowserClient.spec.ts @@ -0,0 +1,19 @@ +import { describe, it, expect } from "vitest"; + +import { MAX_CHUNK_SIZE, stringToBase64URL } from "./utils"; +import { CookieOptions } from "./types"; +import { createBrowserClient } from "./createBrowserClient"; + +describe("createServerClient", () => { + describe("validation", () => { + it("should throw an error on empty URL and anon key", async () => { + expect(() => { + createBrowserClient("URL", ""); + }).toThrow(); + + expect(() => { + createBrowserClient("", "anon key"); + }).toThrow(); + }); + }); +}); diff --git a/src/createBrowserClient.ts b/src/createBrowserClient.ts index 94d80bc..8ba5dfc 100644 --- a/src/createBrowserClient.ts +++ b/src/createBrowserClient.ts @@ -1,8 +1,5 @@ -import { createClient } from "@supabase/supabase-js"; -import { mergeDeepRight } from "ramda"; import { parse, serialize } from "cookie"; - -import type { SupabaseClient } from "@supabase/supabase-js"; +import { createClient, SupabaseClient } from "@supabase/supabase-js"; import type { GenericSchema, SupabaseClientOptions, @@ -15,10 +12,81 @@ import { createChunks, deleteChunks, isBrowser, + isChunkLike, } from "./utils"; -import type { CookieMethods, CookieOptionsWithName } from "./types"; -let cachedBrowserClient: SupabaseClient | undefined; +import type { + CookieMethodsBrowser, + CookieMethodsBrowserDeprecated, + CookieOptions, + CookieOptionsWithName, + GetAllCookies, + SetAllCookies, +} from "./types"; + +import { createStorageFromOptions } from "./cookies"; + +let cachedBrowserClient: SupabaseClient | undefined; + +/** + * @deprecated Please specify `getAll` and `setAll` cookie methods instead of + * the `get`, `set` and `remove`. These will not be supported in the next major + * version. + */ +export function createBrowserClient< + Database = any, + SchemaName extends string & keyof Database = "public" extends keyof Database + ? "public" + : string & keyof Database, + Schema extends GenericSchema = Database[SchemaName] extends GenericSchema + ? Database[SchemaName] + : any, +>( + supabaseUrl: string, + supabaseKey: string, + options?: SupabaseClientOptions & { + cookies: CookieMethodsBrowserDeprecated; + cookieOptions?: CookieOptionsWithName; + cookieEncoding?: "raw" | "base64url"; + isSingleton?: boolean; + }, +): SupabaseClient; + +/** + * Creates a Supabase Client for use in a browser environment. + * + * In most cases you should not configure the `options.cookies` object, as this + * is automatically handled for you. If you do customize this, prefer using the + * `getAll` and `setAll` functions over `get`, `set` and `remove`. The latter + * are deprecated due to being difficult to correctly implement and not + * supporting some edge-cases. Both `getAll` and `setAll` (or both `get`, `set` + * and `remove`) must be provided. Failing to provide the methods for setting + * will throw an exception, and in previous versions of the library will result + * in difficult to debug authentication issues such as random logouts, early + * session termination or problems with inconsistent state. + * + * @param supabaseUrl The URL of the Supabase project. + * @param supabaseKey The `anon` API key of the Supabase project. + * @param options Various configuration options. + */ +export function createBrowserClient< + Database = any, + SchemaName extends string & keyof Database = "public" extends keyof Database + ? "public" + : string & keyof Database, + Schema extends GenericSchema = Database[SchemaName] extends GenericSchema + ? Database[SchemaName] + : any, +>( + supabaseUrl: string, + supabaseKey: string, + options?: SupabaseClientOptions & { + cookies?: CookieMethodsBrowser; + cookieOptions?: CookieOptionsWithName; + cookieEncoding?: "raw" | "base64url"; + isSingleton?: boolean; + }, +): SupabaseClient; export function createBrowserClient< Database = any, @@ -32,180 +100,64 @@ export function createBrowserClient< supabaseUrl: string, supabaseKey: string, options?: SupabaseClientOptions & { - cookies?: CookieMethods; + cookies?: CookieMethodsBrowser | CookieMethodsBrowserDeprecated; cookieOptions?: CookieOptionsWithName; + cookieEncoding?: "raw" | "base64url"; isSingleton?: boolean; }, -) { +): SupabaseClient { + // singleton client is created only if isSingleton is set to true, or if isSingleton is not defined and we detect a browser + const shouldUseSingleton = + options?.isSingleton === true || + ((!options || !("isSingleton" in options)) && isBrowser()); + + if (shouldUseSingleton && cachedBrowserClient) { + return cachedBrowserClient; + } + if (!supabaseUrl || !supabaseKey) { throw new Error( - `Your project's URL and Key are required to create a Supabase client!\n\nCheck your Supabase project's API settings to find these values\n\nhttps://supabase.com/dashboard/project/_/settings/api`, + `@supabase/ssr: Your project's URL and API key are required to create a Supabase client!\n\nCheck your Supabase project's API settings to find these values\n\nhttps://supabase.com/dashboard/project/_/settings/api`, ); } - let cookies: CookieMethods = {}; - let isSingleton = true; - let cookieOptions: CookieOptionsWithName | undefined; - let userDefinedClientOptions; - - if (options) { - ({ - cookies = {}, - isSingleton = true, - cookieOptions, - ...userDefinedClientOptions - } = options); - cookies = cookies || {}; - } - - if (cookieOptions?.name) { - userDefinedClientOptions.auth = { - ...userDefinedClientOptions.auth, - storageKey: cookieOptions.name, - }; - } - - const deleteAllChunks = async (key: string) => { - await deleteChunks( - key, - async (chunkName) => { - if (typeof cookies.get === "function") { - return await cookies.get(chunkName); - } - if (isBrowser()) { - const documentCookies = parse(document.cookie); - return documentCookies[chunkName]; - } - }, - async (chunkName) => { - if (typeof cookies.remove === "function") { - await cookies.remove(chunkName, { - ...DEFAULT_COOKIE_OPTIONS, - ...cookieOptions, - maxAge: 0, - }); - } else { - if (isBrowser()) { - document.cookie = serialize(chunkName, "", { - ...DEFAULT_COOKIE_OPTIONS, - ...cookieOptions, - maxAge: 0, - }); - } - } - }, - ); - }; - - const cookieClientOptions = { - global: { - headers: { - "X-Client-Info": `supabase-ssr/${VERSION}`, - }, + const { storage } = createStorageFromOptions( + { + ...options, + cookieEncoding: options?.cookieEncoding ?? "base64url", }, - auth: { - flowType: "pkce", - autoRefreshToken: isBrowser(), - detectSessionInUrl: isBrowser(), - persistSession: true, - storage: { - // this client is used on the browser so cookies can be trusted - isServer: false, - getItem: async (key: string) => { - const chunkedCookie = await combineChunks(key, async (chunkName) => { - if (typeof cookies.get === "function") { - return await cookies.get(chunkName); - } - if (isBrowser()) { - const cookie = parse(document.cookie); - return cookie[chunkName]; - } - }); - return chunkedCookie; - }, - setItem: async (key: string, value: string) => { - // first remove all chunks so there is no overlap - await deleteAllChunks(key); - - const chunks = await createChunks(key, value); - - for (let i = 0; i < chunks.length; i += 1) { - const chunk = chunks[i]; - - if (typeof cookies.set === "function") { - await cookies.set(chunk.name, chunk.value, { - ...DEFAULT_COOKIE_OPTIONS, - ...cookieOptions, - maxAge: DEFAULT_COOKIE_OPTIONS.maxAge, - }); - } else { - if (isBrowser()) { - document.cookie = serialize(chunk.name, chunk.value, { - ...DEFAULT_COOKIE_OPTIONS, - ...cookieOptions, - maxAge: DEFAULT_COOKIE_OPTIONS.maxAge, - }); - } - } - } - }, - removeItem: async (key: string) => { - if ( - typeof cookies.remove === "function" && - typeof cookies.get !== "function" - ) { - console.log( - "Removing chunked cookie without a `get` method is not supported.\n\n\tWhen you call the `createBrowserClient` function from the `@supabase/ssr` package, make sure you declare both a `get` and `remove` method on the `cookies` object.\n\nhttps://supabase.com/docs/guides/auth/server-side/creating-a-client", - ); - return; - } + false, + ); - await deleteAllChunks(key); + const client = createClient( + supabaseUrl, + supabaseKey, + { + ...options, + global: { + ...options?.global, + headers: { + ...options?.global?.headers, + "X-Client-Info": `supabase-ssr/${VERSION}`, }, }, + auth: { + ...options?.auth, + ...(options?.cookieOptions?.name + ? { storageKey: options.cookieOptions.name } + : null), + flowType: "pkce", + autoRefreshToken: isBrowser(), + detectSessionInUrl: isBrowser(), + persistSession: true, + storage, + }, }, - }; - - // Overwrites default client config with any user defined options - const clientOptions = mergeDeepRight( - cookieClientOptions, - userDefinedClientOptions, - ) as SupabaseClientOptions; - - if (isSingleton) { - // The `Singleton` pattern is the default to simplify the instantiation - // of a Supabase client in the browser - there must only be one - - const browser = isBrowser(); - - if (browser && cachedBrowserClient) { - return cachedBrowserClient as SupabaseClient< - Database, - SchemaName, - Schema - >; - } - - const client = createClient( - supabaseUrl, - supabaseKey, - clientOptions, - ); - - if (browser) { - // The client should only be cached in the browser - cachedBrowserClient = client; - } + ); - return client; + if (shouldUseSingleton) { + cachedBrowserClient = client; } - // This allows for multiple Supabase clients, which may be required when using - // multiple schemas. The user will be responsible for ensuring a single - // instance of Supabase is used for each schema in the browser. - return createClient( - supabaseUrl, - supabaseKey, - clientOptions, - ); + return client; } diff --git a/src/createServerClient.spec.ts b/src/createServerClient.spec.ts new file mode 100644 index 0000000..21d612d --- /dev/null +++ b/src/createServerClient.spec.ts @@ -0,0 +1,383 @@ +import { describe, it, expect } from "vitest"; + +import { MAX_CHUNK_SIZE, stringToBase64URL } from "./utils"; +import { CookieOptions } from "./types"; +import { createServerClient } from "./createServerClient"; + +describe("createServerClient", () => { + describe("validation", () => { + it("should throw an error on empty URL and anon key", async () => { + expect(() => { + createServerClient("URL", "", { + cookies: { + getAll() { + return []; + }, + + setAll(cookiesToSet) { + // no-op + }, + }, + }); + }).toThrow(); + + expect(() => { + createServerClient("", "anon key", { + cookies: { + getAll() { + return []; + }, + + setAll(cookiesToSet) { + // no-op + }, + }, + }); + }).toThrow(); + }); + }); + + describe("use cases", () => { + const storageKeys = [null, "custom-storage-key"]; + + storageKeys.forEach((storageKey) => { + it(`should set PKCE code verifier correctly (storage key = ${storageKey})`, async () => { + let setAllCalls = 0; + let getAllCalls = 0; + + const setCookies: { + name: string; + value: string; + options: CookieOptions; + }[] = []; + + const supabase = createServerClient( + "https://project-ref.supabase.co", + "anon-key", + { + ...(storageKey ? { cookieOptions: { name: storageKey } } : null), + cookies: { + getAll() { + getAllCalls += 1; + + return []; + }, + + setAll(cookiesToSet) { + setAllCalls += 1; + setCookies.push(...cookiesToSet); + }, + }, + + global: { + fetch: async (a: any, b?: any) => { + throw new Error("Should not be called"); + }, + }, + }, + ); + + const { + data: { url }, + error, + } = await supabase.auth.signInWithOAuth({ + provider: "google", + options: { skipBrowserRedirect: true }, + }); + + expect(error).toBeNull(); + expect(setAllCalls).toEqual(1); + + // change cookie values to a fixed value so snapshots don't change due to randomness + setCookies.forEach((obj) => { + if (typeof obj.value === "string") { + obj.value = ""; + } + }); + + expect(setCookies).toMatchSnapshot(); + }); + + it(`should set exchange PKCE code for session correctly (storage key = ${storageKey})`, async () => { + let setAllCalls = 0; + let getAllCalls = 0; + + const setCookies: { + name: string; + value: string; + options: CookieOptions; + }[] = []; + + const supabase = createServerClient( + "https://project-ref.supabase.co", + "anon-key", + { + ...(storageKey ? { cookieOptions: { name: storageKey } } : null), + cookies: { + getAll() { + getAllCalls += 1; + + return [ + { + name: storageKey + ? `${storageKey}-code-verifier` + : "sb-project-ref-auth-token-code-verifier", + value: "", + }, + ]; + }, + + setAll(cookiesToSet) { + setAllCalls += 1; + setCookies.push(...cookiesToSet); + }, + }, + + global: { + fetch: async (a: any, b?: any) => { + if ( + a.endsWith("/token?grant_type=pkce") && + b.method === "POST" + ) { + return new Response( + JSON.stringify({ + expires_in: 3600, + expires_at: Math.floor( + new Date("2037-01-01T00:00:00Z").getTime() / 1000.0, + ), // to make sure the snapshot does not change with Date.now() + access_token: "", + refresh_token: "", + user: { + id: "", + // to force chunking + email: + Array.from( + { length: MAX_CHUNK_SIZE }, + () => "x", + ).join("") + "@example.com", + }, + }), + { + status: 200, + headers: { + "Content-Type": "application/json; charset=UTF-8", + }, + }, + ); + } else { + throw new Error("Bad mock!"); + } + }, + }, + }, + ); + + const { data, error } = + await supabase.auth.exchangeCodeForSession(""); + + expect(error).toBeNull(); + expect(setAllCalls).toEqual(1); + expect(setCookies).toMatchSnapshot(); + expect(data).toMatchSnapshot(); + }); + + it(`should refresh session correctly as typically used in middlewares (storage key = ${storageKey})`, async () => { + let setAllCalls = 0; + let getAllCalls = 0; + + const setCookies: { + name: string; + value: string; + options: CookieOptions; + }[] = []; + + const supabase = createServerClient( + "https://project-ref.supabase.co", + "anon-key", + { + ...(storageKey ? { cookieOptions: { name: storageKey } } : null), + cookies: { + getAll() { + getAllCalls += 1; + + return [ + { + name: storageKey ? storageKey : "sb-project-ref-auth-token", + value: + "base64-" + + stringToBase64URL( + JSON.stringify({ + token_type: "bearer", + access_token: "", + refresh_token: "", + expires_at: Math.floor(Date.now() / 1000), + expires_in: 0, + user: { + id: "", + }, + }), + ), + }, + ]; + }, + + setAll(cookiesToSet) { + setAllCalls += 1; + setCookies.push(...cookiesToSet); + }, + }, + + global: { + fetch: async (a: any, b?: any) => { + if (typeof a !== "string" && typeof b !== "object") { + throw new Error("Bad mock!"); + } + + if ( + a.endsWith("/token?grant_type=refresh_token") && + b.method === "POST" + ) { + return new Response( + JSON.stringify({ + expires_in: 3600, + expires_at: Math.floor( + new Date("2037-01-01T00:00:00Z").getTime() / 1000.0, + ), // to make sure the snapshot does not change with Date.now() + access_token: "", + refresh_token: "", + user: { + id: "", + // to force chunking + email: + Array.from( + { length: MAX_CHUNK_SIZE }, + () => "x", + ).join("") + "@example.com", + }, + }), + { + status: 200, + headers: { + "Content-Type": "application/json; charset=UTF-8", + }, + }, + ); + } else if (a.endsWith("/user") && b.method === "GET") { + return new Response( + JSON.stringify({ + id: "", + }), + { + status: 200, + headers: { + "Content-Type": "application/json; charset=UTF-8", + }, + }, + ); + } else { + throw new Error("Bad mock!"); + } + }, + }, + }, + ); + + const { + data: { user }, + error, + } = await supabase.auth.getUser(); + + expect(error).toBeNull(); + expect(getAllCalls > 1).toBeTruthy(); + expect(setAllCalls).toEqual(1); + + expect(user).toMatchSnapshot(); + expect(setCookies).toMatchSnapshot(); + }); + + it(`should not set cookies if the session does not need to be refreshed (storage key = ${storageKey})`, async () => { + let setAllCalls = 0; + let getAllCalls = 0; + + const supabase = createServerClient( + "https://project-ref.supabase.co", + "anon-key", + { + ...(storageKey ? { cookieOptions: { name: storageKey } } : null), + cookies: { + getAll() { + getAllCalls += 1; + + return [ + { + name: storageKey ? storageKey : "sb-project-ref-auth-token", + value: + "base64-" + + stringToBase64URL( + JSON.stringify({ + token_type: "bearer", + access_token: "", + refresh_token: "", + expires_at: Math.floor(Date.now() / 1000) + 5 * 60, // expires in 5 mins + expires_in: 5 * 60, + user: { + id: "", + }, + }), + ), + }, + ]; + }, + + setAll(cookiesToSet) { + setAllCalls += 1; + }, + }, + + global: { + fetch: async (a: any, b?: any) => { + if (typeof a !== "string" && typeof b !== "object") { + throw new Error("Bad mock!"); + } + + if ( + a.endsWith("/token?grant_type=refresh_token") && + b.method === "POST" + ) { + throw new Error( + "Refreshing the session should not take place!", + ); + } else if (a.endsWith("/user") && b.method === "GET") { + return new Response( + JSON.stringify({ + id: "", + }), + { + status: 200, + headers: { + "Content-Type": "application/json; charset=UTF-8", + }, + }, + ); + } else { + throw new Error("Bad mock!"); + } + }, + }, + }, + ); + + const { + data: { user }, + error, + } = await supabase.auth.getUser(); + + expect(getAllCalls > 1).toBeTruthy(); + expect(setAllCalls).toEqual(0); + + expect(error).toBeNull(); + expect(user).toMatchSnapshot(); + }); + }); + }); +}); diff --git a/src/createServerClient.ts b/src/createServerClient.ts index 7685f03..c424a58 100644 --- a/src/createServerClient.ts +++ b/src/createServerClient.ts @@ -1,21 +1,109 @@ -import { createClient } from "@supabase/supabase-js"; -import { mergeDeepRight } from "ramda"; +import { createClient, SupabaseClient } from "@supabase/supabase-js"; +import type { + GenericSchema, + SupabaseClientOptions, +} from "@supabase/supabase-js/dist/module/lib/types"; + +import { VERSION } from "./version"; import { DEFAULT_COOKIE_OPTIONS, combineChunks, createChunks, deleteChunks, isBrowser, + isChunkLike, } from "./utils"; - +import { createStorageFromOptions, applyServerStorage } from "./cookies"; import type { - GenericSchema, - SupabaseClientOptions, -} from "@supabase/supabase-js/dist/module/lib/types"; + CookieOptionsWithName, + CookieMethodsServer, + CookieMethodsServerDeprecated, +} from "./types"; -import { VERSION } from "./version"; -import type { CookieOptionsWithName, CookieMethods } from "./types"; +/** + * @deprecated Please specify `getAll` and `setAll` cookie methods instead of + * the `get`, `set` and `remove`. These will not be supported in the next major + * version. + */ +export function createServerClient< + Database = any, + SchemaName extends string & keyof Database = "public" extends keyof Database + ? "public" + : string & keyof Database, + Schema extends GenericSchema = Database[SchemaName] extends GenericSchema + ? Database[SchemaName] + : any, +>( + supabaseUrl: string, + supabaseKey: string, + options: SupabaseClientOptions & { + cookieOptions?: CookieOptionsWithName; + cookies: CookieMethodsServerDeprecated; + cookieEncoding?: "raw" | "base64url"; + }, +): SupabaseClient; +/** + * Creates a Supabase Client for use on the server-side of a server-side + * rendering (SSR) framework. + * + * There are two categories of uses for this function: use in middlewares and + * use in pages, components or routes. + * + * **Use in middlewares.** + * + * Middlewares are functions that run before any rendering logic is executed on + * the server-side. They typically have access to request headers (cookies) and + * can modify both the request and response headers. + * + * In most SSR frameworks, to use Supabase correctly you *must set up a + * middleware* and use this function in it. + * + * When using this in a middleware, the `cookie` option must be configured to + * use both `getAll` and `setAll`. Alternatively you can use the `get`, `set` + * and `remove` functions. The latter are deprecated **and not recommended** + * for most use cases due to being difficult to use properly and they do not + * cover important edge cases. In future major versions of the library, the + * option to configure `get`, `set` and `remove` will be removed. + * + * **IMPORTANT:** Failing to implement `getAll` and `setAll` correctly (or the + * deprecated `get`, `set` and `remove`) including omitting them **will cause + * significant and difficult to debug authentication issues**. They will + * manifest as: random logouts, early session termination, JSON parsing errors, + * increased number of refresh token requests, or relying on garbage state. + * + * **Use in pages, components or routes.** + * + * To use Supabase features server-side rendered in pages, components or routes + * (a.k.a. actions / APIs) you must create a client with this funciton. Not all + * frameworks allow the ability to set cookies or response headers when pages + * or components are rendered. In those cases you _can omit `setAll` (or the + * deprecated `set`, `remove`) cookie option methods_. **It is strongly + * recommended that if the ability to set cookies and response headers is + * present, you should configure the `setAll` (or the deprecated `set` and + * `remove`) cookie access methods.** + * + * **IMPORTANT:** If the ability to set cookies or response headers is not + * available **middleware or an equivalent must be used.** Failing to do this + * will cause significant and difficult to debug authentication issues. + * + * When `setAll` (or the deprecated `set`, `remove`) cookie methods are not + * configured, the Supabase Client will emit a warning if it is used in a way + * that requires setting cookies. If you see this warning, it usually means + * that you are using the Supabase Client in a wrong way: + * + * - You should have, but did not configure a middleware client. + * - There is a bug in your middleware function. + * - You are using features of the Supabase Client that change the User, e.g. + * by calling `supabase.auth.updateUser()` on the server. + * + * Please consult the latest Supabase guides for advice on how to avoid common + * pitfalls depending on SSR framework. + * + * @param supabaseUrl The URL of the Supabase project. + * @param supabaseKey The `anon` API key of the Supabase project. + * @param options Various configuration options. + */ export function createServerClient< Database = any, SchemaName extends string & keyof Database = "public" extends keyof Database @@ -28,115 +116,94 @@ export function createServerClient< supabaseUrl: string, supabaseKey: string, options: SupabaseClientOptions & { - cookies: CookieMethods; cookieOptions?: CookieOptionsWithName; + cookies: CookieMethodsServer; + cookieEncoding?: "raw" | "base64url"; }, -) { +): SupabaseClient; + +export function createServerClient< + Database = any, + SchemaName extends string & keyof Database = "public" extends keyof Database + ? "public" + : string & keyof Database, + Schema extends GenericSchema = Database[SchemaName] extends GenericSchema + ? Database[SchemaName] + : any, +>( + supabaseUrl: string, + supabaseKey: string, + options: SupabaseClientOptions & { + cookieOptions?: CookieOptionsWithName; + cookies: CookieMethodsServer | CookieMethodsServerDeprecated; + cookieEncoding?: "raw" | "base64url"; + }, +): SupabaseClient { if (!supabaseUrl || !supabaseKey) { throw new Error( `Your project's URL and Key are required to create a Supabase client!\n\nCheck your Supabase project's API settings to find these values\n\nhttps://supabase.com/dashboard/project/_/settings/api`, ); } - const { cookies, cookieOptions, ...userDefinedClientOptions } = options; - - // use the cookie name as the storageKey value if it's set - if (cookieOptions?.name) { - userDefinedClientOptions.auth = { - ...userDefinedClientOptions.auth, - storageKey: cookieOptions.name, - }; - } - - const deleteAllChunks = async (key: string) => { - await deleteChunks( - key, - async (chunkName) => { - if (typeof cookies.get === "function") { - return await cookies.get(chunkName); - } - }, - async (chunkName) => { - if (typeof cookies.remove === "function") { - return await cookies.remove(chunkName, { - ...DEFAULT_COOKIE_OPTIONS, - ...cookieOptions, - maxAge: 0, - }); - } + const { storage, getAll, setAll, setItems, removedItems } = + createStorageFromOptions( + { + ...options, + cookieEncoding: options?.cookieEncoding ?? "base64url", }, + true, ); - }; - const cookieClientOptions = { - global: { - headers: { - "X-Client-Info": `supabase-ssr/${VERSION}`, + const client = createClient( + supabaseUrl, + supabaseKey, + { + ...options, + global: { + ...options?.global, + headers: { + ...options?.global?.headers, + "X-Client-Info": `supabase-ssr/${VERSION}`, + }, + }, + auth: { + ...(options?.cookieOptions?.name + ? { storageKey: options.cookieOptions.name } + : null), + ...options?.auth, + flowType: "pkce", + autoRefreshToken: false, + detectSessionInUrl: false, + persistSession: true, + storage, }, }, - auth: { - flowType: "pkce", - autoRefreshToken: isBrowser(), - detectSessionInUrl: isBrowser(), - persistSession: true, - storage: { - // to signal to the libraries that these cookies are coming from a server environment and their value should not be trusted - isServer: true, - getItem: async (key: string) => { - const chunkedCookie = await combineChunks( - key, - async (chunkName: string) => { - if (typeof cookies.get === "function") { - return await cookies.get(chunkName); - } - }, - ); - return chunkedCookie; - }, - setItem: async (key: string, value: string) => { - if (typeof cookies.set === "function") { - // first delete all chunks so that there would be no overlap - await deleteAllChunks(key); - - const chunks = createChunks(key, value); - - for (let i = 0; i < chunks.length; i += 1) { - const chunk = chunks[i]; + ); - await cookies.set(chunk.name, chunk.value, { - ...DEFAULT_COOKIE_OPTIONS, - ...cookieOptions, - maxAge: DEFAULT_COOKIE_OPTIONS.maxAge, - }); - } - } - }, - removeItem: async (key: string) => { - if ( - typeof cookies.remove === "function" && - typeof cookies.get !== "function" - ) { - console.log( - "Removing chunked cookie without a `get` method is not supported.\n\n\tWhen you call the `createServerClient` function from the `@supabase/ssr` package, make sure you declare both a `get` and `remove` method on the `cookies` object.\n\nhttps://supabase.com/docs/guides/auth/server-side/creating-a-client", - ); - return; - } + client.auth.onAuthStateChange(async (event) => { + // The SIGNED_IN event is fired very often, but we don't need to + // apply the storage each time it fires, only if there are changes + // that need to be set -- which is if setItems / removeItems have + // data. + const hasStorageChanges = + Object.keys(setItems).length > 0 || Object.keys(removedItems).length > 0; - await deleteAllChunks(key); + if ( + hasStorageChanges && + (event === "SIGNED_IN" || + event === "TOKEN_REFRESHED" || + event === "USER_UPDATED" || + event === "SIGNED_OUT") + ) { + await applyServerStorage( + { getAll, setAll, setItems, removedItems }, + { + cookieOptions: options?.cookieOptions ?? null, + cookieEncoding: options?.cookieEncoding ?? "base64url", }, - }, - }, - }; - - // Overwrites default client config with any user defined options - const clientOptions = mergeDeepRight( - cookieClientOptions, - userDefinedClientOptions, - ) as SupabaseClientOptions; + ); + } + }); - return createClient( - supabaseUrl, - supabaseKey, - clientOptions, - ); + return client; } diff --git a/src/types.ts b/src/types.ts index 00063e1..043f363 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2,14 +2,48 @@ import type { CookieSerializeOptions } from "cookie"; export type CookieOptions = Partial; export type CookieOptionsWithName = { name?: string } & CookieOptions; -export type CookieMethods = { - get?: ( - key: string, - ) => Promise | string | null | undefined; - set?: ( - key: string, - value: string, - options: CookieOptions, - ) => Promise | void; - remove?: (key: string, options: CookieOptions) => Promise | void; + +export type GetCookie = ( + name: string, +) => Promise | string | null | undefined; + +export type SetCookie = ( + name: string, + value: string, + options: CookieOptions, +) => Promise | void; +export type RemoveCookie = ( + name: string, + options: CookieOptions, +) => Promise | void; + +export type GetAllCookies = () => + | Promise<{ name: string; value: string }[] | null> + | { name: string; value: string }[] + | null; + +export type SetAllCookies = ( + cookies: { name: string; value: string; options: CookieOptions }[], +) => Promise | void; + +export type CookieMethodsBrowserDeprecated = { + get: GetCookie; + set: SetCookie; + remove: RemoveCookie; +}; + +export type CookieMethodsBrowser = { + getAll: GetAllCookies; + setAll: SetAllCookies; +}; + +export type CookieMethodsServerDeprecated = { + get: GetCookie; + set?: SetCookie; + remove?: RemoveCookie; +}; + +export type CookieMethodsServer = { + getAll: GetAllCookies; + setAll?: SetAllCookies; }; diff --git a/src/utils/base64url.test.ts b/src/utils/base64url.test.ts new file mode 100644 index 0000000..4a38601 --- /dev/null +++ b/src/utils/base64url.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, test } from "vitest"; +import { + stringToBase64URL, + stringFromBase64URL, + stringFromUTF8, + codepointToUTF8, +} from "./base64url"; + +const EXAMPLES = [ + "a", + "ab", + "abc", + "abcd", + "hello world", + "нешто на кирилица", + "something with emojis 🤙🏾 ", + "Supabaseは、オープンソースの Firebase 代替製品です。エンタープライズグレードのオープンソースツールを使って、Firebase の機能を構築しています。", +]; + +describe("stringToBase64URL", () => { + EXAMPLES.forEach((example) => { + test(`encode "${example}"`, () => { + expect(stringToBase64URL(example)).toEqual( + Buffer.from(example).toString("base64url"), + ); + }); + }); +}); + +describe("stringFromBase64URL", () => { + EXAMPLES.forEach((example) => { + test(`decode "${example}"`, () => { + expect( + stringFromBase64URL( + "\r\t\n " + Buffer.from(example).toString("base64url"), + ), + ).toEqual(example); + }); + }); + + test("decode with invalid Base64-URL character", () => { + expect(() => { + stringFromBase64URL("*"); + }).toThrow(new Error(`Invalid Base64-URL character "*" at position 0`)); + }); +}); + +const BAD_UTF8 = [ + [0xf8], // 11111000 + [0xff], // 11111111 + [0x80], // 10000000 + [0xf8, 1], // 11110000 00000001 + [0xe0, 1], // 11100000 00000001 + [0xc0, 1], // 11100000 00000001 +]; + +describe("stringFromUTF8", () => { + BAD_UTF8.forEach((example) => { + test(`should recognize bad UTF-8 sequence ${example.map((x) => x.toString(16)).join(" ")}`, () => { + expect(() => { + const state = { utf8seq: 0, codepoint: 0 }; + example.forEach((byte) => { + stringFromUTF8(byte, state, () => {}); + }); + }).toThrow(new Error("Invalid UTF-8 sequence")); + }); + }); +}); + +describe("codepointToUTF8", () => { + test("invalid codepoints above 0x10ffff", () => { + const invalidCodepoint = 0x10ffff + 1; + expect(() => { + codepointToUTF8(invalidCodepoint, () => { + throw new Error("Should not becalled"); + }); + }).toThrow( + new Error( + `Unrecognized Unicode codepoint: ${invalidCodepoint.toString(16)}`, + ), + ); + }); +}); diff --git a/src/utils/base64url.ts b/src/utils/base64url.ts new file mode 100644 index 0000000..6e4bca4 --- /dev/null +++ b/src/utils/base64url.ts @@ -0,0 +1,236 @@ +/** + * Avoid modifying this file. It's part of + * https://github.com/supabase-community/base64url-js. Submit all fixes on + * that repo! + */ + +/** + * An array of characters that encode 6 bits into a Base64-URL alphabet + * character. + */ +const TO_BASE64URL = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_".split(""); + +/** + * An array of characters that can appear in a Base64-URL encoded string but + * should be ignored. + */ +const IGNORE_BASE64URL = " \t\n\r=".split(""); + +/** + * An array of 128 numbers that map a Base64-URL character to 6 bits, or if -2 + * used to skip the character, or if -1 used to error out. + */ +const FROM_BASE64URL = (() => { + const charMap: number[] = new Array(128); + + for (let i = 0; i < charMap.length; i += 1) { + charMap[i] = -1; + } + + for (let i = 0; i < IGNORE_BASE64URL.length; i += 1) { + charMap[IGNORE_BASE64URL[i].charCodeAt(0)] = -2; + } + + for (let i = 0; i < TO_BASE64URL.length; i += 1) { + charMap[TO_BASE64URL[i].charCodeAt(0)] = i; + } + + return charMap; +})(); + +/** + * Converts a JavaScript string (which may include any valid character) into a + * Base64-URL encoded string. The string is first encoded in UTF-8 which is + * then encoded as Base64-URL. + * + * @param str The string to convert. + */ +export function stringToBase64URL(str: string) { + const base64: string[] = []; + + let queue = 0; + let queuedBits = 0; + + const emitter = (byte: number) => { + queue = (queue << 8) | byte; + queuedBits += 8; + + while (queuedBits >= 6) { + const pos = (queue >> (queuedBits - 6)) & 63; + base64.push(TO_BASE64URL[pos]); + queuedBits -= 6; + } + }; + + stringToUTF8(str, emitter); + + if (queuedBits > 0) { + queue = queue << (6 - queuedBits); + queuedBits = 6; + + while (queuedBits >= 6) { + const pos = (queue >> (queuedBits - 6)) & 63; + base64.push(TO_BASE64URL[pos]); + queuedBits -= 6; + } + } + + return base64.join(""); +} + +/** + * Converts a Base64-URL encoded string into a JavaScript string. It is assumed + * that the underlying string has been encoded as UTF-8. + * + * @param str The Base64-URL encoded string. + */ +export function stringFromBase64URL(str: string) { + const conv: string[] = []; + + const emit = (codepoint: number) => { + conv.push(String.fromCodePoint(codepoint)); + }; + + const state = { + utf8seq: 0, + codepoint: 0, + }; + + let queue = 0; + let queuedBits = 0; + + for (let i = 0; i < str.length; i += 1) { + const codepoint = str.charCodeAt(i); + const bits = FROM_BASE64URL[codepoint]; + + if (bits > -1) { + // valid Base64-URL character + queue = (queue << 6) | bits; + queuedBits += 6; + + while (queuedBits >= 8) { + stringFromUTF8((queue >> (queuedBits - 8)) & 0xff, state, emit); + queuedBits -= 8; + } + } else if (bits === -2) { + // ignore spaces, tabs, newlines, = + continue; + } else { + throw new Error( + `Invalid Base64-URL character "${str.at(i)}" at position ${i}`, + ); + } + } + + return conv.join(""); +} + +/** + * Converts a Unicode codepoint to a multi-byte UTF-8 sequence. + * + * @param codepoint The Unicode codepoint. + * @param emit Function which will be called for each UTF-8 byte that represents the codepoint. + */ +export function codepointToUTF8( + codepoint: number, + emit: (byte: number) => void, +) { + if (codepoint <= 0x7f) { + emit(codepoint); + return; + } else if (codepoint <= 0x7ff) { + emit(0xc0 | (codepoint >> 6)); + emit(0x80 | (codepoint & 0x3f)); + return; + } else if (codepoint <= 0xffff) { + emit(0xe0 | (codepoint >> 12)); + emit(0x80 | ((codepoint >> 6) & 0x3f)); + emit(0x80 | (codepoint & 0x3f)); + return; + } else if (codepoint <= 0x10ffff) { + emit(0xf0 | (codepoint >> 18)); + emit(0x80 | ((codepoint >> 12) & 0x3f)); + emit(0x80 | ((codepoint >> 6) & 0x3f)); + emit(0x80 | (codepoint & 0x3f)); + return; + } + + throw new Error(`Unrecognized Unicode codepoint: ${codepoint.toString(16)}`); +} + +/** + * Converts a JavaScript string to a sequence of UTF-8 bytes. + * + * @param str The string to convert to UTF-8. + * @param emit Function which will be called for each UTF-8 byte of the string. + */ +export function stringToUTF8(str: string, emit: (byte: number) => void) { + for (let i = 0; i < str.length; i += 1) { + let codepoint = str.charCodeAt(i); + + if (codepoint > 0xd7ff && codepoint <= 0xdbff) { + // most UTF-16 codepoints are Unicode codepoints, except values in this + // range where the next UTF-16 codepoint needs to be combined with the + // current one to get the Unicode codepoint + const highSurrogate = ((codepoint - 0xd800) * 0x400) & 0xffff; + const lowSurrogate = (str.charCodeAt(i + 1) - 0xdc00) & 0xffff; + codepoint = (lowSurrogate | highSurrogate) + 0x10000; + i += 1; + } + + codepointToUTF8(codepoint, emit); + } +} + +/** + * Converts a UTF-8 byte to a Unicode codepoint. + * + * @param byte The UTF-8 byte next in the sequence. + * @param state The shared state between consecutive UTF-8 bytes in the + * sequence, an object with the shape `{ utf8seq: 0, codepoint: 0 }`. + * @param emit Function which will be called for each codepoint. + */ +export function stringFromUTF8( + byte: number, + state: { utf8seq: number; codepoint: number }, + emit: (codepoint: number) => void, +) { + if (state.utf8seq === 0) { + if (byte <= 0x7f) { + emit(byte); + return; + } + + // count the number of 1 leading bits until you reach 0 + for (let leadingBit = 1; leadingBit < 6; leadingBit += 1) { + if (((byte >> (7 - leadingBit)) & 1) === 0) { + state.utf8seq = leadingBit; + break; + } + } + + if (state.utf8seq === 2) { + state.codepoint = byte & 31; + } else if (state.utf8seq === 3) { + state.codepoint = byte & 15; + } else if (state.utf8seq === 4) { + state.codepoint = byte & 7; + } else { + throw new Error("Invalid UTF-8 sequence"); + } + + state.utf8seq -= 1; + } else if (state.utf8seq > 0) { + if (byte <= 0x7f) { + throw new Error("Invalid UTF-8 sequence"); + } + + state.codepoint = (state.codepoint << 6) | (byte & 63); + state.utf8seq -= 1; + + if (state.utf8seq === 0) { + emit(state.codepoint); + } + } +} diff --git a/src/utils/chunker.ts b/src/utils/chunker.ts index d98d304..19d142c 100644 --- a/src/utils/chunker.ts +++ b/src/utils/chunker.ts @@ -3,7 +3,21 @@ interface Chunk { value: string; } -const MAX_CHUNK_SIZE = 3180; +export const MAX_CHUNK_SIZE = 3180; + +const CHUNK_LIKE_REGEX = /^(.*)[.](0|[1-9][0-9]*)$/; +export function isChunkLike(cookieName: string, key: string) { + if (cookieName === key) { + return true; + } + + const chunkLike = cookieName.match(CHUNK_LIKE_REGEX); + if (chunkLike && chunkLike[1] === key) { + return true; + } + + return false; +} /** * create chunks from a string and return an array of object @@ -97,6 +111,8 @@ export async function combineChunks( if (values.length > 0) { return values.join(""); } + + return null; } export async function deleteChunks( diff --git a/src/utils/index.ts b/src/utils/index.ts index 70ea7fa..5c11649 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,3 +1,4 @@ export * from "./helpers"; export * from "./constants"; export * from "./chunker"; +export * from "./base64url";