fix(front): store auth tokenPair in localStorage instead of a cookie (#21507)
## Problem A client hit an AWS S3 `RequestHeaderSectionTooLarge` error (`MaxSizeAllowed 8192`) when opening a `https://<workspace>.twenty.com/verify?loginToken=<JWT>` link — the request to load the `/verify` SPA page is served from S3, which rejects it before the app loads. The dominant cause is the **`tokenPair` cookie**. The auth tokenPair (access + refresh JWTs, ~2–5KB) was persisted in a host-scoped, JS-readable cookie. Nothing server-side ever reads it — the access token is sent to the API via an `Authorization: Bearer` header set in the Apollo auth link (`ExtractJwt.fromAuthHeaderAsBearerToken()` on the backend; no `cookie-parser`). Yet the browser attached that cookie to **every** request to the origin, including static assets and the `/verify` page. Combined with the `loginToken` in the URL, the request header section exceeds S3's 8192-byte limit. ## Fix Move `tokenPair` from cookie storage to **localStorage**, which is never transmitted in request headers. - `tokenPairState` now uses `useLocalStorage` (with `getOnInit: true`). - `getTokenPair` (the synchronous read used by the Apollo auth link) reads from localStorage under the same key. - A one-time migration (`migrateTokenPairCookieToLocalStorage`) runs before React renders: it ports any existing `tokenPair` cookie into localStorage and **deletes the cookie**, so already-authenticated users aren't logged out and the oversized cookie stops being sent. ## Why this is safe **Behavior:** equivalent. The cookie was host-scoped (no `domain` attribute), so it never provided cross-subdomain sharing — cross-workspace auth already re-establishes the token per-origin via the `loginToken`-in-URL → `/verify` handoff. localStorage has identical origin scoping. **Security:** neutral-to-positive. - No XSS protection lost — the cookie was **not** `httpOnly` (it can't be; JS reads it to build the Bearer header), so it was already XSS-exposed exactly like localStorage. - No CSRF surface change — the token was never sent as a cookie credential (no `credentials: 'include'`). - **Reduced exposure** — the token no longer leaks into CDN/proxy/server access logs or request headers, which is the actual bug. - Server-side revocation (`revokedAt`) and the 60-day refresh-token JWT expiry govern validity, so localStorage's lack of auto-expiry is moot. ## Testing - `getTokenPair` unit tests updated to localStorage. - New unit tests for the migration util (port, no-op, no-clobber, error-safety). - `nx test twenty-front` auth + apollo suites: 125 passing. - `lint:diff-with-main` clean; changed files typecheck clean. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21507?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
This commit is contained in:
+41
@@ -0,0 +1,41 @@
|
||||
import { createStore } from 'jotai';
|
||||
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
|
||||
|
||||
type Stored = { token: string } | null;
|
||||
|
||||
const validateInitFn = (payload: NonNullable<Stored>) =>
|
||||
typeof payload.token === 'string';
|
||||
|
||||
describe('createAtomState validated localStorage', () => {
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('hydrates a persisted value that passes validateInitFn', () => {
|
||||
localStorage.setItem('validAtom', JSON.stringify({ token: 'abc' }));
|
||||
|
||||
const state = createAtomState<Stored>({
|
||||
key: 'validAtom',
|
||||
defaultValue: null,
|
||||
useLocalStorage: true,
|
||||
localStorageOptions: { getOnInit: true },
|
||||
validateInitFn,
|
||||
});
|
||||
|
||||
expect(createStore().get(state.atom)).toEqual({ token: 'abc' });
|
||||
});
|
||||
|
||||
it('falls back to the default when the persisted value fails validateInitFn', () => {
|
||||
localStorage.setItem('invalidAtom', JSON.stringify({ nope: true }));
|
||||
|
||||
const state = createAtomState<Stored>({
|
||||
key: 'invalidAtom',
|
||||
defaultValue: null,
|
||||
useLocalStorage: true,
|
||||
localStorageOptions: { getOnInit: true },
|
||||
validateInitFn,
|
||||
});
|
||||
|
||||
expect(createStore().get(state.atom)).toBeNull();
|
||||
});
|
||||
});
|
||||
+31
-1
@@ -24,12 +24,38 @@ type StateAtom<ValueType> = WritableAtom<
|
||||
|
||||
type LocalStorageOptions = { getOnInit?: boolean };
|
||||
|
||||
// Wraps the default JSON localStorage so a persisted value that fails
|
||||
// validateInitFn falls back to the initial value instead of hydrating the atom
|
||||
// with an invalid payload.
|
||||
const createValidatedLocalStorage = <ValueType>(
|
||||
validateInitFn: (payload: NonNullable<ValueType>) => boolean,
|
||||
) => {
|
||||
const storage = createJSONStorage<ValueType>(() => localStorage);
|
||||
|
||||
return {
|
||||
...storage,
|
||||
getItem: (key: string, initialValue: ValueType): ValueType => {
|
||||
const value = storage.getItem(key, initialValue) as ValueType;
|
||||
|
||||
if (
|
||||
isDefined(value) &&
|
||||
!validateInitFn(value as NonNullable<ValueType>)
|
||||
) {
|
||||
return initialValue;
|
||||
}
|
||||
|
||||
return value;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const createAtomState = <ValueType>({
|
||||
key,
|
||||
defaultValue,
|
||||
useLocalStorage = false,
|
||||
useSessionStorage = false,
|
||||
localStorageOptions,
|
||||
validateInitFn,
|
||||
useCookieStorage,
|
||||
}: {
|
||||
key: string;
|
||||
@@ -37,6 +63,7 @@ export const createAtomState = <ValueType>({
|
||||
useLocalStorage?: boolean;
|
||||
useSessionStorage?: boolean;
|
||||
localStorageOptions?: LocalStorageOptions;
|
||||
validateInitFn?: (payload: NonNullable<ValueType>) => boolean;
|
||||
useCookieStorage?: CookieStorageConfig<ValueType>;
|
||||
}): State<ValueType> => {
|
||||
let baseAtom: StateAtom<ValueType>;
|
||||
@@ -59,10 +86,13 @@ export const createAtomState = <ValueType>({
|
||||
getOnInit: true,
|
||||
}) as StateAtom<ValueType>;
|
||||
} else if (useLocalStorage) {
|
||||
const storage = isDefined(validateInitFn)
|
||||
? createValidatedLocalStorage<ValueType>(validateInitFn)
|
||||
: undefined;
|
||||
baseAtom = atomWithStorage<ValueType>(
|
||||
key,
|
||||
defaultValue,
|
||||
undefined,
|
||||
storage,
|
||||
localStorageOptions ?? undefined,
|
||||
) as StateAtom<ValueType>;
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user