UNPKG

@tealbase/ssr

Version:

Use the tealbase JavaScript library in popular server-side rendering (SSR) frameworks.

333 lines (260 loc) 15.9 kB
# Design This document should help clarify how this library works internally and why certain choices were made. ## Data flows tealbase 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 tealbase 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=<many seconds> Set-Cookie: storage-item.1=ue; Max-Age=<many seconds> 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 tealbase 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, tealbase 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**. <ins>It is not possible to **set cookies**.</ins> 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 tealbase 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 tealbase 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<Enter>` 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 tealbase 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