From d22fa377e72cc28b508bb7b2a7daa9ccaf803347 Mon Sep 17 00:00:00 2001 From: Charles Bochet Date: Fri, 12 Jun 2026 22:39:20 +0200 Subject: [PATCH] fix(front): store auth tokenPair in localStorage instead of a cookie (#21507) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem A client hit an AWS S3 `RequestHeaderSectionTooLarge` error (`MaxSizeAllowed 8192`) when opening a `https://.twenty.com/verify?loginToken=` 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. Review in cubic --- packages/twenty-front/src/index.tsx | 5 + .../utils/__tests__/getTokenPair.test.ts | 126 ++++++++---------- .../src/modules/apollo/utils/getTokenPair.ts | 11 +- .../src/modules/auth/states/tokenPairState.ts | 13 +- .../modules/auth/utils/ensureTokenRenewed.ts | 2 +- .../migrateTokenPairCookieToLocalStorage.ts | 29 ++++ .../utils/__tests__/createAtomState.test.ts | 41 ++++++ .../state/jotai/utils/createAtomState.ts | 32 ++++- 8 files changed, 171 insertions(+), 88 deletions(-) create mode 100644 packages/twenty-front/src/modules/auth/utils/migrateTokenPairCookieToLocalStorage.ts create mode 100644 packages/twenty-front/src/modules/ui/utilities/state/jotai/utils/__tests__/createAtomState.test.ts diff --git a/packages/twenty-front/src/index.tsx b/packages/twenty-front/src/index.tsx index 5bbdd64bc1f..ca1d2610d2c 100644 --- a/packages/twenty-front/src/index.tsx +++ b/packages/twenty-front/src/index.tsx @@ -1,6 +1,7 @@ import ReactDOM from 'react-dom/client'; import { App } from '@/app/components/App'; +import { migrateTokenPairCookieToLocalStorage } from '@/auth/utils/migrateTokenPairCookieToLocalStorage'; import 'react-loading-skeleton/dist/skeleton.css'; import 'twenty-ui-deprecated/style.css'; import 'twenty-ui-deprecated/theme-light.css'; @@ -10,6 +11,10 @@ import 'twenty-ui-deprecated/theme-dark.css'; import 'twenty-ui/style.css'; import './index.css'; +// TODO: REMOVE this after 2026-12-12 — temporary migration of tokenPair from the +// legacy cookie to localStorage (legacy cookie has a 180-day expiry). +migrateTokenPairCookieToLocalStorage(); + const root = ReactDOM.createRoot( document.getElementById('root') ?? document.body, ); diff --git a/packages/twenty-front/src/modules/apollo/utils/__tests__/getTokenPair.test.ts b/packages/twenty-front/src/modules/apollo/utils/__tests__/getTokenPair.test.ts index b7d687d2d4d..d3a1756039b 100644 --- a/packages/twenty-front/src/modules/apollo/utils/__tests__/getTokenPair.test.ts +++ b/packages/twenty-front/src/modules/apollo/utils/__tests__/getTokenPair.test.ts @@ -1,126 +1,110 @@ -import { cookieStorage } from '~/utils/cookie-storage'; import { getTokenPair } from '@/apollo/utils/getTokenPair'; - -jest.mock('~/utils/cookie-storage', () => ({ - cookieStorage: { - getItem: jest.fn(), - removeItem: jest.fn(), - }, -})); - -const mockCookieStorage = cookieStorage as jest.Mocked; +import { TOKEN_PAIR_LOCAL_STORAGE_KEY } from '@/auth/states/tokenPairState'; describe('getTokenPair', () => { + let getItemSpy: jest.SpyInstance; + let removeItemSpy: jest.SpyInstance; + beforeEach(() => { - jest.clearAllMocks(); + getItemSpy = jest.spyOn(Storage.prototype, 'getItem'); + removeItemSpy = jest.spyOn(Storage.prototype, 'removeItem'); }); - describe('when tokenPair cookie does not exist', () => { - it('should return undefined when cookie is not set', () => { - mockCookieStorage.getItem.mockReturnValue(undefined); + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('when tokenPair is not stored', () => { + it('should return undefined when nothing is stored', () => { + getItemSpy.mockReturnValue(null); const result = getTokenPair(); expect(result).toBeUndefined(); - expect(mockCookieStorage.getItem).toHaveBeenCalledWith('tokenPair'); - }); - - it('should return undefined when cookie is undefined', () => { - mockCookieStorage.getItem.mockReturnValue(undefined); - - const result = getTokenPair(); - - expect(result).toBeUndefined(); - expect(mockCookieStorage.getItem).toHaveBeenCalledWith('tokenPair'); + expect(getItemSpy).toHaveBeenCalledWith(TOKEN_PAIR_LOCAL_STORAGE_KEY); }); }); - describe('when tokenPair cookie has invalid JSON', () => { - it('should remove cookie and return undefined for malformed JSON', () => { - mockCookieStorage.getItem.mockReturnValue('invalid-json'); + describe('when stored tokenPair has invalid JSON', () => { + it('should remove the item and return undefined for malformed JSON', () => { + getItemSpy.mockReturnValue('invalid-json'); const result = getTokenPair(); expect(result).toBeUndefined(); - expect(mockCookieStorage.removeItem).toHaveBeenCalledWith('tokenPair'); + expect(removeItemSpy).toHaveBeenCalledWith(TOKEN_PAIR_LOCAL_STORAGE_KEY); }); - it('should remove cookie and return undefined for partial JSON', () => { - mockCookieStorage.getItem.mockReturnValue('{"incomplete":'); + it('should remove the item and return undefined for partial JSON', () => { + getItemSpy.mockReturnValue('{"incomplete":'); const result = getTokenPair(); expect(result).toBeUndefined(); - expect(mockCookieStorage.removeItem).toHaveBeenCalledWith('tokenPair'); + expect(removeItemSpy).toHaveBeenCalledWith(TOKEN_PAIR_LOCAL_STORAGE_KEY); }); }); - describe('when tokenPair cookie has invalid structure', () => { - it('should remove cookie and return undefined when tokenPair is null', () => { - mockCookieStorage.getItem.mockReturnValue('null'); + describe('when stored tokenPair has invalid structure', () => { + it('should remove the item and return undefined when tokenPair is null', () => { + getItemSpy.mockReturnValue('null'); const result = getTokenPair(); expect(result).toBeUndefined(); - expect(mockCookieStorage.removeItem).toHaveBeenCalledWith('tokenPair'); + expect(removeItemSpy).toHaveBeenCalledWith(TOKEN_PAIR_LOCAL_STORAGE_KEY); }); - it('should remove cookie and return undefined when tokenPair is not an object', () => { - mockCookieStorage.getItem.mockReturnValue('"string-value"'); + it('should remove the item and return undefined when tokenPair is not an object', () => { + getItemSpy.mockReturnValue('"string-value"'); const result = getTokenPair(); expect(result).toBeUndefined(); - expect(mockCookieStorage.removeItem).toHaveBeenCalledWith('tokenPair'); + expect(removeItemSpy).toHaveBeenCalledWith(TOKEN_PAIR_LOCAL_STORAGE_KEY); }); - it('should remove cookie and return undefined when accessOrWorkspaceAgnosticToken is missing', () => { + it('should remove the item and return undefined when accessOrWorkspaceAgnosticToken is missing', () => { const invalidTokenPair = { refreshToken: { token: 'refresh-token' }, }; - mockCookieStorage.getItem.mockReturnValue( - JSON.stringify(invalidTokenPair), - ); + getItemSpy.mockReturnValue(JSON.stringify(invalidTokenPair)); const result = getTokenPair(); expect(result).toBeUndefined(); - expect(mockCookieStorage.removeItem).toHaveBeenCalledWith('tokenPair'); + expect(removeItemSpy).toHaveBeenCalledWith(TOKEN_PAIR_LOCAL_STORAGE_KEY); }); - it('should remove cookie and return undefined when accessOrWorkspaceAgnosticToken is not an object', () => { + it('should remove the item and return undefined when accessOrWorkspaceAgnosticToken is not an object', () => { const invalidTokenPair = { accessOrWorkspaceAgnosticToken: 'not-an-object', refreshToken: { token: 'refresh-token' }, }; - mockCookieStorage.getItem.mockReturnValue( - JSON.stringify(invalidTokenPair), - ); + getItemSpy.mockReturnValue(JSON.stringify(invalidTokenPair)); const result = getTokenPair(); expect(result).toBeUndefined(); - expect(mockCookieStorage.removeItem).toHaveBeenCalledWith('tokenPair'); + expect(removeItemSpy).toHaveBeenCalledWith(TOKEN_PAIR_LOCAL_STORAGE_KEY); }); - it('should remove cookie and return undefined when token is missing', () => { + it('should remove the item and return undefined when token is missing', () => { const invalidTokenPair = { accessOrWorkspaceAgnosticToken: { expiresAt: '2024-01-01T00:00:00Z', }, refreshToken: { token: 'refresh-token' }, }; - mockCookieStorage.getItem.mockReturnValue( - JSON.stringify(invalidTokenPair), - ); + getItemSpy.mockReturnValue(JSON.stringify(invalidTokenPair)); const result = getTokenPair(); expect(result).toBeUndefined(); - expect(mockCookieStorage.removeItem).toHaveBeenCalledWith('tokenPair'); + expect(removeItemSpy).toHaveBeenCalledWith(TOKEN_PAIR_LOCAL_STORAGE_KEY); }); - it('should remove cookie and return undefined when token is not a string', () => { + it('should remove the item and return undefined when token is not a string', () => { const invalidTokenPair = { accessOrWorkspaceAgnosticToken: { token: 123, @@ -128,14 +112,12 @@ describe('getTokenPair', () => { }, refreshToken: { token: 'refresh-token' }, }; - mockCookieStorage.getItem.mockReturnValue( - JSON.stringify(invalidTokenPair), - ); + getItemSpy.mockReturnValue(JSON.stringify(invalidTokenPair)); const result = getTokenPair(); expect(result).toBeUndefined(); - expect(mockCookieStorage.removeItem).toHaveBeenCalledWith('tokenPair'); + expect(removeItemSpy).toHaveBeenCalledWith(TOKEN_PAIR_LOCAL_STORAGE_KEY); }); it('should accept empty string token as valid', () => { @@ -146,16 +128,16 @@ describe('getTokenPair', () => { }, refreshToken: { token: 'refresh-token' }, }; - mockCookieStorage.getItem.mockReturnValue(JSON.stringify(validTokenPair)); + getItemSpy.mockReturnValue(JSON.stringify(validTokenPair)); const result = getTokenPair(); expect(result).toEqual(validTokenPair); - expect(mockCookieStorage.removeItem).not.toHaveBeenCalled(); + expect(removeItemSpy).not.toHaveBeenCalled(); }); }); - describe('when tokenPair cookie has valid structure', () => { + describe('when stored tokenPair has valid structure', () => { it('should return valid tokenPair with all required fields', () => { const validTokenPair = { accessOrWorkspaceAgnosticToken: { @@ -167,12 +149,12 @@ describe('getTokenPair', () => { expiresAt: '2024-01-02T00:00:00Z', }, }; - mockCookieStorage.getItem.mockReturnValue(JSON.stringify(validTokenPair)); + getItemSpy.mockReturnValue(JSON.stringify(validTokenPair)); const result = getTokenPair(); expect(result).toEqual(validTokenPair); - expect(mockCookieStorage.removeItem).not.toHaveBeenCalled(); + expect(removeItemSpy).not.toHaveBeenCalled(); }); it('should return valid tokenPair with minimal required fields', () => { @@ -181,12 +163,12 @@ describe('getTokenPair', () => { token: 'minimal-access-token', }, }; - mockCookieStorage.getItem.mockReturnValue(JSON.stringify(validTokenPair)); + getItemSpy.mockReturnValue(JSON.stringify(validTokenPair)); const result = getTokenPair(); expect(result).toEqual(validTokenPair); - expect(mockCookieStorage.removeItem).not.toHaveBeenCalled(); + expect(removeItemSpy).not.toHaveBeenCalled(); }); it('should return valid tokenPair with extra fields', () => { @@ -201,19 +183,18 @@ describe('getTokenPair', () => { }, additionalField: 'additional-value', }; - mockCookieStorage.getItem.mockReturnValue(JSON.stringify(validTokenPair)); + getItemSpy.mockReturnValue(JSON.stringify(validTokenPair)); const result = getTokenPair(); expect(result).toEqual(validTokenPair); - expect(mockCookieStorage.removeItem).not.toHaveBeenCalled(); + expect(removeItemSpy).not.toHaveBeenCalled(); }); }); describe('edge cases', () => { it('should handle JSON parsing error gracefully', () => { - mockCookieStorage.getItem.mockReturnValue('{"valid": "json"'); - // Simulate JSON.parse throwing an error + getItemSpy.mockReturnValue('{"valid": "json"'); const originalParse = JSON.parse; JSON.parse = jest.fn(() => { throw new SyntaxError('Unexpected end of JSON input'); @@ -222,9 +203,8 @@ describe('getTokenPair', () => { const result = getTokenPair(); expect(result).toBeUndefined(); - expect(mockCookieStorage.removeItem).toHaveBeenCalledWith('tokenPair'); + expect(removeItemSpy).toHaveBeenCalledWith(TOKEN_PAIR_LOCAL_STORAGE_KEY); - // Restore original JSON.parse JSON.parse = originalParse; }); @@ -235,7 +215,7 @@ describe('getTokenPair', () => { token: longToken, }, }; - mockCookieStorage.getItem.mockReturnValue(JSON.stringify(validTokenPair)); + getItemSpy.mockReturnValue(JSON.stringify(validTokenPair)); const result = getTokenPair(); @@ -250,7 +230,7 @@ describe('getTokenPair', () => { token: unicodeToken, }, }; - mockCookieStorage.getItem.mockReturnValue(JSON.stringify(validTokenPair)); + getItemSpy.mockReturnValue(JSON.stringify(validTokenPair)); const result = getTokenPair(); diff --git a/packages/twenty-front/src/modules/apollo/utils/getTokenPair.ts b/packages/twenty-front/src/modules/apollo/utils/getTokenPair.ts index 656bb909c53..e25b1cc1b0d 100644 --- a/packages/twenty-front/src/modules/apollo/utils/getTokenPair.ts +++ b/packages/twenty-front/src/modules/apollo/utils/getTokenPair.ts @@ -1,15 +1,12 @@ import { isDefined } from 'twenty-shared/utils'; +import { TOKEN_PAIR_LOCAL_STORAGE_KEY } from '@/auth/states/tokenPairState'; import { type AuthTokenPair } from '~/generated-metadata/graphql'; -import { cookieStorage } from '~/utils/cookie-storage'; import { isValidAuthTokenPair } from './isValidAuthTokenPair'; export const getTokenPair = (): AuthTokenPair | undefined => { - const stringTokenPair = cookieStorage.getItem('tokenPair'); + const stringTokenPair = localStorage.getItem(TOKEN_PAIR_LOCAL_STORAGE_KEY); if (!isDefined(stringTokenPair)) { - // oxlint-disable-next-line no-console - console.log('tokenPair is undefined'); - return undefined; } @@ -17,13 +14,13 @@ export const getTokenPair = (): AuthTokenPair | undefined => { const parsedTokenPair = JSON.parse(stringTokenPair); if (!isValidAuthTokenPair(parsedTokenPair)) { - cookieStorage.removeItem('tokenPair'); + localStorage.removeItem(TOKEN_PAIR_LOCAL_STORAGE_KEY); return undefined; } return parsedTokenPair; } catch { - cookieStorage.removeItem('tokenPair'); + localStorage.removeItem(TOKEN_PAIR_LOCAL_STORAGE_KEY); return undefined; } }; diff --git a/packages/twenty-front/src/modules/auth/states/tokenPairState.ts b/packages/twenty-front/src/modules/auth/states/tokenPairState.ts index 5213f5be3a4..8e17915b864 100644 --- a/packages/twenty-front/src/modules/auth/states/tokenPairState.ts +++ b/packages/twenty-front/src/modules/auth/states/tokenPairState.ts @@ -1,12 +1,13 @@ +import { isValidAuthTokenPair } from '@/apollo/utils/isValidAuthTokenPair'; import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState'; import { type AuthTokenPair } from '~/generated-metadata/graphql'; +export const TOKEN_PAIR_LOCAL_STORAGE_KEY = 'tokenPairState'; + export const tokenPairState = createAtomState({ - key: 'tokenPairState', + key: TOKEN_PAIR_LOCAL_STORAGE_KEY, defaultValue: null, - useCookieStorage: { - cookieKey: 'tokenPair', - validateInitFn: (payload: AuthTokenPair) => - Boolean(payload['accessOrWorkspaceAgnosticToken']), - }, + useLocalStorage: true, + localStorageOptions: { getOnInit: true }, + validateInitFn: (payload) => isValidAuthTokenPair(payload), }); diff --git a/packages/twenty-front/src/modules/auth/utils/ensureTokenRenewed.ts b/packages/twenty-front/src/modules/auth/utils/ensureTokenRenewed.ts index bb234cdb673..465bc9621b7 100644 --- a/packages/twenty-front/src/modules/auth/utils/ensureTokenRenewed.ts +++ b/packages/twenty-front/src/modules/auth/utils/ensureTokenRenewed.ts @@ -19,7 +19,7 @@ export const ensureTokenRenewed = ( const tokenPair = store.get(tokenPairState.atom); - if (!isDefined(tokenPair)) { + if (!isDefined(tokenPair?.refreshToken?.token)) { return Promise.resolve(false); } diff --git a/packages/twenty-front/src/modules/auth/utils/migrateTokenPairCookieToLocalStorage.ts b/packages/twenty-front/src/modules/auth/utils/migrateTokenPairCookieToLocalStorage.ts new file mode 100644 index 00000000000..e7a81805f94 --- /dev/null +++ b/packages/twenty-front/src/modules/auth/utils/migrateTokenPairCookieToLocalStorage.ts @@ -0,0 +1,29 @@ +import { isDefined } from 'twenty-shared/utils'; +import { TOKEN_PAIR_LOCAL_STORAGE_KEY } from '@/auth/states/tokenPairState'; +import { cookieStorage } from '~/utils/cookie-storage'; + +const LEGACY_TOKEN_PAIR_COOKIE_KEY = 'tokenPair'; + +export const migrateTokenPairCookieToLocalStorage = () => { + try { + const legacyCookieValue = cookieStorage.getItem( + LEGACY_TOKEN_PAIR_COOKIE_KEY, + ); + + if (!isDefined(legacyCookieValue)) { + return; + } + + const existingLocalStorageValue = localStorage.getItem( + TOKEN_PAIR_LOCAL_STORAGE_KEY, + ); + + if (!isDefined(existingLocalStorageValue)) { + localStorage.setItem(TOKEN_PAIR_LOCAL_STORAGE_KEY, legacyCookieValue); + } + + cookieStorage.removeItem(LEGACY_TOKEN_PAIR_COOKIE_KEY); + } catch { + // noop + } +}; diff --git a/packages/twenty-front/src/modules/ui/utilities/state/jotai/utils/__tests__/createAtomState.test.ts b/packages/twenty-front/src/modules/ui/utilities/state/jotai/utils/__tests__/createAtomState.test.ts new file mode 100644 index 00000000000..303d80609d4 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/utilities/state/jotai/utils/__tests__/createAtomState.test.ts @@ -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) => + 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({ + 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({ + key: 'invalidAtom', + defaultValue: null, + useLocalStorage: true, + localStorageOptions: { getOnInit: true }, + validateInitFn, + }); + + expect(createStore().get(state.atom)).toBeNull(); + }); +}); diff --git a/packages/twenty-front/src/modules/ui/utilities/state/jotai/utils/createAtomState.ts b/packages/twenty-front/src/modules/ui/utilities/state/jotai/utils/createAtomState.ts index 5f538352629..c72b0aa1ce9 100644 --- a/packages/twenty-front/src/modules/ui/utilities/state/jotai/utils/createAtomState.ts +++ b/packages/twenty-front/src/modules/ui/utilities/state/jotai/utils/createAtomState.ts @@ -24,12 +24,38 @@ type StateAtom = 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 = ( + validateInitFn: (payload: NonNullable) => boolean, +) => { + const storage = createJSONStorage(() => localStorage); + + return { + ...storage, + getItem: (key: string, initialValue: ValueType): ValueType => { + const value = storage.getItem(key, initialValue) as ValueType; + + if ( + isDefined(value) && + !validateInitFn(value as NonNullable) + ) { + return initialValue; + } + + return value; + }, + }; +}; + export const createAtomState = ({ key, defaultValue, useLocalStorage = false, useSessionStorage = false, localStorageOptions, + validateInitFn, useCookieStorage, }: { key: string; @@ -37,6 +63,7 @@ export const createAtomState = ({ useLocalStorage?: boolean; useSessionStorage?: boolean; localStorageOptions?: LocalStorageOptions; + validateInitFn?: (payload: NonNullable) => boolean; useCookieStorage?: CookieStorageConfig; }): State => { let baseAtom: StateAtom; @@ -59,10 +86,13 @@ export const createAtomState = ({ getOnInit: true, }) as StateAtom; } else if (useLocalStorage) { + const storage = isDefined(validateInitFn) + ? createValidatedLocalStorage(validateInitFn) + : undefined; baseAtom = atomWithStorage( key, defaultValue, - undefined, + storage, localStorageOptions ?? undefined, ) as StateAtom; } else {