Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
133 changes: 133 additions & 0 deletions apps/web/src/auth/CoreKitProvider.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
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';
/** 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 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<typeof import('@web3auth/mpc-core-kit')>();
const readStore = (): unknown => {
const parsed = JSON.parse(window.localStorage.getItem(STORE_KEY) || '{}') as Record<
string,
unknown
>;
return parsed.sessionId;
};
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.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);
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