From 708513f60d8a364cc508f8fbe0d88666c9aa1ce3 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Tue, 21 Jul 2026 17:48:42 -0700 Subject: [PATCH 1/3] fix(expo): Reject session-less foreign native client tokens during sync --- .../guard-native-client-sync-ghost-tokens.md | 5 + .../ClerkProvider.nativeClientSync.test.tsx | 415 ++++++++++++++++++ .../expo/src/provider/nativeClientSync.tsx | 63 ++- 3 files changed, 477 insertions(+), 6 deletions(-) create mode 100644 .changeset/guard-native-client-sync-ghost-tokens.md diff --git a/.changeset/guard-native-client-sync-ghost-tokens.md b/.changeset/guard-native-client-sync-ghost-tokens.md new file mode 100644 index 00000000000..3b253dc8ab4 --- /dev/null +++ b/.changeset/guard-native-client-sync-ghost-tokens.md @@ -0,0 +1,5 @@ +--- +'@clerk/expo': patch +--- + +Fix signed-in sessions being dropped shortly after browser SSO on Android when the native SDK refreshes with a stale device token and receives a new anonymous client. The native-to-JS client sync now rejects a device token that belongs to a different client with no signed-in sessions, keeps the JS session active, and re-syncs the native SDK back to the JS client. Native client events that carry a stale device token are also ignored while a JS-to-native token sync is still in flight. diff --git a/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx b/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx index b6ae6bb8eef..8434bc68cb6 100644 --- a/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx +++ b/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx @@ -1302,4 +1302,419 @@ describe('ClerkProvider native client sync', () => { expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith(null, expect.any(String), false, true); }); }); + + test('rejects a foreign session-less native client token while JS is signed in and heals native', async () => { + const activeSession = { + id: 'session_1', + status: 'active', + user: { id: 'user_1' }, + }; + const ghostClient = { + id: 'client_ghost', + signedInSessions: [], + lastActiveSessionId: null, + }; + const originalUpdateClient = mocks.clerkInstance.updateClient; + + mocks.tokenCache.getToken.mockResolvedValue('js-client-token'); + mocks.getClientToken.mockResolvedValue('js-client-token'); + mocks.clerkInstance.client = { + id: 'client_js', + signedInSessions: [activeSession], + lastActiveSessionId: 'session_1', + fetch: vi.fn().mockResolvedValue(ghostClient), + }; + mocks.clerkInstance.session = activeSession; + + const { rerender } = render( + , + ); + + await waitFor(() => { + expect(mocks.configure).toHaveBeenCalled(); + }); + + mocks.tokenCache.saveToken.mockClear(); + mocks.syncClientStateFromJs.mockClear(); + mocks.clerkInstance.__internal_reloadInitialResources.mockClear(); + + mocks.nativeClientEvent = { + issuedAt: 1, + changed: { + client: true, + deviceToken: true, + }, + deviceToken: 'ghost-client-token', + }; + rerender( + , + ); + + await waitFor(() => { + expect(mocks.tokenCache.saveToken).toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, 'js-client-token'); + }); + expect(mocks.tokenCache.saveToken).toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, 'ghost-client-token'); + expect(originalUpdateClient).not.toHaveBeenCalledWith(ghostClient); + expect(originalUpdateClient).not.toHaveBeenCalledWith(ghostClient, expect.anything()); + expect(mocks.clerkInstance.session).toBe(activeSession); + expect(mocks.clerkInstance.__internal_reloadInitialResources).not.toHaveBeenCalled(); + + await waitFor(() => { + expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith('js-client-token', expect.any(String), false, true); + }); + }); + + test('still signs JS out when the same client loses its signed-in sessions', async () => { + const activeSession = { + id: 'session_1', + status: 'active', + user: { id: 'user_1' }, + }; + const signedOutClient = { + id: 'client_shared', + signedInSessions: [], + lastActiveSessionId: null, + }; + const originalUpdateClient = mocks.clerkInstance.updateClient; + + mocks.tokenCache.getToken.mockResolvedValue('js-client-token'); + mocks.getClientToken.mockResolvedValue('js-client-token'); + mocks.clerkInstance.client = { + id: 'client_shared', + signedInSessions: [activeSession], + lastActiveSessionId: 'session_1', + fetch: vi.fn().mockResolvedValue(signedOutClient), + }; + mocks.clerkInstance.session = activeSession; + + const { rerender } = render( + , + ); + + await waitFor(() => { + expect(mocks.configure).toHaveBeenCalled(); + }); + + mocks.syncClientStateFromJs.mockClear(); + + mocks.nativeClientEvent = { + issuedAt: 1, + changed: { + client: true, + deviceToken: false, + }, + deviceToken: 'js-client-token', + }; + rerender( + , + ); + + await waitFor(() => { + expect(originalUpdateClient).toHaveBeenCalledWith(signedOutClient); + }); + expect(mocks.syncClientStateFromJs).not.toHaveBeenCalled(); + }); + + test('adopts a different native client that has signed-in sessions', async () => { + const activeSession = { + id: 'session_1', + status: 'active', + user: { id: 'user_1' }, + }; + const nativeSession = { + id: 'session_2', + status: 'active', + user: { id: 'user_2' }, + }; + const nativeClient = { + id: 'client_native', + signedInSessions: [nativeSession], + lastActiveSessionId: 'session_2', + }; + const originalUpdateClient = mocks.clerkInstance.updateClient; + + mocks.tokenCache.getToken.mockResolvedValue('js-client-token'); + mocks.getClientToken.mockResolvedValue('js-client-token'); + mocks.clerkInstance.client = { + id: 'client_js', + signedInSessions: [activeSession], + lastActiveSessionId: 'session_1', + fetch: vi.fn().mockResolvedValue(nativeClient), + }; + mocks.clerkInstance.session = activeSession; + mocks.clerkInstance.setActive.mockImplementation(({ session }) => { + mocks.clerkInstance.session = session; + return Promise.resolve(); + }); + + const { rerender } = render( + , + ); + + await waitFor(() => { + expect(mocks.configure).toHaveBeenCalled(); + }); + + mocks.tokenCache.saveToken.mockClear(); + mocks.clerkInstance.setActive.mockClear(); + + mocks.nativeClientEvent = { + issuedAt: 1, + changed: { + client: true, + deviceToken: true, + }, + deviceToken: 'native-client-token', + }; + rerender( + , + ); + + await waitFor(() => { + expect(mocks.clerkInstance.setActive).toHaveBeenCalledWith({ session: nativeSession }); + }); + expect(originalUpdateClient).toHaveBeenCalledWith(nativeClient, { __internal_dangerouslySkipEmit: true }); + expect(mocks.tokenCache.saveToken).toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, 'native-client-token'); + expect(mocks.tokenCache.saveToken).not.toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, 'js-client-token'); + }); + + test('ignores divergent native events while a JS-to-native device token push is in flight', async () => { + mocks.tokenCache.getToken.mockResolvedValue(null); + let resolveFirstSync: (() => void) | undefined; + mocks.syncClientStateFromJs.mockImplementationOnce(() => { + return new Promise(resolve => { + resolveFirstSync = resolve; + }); + }); + + const fetchSpy = vi.fn().mockResolvedValue({ + id: 'client_ghost', + signedInSessions: [], + lastActiveSessionId: null, + }); + mocks.clerkInstance.client = { + id: 'client_js', + signedInSessions: [], + lastActiveSessionId: null, + fetch: fetchSpy, + }; + + const { rerender } = render( + , + ); + + await waitFor(() => { + expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null); + }); + + await act(async () => { + await mocks.clerkOptions?.tokenCache?.saveToken(CLERK_CLIENT_JWT_KEY, 'rotated-client-token'); + }); + + await waitFor(() => { + expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith('rotated-client-token', expect.any(String), false, true); + }); + + mocks.tokenCache.saveToken.mockClear(); + mocks.clerkInstance.__internal_reloadInitialResources.mockClear(); + + mocks.nativeClientEvent = { + issuedAt: 1, + changed: { + client: true, + deviceToken: true, + }, + deviceToken: 'ghost-client-token', + }; + rerender( + , + ); + + await act(async () => {}); + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(mocks.tokenCache.saveToken).not.toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, 'ghost-client-token'); + expect(mocks.clerkInstance.__internal_reloadInitialResources).not.toHaveBeenCalled(); + + await act(async () => { + resolveFirstSync?.(); + }); + + mocks.nativeClientEvent = { + issuedAt: 2, + changed: { + client: true, + deviceToken: true, + }, + deviceToken: 'ghost-client-token', + }; + rerender( + , + ); + + await waitFor(() => { + expect(fetchSpy).toHaveBeenCalled(); + }); + }); + + test('processes native events again after a failed JS-to-native push settles', async () => { + mocks.tokenCache.getToken.mockResolvedValue(null); + let rejectFirstSync: ((error: Error) => void) | undefined; + mocks.syncClientStateFromJs.mockImplementationOnce(() => { + return new Promise((_resolve, reject) => { + rejectFirstSync = reject; + }); + }); + + const fetchSpy = vi.fn().mockResolvedValue({ + id: 'client_ghost', + signedInSessions: [], + lastActiveSessionId: null, + }); + mocks.clerkInstance.client = { + id: 'client_js', + signedInSessions: [], + lastActiveSessionId: null, + fetch: fetchSpy, + }; + + const { rerender } = render( + , + ); + + await waitFor(() => { + expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null); + }); + + await act(async () => { + await mocks.clerkOptions?.tokenCache?.saveToken(CLERK_CLIENT_JWT_KEY, 'rotated-client-token'); + }); + + await waitFor(() => { + expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith('rotated-client-token', expect.any(String), false, true); + }); + + mocks.nativeClientEvent = { + issuedAt: 1, + changed: { + client: true, + deviceToken: true, + }, + deviceToken: 'ghost-client-token', + }; + rerender( + , + ); + + await act(async () => {}); + expect(fetchSpy).not.toHaveBeenCalled(); + + await act(async () => { + rejectFirstSync?.(new Error('native sync failed')); + }); + + mocks.nativeClientEvent = { + issuedAt: 2, + changed: { + client: true, + deviceToken: true, + }, + deviceToken: 'ghost-client-token', + }; + rerender( + , + ); + + await waitFor(() => { + expect(fetchSpy).toHaveBeenCalled(); + }); + }); + + test('adopts a foreign session-less client while JS is signed out', async () => { + const ghostClient = { + id: 'client_ghost', + signedInSessions: [], + lastActiveSessionId: null, + }; + const originalUpdateClient = mocks.clerkInstance.updateClient; + + mocks.tokenCache.getToken.mockResolvedValue('js-client-token'); + mocks.getClientToken.mockResolvedValue('js-client-token'); + mocks.clerkInstance.client = { + id: 'client_js', + signedInSessions: [], + lastActiveSessionId: null, + fetch: vi.fn().mockResolvedValue(ghostClient), + }; + mocks.clerkInstance.session = undefined; + + const { rerender } = render( + , + ); + + await waitFor(() => { + expect(mocks.configure).toHaveBeenCalled(); + }); + + mocks.syncClientStateFromJs.mockClear(); + + mocks.nativeClientEvent = { + issuedAt: 1, + changed: { + client: true, + deviceToken: true, + }, + deviceToken: 'ghost-client-token', + }; + rerender( + , + ); + + await waitFor(() => { + expect(originalUpdateClient).toHaveBeenCalledWith(ghostClient); + }); + expect(mocks.syncClientStateFromJs).not.toHaveBeenCalled(); + }); }); diff --git a/packages/expo/src/provider/nativeClientSync.tsx b/packages/expo/src/provider/nativeClientSync.tsx index 1f50aa0699e..4047257a7bb 100644 --- a/packages/expo/src/provider/nativeClientSync.tsx +++ b/packages/expo/src/provider/nativeClientSync.tsx @@ -40,6 +40,7 @@ type NativeRefreshFromJsOptions = { export type NativeRefreshFromJsController = { cancel: () => void; + getInFlightDeviceTokenPush?: () => { deviceToken: string | null } | null; }; export type DeviceTokenCacheListener = (deviceToken: string | null) => void; @@ -204,22 +205,32 @@ function getDefaultSignedInSession(client: ClientResource | null | undefined): S return client.signedInSessions[0] ?? null; } -async function refreshJsClientFromServer(clerkInstance: SyncableClerkInstance): Promise { +async function fetchRefreshedJsClient(clerkInstance: SyncableClerkInstance): Promise { const client = clerkInstance.client as RefreshableClientResource | undefined; if (typeof client?.fetch !== 'function' || typeof clerkInstance.updateClient !== 'function') { return null; } - const refreshedClient = await client.fetch({ fetchMaxTries: 1 }); - clerkInstance.updateClient(refreshedClient); + return client.fetch({ fetchMaxTries: 1 }); +} - return refreshedClient; +function isForeignSessionlessClient( + previousClient: ClientResource | null | undefined, + refreshedClient: ClientResource, +): boolean { + if (!previousClient?.id || !refreshedClient.id || previousClient.id === refreshedClient.id) { + return false; + } + + return Boolean(getDefaultSignedInSession(previousClient)) && refreshedClient.signedInSessions.length === 0; } async function refreshJsClientFromNativeState({ clerkInstance, nativeDeviceToken, + previousDeviceToken, + rejectForeignSessionlessClient = false, reloadInitialResources, shouldSyncDeviceToken = true, suppressTokenCacheNotificationsRef, @@ -227,11 +238,15 @@ async function refreshJsClientFromNativeState({ }: { clerkInstance: SyncableClerkInstance; nativeDeviceToken: string | null; + previousDeviceToken?: string | null; + rejectForeignSessionlessClient?: boolean; reloadInitialResources: boolean; shouldSyncDeviceToken?: boolean; suppressTokenCacheNotificationsRef?: MutableRefObject; tokenCache: TokenCache | undefined; }): Promise { + const previousClient = clerkInstance.client; + if (shouldSyncDeviceToken) { await syncNativeDeviceTokenToCache({ deviceToken: nativeDeviceToken, @@ -240,8 +255,17 @@ async function refreshJsClientFromNativeState({ }); } - const refreshedClient = await refreshJsClientFromServer(clerkInstance); + const refreshedClient = await fetchRefreshedJsClient(clerkInstance); if (refreshedClient) { + if (rejectForeignSessionlessClient && isForeignSessionlessClient(previousClient, refreshedClient)) { + // Restoring the previous token also notifies native to re-sync to it. + if (shouldSyncDeviceToken && previousDeviceToken !== undefined) { + await syncDeviceTokenToCache(tokenCache, previousDeviceToken); + } + return true; + } + + clerkInstance.updateClient?.(refreshedClient); await reconcileJsActiveSessionFromClient({ clerkInstance, }); @@ -434,12 +458,16 @@ async function syncNativeClientToJs({ return; } + const previousDeviceToken = didChangeDeviceToken ? await getCachedDeviceToken(tokenCache) : undefined; + await runWithSuppressedJsClientChanges(suppressJsClientChangedRef, async () => { nativeRefreshFromJsControllerRef?.current?.cancel(); await refreshJsClientFromNativeState({ clerkInstance, nativeDeviceToken, + previousDeviceToken, + rejectForeignSessionlessClient: true, reloadInitialResources: true, shouldSyncDeviceToken: didChangeDeviceToken, suppressTokenCacheNotificationsRef, @@ -476,6 +504,7 @@ export function NativeClientSync({ const pendingNativeRefreshRef = useRef(null); const pendingNativeRefreshBeforeReadyRef = useRef(null); const nativeRefreshGenerationRef = useRef(0); + const inFlightDeviceTokenPushRef = useRef<{ deviceToken: string | null } | null>(null); const enabledRef = useRef(enabled); enabledRef.current = enabled; @@ -484,11 +513,17 @@ export function NativeClientSync({ pendingNativeRefreshBeforeReadyRef.current = null; nativeRefreshGenerationRef.current += 1; isRefreshingNativeFromJsRef.current = false; + inFlightDeviceTokenPushRef.current = null; + }, []); + + const getInFlightDeviceTokenPush = useCallback(() => { + return isRefreshingNativeFromJsRef.current ? inFlightDeviceTokenPushRef.current : null; }, []); useEffect(() => { nativeRefreshFromJsControllerRef.current = { cancel: cancelNativeRefreshFromJs, + getInFlightDeviceTokenPush, }; return () => { @@ -496,7 +531,7 @@ export function NativeClientSync({ nativeRefreshFromJsControllerRef.current = null; } }; - }, [cancelNativeRefreshFromJs, nativeRefreshFromJsControllerRef]); + }, [cancelNativeRefreshFromJs, getInFlightDeviceTokenPush, nativeRefreshFromJsControllerRef]); useEffect(() => { if ( @@ -563,15 +598,23 @@ export function NativeClientSync({ }, [clerkInstance, suppressJsClientChangedRef]); const queueNativeRefreshFromJs = useCallback((options: NativeRefreshFromJsOptions): void => { + const trackInFlightDeviceTokenPush = (pushOptions: NativeRefreshFromJsOptions) => { + if (pushOptions.didChangeDeviceToken) { + inFlightDeviceTokenPushRef.current = { deviceToken: pushOptions.deviceToken ?? null }; + } + }; + if (isRefreshingNativeFromJsRef.current) { pendingNativeRefreshRef.current = mergePendingNativeRefreshOptions(pendingNativeRefreshRef.current, options); nativeRefreshGenerationRef.current += 1; + trackInFlightDeviceTokenPush(pendingNativeRefreshRef.current); return; } const initialGeneration = nativeRefreshGenerationRef.current + 1; nativeRefreshGenerationRef.current = initialGeneration; isRefreshingNativeFromJsRef.current = true; + trackInFlightDeviceTokenPush(options); const refreshNativeFromJsClient = async ( options: NativeRefreshFromJsOptions, @@ -623,6 +666,7 @@ export function NativeClientSync({ })().finally(() => { if (latestRunGeneration === nativeRefreshGenerationRef.current || pendingNativeRefreshRef.current === null) { isRefreshingNativeFromJsRef.current = false; + inFlightDeviceTokenPushRef.current = null; } }); }, []); @@ -969,6 +1013,13 @@ export function useNativeClientEventSync({ return; } + const inFlightDeviceTokenPush = nativeRefreshFromJsControllerRef.current?.getInFlightDeviceTokenPush?.(); + if (inFlightDeviceTokenPush && nativeClientEvent.deviceToken !== inFlightDeviceTokenPush.deviceToken) { + // The event was computed before the device token JS is currently pushing + // to native; that push realigns native, so the stale token must not win. + return; + } + const syncNativeClientStateToJs = async () => { try { if (!isMountedRef.current) { From 51a03c32d0fa474e45214493911b8aefdc8954ae Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Tue, 21 Jul 2026 17:48:43 -0700 Subject: [PATCH 2/3] test(e2e): Add Maestro regression for native client token divergence --- integration/templates/expo-native/App.tsx | 109 +++++++++++++++++- .../modules/e2e-hooks/android/build.gradle | 48 ++++++++ .../java/com/clerk/e2ehooks/E2EHooksModule.kt | 35 ++++++ .../modules/e2e-hooks/expo-module.config.json | 6 + ...sion-survives-native-token-divergence.yaml | 44 +++++++ 5 files changed, 238 insertions(+), 4 deletions(-) create mode 100644 integration/templates/expo-native/modules/e2e-hooks/android/build.gradle create mode 100644 integration/templates/expo-native/modules/e2e-hooks/android/src/main/java/com/clerk/e2ehooks/E2EHooksModule.kt create mode 100644 integration/templates/expo-native/modules/e2e-hooks/expo-module.config.json create mode 100644 integration/tests/expo-native/flows/session-survives-native-token-divergence.yaml diff --git a/integration/templates/expo-native/App.tsx b/integration/templates/expo-native/App.tsx index 2031e9b4512..b4549275183 100644 --- a/integration/templates/expo-native/App.tsx +++ b/integration/templates/expo-native/App.tsx @@ -1,9 +1,10 @@ -import { ClerkProvider, useAuth, useUser } from '@clerk/expo'; +import { ClerkProvider, useAuth, useSignIn, useUser } from '@clerk/expo'; import { useSignInWithGoogle } from '@clerk/expo/google'; import { AuthView, UserButton } from '@clerk/expo/native'; import { tokenCache } from '@clerk/expo/token-cache'; +import { requireOptionalNativeModule } from 'expo'; import { useState } from 'react'; -import { Button, Modal, StyleSheet, Text, View } from 'react-native'; +import { Button, Modal, StyleSheet, Text, TextInput, View } from 'react-native'; const publishableKey = process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY; @@ -11,12 +12,57 @@ if (!publishableKey) { throw new Error('Missing EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY'); } +// Fixture-local Maestro hook (modules/e2e-hooks); android-only, null elsewhere. +const E2EHooks = requireOptionalNativeModule<{ corruptNativeDeviceToken(): Promise }>('E2EHooks'); + function NativeBuildFixture() { - const { isLoaded, isSignedIn, signOut } = useAuth({ treatPendingAsSignedOut: false }); + const { isLoaded, isSignedIn, getToken, signOut } = useAuth({ treatPendingAsSignedOut: false }); const { user } = useUser(); + const { signIn } = useSignIn(); const { startGoogleAuthenticationFlow } = useSignInWithGoogle(); const [isAuthOpen, setIsAuthOpen] = useState(false); const [googleResult, setGoogleResult] = useState(null); + const [identifier, setIdentifier] = useState(''); + const [password, setPassword] = useState(''); + const [e2eStatus, setE2eStatus] = useState(null); + + const onJsSignIn = async () => { + try { + const { error } = await signIn.password({ identifier, password }); + if (error) { + setE2eStatus(`sign-in-error ${error.message ?? ''}`.replace(/\s+/g, ' ').trim()); + return; + } + const { error: finalizeError } = await signIn.finalize(); + if (finalizeError) { + setE2eStatus(`sign-in-error ${finalizeError.message ?? ''}`.replace(/\s+/g, ' ').trim()); + } + } catch (error) { + setE2eStatus(`sign-in-error ${String(error)}`.replace(/\s+/g, ' ')); + } + }; + + const onCorruptNativeToken = async () => { + setE2eStatus(null); + try { + const didCorrupt = await E2EHooks?.corruptNativeDeviceToken(); + // Delay the marker so Maestro cannot race the native client event and + // the JS sync settling. + setTimeout(() => setE2eStatus(didCorrupt ? 'corrupt-done' : 'corrupt-failed'), 3000); + } catch { + setE2eStatus('corrupt-failed'); + } + }; + + const onMintSessionToken = async () => { + setE2eStatus(null); + try { + const token = await getToken({ skipCache: true }); + setE2eStatus(token ? 'token-ok' : 'token-empty'); + } catch { + setE2eStatus('token-error'); + } + }; return ( @@ -45,6 +91,49 @@ function NativeBuildFixture() { /> )} {googleResult && {googleResult}} + {!isSignedIn && ( + <> + + +