Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions apps/web/src/auth/CoreKitProvider.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof import('@web3auth/mpc-core-kit')>();
const readStore = (): unknown => JSON.parse(window.localStorage.getItem(STORE_KEY) ?? '{}');
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
return {
...actual,
Web3AuthMPCCoreKit: class {
readonly _storageKey = STORE_KEY;
get status(): string {
return sdk.status;
}
async init(): Promise<void> {
readStore();
if (sdk.initFailure) throw sdk.initFailure;
}
async loginWithOAuth(): Promise<void> {
readStore();
window.localStorage.setItem(STORE_KEY, LOGGED_IN_STORE);
sdk.status = actual.COREKIT_STATUS.LOGGED_IN;
}
commitChanges(): Promise<void> {
return Promise.resolve();
}
},
};
});

const ENV = {
VITE_WEB3AUTH_CLIENT_ID: 'client-id',
VITE_WEB3AUTH_VERIFIER: 'verifier',
} satisfies Partial<ImportMetaEnv>;

/** 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 }) => (
<CoreKitProvider createSession={() => createCoreKitSession(ENV)}>{children}</CoreKitProvider>
),
});
}

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);
});
});
29 changes: 18 additions & 11 deletions apps/web/src/auth/CoreKitProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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;
Expand Down
25 changes: 23 additions & 2 deletions apps/web/src/auth/coreKit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,15 @@ class Web3AuthSession implements CoreKitSession {
) {}

async restore(): Promise<void> {
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 {
Expand Down Expand Up @@ -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<string> {
return this.coreKit._UNSAFE_exportTssKey();
}
Expand Down