From f6ed0646cf17754ffc964f1efa4d77b606a7c53b Mon Sep 17 00:00:00 2001 From: Gustavo Date: Thu, 17 Sep 2026 10:52:24 -0300 Subject: [PATCH] CertiK: Fix - Clear stale session cookies [BIP-10] --- src/store/bitpay-id/bitpay-id.effects.spec.ts | 66 ++++++++++++++++++- src/store/bitpay-id/bitpay-id.effects.ts | 21 +++++- 2 files changed, 84 insertions(+), 3 deletions(-) diff --git a/src/store/bitpay-id/bitpay-id.effects.spec.ts b/src/store/bitpay-id/bitpay-id.effects.spec.ts index e459ca3330..d411213207 100644 --- a/src/store/bitpay-id/bitpay-id.effects.spec.ts +++ b/src/store/bitpay-id/bitpay-id.effects.spec.ts @@ -2,7 +2,7 @@ * Tests for bitpay-id.effects.ts * * Covers: - * - startFetchSession (success + failure) + * - startFetchSession (success + failure + stale-cookie clear when paired) * - startBitPayIdStoreInit (dispatches SUCCESS_INITIALIZE_STORE) * - startBitPayIdAnalyticsInit (Braze merge branch, no-op when user is falsy) * - checkLoginWithPasskey (no email, passkey false, passkey true, error 1001, other error) @@ -14,6 +14,7 @@ */ import configureTestStore from '@test/store'; +import {clearAllCookiesEverywhere} from '../../utils/cookieAuth'; import {Network} from '../../constants'; import {BitPayIdActionTypes} from './bitpay-id.types'; import { @@ -256,6 +257,69 @@ describe('startFetchSession', () => { // failedFetchSession sets fetchSessionStatus to 'failed' expect(store.getState().BITPAY_ID.fetchSessionStatus).toBe('failed'); }); + + it('clears cookies and re-fetches when a paired user has a dead session', async () => { + (MockAuthApi.fetchSession as jest.Mock) + .mockResolvedValueOnce(makeSession({isAuthenticated: false})) + .mockResolvedValueOnce( + makeSession({csrfToken: 'fresh-token', isAuthenticated: false}), + ); + + const store = baseStore(); + await store.dispatch(startFetchSession()); + + expect(clearAllCookiesEverywhere).toHaveBeenCalledTimes(1); + expect(MockAuthApi.fetchSession).toHaveBeenCalledTimes(2); + // The stored csrfToken must come from the session fetched after the clear. + expect(store.getState().BITPAY_ID.session.csrfToken).toBe('fresh-token'); + }); + + it('does NOT clear cookies when the user is not paired', async () => { + (MockAuthApi.fetchSession as jest.Mock).mockResolvedValueOnce( + makeSession({isAuthenticated: false}), + ); + + const store = configureTestStore({ + BITPAY_ID: { + session: makeSession(), + apiToken: {[Network.mainnet]: ''}, + }, + APP: {network: Network.mainnet}, + }); + await store.dispatch(startFetchSession()); + + expect(clearAllCookiesEverywhere).not.toHaveBeenCalled(); + expect(MockAuthApi.fetchSession).toHaveBeenCalledTimes(1); + }); + + it('does NOT clear cookies when the session is authenticated', async () => { + (MockAuthApi.fetchSession as jest.Mock).mockResolvedValueOnce( + makeSession({isAuthenticated: true}), + ); + + const store = baseStore(); + await store.dispatch(startFetchSession()); + + expect(clearAllCookiesEverywhere).not.toHaveBeenCalled(); + expect(MockAuthApi.fetchSession).toHaveBeenCalledTimes(1); + }); + + it('still re-fetches when clearing cookies throws', async () => { + (clearAllCookiesEverywhere as jest.Mock).mockRejectedValueOnce( + new Error('cookie store unavailable'), + ); + (MockAuthApi.fetchSession as jest.Mock) + .mockResolvedValueOnce(makeSession({isAuthenticated: false})) + .mockResolvedValueOnce( + makeSession({csrfToken: 'fresh-token', isAuthenticated: false}), + ); + + const store = baseStore(); + await store.dispatch(startFetchSession()); + + expect(MockAuthApi.fetchSession).toHaveBeenCalledTimes(2); + expect(store.getState().BITPAY_ID.session.csrfToken).toBe('fresh-token'); + }); }); // --------------------------------------------------------------------------- diff --git a/src/store/bitpay-id/bitpay-id.effects.ts b/src/store/bitpay-id/bitpay-id.effects.ts index 4299f4089a..99f93081ab 100644 --- a/src/store/bitpay-id/bitpay-id.effects.ts +++ b/src/store/bitpay-id/bitpay-id.effects.ts @@ -148,10 +148,27 @@ export const startBitPayIdStoreInit = export const startFetchSession = (): Effect> => async (dispatch, getState) => { try { - const {APP} = getState(); + const {APP, BITPAY_ID} = getState(); dispatch(BitPayIdActions.updateFetchSessionStatus('loading')); - const session = await AuthApi.fetchSession(APP.network); + let session = await AuthApi.fetchSession(APP.network); + + // A paired user with a dead web session leaves only stale cookies on + // disk. Drop them, then re-fetch: the csrfToken just read is bound to the + // cookie being cleared, so storing it would orphan every consumer. + if (!session.isAuthenticated && BITPAY_ID.apiToken[APP.network]) { + try { + await clearAllCookiesEverywhere(); + } catch (err: any) { + const errMsg = + err instanceof Error ? err.message : JSON.stringify(err); + logManager.debug( + '[startFetchSession] An error occurred while clearing cookies.', + errMsg, + ); + } + session = await AuthApi.fetchSession(APP.network); + } dispatch(BitPayIdActions.successFetchSession(session)); } catch {