Skip to content
Draft
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
5 changes: 5 additions & 0 deletions .changeset/simplify-expo-native-client-sync.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/expo': minor
---

Rewrite the sync between the Clerk JS client and the native Clerk SDK. Both now share a single device token stored by the native SDK, and startup makes fewer requests. This fixes a stale token in the app's `tokenCache` signing out a session that was signed in natively, and the two SDKs ending up on different tokens when both rotated the device token at the same time. Requires the matching native module that ships with the next native SDK versions.
Original file line number Diff line number Diff line change
@@ -1,17 +1,14 @@
import { renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { beforeEach, describe, expect, test, vi } from 'vitest';

import {
__internal_resetNativeClientSyncCoordinator,
registerNativeToJsSyncHandler,
trackPendingJsToNativeSync,
} from '../../provider/nativeClientSyncCoordinator';
import { useBiometricCredentials as useUnsupportedBiometrics } from '../useBiometricCredentials';
import { useBiometricCredentials as useAndroidBiometrics } from '../useBiometricCredentials.android';
import { useBiometricCredentials as useIosBiometrics } from '../useBiometricCredentials.ios';

const mocks = vi.hoisted(() => ({
useClerk: vi.fn(),
idle: vi.fn(),
synchronize: vi.fn(),
nativeModule: {
getTrustedDeviceAvailability: vi.fn(),
listTrustedDevices: vi.fn(),
Expand All @@ -24,6 +21,7 @@ const mocks = vi.hoisted(() => ({

vi.mock('@clerk/react', () => ({ useClerk: mocks.useClerk }));
vi.mock('../../utils/native-module', () => ({ ClerkExpoModule: mocks.nativeModule }));
vi.mock('../../provider/nativeClientSync', () => ({ idle: mocks.idle, pullFromNative: mocks.synchronize }));
vi.mock('react-native', () => ({ Platform: { OS: 'ios' } }));

const session = {
Expand All @@ -32,13 +30,11 @@ const session = {
getToken: vi.fn(),
};
const clerk = { session: session as typeof session | null, setActive: vi.fn() };
const synchronize = vi.fn();
let unregister: () => void;
const synchronize = mocks.synchronize;

beforeEach(() => {
vi.resetAllMocks();
__internal_resetNativeClientSyncCoordinator();
unregister = registerNativeToJsSyncHandler(synchronize);
mocks.idle.mockResolvedValue(undefined);
clerk.session = session;
mocks.useClerk.mockReturnValue(clerk);
session.getToken.mockResolvedValue('fresh-token');
Expand All @@ -50,8 +46,6 @@ beforeEach(() => {
});
});

afterEach(() => unregister());

describe.each([
['iOS', useIosBiometrics],
['Android', useAndroidBiometrics],
Expand Down Expand Up @@ -115,7 +109,7 @@ test('incomplete verification returns the synchronized session without refreshin

test('waits for pending JS-to-native synchronization', async () => {
let finish!: () => void;
trackPendingJsToNativeSync(
mocks.idle.mockReturnValueOnce(
new Promise<void>(resolve => {
finish = resolve;
}),
Expand All @@ -130,7 +124,7 @@ test('waits for pending JS-to-native synchronization', async () => {

test('does not start a biometric operation after the active session changes during synchronization', async () => {
let finish!: () => void;
trackPendingJsToNativeSync(
mocks.idle.mockReturnValueOnce(
new Promise<void>(resolve => {
finish = resolve;
}),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,6 @@
import { renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';

import {
__internal_resetNativeClientSyncCoordinator,
registerNativeToJsSyncHandler,
trackPendingJsToNativeSync,
} from '../../provider/nativeClientSyncCoordinator';
import { isBiometricCredentialError } from '../errors';
import { useBiometricCredentials as useBiometricCredentialsOnUnsupportedPlatform } from '../useBiometricCredentials';
import { useBiometricCredentials as useBiometricCredentialsOnAndroid } from '../useBiometricCredentials.android';
Expand All @@ -23,7 +18,8 @@ const mocks = vi.hoisted(() => ({
jsSignedInSessions: [{ id: 'sess_123' }],
useClerk: vi.fn(),
setActive: vi.fn(),
synchronizeNativeClientToJs: vi.fn(),
idle: vi.fn(),
pullFromNative: vi.fn(),
isNativeModuleInstalled: true,
nativeModule: {
getTrustedDeviceAvailability: vi.fn(),
Expand All @@ -38,6 +34,11 @@ vi.mock('@clerk/react', () => ({
useClerk: mocks.useClerk,
}));

vi.mock('../../provider/nativeClientSync', () => ({
idle: mocks.idle,
pullFromNative: mocks.pullFromNative,
}));

vi.mock('../../utils/native-module', () => ({
get ClerkExpoModule() {
return mocks.isNativeModuleInstalled ? mocks.nativeModule : null;
Expand Down Expand Up @@ -68,12 +69,9 @@ function renderBiometricCredentials(useHook = useBiometricCredentialsOnIos) {
return renderHook(() => useHook()).result.current;
}

let unregisterNativeToJsSyncHandler: (() => void) | undefined;

beforeEach(() => {
__internal_resetNativeClientSyncCoordinator();
unregisterNativeToJsSyncHandler = registerNativeToJsSyncHandler(mocks.synchronizeNativeClientToJs);
mocks.synchronizeNativeClientToJs.mockResolvedValue(undefined);
mocks.idle.mockResolvedValue(undefined);
mocks.pullFromNative.mockResolvedValue(undefined);
mocks.useClerk.mockReturnValue({
client: { signIn: mocks.jsSignIn, signedInSessions: mocks.jsSignedInSessions },
setActive: mocks.setActive,
Expand All @@ -88,7 +86,6 @@ beforeEach(() => {

afterEach(() => {
vi.useRealTimers();
unregisterNativeToJsSyncHandler?.();
});

describe('useBiometricCredentials on iOS', () => {
Expand Down Expand Up @@ -117,7 +114,7 @@ describe('useBiometricCredentials on iOS', () => {
const nativeSync = new Promise<void>(resolve => {
finishNativeSync = resolve;
});
trackPendingJsToNativeSync(nativeSync);
mocks.idle.mockReturnValueOnce(nativeSync);
mocks.nativeModule.getTrustedDeviceAvailability.mockResolvedValue({
isAvailable: true,
unavailableReason: null,
Expand All @@ -134,21 +131,12 @@ describe('useBiometricCredentials on iOS', () => {
});

test('rejects availability when native client synchronization times out', async () => {
vi.useFakeTimers();
let finishNativeSync!: () => void;
const nativeSync = new Promise<void>(resolve => {
finishNativeSync = resolve;
});
trackPendingJsToNativeSync(nativeSync);
mocks.idle.mockRejectedValueOnce(Object.assign(new Error('timed out'), { code: 'environment_unavailable' }));

const availability = expect(renderBiometricCredentials().getAvailability()).rejects.toMatchObject({
await expect(renderBiometricCredentials().getAvailability()).rejects.toMatchObject({
code: 'environment_unavailable',
});

await vi.advanceTimersByTimeAsync(5_000);
await availability;
expect(mocks.nativeModule.getTrustedDeviceAvailability).not.toHaveBeenCalled();
finishNativeSync();
});

test('lists biometric credentials and converts native timestamps to dates', async () => {
Expand Down Expand Up @@ -185,7 +173,7 @@ describe('useBiometricCredentials on iOS', () => {
const nativeSync = new Promise<void>(resolve => {
finishNativeSync = resolve;
});
trackPendingJsToNativeSync(nativeSync);
mocks.idle.mockReturnValueOnce(nativeSync);
mocks.nativeModule.listTrustedDevices.mockResolvedValue([nativeBiometricCredential]);

const listing = renderBiometricCredentials().list();
Expand Down Expand Up @@ -230,7 +218,7 @@ describe('useBiometricCredentials on iOS', () => {
const nativeSync = new Promise<void>(resolve => {
finishNativeSync = resolve;
});
trackPendingJsToNativeSync(nativeSync);
mocks.idle.mockReturnValueOnce(nativeSync);
mocks.nativeModule.enrollTrustedDevice.mockResolvedValue(nativeBiometricCredential);

const enrollment = renderBiometricCredentials().enroll();
Expand Down Expand Up @@ -262,7 +250,7 @@ describe('useBiometricCredentials on iOS', () => {
const nativeSync = new Promise<void>(resolve => {
finishNativeSync = resolve;
});
trackPendingJsToNativeSync(nativeSync);
mocks.idle.mockReturnValueOnce(nativeSync);
mocks.nativeModule.revokeTrustedDevice.mockResolvedValue({
...nativeBiometricCredential,
status: 'revoked',
Expand Down Expand Up @@ -362,7 +350,7 @@ describe('useBiometricCredentials on iOS', () => {
const nativeSync = new Promise<void>(resolve => {
finishNativeSync = resolve;
});
trackPendingJsToNativeSync(nativeSync);
mocks.idle.mockReturnValueOnce(nativeSync);
mocks.nativeModule.signInWithTrustedDevice.mockResolvedValue({
id: 'sia_123',
status: 'complete',
Expand All @@ -389,7 +377,7 @@ describe('useBiometricCredentials on iOS', () => {
status,
createdSessionId: null,
});
mocks.synchronizeNativeClientToJs.mockImplementation(() => {
mocks.pullFromNative.mockImplementation(() => {
Object.assign(mocks.jsSignIn, {
id: 'sia_mfa',
status,
Expand All @@ -415,7 +403,7 @@ describe('useBiometricCredentials on iOS', () => {
status: 'complete',
createdSessionId: 'sess_123',
});
mocks.synchronizeNativeClientToJs.mockReturnValue(
mocks.pullFromNative.mockReturnValue(
new Promise<void>(resolve => {
finishSync = resolve;
}),
Expand All @@ -428,7 +416,7 @@ describe('useBiometricCredentials on iOS', () => {
didResolve = true;
return result;
});
await vi.waitFor(() => expect(mocks.synchronizeNativeClientToJs).toHaveBeenCalled());
await vi.waitFor(() => expect(mocks.pullFromNative).toHaveBeenCalled());
expect(didResolve).toBe(false);

finishSync();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useClerk } from '@clerk/react';
import { useMemo } from 'react';

import { synchronizeNativeClientToJs, waitForPendingJsToNativeSync } from '../provider/nativeClientSyncCoordinator';
import { idle, pullFromNative } from '../provider/nativeClientSync';
import type { NativeBiometricCredential, NativeBiometricCredentialModule } from '../specs/NativeClerkModule.types';
import { errorThrower } from '../utils/errors';
import { ClerkExpoModule } from '../utils/native-module';
Expand Down Expand Up @@ -65,18 +65,18 @@ function createBiometricCredentials(clerk: ReturnType<typeof useClerk>): UseBiom
return {
getAvailability: async params => {
const nativeModule = getNativeModule();
await waitForPendingJsToNativeSync();
await idle();
return nativeModule.getTrustedDeviceAvailability(params?.id ?? null, params?.identifierHint ?? null);
},
list: async () => {
const nativeModule = getNativeModule();
await waitForPendingJsToNativeSync();
await idle();
const credentials = await nativeModule.listTrustedDevices();
return credentials.map(toBiometricCredential);
},
enroll: async params => {
const nativeModule = getNativeModule();
await waitForPendingJsToNativeSync();
await idle();
const credential = await nativeModule.enrollTrustedDevice(
params?.name ?? null,
params?.identifierHint ?? null,
Expand All @@ -87,7 +87,7 @@ function createBiometricCredentials(clerk: ReturnType<typeof useClerk>): UseBiom
},
revoke: async id => {
const nativeModule = getNativeModule();
await waitForPendingJsToNativeSync();
await idle();
const credential = await nativeModule.revokeTrustedDevice(id);
return toBiometricCredential(credential);
},
Expand All @@ -108,7 +108,7 @@ function createBiometricCredentials(clerk: ReturnType<typeof useClerk>): UseBiom
if (!session) {
return errorThrower.throw('Biometric reverification requires an active session.');
}
await waitForPendingJsToNativeSync();
await idle();
if (clerk.session?.id !== session.id) {
return errorThrower.throw('The active session changed before biometric reverification started.');
}
Expand All @@ -119,7 +119,7 @@ function createBiometricCredentials(clerk: ReturnType<typeof useClerk>): UseBiom
if (verification.status === 'complete') {
session.clearCache();
}
await synchronizeNativeClientToJs();
await pullFromNative();
const synchronizedSession = clerk.session;
if (synchronizedSession?.id !== session.id) {
return errorThrower.throw('The active session changed during biometric reverification.');
Expand All @@ -143,13 +143,13 @@ function createBiometricCredentials(clerk: ReturnType<typeof useClerk>): UseBiom
},
signIn: async params => {
const nativeModule = getNativeModule();
await waitForPendingJsToNativeSync();
await idle();
const nativeSignIn = await nativeModule.signInWithTrustedDevice(
params?.id ?? null,
params?.identifierHint ?? null,
params?.reason ?? null,
);
await synchronizeNativeClientToJs();
await pullFromNative();

const client = clerk.client;
const signIn = client?.signIn;
Expand Down
Loading
Loading