Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
86 commits
Select commit Hold shift + click to select a range
eedabc7
feat(clerk-js,shared): add experimental trusted device resources
mikepitre Sep 27, 2026
06409ce
feat(expo-biometrics): add biometric credential native module
mikepitre Sep 27, 2026
193f277
chore(expo-biometrics): point iOS storage comment at the clerk-ios co…
mikepitre Sep 28, 2026
8ae86de
fix(expo-biometrics): report missing secure key storage
mikepitre Sep 28, 2026
b04b97d
feat(expo-biometrics): add Android support
mikepitre Sep 27, 2026
406e03d
fix(expo-biometrics): report missing secure key storage on Android
mikepitre Sep 28, 2026
b3fe64c
feat(expo): move biometric credentials to @clerk/expo-biometrics
mikepitre Sep 27, 2026
cffd179
fix(expo): keep API error codes on biometric credential errors
mikepitre Sep 27, 2026
8bc206c
fix(expo): treat missing secure key storage as unavailable
mikepitre Sep 28, 2026
76e4e5b
chore(expo): port biometrics to main and fix test typing
mikepitre Sep 29, 2026
3d8a2eb
chore(expo-biometrics): raise the clerk.browser.js size limit and cla…
mikepitre Sep 30, 2026
507c4fb
refactor(expo-native): move native SDK code from @clerk/expo into pac…
mikepitre Sep 27, 2026
c488efa
feat(expo-native): scaffold @clerk/expo-native package
mikepitre Sep 27, 2026
a0c461c
feat(expo): re-export native components from @clerk/expo-native and d…
mikepitre Sep 27, 2026
99a6290
chore(expo-native): wire the package into CI, the native fixture, and…
mikepitre Sep 27, 2026
b6bc9c2
fix(expo): clarify config plugin warnings for @clerk/expo-native options
mikepitre Sep 27, 2026
872e66b
refactor(expo-native): drop the dependency on @clerk/expo
mikepitre Sep 27, 2026
007540a
chore(expo): leave biometric credentials out of the native split chan…
mikepitre Sep 28, 2026
a7f449e
refactor(expo-native-components): rename @clerk/expo-native to @clerk…
mikepitre Sep 28, 2026
e1a16c3
fix(expo): point biometric reverification at @clerk/expo-native-compo…
mikepitre Sep 29, 2026
b58926b
feat(expo-native): add single-token client sync bridge
mikepitre Sep 27, 2026
252dc62
refactor(expo-native-components): rename @clerk/expo-native references
mikepitre Sep 28, 2026
7ef069b
docs(expo-biometrics): describe the package as the install for useBio…
mikepitre Sep 30, 2026
fa996d4
feat(expo): run biometric reverification in JS
mikepitre Sep 30, 2026
ab01e9d
chore(expo): correct useBiometricCredentials requirements and raise t…
mikepitre Sep 30, 2026
0c71b3d
Merge branch 'mike/expo-biometrics-package' of github.com:clerkinc/ja…
mikepitre Sep 30, 2026
3bcc2f3
Merge branch 'mike/expo-native-package' of github.com:clerkinc/javasc…
mikepitre Sep 30, 2026
6a7b7fb
chore(expo-biometrics): point storage comments at clerk-android's con…
mikepitre Sep 30, 2026
0d8e5b0
fix(expo-biometrics): serialize Android record saves and deletes
mikepitre Sep 30, 2026
a662774
Merge branch 'main' into mike/expo-biometrics-package
wobsoriano Sep 30, 2026
628192a
Merge branch 'mike/expo-biometrics-package' of github.com:clerkinc/ja…
mikepitre Sep 30, 2026
690bef2
chore(expo): drop the Android re-enrollment note now that clerk-andro…
mikepitre Sep 30, 2026
119ba4e
chore(expo-biometrics): exclude native tests from the published package
wobsoriano Oct 1, 2026
adecbb5
refactor(expo,expo-biometrics): remove unreachable compatibility shims
wobsoriano Oct 1, 2026
413705c
refactor(expo): dedupe local credential cleanup in sign-in and reverify
wobsoriano Oct 1, 2026
32f6c80
test(expo): drop native sync mocks the biometric hook no longer uses
wobsoriano Oct 1, 2026
f854344
chore(clerk-js): raise the clerk.legacy.browser.js size limit
wobsoriano Oct 1, 2026
c720537
chore(repo): simplify biometric credential changesets
wobsoriano Oct 1, 2026
d9f99e6
fix(expo): keep biometric credential error codes consistent
wobsoriano Oct 1, 2026
f253936
docs(expo): describe the @clerk/expo-biometrics requirement on useBio…
wobsoriano Oct 1, 2026
4714f8e
test(e2e): add a biometric availability smoke flow
wobsoriano Oct 1, 2026
4fe93d7
refactor(clerk-js): align native settings parsing with sibling resources
wobsoriano Oct 1, 2026
89da67e
Merge branch 'main' into mike/expo-biometrics-package
wobsoriano Oct 1, 2026
6ec83d4
fix(expo,expo-biometrics): check biometric challenges before promptin…
mikepitre Oct 1, 2026
4ba69f8
feat(expo): export useBiometricCredentials from @clerk/expo/biometrics
wobsoriano Oct 1, 2026
b609ad1
fix(expo-biometrics): settle Android sign() when the prompt cannot show
swolfand Oct 1, 2026
af58d9f
Merge branch 'main' into mike/expo-biometrics-package
mikepitre Oct 1, 2026
d7094a9
ci(repo): copy expo-biometrics Android tests into the packed fixture …
mikepitre Oct 1, 2026
d82987a
Merge branch 'main' into mike/expo-biometrics-package
mikepitre Oct 1, 2026
b078daa
Merge commit 'd82987a040' into mike/expo-native-package
mikepitre Oct 1, 2026
fc5c343
Merge main before the #9989 squash into mike/expo-native-package
mikepitre Oct 1, 2026
f2aed86
Merge main into mike/expo-native-package
mikepitre Oct 1, 2026
7d58a73
chore(expo): note that Android enrollments need the native SDK to mig…
mikepitre Oct 1, 2026
061f265
Merge branch 'mike/expo-native-package' into mike/expo-native-sync-br…
mikepitre Oct 1, 2026
b9138eb
fix(repo): use the @clerk/expo-native-components plugin in the native…
mikepitre Oct 1, 2026
919d59c
Merge branch 'mike/expo-native-package' into mike/expo-native-sync-br…
mikepitre Oct 1, 2026
e6d2f6f
Merge branch 'main' into mike/expo-native-package
mikepitre Oct 2, 2026
cb9e44e
Merge branch 'mike/expo-native-package' into mike/expo-native-sync-br…
mikepitre Oct 2, 2026
f58ba94
Merge branch 'main' into mike/expo-native-package
mikepitre Oct 5, 2026
472f686
Merge branch 'mike/expo-native-package' into mike/expo-native-sync-br…
mikepitre Oct 5, 2026
3b1a370
Merge branch 'main' into mike/expo-native-package
mikepitre Oct 5, 2026
ade2a17
Merge branch 'mike/expo-native-package' into mike/expo-native-sync-br…
mikepitre Oct 5, 2026
7dc90d2
Merge branch 'main' into mike/expo-native-package
mikepitre Oct 6, 2026
607a8c3
Merge branch 'mike/expo-native-package' into mike/expo-native-sync-br…
mikepitre Oct 6, 2026
affd28d
Merge branch 'main' into mike/expo-native-package
mikepitre Oct 6, 2026
a9dfdfe
Merge branch 'mike/expo-native-package' into mike/expo-native-sync-br…
mikepitre Oct 6, 2026
689340d
Merge branch 'main' into mike/expo-native-package
mikepitre Oct 7, 2026
d1b873e
Merge branch 'mike/expo-native-package' into mike/expo-native-sync-br…
mikepitre Oct 7, 2026
6df3111
Merge branch 'main' into mike/expo-native-package
wobsoriano Oct 8, 2026
55c8eae
refactor(expo,expo-native-components): keep useAuthViewState in @cler…
wobsoriano Oct 8, 2026
792aecc
chore(expo,expo-native-components): describe the native components sp…
wobsoriano Oct 8, 2026
8b767b2
chore(expo,expo-native-components): trim the native components changeset
wobsoriano Oct 8, 2026
c708f9c
Merge remote-tracking branch 'origin/main' into mike/expo-native-package
wobsoriano Oct 8, 2026
12061bb
Merge branch 'main' into mike/expo-native-package
wobsoriano Oct 12, 2026
094939a
Merge branch 'main' into mike/expo-native-package
wobsoriano Oct 13, 2026
15c5c3d
feat(expo): export native components from @clerk/expo/native-components
wobsoriano Oct 13, 2026
569545f
docs(expo-native-components): trim the README and point to the docs
wobsoriano Oct 13, 2026
d592ca1
chore(repo,expo): track @clerk/expo-native-components in break-check …
wobsoriano Oct 13, 2026
245af6a
Merge #9955's final branch head into mike/expo-native-sync-bridge
mikepitre Oct 14, 2026
1c70b92
Merge main before the #9955 squash into mike/expo-native-sync-bridge
mikepitre Oct 14, 2026
2da954c
Merge main into mike/expo-native-sync-bridge
mikepitre Oct 14, 2026
d67a86f
Merge branch 'main' into mike/expo-native-sync-bridge
mikepitre Oct 14, 2026
5133f51
chore(expo-native-components): pin clerk-ios to 1.6.1 instead of the …
mikepitre Oct 14, 2026
b079ca8
Merge branch 'main' into mike/expo-native-sync-bridge
mikepitre Oct 14, 2026
57336ab
chore(expo-native-components): bump clerk-android to 1.2.0 for setDev…
mikepitre Oct 14, 2026
e451e40
chore(expo-native-components): bump clerk-android to 1.2.1
mikepitre Oct 15, 2026
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/expo-native-single-token-sync-bridge.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/expo-native-components': minor
---

