From c9a13a1fd632054a6fabd1091c3c341d5302c69e Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Wed, 22 Jul 2026 15:22:45 -0700 Subject: [PATCH 1/4] fix(expo): Prevent stale native clients from replacing signed-in sessions --- .changeset/protect-expo-native-client-sync.md | 5 + .../ClerkProvider.nativeClientSync.test.tsx | 306 ++++++++++++++++++ .../expo/src/provider/nativeClientSync.tsx | 138 ++++++-- 3 files changed, 425 insertions(+), 24 deletions(-) create mode 100644 .changeset/protect-expo-native-client-sync.md diff --git a/.changeset/protect-expo-native-client-sync.md b/.changeset/protect-expo-native-client-sync.md new file mode 100644 index 00000000000..b761633f98b --- /dev/null +++ b/.changeset/protect-expo-native-client-sync.md @@ -0,0 +1,5 @@ +--- +'@clerk/expo': patch +--- + +Prevent native client updates from replacing a signed-in Expo client with a stale or sessionless client. Native-to-JS synchronization now keeps newer JavaScript tokens, validates client changes before applying them, and restores the previous token when validation fails. diff --git a/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx b/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx index b6ae6bb8eef..e67b54084b7 100644 --- a/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx +++ b/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx @@ -124,6 +124,13 @@ function deferred(): { promise: Promise; resolve: () => void } { return { promise, resolve }; } +function makeClientJwt(iat: number): string { + const encode = (value: object) => + btoa(JSON.stringify(value)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + + return `${encode({ alg: 'none' })}.${encode({ iat })}.signature`; +} + describe('ClerkProvider native client sync', () => { beforeEach(() => { vi.clearAllMocks(); @@ -1302,4 +1309,303 @@ describe('ClerkProvider native client sync', () => { expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith(null, expect.any(String), false, true); }); }); + + test('keeps a strictly newer signed-in JS token authoritative over a native event', async () => { + const jsDeviceToken = makeClientJwt(200); + const nativeDeviceToken = makeClientJwt(100); + const activeSession = { + id: 'session_1', + status: 'active', + user: { id: 'user_1' }, + }; + const fetchClient = vi.fn(); + + mocks.tokenCache.getToken.mockResolvedValue(jsDeviceToken); + mocks.getClientToken.mockResolvedValue(jsDeviceToken); + mocks.clerkInstance.client = { + id: 'client_js', + signedInSessions: [activeSession], + lastActiveSessionId: activeSession.id, + fetch: fetchClient, + }; + mocks.clerkInstance.session = activeSession; + + const { rerender } = render( + , + ); + + await waitFor(() => { + expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', jsDeviceToken); + }); + + mocks.syncClientStateFromJs.mockClear(); + mocks.tokenCache.saveToken.mockClear(); + + mocks.nativeClientEvent = { + issuedAt: 1, + changed: { + client: true, + deviceToken: true, + }, + deviceToken: nativeDeviceToken, + }; + rerender( + , + ); + + await waitFor(() => { + expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith(jsDeviceToken, expect.any(String), false, true); + }); + expect(fetchClient).not.toHaveBeenCalled(); + expect(mocks.tokenCache.saveToken).not.toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, nativeDeviceToken); + }); + + test('rejects a foreign session-less native client and restores the signed-in JS token', async () => { + const jsDeviceToken = makeClientJwt(100); + const nativeDeviceToken = makeClientJwt(200); + const activeSession = { + id: 'session_1', + status: 'active', + user: { id: 'user_1' }, + }; + const foreignClient = { + id: 'client_foreign', + signedInSessions: [], + lastActiveSessionId: null, + }; + const updateClient = mocks.clerkInstance.updateClient; + + mocks.tokenCache.getToken.mockResolvedValue(jsDeviceToken); + mocks.getClientToken.mockResolvedValue(jsDeviceToken); + mocks.clerkInstance.client = { + id: 'client_js', + signedInSessions: [activeSession], + lastActiveSessionId: activeSession.id, + fetch: vi.fn().mockResolvedValue(foreignClient), + }; + mocks.clerkInstance.session = activeSession; + + const { rerender } = render( + , + ); + + await waitFor(() => { + expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', jsDeviceToken); + }); + + mocks.syncClientStateFromJs.mockClear(); + mocks.tokenCache.saveToken.mockClear(); + + mocks.nativeClientEvent = { + issuedAt: 1, + changed: { + client: true, + deviceToken: true, + }, + deviceToken: nativeDeviceToken, + }; + rerender( + , + ); + + await waitFor(() => { + expect(mocks.tokenCache.saveToken).toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, jsDeviceToken); + }); + expect(mocks.tokenCache.saveToken).toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, nativeDeviceToken); + expect(updateClient).not.toHaveBeenCalledWith(foreignClient); + expect(mocks.clerkInstance.session).toBe(activeSession); + + await waitFor(() => { + expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith(jsDeviceToken, expect.any(String), false, true); + }); + }); + + test('restores the signed-in JS token when native client verification fails', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const jsDeviceToken = makeClientJwt(100); + const nativeDeviceToken = makeClientJwt(200); + const activeSession = { + id: 'session_1', + status: 'active', + user: { id: 'user_1' }, + }; + + mocks.tokenCache.getToken.mockResolvedValue(jsDeviceToken); + mocks.getClientToken.mockResolvedValue(jsDeviceToken); + mocks.clerkInstance.client = { + id: 'client_js', + signedInSessions: [activeSession], + lastActiveSessionId: activeSession.id, + fetch: vi.fn().mockRejectedValue(new Error('verification failed')), + }; + mocks.clerkInstance.session = activeSession; + + const { rerender } = render( + , + ); + + await waitFor(() => { + expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', jsDeviceToken); + }); + + mocks.syncClientStateFromJs.mockClear(); + mocks.tokenCache.saveToken.mockClear(); + + mocks.nativeClientEvent = { + issuedAt: 1, + changed: { + client: true, + deviceToken: true, + }, + deviceToken: nativeDeviceToken, + }; + rerender( + , + ); + + await waitFor(() => { + expect(mocks.tokenCache.saveToken).toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, jsDeviceToken); + }); + expect(mocks.clerkInstance.session).toBe(activeSession); + + await waitFor(() => { + expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith(jsDeviceToken, expect.any(String), false, true); + }); + + consoleError.mockRestore(); + }); + + test('does not replace a signed-in JS token when the native client cannot be verified', async () => { + const jsDeviceToken = makeClientJwt(100); + const nativeDeviceToken = makeClientJwt(200); + const activeSession = { + id: 'session_1', + status: 'active', + user: { id: 'user_1' }, + }; + + mocks.tokenCache.getToken.mockResolvedValue(jsDeviceToken); + mocks.getClientToken.mockResolvedValue(jsDeviceToken); + mocks.clerkInstance.client = { + id: 'client_js', + signedInSessions: [activeSession], + lastActiveSessionId: activeSession.id, + }; + mocks.clerkInstance.session = activeSession; + + const { rerender } = render( + , + ); + + await waitFor(() => { + expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', jsDeviceToken); + }); + + mocks.syncClientStateFromJs.mockClear(); + mocks.tokenCache.saveToken.mockClear(); + mocks.clerkInstance.__internal_reloadInitialResources.mockClear(); + + mocks.nativeClientEvent = { + issuedAt: 1, + changed: { + client: true, + deviceToken: true, + }, + deviceToken: nativeDeviceToken, + }; + rerender( + , + ); + + await waitFor(() => { + expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith(jsDeviceToken, expect.any(String), false, true); + }); + expect(mocks.tokenCache.saveToken).not.toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, nativeDeviceToken); + expect(mocks.clerkInstance.__internal_reloadInitialResources).not.toHaveBeenCalled(); + expect(mocks.clerkInstance.session).toBe(activeSession); + }); + + test('applies a session-less native response when it belongs to the current JS client', async () => { + const jsDeviceToken = makeClientJwt(100); + const nativeDeviceToken = makeClientJwt(200); + const activeSession = { + id: 'session_1', + status: 'active', + user: { id: 'user_1' }, + }; + const signedOutClient = { + id: 'client_shared', + signedInSessions: [], + lastActiveSessionId: null, + }; + const updateClient = mocks.clerkInstance.updateClient; + + mocks.tokenCache.getToken.mockResolvedValue(jsDeviceToken); + mocks.getClientToken.mockResolvedValue(jsDeviceToken); + mocks.clerkInstance.client = { + id: 'client_shared', + signedInSessions: [activeSession], + lastActiveSessionId: activeSession.id, + fetch: vi.fn().mockResolvedValue(signedOutClient), + }; + mocks.clerkInstance.session = activeSession; + + const { rerender } = render( + , + ); + + await waitFor(() => { + expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', jsDeviceToken); + }); + + mocks.tokenCache.saveToken.mockClear(); + + mocks.nativeClientEvent = { + issuedAt: 1, + changed: { + client: true, + deviceToken: true, + }, + deviceToken: nativeDeviceToken, + }; + rerender( + , + ); + + await waitFor(() => { + expect(updateClient).toHaveBeenCalledWith(signedOutClient); + }); + expect(mocks.tokenCache.saveToken).not.toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, jsDeviceToken); + expect(mocks.clerkInstance.session).toBeNull(); + }); }); diff --git a/packages/expo/src/provider/nativeClientSync.tsx b/packages/expo/src/provider/nativeClientSync.tsx index 1f50aa0699e..269658248ab 100644 --- a/packages/expo/src/provider/nativeClientSync.tsx +++ b/packages/expo/src/provider/nativeClientSync.tsx @@ -1,3 +1,4 @@ +import { urlDecodeB64 } from '@clerk/shared/internal/clerk-js/encoders'; import type { ClientResource, SignedInSessionResource } from '@clerk/shared/types'; import { type MutableRefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Platform } from 'react-native'; @@ -40,6 +41,7 @@ type NativeRefreshFromJsOptions = { export type NativeRefreshFromJsController = { cancel: () => void; + syncDeviceTokenToNative: (deviceToken: string | null) => void; }; export type DeviceTokenCacheListener = (deviceToken: string | null) => void; @@ -204,22 +206,38 @@ function getDefaultSignedInSession(client: ClientResource | null | undefined): S return client.signedInSessions[0] ?? null; } -async function refreshJsClientFromServer(clerkInstance: SyncableClerkInstance): Promise { +function canRefreshJsClientFromServer(clerkInstance: SyncableClerkInstance): boolean { + const client = clerkInstance.client as RefreshableClientResource | undefined; + + return typeof client?.fetch === 'function' && typeof clerkInstance.updateClient === 'function'; +} + +function fetchRefreshedJsClient(clerkInstance: SyncableClerkInstance): Promise { const client = clerkInstance.client as RefreshableClientResource | undefined; if (typeof client?.fetch !== 'function' || typeof clerkInstance.updateClient !== 'function') { - return null; + return Promise.resolve(null); } - const refreshedClient = await client.fetch({ fetchMaxTries: 1 }); - clerkInstance.updateClient(refreshedClient); + return client.fetch({ fetchMaxTries: 1 }); +} + +function isForeignSessionlessClient( + previousClient: ClientResource | null | undefined, + refreshedClient: ClientResource, +): boolean { + if (!previousClient?.id || !refreshedClient.id || previousClient.id === refreshedClient.id) { + return false; + } - return refreshedClient; + return Boolean(getDefaultSignedInSession(previousClient)) && refreshedClient.signedInSessions.length === 0; } async function refreshJsClientFromNativeState({ clerkInstance, nativeDeviceToken, + previousDeviceToken, + rejectForeignSessionlessClient = false, reloadInitialResources, shouldSyncDeviceToken = true, suppressTokenCacheNotificationsRef, @@ -227,21 +245,46 @@ async function refreshJsClientFromNativeState({ }: { clerkInstance: SyncableClerkInstance; nativeDeviceToken: string | null; + previousDeviceToken?: string | null; + rejectForeignSessionlessClient?: boolean; reloadInitialResources: boolean; shouldSyncDeviceToken?: boolean; suppressTokenCacheNotificationsRef?: MutableRefObject; tokenCache: TokenCache | undefined; }): Promise { - if (shouldSyncDeviceToken) { - await syncNativeDeviceTokenToCache({ - deviceToken: nativeDeviceToken, - suppressTokenCacheNotificationsRef, - tokenCache, - }); + const previousClient = clerkInstance.client; + + const restorePreviousDeviceToken = async () => { + if (!rejectForeignSessionlessClient || !shouldSyncDeviceToken || previousDeviceToken === undefined) { + return; + } + + await syncDeviceTokenToCache(tokenCache, previousDeviceToken); + }; + + let refreshedClient: ClientResource | null; + try { + if (shouldSyncDeviceToken) { + await syncNativeDeviceTokenToCache({ + deviceToken: nativeDeviceToken, + suppressTokenCacheNotificationsRef, + tokenCache, + }); + } + + refreshedClient = await fetchRefreshedJsClient(clerkInstance); + } catch (error) { + await restorePreviousDeviceToken(); + throw error; } - const refreshedClient = await refreshJsClientFromServer(clerkInstance); if (refreshedClient) { + if (rejectForeignSessionlessClient && isForeignSessionlessClient(previousClient, refreshedClient)) { + await restorePreviousDeviceToken(); + return true; + } + + clerkInstance.updateClient?.(refreshedClient); await reconcileJsActiveSessionFromClient({ clerkInstance, }); @@ -402,6 +445,31 @@ async function getCachedDeviceToken(tokenCache: TokenCache | undefined): Promise } } +function getDeviceTokenIssuedAt(deviceToken: string | null): number | null { + if (!deviceToken) { + return null; + } + + try { + const payload = deviceToken.split('.')[1]; + if (!payload) { + return null; + } + + const { iat } = JSON.parse(urlDecodeB64(payload)) as { iat?: unknown }; + return typeof iat === 'number' && Number.isFinite(iat) ? iat : null; + } catch { + return null; + } +} + +function isStrictlyOlderDeviceToken(currentDeviceToken: string, incomingDeviceToken: string | null): boolean { + const currentIssuedAt = getDeviceTokenIssuedAt(currentDeviceToken); + const incomingIssuedAt = getDeviceTokenIssuedAt(incomingDeviceToken); + + return currentIssuedAt !== null && incomingIssuedAt !== null && currentIssuedAt > incomingIssuedAt; +} + async function syncNativeClientToJs({ clerkInstance, nativeRefreshFromJsControllerRef, @@ -434,12 +502,27 @@ async function syncNativeClientToJs({ return; } + const previousDeviceToken = didChangeDeviceToken ? await getCachedDeviceToken(tokenCache) : undefined; + const hasSignedInJsClient = Boolean(getDefaultSignedInSession(clerkInstance.client)); + + if (didChangeDeviceToken && hasSignedInJsClient && previousDeviceToken) { + if ( + isStrictlyOlderDeviceToken(previousDeviceToken, nativeDeviceToken) || + !canRefreshJsClientFromServer(clerkInstance) + ) { + nativeRefreshFromJsControllerRef?.current?.syncDeviceTokenToNative(previousDeviceToken); + return; + } + } + await runWithSuppressedJsClientChanges(suppressJsClientChangedRef, async () => { nativeRefreshFromJsControllerRef?.current?.cancel(); await refreshJsClientFromNativeState({ clerkInstance, nativeDeviceToken, + previousDeviceToken, + rejectForeignSessionlessClient: true, reloadInitialResources: true, shouldSyncDeviceToken: didChangeDeviceToken, suppressTokenCacheNotificationsRef, @@ -486,18 +569,6 @@ export function NativeClientSync({ isRefreshingNativeFromJsRef.current = false; }, []); - useEffect(() => { - nativeRefreshFromJsControllerRef.current = { - cancel: cancelNativeRefreshFromJs, - }; - - return () => { - if (nativeRefreshFromJsControllerRef.current?.cancel === cancelNativeRefreshFromJs) { - nativeRefreshFromJsControllerRef.current = null; - } - }; - }, [cancelNativeRefreshFromJs, nativeRefreshFromJsControllerRef]); - useEffect(() => { if ( !clerkInstance || @@ -627,6 +698,25 @@ export function NativeClientSync({ }); }, []); + useEffect(() => { + nativeRefreshFromJsControllerRef.current = { + cancel: cancelNativeRefreshFromJs, + syncDeviceTokenToNative: deviceToken => { + queueNativeRefreshFromJs({ + deviceToken, + didChangeClient: false, + didChangeDeviceToken: true, + }); + }, + }; + + return () => { + if (nativeRefreshFromJsControllerRef.current?.cancel === cancelNativeRefreshFromJs) { + nativeRefreshFromJsControllerRef.current = null; + } + }; + }, [cancelNativeRefreshFromJs, nativeRefreshFromJsControllerRef, queueNativeRefreshFromJs]); + useEffect(() => { if (!enabled) { pendingNativeRefreshBeforeReadyRef.current = null; From 2fa5619f56ac5aaa7a9622ebe589c2dd5d47f26a Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Fri, 24 Jul 2026 10:05:15 -0700 Subject: [PATCH 2/4] fix(expo): Guard 401 recovery and harden token cache reads in native client sync --- .changeset/protect-expo-native-client-sync.md | 2 +- .../ClerkProvider.nativeClientSync.test.tsx | 177 +++++++++++------- .../expo/src/provider/nativeClientSync.tsx | 68 +++---- 3 files changed, 141 insertions(+), 106 deletions(-) diff --git a/.changeset/protect-expo-native-client-sync.md b/.changeset/protect-expo-native-client-sync.md index b761633f98b..56cab5fb812 100644 --- a/.changeset/protect-expo-native-client-sync.md +++ b/.changeset/protect-expo-native-client-sync.md @@ -2,4 +2,4 @@ '@clerk/expo': patch --- -Prevent native client updates from replacing a signed-in Expo client with a stale or sessionless client. Native-to-JS synchronization now keeps newer JavaScript tokens, validates client changes before applying them, and restores the previous token when validation fails. +Prevent native client updates from replacing a signed-in Expo client with a stale or sessionless client. Native-to-JS synchronization now validates client changes before applying them, restores the previous token when validation fails, and applies the same protection during 401 recovery. diff --git a/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx b/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx index e67b54084b7..32e6ec97215 100644 --- a/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx +++ b/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx @@ -124,13 +124,6 @@ function deferred(): { promise: Promise; resolve: () => void } { return { promise, resolve }; } -function makeClientJwt(iat: number): string { - const encode = (value: object) => - btoa(JSON.stringify(value)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); - - return `${encode({ alg: 'none' })}.${encode({ iat })}.signature`; -} - describe('ClerkProvider native client sync', () => { beforeEach(() => { vi.clearAllMocks(); @@ -1310,15 +1303,20 @@ describe('ClerkProvider native client sync', () => { }); }); - test('keeps a strictly newer signed-in JS token authoritative over a native event', async () => { - const jsDeviceToken = makeClientJwt(200); - const nativeDeviceToken = makeClientJwt(100); + test('rejects a foreign session-less native client and restores the signed-in JS token', async () => { + const jsDeviceToken = 'js-device-token'; + const nativeDeviceToken = 'native-device-token'; const activeSession = { id: 'session_1', status: 'active', user: { id: 'user_1' }, }; - const fetchClient = vi.fn(); + const foreignClient = { + id: 'client_foreign', + signedInSessions: [], + lastActiveSessionId: null, + }; + const updateClient = mocks.clerkInstance.updateClient; mocks.tokenCache.getToken.mockResolvedValue(jsDeviceToken); mocks.getClientToken.mockResolvedValue(jsDeviceToken); @@ -1326,7 +1324,7 @@ describe('ClerkProvider native client sync', () => { id: 'client_js', signedInSessions: [activeSession], lastActiveSessionId: activeSession.id, - fetch: fetchClient, + fetch: vi.fn().mockResolvedValue(foreignClient), }; mocks.clerkInstance.session = activeSession; @@ -1359,27 +1357,27 @@ describe('ClerkProvider native client sync', () => { />, ); + await waitFor(() => { + expect(mocks.tokenCache.saveToken).toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, jsDeviceToken); + }); + expect(mocks.tokenCache.saveToken).toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, nativeDeviceToken); + expect(updateClient).not.toHaveBeenCalledWith(foreignClient); + expect(mocks.clerkInstance.session).toBe(activeSession); + await waitFor(() => { expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith(jsDeviceToken, expect.any(String), false, true); }); - expect(fetchClient).not.toHaveBeenCalled(); - expect(mocks.tokenCache.saveToken).not.toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, nativeDeviceToken); }); - test('rejects a foreign session-less native client and restores the signed-in JS token', async () => { - const jsDeviceToken = makeClientJwt(100); - const nativeDeviceToken = makeClientJwt(200); + test('restores the signed-in JS token when native client verification fails', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const jsDeviceToken = 'js-device-token'; + const nativeDeviceToken = 'native-device-token'; const activeSession = { id: 'session_1', status: 'active', user: { id: 'user_1' }, }; - const foreignClient = { - id: 'client_foreign', - signedInSessions: [], - lastActiveSessionId: null, - }; - const updateClient = mocks.clerkInstance.updateClient; mocks.tokenCache.getToken.mockResolvedValue(jsDeviceToken); mocks.getClientToken.mockResolvedValue(jsDeviceToken); @@ -1387,7 +1385,7 @@ describe('ClerkProvider native client sync', () => { id: 'client_js', signedInSessions: [activeSession], lastActiveSessionId: activeSession.id, - fetch: vi.fn().mockResolvedValue(foreignClient), + fetch: vi.fn().mockRejectedValue(new Error('verification failed')), }; mocks.clerkInstance.session = activeSession; @@ -1423,19 +1421,18 @@ describe('ClerkProvider native client sync', () => { await waitFor(() => { expect(mocks.tokenCache.saveToken).toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, jsDeviceToken); }); - expect(mocks.tokenCache.saveToken).toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, nativeDeviceToken); - expect(updateClient).not.toHaveBeenCalledWith(foreignClient); expect(mocks.clerkInstance.session).toBe(activeSession); await waitFor(() => { expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith(jsDeviceToken, expect.any(String), false, true); }); + + consoleError.mockRestore(); }); - test('restores the signed-in JS token when native client verification fails', async () => { - const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); - const jsDeviceToken = makeClientJwt(100); - const nativeDeviceToken = makeClientJwt(200); + test('does not replace a signed-in JS token when the native client cannot be verified', async () => { + const jsDeviceToken = 'js-device-token'; + const nativeDeviceToken = 'native-device-token'; const activeSession = { id: 'session_1', status: 'active', @@ -1448,7 +1445,6 @@ describe('ClerkProvider native client sync', () => { id: 'client_js', signedInSessions: [activeSession], lastActiveSessionId: activeSession.id, - fetch: vi.fn().mockRejectedValue(new Error('verification failed')), }; mocks.clerkInstance.session = activeSession; @@ -1465,6 +1461,7 @@ describe('ClerkProvider native client sync', () => { mocks.syncClientStateFromJs.mockClear(); mocks.tokenCache.saveToken.mockClear(); + mocks.clerkInstance.__internal_reloadInitialResources.mockClear(); mocks.nativeClientEvent = { issuedAt: 1, @@ -1481,33 +1478,36 @@ describe('ClerkProvider native client sync', () => { />, ); - await waitFor(() => { - expect(mocks.tokenCache.saveToken).toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, jsDeviceToken); - }); - expect(mocks.clerkInstance.session).toBe(activeSession); - await waitFor(() => { expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith(jsDeviceToken, expect.any(String), false, true); }); - - consoleError.mockRestore(); + expect(mocks.tokenCache.saveToken).not.toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, nativeDeviceToken); + expect(mocks.clerkInstance.__internal_reloadInitialResources).not.toHaveBeenCalled(); + expect(mocks.clerkInstance.session).toBe(activeSession); }); - test('does not replace a signed-in JS token when the native client cannot be verified', async () => { - const jsDeviceToken = makeClientJwt(100); - const nativeDeviceToken = makeClientJwt(200); + test('applies a session-less native response when it belongs to the current JS client', async () => { + const jsDeviceToken = 'js-device-token'; + const nativeDeviceToken = 'native-device-token'; const activeSession = { id: 'session_1', status: 'active', user: { id: 'user_1' }, }; + const signedOutClient = { + id: 'client_shared', + signedInSessions: [], + lastActiveSessionId: null, + }; + const updateClient = mocks.clerkInstance.updateClient; mocks.tokenCache.getToken.mockResolvedValue(jsDeviceToken); mocks.getClientToken.mockResolvedValue(jsDeviceToken); mocks.clerkInstance.client = { - id: 'client_js', + id: 'client_shared', signedInSessions: [activeSession], lastActiveSessionId: activeSession.id, + fetch: vi.fn().mockResolvedValue(signedOutClient), }; mocks.clerkInstance.session = activeSession; @@ -1522,9 +1522,7 @@ describe('ClerkProvider native client sync', () => { expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', jsDeviceToken); }); - mocks.syncClientStateFromJs.mockClear(); mocks.tokenCache.saveToken.mockClear(); - mocks.clerkInstance.__internal_reloadInitialResources.mockClear(); mocks.nativeClientEvent = { issuedAt: 1, @@ -1542,35 +1540,83 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith(jsDeviceToken, expect.any(String), false, true); + expect(updateClient).toHaveBeenCalledWith(signedOutClient); }); - expect(mocks.tokenCache.saveToken).not.toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, nativeDeviceToken); - expect(mocks.clerkInstance.__internal_reloadInitialResources).not.toHaveBeenCalled(); - expect(mocks.clerkInstance.session).toBe(activeSession); + expect(mocks.tokenCache.saveToken).not.toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, jsDeviceToken); + expect(mocks.clerkInstance.session).toBeNull(); }); - test('applies a session-less native response when it belongs to the current JS client', async () => { - const jsDeviceToken = makeClientJwt(100); - const nativeDeviceToken = makeClientJwt(200); + test('rejects a foreign session-less native client during unauthenticated recovery', async () => { const activeSession = { id: 'session_1', status: 'active', user: { id: 'user_1' }, }; - const signedOutClient = { - id: 'client_shared', + const foreignClient = { + id: 'client_foreign', signedInSessions: [], lastActiveSessionId: null, }; const updateClient = mocks.clerkInstance.updateClient; + const originalHandleUnauthenticated = mocks.clerkInstance.handleUnauthenticated; - mocks.tokenCache.getToken.mockResolvedValue(jsDeviceToken); - mocks.getClientToken.mockResolvedValue(jsDeviceToken); + mocks.tokenCache.getToken.mockResolvedValue('js-device-token'); + mocks.getClientToken.mockResolvedValue('js-device-token'); mocks.clerkInstance.client = { - id: 'client_shared', + id: 'client_js', signedInSessions: [activeSession], lastActiveSessionId: activeSession.id, - fetch: vi.fn().mockResolvedValue(signedOutClient), + fetch: vi.fn().mockResolvedValue(foreignClient), + }; + mocks.clerkInstance.session = activeSession; + + render( + , + ); + + await waitFor(() => { + expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', 'js-device-token'); + }); + await waitFor(() => { + expect(mocks.clerkInstance.handleUnauthenticated).not.toBe(originalHandleUnauthenticated); + }); + + mocks.getClientToken.mockResolvedValue('ghost-device-token'); + mocks.tokenCache.saveToken.mockClear(); + + await act(async () => { + await mocks.clerkInstance.handleUnauthenticated(); + }); + + expect(updateClient).not.toHaveBeenCalledWith(foreignClient); + expect(mocks.clerkInstance.session).toBe(activeSession); + expect(originalHandleUnauthenticated).not.toHaveBeenCalled(); + expect(mocks.tokenCache.saveToken).toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, 'ghost-device-token'); + expect(mocks.tokenCache.saveToken).toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, 'js-device-token'); + }); + + test('skips native adoption when the cached device token read times out while signed in', async () => { + const activeSession = { + id: 'session_1', + status: 'active', + user: { id: 'user_1' }, + }; + const fetchClient = vi.fn().mockResolvedValue({ + id: 'client_foreign', + signedInSessions: [], + lastActiveSessionId: null, + }); + + mocks.tokenCache.getToken.mockResolvedValue('js-device-token'); + mocks.getClientToken.mockResolvedValue('js-device-token'); + mocks.clerkInstance.client = { + id: 'client_js', + signedInSessions: [activeSession], + lastActiveSessionId: activeSession.id, + fetch: fetchClient, }; mocks.clerkInstance.session = activeSession; @@ -1582,10 +1628,12 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', jsDeviceToken); + expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', 'js-device-token'); }); + mocks.tokenCache.getToken.mockImplementation(() => new Promise(() => {})); mocks.tokenCache.saveToken.mockClear(); + mocks.tokenCache.clearToken.mockClear(); mocks.nativeClientEvent = { issuedAt: 1, @@ -1593,7 +1641,7 @@ describe('ClerkProvider native client sync', () => { client: true, deviceToken: true, }, - deviceToken: nativeDeviceToken, + deviceToken: 'native-device-token', }; rerender( { />, ); - await waitFor(() => { - expect(updateClient).toHaveBeenCalledWith(signedOutClient); + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 1_200)); }); - expect(mocks.tokenCache.saveToken).not.toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, jsDeviceToken); - expect(mocks.clerkInstance.session).toBeNull(); + + expect(mocks.tokenCache.saveToken).not.toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, 'native-device-token'); + expect(mocks.tokenCache.clearToken).not.toHaveBeenCalled(); + expect(fetchClient).not.toHaveBeenCalled(); + expect(mocks.clerkInstance.session).toBe(activeSession); }); }); diff --git a/packages/expo/src/provider/nativeClientSync.tsx b/packages/expo/src/provider/nativeClientSync.tsx index 269658248ab..6893e357ec8 100644 --- a/packages/expo/src/provider/nativeClientSync.tsx +++ b/packages/expo/src/provider/nativeClientSync.tsx @@ -1,4 +1,3 @@ -import { urlDecodeB64 } from '@clerk/shared/internal/clerk-js/encoders'; import type { ClientResource, SignedInSessionResource } from '@clerk/shared/types'; import { type MutableRefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Platform } from 'react-native'; @@ -423,21 +422,26 @@ function mergePendingNativeRefreshOptions( return merged; } -async function getCachedDeviceToken(tokenCache: TokenCache | undefined): Promise { +const tokenCacheReadTimedOut = Symbol('tokenCacheReadTimedOut'); + +// `undefined` = read timed out, `null` = confirmed missing token. +async function getCachedDeviceToken(tokenCache: TokenCache | undefined): Promise { if (!tokenCache) { return null; } let timeoutId: ReturnType | undefined; try { - return ( - (await Promise.race([ - tokenCache.getToken(CLERK_CLIENT_JWT_KEY), - new Promise(resolve => { - timeoutId = setTimeout(() => resolve(null), tokenCacheReadTimeoutMs); - }), - ])) ?? null - ); + const result = await Promise.race([ + tokenCache.getToken(CLERK_CLIENT_JWT_KEY), + new Promise(resolve => { + timeoutId = setTimeout(() => resolve(tokenCacheReadTimedOut), tokenCacheReadTimeoutMs); + }), + ]); + if (result === tokenCacheReadTimedOut) { + return undefined; + } + return result ?? null; } finally { if (timeoutId) { clearTimeout(timeoutId); @@ -445,31 +449,6 @@ async function getCachedDeviceToken(tokenCache: TokenCache | undefined): Promise } } -function getDeviceTokenIssuedAt(deviceToken: string | null): number | null { - if (!deviceToken) { - return null; - } - - try { - const payload = deviceToken.split('.')[1]; - if (!payload) { - return null; - } - - const { iat } = JSON.parse(urlDecodeB64(payload)) as { iat?: unknown }; - return typeof iat === 'number' && Number.isFinite(iat) ? iat : null; - } catch { - return null; - } -} - -function isStrictlyOlderDeviceToken(currentDeviceToken: string, incomingDeviceToken: string | null): boolean { - const currentIssuedAt = getDeviceTokenIssuedAt(currentDeviceToken); - const incomingIssuedAt = getDeviceTokenIssuedAt(incomingDeviceToken); - - return currentIssuedAt !== null && incomingIssuedAt !== null && currentIssuedAt > incomingIssuedAt; -} - async function syncNativeClientToJs({ clerkInstance, nativeRefreshFromJsControllerRef, @@ -505,11 +484,13 @@ async function syncNativeClientToJs({ const previousDeviceToken = didChangeDeviceToken ? await getCachedDeviceToken(tokenCache) : undefined; const hasSignedInJsClient = Boolean(getDefaultSignedInSession(clerkInstance.client)); - if (didChangeDeviceToken && hasSignedInJsClient && previousDeviceToken) { - if ( - isStrictlyOlderDeviceToken(previousDeviceToken, nativeDeviceToken) || - !canRefreshJsClientFromServer(clerkInstance) - ) { + if (didChangeDeviceToken && hasSignedInJsClient) { + // Timed-out cache read leaves no rollback snapshot, so keep JS authoritative. + if (previousDeviceToken === undefined) { + return; + } + + if (previousDeviceToken && !canRefreshJsClientFromServer(clerkInstance)) { nativeRefreshFromJsControllerRef?.current?.syncDeviceTokenToNative(previousDeviceToken); return; } @@ -776,12 +757,15 @@ export function NativeClientSync({ return await runWithSuppressedJsClientChanges(suppressJsClientChangedRef, async () => { try { const nativeDeviceToken = await readNativeDeviceToken({ waitForToken: false }); + const previousDeviceToken = await getCachedDeviceToken(tokenCache); // Native may have already moved the server-side client to a new // active session. Refresh JS before allowing Clerk JS' stale-session // 401 path to collapse the whole client to signed out. const didRecover = await refreshJsClientFromNativeState({ clerkInstance, nativeDeviceToken, + previousDeviceToken, + rejectForeignSessionlessClient: true, reloadInitialResources: false, suppressTokenCacheNotificationsRef, tokenCache, @@ -947,7 +931,7 @@ export function useNativeClientBootstrap({ let initialJsDeviceToken: string | null = null; try { - initialJsDeviceToken = await getCachedDeviceToken(tokenCache); + initialJsDeviceToken = (await getCachedDeviceToken(tokenCache)) ?? null; } catch (e) { if (__DEV__) { console.warn('[ClerkProvider] Token cache read failed:', e); @@ -966,7 +950,7 @@ export function useNativeClientBootstrap({ } if (clerkInstance) { - const currentJsDeviceToken = await getCachedDeviceToken(tokenCache); + const currentJsDeviceToken = (await getCachedDeviceToken(tokenCache)) ?? null; const nativeDeviceToken = await readNativeDeviceToken({ waitForToken: false }); if (!isCurrentConfiguration() || currentJsDeviceToken === nativeDeviceToken) { From b1eb475d041e6f61763f0288ac077f0a41672485 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Fri, 24 Jul 2026 11:06:51 -0700 Subject: [PATCH 3/4] test(expo): Add Maestro regression for native client token divergence --- integration/templates/expo-native/App.tsx | 9 ++- .../expo-native/components/E2EControls.tsx | 58 ++++++++++++++++ .../expo-native/components/JsSignInForm.tsx | 69 +++++++++++++++++++ .../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 ++++++++++++ 7 files changed, 268 insertions(+), 1 deletion(-) create mode 100644 integration/templates/expo-native/components/E2EControls.tsx create mode 100644 integration/templates/expo-native/components/JsSignInForm.tsx 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..7e38fa83949 100644 --- a/integration/templates/expo-native/App.tsx +++ b/integration/templates/expo-native/App.tsx @@ -5,6 +5,9 @@ import { tokenCache } from '@clerk/expo/token-cache'; import { useState } from 'react'; import { Button, Modal, StyleSheet, Text, View } from 'react-native'; +import { E2EControls } from './components/E2EControls'; +import { JsSignInForm } from './components/JsSignInForm'; + const publishableKey = process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY; if (!publishableKey) { @@ -17,6 +20,7 @@ function NativeBuildFixture() { const { startGoogleAuthenticationFlow } = useSignInWithGoogle(); const [isAuthOpen, setIsAuthOpen] = useState(false); const [googleResult, setGoogleResult] = useState(null); + const [e2eStatus, setE2eStatus] = useState(null); return ( @@ -45,6 +49,9 @@ function NativeBuildFixture() { /> )} {googleResult && {googleResult}} + {!isSignedIn && } + {isSignedIn && } + {e2eStatus && {e2eStatus}} {isSignedIn && (