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
5 changes: 5 additions & 0 deletions .changeset/protect-expo-native-client-sync.md
Original file line number Diff line number Diff line change
@@ -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 validates client changes before applying them, restores the previous token when validation fails, and applies the same protection during 401 recovery.
27 changes: 10 additions & 17 deletions integration/templates/expo-native/App.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { ClerkProvider, useAuth, 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 { useState } from 'react';
import { Button, Modal, StyleSheet, Text, View } from 'react-native';

import { E2EControls } from './components/E2EControls';
import { GoogleSignInButton } from './components/GoogleSignInButton';
import { JsSignInForm } from './components/JsSignInForm';

const publishableKey = process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY;

if (!publishableKey) {
Expand All @@ -14,9 +17,8 @@ if (!publishableKey) {
function NativeBuildFixture() {
const { isLoaded, isSignedIn, signOut } = useAuth({ treatPendingAsSignedOut: false });
const { user } = useUser();
const { startGoogleAuthenticationFlow } = useSignInWithGoogle();
const [isAuthOpen, setIsAuthOpen] = useState(false);
const [googleResult, setGoogleResult] = useState<string | null>(null);
const [e2eStatus, setE2eStatus] = useState<string | null>(null);

return (
<View style={styles.container}>
Expand All @@ -32,19 +34,10 @@ function NativeBuildFixture() {
title='Open native AuthView'
onPress={() => setIsAuthOpen(true)}
/>
{!isSignedIn && (
<Button
testID='google-sign-in-button'
title='Sign in with Google'
onPress={() => {
void startGoogleAuthenticationFlow().catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
setGoogleResult(message.replace(/\s+/g, ' '));
});
}}
/>
)}
{googleResult && <Text testID='google-result'>{googleResult}</Text>}
{!isSignedIn && <GoogleSignInButton />}
{!isSignedIn && <JsSignInForm onStatus={setE2eStatus} />}
{isSignedIn && <E2EControls onStatus={setE2eStatus} />}
{e2eStatus && <Text testID='e2e-status'>{e2eStatus}</Text>}
{isSignedIn && (
<Button
testID='sign-out-button'
Expand Down Expand Up @@ -79,7 +72,7 @@ export default function App() {
const styles = StyleSheet.create({
container: {
flex: 1,
gap: 16,
gap: 12,
justifyContent: 'center',
padding: 24,
},
Expand Down
57 changes: 57 additions & 0 deletions integration/templates/expo-native/components/E2EControls.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { useAuth } from '@clerk/expo';
import { requireOptionalNativeModule } from 'expo';
import { Button, StyleSheet, View } from 'react-native';

// Fixture-local Maestro hook (modules/e2e-hooks); android-only, null elsewhere.
const E2EHooks = requireOptionalNativeModule<{ corruptNativeDeviceToken(): Promise<boolean> }>('E2EHooks');

/**
* Signed-in controls for the native-token-divergence regression flow.
*/
export function E2EControls({ onStatus }: { onStatus: (status: string | null) => void }) {
const { getToken } = useAuth({ treatPendingAsSignedOut: false });

const onCorruptNativeToken = async () => {
onStatus(null);
try {
const didCorrupt = await E2EHooks?.corruptNativeDeviceToken();
// Delayed so Maestro cannot race the native event and the JS sync settling.
setTimeout(() => onStatus(didCorrupt ? 'corrupt-done' : 'corrupt-failed'), 3000);
} catch {
onStatus('corrupt-failed');
}
};

const onMintSessionToken = async () => {
onStatus(null);
try {
const token = await getToken({ skipCache: true });
onStatus(token ? 'token-ok' : 'token-empty');
} catch {
onStatus('token-error');
}
};

return (
<View style={styles.e2eRow}>
<Button
testID='e2e-corrupt-native-token-button'
title='Corrupt'
onPress={() => void onCorruptNativeToken()}
/>
<Button
testID='e2e-refresh-token-button'
title='Mint'
onPress={() => void onMintSessionToken()}
/>
</View>
);
}

const styles = StyleSheet.create({
e2eRow: {
flexDirection: 'row',
gap: 12,
justifyContent: 'space-between',
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { useSignInWithGoogle } from '@clerk/expo/google';
import { useState } from 'react';
import { Button, Text } from 'react-native';

export function GoogleSignInButton() {
const { startGoogleAuthenticationFlow } = useSignInWithGoogle();
const [result, setResult] = useState<string | null>(null);

return (
<>
<Button
testID='google-sign-in-button'
title='Sign in with Google'
onPress={() => {
void startGoogleAuthenticationFlow().catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
setResult(message.replace(/\s+/g, ' '));
});
}}
/>
{result && <Text testID='google-result'>{result}</Text>}
</>
);
}
71 changes: 71 additions & 0 deletions integration/templates/expo-native/components/JsSignInForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { useSignIn } from '@clerk/expo';
import { useState } from 'react';
import { Button, StyleSheet, TextInput } from 'react-native';

/**
* Headless JS-runtime sign-in so the JS client owns the session, which the
* native-token-divergence flow requires (the native AuthView would create the
* session on the native client instead).
*/
export function JsSignInForm({ onStatus }: { onStatus: (status: string) => void }) {
const { signIn } = useSignIn();
const [identifier, setIdentifier] = useState('');
const [password, setPassword] = useState('');

const reportError = (message: string) => onStatus(`sign-in-error ${message}`.replace(/\s+/g, ' ').trim());

const onJsSignIn = async () => {
try {
const { error } = await signIn.password({ identifier, password });
if (error) {
reportError(error.message ?? '');
return;
}
const { error: finalizeError } = await signIn.finalize();
if (finalizeError) {
reportError(finalizeError.message ?? '');
}
} catch (error) {
reportError(String(error));
}
};

return (
<>
<TextInput
testID='e2e-identifier-input'
style={styles.input}
autoCapitalize='none'
autoCorrect={false}
placeholder='e2e identifier'
value={identifier}
onChangeText={setIdentifier}
/>
<TextInput
testID='e2e-password-input'
style={styles.input}
autoCapitalize='none'
autoCorrect={false}
secureTextEntry
placeholder='e2e secret'
value={password}
onChangeText={setPassword}
/>
<Button
testID='e2e-js-sign-in-button'
title='E2E JS sign in'
onPress={() => void onJsSignIn()}
/>
</>
);
}

const styles = StyleSheet.create({
input: {
borderColor: '#cccccc',
borderRadius: 6,
borderWidth: 1,
paddingHorizontal: 12,
paddingVertical: 8,
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
plugins {
id 'com.android.library'
id 'org.jetbrains.kotlin.android'
}

def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
apply from: expoModulesCorePlugin
applyKotlinExpoModulesCorePlugin()

group = 'com.clerk.e2ehooks'
version = '1.0.0'

def safeExtGet(prop, fallback) {
rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
}

android {
namespace "com.clerk.e2ehooks"

compileSdk safeExtGet("compileSdkVersion", 36)

defaultConfig {
minSdk safeExtGet("minSdkVersion", 24)
targetSdk safeExtGet("targetSdkVersion", 36)
}

compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}

kotlinOptions {
jvmTarget = "17"
// clerk-android metadata is Kotlin 2.3.x; Expo SDKs compile with 2.1.x.
freeCompilerArgs += ['-Xskip-metadata-version-check']
}
}

dependencies {
implementation project(':expo-modules-core')
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3"

// Provided at runtime by the @clerk/expo native module; keep versions in sync
// with packages/expo/android/build.gradle.
compileOnly("com.clerk:clerk-android-api:1.0.36") {
exclude group: 'org.jetbrains.kotlin', module: 'kotlin-stdlib'
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.clerk.e2ehooks

import com.clerk.api.Clerk
import com.clerk.api.network.serialization.ClerkResult
import expo.modules.kotlin.Promise
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch

/**
* Fixture-only Maestro hook. Never ship this in a real app.
*/
class E2EHooksModule : Module() {
private val coroutineScope = CoroutineScope(Dispatchers.Main)

override fun definition() = ModuleDefinition {
Name("E2EHooks")

// Reproduces the stale-token foreground refresh from MOBILE-594: replace
// the native device token with garbage and refresh, so the server hands
// native a brand-new session-less client whose token is emitted to JS.
AsyncFunction("corruptNativeDeviceToken") { promise: Promise ->
coroutineScope.launch {
try {
val result = Clerk.updateDeviceToken("e2e-stale-device-token")
promise.resolve(result is ClerkResult.Success)
} catch (e: Exception) {
promise.reject("E_CORRUPT_FAILED", e.message, e)
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"platforms": ["android"],
"android": {
"modules": ["com.clerk.e2ehooks.E2EHooksModule"]
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Regression for the lazily-persisted-clients incident (MOBILE-594): a stale
# native refresh hands JS a token for a brand-new session-less client, and the
# JS session must survive. Sign-in happens through the JS runtime (not the
# native AuthView) so the JS client owns the session, which is the topology
# the incident requires. Android-only: the corrupt hook lives in the fixture's
# android E2EHooks module, so the flow no-ops on iOS.
appId: com.clerk.exponativebuildfixture
name: JS session survives a divergent native client token
---
- runFlow:
when:
platform: Android
commands:
- runFlow: subflows/open-app.yaml
- tapOn:
id: 'e2e-identifier-input'
- inputText: ${CLERK_TEST_EMAIL}
- tapOn:
id: 'e2e-password-input'
- inputText: ${CLERK_TEST_PASSWORD}
- hideKeyboard
- tapOn:
id: 'e2e-js-sign-in-button'
- runFlow: subflows/assert-signed-in.yaml
# Force the native SDK onto a stale token; its refresh emits a
# session-less client token to JS (the incident trigger). The fixture
# shows corrupt-done only after the sync had time to settle.
- tapOn:
id: 'e2e-corrupt-native-token-button'
- extendedWaitUntil:
visible: 'corrupt-done'
timeout: 30000
# Mint a session token with whatever the JS cache now holds. Without the
# adoption guard this fails and the session collapses (~44s in prod).
- tapOn:
id: 'e2e-refresh-token-button'
- extendedWaitUntil:
visible: 'token-ok'
timeout: 20000
- assertVisible: 'signed in'
# Leave the app signed out for whichever flow runs next.
- tapOn:
id: 'sign-out-button'
- runFlow: subflows/assert-signed-out.yaml
Loading
Loading