Add native module support for the upcoming single-token client sync between `@clerk/expo` and the Clerk iOS and Android SDKs. The native SDK's stored device token becomes the one token both runtimes use, and each side is notified to reload its own client when the other changes it. Existing sync behavior is unchanged until `@clerk/expo` adopts it.
4 changes: 2 additions & 2 deletions packages/expo-native-components/android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ def clerkExpoVersion = rootProject.findProperty("clerkExpo.hostSdkVersion")?.toS
// See: https://docs.gradle.org/current/userguide/version_catalogs.html for app-level version catalogs
ext {
kotlinxCoroutinesVersion = "1.7.3"
clerkAndroidApiVersion = "1.1.11"
clerkAndroidUiVersion = "1.1.11"
clerkAndroidApiVersion = "1.2.1"
clerkAndroidUiVersion = "1.2.1"
okhttpVersion = "5.4.0"
okhttpUrlConnectionVersion = "5.0.0-alpha.16"
composeVersion = "1.7.0"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package expo.modules.clerk

import com.clerk.api.network.model.client.Client

/** The native client state that JS must refetch its own client for when it changes. */
internal data class ClerkClientFingerprint(
val clientId: String?,
val lastActiveSessionId: String?,
val sessions: List<SessionState>,
val activeUserId: String?,
val activeUserUpdatedAt: Long?,
val deviceToken: String?
) {
data class SessionState(val id: String, val status: String)

companion object {
fun from(client: Client?, deviceToken: String?): ClerkClientFingerprint {
val activeUser = client?.sessions?.firstOrNull { it.id == client.lastActiveSessionId }?.user
return ClerkClientFingerprint(
clientId = client?.id,
lastActiveSessionId = client?.lastActiveSessionId,
sessions = client?.sessions?.map { SessionState(it.id, it.status.name) }.orEmpty(),
activeUserId = activeUser?.id,
activeUserUpdatedAt = activeUser?.updatedAt,
deviceToken = deviceToken
)
}
}
}

/**
* Emits one payload-free invalidation per main-loop turn in which the fingerprint moved away from
* the last state JS was told about. Not thread-safe: call it from the main thread only.
*/
internal class ClerkClientInvalidationTracker(
private val schedule: (() -> Unit) -> Unit,
private val emit: () -> Unit
) {
private var baseline: ClerkClientFingerprint? = null
private var latest: ClerkClientFingerprint? = null
private var isFlushScheduled = false

/** Sets the state JS already knows about without emitting. */
fun reset(fingerprint: ClerkClientFingerprint?) {
baseline = fingerprint
latest = fingerprint
}

fun observe(fingerprint: ClerkClientFingerprint) {
if (baseline == null) return
latest = fingerprint
if (fingerprint == baseline || isFlushScheduled) return
isFlushScheduled = true
schedule(::flush)
}

/** A token JS wrote itself is not news to JS, so it must not echo back as an invalidation. */
fun acknowledgeDeviceToken(token: String?, current: ClerkClientFingerprint) {
baseline = baseline?.copy(deviceToken = token)
observe(current)
}

private fun flush() {
isFlushScheduled = false
val current = latest ?: return
if (current == baseline) return
baseline = current
emit()
}
}

internal data class ClerkDeviceTokenBridgeError(val code: String, val message: String)

internal fun clerkSetDeviceTokenBridgeError(throwable: Throwable): ClerkDeviceTokenBridgeError = when (throwable) {
is IllegalArgumentException -> ClerkDeviceTokenBridgeError(
"E_INVALID_DEVICE_TOKEN",
throwable.message ?: "Device token must not be blank"
)
is IllegalStateException -> ClerkDeviceTokenBridgeError(
"E_NOT_CONFIGURED",
throwable.message ?: "Clerk must be configured with configureNative before syncing client state."
)
else -> ClerkDeviceTokenBridgeError(
"E_SET_DEVICE_TOKEN_FAILED",
throwable.message ?: "Unable to set the device token"
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
package expo.modules.clerk

import android.content.Context
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
Expand Down Expand Up @@ -39,12 +41,14 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import org.json.JSONObject

private const val TAG = "ClerkExpoModule"
private const val NATIVE_AUTH_FLOW_CHANGED_EVENT = "clerkNativeAuthFlowChanged"
private const val NATIVE_CLIENT_CHANGED_EVENT = "clerkNativeClientChanged"
private const val NATIVE_CLIENT_INVALIDATED_EVENT = "clerkNativeClientInvalidated"
private const val HOST_SDK_HEADER = "x-clerk-host-sdk"
private const val HOST_SDK_VERSION_HEADER = "x-clerk-host-sdk-version"
private const val HOST_SDK = "expo"
Expand Down Expand Up @@ -212,6 +216,12 @@ class ClerkExpoModule : Module() {
private var lastObservedClientState: ClientStateSnapshot? = null
private var jsOriginatedClientSyncDepth = 0
private var configuredPublishableKey: String? = null
private val mainHandler = Handler(Looper.getMainLooper())
private var clientInvalidationObserverJob: Job? = null
private val clientInvalidationTracker = ClerkClientInvalidationTracker(
schedule = { flush -> mainHandler.post(flush) },
emit = { sendEvent(NATIVE_CLIENT_INVALIDATED_EVENT, emptyMap<String, Any?>()) }
)
private var configuredProxyUrl: String? = null

private data class AuthFlowStateSnapshot(
Expand Down Expand Up @@ -247,7 +257,7 @@ class ClerkExpoModule : Module() {
override fun definition() = ModuleDefinition {
Name("ClerkExpo")

Events(NATIVE_AUTH_FLOW_CHANGED_EVENT, NATIVE_CLIENT_CHANGED_EVENT)
Events(NATIVE_AUTH_FLOW_CHANGED_EVENT, NATIVE_CLIENT_CHANGED_EVENT, NATIVE_CLIENT_INVALIDATED_EVENT)

OnCreate {
sharedInstance = this@ClerkExpoModule
Expand All @@ -262,6 +272,9 @@ class ClerkExpoModule : Module() {
authFlowStateObserverJob = null
clientStateObserverJob?.cancel()
clientStateObserverJob = null
clientInvalidationObserverJob?.cancel()
clientInvalidationObserverJob = null
mainHandler.removeCallbacksAndMessages(null)
}

AsyncFunction("configure") { pubKey: String, bearerToken: String?, promise: Promise ->
Expand All @@ -276,6 +289,22 @@ class ClerkExpoModule : Module() {
getClientToken(promise)
}

AsyncFunction("configureNative") { pubKey: String, seedDeviceToken: String?, proxyUrl: String?, promise: Promise ->
configureNative(pubKey, seedDeviceToken, proxyUrl, promise)
}

AsyncFunction("getDeviceToken") { promise: Promise ->
getDeviceToken(promise)
}

AsyncFunction("setDeviceToken") { token: String?, expected: String?, promise: Promise ->
setDeviceToken(token, expected, promise)
}

AsyncFunction("refreshClient") { promise: Promise ->
refreshClient(promise)
}

AsyncFunction("getAuthFlowState") { promise: Promise ->
promise.resolve(authFlowStatePayload())
}
Expand Down Expand Up @@ -632,6 +661,143 @@ class ClerkExpoModule : Module() {
}
}

// MARK: - single-token client sync

private fun configureNative(pubKey: String, seedDeviceToken: String?, proxyUrl: String?, promise: Promise) {
val context = reactContext ?: run {
promise.reject("E_CONFIGURE_FAILED", "React context is not available", null)
return
}

coroutineScope.launch {
try {
val normalizedProxyUrl = proxyUrl?.trim()?.takeIf { it.isNotEmpty() }
val activePublishableKey = configuredPublishableKey ?: Clerk.publishableKey
val activeProxyUrl = configuredProxyUrl ?: Clerk.proxyUrl
val didConfigure = when {
activePublishableKey == null -> {
Clerk.initialize(context, pubKey, clerkConfigurationOptions(normalizedProxyUrl))
true
}
activePublishableKey != pubKey || activeProxyUrl != normalizedProxyUrl -> {
Clerk.switchConfiguration(context, pubKey, clerkConfigurationOptions(normalizedProxyUrl))
true
}
else -> false
}
if (didConfigure) {
configuredPublishableKey = pubKey
configuredProxyUrl = normalizedProxyUrl
appContext.currentActivity?.let { Clerk.attachActivity(it) }
// Must follow initialize(), which resets customTheme.
loadThemeFromAssets(context)
}
startClientStateObserver()

val didAdoptSeed = adoptSeedDeviceTokenIfNeeded(seedDeviceToken)
startClientInvalidationObserver()
lastObservedClientState = clientStateSnapshot()
if (didAdoptSeed) {
// An initialization refresh started before the seed was stored is fenced off by the token change.
launch {
val result = Clerk.refreshClient()
if (result is ClerkResult.Failure) {
debugLog(TAG, "configureNative - refresh after seed adoption failed: ${result.error}")
}
clientInvalidationTracker.observe(clientFingerprint())
}
}
promise.resolve(null)
} catch (e: Exception) {
promise.reject("E_CONFIGURE_FAILED", "Failed to configure Clerk SDK: ${e.message}", e)
}
}
}

private suspend fun adoptSeedDeviceTokenIfNeeded(seedDeviceToken: String?): Boolean {
val seed = seedDeviceToken?.trim()?.takeIf { it.isNotEmpty() } ?: return false
return withContext(Dispatchers.IO) {
Clerk.getDeviceToken() == null && Clerk.setDeviceToken(seed, null)
}
}

private fun startClientInvalidationObserver() {
clientInvalidationTracker.reset(clientFingerprint())
if (clientInvalidationObserverJob != null) {
return
}

clientInvalidationObserverJob = coroutineScope.launch {
Clerk.clientFlow.collect { client ->
clientInvalidationTracker.observe(clientFingerprint(client))
}
}
}

private fun clientFingerprint(client: Client? = Clerk.clientFlow.value): ClerkClientFingerprint {
return ClerkClientFingerprint.from(client, clientStateSnapshot(client).deviceToken)
}

private fun isClerkConfigured(): Boolean = Clerk.publishableKey != null

private fun getDeviceToken(promise: Promise) {
if (!isClerkConfigured()) {
promise.resolve(null)
return
}
try {
promise.resolve(Clerk.getDeviceToken())
} catch (e: Exception) {
promise.reject("E_GET_DEVICE_TOKEN_FAILED", e.message ?: "Unable to read the device token", e)
}
}

private fun setDeviceToken(token: String?, expected: String?, promise: Promise) {
coroutineScope.launch {
try {
val didSet = withContext(Dispatchers.IO) { Clerk.setDeviceToken(token, expected) }
val fingerprint = clientFingerprint()
if (didSet) {
clientInvalidationTracker.acknowledgeDeviceToken(fingerprint.deviceToken, fingerprint)
} else {
clientInvalidationTracker.observe(fingerprint)
}
promise.resolve(didSet)
} catch (e: Exception) {
val error = clerkSetDeviceTokenBridgeError(e)
promise.reject(error.code, error.message, e)
}
}
}
Comment thread
mikepitre marked this conversation as resolved.

private fun refreshClient(promise: Promise) {
if (!isClerkConfigured()) {
promise.reject(
"E_NOT_CONFIGURED",
"Clerk must be configured with configureNative before syncing client state.",
null
)
return
}

coroutineScope.launch {
try {
val result = Clerk.refreshClient()
clientInvalidationTracker.observe(clientFingerprint())
when (result) {
is ClerkResult.Success -> promise.resolve(null)
is ClerkResult.Failure -> promise.reject(
"E_REFRESH_CLIENT_FAILED",
result.error?.firstMessage() ?: result.throwable?.message ?: "Client refresh failed",
result.throwable
)
}
} catch (e: Exception) {
promise.reject("E_REFRESH_CLIENT_FAILED", e.message ?: "Client refresh failed", e)
}
}
}

// MARK: - getClientToken

private fun getClientToken(promise: Promise) {
Expand Down
Loading
Loading