Skip to content
Closed
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/guard-native-client-sync-ghost-tokens.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 8 additions & 1 deletion integration/templates/expo-native/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -17,6 +20,7 @@ function NativeBuildFixture() {
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 Down Expand Up @@ -45,6 +49,9 @@ function NativeBuildFixture() {
/>
)}
{googleResult && <Text testID='google-result'>{googleResult}</Text>}
{!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 +86,7 @@ export default function App() {
const styles = StyleSheet.create({
container: {
flex: 1,
gap: 16,
gap: 12,
justifyContent: 'center',
padding: 24,
},
Expand Down
58 changes: 58 additions & 0 deletions integration/templates/expo-native/components/E2EControls.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
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();
// Delay the marker so Maestro cannot race the native client 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',
},
});
69 changes: 69 additions & 0 deletions integration/templates/expo-native/components/JsSignInForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
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 onJsSignIn = async () => {
try {
const { error } = await signIn.password({ identifier, password });
if (error) {
onStatus(`sign-in-error ${error.message ?? ''}`.replace(/\s+/g, ' ').trim());
return;
}
const { error: finalizeError } = await signIn.finalize();
if (finalizeError) {
onStatus(`sign-in-error ${finalizeError.message ?? ''}`.replace(/\s+/g, ' ').trim());
}
} catch (error) {
onStatus(`sign-in-error ${String(error)}`.replace(/\s+/g, ' '));
}
};

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