From 0abfa18e9d259d7708cdb5876ee61b135bf2236f Mon Sep 17 00:00:00 2001 From: Michael Yankelev Date: Sat, 8 Aug 2026 14:48:15 +0200 Subject: [PATCH 1/2] fix: self-heal a corrupt Core Kit store instead of wedging the app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `AsyncStorage.get` parses the Core Kit store with a bare `JSON.parse`, and `init()` has no guard around it, so an unreadable `corekit_store` threw out of `restore()`. `CoreKitProvider` handed the same `settle` to fulfil and reject, so that throw was indistinguishable from a clean signed-out restore: no console surface, no error state, and nothing cleared the blob. The next login hit the same parse in `createSession` and threw again, wedging the tab across reloads. `Web3AuthSession.restore()` now clears the store when the failure was the store itself, reusing the existing `clearStore()`. The trigger is narrow on purpose: `init()` also ends in a bare `fetch` for the SDK's feature check, so an unconditional purge would delete a good store on an offline reload — and that blob carries the device factor share once MFA is reachable. `CoreKitProvider` now distinguishes reject from resolve and records the failure. The tab still lands at the front door and can never present as authenticated, but it lands there carrying the failure, and it keeps the session in hand because logging in again is the way out. The message is a fixed string rather than the underlying throw: V8 quotes the offending input in a `JSON.parse` failure, and that input is bearer key material. Closes #1182 Co-Authored-By: Claude Opus 5 --- apps/web/src/auth/CoreKitProvider.test.tsx | 115 +++++++++++++++++++++ apps/web/src/auth/CoreKitProvider.tsx | 29 ++++-- apps/web/src/auth/coreKit.ts | 25 ++++- 3 files changed, 156 insertions(+), 13 deletions(-) create mode 100644 apps/web/src/auth/CoreKitProvider.test.tsx diff --git a/apps/web/src/auth/CoreKitProvider.test.tsx b/apps/web/src/auth/CoreKitProvider.test.tsx new file mode 100644 index 0000000000..983a40d2e7 --- /dev/null +++ b/apps/web/src/auth/CoreKitProvider.test.tsx @@ -0,0 +1,115 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { COREKIT_STATUS } from '@web3auth/mpc-core-kit'; +import type { ReactNode } from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createCoreKitSession } from './coreKit'; +import { CoreKitProvider, useCoreKit } from './CoreKitProvider'; + +const STORE_KEY = 'corekit_store'; +/** A truncated write, which is what an evicted or half-flushed store looks like. */ +const CORRUPT_STORE = '{"sessionId":"a-sessio'; +const SIGNED_OUT_STORE = '{"deviceFactor":"a-device-factor"}'; +const LOGGED_IN_STORE = '{"sessionId":"a-fresh-session-id"}'; +/** The SDK's own feature check is a bare `fetch`, so a restore fails offline. */ +const OFFLINE = new Error('Failed to fetch'); + +// The SDK reads its store through a bare `JSON.parse` on both the restore and +// the login path, so one unreadable blob defeats every later login too. The +// fake reproduces that read, and the network leg that follows it in `init`. +const sdk = vi.hoisted(() => ({ + status: 'NOT_INITIALIZED', + initFailure: undefined as Error | undefined, +})); +vi.mock('@web3auth/mpc-core-kit', async (importOriginal) => { + const actual = await importOriginal(); + const readStore = (): unknown => JSON.parse(window.localStorage.getItem(STORE_KEY) ?? '{}'); + return { + ...actual, + Web3AuthMPCCoreKit: class { + readonly _storageKey = STORE_KEY; + get status(): string { + return sdk.status; + } + async init(): Promise { + readStore(); + if (sdk.initFailure) throw sdk.initFailure; + } + async loginWithOAuth(): Promise { + readStore(); + window.localStorage.setItem(STORE_KEY, LOGGED_IN_STORE); + sdk.status = actual.COREKIT_STATUS.LOGGED_IN; + } + commitChanges(): Promise { + return Promise.resolve(); + } + }, + }; +}); + +const ENV = { + VITE_WEB3AUTH_CLIENT_ID: 'client-id', + VITE_WEB3AUTH_VERIFIER: 'verifier', +} satisfies Partial; + +/** The provider over a real Core Kit session, so the store it owns is the real one. */ +function mount() { + return renderHook(() => useCoreKit(), { + wrapper: ({ children }: { children: ReactNode }) => ( + createCoreKitSession(ENV)}>{children} + ), + }); +} + +describe('CoreKitProvider', () => { + beforeEach(() => { + sdk.status = COREKIT_STATUS.NOT_INITIALIZED; + sdk.initFailure = undefined; + window.localStorage.clear(); + }); + + it('discards a store the restore could not read, and does not pass it off as a signed-out tab', async () => { + window.localStorage.setItem(STORE_KEY, CORRUPT_STORE); + const { result } = mount(); + + await waitFor(() => expect(result.current.status).toBe('ready')); + + expect(result.current.error).toMatch(/could not be restored/); + expect(result.current.session?.isLoggedIn()).toBe(false); + expect(window.localStorage.getItem(STORE_KEY)).toBeNull(); + }); + + it('leaves a login after a failed restore able to establish a session', async () => { + window.localStorage.setItem(STORE_KEY, CORRUPT_STORE); + const { result } = mount(); + await waitFor(() => expect(result.current.status).toBe('ready')); + + await act(async () => { + await result.current.session?.login('google'); + }); + + expect(result.current.session?.isLoggedIn()).toBe(true); + expect(window.localStorage.getItem(STORE_KEY)).toBe(LOGGED_IN_STORE); + }); + + it('keeps a readable store when the restore failed for some other reason', async () => { + window.localStorage.setItem(STORE_KEY, SIGNED_OUT_STORE); + sdk.initFailure = OFFLINE; + const { result } = mount(); + + await waitFor(() => expect(result.current.status).toBe('ready')); + + expect(result.current.error).toMatch(/could not be restored/); + expect(window.localStorage.getItem(STORE_KEY)).toBe(SIGNED_OUT_STORE); + }); + + it('keeps a store the restore read cleanly, session in it or not', async () => { + window.localStorage.setItem(STORE_KEY, SIGNED_OUT_STORE); + const { result } = mount(); + + await waitFor(() => expect(result.current.status).toBe('ready')); + + expect(result.current.error).toBeNull(); + expect(result.current.session?.isLoggedIn()).toBe(false); + expect(window.localStorage.getItem(STORE_KEY)).toBe(SIGNED_OUT_STORE); + }); +}); diff --git a/apps/web/src/auth/CoreKitProvider.tsx b/apps/web/src/auth/CoreKitProvider.tsx index 1c4cca3084..3ae07a8bdf 100644 --- a/apps/web/src/auth/CoreKitProvider.tsx +++ b/apps/web/src/auth/CoreKitProvider.tsx @@ -10,11 +10,18 @@ const RESTORE_DEADLINE_MS = 10_000; const UNREACHABLE = 'the login provider is not responding — check your connection and reload'; +/** + * Deliberately says nothing about the cause: the throw that gets here is + * usually the SDK parsing its own store, and that message quotes the bytes it + * choked on, which are bearer key material. + */ +const RESTORE_FAILED = 'the saved sign-in could not be restored — please sign in again'; + export interface CoreKitContextValue { /** `null` until the session is built and its restore attempt has settled. */ session: CoreKitSession | null; status: CoreKitStatus; - /** Why Core Kit is unusable at all — a bad build config, or silence. */ + /** Why this tab has no session — a bad build config, silence, or a failed restore. */ error: string | null; } @@ -58,18 +65,18 @@ export function CoreKitProvider({ createSession, children }: CoreKitProviderProp if (live) setValue({ session: null, status: 'unavailable', error: UNREACHABLE }); }, RESTORE_DEADLINE_MS); - const settled: CoreKitContextValue = { - session: session.current, - status: 'ready', - error: null, - }; - // A failed restore just means there is no session to resume; the methods - // below still work, and a real breakage surfaces when one is used. - const settle = () => { + const restored = session.current; + const settle = (error: string | null) => { clearTimeout(deadline); - if (live) setValue(settled); + if (live) setValue({ session: restored, status: 'ready', error }); }; - restore.current.then(settle, settle); + // A rejected restore resumes nothing, so the tab still lands at the front + // door — but it lands there carrying the failure, not as a clean sign-out. + // The session stays in hand because logging in again is the way out of it. + restore.current.then( + () => settle(null), + () => settle(RESTORE_FAILED) + ); return () => { live = false; diff --git a/apps/web/src/auth/coreKit.ts b/apps/web/src/auth/coreKit.ts index 95ca92f812..bb08404954 100644 --- a/apps/web/src/auth/coreKit.ts +++ b/apps/web/src/auth/coreKit.ts @@ -40,7 +40,15 @@ class Web3AuthSession implements CoreKitSession { ) {} async restore(): Promise { - await this.coreKit.init(); + try { + await this.coreKit.init(); + } catch (failure) { + // Only an unreadable store wedges the next login through that same read. + // A restore that failed for any other reason — the SDK's feature check + // has no network — must leave a good store standing. + if (!this.storeIsReadable()) this.clearStore(); + throw failure; + } } isLoggedIn(): boolean { @@ -86,12 +94,25 @@ class Web3AuthSession implements CoreKitSession { /** * The SDK's own logout blanks its session id in place and leaves the rest of * its store standing — a device factor share among it, once MFA is reachable. - * So every path that ends a session, refused or partial, clears it here. + * So every path that leaves this device without a usable session clears it + * here, whether the session ended, was refused, or was never readable. */ private clearStore(): void { this.store.removeItem(this.coreKit._storageKey); } + /** The SDK reads its store as `JSON.parse(raw || '{}')[key]`; nothing else opens. */ + private storeIsReadable(): boolean { + const raw = this.store.getItem(this.coreKit._storageKey); + if (!raw) return true; + try { + const parsed: unknown = JSON.parse(raw); + return typeof parsed === 'object' && parsed !== null; + } catch { + return false; + } + } + _UNSAFE_exportTssKey(): Promise { return this.coreKit._UNSAFE_exportTssKey(); } From 7669450b9c0509c665bead732ee78a459360e4a1 Mon Sep 17 00:00:00 2001 From: Michael Yankelev Date: Sat, 8 Aug 2026 21:01:56 +0200 Subject: [PATCH 2/2] test: drive the Core Kit store recovery through the SDK's real read The fake SDK only parsed its store, while the real `AsyncStorage.get` parses and then indexes the result for `sessionId`. A store holding the JSON `null` literal therefore survived the fake and threw in the SDK, so the `parsed !== null` arm of `storeIsReadable` was never exercised. The fake now performs the index, and both corrupt-store tests run over every shape the SDK's read throws on: a truncated write and a null literal. Co-Authored-By: Claude Opus 5 --- apps/web/src/auth/CoreKitProvider.test.tsx | 76 +++++++++++++--------- 1 file changed, 47 insertions(+), 29 deletions(-) diff --git a/apps/web/src/auth/CoreKitProvider.test.tsx b/apps/web/src/auth/CoreKitProvider.test.tsx index 983a40d2e7..6f8b88ad43 100644 --- a/apps/web/src/auth/CoreKitProvider.test.tsx +++ b/apps/web/src/auth/CoreKitProvider.test.tsx @@ -6,23 +6,35 @@ import { createCoreKitSession } from './coreKit'; import { CoreKitProvider, useCoreKit } from './CoreKitProvider'; const STORE_KEY = 'corekit_store'; -/** A truncated write, which is what an evicted or half-flushed store looks like. */ -const CORRUPT_STORE = '{"sessionId":"a-sessio'; +/** Every store shape the SDK's read throws on, since it parses and then indexes. */ +const UNREADABLE_STORES: [string, string][] = [ + // What an evicted or half-flushed store looks like: the parse itself throws. + ['a truncated write', '{"sessionId":"a-sessio'], + // Parses cleanly, then the index step throws because `null` has no keys. + ['a null literal', 'null'], +]; const SIGNED_OUT_STORE = '{"deviceFactor":"a-device-factor"}'; const LOGGED_IN_STORE = '{"sessionId":"a-fresh-session-id"}'; /** The SDK's own feature check is a bare `fetch`, so a restore fails offline. */ const OFFLINE = new Error('Failed to fetch'); -// The SDK reads its store through a bare `JSON.parse` on both the restore and -// the login path, so one unreadable blob defeats every later login too. The -// fake reproduces that read, and the network leg that follows it in `init`. +// The SDK reads its store as `JSON.parse(raw || '{}')[key]` (`AsyncStorage.get`, +// which `init` calls for `sessionId`) on both the restore and the login path, so +// one unreadable blob defeats every later login too. The fake reproduces that +// read — parse *and* index — and the network leg that follows it in `init`. const sdk = vi.hoisted(() => ({ status: 'NOT_INITIALIZED', initFailure: undefined as Error | undefined, })); vi.mock('@web3auth/mpc-core-kit', async (importOriginal) => { const actual = await importOriginal(); - const readStore = (): unknown => JSON.parse(window.localStorage.getItem(STORE_KEY) ?? '{}'); + const readStore = (): unknown => { + const parsed = JSON.parse(window.localStorage.getItem(STORE_KEY) || '{}') as Record< + string, + unknown + >; + return parsed.sessionId; + }; return { ...actual, Web3AuthMPCCoreKit: class { @@ -67,29 +79,35 @@ describe('CoreKitProvider', () => { window.localStorage.clear(); }); - it('discards a store the restore could not read, and does not pass it off as a signed-out tab', async () => { - window.localStorage.setItem(STORE_KEY, CORRUPT_STORE); - const { result } = mount(); - - await waitFor(() => expect(result.current.status).toBe('ready')); - - expect(result.current.error).toMatch(/could not be restored/); - expect(result.current.session?.isLoggedIn()).toBe(false); - expect(window.localStorage.getItem(STORE_KEY)).toBeNull(); - }); - - it('leaves a login after a failed restore able to establish a session', async () => { - window.localStorage.setItem(STORE_KEY, CORRUPT_STORE); - const { result } = mount(); - await waitFor(() => expect(result.current.status).toBe('ready')); - - await act(async () => { - await result.current.session?.login('google'); - }); - - expect(result.current.session?.isLoggedIn()).toBe(true); - expect(window.localStorage.getItem(STORE_KEY)).toBe(LOGGED_IN_STORE); - }); + it.each(UNREADABLE_STORES)( + 'discards %s the restore could not read, and does not pass it off as a signed-out tab', + async (_shape, store) => { + window.localStorage.setItem(STORE_KEY, store); + const { result } = mount(); + + await waitFor(() => expect(result.current.status).toBe('ready')); + + expect(result.current.error).toMatch(/could not be restored/); + expect(result.current.session?.isLoggedIn()).toBe(false); + expect(window.localStorage.getItem(STORE_KEY)).toBeNull(); + } + ); + + it.each(UNREADABLE_STORES)( + 'leaves a login after a restore that failed on %s able to establish a session', + async (_shape, store) => { + window.localStorage.setItem(STORE_KEY, store); + const { result } = mount(); + await waitFor(() => expect(result.current.status).toBe('ready')); + + await act(async () => { + await result.current.session?.login('google'); + }); + + expect(result.current.session?.isLoggedIn()).toBe(true); + expect(window.localStorage.getItem(STORE_KEY)).toBe(LOGGED_IN_STORE); + } + ); it('keeps a readable store when the restore failed for some other reason', async () => { window.localStorage.setItem(STORE_KEY, SIGNED_OUT_STORE);