diff --git a/.changeset/fix-native-bridge-quality.md b/.changeset/fix-native-bridge-quality.md
new file mode 100644
index 00000000000..6d65646ce5f
--- /dev/null
+++ b/.changeset/fix-native-bridge-quality.md
@@ -0,0 +1,8 @@
+---
+'@clerk/expo': minor
+'@clerk/react': patch
+---
+
+Add native AuthView and UserProfileView components for iOS (SwiftUI) and Android (Jetpack Compose)
+Update @clerk/expo and @clerk/react to Core-3 Signal APIs
+Integrate native Google Sign-In and Apple Sign-In via Credential Manager and ASAuthorization
diff --git a/packages/expo/NATIVE_IOS_SETUP.md b/packages/expo/NATIVE_IOS_SETUP.md
new file mode 100644
index 00000000000..2fb3cb9b249
--- /dev/null
+++ b/packages/expo/NATIVE_IOS_SETUP.md
@@ -0,0 +1,279 @@
+# Native iOS Setup for @clerk/clerk-expo
+
+This guide explains how to use Clerk's native iOS components in your Expo or React Native application.
+
+## Overview
+
+`@clerk/clerk-expo` supports two implementations:
+
+1. **Native-First (Recommended)**: Uses Clerk's native iOS Swift UI components for the best user experience
+2. **React Native**: Cross-platform React Native components that work everywhere
+
+## Feature Comparison
+
+| Feature | Native iOS (Swift UI) | React Native |
+| -------------------- | ------------------------------------ | ------------------------------- |
+| **UI/UX** | Native iOS design, follows Apple HIG | Cross-platform design |
+| **Performance** | Native Swift performance | JavaScript bridge overhead |
+| **Bundle Size** | Smaller JS bundle | Larger JS bundle |
+| **Customization** | Limited to Clerk iOS theming | Full React Native customization |
+| **Platform Support** | iOS only | iOS, Android, Web |
+| **Build Method** | Requires native build (EAS/Xcode) | Works with Expo Go |
+| **Face ID/Touch ID** | Native biometric integration | Via expo-local-authentication |
+| **Passkeys** | Native passkey support | Limited support |
+| **OAuth** | Native SFAuthenticationSession | WebBrowser-based |
+
+---
+
+## Setup Instructions
+
+### For Expo Users (Recommended)
+
+#### Prerequisites
+
+- Expo SDK 50 or later
+- EAS Build account (native builds required)
+- iOS deployment target 15.1+
+
+#### 1. Install the Package
+
+```bash
+npx expo install @clerk/clerk-expo
+```
+
+#### 2. Add the Expo Config Plugin
+
+In your `app.json` or `app.config.js`:
+
+```json
+{
+ "expo": {
+ "plugins": [["@clerk/clerk-expo/app.plugin"]]
+ }
+}
+```
+
+#### 3. Configure Your App
+
+```tsx
+// app/_layout.tsx
+import { ClerkProvider } from '@clerk/clerk-expo';
+
+export default function RootLayout() {
+ return (
+
+ {/* Your app content */}
+
+ );
+}
+```
+
+#### 4. Use Native Components
+
+```tsx
+// app/(auth)/sign-in.tsx
+import { SignIn } from '@clerk/clerk-expo/native';
+import { useRouter } from 'expo-router';
+
+export default function SignInScreen() {
+ const router = useRouter();
+
+ return (
+ router.replace('/(home)')}
+ onError={error => console.error('Sign in error:', error)}
+ />
+ );
+}
+```
+
+#### 5. Build with EAS
+
+The native iOS components require a native build:
+
+```bash
+# Development build
+eas build --profile development --platform ios
+
+# Install on simulator
+eas build:run --profile development --platform ios
+
+# Production build
+eas build --profile production --platform ios
+```
+
+**Important**: Native iOS components **will not work** with Expo Go. You must create a development build.
+
+---
+
+### For React Native CLI Users
+
+If you're using React Native without Expo, you'll need to manually add the clerk-ios Swift package.
+
+#### Prerequisites
+
+- React Native 0.70 or later
+- CocoaPods
+- Xcode 14+
+- iOS deployment target 15.1+
+
+#### 1. Install the Package
+
+```bash
+npm install @clerk/clerk-expo
+# or
+yarn add @clerk/clerk-expo
+```
+
+#### 2. Install iOS Dependencies
+
+```bash
+cd ios && pod install && cd ..
+```
+
+#### 3. Add clerk-ios Swift Package in Xcode
+
+1. Open your `.xcworkspace` file in Xcode
+2. Select your project in the Project Navigator
+3. Select your app target
+4. Go to the "Package Dependencies" tab
+5. Click the "+" button
+6. Enter the repository URL: `https://github.com/clerk/clerk-ios.git`
+7. Select "Up to Next Major Version" with minimum version `0.68.1`
+8. Ensure the "Clerk" product is selected for your target
+9. Click "Add Package"
+
+#### 4. Verify Installation
+
+Build your project to ensure the Swift package is properly linked:
+
+```bash
+npx react-native run-ios
+```
+
+---
+
+## Using React Native Components Instead
+
+If you want to use the cross-platform React Native components (works with Expo Go), import from the main package:
+
+```tsx
+import { SignIn } from '@clerk/clerk-expo';
+// NOT from '@clerk/clerk-expo/native'
+```
+
+### When to Use React Native Components
+
+- Testing in Expo Go
+- Need Android support
+- Want full UI customization
+- Don't need native iOS features (Face ID, Passkeys)
+
+### When to Use Native iOS Components
+
+- Building a production iOS app
+- Want the best iOS user experience
+- Need native biometric authentication
+- Want smaller JavaScript bundle size
+- Need passkey support
+
+---
+
+## API Reference
+
+### Native SignIn Component
+
+```tsx
+import { SignIn } from '@clerk/clerk-expo/native';
+
+ void}
+ onError={(error) => void}
+/>
+```
+
+**Props:**
+
+- `mode`: Authentication mode (default: `"signInOrUp"`)
+- `isDismissable`: Whether the view can be dismissed (default: `true`)
+- `onSuccess`: Callback when authentication succeeds
+- `onError`: Callback when authentication fails
+
+---
+
+## Troubleshooting
+
+### "Module 'Clerk' not found"
+
+The clerk-ios Swift package isn't installed. Follow the manual setup steps above.
+
+### "Expo Go doesn't show native components"
+
+Native components require a development build. Run `eas build --profile development --platform ios`.
+
+### Plugin doesn't add Swift package
+
+The config plugin only runs during `expo prebuild` or `eas build`. If you're using a bare workflow, you'll need to add the package manually in Xcode.
+
+### Build fails with Swift errors
+
+Ensure your iOS deployment target is at least 15.1 in your `Podfile`:
+
+```ruby
+platform :ios, '15.1'
+```
+
+---
+
+## Migration Guide
+
+### From React Native Components to Native
+
+1. Change your imports:
+
+```tsx
+// Before
+import { SignIn } from '@clerk/clerk-expo';
+
+// After
+import { SignIn } from '@clerk/clerk-expo/native';
+```
+
+2. Create a development build (can't use Expo Go)
+3. Test on a physical device or simulator
+
+### From Native to React Native
+
+1. Change your imports back:
+
+```tsx
+// Before
+import { SignIn } from '@clerk/clerk-expo/native';
+
+// After
+import { SignIn } from '@clerk/clerk-expo';
+```
+
+2. Can now use Expo Go for testing
+
+---
+
+## Additional Resources
+
+- [Clerk iOS SDK Documentation](https://github.com/clerk/clerk-ios)
+- [Expo Config Plugins](https://docs.expo.dev/config-plugins/introduction/)
+- [EAS Build Documentation](https://docs.expo.dev/build/introduction/)
+- [Clerk Dashboard](https://dashboard.clerk.com/)
+
+---
+
+## Support
+
+For issues related to:
+
+- Native iOS components: [clerk-ios repository](https://github.com/clerk/clerk-ios/issues)
+- Expo integration: [clerk-javascript repository](https://github.com/clerk/javascript/issues)
+- General Clerk questions: [Clerk Discord](https://clerk.com/discord)
diff --git a/packages/expo/android/build.gradle b/packages/expo/android/build.gradle
index ee1fab8fa00..edb1f5e2387 100644
--- a/packages/expo/android/build.gradle
+++ b/packages/expo/android/build.gradle
@@ -1,5 +1,13 @@
-apply plugin: 'com.android.library'
-apply plugin: 'kotlin-android'
+plugins {
+ id 'com.android.library'
+ id 'org.jetbrains.kotlin.android'
+ id 'org.jetbrains.kotlin.plugin.compose' version '2.1.20'
+}
+
+// Required for React Native codegen to generate Fabric component descriptors
+if (project.hasProperty("newArchEnabled") && project.newArchEnabled == "true") {
+ apply plugin: "com.facebook.react"
+}
group = 'com.clerk.expo'
version = '1.0.0'
@@ -10,6 +18,11 @@ ext {
credentialsVersion = "1.3.0"
googleIdVersion = "1.1.1"
kotlinxCoroutinesVersion = "1.7.3"
+ clerkAndroidApiVersion = "1.0.1"
+ clerkAndroidUiVersion = "1.0.1"
+ composeVersion = "1.7.0"
+ activityComposeVersion = "1.9.0"
+ lifecycleVersion = "2.8.0"
}
def safeExtGet(prop, fallback) {
@@ -17,7 +30,7 @@ def safeExtGet(prop, fallback) {
}
android {
- namespace "expo.modules.clerk.googlesignin"
+ namespace "expo.modules.clerk"
compileSdk safeExtGet("compileSdkVersion", 36)
@@ -43,16 +56,28 @@ android {
jvmTarget = "17"
}
+ buildFeatures {
+ compose = true
+ }
+
+ packaging {
+ resources {
+ excludes += ['META-INF/versions/9/OSGI-INF/MANIFEST.MF']
+ }
+ }
+
sourceSets {
main {
- java.srcDirs = ['src/main/java']
+ java.srcDirs = ['src/main/java', "${project.buildDir}/generated/source/codegen/java"]
}
}
}
+// Note: kotlin-stdlib exclusions are handled in the clerk-android-ui dependency declaration
+
dependencies {
- // Expo modules core
- implementation project(':expo-modules-core')
+ // React Native
+ implementation 'com.facebook.react:react-native:+'
// Credential Manager for Google Sign-In with nonce support
implementation "androidx.credentials:credentials:$credentialsVersion"
@@ -61,4 +86,22 @@ dependencies {
// Coroutines for async operations
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlinxCoroutinesVersion"
+
+ // Clerk Android SDK with prebuilt UI
+ // Exclude kotlin-stdlib to prevent 2.3.0 from polluting the project
+ // Exclude okhttp to prevent version conflict with React Native's okhttp
+ implementation("com.clerk:clerk-android-ui:$clerkAndroidUiVersion") {
+ exclude group: 'org.jetbrains.kotlin', module: 'kotlin-stdlib'
+ exclude group: 'org.jetbrains.kotlin', module: 'kotlin-stdlib-jdk7'
+ exclude group: 'org.jetbrains.kotlin', module: 'kotlin-stdlib-jdk8'
+ exclude group: 'com.squareup.okhttp3', module: 'okhttp'
+ exclude group: 'com.squareup.okhttp3', module: 'okhttp-urlconnection'
+ }
+
+ // Jetpack Compose for wrapping Clerk views
+ implementation "androidx.compose.ui:ui:$composeVersion"
+ implementation "androidx.compose.material3:material3:1.3.0"
+ implementation "androidx.activity:activity-compose:$activityComposeVersion"
+ implementation "androidx.lifecycle:lifecycle-runtime-compose:$lifecycleVersion"
+ implementation "androidx.lifecycle:lifecycle-viewmodel-compose:$lifecycleVersion"
}
diff --git a/packages/expo/android/src/main/AndroidManifest.xml b/packages/expo/android/src/main/AndroidManifest.xml
index a2f47b6057d..4683222f409 100644
--- a/packages/expo/android/src/main/AndroidManifest.xml
+++ b/packages/expo/android/src/main/AndroidManifest.xml
@@ -1,2 +1,17 @@
+
+
+
+
+
+
+
diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthActivity.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthActivity.kt
new file mode 100644
index 00000000000..7ef0bee81a0
--- /dev/null
+++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthActivity.kt
@@ -0,0 +1,303 @@
+package expo.modules.clerk
+
+import android.app.Activity
+import android.content.Intent
+import android.os.Bundle
+import android.util.Log
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.BackHandler
+import androidx.activity.compose.setContent
+import java.util.concurrent.atomic.AtomicBoolean
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.clerk.api.Clerk
+import com.clerk.api.signin.SignIn
+import com.clerk.api.signin.prepareSecondFactor
+import com.clerk.api.signup.SignUp
+import com.clerk.api.signup.prepareVerification
+import com.clerk.api.network.serialization.onSuccess
+import com.clerk.api.network.serialization.onFailure
+import com.clerk.api.network.serialization.errorMessage
+import com.clerk.ui.auth.AuthView
+import kotlinx.coroutines.delay
+
+/**
+ * Activity that hosts Clerk's AuthView Compose component.
+ *
+ * This activity is launched from ClerkExpoModule to present a full-screen
+ * authentication modal (sign-in, sign-up, or combined flow).
+ *
+ * Intent extras:
+ * - "mode": String - "signIn", "signUp", or "signInOrUp" (default)
+ * - "dismissable": Boolean - whether back press dismisses (default: true)
+ *
+ * Result:
+ * - RESULT_OK: Auth completed successfully (session is available via Clerk.session)
+ * - RESULT_CANCELED: User dismissed the modal
+ */
+class ClerkAuthActivity : ComponentActivity() {
+
+ companion object {
+ private const val TAG = "ClerkAuthActivity"
+
+ private fun debugLog(tag: String, message: String) {
+ if (BuildConfig.DEBUG) {
+ Log.d(tag, message)
+ }
+ }
+ }
+
+ private val authCompleteGuard = AtomicBoolean(false)
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ val mode = intent.getStringExtra(ClerkExpoModule.EXTRA_MODE) ?: "signInOrUp"
+ val dismissable = intent.getBooleanExtra(ClerkExpoModule.EXTRA_DISMISSABLE, true)
+
+ // Track if we had a session when we started (to detect new sign-in)
+ val initialSession = Clerk.session
+ debugLog(TAG, "onCreate - initialSession: ${initialSession?.id}, mode: $mode")
+
+ setContent {
+ // Observe initialization state
+ val isInitialized by Clerk.isInitialized.collectAsStateWithLifecycle()
+
+ // Observe both session and user state for completion
+ val session by Clerk.sessionFlow.collectAsStateWithLifecycle()
+ val user by Clerk.userFlow.collectAsStateWithLifecycle()
+
+ // Track if the client has been synced (environment is ready)
+ // We need to wait for the client to sync before showing AuthView
+ var isClientReady by remember { mutableStateOf(false) }
+
+ // Track when auth is complete to hide AuthView before finishing
+ // This prevents the "NavDisplay backstack cannot be empty" crash
+ var isAuthComplete by remember { mutableStateOf(false) }
+
+ // Wait for SDK to be fully initialized AND client to sync
+ // The client sync happens after isInitialized becomes true
+ LaunchedEffect(isInitialized) {
+ if (isInitialized) {
+ // Give the client a moment to sync after initialization
+ // The SDK needs time to fetch the environment configuration
+ var attempts = 0
+ while (attempts < 30) { // Wait up to 3 seconds
+ val client = Clerk.client
+ if (client != null) {
+ debugLog(TAG, "Client is ready: ${client.id}")
+ isClientReady = true
+ break
+ }
+ delay(100)
+ attempts++
+ }
+ if (!isClientReady) {
+ Log.w(TAG, "Client did not become ready after 3 seconds, showing AuthView anyway")
+ isClientReady = true
+ }
+ }
+ }
+
+ // Track last signUp ID to detect when a new signUp is created
+ var lastSignUpId by remember { mutableStateOf(null) }
+ // Track if we've already triggered prepareVerification for this signUp
+ var preparedSignUpId by remember { mutableStateOf(null) }
+
+ // Track if we've already triggered prepareSecondFactor for this signIn
+ var preparedSecondFactorSignInId by remember { mutableStateOf(null) }
+
+ // Monitor signUp state changes and manually trigger prepareVerification
+ LaunchedEffect(isClientReady) {
+ if (isClientReady) {
+ while (true) {
+ delay(500) // Check every 500ms
+ val client = Clerk.client
+ val signUp = client?.signUp
+
+ if (signUp != null && signUp.id != lastSignUpId) {
+ lastSignUpId = signUp.id
+ debugLog(TAG, "New signUp detected: ${signUp.id}, status: ${signUp.status}")
+ }
+
+ // Manually trigger prepareVerification if needed
+ // This is a workaround for clerk-android-ui not calling prepareVerification
+ if (signUp != null &&
+ signUp.id != preparedSignUpId &&
+ signUp.emailAddress != null &&
+ signUp.status == SignUp.Status.MISSING_REQUIREMENTS) {
+
+ val emailVerification = signUp.verifications?.get("email_address")
+ // Only prepare if email is unverified
+ if (emailVerification?.status?.name == "UNVERIFIED") {
+ preparedSignUpId = signUp.id
+
+ try {
+ val result = signUp.prepareVerification(
+ SignUp.PrepareVerificationParams.Strategy.EmailCode()
+ )
+ result
+ .onSuccess {
+ debugLog(TAG, "prepareVerification succeeded")
+ }
+ .onFailure { error ->
+ Log.e(TAG, "prepareVerification failed: ${error.errorMessage}")
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "prepareVerification exception: ${e.message}")
+ }
+ }
+ }
+
+ // Manually trigger prepareSecondFactor for MFA if needed
+ // This is a workaround for clerk-android-ui not calling prepareSecondFactor
+ val signIn = client?.signIn
+ if (signIn != null &&
+ signIn.id != preparedSecondFactorSignInId &&
+ signIn.status == SignIn.Status.NEEDS_SECOND_FACTOR) {
+
+ preparedSecondFactorSignInId = signIn.id
+
+ try {
+ val result = signIn.prepareSecondFactor()
+ result
+ .onSuccess { updatedSignIn ->
+ debugLog(TAG, "prepareSecondFactor succeeded, status: ${updatedSignIn.status}")
+ }
+ .onFailure { error ->
+ Log.e(TAG, "prepareSecondFactor failed: ${error.errorMessage}")
+ // Reset so we can retry
+ preparedSecondFactorSignInId = null
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "prepareSecondFactor exception: ${e.message}")
+ // Reset so we can retry
+ preparedSecondFactorSignInId = null
+ }
+ }
+
+ // Check if auth completed - finish activity immediately
+ val currentSession = Clerk.session
+ if (currentSession != null && authCompleteGuard.compareAndSet(false, true)) {
+ isAuthComplete = true
+
+ val resultIntent = Intent().apply {
+ putExtra("sessionId", currentSession.id)
+ putExtra("userId", currentSession.user?.id ?: Clerk.user?.id)
+ }
+ setResult(Activity.RESULT_OK, resultIntent)
+ finish()
+ break
+ }
+ }
+ }
+ }
+
+ // Backup: Also listen for session via Flow (in case polling misses it)
+ LaunchedEffect(session) {
+ if (session != null && initialSession == null && authCompleteGuard.compareAndSet(false, true)) {
+ // Mark auth as complete FIRST to hide AuthView
+ // This prevents the "NavDisplay backstack cannot be empty" crash
+ isAuthComplete = true
+
+ // Small delay to let the UI update before finishing
+ delay(100)
+
+ // Auth completed - return session info
+ val resultIntent = Intent().apply {
+ putExtra("sessionId", session?.id)
+ putExtra("userId", session?.user?.id ?: user?.id)
+ }
+ setResult(Activity.RESULT_OK, resultIntent)
+ finish()
+ }
+ }
+
+ // Handle back press
+ if (dismissable) {
+ BackHandler {
+ setResult(Activity.RESULT_CANCELED)
+ finish()
+ }
+ } else {
+ // Block back press when not dismissable
+ BackHandler { /* Do nothing */ }
+ }
+
+ // Render Clerk's AuthView in a Material3 surface
+ MaterialTheme {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = MaterialTheme.colorScheme.background
+ ) {
+ when {
+ isAuthComplete -> {
+ // Auth completed - show success indicator while finishing
+ // This prevents AuthView from crashing with empty navigation backstack
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(48.dp)
+ )
+ Text(
+ text = "Signed in!",
+ style = MaterialTheme.typography.bodyMedium
+ )
+ }
+ }
+ }
+ isClientReady -> {
+ // Client is ready, show AuthView
+ AuthView(
+ modifier = Modifier.fillMaxSize(),
+ clerkTheme = null // Use default theme, or pass custom
+ )
+ }
+ else -> {
+ // Show loading while waiting for client to sync
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(48.dp)
+ )
+ Text(
+ text = "Loading...",
+ style = MaterialTheme.typography.bodyMedium
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthExpoView.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthExpoView.kt
new file mode 100644
index 00000000000..712b0ec62f3
--- /dev/null
+++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthExpoView.kt
@@ -0,0 +1,149 @@
+package expo.modules.clerk
+
+import android.content.Context
+import android.content.ContextWrapper
+import android.util.Log
+import android.widget.FrameLayout
+import androidx.activity.ComponentActivity
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.Recomposer
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.AndroidUiDispatcher
+import androidx.compose.ui.platform.ComposeView
+import androidx.lifecycle.compose.LocalLifecycleOwner
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.setViewTreeLifecycleOwner
+import androidx.lifecycle.setViewTreeViewModelStoreOwner
+import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner
+import androidx.savedstate.compose.LocalSavedStateRegistryOwner
+import androidx.savedstate.setViewTreeSavedStateRegistryOwner
+import com.clerk.api.Clerk
+import com.clerk.ui.auth.AuthView
+import com.facebook.react.bridge.Arguments
+import com.facebook.react.bridge.ReactContext
+import com.facebook.react.uimanager.events.RCTEventEmitter
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+
+private const val TAG = "ClerkAuthExpoView"
+
+class ClerkAuthNativeView(context: Context) : FrameLayout(context) {
+ var mode: String = "signInOrUp"
+ var isDismissable: Boolean = true
+
+ private val activity: ComponentActivity? = findActivity(context)
+
+ private var recomposer: Recomposer? = null
+ private var recomposerJob: kotlinx.coroutines.Job? = null
+
+ private val composeView = ComposeView(context).also { view ->
+ activity?.let { act ->
+ view.setViewTreeLifecycleOwner(act)
+ view.setViewTreeViewModelStoreOwner(act)
+ view.setViewTreeSavedStateRegistryOwner(act)
+
+ // Create an explicit Recomposer to bypass windowRecomposer resolution.
+ // In Compose 1.7+, windowRecomposer looks at rootView which may not have
+ // lifecycle owners in React Native Fabric's detached view trees.
+ val recomposerContext = AndroidUiDispatcher.Main
+ val newRecomposer = Recomposer(recomposerContext)
+ recomposer = newRecomposer
+ view.setParentCompositionContext(newRecomposer)
+ val scope = CoroutineScope(recomposerContext + kotlinx.coroutines.SupervisorJob())
+ recomposerJob = scope.coroutineContext[kotlinx.coroutines.Job]
+ scope.launch {
+ newRecomposer.runRecomposeAndApplyChanges()
+ }
+ }
+ addView(view, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
+ }
+
+ override fun onDetachedFromWindow() {
+ recomposer?.cancel()
+ recomposerJob?.cancel()
+ super.onDetachedFromWindow()
+ }
+
+ // Track the initial session to detect new sign-ins
+ private var initialSessionId: String? = Clerk.session?.id
+
+ fun setupView() {
+ Log.d(TAG, "setupView - mode: $mode, isDismissable: $isDismissable, activity: $activity")
+
+ composeView.setContent {
+ val session by Clerk.sessionFlow.collectAsStateWithLifecycle()
+
+ // Detect auth completion: session appeared when there wasn't one
+ LaunchedEffect(session) {
+ val currentSession = session
+ if (currentSession != null && initialSessionId == null) {
+ Log.d(TAG, "Auth completed - session: ${currentSession.id}")
+ sendEvent("signInCompleted", mapOf(
+ "sessionId" to currentSession.id,
+ "type" to "signIn"
+ ))
+ }
+ }
+
+ // Provide the Activity as ViewModelStoreOwner so Clerk's viewModel() calls work
+ val content = @androidx.compose.runtime.Composable {
+ MaterialTheme {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = MaterialTheme.colorScheme.background
+ ) {
+ AuthView(
+ modifier = Modifier.fillMaxSize(),
+ clerkTheme = null
+ )
+ }
+ }
+ }
+
+ if (activity != null) {
+ CompositionLocalProvider(
+ LocalViewModelStoreOwner provides activity,
+ LocalLifecycleOwner provides activity,
+ LocalSavedStateRegistryOwner provides activity,
+ ) {
+ content()
+ }
+ } else {
+ Log.e(TAG, "No ComponentActivity found!")
+ content()
+ }
+ }
+ }
+
+ private fun sendEvent(type: String, data: Map) {
+ val reactContext = context as? ReactContext ?: return
+ val eventData = Arguments.createMap().apply {
+ putString("type", type)
+ // Serialize data as JSON string for codegen event
+ val jsonString = try {
+ org.json.JSONObject(data).toString()
+ } catch (e: Exception) {
+ "{}"
+ }
+ putString("data", jsonString)
+ }
+ reactContext.getJSModule(RCTEventEmitter::class.java)
+ .receiveEvent(id, "onAuthEvent", eventData)
+ }
+
+ companion object {
+ fun findActivity(context: Context): ComponentActivity? {
+ var ctx: Context? = context
+ while (ctx != null) {
+ if (ctx is ComponentActivity) return ctx
+ ctx = (ctx as? ContextWrapper)?.baseContext
+ }
+ return null
+ }
+ }
+}
diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthViewManager.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthViewManager.kt
new file mode 100644
index 00000000000..9ff989d9ea8
--- /dev/null
+++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthViewManager.kt
@@ -0,0 +1,38 @@
+package expo.modules.clerk
+
+import com.facebook.react.common.MapBuilder
+import com.facebook.react.uimanager.SimpleViewManager
+import com.facebook.react.uimanager.ThemedReactContext
+import com.facebook.react.uimanager.annotations.ReactProp
+import com.facebook.react.viewmanagers.ClerkAuthViewManagerInterface
+
+class ClerkAuthViewManager : SimpleViewManager(),
+ ClerkAuthViewManagerInterface {
+
+ override fun getName(): String = "ClerkAuthView"
+
+ override fun createViewInstance(reactContext: ThemedReactContext): ClerkAuthNativeView {
+ return ClerkAuthNativeView(reactContext)
+ }
+
+ @ReactProp(name = "mode")
+ override fun setMode(view: ClerkAuthNativeView, mode: String?) {
+ view.mode = mode ?: "signInOrUp"
+ view.setupView()
+ }
+
+ @ReactProp(name = "isDismissable")
+ override fun setIsDismissable(view: ClerkAuthNativeView, isDismissable: Boolean) {
+ view.isDismissable = isDismissable
+ view.setupView()
+ }
+
+ override fun getExportedCustomBubblingEventTypeConstants(): MutableMap? {
+ return MapBuilder.builder()
+ .put("onAuthEvent", MapBuilder.of(
+ "phasedRegistrationNames",
+ MapBuilder.of("bubbled", "onAuthEvent")
+ ))
+ .build() as MutableMap
+ }
+}
diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt
new file mode 100644
index 00000000000..23cf1cd24a3
--- /dev/null
+++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt
@@ -0,0 +1,340 @@
+package expo.modules.clerk
+
+import android.app.Activity
+import android.content.Context
+import android.content.Intent
+import android.util.Log
+import com.clerk.api.Clerk
+import com.facebook.react.bridge.ActivityEventListener
+import com.facebook.react.bridge.Promise
+import com.facebook.react.bridge.ReactApplicationContext
+import com.facebook.react.bridge.ReactMethod
+import com.facebook.react.bridge.ReadableMap
+import com.facebook.react.bridge.WritableNativeMap
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.TimeoutCancellationException
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withTimeout
+
+private const val TAG = "ClerkExpoModule"
+
+private fun debugLog(tag: String, message: String) {
+ if (BuildConfig.DEBUG) {
+ Log.d(tag, message)
+ }
+}
+
+class ClerkExpoModule(reactContext: ReactApplicationContext) :
+ NativeClerkModuleSpec(reactContext),
+ ActivityEventListener {
+
+ companion object {
+ const val CLERK_AUTH_REQUEST_CODE = 9001
+ const val CLERK_PROFILE_REQUEST_CODE = 9002
+
+ // Intent extras
+ const val EXTRA_DISMISSABLE = "dismissable"
+ const val EXTRA_PUBLISHABLE_KEY = "publishableKey"
+ const val EXTRA_MODE = "mode"
+
+ // Result extras
+ const val RESULT_SESSION_ID = "sessionId"
+ const val RESULT_CANCELLED = "cancelled"
+
+ // Pending promises for activity results
+ private var pendingAuthPromise: Promise? = null
+ private var pendingProfilePromise: Promise? = null
+
+ // Store publishable key for passing to activities
+ private var publishableKey: String? = null
+ }
+
+ private val coroutineScope = CoroutineScope(Dispatchers.Main)
+
+ init {
+ reactContext.addActivityEventListener(this)
+ }
+
+ override fun getName(): String = "ClerkExpo"
+
+ // MARK: - configure
+
+ @ReactMethod
+ override fun configure(pubKey: String, bearerToken: String?, promise: Promise) {
+ coroutineScope.launch {
+ try {
+ publishableKey = pubKey
+
+ // If the JS SDK has a bearer token, write it to the native SDK's
+ // SharedPreferences so both SDKs share the same Clerk API client.
+ if (!bearerToken.isNullOrEmpty()) {
+ reactApplicationContext.getSharedPreferences("clerk_preferences", Context.MODE_PRIVATE)
+ .edit()
+ .putString("DEVICE_TOKEN", bearerToken)
+ .apply()
+ debugLog(TAG, "configure - wrote JS bearer token to native SharedPreferences")
+ }
+
+ Clerk.initialize(reactApplicationContext, pubKey)
+
+ // Wait for initialization to complete with timeout
+ try {
+ withTimeout(10_000L) {
+ Clerk.isInitialized.first { it }
+ }
+ } catch (e: TimeoutCancellationException) {
+ val initError = Clerk.initializationError.value
+ val message = if (initError != null) {
+ "Clerk initialization timed out: ${initError.message}"
+ } else {
+ "Clerk initialization timed out after 10 seconds"
+ }
+ promise.reject("E_TIMEOUT", message)
+ return@launch
+ }
+
+ // Check for initialization errors
+ val error = Clerk.initializationError.value
+ if (error != null) {
+ promise.reject("E_INIT_FAILED", "Failed to initialize Clerk SDK: ${error.message}")
+ } else {
+ promise.resolve(null)
+ }
+ } catch (e: Exception) {
+ promise.reject("E_INIT_FAILED", "Failed to initialize Clerk SDK: ${e.message}", e)
+ }
+ }
+ }
+
+ // MARK: - presentAuth
+
+ @ReactMethod
+ override fun presentAuth(options: ReadableMap, promise: Promise) {
+ val activity = getCurrentActivity() ?: run {
+ promise.reject("E_ACTIVITY_UNAVAILABLE", "No activity available to present Clerk UI.")
+ return
+ }
+
+ if (!Clerk.isInitialized.value) {
+ promise.reject("E_NOT_INITIALIZED", "Clerk SDK is not initialized. Call configure() first.")
+ return
+ }
+
+ // Check if user is already signed in
+ if (Clerk.session != null) {
+ promise.reject("already_signed_in", "User is already signed in")
+ return
+ }
+
+ pendingAuthPromise?.reject("E_SUPERSEDED", "Auth presentation was superseded")
+ pendingAuthPromise = promise
+
+ val mode = if (options.hasKey("mode")) options.getString("mode") ?: "signInOrUp" else "signInOrUp"
+ val dismissable = if (options.hasKey("dismissable")) options.getBoolean("dismissable") else true
+
+ val intent = Intent(activity, ClerkAuthActivity::class.java).apply {
+ putExtra(EXTRA_MODE, mode)
+ putExtra(EXTRA_DISMISSABLE, dismissable)
+ }
+
+ activity.startActivityForResult(intent, CLERK_AUTH_REQUEST_CODE)
+ }
+
+ // MARK: - presentUserProfile
+
+ @ReactMethod
+ override fun presentUserProfile(options: ReadableMap, promise: Promise) {
+ val activity = getCurrentActivity() ?: run {
+ promise.reject("E_ACTIVITY_UNAVAILABLE", "No activity available to present Clerk UI.")
+ return
+ }
+
+ if (!Clerk.isInitialized.value) {
+ promise.reject("E_NOT_INITIALIZED", "Clerk SDK is not initialized. Call configure() first.")
+ return
+ }
+
+ pendingProfilePromise?.reject("E_SUPERSEDED", "Profile presentation was superseded")
+ pendingProfilePromise = promise
+
+ val dismissable = if (options.hasKey("dismissable")) options.getBoolean("dismissable") else true
+
+ val intent = Intent(activity, ClerkUserProfileActivity::class.java).apply {
+ putExtra(EXTRA_DISMISSABLE, dismissable)
+ putExtra(EXTRA_PUBLISHABLE_KEY, publishableKey)
+ }
+
+ activity.startActivityForResult(intent, CLERK_PROFILE_REQUEST_CODE)
+ }
+
+ // MARK: - getSession
+
+ @ReactMethod
+ override fun getSession(promise: Promise) {
+ if (!Clerk.isInitialized.value) {
+ promise.reject("E_NOT_INITIALIZED", "Clerk SDK is not initialized. Call configure() first.")
+ return
+ }
+
+ val session = Clerk.session
+ val user = Clerk.user
+
+ debugLog(TAG, "getSession - session: ${session?.id}, user: ${user?.id}")
+
+ val result = WritableNativeMap()
+
+ session?.let {
+ val sessionMap = WritableNativeMap()
+ sessionMap.putString("id", it.id)
+ sessionMap.putString("status", it.status.name)
+ sessionMap.putString("userId", it.user?.id)
+ result.putMap("session", sessionMap)
+ }
+
+ user?.let {
+ val primaryEmail = it.emailAddresses?.find { e -> e.id == it.primaryEmailAddressId }
+ val primaryPhone = it.phoneNumbers.find { p -> p.id == it.primaryPhoneNumberId }
+
+ val userMap = WritableNativeMap()
+ userMap.putString("id", it.id)
+ userMap.putString("firstName", it.firstName)
+ userMap.putString("lastName", it.lastName)
+ userMap.putString("imageUrl", it.imageUrl)
+ userMap.putString("primaryEmailAddress", primaryEmail?.emailAddress)
+ userMap.putString("primaryPhoneNumber", primaryPhone?.phoneNumber)
+ result.putMap("user", userMap)
+ }
+
+ promise.resolve(result)
+ }
+
+ // MARK: - getClientToken
+
+ @ReactMethod
+ override fun getClientToken(promise: Promise) {
+ try {
+ val prefs = reactApplicationContext.getSharedPreferences("clerk_preferences", Context.MODE_PRIVATE)
+ val deviceToken = prefs.getString("DEVICE_TOKEN", null)
+ debugLog(TAG, "getClientToken - deviceToken: ${if (deviceToken != null) "found" else "null"}")
+ promise.resolve(deviceToken)
+ } catch (e: Exception) {
+ debugLog(TAG, "getClientToken failed: ${e.message}")
+ promise.resolve(null)
+ }
+ }
+
+ // MARK: - signOut
+
+ @ReactMethod
+ override fun signOut(promise: Promise) {
+ if (!Clerk.isInitialized.value) {
+ promise.reject("E_NOT_INITIALIZED", "Clerk SDK is not initialized. Call configure() first.")
+ return
+ }
+
+ coroutineScope.launch {
+ try {
+ Clerk.auth.signOut()
+ promise.resolve(null)
+ } catch (e: Exception) {
+ promise.reject("E_SIGN_OUT_FAILED", e.message ?: "Sign out failed", e)
+ }
+ }
+ }
+
+ // MARK: - Activity Result Handling
+
+ override fun onActivityResult(activity: Activity, requestCode: Int, resultCode: Int, data: Intent?) {
+ when (requestCode) {
+ CLERK_AUTH_REQUEST_CODE -> handleAuthResult(resultCode, data)
+ CLERK_PROFILE_REQUEST_CODE -> handleProfileResult(resultCode, data)
+ }
+ }
+
+ override fun onNewIntent(intent: Intent) {
+ // Not used
+ }
+
+ private fun handleAuthResult(resultCode: Int, data: Intent?) {
+ debugLog(TAG, "handleAuthResult - resultCode: $resultCode")
+
+ val promise = pendingAuthPromise ?: return
+ pendingAuthPromise = null
+
+ if (resultCode == Activity.RESULT_OK) {
+ val session = Clerk.session
+ val user = Clerk.user
+
+ debugLog(TAG, "handleAuthResult - session: ${session?.id}, user: ${user?.id}")
+
+ val result = WritableNativeMap()
+
+ // Top-level sessionId for JS SDK compatibility (matches iOS response format)
+ result.putString("sessionId", session?.id)
+
+ session?.let {
+ val sessionMap = WritableNativeMap()
+ sessionMap.putString("id", it.id)
+ sessionMap.putString("status", it.status.name)
+ sessionMap.putString("userId", it.user?.id)
+ result.putMap("session", sessionMap)
+ }
+
+ user?.let {
+ val primaryEmail = it.emailAddresses?.find { e -> e.id == it.primaryEmailAddressId }
+
+ val userMap = WritableNativeMap()
+ userMap.putString("id", it.id)
+ userMap.putString("firstName", it.firstName)
+ userMap.putString("lastName", it.lastName)
+ userMap.putString("imageUrl", it.imageUrl)
+ userMap.putString("primaryEmailAddress", primaryEmail?.emailAddress)
+ result.putMap("user", userMap)
+ }
+
+ promise.resolve(result)
+ } else {
+ debugLog(TAG, "handleAuthResult - user cancelled")
+ val result = WritableNativeMap()
+ result.putBoolean("cancelled", true)
+ promise.resolve(result)
+ }
+ }
+
+ private fun handleProfileResult(resultCode: Int, data: Intent?) {
+ val promise = pendingProfilePromise ?: return
+ pendingProfilePromise = null
+
+ // Profile always returns current session state
+ val session = Clerk.session
+ val user = Clerk.user
+
+ val result = WritableNativeMap()
+
+ session?.let {
+ val sessionMap = WritableNativeMap()
+ sessionMap.putString("id", it.id)
+ sessionMap.putString("status", it.status.name)
+ sessionMap.putString("userId", it.user?.id)
+ result.putMap("session", sessionMap)
+ }
+
+ user?.let {
+ val primaryEmail = it.emailAddresses?.find { e -> e.id == it.primaryEmailAddressId }
+
+ val userMap = WritableNativeMap()
+ userMap.putString("id", it.id)
+ userMap.putString("firstName", it.firstName)
+ userMap.putString("lastName", it.lastName)
+ userMap.putString("imageUrl", it.imageUrl)
+ userMap.putString("primaryEmailAddress", primaryEmail?.emailAddress)
+ result.putMap("user", userMap)
+ }
+
+ result.putBoolean("dismissed", resultCode == Activity.RESULT_CANCELED)
+
+ promise.resolve(result)
+ }
+}
diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkPackage.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkPackage.kt
new file mode 100644
index 00000000000..9a97309ac5e
--- /dev/null
+++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkPackage.kt
@@ -0,0 +1,43 @@
+package expo.modules.clerk
+
+import com.facebook.react.TurboReactPackage
+import com.facebook.react.bridge.NativeModule
+import com.facebook.react.bridge.ReactApplicationContext
+import com.facebook.react.module.model.ReactModuleInfo
+import com.facebook.react.module.model.ReactModuleInfoProvider
+import com.facebook.react.uimanager.ViewManager
+
+class ClerkPackage : TurboReactPackage() {
+
+ override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? {
+ return when (name) {
+ NativeClerkModuleSpec.NAME -> ClerkExpoModule(reactContext)
+ NativeClerkGoogleSignInSpec.NAME -> expo.modules.clerk.googlesignin.ClerkGoogleSignInModule(reactContext)
+ else -> null
+ }
+ }
+
+ override fun getReactModuleInfoProvider(): ReactModuleInfoProvider {
+ return ReactModuleInfoProvider {
+ mapOf(
+ NativeClerkModuleSpec.NAME to ReactModuleInfo(
+ NativeClerkModuleSpec.NAME,
+ ClerkExpoModule::class.java.name,
+ false, false, true, false, true
+ ),
+ NativeClerkGoogleSignInSpec.NAME to ReactModuleInfo(
+ NativeClerkGoogleSignInSpec.NAME,
+ expo.modules.clerk.googlesignin.ClerkGoogleSignInModule::class.java.name,
+ false, false, true, false, true
+ ),
+ )
+ }
+ }
+
+ override fun createViewManagers(reactContext: ReactApplicationContext): List> {
+ return listOf(
+ ClerkAuthViewManager(),
+ ClerkUserProfileViewManager(),
+ )
+ }
+}
diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileActivity.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileActivity.kt
new file mode 100644
index 00000000000..b95f4fbe7fa
--- /dev/null
+++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileActivity.kt
@@ -0,0 +1,119 @@
+package expo.modules.clerk
+
+import android.app.Activity
+import android.content.Intent
+import android.os.Bundle
+import android.util.Log
+import androidx.activity.ComponentActivity
+import androidx.activity.OnBackPressedCallback
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.clerk.api.Clerk
+import com.clerk.ui.userprofile.UserProfileView
+
+/**
+ * Activity that hosts the Clerk UserProfileView composable.
+ * Presents the native user profile UI and returns the result when dismissed.
+ */
+class ClerkUserProfileActivity : ComponentActivity() {
+
+ companion object {
+ private const val TAG = "ClerkUserProfileActivity"
+
+ private fun debugLog(tag: String, message: String) {
+ if (BuildConfig.DEBUG) {
+ Log.d(tag, message)
+ }
+ }
+ }
+
+ private var dismissed = false
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+
+ val dismissable = intent.getBooleanExtra(ClerkExpoModule.EXTRA_DISMISSABLE, true)
+ val publishableKey = intent.getStringExtra(ClerkExpoModule.EXTRA_PUBLISHABLE_KEY)
+
+ debugLog(TAG, "onCreate - isInitialized: ${Clerk.isInitialized.value}")
+ debugLog(TAG, "onCreate - session: ${Clerk.session?.id}, user: ${Clerk.user?.id}")
+
+ // Initialize Clerk if not already initialized
+ if (publishableKey != null && !Clerk.isInitialized.value) {
+ debugLog(TAG, "Initializing Clerk...")
+ Clerk.initialize(applicationContext, publishableKey)
+ }
+
+ setContent {
+ // Observe user state changes
+ val user by Clerk.userFlow.collectAsStateWithLifecycle()
+ val session by Clerk.sessionFlow.collectAsStateWithLifecycle()
+
+ // Track if we had a session when the profile opened (to detect sign-out)
+ var hadSession by remember { mutableStateOf(Clerk.session != null) }
+
+ // Log when user/session state changes
+ LaunchedEffect(user, session) {
+ debugLog(TAG, "State changed - session: ${session?.id}, user: ${user?.id}")
+ }
+
+ // Detect sign-out: if we had a session and now it's null, user signed out
+ LaunchedEffect(session) {
+ if (hadSession && session == null) {
+ debugLog(TAG, "Sign-out detected - session became null, dismissing activity")
+ finishWithSuccess()
+ }
+ // Update hadSession if we get a session (handles edge cases)
+ if (session != null) {
+ hadSession = true
+ }
+ }
+
+ MaterialTheme {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = MaterialTheme.colorScheme.background
+ ) {
+ UserProfileView(
+ clerkTheme = Clerk.customTheme,
+ onDismiss = {
+ finishWithSuccess()
+ }
+ )
+ }
+ }
+ }
+
+ // Handle back press via onBackPressedDispatcher (replaces deprecated onBackPressed)
+ onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
+ override fun handleOnBackPressed() {
+ if (dismissable) {
+ finishWithSuccess()
+ }
+ // Otherwise ignore back press
+ }
+ })
+ }
+
+ private fun finishWithSuccess() {
+ if (dismissed) return
+ dismissed = true
+
+ val result = Intent()
+ result.putExtra(ClerkExpoModule.RESULT_SESSION_ID, Clerk.session?.id)
+ result.putExtra(ClerkExpoModule.RESULT_CANCELLED, false)
+ setResult(Activity.RESULT_OK, result)
+ finish()
+ }
+}
diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileExpoView.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileExpoView.kt
new file mode 100644
index 00000000000..dd770bee4f5
--- /dev/null
+++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileExpoView.kt
@@ -0,0 +1,132 @@
+package expo.modules.clerk
+
+import android.content.Context
+import android.util.Log
+import android.widget.FrameLayout
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.Recomposer
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.AndroidUiDispatcher
+import androidx.compose.ui.platform.ComposeView
+import androidx.lifecycle.compose.LocalLifecycleOwner
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.setViewTreeLifecycleOwner
+import androidx.lifecycle.setViewTreeViewModelStoreOwner
+import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner
+import androidx.savedstate.compose.LocalSavedStateRegistryOwner
+import androidx.savedstate.setViewTreeSavedStateRegistryOwner
+import com.clerk.api.Clerk
+import com.clerk.ui.userprofile.UserProfileView
+import com.facebook.react.bridge.Arguments
+import com.facebook.react.bridge.ReactContext
+import com.facebook.react.uimanager.events.RCTEventEmitter
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+
+private const val TAG = "ClerkUserProfileExpoView"
+
+class ClerkUserProfileNativeView(context: Context) : FrameLayout(context) {
+ var isDismissable: Boolean = true
+
+ private val activity = ClerkAuthNativeView.findActivity(context)
+
+ private var recomposer: Recomposer? = null
+ private var recomposerJob: kotlinx.coroutines.Job? = null
+
+ private val composeView = ComposeView(context).also { view ->
+ activity?.let { act ->
+ view.setViewTreeLifecycleOwner(act)
+ view.setViewTreeViewModelStoreOwner(act)
+ view.setViewTreeSavedStateRegistryOwner(act)
+
+ val recomposerContext = AndroidUiDispatcher.Main
+ val newRecomposer = Recomposer(recomposerContext)
+ recomposer = newRecomposer
+ view.setParentCompositionContext(newRecomposer)
+ val scope = CoroutineScope(recomposerContext + kotlinx.coroutines.SupervisorJob())
+ recomposerJob = scope.coroutineContext[kotlinx.coroutines.Job]
+ scope.launch {
+ newRecomposer.runRecomposeAndApplyChanges()
+ }
+ }
+ addView(view, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
+ }
+
+ override fun onDetachedFromWindow() {
+ recomposer?.cancel()
+ recomposerJob?.cancel()
+ super.onDetachedFromWindow()
+ }
+
+ fun setupView() {
+ Log.d(TAG, "setupView - isDismissable: $isDismissable")
+
+ composeView.setContent {
+ val session by Clerk.sessionFlow.collectAsStateWithLifecycle()
+
+ var hadSession by remember { mutableStateOf(Clerk.session != null) }
+
+ LaunchedEffect(session) {
+ if (hadSession && session == null) {
+ Log.d(TAG, "Sign-out detected")
+ sendEvent("signedOut", emptyMap())
+ }
+ if (session != null) {
+ hadSession = true
+ }
+ }
+
+ val content = @androidx.compose.runtime.Composable {
+ MaterialTheme {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = MaterialTheme.colorScheme.background
+ ) {
+ UserProfileView(
+ clerkTheme = Clerk.customTheme,
+ onDismiss = {
+ Log.d(TAG, "Profile dismissed")
+ sendEvent("dismissed", emptyMap())
+ }
+ )
+ }
+ }
+ }
+
+ if (activity != null) {
+ CompositionLocalProvider(
+ LocalViewModelStoreOwner provides activity,
+ LocalLifecycleOwner provides activity,
+ LocalSavedStateRegistryOwner provides activity,
+ ) {
+ content()
+ }
+ } else {
+ content()
+ }
+ }
+ }
+
+ private fun sendEvent(type: String, data: Map) {
+ val reactContext = context as? ReactContext ?: return
+ val eventData = Arguments.createMap().apply {
+ putString("type", type)
+ val jsonString = try {
+ org.json.JSONObject(data).toString()
+ } catch (e: Exception) {
+ "{}"
+ }
+ putString("data", jsonString)
+ }
+ reactContext.getJSModule(RCTEventEmitter::class.java)
+ .receiveEvent(id, "onProfileEvent", eventData)
+ }
+}
diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileViewManager.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileViewManager.kt
new file mode 100644
index 00000000000..bc5a338271e
--- /dev/null
+++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileViewManager.kt
@@ -0,0 +1,32 @@
+package expo.modules.clerk
+
+import com.facebook.react.common.MapBuilder
+import com.facebook.react.uimanager.SimpleViewManager
+import com.facebook.react.uimanager.ThemedReactContext
+import com.facebook.react.uimanager.annotations.ReactProp
+import com.facebook.react.viewmanagers.ClerkUserProfileViewManagerInterface
+
+class ClerkUserProfileViewManager : SimpleViewManager(),
+ ClerkUserProfileViewManagerInterface {
+
+ override fun getName(): String = "ClerkUserProfileView"
+
+ override fun createViewInstance(reactContext: ThemedReactContext): ClerkUserProfileNativeView {
+ return ClerkUserProfileNativeView(reactContext)
+ }
+
+ @ReactProp(name = "isDismissable")
+ override fun setIsDismissable(view: ClerkUserProfileNativeView, isDismissable: Boolean) {
+ view.isDismissable = isDismissable
+ view.setupView()
+ }
+
+ override fun getExportedCustomBubblingEventTypeConstants(): MutableMap? {
+ return MapBuilder.builder()
+ .put("onProfileEvent", MapBuilder.of(
+ "phasedRegistrationNames",
+ MapBuilder.of("bubbled", "onProfileEvent")
+ ))
+ .build() as MutableMap
+ }
+}
diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkViewFactory.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkViewFactory.kt
new file mode 100644
index 00000000000..e77ad21ddf0
--- /dev/null
+++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkViewFactory.kt
@@ -0,0 +1,102 @@
+package expo.modules.clerk
+
+import android.content.Context
+import android.content.Intent
+import com.clerk.api.Clerk
+import com.clerk.api.network.serialization.ClerkResult
+import kotlinx.coroutines.flow.first
+
+/**
+ * Implementation of ClerkViewFactoryInterface.
+ * Provides Clerk SDK operations and creates intents for auth/profile activities.
+ */
+class ClerkViewFactory : ClerkViewFactoryInterface {
+
+ // Store the publishable key for later use
+ private var storedPublishableKey: String? = null
+ private var storedContext: Context? = null
+
+ override suspend fun configure(context: Context, publishableKey: String) {
+ println("[ClerkViewFactory] Configuring Clerk with publishable key: ${publishableKey.take(20)}...")
+
+ // Store for later use
+ storedPublishableKey = publishableKey
+ storedContext = context.applicationContext
+
+ // Initialize Clerk if not already initialized
+ if (!Clerk.isInitialized.value) {
+ Clerk.initialize(context.applicationContext, publishableKey)
+
+ // Wait for initialization to complete
+ Clerk.isInitialized.first { it }
+ println("[ClerkViewFactory] Clerk initialized successfully")
+ } else {
+ println("[ClerkViewFactory] Clerk already initialized")
+ }
+ }
+
+ override fun createAuthIntent(context: Context, mode: String, dismissable: Boolean): Intent {
+ return Intent(context, ClerkAuthActivity::class.java).apply {
+ putExtra(ClerkExpoModule.EXTRA_MODE, mode)
+ putExtra(ClerkExpoModule.EXTRA_DISMISSABLE, dismissable)
+ storedPublishableKey?.let { putExtra(ClerkExpoModule.EXTRA_PUBLISHABLE_KEY, it) }
+ }
+ }
+
+ override fun createUserProfileIntent(context: Context, dismissable: Boolean): Intent {
+ return Intent(context, ClerkUserProfileActivity::class.java).apply {
+ putExtra(ClerkExpoModule.EXTRA_DISMISSABLE, dismissable)
+ storedPublishableKey?.let { putExtra(ClerkExpoModule.EXTRA_PUBLISHABLE_KEY, it) }
+ }
+ }
+
+ override suspend fun getSession(): Map? {
+ val session = Clerk.session ?: return null
+ val user = Clerk.user ?: return null
+
+ return mapOf(
+ "sessionId" to session.id,
+ "userId" to user.id,
+ "user" to mapOf(
+ "id" to user.id,
+ "firstName" to user.firstName,
+ "lastName" to user.lastName,
+ "fullName" to "${user.firstName ?: ""} ${user.lastName ?: ""}".trim().ifEmpty { null },
+ "username" to user.username,
+ "imageUrl" to user.imageUrl,
+ "primaryEmailAddress" to user.primaryEmailAddress?.emailAddress,
+ "primaryPhoneNumber" to user.primaryPhoneNumber?.phoneNumber,
+ "createdAt" to user.createdAt,
+ "updatedAt" to user.updatedAt,
+ )
+ )
+ }
+
+ override suspend fun signOut() {
+ val result = Clerk.auth.signOut()
+ when (result) {
+ is ClerkResult.Success -> {
+ println("[ClerkViewFactory] Sign out successful")
+ }
+ is ClerkResult.Failure -> {
+ println("[ClerkViewFactory] Sign out failed: ${result.error}")
+ throw Exception("Sign out failed: ${result.error}")
+ }
+ }
+ }
+
+ override fun isInitialized(): Boolean {
+ return Clerk.isInitialized.value
+ }
+
+ companion object {
+ /**
+ * Initialize the ClerkViewFactory and register it globally.
+ * Call this from your Application.onCreate() or MainActivity.onCreate()
+ */
+ fun initialize() {
+ ClerkViewFactoryRegistry.factory = ClerkViewFactory()
+ println("[ClerkViewFactory] Factory registered")
+ }
+ }
+}
diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkViewFactoryInterface.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkViewFactoryInterface.kt
new file mode 100644
index 00000000000..7b82bd1ec20
--- /dev/null
+++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkViewFactoryInterface.kt
@@ -0,0 +1,52 @@
+package expo.modules.clerk
+
+import android.content.Context
+import android.content.Intent
+
+/**
+ * Interface for providing Clerk views and SDK operations.
+ * This mirrors the iOS ClerkViewFactoryProtocol pattern.
+ */
+interface ClerkViewFactoryInterface {
+ /**
+ * Configure the Clerk SDK with the publishable key.
+ */
+ suspend fun configure(context: Context, publishableKey: String)
+
+ /**
+ * Create an Intent to launch the authentication activity.
+ * @param mode The auth mode: "signIn", "signUp", or "signInOrUp"
+ * @param dismissable Whether the user can dismiss the modal
+ */
+ fun createAuthIntent(context: Context, mode: String, dismissable: Boolean): Intent
+
+ /**
+ * Create an Intent to launch the user profile activity.
+ * @param dismissable Whether the user can dismiss the modal
+ */
+ fun createUserProfileIntent(context: Context, dismissable: Boolean): Intent
+
+ /**
+ * Get the current session data as a Map for JS.
+ * Returns null if no session is active.
+ */
+ suspend fun getSession(): Map?
+
+ /**
+ * Sign out the current user.
+ */
+ suspend fun signOut()
+
+ /**
+ * Check if the SDK is initialized.
+ */
+ fun isInitialized(): Boolean
+}
+
+/**
+ * Global registry for the Clerk view factory.
+ * Set by the app target at startup (similar to iOS pattern).
+ */
+object ClerkViewFactoryRegistry {
+ var factory: ClerkViewFactoryInterface? = null
+}
diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/googlesignin/ClerkGoogleSignInModule.kt b/packages/expo/android/src/main/java/expo/modules/clerk/googlesignin/ClerkGoogleSignInModule.kt
index 3234fea2214..54183ce5552 100644
--- a/packages/expo/android/src/main/java/expo/modules/clerk/googlesignin/ClerkGoogleSignInModule.kt
+++ b/packages/expo/android/src/main/java/expo/modules/clerk/googlesignin/ClerkGoogleSignInModule.kt
@@ -9,225 +9,209 @@ import androidx.credentials.GetCredentialResponse
import androidx.credentials.exceptions.GetCredentialCancellationException
import androidx.credentials.exceptions.GetCredentialException
import androidx.credentials.exceptions.NoCredentialException
+import com.facebook.react.bridge.Promise
+import com.facebook.react.bridge.ReactApplicationContext
+import com.facebook.react.bridge.ReactMethod
+import expo.modules.clerk.NativeClerkGoogleSignInSpec
+import com.facebook.react.bridge.ReadableMap
+import com.facebook.react.bridge.WritableNativeMap
import com.google.android.libraries.identity.googleid.GetGoogleIdOption
import com.google.android.libraries.identity.googleid.GetSignInWithGoogleOption
import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential
import com.google.android.libraries.identity.googleid.GoogleIdTokenParsingException
-import expo.modules.kotlin.Promise
-import expo.modules.kotlin.exception.CodedException
-import expo.modules.kotlin.modules.Module
-import expo.modules.kotlin.modules.ModuleDefinition
-import expo.modules.kotlin.records.Field
-import expo.modules.kotlin.records.Record
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
-// Configuration parameters
-class ConfigureParams : Record {
- @Field
- val webClientId: String = ""
+class ClerkGoogleSignInModule(reactContext: ReactApplicationContext) :
+ NativeClerkGoogleSignInSpec(reactContext) {
- @Field
- val hostedDomain: String? = null
-
- @Field
- val autoSelectEnabled: Boolean? = null
-}
-
-// Sign-in parameters
-class SignInParams : Record {
- @Field
- val nonce: String? = null
-
- @Field
- val filterByAuthorizedAccounts: Boolean? = null
-}
-
-// Create account parameters
-class CreateAccountParams : Record {
- @Field
- val nonce: String? = null
-}
-
-// Explicit sign-in parameters
-class ExplicitSignInParams : Record {
- @Field
- val nonce: String? = null
-}
-
-// Custom exceptions
-class GoogleSignInCancelledException : CodedException("SIGN_IN_CANCELLED", "User cancelled the sign-in flow", null)
-class GoogleSignInNoCredentialException : CodedException("NO_SAVED_CREDENTIAL_FOUND", "No saved credential found", null)
-class GoogleSignInException(message: String) : CodedException("GOOGLE_SIGN_IN_ERROR", message, null)
-class GoogleSignInNotConfiguredException : CodedException("NOT_CONFIGURED", "Google Sign-In is not configured. Call configure() first.", null)
-class GoogleSignInActivityUnavailableException : CodedException("E_ACTIVITY_UNAVAILABLE", "Activity is not available", null)
-
-class ClerkGoogleSignInModule : Module() {
private var webClientId: String? = null
private var hostedDomain: String? = null
private var autoSelectEnabled: Boolean = false
private val mainScope = CoroutineScope(Dispatchers.Main)
- private val context: Context
- get() = requireNotNull(appContext.reactContext)
-
private val credentialManager: CredentialManager
- get() = CredentialManager.create(context)
+ get() = CredentialManager.create(reactApplicationContext)
- override fun definition() = ModuleDefinition {
- Name("ClerkGoogleSignIn")
+ override fun getName(): String = "ClerkGoogleSignIn"
- // Configure the module
- Function("configure") { params: ConfigureParams ->
- webClientId = params.webClientId
- hostedDomain = params.hostedDomain
- autoSelectEnabled = params.autoSelectEnabled ?: false
- }
+ // MARK: - configure
- // Sign in - attempts automatic sign-in with saved credentials
- AsyncFunction("signIn") { params: SignInParams?, promise: Promise ->
- val clientId = webClientId ?: run {
- promise.reject(GoogleSignInNotConfiguredException())
- return@AsyncFunction
- }
+ @ReactMethod
+ override fun configure(params: ReadableMap) {
+ webClientId = if (params.hasKey("webClientId")) params.getString("webClientId") else null
+ hostedDomain = if (params.hasKey("hostedDomain")) params.getString("hostedDomain") else null
+ autoSelectEnabled = if (params.hasKey("autoSelectEnabled")) params.getBoolean("autoSelectEnabled") else false
+ }
- val activity = appContext.currentActivity ?: run {
- promise.reject(GoogleSignInActivityUnavailableException())
- return@AsyncFunction
- }
+ // MARK: - signIn
- mainScope.launch {
- try {
- val googleIdOption = GetGoogleIdOption.Builder()
- .setFilterByAuthorizedAccounts(params?.filterByAuthorizedAccounts ?: true)
- .setServerClientId(clientId)
- .setAutoSelectEnabled(autoSelectEnabled)
- .apply {
- params?.nonce?.let { setNonce(it) }
- }
- .build()
-
- val request = GetCredentialRequest.Builder()
- .addCredentialOption(googleIdOption)
- .build()
-
- val result = credentialManager.getCredential(
- request = request,
- context = activity
- )
-
- handleSignInResult(result, promise)
- } catch (e: GetCredentialCancellationException) {
- promise.reject(GoogleSignInCancelledException())
- } catch (e: NoCredentialException) {
- promise.reject(GoogleSignInNoCredentialException())
- } catch (e: GetCredentialException) {
- promise.reject(GoogleSignInException(e.message ?: "Unknown error"))
- } catch (e: Exception) {
- promise.reject(GoogleSignInException(e.message ?: "Unknown error"))
+ @ReactMethod
+ override fun signIn(params: ReadableMap?, promise: Promise) {
+ val clientId = webClientId ?: run {
+ promise.reject("NOT_CONFIGURED", "Google Sign-In is not configured. Call configure() first.")
+ return
+ }
+
+ val activity = getCurrentActivity() ?: run {
+ promise.reject("E_ACTIVITY_UNAVAILABLE", "Activity is not available")
+ return
+ }
+
+ mainScope.launch {
+ try {
+ val filterByAuthorized = params?.let {
+ if (it.hasKey("filterByAuthorizedAccounts")) it.getBoolean("filterByAuthorizedAccounts") else true
+ } ?: true
+ val nonce = params?.let {
+ if (it.hasKey("nonce")) it.getString("nonce") else null
}
+
+ val googleIdOption = GetGoogleIdOption.Builder()
+ .setFilterByAuthorizedAccounts(filterByAuthorized)
+ .setServerClientId(clientId)
+ .setAutoSelectEnabled(autoSelectEnabled)
+ .apply {
+ nonce?.let { setNonce(it) }
+ }
+ .build()
+
+ val request = GetCredentialRequest.Builder()
+ .addCredentialOption(googleIdOption)
+ .build()
+
+ val result = credentialManager.getCredential(
+ request = request,
+ context = activity
+ )
+
+ handleSignInResult(result, promise)
+ } catch (e: GetCredentialCancellationException) {
+ promise.reject("SIGN_IN_CANCELLED", "User cancelled the sign-in flow", e)
+ } catch (e: NoCredentialException) {
+ promise.reject("NO_SAVED_CREDENTIAL_FOUND", "No saved credential found", e)
+ } catch (e: GetCredentialException) {
+ promise.reject("GOOGLE_SIGN_IN_ERROR", e.message ?: "Unknown error", e)
+ } catch (e: Exception) {
+ promise.reject("GOOGLE_SIGN_IN_ERROR", e.message ?: "Unknown error", e)
}
}
+ }
- // Create account - shows account creation UI
- AsyncFunction("createAccount") { params: CreateAccountParams?, promise: Promise ->
- val clientId = webClientId ?: run {
- promise.reject(GoogleSignInNotConfiguredException())
- return@AsyncFunction
- }
+ // MARK: - createAccount
- val activity = appContext.currentActivity ?: run {
- promise.reject(GoogleSignInActivityUnavailableException())
- return@AsyncFunction
- }
+ @ReactMethod
+ override fun createAccount(params: ReadableMap?, promise: Promise) {
+ val clientId = webClientId ?: run {
+ promise.reject("NOT_CONFIGURED", "Google Sign-In is not configured. Call configure() first.")
+ return
+ }
- mainScope.launch {
- try {
- val googleIdOption = GetGoogleIdOption.Builder()
- .setFilterByAuthorizedAccounts(false) // Show all accounts for creation
- .setServerClientId(clientId)
- .apply {
- params?.nonce?.let { setNonce(it) }
- }
- .build()
-
- val request = GetCredentialRequest.Builder()
- .addCredentialOption(googleIdOption)
- .build()
-
- val result = credentialManager.getCredential(
- request = request,
- context = activity
- )
-
- handleSignInResult(result, promise)
- } catch (e: GetCredentialCancellationException) {
- promise.reject(GoogleSignInCancelledException())
- } catch (e: NoCredentialException) {
- promise.reject(GoogleSignInNoCredentialException())
- } catch (e: GetCredentialException) {
- promise.reject(GoogleSignInException(e.message ?: "Unknown error"))
- } catch (e: Exception) {
- promise.reject(GoogleSignInException(e.message ?: "Unknown error"))
+ val activity = getCurrentActivity() ?: run {
+ promise.reject("E_ACTIVITY_UNAVAILABLE", "Activity is not available")
+ return
+ }
+
+ mainScope.launch {
+ try {
+ val nonce = params?.let {
+ if (it.hasKey("nonce")) it.getString("nonce") else null
}
+
+ val googleIdOption = GetGoogleIdOption.Builder()
+ .setFilterByAuthorizedAccounts(false) // Show all accounts for creation
+ .setServerClientId(clientId)
+ .apply {
+ nonce?.let { setNonce(it) }
+ }
+ .build()
+
+ val request = GetCredentialRequest.Builder()
+ .addCredentialOption(googleIdOption)
+ .build()
+
+ val result = credentialManager.getCredential(
+ request = request,
+ context = activity
+ )
+
+ handleSignInResult(result, promise)
+ } catch (e: GetCredentialCancellationException) {
+ promise.reject("SIGN_IN_CANCELLED", "User cancelled the sign-in flow", e)
+ } catch (e: NoCredentialException) {
+ promise.reject("NO_SAVED_CREDENTIAL_FOUND", "No saved credential found", e)
+ } catch (e: GetCredentialException) {
+ promise.reject("GOOGLE_SIGN_IN_ERROR", e.message ?: "Unknown error", e)
+ } catch (e: Exception) {
+ promise.reject("GOOGLE_SIGN_IN_ERROR", e.message ?: "Unknown error", e)
}
}
+ }
- // Explicit sign-in - uses Sign In With Google button flow
- AsyncFunction("presentExplicitSignIn") { params: ExplicitSignInParams?, promise: Promise ->
- val clientId = webClientId ?: run {
- promise.reject(GoogleSignInNotConfiguredException())
- return@AsyncFunction
- }
+ // MARK: - presentExplicitSignIn
- val activity = appContext.currentActivity ?: run {
- promise.reject(GoogleSignInActivityUnavailableException())
- return@AsyncFunction
- }
+ @ReactMethod
+ override fun presentExplicitSignIn(params: ReadableMap?, promise: Promise) {
+ val clientId = webClientId ?: run {
+ promise.reject("NOT_CONFIGURED", "Google Sign-In is not configured. Call configure() first.")
+ return
+ }
- mainScope.launch {
- try {
- val signInWithGoogleOption = GetSignInWithGoogleOption.Builder(clientId)
- .apply {
- params?.nonce?.let { setNonce(it) }
- hostedDomain?.let { setHostedDomainFilter(it) }
- }
- .build()
-
- val request = GetCredentialRequest.Builder()
- .addCredentialOption(signInWithGoogleOption)
- .build()
-
- val result = credentialManager.getCredential(
- request = request,
- context = activity
- )
-
- handleSignInResult(result, promise)
- } catch (e: GetCredentialCancellationException) {
- promise.reject(GoogleSignInCancelledException())
- } catch (e: GetCredentialException) {
- promise.reject(GoogleSignInException(e.message ?: "Unknown error"))
- } catch (e: Exception) {
- promise.reject(GoogleSignInException(e.message ?: "Unknown error"))
+ val activity = getCurrentActivity() ?: run {
+ promise.reject("E_ACTIVITY_UNAVAILABLE", "Activity is not available")
+ return
+ }
+
+ mainScope.launch {
+ try {
+ val nonce = params?.let {
+ if (it.hasKey("nonce")) it.getString("nonce") else null
}
+
+ val signInWithGoogleOption = GetSignInWithGoogleOption.Builder(clientId)
+ .apply {
+ nonce?.let { setNonce(it) }
+ hostedDomain?.let { setHostedDomainFilter(it) }
+ }
+ .build()
+
+ val request = GetCredentialRequest.Builder()
+ .addCredentialOption(signInWithGoogleOption)
+ .build()
+
+ val result = credentialManager.getCredential(
+ request = request,
+ context = activity
+ )
+
+ handleSignInResult(result, promise)
+ } catch (e: GetCredentialCancellationException) {
+ promise.reject("SIGN_IN_CANCELLED", "User cancelled the sign-in flow", e)
+ } catch (e: GetCredentialException) {
+ promise.reject("GOOGLE_SIGN_IN_ERROR", e.message ?: "Unknown error", e)
+ } catch (e: Exception) {
+ promise.reject("GOOGLE_SIGN_IN_ERROR", e.message ?: "Unknown error", e)
}
}
+ }
- // Sign out - clears credential state
- AsyncFunction("signOut") { promise: Promise ->
- mainScope.launch {
- try {
- credentialManager.clearCredentialState(ClearCredentialStateRequest())
- promise.resolve(null)
- } catch (e: Exception) {
- promise.reject(GoogleSignInException(e.message ?: "Failed to sign out"))
- }
+ // MARK: - signOut
+
+ @ReactMethod
+ override fun signOut(promise: Promise) {
+ mainScope.launch {
+ try {
+ credentialManager.clearCredentialState(ClearCredentialStateRequest())
+ promise.resolve(null)
+ } catch (e: Exception) {
+ promise.reject("GOOGLE_SIGN_IN_ERROR", e.message ?: "Failed to sign out", e)
}
}
}
+ // MARK: - Helpers
+
private fun handleSignInResult(result: GetCredentialResponse, promise: Promise) {
when (val credential = result.credential) {
is CustomCredential -> {
@@ -235,29 +219,35 @@ class ClerkGoogleSignInModule : Module() {
try {
val googleIdTokenCredential = GoogleIdTokenCredential.createFrom(credential.data)
- promise.resolve(mapOf(
- "type" to "success",
- "data" to mapOf(
- "idToken" to googleIdTokenCredential.idToken,
- "user" to mapOf(
- "id" to googleIdTokenCredential.id,
- "email" to googleIdTokenCredential.id,
- "name" to googleIdTokenCredential.displayName,
- "givenName" to googleIdTokenCredential.givenName,
- "familyName" to googleIdTokenCredential.familyName,
- "photo" to googleIdTokenCredential.profilePictureUri?.toString()
- )
- )
- ))
+ val userMap = WritableNativeMap().apply {
+ putString("id", googleIdTokenCredential.id)
+ putString("email", googleIdTokenCredential.id)
+ putString("name", googleIdTokenCredential.displayName)
+ putString("givenName", googleIdTokenCredential.givenName)
+ putString("familyName", googleIdTokenCredential.familyName)
+ putString("photo", googleIdTokenCredential.profilePictureUri?.toString())
+ }
+
+ val dataMap = WritableNativeMap().apply {
+ putString("idToken", googleIdTokenCredential.idToken)
+ putMap("user", userMap)
+ }
+
+ val responseMap = WritableNativeMap().apply {
+ putString("type", "success")
+ putMap("data", dataMap)
+ }
+
+ promise.resolve(responseMap)
} catch (e: GoogleIdTokenParsingException) {
- promise.reject(GoogleSignInException("Failed to parse Google ID token: ${e.message}"))
+ promise.reject("GOOGLE_SIGN_IN_ERROR", "Failed to parse Google ID token: ${e.message}", e)
}
} else {
- promise.reject(GoogleSignInException("Unexpected credential type: ${credential.type}"))
+ promise.reject("GOOGLE_SIGN_IN_ERROR", "Unexpected credential type: ${credential.type}")
}
}
else -> {
- promise.reject(GoogleSignInException("Unexpected credential type"))
+ promise.reject("GOOGLE_SIGN_IN_ERROR", "Unexpected credential type")
}
}
}
diff --git a/packages/expo/app.plugin.d.ts b/packages/expo/app.plugin.d.ts
new file mode 100644
index 00000000000..82abd6c984b
--- /dev/null
+++ b/packages/expo/app.plugin.d.ts
@@ -0,0 +1,6 @@
+export = withClerkExpo;
+/**
+ * Combined Clerk Expo plugin
+ */
+declare function withClerkExpo(config: any): any;
+//# sourceMappingURL=app.plugin.d.ts.map
diff --git a/packages/expo/app.plugin.d.ts.map b/packages/expo/app.plugin.d.ts.map
new file mode 100644
index 00000000000..99752a11459
--- /dev/null
+++ b/packages/expo/app.plugin.d.ts.map
@@ -0,0 +1 @@
+{"version":3,"file":"app.plugin.d.ts","sourceRoot":"","sources":["app.plugin.js"],"names":[],"mappings":";AA0gBA;;GAEG;AACH,iDAKC"}
\ No newline at end of file
diff --git a/packages/expo/app.plugin.js b/packages/expo/app.plugin.js
index 65835131de7..acb6d5c06b9 100644
--- a/packages/expo/app.plugin.js
+++ b/packages/expo/app.plugin.js
@@ -1 +1,563 @@
-module.exports = require('./dist/plugin/withClerkExpo');
+/**
+ * Expo config plugin for @clerk/clerk-expo
+ * Automatically configures iOS and Android to work with Clerk native components
+ *
+ * When this plugin is used:
+ * 1. iOS is configured with Swift Package Manager dependency for clerk-ios
+ * 2. Android is configured with packaging exclusions for dependencies
+ *
+ * Native modules are registered via react-native.config.js and standard
+ * React Native autolinking (RCTViewManager / ReactPackage).
+ */
+const { withXcodeProject, withDangerousMod, withInfoPlist, withAppBuildGradle } = require('@expo/config-plugins');
+const path = require('path');
+const fs = require('fs');
+
+const CLERK_IOS_REPO = 'https://github.com/clerk/clerk-ios.git';
+const CLERK_IOS_VERSION = '1.0.0';
+
+const CLERK_MIN_IOS_VERSION = '17.0';
+
+const withClerkIOS = config => {
+ console.log('✅ Clerk iOS plugin loaded');
+
+ // IMPORTANT: Set iOS deployment target in Podfile.properties.json BEFORE pod install
+ // This ensures ClerkExpo pod gets installed (it requires iOS 17.0)
+ config = withDangerousMod(config, [
+ 'ios',
+ async config => {
+ const podfilePropertiesPath = path.join(config.modRequest.platformProjectRoot, 'Podfile.properties.json');
+
+ let properties = {};
+ if (fs.existsSync(podfilePropertiesPath)) {
+ try {
+ properties = JSON.parse(fs.readFileSync(podfilePropertiesPath, 'utf8'));
+ } catch {
+ // If file exists but is invalid JSON, start fresh
+ }
+ }
+
+ // Set the iOS deployment target
+ if (
+ !properties['ios.deploymentTarget'] ||
+ parseFloat(properties['ios.deploymentTarget']) < parseFloat(CLERK_MIN_IOS_VERSION)
+ ) {
+ properties['ios.deploymentTarget'] = CLERK_MIN_IOS_VERSION;
+ fs.writeFileSync(podfilePropertiesPath, JSON.stringify(properties, null, 2) + '\n');
+ console.log(`✅ Set ios.deploymentTarget to ${CLERK_MIN_IOS_VERSION} in Podfile.properties.json`);
+ }
+
+ return config;
+ },
+ ]);
+
+ // First update the iOS deployment target to 17.0 (required by Clerk iOS SDK)
+ config = withXcodeProject(config, config => {
+ const xcodeProject = config.modResults;
+
+ try {
+ // Update deployment target in all build configurations
+ const buildConfigs = xcodeProject.hash.project.objects.XCBuildConfiguration || {};
+
+ for (const [uuid, buildConfig] of Object.entries(buildConfigs)) {
+ if (buildConfig && buildConfig.buildSettings) {
+ const currentTarget = buildConfig.buildSettings.IPHONEOS_DEPLOYMENT_TARGET;
+ if (currentTarget && parseFloat(currentTarget) < parseFloat(CLERK_MIN_IOS_VERSION)) {
+ buildConfig.buildSettings.IPHONEOS_DEPLOYMENT_TARGET = CLERK_MIN_IOS_VERSION;
+ }
+ }
+ }
+
+ console.log(`✅ Updated iOS deployment target to ${CLERK_MIN_IOS_VERSION}`);
+ } catch (error) {
+ console.error('❌ Error updating deployment target:', error.message);
+ }
+
+ return config;
+ });
+
+ // Then add the Swift Package dependency
+ config = withXcodeProject(config, config => {
+ const xcodeProject = config.modResults;
+
+ try {
+ // Get the main app target
+ const targets = xcodeProject.getFirstTarget();
+ if (!targets) {
+ console.warn('⚠️ Could not find main target in Xcode project');
+ return config;
+ }
+
+ const targetUuid = targets.uuid;
+ const targetName = targets.name;
+
+ // Add Swift Package reference to the project
+ const packageUuid = xcodeProject.generateUuid();
+ const packageName = 'clerk-ios';
+
+ // Add package reference to XCRemoteSwiftPackageReference section
+ if (!xcodeProject.hash.project.objects.XCRemoteSwiftPackageReference) {
+ xcodeProject.hash.project.objects.XCRemoteSwiftPackageReference = {};
+ }
+
+ xcodeProject.hash.project.objects.XCRemoteSwiftPackageReference[packageUuid] = {
+ isa: 'XCRemoteSwiftPackageReference',
+ repositoryURL: CLERK_IOS_REPO,
+ requirement: {
+ kind: 'upToNextMajorVersion',
+ minimumVersion: CLERK_IOS_VERSION,
+ },
+ };
+
+ // Add package product dependencies (ClerkKit + ClerkKitUI)
+ const productUuidKit = xcodeProject.generateUuid();
+ const productUuidKitUI = xcodeProject.generateUuid();
+ if (!xcodeProject.hash.project.objects.XCSwiftPackageProductDependency) {
+ xcodeProject.hash.project.objects.XCSwiftPackageProductDependency = {};
+ }
+
+ xcodeProject.hash.project.objects.XCSwiftPackageProductDependency[productUuidKit] = {
+ isa: 'XCSwiftPackageProductDependency',
+ package: packageUuid,
+ productName: 'ClerkKit',
+ };
+
+ xcodeProject.hash.project.objects.XCSwiftPackageProductDependency[productUuidKitUI] = {
+ isa: 'XCSwiftPackageProductDependency',
+ package: packageUuid,
+ productName: 'ClerkKitUI',
+ };
+
+ // Add package to project's package references
+ const projectSection = xcodeProject.hash.project.objects.PBXProject;
+ const projectUuid = Object.keys(projectSection)[0];
+ const project = projectSection[projectUuid];
+
+ if (!project.packageReferences) {
+ project.packageReferences = [];
+ }
+
+ // Check if package is already added
+ const alreadyAdded = project.packageReferences.some(ref => {
+ const refObj = xcodeProject.hash.project.objects.XCRemoteSwiftPackageReference[ref.value];
+ return refObj && refObj.repositoryURL === CLERK_IOS_REPO;
+ });
+
+ if (!alreadyAdded) {
+ project.packageReferences.push({
+ value: packageUuid,
+ comment: packageName,
+ });
+ }
+
+ // Add package products to main app target
+ const nativeTarget = xcodeProject.hash.project.objects.PBXNativeTarget[targetUuid];
+ if (!nativeTarget.packageProductDependencies) {
+ nativeTarget.packageProductDependencies = [];
+ }
+
+ const kitAlreadyAdded = nativeTarget.packageProductDependencies.some(dep => dep.value === productUuidKit);
+ if (!kitAlreadyAdded) {
+ nativeTarget.packageProductDependencies.push({
+ value: productUuidKit,
+ comment: 'ClerkKit',
+ });
+ }
+
+ const kitUIAlreadyAdded = nativeTarget.packageProductDependencies.some(dep => dep.value === productUuidKitUI);
+ if (!kitUIAlreadyAdded) {
+ nativeTarget.packageProductDependencies.push({
+ value: productUuidKitUI,
+ comment: 'ClerkKitUI',
+ });
+ }
+
+ // Also add packages to ClerkExpo pod target if it exists
+ const allTargets = xcodeProject.hash.project.objects.PBXNativeTarget;
+ for (const [uuid, target] of Object.entries(allTargets)) {
+ if (target && target.name === 'ClerkExpo') {
+ if (!target.packageProductDependencies) {
+ target.packageProductDependencies = [];
+ }
+
+ const podKitAdded = target.packageProductDependencies.some(dep => dep.value === productUuidKit);
+ if (!podKitAdded) {
+ target.packageProductDependencies.push({
+ value: productUuidKit,
+ comment: 'ClerkKit',
+ });
+ }
+
+ const podKitUIAdded = target.packageProductDependencies.some(dep => dep.value === productUuidKitUI);
+ if (!podKitUIAdded) {
+ target.packageProductDependencies.push({
+ value: productUuidKitUI,
+ comment: 'ClerkKitUI',
+ });
+ }
+
+ console.log(`✅ Added ClerkKit and ClerkKitUI packages to ClerkExpo pod target`);
+ }
+ }
+
+ console.log(`✅ Added clerk-ios Swift package dependency (${CLERK_IOS_VERSION})`);
+ } catch (error) {
+ console.error('❌ Error adding clerk-ios package:', error.message);
+ }
+
+ return config;
+ });
+
+ // Inject ClerkViewFactory.register() call into AppDelegate.swift
+ config = withDangerousMod(config, [
+ 'ios',
+ async config => {
+ const platformProjectRoot = config.modRequest.platformProjectRoot;
+ const projectName = config.modRequest.projectName;
+ const appDelegatePath = path.join(platformProjectRoot, projectName, 'AppDelegate.swift');
+
+ if (fs.existsSync(appDelegatePath)) {
+ let contents = fs.readFileSync(appDelegatePath, 'utf8');
+
+ // Check if already added
+ if (!contents.includes('ClerkViewFactory.register()')) {
+ // Find the didFinishLaunchingWithOptions method and add the registration call
+ // Look for the return statement in didFinishLaunching
+ const pattern = /(func application\s*\([^)]*didFinishLaunchingWithOptions[^)]*\)[^{]*\{)/;
+ const match = contents.match(pattern);
+
+ if (match) {
+ // Insert after the opening brace of didFinishLaunching
+ const insertPoint = match.index + match[0].length;
+ const registrationCode = '\n // Register Clerk native views\n ClerkViewFactory.register()\n';
+ contents = contents.slice(0, insertPoint) + registrationCode + contents.slice(insertPoint);
+ fs.writeFileSync(appDelegatePath, contents);
+ console.log('✅ Added ClerkViewFactory.register() to AppDelegate.swift');
+ } else {
+ console.warn('⚠️ Could not find didFinishLaunchingWithOptions in AppDelegate.swift');
+ }
+ }
+ }
+
+ return config;
+ },
+ ]);
+
+ // Then inject ClerkViewFactory.swift into the app target
+ // This is required because the file uses `import ClerkKit` which is only available
+ // via SPM in the app target (CocoaPods targets can't see SPM packages)
+ config = withXcodeProject(config, config => {
+ try {
+ const platformProjectRoot = config.modRequest.platformProjectRoot;
+ const projectName = config.modRequest.projectName;
+ const iosProjectPath = path.join(platformProjectRoot, projectName);
+
+ // Find the ClerkViewFactory.swift source file
+ // Check multiple possible locations in order of preference
+ let sourceFile;
+ const possiblePaths = [
+ // Standard node_modules (npm, yarn)
+ path.join(config.modRequest.projectRoot, 'node_modules', '@clerk', 'expo', 'ios', 'ClerkViewFactory.swift'),
+ // pnpm hoisted node_modules
+ path.join(
+ config.modRequest.projectRoot,
+ '..',
+ 'node_modules',
+ '@clerk',
+ 'expo',
+ 'ios',
+ 'ClerkViewFactory.swift',
+ ),
+ // Monorepo workspace (pnpm workspace)
+ path.join(
+ config.modRequest.projectRoot,
+ '..',
+ 'javascript',
+ 'packages',
+ 'expo',
+ 'ios',
+ 'ClerkViewFactory.swift',
+ ),
+ // Alternative monorepo structure
+ path.join(config.modRequest.projectRoot, '..', 'packages', 'expo', 'ios', 'ClerkViewFactory.swift'),
+ ];
+
+ for (const possiblePath of possiblePaths) {
+ if (fs.existsSync(possiblePath)) {
+ sourceFile = possiblePath;
+ break;
+ }
+ }
+
+ if (sourceFile && fs.existsSync(sourceFile)) {
+ // ALWAYS copy the file to ensure we have the latest version
+ const targetFile = path.join(iosProjectPath, 'ClerkViewFactory.swift');
+ fs.copyFileSync(sourceFile, targetFile);
+ console.log('✅ Copied ClerkViewFactory.swift to app target');
+
+ // Add the file to the Xcode project manually
+ const xcodeProject = config.modResults;
+ const relativePath = `${projectName}/ClerkViewFactory.swift`;
+ const fileName = 'ClerkViewFactory.swift';
+
+ try {
+ // Get the main target
+ const target = xcodeProject.getFirstTarget();
+ if (!target || !target.uuid) {
+ console.warn('⚠️ Could not find target UUID, file copied but not added to project');
+ return config;
+ }
+
+ const targetUuid = target.uuid;
+
+ // Check if file is already in the Xcode project references
+ const fileReferences = xcodeProject.hash.project.objects.PBXFileReference || {};
+ const alreadyExists = Object.values(fileReferences).some(ref => ref && ref.path === fileName);
+
+ if (alreadyExists) {
+ // File is already in project, but we still copied the latest version
+ console.log('✅ ClerkViewFactory.swift updated in app target');
+ return config;
+ }
+
+ // 1. Create PBXFileReference
+ const fileRefUuid = xcodeProject.generateUuid();
+ if (!xcodeProject.hash.project.objects.PBXFileReference) {
+ xcodeProject.hash.project.objects.PBXFileReference = {};
+ }
+
+ xcodeProject.hash.project.objects.PBXFileReference[fileRefUuid] = {
+ isa: 'PBXFileReference',
+ lastKnownFileType: 'sourcecode.swift',
+ name: fileName,
+ path: relativePath, // Use full relative path (projectName/ClerkViewFactory.swift)
+ sourceTree: '""',
+ };
+
+ // 2. Create PBXBuildFile
+ const buildFileUuid = xcodeProject.generateUuid();
+ if (!xcodeProject.hash.project.objects.PBXBuildFile) {
+ xcodeProject.hash.project.objects.PBXBuildFile = {};
+ }
+
+ xcodeProject.hash.project.objects.PBXBuildFile[buildFileUuid] = {
+ isa: 'PBXBuildFile',
+ fileRef: fileRefUuid,
+ fileRef_comment: fileName,
+ };
+
+ // 3. Add to PBXSourcesBuildPhase
+ const buildPhases = xcodeProject.hash.project.objects.PBXSourcesBuildPhase || {};
+ let sourcesPhaseUuid = null;
+
+ // Find the sources build phase for the main target
+ const nativeTarget = xcodeProject.hash.project.objects.PBXNativeTarget[targetUuid];
+ if (nativeTarget && nativeTarget.buildPhases) {
+ for (const phase of nativeTarget.buildPhases) {
+ if (buildPhases[phase.value] && buildPhases[phase.value].isa === 'PBXSourcesBuildPhase') {
+ sourcesPhaseUuid = phase.value;
+ break;
+ }
+ }
+ }
+
+ if (sourcesPhaseUuid && buildPhases[sourcesPhaseUuid]) {
+ if (!buildPhases[sourcesPhaseUuid].files) {
+ buildPhases[sourcesPhaseUuid].files = [];
+ }
+
+ buildPhases[sourcesPhaseUuid].files.push({
+ value: buildFileUuid,
+ comment: fileName,
+ });
+ } else {
+ console.warn('⚠️ Could not find PBXSourcesBuildPhase for target');
+ }
+
+ // 4. Add to PBXGroup (main group for the project)
+ const groups = xcodeProject.hash.project.objects.PBXGroup || {};
+ let mainGroupUuid = null;
+
+ // Find the group with the same name as the project
+ for (const [uuid, group] of Object.entries(groups)) {
+ if (group && group.name === projectName) {
+ mainGroupUuid = uuid;
+ break;
+ }
+ }
+
+ if (mainGroupUuid && groups[mainGroupUuid]) {
+ if (!groups[mainGroupUuid].children) {
+ groups[mainGroupUuid].children = [];
+ }
+
+ // Add file reference to the group
+ groups[mainGroupUuid].children.push({
+ value: fileRefUuid,
+ comment: fileName,
+ });
+ } else {
+ console.warn('⚠️ Could not find main PBXGroup for project');
+ }
+
+ console.log('✅ Added ClerkViewFactory.swift to Xcode project');
+ } catch (addError) {
+ console.error('❌ Error adding file to Xcode project:', addError.message);
+ console.error(addError.stack);
+ }
+ } else {
+ console.warn('⚠️ ClerkViewFactory.swift not found, skipping injection');
+ }
+ } catch (error) {
+ console.error('❌ Error injecting ClerkViewFactory.swift:', error.message);
+ }
+
+ return config;
+ });
+
+ // Inject SPM package resolution into Podfile post_install hook
+ // This runs synchronously during pod install, ensuring packages are resolved before prebuild completes
+ config = withDangerousMod(config, [
+ 'ios',
+ async config => {
+ const platformProjectRoot = config.modRequest.platformProjectRoot;
+ const projectName = config.modRequest.projectName;
+ const podfilePath = path.join(platformProjectRoot, 'Podfile');
+
+ if (fs.existsSync(podfilePath)) {
+ let podfileContents = fs.readFileSync(podfilePath, 'utf8');
+
+ // Check if we've already added our resolution code
+ if (!podfileContents.includes('# Clerk: Resolve SPM packages')) {
+ // Code to inject into existing post_install block
+ // Note: We run this AFTER react_native_post_install to ensure the workspace is fully written
+ const spmResolutionCode = `
+ # Clerk: Resolve SPM packages synchronously during pod install
+ # This ensures packages are downloaded before the user opens Xcode
+ # We wait until the end of post_install to ensure workspace is fully written
+ at_exit do
+ workspace_path = File.join(__dir__, '${projectName}.xcworkspace')
+ if File.exist?(workspace_path)
+ puts ""
+ puts "📦 [Clerk] Resolving Swift Package dependencies..."
+ puts " This may take a minute on first run..."
+ # Use backticks to capture output and check exit status
+ output = \`xcodebuild -resolvePackageDependencies -workspace "#{workspace_path}" -scheme "${projectName}" 2>&1\`
+ if $?.success?
+ puts "✅ [Clerk] Swift Package dependencies resolved successfully"
+ else
+ puts "⚠️ [Clerk] SPM resolution output:"
+ puts output.lines.last(10).join
+ end
+ puts ""
+ end
+ end
+`;
+
+ // Insert our code at the beginning of the existing post_install block
+ if (podfileContents.includes('post_install do |installer|')) {
+ podfileContents = podfileContents.replace(
+ /post_install do \|installer\|/,
+ `post_install do |installer|${spmResolutionCode}`,
+ );
+ fs.writeFileSync(podfilePath, podfileContents);
+ console.log('✅ Added SPM resolution to Podfile post_install hook');
+ }
+ }
+ }
+
+ return config;
+ },
+ ]);
+
+ return config;
+};
+
+/**
+ * Add packaging exclusions to Android app build.gradle to resolve
+ * duplicate META-INF file conflicts from clerk-android dependencies.
+ */
+const withClerkAndroid = config => {
+ console.log('✅ Clerk Android plugin loaded');
+
+ return withAppBuildGradle(config, modConfig => {
+ let buildGradle = modConfig.modResults.contents;
+
+ // Check if exclusion already exists
+ if (buildGradle.includes('META-INF/versions/9/OSGI-INF/MANIFEST.MF')) {
+ console.log('✅ Clerk Android packaging exclusions already configured');
+ return modConfig;
+ }
+
+ // Find the existing packagingOptions block and add resources.excludes
+ const packagingOptionsMatch = buildGradle.match(/packagingOptions\s*\{/);
+ if (packagingOptionsMatch) {
+ // Add resources block inside packagingOptions
+ const resourcesExclude = `packagingOptions {
+ // Clerk Android SDK: exclude duplicate META-INF files
+ resources {
+ excludes += ['META-INF/versions/9/OSGI-INF/MANIFEST.MF']
+ }`;
+
+ buildGradle = buildGradle.replace(/packagingOptions\s*\{/, resourcesExclude);
+ modConfig.modResults.contents = buildGradle;
+ console.log('✅ Clerk Android packaging exclusions added');
+ } else {
+ console.warn('⚠️ Could not find packagingOptions block in build.gradle');
+ }
+
+ return modConfig;
+ });
+};
+
+/**
+ * Add Google Sign-In URL scheme to Info.plist (from main branch)
+ */
+const withClerkGoogleSignIn = config => {
+ const iosUrlScheme =
+ process.env.EXPO_PUBLIC_CLERK_GOOGLE_IOS_URL_SCHEME ||
+ (config.extra && config.extra.EXPO_PUBLIC_CLERK_GOOGLE_IOS_URL_SCHEME);
+
+ if (!iosUrlScheme) {
+ return config;
+ }
+
+ return withInfoPlist(config, modConfig => {
+ if (!Array.isArray(modConfig.modResults.CFBundleURLTypes)) {
+ modConfig.modResults.CFBundleURLTypes = [];
+ }
+
+ const schemeExists = modConfig.modResults.CFBundleURLTypes.some(urlType =>
+ urlType.CFBundleURLSchemes?.includes(iosUrlScheme),
+ );
+
+ if (!schemeExists) {
+ modConfig.modResults.CFBundleURLTypes.push({
+ CFBundleURLSchemes: [iosUrlScheme],
+ });
+ console.log(`✅ Added Google Sign-In URL scheme: ${iosUrlScheme}`);
+ }
+
+ return modConfig;
+ });
+};
+
+/**
+ * Combined Clerk Expo plugin
+ *
+ * When this plugin is configured in app.json/app.config.js:
+ * 1. iOS gets Swift Package Manager dependency for clerk-ios SDK
+ * 2. Android gets packaging exclusions for dependency conflicts
+ * 3. Google Sign-In URL scheme is configured (if env var is set)
+ *
+ * Native modules are registered via react-native.config.js and standard
+ * React Native autolinking (RCTViewManager / ReactPackage).
+ */
+const withClerkExpo = config => {
+ config = withClerkIOS(config);
+ config = withClerkGoogleSignIn(config);
+ config = withClerkAndroid(config);
+ return config;
+};
+
+module.exports = withClerkExpo;
diff --git a/packages/expo/expo-module.config.json b/packages/expo/expo-module.config.json
index e59f14eef13..876f466b1ad 100644
--- a/packages/expo/expo-module.config.json
+++ b/packages/expo/expo-module.config.json
@@ -1,9 +1,3 @@
{
- "platforms": ["android", "ios"],
- "android": {
- "modules": ["expo.modules.clerk.googlesignin.ClerkGoogleSignInModule"]
- },
- "ios": {
- "modules": ["ClerkGoogleSignInModule"]
- }
+ "platforms": ["apple"]
}
diff --git a/packages/expo/ios/ClerkAuthViewManager.m b/packages/expo/ios/ClerkAuthViewManager.m
new file mode 100644
index 00000000000..c5a25dd8a9b
--- /dev/null
+++ b/packages/expo/ios/ClerkAuthViewManager.m
@@ -0,0 +1,9 @@
+#import
+
+@interface RCT_EXTERN_MODULE(ClerkAuthViewManager, RCTViewManager)
+
+RCT_EXPORT_VIEW_PROPERTY(mode, NSString)
+RCT_EXPORT_VIEW_PROPERTY(isDismissable, NSNumber)
+RCT_EXPORT_VIEW_PROPERTY(onAuthEvent, RCTBubblingEventBlock)
+
+@end
diff --git a/packages/expo/ios/ClerkAuthViewManager.swift b/packages/expo/ios/ClerkAuthViewManager.swift
new file mode 100644
index 00000000000..0ab9629edba
--- /dev/null
+++ b/packages/expo/ios/ClerkAuthViewManager.swift
@@ -0,0 +1,13 @@
+import React
+
+@objc(ClerkAuthViewManager)
+class ClerkAuthViewManager: RCTViewManager {
+
+ override static func requiresMainQueueSetup() -> Bool {
+ return true
+ }
+
+ override func view() -> UIView! {
+ return ClerkAuthNativeView()
+ }
+}
diff --git a/packages/expo/ios/ClerkExpo.podspec b/packages/expo/ios/ClerkExpo.podspec
new file mode 100644
index 00000000000..f5b31fb617c
--- /dev/null
+++ b/packages/expo/ios/ClerkExpo.podspec
@@ -0,0 +1,45 @@
+require 'json'
+
+# Find package.json by following symlinks if necessary
+package_json_path = File.join(__dir__, '..', 'package.json')
+package_json_path = File.join(File.readlink(__dir__), '..', 'package.json') if File.symlink?(__dir__)
+
+# Fallback to hardcoded values if package.json is not found
+if File.exist?(package_json_path)
+ package = JSON.parse(File.read(package_json_path))
+else
+ package = {
+ 'version' => '0.0.0-FALLBACK',
+ 'description' => 'Clerk React Native/Expo library',
+ 'license' => 'MIT',
+ 'author' => 'Clerk',
+ 'homepage' => 'https://clerk.com/'
+ }
+end
+
+Pod::Spec.new do |s|
+ s.name = 'ClerkExpo'
+ s.version = package['version']
+ s.summary = package['description']
+ s.license = package['license']
+ s.author = package['author']
+ s.homepage = package['homepage']
+ s.platforms = { :ios => '17.0' } # Clerk iOS SDK requires iOS 17
+ s.swift_version = '5.10'
+ s.source = { git: 'https://github.com/clerk/javascript' }
+ s.static_framework = true
+
+ s.pod_target_xcconfig = {
+ 'DEFINES_MODULE' => 'YES',
+ 'SWIFT_COMPILATION_MODE' => 'wholemodule'
+ }
+
+ # Only include the module files in the pod (both Swift and ObjC bridges).
+ # ClerkViewFactory.swift (with views) is injected into the app target by the config plugin
+ # because it uses `import ClerkKit` which is only available via SPM in the app target.
+ s.source_files = "ClerkExpoModule.swift", "ClerkExpoModule.m",
+ "ClerkAuthViewManager.swift", "ClerkAuthViewManager.m",
+ "ClerkUserProfileViewManager.swift", "ClerkUserProfileViewManager.m"
+
+ install_modules_dependencies(s)
+end
diff --git a/packages/expo/ios/ClerkExpoModule.m b/packages/expo/ios/ClerkExpoModule.m
new file mode 100644
index 00000000000..febfe003c61
--- /dev/null
+++ b/packages/expo/ios/ClerkExpoModule.m
@@ -0,0 +1,28 @@
+#import
+#import
+
+@interface RCT_EXTERN_MODULE(ClerkExpo, RCTEventEmitter)
+
+RCT_EXTERN_METHOD(configure:(NSString *)publishableKey
+ bearerToken:(NSString *)bearerToken
+ resolve:(RCTPromiseResolveBlock)resolve
+ reject:(RCTPromiseRejectBlock)reject)
+
+RCT_EXTERN_METHOD(presentAuth:(NSDictionary *)options
+ resolve:(RCTPromiseResolveBlock)resolve
+ reject:(RCTPromiseRejectBlock)reject)
+
+RCT_EXTERN_METHOD(presentUserProfile:(NSDictionary *)options
+ resolve:(RCTPromiseResolveBlock)resolve
+ reject:(RCTPromiseRejectBlock)reject)
+
+RCT_EXTERN_METHOD(getSession:(RCTPromiseResolveBlock)resolve
+ reject:(RCTPromiseRejectBlock)reject)
+
+RCT_EXTERN_METHOD(getClientToken:(RCTPromiseResolveBlock)resolve
+ reject:(RCTPromiseRejectBlock)reject)
+
+RCT_EXTERN_METHOD(signOut:(RCTPromiseResolveBlock)resolve
+ reject:(RCTPromiseRejectBlock)reject)
+
+@end
diff --git a/packages/expo/ios/ClerkExpoModule.swift b/packages/expo/ios/ClerkExpoModule.swift
new file mode 100644
index 00000000000..33b8b91e2b8
--- /dev/null
+++ b/packages/expo/ios/ClerkExpoModule.swift
@@ -0,0 +1,386 @@
+// ClerkExpoModule - Native module for Clerk integration
+// This module provides the configure function and view presentation methods.
+// Views are presented as modal view controllers (not embedded views)
+// because the Clerk SDK (SPM) isn't accessible from CocoaPods.
+
+import UIKit
+import React
+
+// Global registry for the Clerk view factory (set by app target at startup)
+public var clerkViewFactory: ClerkViewFactoryProtocol?
+
+// Protocol that the app target implements to provide Clerk views
+public protocol ClerkViewFactoryProtocol {
+ // Modal presentation (existing)
+ func createAuthViewController(mode: String, dismissable: Bool, completion: @escaping (Result<[String: Any], Error>) -> Void) -> UIViewController?
+ func createUserProfileViewController(dismissable: Bool, completion: @escaping (Result<[String: Any], Error>) -> Void) -> UIViewController?
+
+ // Inline rendering — returns UIViewController to preserve SwiftUI lifecycle
+ func createAuthView(mode: String, dismissable: Bool, onEvent: @escaping (String, [String: Any]) -> Void) -> UIViewController?
+ func createUserProfileView(dismissable: Bool, onEvent: @escaping (String, [String: Any]) -> Void) -> UIViewController?
+
+ // SDK operations
+ func configure(publishableKey: String, bearerToken: String?) async throws
+ func getSession() async -> [String: Any]?
+ func signOut() async throws
+}
+
+// MARK: - Module
+
+@objc(ClerkExpo)
+class ClerkExpoModule: RCTEventEmitter {
+
+ private static var _hasListeners = false
+
+ override init() {
+ super.init()
+ }
+
+ @objc override static func requiresMainQueueSetup() -> Bool {
+ return false
+ }
+
+ override func supportedEvents() -> [String]! {
+ return ["onAuthStateChange"]
+ }
+
+ override func startObserving() {
+ ClerkExpoModule._hasListeners = true
+ }
+
+ override func stopObserving() {
+ ClerkExpoModule._hasListeners = false
+ }
+
+ /// Returns the topmost presented view controller, avoiding deprecated `keyWindow`.
+ private static func topViewController() -> UIViewController? {
+ guard let scene = UIApplication.shared.connectedScenes
+ .compactMap({ $0 as? UIWindowScene })
+ .first(where: { $0.activationState == .foregroundActive }),
+ let rootVC = scene.windows.first(where: { $0.isKeyWindow })?.rootViewController
+ else { return nil }
+
+ var top = rootVC
+ while let presented = top.presentedViewController {
+ top = presented
+ }
+ return top
+ }
+
+ // MARK: - configure
+
+ @objc func configure(_ publishableKey: String,
+ bearerToken: String?,
+ resolve: @escaping RCTPromiseResolveBlock,
+ reject: @escaping RCTPromiseRejectBlock) {
+ guard let factory = clerkViewFactory else {
+ reject("E_NOT_INITIALIZED", "Clerk not initialized. Make sure ClerkViewFactory is registered.", nil)
+ return
+ }
+
+ Task {
+ do {
+ try await factory.configure(publishableKey: publishableKey, bearerToken: bearerToken)
+ resolve(nil)
+ } catch {
+ reject("E_CONFIGURE_FAILED", error.localizedDescription, error)
+ }
+ }
+ }
+
+ // MARK: - presentAuth
+
+ @objc func presentAuth(_ options: NSDictionary,
+ resolve: @escaping RCTPromiseResolveBlock,
+ reject: @escaping RCTPromiseRejectBlock) {
+ guard let factory = clerkViewFactory else {
+ reject("E_NOT_INITIALIZED", "Clerk not initialized", nil)
+ return
+ }
+
+ let mode = options["mode"] as? String ?? "signInOrUp"
+ let dismissable = options["dismissable"] as? Bool ?? true
+
+ DispatchQueue.main.async {
+ guard let vc = factory.createAuthViewController(mode: mode, dismissable: dismissable, completion: { result in
+ switch result {
+ case .success(let data):
+ resolve(data)
+ case .failure(let error):
+ reject("E_AUTH_FAILED", error.localizedDescription, error)
+ }
+ }) else {
+ reject("E_CREATE_FAILED", "Could not create auth view controller", nil)
+ return
+ }
+
+ if let rootVC = Self.topViewController() {
+ rootVC.present(vc, animated: true)
+ } else {
+ reject("E_NO_ROOT_VC", "No root view controller available to present auth", nil)
+ }
+ }
+ }
+
+ // MARK: - presentUserProfile
+
+ @objc func presentUserProfile(_ options: NSDictionary,
+ resolve: @escaping RCTPromiseResolveBlock,
+ reject: @escaping RCTPromiseRejectBlock) {
+ guard let factory = clerkViewFactory else {
+ reject("E_NOT_INITIALIZED", "Clerk not initialized", nil)
+ return
+ }
+
+ let dismissable = options["dismissable"] as? Bool ?? true
+
+ DispatchQueue.main.async {
+ guard let vc = factory.createUserProfileViewController(dismissable: dismissable, completion: { result in
+ switch result {
+ case .success(let data):
+ resolve(data)
+ case .failure(let error):
+ reject("E_PROFILE_FAILED", error.localizedDescription, error)
+ }
+ }) else {
+ reject("E_CREATE_FAILED", "Could not create profile view controller", nil)
+ return
+ }
+
+ if let rootVC = Self.topViewController() {
+ rootVC.present(vc, animated: true)
+ } else {
+ reject("E_NO_ROOT_VC", "No root view controller available to present profile", nil)
+ }
+ }
+ }
+
+ // MARK: - getSession
+
+ @objc func getSession(_ resolve: @escaping RCTPromiseResolveBlock,
+ reject: @escaping RCTPromiseRejectBlock) {
+ guard let factory = clerkViewFactory else {
+ resolve(nil)
+ return
+ }
+
+ Task {
+ let session = await factory.getSession()
+ resolve(session)
+ }
+ }
+
+ // MARK: - getClientToken
+
+ @objc func getClientToken(_ resolve: @escaping RCTPromiseResolveBlock,
+ reject: @escaping RCTPromiseRejectBlock) {
+ let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: Bundle.main.bundleIdentifier ?? "",
+ kSecAttrAccount as String: "clerkDeviceToken",
+ kSecReturnData as String: true,
+ kSecMatchLimit as String: kSecMatchLimitOne
+ ]
+
+ var result: AnyObject?
+ let status = SecItemCopyMatching(query as CFDictionary, &result)
+
+ if status == errSecSuccess, let data = result as? Data {
+ resolve(String(data: data, encoding: .utf8))
+ } else {
+ resolve(nil)
+ }
+ }
+
+ // MARK: - signOut
+
+ @objc func signOut(_ resolve: @escaping RCTPromiseResolveBlock,
+ reject: @escaping RCTPromiseRejectBlock) {
+ guard let factory = clerkViewFactory else {
+ reject("E_NOT_INITIALIZED", "Clerk not initialized", nil)
+ return
+ }
+
+ Task {
+ do {
+ try await factory.signOut()
+ resolve(nil)
+ } catch {
+ reject("E_SIGN_OUT_FAILED", error.localizedDescription, error)
+ }
+ }
+ }
+}
+
+// MARK: - Inline View: ClerkAuthNativeView
+
+public class ClerkAuthNativeView: UIView {
+ private var hostingController: UIViewController?
+ private var currentMode: String = "signInOrUp"
+ private var currentDismissable: Bool = true
+ private var hasInitialized: Bool = false
+
+ @objc var onAuthEvent: RCTBubblingEventBlock?
+
+ @objc var mode: NSString? {
+ didSet {
+ currentMode = (mode as String?) ?? "signInOrUp"
+ if hasInitialized { updateView() }
+ }
+ }
+
+ @objc var isDismissable: NSNumber? {
+ didSet {
+ currentDismissable = isDismissable?.boolValue ?? true
+ if hasInitialized { updateView() }
+ }
+ }
+
+ override init(frame: CGRect) {
+ super.init(frame: frame)
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ override public func didMoveToWindow() {
+ super.didMoveToWindow()
+ if window != nil && !hasInitialized {
+ hasInitialized = true
+ updateView()
+ }
+ }
+
+ private func updateView() {
+ // Remove old hosting controller
+ hostingController?.view.removeFromSuperview()
+ hostingController?.removeFromParent()
+ hostingController = nil
+
+ guard let factory = clerkViewFactory else { return }
+
+ guard let returnedController = factory.createAuthView(
+ mode: currentMode,
+ dismissable: currentDismissable,
+ onEvent: { [weak self] eventName, data in
+ // Convert data dict to JSON string for codegen event
+ let jsonData = (try? JSONSerialization.data(withJSONObject: data)) ?? Data()
+ let jsonString = String(data: jsonData, encoding: .utf8) ?? "{}"
+ self?.onAuthEvent?(["type": eventName, "data": jsonString])
+ }
+ ) else { return }
+
+ // Attach the returned UIHostingController as a child to preserve SwiftUI lifecycle
+ if let parentVC = findViewController() {
+ parentVC.addChild(returnedController)
+ returnedController.view.frame = bounds
+ returnedController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
+ addSubview(returnedController.view)
+ returnedController.didMove(toParent: parentVC)
+ hostingController = returnedController
+ } else {
+ returnedController.view.frame = bounds
+ returnedController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
+ addSubview(returnedController.view)
+ hostingController = returnedController
+ }
+ }
+
+ private func findViewController() -> UIViewController? {
+ var responder: UIResponder? = self
+ while let nextResponder = responder?.next {
+ if let vc = nextResponder as? UIViewController {
+ return vc
+ }
+ responder = nextResponder
+ }
+ return nil
+ }
+
+ override public func layoutSubviews() {
+ super.layoutSubviews()
+ hostingController?.view.frame = bounds
+ }
+}
+
+// MARK: - Inline View: ClerkUserProfileNativeView
+
+public class ClerkUserProfileNativeView: UIView {
+ private var hostingController: UIViewController?
+ private var currentDismissable: Bool = true
+ private var hasInitialized: Bool = false
+
+ @objc var onProfileEvent: RCTBubblingEventBlock?
+
+ @objc var isDismissable: NSNumber? {
+ didSet {
+ currentDismissable = isDismissable?.boolValue ?? true
+ if hasInitialized { updateView() }
+ }
+ }
+
+ override init(frame: CGRect) {
+ super.init(frame: frame)
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ override public func didMoveToWindow() {
+ super.didMoveToWindow()
+ if window != nil && !hasInitialized {
+ hasInitialized = true
+ updateView()
+ }
+ }
+
+ private func updateView() {
+ // Remove old hosting controller
+ hostingController?.view.removeFromSuperview()
+ hostingController?.removeFromParent()
+ hostingController = nil
+
+ guard let factory = clerkViewFactory else { return }
+
+ guard let returnedController = factory.createUserProfileView(
+ dismissable: currentDismissable,
+ onEvent: { [weak self] eventName, data in
+ let jsonData = (try? JSONSerialization.data(withJSONObject: data)) ?? Data()
+ let jsonString = String(data: jsonData, encoding: .utf8) ?? "{}"
+ self?.onProfileEvent?(["type": eventName, "data": jsonString])
+ }
+ ) else { return }
+
+ if let parentVC = findViewController() {
+ parentVC.addChild(returnedController)
+ returnedController.view.frame = bounds
+ returnedController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
+ addSubview(returnedController.view)
+ returnedController.didMove(toParent: parentVC)
+ hostingController = returnedController
+ } else {
+ returnedController.view.frame = bounds
+ returnedController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
+ addSubview(returnedController.view)
+ hostingController = returnedController
+ }
+ }
+
+ private func findViewController() -> UIViewController? {
+ var responder: UIResponder? = self
+ while let nextResponder = responder?.next {
+ if let vc = nextResponder as? UIViewController {
+ return vc
+ }
+ responder = nextResponder
+ }
+ return nil
+ }
+
+ override public func layoutSubviews() {
+ super.layoutSubviews()
+ hostingController?.view.frame = bounds
+ }
+}
diff --git a/packages/expo/ios/ClerkGoogleSignIn.podspec b/packages/expo/ios/ClerkGoogleSignIn.podspec
index be0f3551b2b..e356ea70c8c 100644
--- a/packages/expo/ios/ClerkGoogleSignIn.podspec
+++ b/packages/expo/ios/ClerkGoogleSignIn.podspec
@@ -15,8 +15,10 @@ Pod::Spec.new do |s|
s.source = { :git => 'https://github.com/clerk/javascript.git' }
s.static_framework = true
- s.dependency 'ExpoModulesCore'
s.dependency 'GoogleSignIn', '~> 9.0'
- s.source_files = '*.swift'
+ # Only include the Google Sign-In module files
+ s.source_files = 'ClerkGoogleSignInModule.swift', 'ClerkGoogleSignInModule.m'
+
+ install_modules_dependencies(s)
end
diff --git a/packages/expo/ios/ClerkGoogleSignInModule.m b/packages/expo/ios/ClerkGoogleSignInModule.m
new file mode 100644
index 00000000000..5848d4a17b7
--- /dev/null
+++ b/packages/expo/ios/ClerkGoogleSignInModule.m
@@ -0,0 +1,22 @@
+#import
+
+@interface RCT_EXTERN_MODULE(ClerkGoogleSignIn, NSObject)
+
+RCT_EXTERN_METHOD(configure:(NSDictionary *)params)
+
+RCT_EXTERN_METHOD(signIn:(NSDictionary *)params
+ resolve:(RCTPromiseResolveBlock)resolve
+ reject:(RCTPromiseRejectBlock)reject)
+
+RCT_EXTERN_METHOD(createAccount:(NSDictionary *)params
+ resolve:(RCTPromiseResolveBlock)resolve
+ reject:(RCTPromiseRejectBlock)reject)
+
+RCT_EXTERN_METHOD(presentExplicitSignIn:(NSDictionary *)params
+ resolve:(RCTPromiseResolveBlock)resolve
+ reject:(RCTPromiseRejectBlock)reject)
+
+RCT_EXTERN_METHOD(signOut:(RCTPromiseResolveBlock)resolve
+ reject:(RCTPromiseRejectBlock)reject)
+
+@end
diff --git a/packages/expo/ios/ClerkGoogleSignInModule.swift b/packages/expo/ios/ClerkGoogleSignInModule.swift
index c06f85b8031..ea29ad2ae79 100644
--- a/packages/expo/ios/ClerkGoogleSignInModule.swift
+++ b/packages/expo/ios/ClerkGoogleSignInModule.swift
@@ -1,229 +1,192 @@
-import ExpoModulesCore
+import React
import GoogleSignIn
-public class ClerkGoogleSignInModule: Module {
- private var clientId: String?
- private var hostedDomain: String?
-
- public func definition() -> ModuleDefinition {
- Name("ClerkGoogleSignIn")
-
- // Configure the module
- Function("configure") { (params: ConfigureParams) in
- self.clientId = params.iosClientId ?? params.webClientId
- self.hostedDomain = params.hostedDomain
-
- // Set the configuration globally
- // clientID: iOS client ID for OAuth flow
- // serverClientID: Web client ID for token audience (what Clerk backend verifies)
- if let clientId = self.clientId {
- let config = GIDConfiguration(
- clientID: clientId,
- serverClientID: params.webClientId
- )
- GIDSignIn.sharedInstance.configuration = config
- }
- }
-
- // Sign in - attempts sign-in with hint if available
- AsyncFunction("signIn") { (params: SignInParams?, promise: Promise) in
- guard self.clientId != nil else {
- promise.reject(NotConfiguredException())
- return
- }
-
- DispatchQueue.main.async {
- guard let presentingVC = self.getPresentingViewController() else {
- promise.reject(GoogleSignInException(message: "No presenting view controller available"))
- return
- }
-
- // Build sign-in hint if filtering by authorized accounts
- let hint: String? = params?.filterByAuthorizedAccounts == true
- ? GIDSignIn.sharedInstance.currentUser?.profile?.email
- : nil
-
- GIDSignIn.sharedInstance.signIn(
- withPresenting: presentingVC,
- hint: hint,
- additionalScopes: nil,
- nonce: params?.nonce
- ) { result, error in
- self.handleSignInResult(result: result, error: error, promise: promise)
- }
- }
- }
-
- // Create account - shows account creation UI (same as sign in on iOS)
- AsyncFunction("createAccount") { (params: CreateAccountParams?, promise: Promise) in
- guard self.clientId != nil else {
- promise.reject(NotConfiguredException())
- return
- }
-
- DispatchQueue.main.async {
- guard let presentingVC = self.getPresentingViewController() else {
- promise.reject(GoogleSignInException(message: "No presenting view controller available"))
- return
- }
-
- GIDSignIn.sharedInstance.signIn(
- withPresenting: presentingVC,
- hint: nil,
- additionalScopes: nil,
- nonce: params?.nonce
- ) { result, error in
- self.handleSignInResult(result: result, error: error, promise: promise)
- }
- }
- }
-
- // Explicit sign-in - uses standard Google Sign-In flow
- AsyncFunction("presentExplicitSignIn") { (params: ExplicitSignInParams?, promise: Promise) in
- guard self.clientId != nil else {
- promise.reject(NotConfiguredException())
- return
- }
-
- DispatchQueue.main.async {
- guard let presentingVC = self.getPresentingViewController() else {
- promise.reject(GoogleSignInException(message: "No presenting view controller available"))
- return
- }
-
- GIDSignIn.sharedInstance.signIn(
- withPresenting: presentingVC,
- hint: nil,
- additionalScopes: nil,
- nonce: params?.nonce
- ) { result, error in
- self.handleSignInResult(result: result, error: error, promise: promise)
- }
- }
- }
-
- // Sign out - clears credential state
- AsyncFunction("signOut") { (promise: Promise) in
- GIDSignIn.sharedInstance.signOut()
- promise.resolve(nil)
- }
- }
+@objc(ClerkGoogleSignIn)
+class ClerkGoogleSignInModule: NSObject, RCTBridgeModule {
- private func getPresentingViewController() -> UIViewController? {
- guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
- let window = scene.windows.first,
- let rootVC = window.rootViewController else {
- return nil
- }
-
- var topVC = rootVC
- while let presentedVC = topVC.presentedViewController {
- topVC = presentedVC
- }
- return topVC
- }
+ static func moduleName() -> String! {
+ return "ClerkGoogleSignIn"
+ }
+
+ @objc static func requiresMainQueueSetup() -> Bool {
+ return false
+ }
+
+ private var clientId: String?
+ private var hostedDomain: String?
+
+ // MARK: - configure
+
+ @objc func configure(_ params: NSDictionary) {
+ let webClientId = params["webClientId"] as? String ?? ""
+ let iosClientId = params["iosClientId"] as? String
+ self.clientId = iosClientId ?? webClientId
+ self.hostedDomain = params["hostedDomain"] as? String
- private func handleSignInResult(result: GIDSignInResult?, error: Error?, promise: Promise) {
- if let error = error {
- let nsError = error as NSError
-
- // Check for user cancellation
- if nsError.domain == kGIDSignInErrorDomain && nsError.code == GIDSignInError.canceled.rawValue {
- promise.reject(SignInCancelledException())
- return
- }
-
- promise.reject(GoogleSignInException(message: error.localizedDescription))
- return
- }
-
- guard let result = result,
- let idToken = result.user.idToken?.tokenString else {
- promise.reject(GoogleSignInException(message: "No ID token received"))
- return
- }
-
- let user = result.user
- let profile = user.profile
-
- let response: [String: Any] = [
- "type": "success",
- "data": [
- "idToken": idToken,
- "user": [
- "id": user.userID ?? "",
- "email": profile?.email ?? "",
- "name": profile?.name ?? "",
- "givenName": profile?.givenName ?? "",
- "familyName": profile?.familyName ?? "",
- "photo": profile?.imageURL(withDimension: 200)?.absoluteString ?? NSNull()
- ] as [String: Any]
- ] as [String: Any]
- ]
-
- promise.resolve(response)
+ if let clientId = self.clientId {
+ let config = GIDConfiguration(
+ clientID: clientId,
+ serverClientID: webClientId
+ )
+ GIDSignIn.sharedInstance.configuration = config
}
-}
+ }
-// MARK: - Records
+ // MARK: - signIn
-struct ConfigureParams: Record {
- @Field
- var webClientId: String = ""
+ @objc func signIn(_ params: NSDictionary?,
+ resolve: @escaping RCTPromiseResolveBlock,
+ reject: @escaping RCTPromiseRejectBlock) {
+ guard self.clientId != nil else {
+ reject("NOT_CONFIGURED", "Google Sign-In is not configured. Call configure() first.", nil)
+ return
+ }
- @Field
- var iosClientId: String?
+ DispatchQueue.main.async {
+ guard let presentingVC = self.getPresentingViewController() else {
+ reject("GOOGLE_SIGN_IN_ERROR", "No presenting view controller available", nil)
+ return
+ }
+
+ let filterByAuthorized = params?["filterByAuthorizedAccounts"] as? Bool ?? false
+ let hint: String? = filterByAuthorized
+ ? GIDSignIn.sharedInstance.currentUser?.profile?.email
+ : nil
+ let nonce = params?["nonce"] as? String
+
+ GIDSignIn.sharedInstance.signIn(
+ withPresenting: presentingVC,
+ hint: hint,
+ additionalScopes: nil,
+ nonce: nonce
+ ) { result, error in
+ self.handleSignInResult(result: result, error: error, resolve: resolve, reject: reject)
+ }
+ }
+ }
- @Field
- var hostedDomain: String?
+ // MARK: - createAccount
- @Field
- var autoSelectEnabled: Bool?
-}
+ @objc func createAccount(_ params: NSDictionary?,
+ resolve: @escaping RCTPromiseResolveBlock,
+ reject: @escaping RCTPromiseRejectBlock) {
+ guard self.clientId != nil else {
+ reject("NOT_CONFIGURED", "Google Sign-In is not configured. Call configure() first.", nil)
+ return
+ }
-struct SignInParams: Record {
- @Field
- var nonce: String?
+ DispatchQueue.main.async {
+ guard let presentingVC = self.getPresentingViewController() else {
+ reject("GOOGLE_SIGN_IN_ERROR", "No presenting view controller available", nil)
+ return
+ }
+
+ let nonce = params?["nonce"] as? String
+
+ GIDSignIn.sharedInstance.signIn(
+ withPresenting: presentingVC,
+ hint: nil,
+ additionalScopes: nil,
+ nonce: nonce
+ ) { result, error in
+ self.handleSignInResult(result: result, error: error, resolve: resolve, reject: reject)
+ }
+ }
+ }
- @Field
- var filterByAuthorizedAccounts: Bool?
-}
+ // MARK: - presentExplicitSignIn
-struct CreateAccountParams: Record {
- @Field
- var nonce: String?
-}
+ @objc func presentExplicitSignIn(_ params: NSDictionary?,
+ resolve: @escaping RCTPromiseResolveBlock,
+ reject: @escaping RCTPromiseRejectBlock) {
+ guard self.clientId != nil else {
+ reject("NOT_CONFIGURED", "Google Sign-In is not configured. Call configure() first.", nil)
+ return
+ }
-struct ExplicitSignInParams: Record {
- @Field
- var nonce: String?
-}
+ DispatchQueue.main.async {
+ guard let presentingVC = self.getPresentingViewController() else {
+ reject("GOOGLE_SIGN_IN_ERROR", "No presenting view controller available", nil)
+ return
+ }
+
+ let nonce = params?["nonce"] as? String
+
+ GIDSignIn.sharedInstance.signIn(
+ withPresenting: presentingVC,
+ hint: nil,
+ additionalScopes: nil,
+ nonce: nonce
+ ) { result, error in
+ self.handleSignInResult(result: result, error: error, resolve: resolve, reject: reject)
+ }
+ }
+ }
-// MARK: - Exceptions
+ // MARK: - signOut
-class SignInCancelledException: Exception {
- override var code: String { "SIGN_IN_CANCELLED" }
- override var reason: String { "User cancelled the sign-in flow" }
-}
+ @objc func signOut(_ resolve: @escaping RCTPromiseResolveBlock,
+ reject: @escaping RCTPromiseRejectBlock) {
+ GIDSignIn.sharedInstance.signOut()
+ resolve(nil)
+ }
-class NoSavedCredentialException: Exception {
- override var code: String { "NO_SAVED_CREDENTIAL_FOUND" }
- override var reason: String { "No saved credential found" }
-}
+ // MARK: - Helpers
-class NotConfiguredException: Exception {
- override var code: String { "NOT_CONFIGURED" }
- override var reason: String { "Google Sign-In is not configured. Call configure() first." }
-}
+ private func getPresentingViewController() -> UIViewController? {
+ guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
+ let window = scene.windows.first,
+ let rootVC = window.rootViewController else {
+ return nil
+ }
-class GoogleSignInException: Exception {
- private let errorMessage: String
+ var topVC = rootVC
+ while let presentedVC = topVC.presentedViewController {
+ topVC = presentedVC
+ }
+ return topVC
+ }
+
+ private func handleSignInResult(result: GIDSignInResult?, error: Error?,
+ resolve: @escaping RCTPromiseResolveBlock,
+ reject: @escaping RCTPromiseRejectBlock) {
+ if let error = error {
+ let nsError = error as NSError
+
+ // Check for user cancellation
+ if nsError.domain == kGIDSignInErrorDomain && nsError.code == GIDSignInError.canceled.rawValue {
+ reject("SIGN_IN_CANCELLED", "User cancelled the sign-in flow", error)
+ return
+ }
+
+ reject("GOOGLE_SIGN_IN_ERROR", error.localizedDescription, error)
+ return
+ }
- init(message: String) {
- self.errorMessage = message
- super.init()
+ guard let result = result,
+ let idToken = result.user.idToken?.tokenString else {
+ reject("GOOGLE_SIGN_IN_ERROR", "No ID token received", nil)
+ return
}
- override var code: String { "GOOGLE_SIGN_IN_ERROR" }
- override var reason: String { errorMessage }
+ let user = result.user
+ let profile = user.profile
+
+ let response: [String: Any] = [
+ "type": "success",
+ "data": [
+ "idToken": idToken,
+ "user": [
+ "id": user.userID ?? "",
+ "email": profile?.email ?? "",
+ "name": profile?.name ?? "",
+ "givenName": profile?.givenName ?? "",
+ "familyName": profile?.familyName ?? "",
+ "photo": profile?.imageURL(withDimension: 200)?.absoluteString ?? NSNull()
+ ] as [String: Any]
+ ] as [String: Any]
+ ]
+
+ resolve(response)
+ }
}
diff --git a/packages/expo/ios/ClerkUserProfileViewManager.m b/packages/expo/ios/ClerkUserProfileViewManager.m
new file mode 100644
index 00000000000..35eaf720ed9
--- /dev/null
+++ b/packages/expo/ios/ClerkUserProfileViewManager.m
@@ -0,0 +1,8 @@
+#import
+
+@interface RCT_EXTERN_MODULE(ClerkUserProfileViewManager, RCTViewManager)
+
+RCT_EXPORT_VIEW_PROPERTY(isDismissable, NSNumber)
+RCT_EXPORT_VIEW_PROPERTY(onProfileEvent, RCTBubblingEventBlock)
+
+@end
diff --git a/packages/expo/ios/ClerkUserProfileViewManager.swift b/packages/expo/ios/ClerkUserProfileViewManager.swift
new file mode 100644
index 00000000000..b8e9c269f6a
--- /dev/null
+++ b/packages/expo/ios/ClerkUserProfileViewManager.swift
@@ -0,0 +1,13 @@
+import React
+
+@objc(ClerkUserProfileViewManager)
+class ClerkUserProfileViewManager: RCTViewManager {
+
+ override static func requiresMainQueueSetup() -> Bool {
+ return true
+ }
+
+ override func view() -> UIView! {
+ return ClerkUserProfileNativeView()
+ }
+}
diff --git a/packages/expo/ios/ClerkViewFactory.swift b/packages/expo/ios/ClerkViewFactory.swift
new file mode 100644
index 00000000000..ccd09e6b1d9
--- /dev/null
+++ b/packages/expo/ios/ClerkViewFactory.swift
@@ -0,0 +1,439 @@
+// ClerkViewFactory - Provides Clerk view controllers to the ClerkExpo module
+// This file is injected into the app target by the config plugin.
+// It uses `import ClerkKit` (SPM) which is only accessible from the app target.
+
+import UIKit
+import SwiftUI
+import Security
+import ClerkKit
+import ClerkKitUI
+import ClerkExpo // Import the pod to access ClerkViewFactoryProtocol
+
+// MARK: - View Factory Implementation
+
+public class ClerkViewFactory: ClerkViewFactoryProtocol {
+ public static let shared = ClerkViewFactory()
+
+ private init() {}
+
+ // Register this factory with the ClerkExpo module
+ public static func register() {
+ clerkViewFactory = shared
+ }
+
+ @MainActor
+ public func configure(publishableKey: String, bearerToken: String? = nil) async throws {
+ // Sync JS SDK's client token to native keychain so both SDKs share the same client.
+ // This handles the case where the user signed in via JS SDK but the native SDK
+ // has no device token (e.g., after app reinstall or first launch).
+ if let token = bearerToken, !token.isEmpty {
+ Self.writeNativeDeviceTokenIfNeeded(token)
+ } else {
+ Self.syncJSTokenToNativeKeychainIfNeeded()
+ }
+
+ Clerk.configure(publishableKey: publishableKey)
+
+ // Wait for Clerk to finish loading (cached data + API refresh).
+ // The static configure() fires off async refreshes; poll until loaded.
+ for _ in 0..<30 { // Wait up to 3 seconds
+ if Clerk.shared.isLoaded && Clerk.shared.session != nil {
+ return
+ }
+ try? await Task.sleep(nanoseconds: 100_000_000) // 100ms
+ }
+ }
+
+ /// Copies the JS SDK's client JWT from expo-secure-store to the native SDK's
+ /// keychain entry, but only if the native SDK doesn't already have a device token.
+ /// Both expo-secure-store and the native Clerk SDK use the iOS Keychain with the
+ /// bundle identifier as the service name, making cross-SDK token sharing possible.
+ private static func syncJSTokenToNativeKeychainIfNeeded() {
+ guard let service = Bundle.main.bundleIdentifier, !service.isEmpty else { return }
+
+ let jsTokenKey = "__clerk_client_jwt"
+ let nativeTokenKey = "clerkDeviceToken"
+
+ // Check if native SDK already has a device token — don't overwrite
+ let checkQuery: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ kSecAttrAccount as String: nativeTokenKey,
+ kSecReturnData as String: false,
+ kSecMatchLimit as String: kSecMatchLimitOne,
+ ]
+ if SecItemCopyMatching(checkQuery as CFDictionary, nil) == errSecSuccess {
+ return // Native token exists, don't overwrite
+ }
+
+ // Read JS SDK's client JWT from keychain (stored by expo-secure-store)
+ var result: CFTypeRef?
+ let readQuery: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ kSecAttrAccount as String: jsTokenKey,
+ kSecReturnData as String: true,
+ kSecMatchLimit as String: kSecMatchLimitOne,
+ ]
+ guard SecItemCopyMatching(readQuery as CFDictionary, &result) == errSecSuccess,
+ let data = result as? Data,
+ let jsToken = String(data: data, encoding: .utf8),
+ !jsToken.isEmpty else {
+ return // No JS token available
+ }
+
+ // Write JS token as native device token
+ guard let tokenData = jsToken.data(using: .utf8) else { return }
+ let writeQuery: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ kSecAttrAccount as String: nativeTokenKey,
+ kSecValueData as String: tokenData,
+ kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
+ ]
+ SecItemAdd(writeQuery as CFDictionary, nil)
+ }
+
+ /// Writes the provided bearer token as the native SDK's device token,
+ /// but only if the native SDK doesn't already have one.
+ private static func writeNativeDeviceTokenIfNeeded(_ token: String) {
+ guard let service = Bundle.main.bundleIdentifier, !service.isEmpty else { return }
+
+ let nativeTokenKey = "clerkDeviceToken"
+
+ // Check if native SDK already has a device token — don't overwrite
+ let checkQuery: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ kSecAttrAccount as String: nativeTokenKey,
+ kSecReturnData as String: false,
+ kSecMatchLimit as String: kSecMatchLimitOne,
+ ]
+ if SecItemCopyMatching(checkQuery as CFDictionary, nil) == errSecSuccess {
+ return
+ }
+
+ // Write the provided token as native device token
+ guard let tokenData = token.data(using: .utf8) else { return }
+ let writeQuery: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ kSecAttrAccount as String: nativeTokenKey,
+ kSecValueData as String: tokenData,
+ kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
+ ]
+ SecItemAdd(writeQuery as CFDictionary, nil)
+ }
+
+ public func createAuthViewController(
+ mode: String,
+ dismissable: Bool,
+ completion: @escaping (Result<[String: Any], Error>) -> Void
+ ) -> UIViewController? {
+ let authMode: AuthView.Mode
+ switch mode {
+ case "signIn":
+ authMode = .signIn
+ case "signUp":
+ authMode = .signUp
+ default:
+ authMode = .signInOrUp
+ }
+
+ let wrapper = ClerkAuthWrapperViewController(
+ mode: authMode,
+ dismissable: dismissable,
+ completion: completion
+ )
+ return wrapper
+ }
+
+ public func createUserProfileViewController(
+ dismissable: Bool,
+ completion: @escaping (Result<[String: Any], Error>) -> Void
+ ) -> UIViewController? {
+ let wrapper = ClerkProfileWrapperViewController(
+ dismissable: dismissable,
+ completion: completion
+ )
+ return wrapper
+ }
+
+ // MARK: - Inline View Creation
+
+ public func createAuthView(
+ mode: String,
+ dismissable: Bool,
+ onEvent: @escaping (String, [String: Any]) -> Void
+ ) -> UIViewController? {
+ let authMode: AuthView.Mode
+ switch mode {
+ case "signIn":
+ authMode = .signIn
+ case "signUp":
+ authMode = .signUp
+ default:
+ authMode = .signInOrUp
+ }
+
+ let hostingController = UIHostingController(
+ rootView: ClerkInlineAuthWrapperView(
+ mode: authMode,
+ dismissable: dismissable,
+ onEvent: onEvent
+ )
+ )
+ hostingController.view.backgroundColor = .clear
+ return hostingController
+ }
+
+ public func createUserProfileView(
+ dismissable: Bool,
+ onEvent: @escaping (String, [String: Any]) -> Void
+ ) -> UIViewController? {
+ let hostingController = UIHostingController(
+ rootView: ClerkInlineProfileWrapperView(
+ dismissable: dismissable,
+ onEvent: onEvent
+ )
+ )
+ hostingController.view.backgroundColor = .clear
+ return hostingController
+ }
+
+ @MainActor
+ public func getSession() async -> [String: Any]? {
+ guard let session = Clerk.shared.session else {
+ return nil
+ }
+
+ var result: [String: Any] = [
+ "sessionId": session.id,
+ "status": String(describing: session.status)
+ ]
+
+ // Include user details if available
+ let user = session.user ?? Clerk.shared.user
+
+ if let user = user {
+ var userDict: [String: Any] = [
+ "id": user.id,
+ "imageUrl": user.imageUrl
+ ]
+ if let firstName = user.firstName {
+ userDict["firstName"] = firstName
+ }
+ if let lastName = user.lastName {
+ userDict["lastName"] = lastName
+ }
+ if let primaryEmail = user.emailAddresses.first(where: { $0.id == user.primaryEmailAddressId }) {
+ userDict["primaryEmailAddress"] = primaryEmail.emailAddress
+ } else if let firstEmail = user.emailAddresses.first {
+ userDict["primaryEmailAddress"] = firstEmail.emailAddress
+ }
+ result["user"] = userDict
+ }
+
+ return result
+ }
+
+ @MainActor
+ public func signOut() async throws {
+ guard let sessionId = Clerk.shared.session?.id else { return }
+ try await Clerk.shared.auth.signOut(sessionId: sessionId)
+ }
+}
+
+// MARK: - Auth View Controller Wrapper
+
+class ClerkAuthWrapperViewController: UIHostingController {
+ private let completion: (Result<[String: Any], Error>) -> Void
+ private var authEventTask: Task?
+ private var completionCalled = false
+
+ init(mode: AuthView.Mode, dismissable: Bool, completion: @escaping (Result<[String: Any], Error>) -> Void) {
+ self.completion = completion
+ let view = ClerkAuthWrapperView(mode: mode, dismissable: dismissable)
+ super.init(rootView: view)
+ self.modalPresentationStyle = .fullScreen
+ subscribeToAuthEvents()
+ }
+
+ @MainActor required dynamic init?(coder aDecoder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ deinit {
+ authEventTask?.cancel()
+ }
+
+ override func viewDidDisappear(_ animated: Bool) {
+ super.viewDidDisappear(animated)
+ if isBeingDismissed {
+ completeOnce(.failure(NSError(domain: "ClerkAuth", code: 3, userInfo: [NSLocalizedDescriptionKey: "Auth modal was dismissed"])))
+ }
+ }
+
+ private func completeOnce(_ result: Result<[String: Any], Error>) {
+ guard !completionCalled else { return }
+ completionCalled = true
+ completion(result)
+ }
+
+ private func subscribeToAuthEvents() {
+ authEventTask = Task { @MainActor [weak self] in
+ for await event in Clerk.shared.auth.events {
+ guard let self = self, !self.completionCalled else { return }
+ switch event {
+ case .signInCompleted(let signIn):
+ if let sessionId = signIn.createdSessionId {
+ self.completeOnce(.success(["sessionId": sessionId, "type": "signIn"]))
+ self.dismiss(animated: true)
+ } else {
+ self.completeOnce(.failure(NSError(domain: "ClerkAuth", code: 1, userInfo: [NSLocalizedDescriptionKey: "Sign-in completed but no session ID was created"])))
+ self.dismiss(animated: true)
+ }
+ case .signUpCompleted(let signUp):
+ if let sessionId = signUp.createdSessionId {
+ self.completeOnce(.success(["sessionId": sessionId, "type": "signUp"]))
+ self.dismiss(animated: true)
+ } else {
+ self.completeOnce(.failure(NSError(domain: "ClerkAuth", code: 1, userInfo: [NSLocalizedDescriptionKey: "Sign-up completed but no session ID was created"])))
+ self.dismiss(animated: true)
+ }
+ default:
+ break
+ }
+ }
+ // Stream ended without an auth completion event
+ guard let self = self else { return }
+ self.completeOnce(.failure(NSError(domain: "ClerkAuth", code: 2, userInfo: [NSLocalizedDescriptionKey: "Auth event stream ended unexpectedly"])))
+ }
+ }
+}
+
+struct ClerkAuthWrapperView: View {
+ let mode: AuthView.Mode
+ let dismissable: Bool
+
+ var body: some View {
+ AuthView(mode: mode, isDismissable: dismissable)
+ .environment(Clerk.shared)
+ }
+}
+
+// MARK: - Profile View Controller Wrapper
+
+class ClerkProfileWrapperViewController: UIHostingController {
+ private let completion: (Result<[String: Any], Error>) -> Void
+ private var authEventTask: Task?
+ private var completionCalled = false
+
+ init(dismissable: Bool, completion: @escaping (Result<[String: Any], Error>) -> Void) {
+ self.completion = completion
+ let view = ClerkProfileWrapperView(dismissable: dismissable)
+ super.init(rootView: view)
+ self.modalPresentationStyle = .fullScreen
+ subscribeToAuthEvents()
+ }
+
+ @MainActor required dynamic init?(coder aDecoder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ deinit {
+ authEventTask?.cancel()
+ }
+
+ override func viewDidDisappear(_ animated: Bool) {
+ super.viewDidDisappear(animated)
+ if isBeingDismissed {
+ completeOnce(.failure(NSError(domain: "ClerkProfile", code: 3, userInfo: [NSLocalizedDescriptionKey: "Profile modal was dismissed"])))
+ }
+ }
+
+ private func completeOnce(_ result: Result<[String: Any], Error>) {
+ guard !completionCalled else { return }
+ completionCalled = true
+ completion(result)
+ }
+
+ private func subscribeToAuthEvents() {
+ authEventTask = Task { @MainActor [weak self] in
+ for await event in Clerk.shared.auth.events {
+ guard let self = self, !self.completionCalled else { return }
+ switch event {
+ case .signedOut(let session):
+ self.completeOnce(.success(["sessionId": session.id]))
+ self.dismiss(animated: true)
+ default:
+ break
+ }
+ }
+ // Stream ended without a sign-out event
+ guard let self = self else { return }
+ self.completeOnce(.failure(NSError(domain: "ClerkProfile", code: 2, userInfo: [NSLocalizedDescriptionKey: "Profile event stream ended unexpectedly"])))
+ }
+ }
+}
+
+struct ClerkProfileWrapperView: View {
+ let dismissable: Bool
+
+ var body: some View {
+ UserProfileView(isDismissable: dismissable)
+ .environment(Clerk.shared)
+ }
+}
+
+// MARK: - Inline Auth View Wrapper (for embedded rendering)
+
+struct ClerkInlineAuthWrapperView: View {
+ let mode: AuthView.Mode
+ let dismissable: Bool
+ let onEvent: (String, [String: Any]) -> Void
+
+ var body: some View {
+ AuthView(mode: mode, isDismissable: dismissable)
+ .environment(Clerk.shared)
+ .task {
+ for await event in Clerk.shared.auth.events {
+ switch event {
+ case .signInCompleted(let signIn):
+ if let sessionId = signIn.createdSessionId {
+ onEvent("signInCompleted", ["sessionId": sessionId, "type": "signIn"])
+ }
+ case .signUpCompleted(let signUp):
+ if let sessionId = signUp.createdSessionId {
+ onEvent("signUpCompleted", ["sessionId": sessionId, "type": "signUp"])
+ }
+ default:
+ break
+ }
+ }
+ }
+ }
+}
+
+// MARK: - Inline Profile View Wrapper (for embedded rendering)
+
+struct ClerkInlineProfileWrapperView: View {
+ let dismissable: Bool
+ let onEvent: (String, [String: Any]) -> Void
+
+ var body: some View {
+ UserProfileView(isDismissable: dismissable)
+ .environment(Clerk.shared)
+ .task {
+ for await event in Clerk.shared.auth.events {
+ switch event {
+ case .signedOut(let session):
+ onEvent("signedOut", ["sessionId": session.id])
+ default:
+ break
+ }
+ }
+ }
+ }
+}
+
diff --git a/packages/expo/ios/templates/ClerkViewFactory.swift b/packages/expo/ios/templates/ClerkViewFactory.swift
new file mode 100644
index 00000000000..ccd09e6b1d9
--- /dev/null
+++ b/packages/expo/ios/templates/ClerkViewFactory.swift
@@ -0,0 +1,439 @@
+// ClerkViewFactory - Provides Clerk view controllers to the ClerkExpo module
+// This file is injected into the app target by the config plugin.
+// It uses `import ClerkKit` (SPM) which is only accessible from the app target.
+
+import UIKit
+import SwiftUI
+import Security
+import ClerkKit
+import ClerkKitUI
+import ClerkExpo // Import the pod to access ClerkViewFactoryProtocol
+
+// MARK: - View Factory Implementation
+
+public class ClerkViewFactory: ClerkViewFactoryProtocol {
+ public static let shared = ClerkViewFactory()
+
+ private init() {}
+
+ // Register this factory with the ClerkExpo module
+ public static func register() {
+ clerkViewFactory = shared
+ }
+
+ @MainActor
+ public func configure(publishableKey: String, bearerToken: String? = nil) async throws {
+ // Sync JS SDK's client token to native keychain so both SDKs share the same client.
+ // This handles the case where the user signed in via JS SDK but the native SDK
+ // has no device token (e.g., after app reinstall or first launch).
+ if let token = bearerToken, !token.isEmpty {
+ Self.writeNativeDeviceTokenIfNeeded(token)
+ } else {
+ Self.syncJSTokenToNativeKeychainIfNeeded()
+ }
+
+ Clerk.configure(publishableKey: publishableKey)
+
+ // Wait for Clerk to finish loading (cached data + API refresh).
+ // The static configure() fires off async refreshes; poll until loaded.
+ for _ in 0..<30 { // Wait up to 3 seconds
+ if Clerk.shared.isLoaded && Clerk.shared.session != nil {
+ return
+ }
+ try? await Task.sleep(nanoseconds: 100_000_000) // 100ms
+ }
+ }
+
+ /// Copies the JS SDK's client JWT from expo-secure-store to the native SDK's
+ /// keychain entry, but only if the native SDK doesn't already have a device token.
+ /// Both expo-secure-store and the native Clerk SDK use the iOS Keychain with the
+ /// bundle identifier as the service name, making cross-SDK token sharing possible.
+ private static func syncJSTokenToNativeKeychainIfNeeded() {
+ guard let service = Bundle.main.bundleIdentifier, !service.isEmpty else { return }
+
+ let jsTokenKey = "__clerk_client_jwt"
+ let nativeTokenKey = "clerkDeviceToken"
+
+ // Check if native SDK already has a device token — don't overwrite
+ let checkQuery: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ kSecAttrAccount as String: nativeTokenKey,
+ kSecReturnData as String: false,
+ kSecMatchLimit as String: kSecMatchLimitOne,
+ ]
+ if SecItemCopyMatching(checkQuery as CFDictionary, nil) == errSecSuccess {
+ return // Native token exists, don't overwrite
+ }
+
+ // Read JS SDK's client JWT from keychain (stored by expo-secure-store)
+ var result: CFTypeRef?
+ let readQuery: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ kSecAttrAccount as String: jsTokenKey,
+ kSecReturnData as String: true,
+ kSecMatchLimit as String: kSecMatchLimitOne,
+ ]
+ guard SecItemCopyMatching(readQuery as CFDictionary, &result) == errSecSuccess,
+ let data = result as? Data,
+ let jsToken = String(data: data, encoding: .utf8),
+ !jsToken.isEmpty else {
+ return // No JS token available
+ }
+
+ // Write JS token as native device token
+ guard let tokenData = jsToken.data(using: .utf8) else { return }
+ let writeQuery: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ kSecAttrAccount as String: nativeTokenKey,
+ kSecValueData as String: tokenData,
+ kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
+ ]
+ SecItemAdd(writeQuery as CFDictionary, nil)
+ }
+
+ /// Writes the provided bearer token as the native SDK's device token,
+ /// but only if the native SDK doesn't already have one.
+ private static func writeNativeDeviceTokenIfNeeded(_ token: String) {
+ guard let service = Bundle.main.bundleIdentifier, !service.isEmpty else { return }
+
+ let nativeTokenKey = "clerkDeviceToken"
+
+ // Check if native SDK already has a device token — don't overwrite
+ let checkQuery: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ kSecAttrAccount as String: nativeTokenKey,
+ kSecReturnData as String: false,
+ kSecMatchLimit as String: kSecMatchLimitOne,
+ ]
+ if SecItemCopyMatching(checkQuery as CFDictionary, nil) == errSecSuccess {
+ return
+ }
+
+ // Write the provided token as native device token
+ guard let tokenData = token.data(using: .utf8) else { return }
+ let writeQuery: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ kSecAttrAccount as String: nativeTokenKey,
+ kSecValueData as String: tokenData,
+ kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
+ ]
+ SecItemAdd(writeQuery as CFDictionary, nil)
+ }
+
+ public func createAuthViewController(
+ mode: String,
+ dismissable: Bool,
+ completion: @escaping (Result<[String: Any], Error>) -> Void
+ ) -> UIViewController? {
+ let authMode: AuthView.Mode
+ switch mode {
+ case "signIn":
+ authMode = .signIn
+ case "signUp":
+ authMode = .signUp
+ default:
+ authMode = .signInOrUp
+ }
+
+ let wrapper = ClerkAuthWrapperViewController(
+ mode: authMode,
+ dismissable: dismissable,
+ completion: completion
+ )
+ return wrapper
+ }
+
+ public func createUserProfileViewController(
+ dismissable: Bool,
+ completion: @escaping (Result<[String: Any], Error>) -> Void
+ ) -> UIViewController? {
+ let wrapper = ClerkProfileWrapperViewController(
+ dismissable: dismissable,
+ completion: completion
+ )
+ return wrapper
+ }
+
+ // MARK: - Inline View Creation
+
+ public func createAuthView(
+ mode: String,
+ dismissable: Bool,
+ onEvent: @escaping (String, [String: Any]) -> Void
+ ) -> UIViewController? {
+ let authMode: AuthView.Mode
+ switch mode {
+ case "signIn":
+ authMode = .signIn
+ case "signUp":
+ authMode = .signUp
+ default:
+ authMode = .signInOrUp
+ }
+
+ let hostingController = UIHostingController(
+ rootView: ClerkInlineAuthWrapperView(
+ mode: authMode,
+ dismissable: dismissable,
+ onEvent: onEvent
+ )
+ )
+ hostingController.view.backgroundColor = .clear
+ return hostingController
+ }
+
+ public func createUserProfileView(
+ dismissable: Bool,
+ onEvent: @escaping (String, [String: Any]) -> Void
+ ) -> UIViewController? {
+ let hostingController = UIHostingController(
+ rootView: ClerkInlineProfileWrapperView(
+ dismissable: dismissable,
+ onEvent: onEvent
+ )
+ )
+ hostingController.view.backgroundColor = .clear
+ return hostingController
+ }
+
+ @MainActor
+ public func getSession() async -> [String: Any]? {
+ guard let session = Clerk.shared.session else {
+ return nil
+ }
+
+ var result: [String: Any] = [
+ "sessionId": session.id,
+ "status": String(describing: session.status)
+ ]
+
+ // Include user details if available
+ let user = session.user ?? Clerk.shared.user
+
+ if let user = user {
+ var userDict: [String: Any] = [
+ "id": user.id,
+ "imageUrl": user.imageUrl
+ ]
+ if let firstName = user.firstName {
+ userDict["firstName"] = firstName
+ }
+ if let lastName = user.lastName {
+ userDict["lastName"] = lastName
+ }
+ if let primaryEmail = user.emailAddresses.first(where: { $0.id == user.primaryEmailAddressId }) {
+ userDict["primaryEmailAddress"] = primaryEmail.emailAddress
+ } else if let firstEmail = user.emailAddresses.first {
+ userDict["primaryEmailAddress"] = firstEmail.emailAddress
+ }
+ result["user"] = userDict
+ }
+
+ return result
+ }
+
+ @MainActor
+ public func signOut() async throws {
+ guard let sessionId = Clerk.shared.session?.id else { return }
+ try await Clerk.shared.auth.signOut(sessionId: sessionId)
+ }
+}
+
+// MARK: - Auth View Controller Wrapper
+
+class ClerkAuthWrapperViewController: UIHostingController {
+ private let completion: (Result<[String: Any], Error>) -> Void
+ private var authEventTask: Task?
+ private var completionCalled = false
+
+ init(mode: AuthView.Mode, dismissable: Bool, completion: @escaping (Result<[String: Any], Error>) -> Void) {
+ self.completion = completion
+ let view = ClerkAuthWrapperView(mode: mode, dismissable: dismissable)
+ super.init(rootView: view)
+ self.modalPresentationStyle = .fullScreen
+ subscribeToAuthEvents()
+ }
+
+ @MainActor required dynamic init?(coder aDecoder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ deinit {
+ authEventTask?.cancel()
+ }
+
+ override func viewDidDisappear(_ animated: Bool) {
+ super.viewDidDisappear(animated)
+ if isBeingDismissed {
+ completeOnce(.failure(NSError(domain: "ClerkAuth", code: 3, userInfo: [NSLocalizedDescriptionKey: "Auth modal was dismissed"])))
+ }
+ }
+
+ private func completeOnce(_ result: Result<[String: Any], Error>) {
+ guard !completionCalled else { return }
+ completionCalled = true
+ completion(result)
+ }
+
+ private func subscribeToAuthEvents() {
+ authEventTask = Task { @MainActor [weak self] in
+ for await event in Clerk.shared.auth.events {
+ guard let self = self, !self.completionCalled else { return }
+ switch event {
+ case .signInCompleted(let signIn):
+ if let sessionId = signIn.createdSessionId {
+ self.completeOnce(.success(["sessionId": sessionId, "type": "signIn"]))
+ self.dismiss(animated: true)
+ } else {
+ self.completeOnce(.failure(NSError(domain: "ClerkAuth", code: 1, userInfo: [NSLocalizedDescriptionKey: "Sign-in completed but no session ID was created"])))
+ self.dismiss(animated: true)
+ }
+ case .signUpCompleted(let signUp):
+ if let sessionId = signUp.createdSessionId {
+ self.completeOnce(.success(["sessionId": sessionId, "type": "signUp"]))
+ self.dismiss(animated: true)
+ } else {
+ self.completeOnce(.failure(NSError(domain: "ClerkAuth", code: 1, userInfo: [NSLocalizedDescriptionKey: "Sign-up completed but no session ID was created"])))
+ self.dismiss(animated: true)
+ }
+ default:
+ break
+ }
+ }
+ // Stream ended without an auth completion event
+ guard let self = self else { return }
+ self.completeOnce(.failure(NSError(domain: "ClerkAuth", code: 2, userInfo: [NSLocalizedDescriptionKey: "Auth event stream ended unexpectedly"])))
+ }
+ }
+}
+
+struct ClerkAuthWrapperView: View {
+ let mode: AuthView.Mode
+ let dismissable: Bool
+
+ var body: some View {
+ AuthView(mode: mode, isDismissable: dismissable)
+ .environment(Clerk.shared)
+ }
+}
+
+// MARK: - Profile View Controller Wrapper
+
+class ClerkProfileWrapperViewController: UIHostingController {
+ private let completion: (Result<[String: Any], Error>) -> Void
+ private var authEventTask: Task?
+ private var completionCalled = false
+
+ init(dismissable: Bool, completion: @escaping (Result<[String: Any], Error>) -> Void) {
+ self.completion = completion
+ let view = ClerkProfileWrapperView(dismissable: dismissable)
+ super.init(rootView: view)
+ self.modalPresentationStyle = .fullScreen
+ subscribeToAuthEvents()
+ }
+
+ @MainActor required dynamic init?(coder aDecoder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ deinit {
+ authEventTask?.cancel()
+ }
+
+ override func viewDidDisappear(_ animated: Bool) {
+ super.viewDidDisappear(animated)
+ if isBeingDismissed {
+ completeOnce(.failure(NSError(domain: "ClerkProfile", code: 3, userInfo: [NSLocalizedDescriptionKey: "Profile modal was dismissed"])))
+ }
+ }
+
+ private func completeOnce(_ result: Result<[String: Any], Error>) {
+ guard !completionCalled else { return }
+ completionCalled = true
+ completion(result)
+ }
+
+ private func subscribeToAuthEvents() {
+ authEventTask = Task { @MainActor [weak self] in
+ for await event in Clerk.shared.auth.events {
+ guard let self = self, !self.completionCalled else { return }
+ switch event {
+ case .signedOut(let session):
+ self.completeOnce(.success(["sessionId": session.id]))
+ self.dismiss(animated: true)
+ default:
+ break
+ }
+ }
+ // Stream ended without a sign-out event
+ guard let self = self else { return }
+ self.completeOnce(.failure(NSError(domain: "ClerkProfile", code: 2, userInfo: [NSLocalizedDescriptionKey: "Profile event stream ended unexpectedly"])))
+ }
+ }
+}
+
+struct ClerkProfileWrapperView: View {
+ let dismissable: Bool
+
+ var body: some View {
+ UserProfileView(isDismissable: dismissable)
+ .environment(Clerk.shared)
+ }
+}
+
+// MARK: - Inline Auth View Wrapper (for embedded rendering)
+
+struct ClerkInlineAuthWrapperView: View {
+ let mode: AuthView.Mode
+ let dismissable: Bool
+ let onEvent: (String, [String: Any]) -> Void
+
+ var body: some View {
+ AuthView(mode: mode, isDismissable: dismissable)
+ .environment(Clerk.shared)
+ .task {
+ for await event in Clerk.shared.auth.events {
+ switch event {
+ case .signInCompleted(let signIn):
+ if let sessionId = signIn.createdSessionId {
+ onEvent("signInCompleted", ["sessionId": sessionId, "type": "signIn"])
+ }
+ case .signUpCompleted(let signUp):
+ if let sessionId = signUp.createdSessionId {
+ onEvent("signUpCompleted", ["sessionId": sessionId, "type": "signUp"])
+ }
+ default:
+ break
+ }
+ }
+ }
+ }
+}
+
+// MARK: - Inline Profile View Wrapper (for embedded rendering)
+
+struct ClerkInlineProfileWrapperView: View {
+ let dismissable: Bool
+ let onEvent: (String, [String: Any]) -> Void
+
+ var body: some View {
+ UserProfileView(isDismissable: dismissable)
+ .environment(Clerk.shared)
+ .task {
+ for await event in Clerk.shared.auth.events {
+ switch event {
+ case .signedOut(let session):
+ onEvent("signedOut", ["sessionId": session.id])
+ default:
+ break
+ }
+ }
+ }
+ }
+}
+
diff --git a/packages/expo/native/package.json b/packages/expo/native/package.json
new file mode 100644
index 00000000000..6ae24b71af4
--- /dev/null
+++ b/packages/expo/native/package.json
@@ -0,0 +1,4 @@
+{
+ "main": "../dist/native/index.js",
+ "types": "../dist/native/index.d.ts"
+}
diff --git a/packages/expo/package.json b/packages/expo/package.json
index 72ec50aa25e..c7be4789c10 100644
--- a/packages/expo/package.json
+++ b/packages/expo/package.json
@@ -1,6 +1,6 @@
{
"name": "@clerk/expo",
- "version": "2.19.10",
+ "version": "2.19.24",
"description": "Clerk React Native/Expo library",
"keywords": [
"react",
@@ -28,6 +28,11 @@
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
+ "./app.plugin.js": "./app.plugin.js",
+ "./native": {
+ "types": "./dist/native/index.d.ts",
+ "default": "./dist/native/index.js"
+ },
"./web": {
"types": "./dist/web/index.d.ts",
"default": "./dist/web/index.js"
@@ -69,7 +74,7 @@
"default": "./dist/legacy.js"
},
"./types": "./dist/types/index.d.ts",
- "./app.plugin.js": "./app.plugin.js"
+ "./package.json": "./package.json"
},
"main": "./dist/index.js",
"source": "./src/index.ts",
@@ -78,10 +83,20 @@
"dist",
"android",
"ios",
+ "native",
+ "web",
+ "local-credentials",
+ "passkeys",
+ "secure-store",
+ "resource-cache",
+ "token-cache",
"google",
"apple",
+ "src/specs",
"expo-module.config.json",
- "app.plugin.js"
+ "react-native.config.js",
+ "app.plugin.js",
+ "app.plugin.d.ts"
],
"scripts": {
"build": "tsup",
@@ -107,12 +122,12 @@
"@clerk/expo-passkeys": "workspace:*",
"@expo/config-plugins": "^54.0.4",
"@types/base-64": "^1.0.2",
+ "esbuild": "^0.19.0",
"expo-apple-authentication": "^7.2.4",
"expo-auth-session": "^5.4.0",
"expo-constants": "^18.0.0",
"expo-crypto": "^15.0.7",
"expo-local-authentication": "^13.8.0",
- "expo-modules-core": "^3.0.0",
"expo-secure-store": "^12.8.1",
"expo-web-browser": "^12.8.2",
"react-native": "^0.81.4"
@@ -125,7 +140,6 @@
"expo-constants": ">=12",
"expo-crypto": ">=12",
"expo-local-authentication": ">=13.5.0",
- "expo-modules-core": ">=3.0.0",
"expo-secure-store": ">=12.4.0",
"expo-web-browser": ">=12.5.0",
"react": "^18.0.0 || ^19.0.0",
@@ -157,5 +171,13 @@
},
"publishConfig": {
"access": "public"
+ },
+ "codegenConfig": {
+ "name": "ClerkExpoSpec",
+ "type": "all",
+ "jsSrcsDir": "src/specs",
+ "android": {
+ "javaPackageName": "expo.modules.clerk"
+ }
}
}
diff --git a/packages/expo/react-native.config.js b/packages/expo/react-native.config.js
new file mode 100644
index 00000000000..84cec6c149d
--- /dev/null
+++ b/packages/expo/react-native.config.js
@@ -0,0 +1,11 @@
+module.exports = {
+ dependency: {
+ platforms: {
+ ios: {},
+ android: {
+ packageImportPath: 'import expo.modules.clerk.ClerkPackage;',
+ packageInstance: 'new ClerkPackage()',
+ },
+ },
+ },
+};
diff --git a/packages/expo/src/components/controlComponents.tsx b/packages/expo/src/components/controlComponents.tsx
index 5ef4f45e015..79f3d97177f 100644
--- a/packages/expo/src/components/controlComponents.tsx
+++ b/packages/expo/src/components/controlComponents.tsx
@@ -1 +1,22 @@
-export { ClerkLoaded, ClerkLoading, Show } from '@clerk/react';
+// Re-export control components from @clerk/react
+// These provide conditional rendering based on auth state
+export { ClerkLoaded, ClerkLoading, RedirectToTasks, Show } from '@clerk/react';
+
+import { Show } from '@clerk/react';
+import type { PropsWithChildren, ReactNode } from 'react';
+
+/**
+ * Render children only when the user is signed in.
+ * A convenience wrapper around ``.
+ */
+export function SignedIn({ children }: PropsWithChildren): ReactNode {
+ return {children};
+}
+
+/**
+ * Render children only when the user is signed out.
+ * A convenience wrapper around ``.
+ */
+export function SignedOut({ children }: PropsWithChildren): ReactNode {
+ return {children};
+}
diff --git a/packages/expo/src/google-one-tap/ClerkGoogleOneTapSignIn.ts b/packages/expo/src/google-one-tap/ClerkGoogleOneTapSignIn.ts
index 5dc89a69982..14fc502e949 100644
--- a/packages/expo/src/google-one-tap/ClerkGoogleOneTapSignIn.ts
+++ b/packages/expo/src/google-one-tap/ClerkGoogleOneTapSignIn.ts
@@ -1,5 +1,4 @@
-import { requireNativeModule } from 'expo-modules-core';
-
+import NativeClerkGoogleSignIn from '../specs/NativeClerkGoogleSignIn';
import type {
CancelledResponse,
ConfigureParams,
@@ -11,21 +10,12 @@ import type {
SignInParams,
} from './types';
-// Type for the native module methods
-interface ClerkGoogleSignInNativeModule {
- configure(params: ConfigureParams): void;
- signIn(params: SignInParams): Promise;
- createAccount(params: CreateAccountParams): Promise;
- presentExplicitSignIn(params: ExplicitSignInParams): Promise;
- signOut(): Promise;
-}
-
// Lazy-load the native module to avoid crashes when not available
-let _nativeModule: ClerkGoogleSignInNativeModule | null = null;
+let _nativeModule: typeof NativeClerkGoogleSignIn | null = null;
-function getNativeModule(): ClerkGoogleSignInNativeModule {
+function getNativeModule(): typeof NativeClerkGoogleSignIn {
if (!_nativeModule) {
- _nativeModule = requireNativeModule('ClerkGoogleSignIn');
+ _nativeModule = NativeClerkGoogleSignIn;
}
return _nativeModule;
}
@@ -84,7 +74,7 @@ export const ClerkGoogleOneTapSignIn = {
* @param params.autoSelectEnabled - Auto-select for single credential (default: false)
*/
configure(params: ConfigureParams): void {
- getNativeModule().configure(params);
+ getNativeModule().configure(params as any);
},
/**
@@ -101,7 +91,7 @@ export const ClerkGoogleOneTapSignIn = {
*/
async signIn(params?: SignInParams): Promise {
try {
- return await getNativeModule().signIn(params ?? {});
+ return (await getNativeModule().signIn((params as any) ?? null)) as unknown as OneTapResponse;
} catch (error) {
if (isErrorWithCode(error)) {
if (error.code === 'SIGN_IN_CANCELLED') {
@@ -128,7 +118,7 @@ export const ClerkGoogleOneTapSignIn = {
*/
async createAccount(params?: CreateAccountParams): Promise {
try {
- return await getNativeModule().createAccount(params ?? {});
+ return (await getNativeModule().createAccount((params as any) ?? null)) as unknown as OneTapResponse;
} catch (error) {
if (isErrorWithCode(error)) {
if (error.code === 'SIGN_IN_CANCELLED') {
@@ -155,7 +145,7 @@ export const ClerkGoogleOneTapSignIn = {
*/
async presentExplicitSignIn(params?: ExplicitSignInParams): Promise {
try {
- return await getNativeModule().presentExplicitSignIn(params ?? {});
+ return (await getNativeModule().presentExplicitSignIn((params as any) ?? null)) as unknown as OneTapResponse;
} catch (error) {
if (isErrorWithCode(error)) {
if (error.code === 'SIGN_IN_CANCELLED') {
diff --git a/packages/expo/src/hooks/__tests__/useSignInWithGoogle.test.ts b/packages/expo/src/hooks/__tests__/useSignInWithGoogle.test.ts
index 08e2e2a92b8..c297713a801 100644
--- a/packages/expo/src/hooks/__tests__/useSignInWithGoogle.test.ts
+++ b/packages/expo/src/hooks/__tests__/useSignInWithGoogle.test.ts
@@ -46,10 +46,26 @@ vi.mock('react-native', () => {
};
});
-vi.mock('expo-modules-core', () => {
+vi.mock('../../specs/NativeClerkModule', () => {
return {
- EventEmitter: vi.fn(),
- requireNativeModule: vi.fn(),
+ default: {
+ configure: vi.fn(),
+ getSession: vi.fn(),
+ getClientToken: vi.fn(),
+ signOut: vi.fn(),
+ },
+ };
+});
+
+vi.mock('../../specs/NativeClerkGoogleSignIn', () => {
+ return {
+ default: {
+ configure: vi.fn(),
+ signIn: vi.fn(),
+ createAccount: vi.fn(),
+ presentExplicitSignIn: vi.fn(),
+ signOut: vi.fn(),
+ },
};
});
diff --git a/packages/expo/src/hooks/index.ts b/packages/expo/src/hooks/index.ts
index 0b64ce0c5be..8f9e13694ea 100644
--- a/packages/expo/src/hooks/index.ts
+++ b/packages/expo/src/hooks/index.ts
@@ -1,3 +1,4 @@
+// Re-export hooks that don't need type overrides
export {
useClerk,
useEmailLink,
@@ -15,3 +16,5 @@ export {
export * from './useSSO';
export * from './useOAuth';
export * from './useAuth';
+export * from './useNativeSession';
+export * from './useNativeAuthEvents';
diff --git a/packages/expo/src/hooks/useNativeAuthEvents.ts b/packages/expo/src/hooks/useNativeAuthEvents.ts
new file mode 100644
index 00000000000..8ef9feeb83f
--- /dev/null
+++ b/packages/expo/src/hooks/useNativeAuthEvents.ts
@@ -0,0 +1,107 @@
+import { useEffect, useState } from 'react';
+import { NativeEventEmitter, Platform } from 'react-native';
+
+import NativeClerkModule from '../specs/NativeClerkModule';
+
+// Check if native module is supported on this platform
+const isNativeSupported = Platform.OS === 'ios' || Platform.OS === 'android';
+
+// Get the native module for event listening
+let ClerkExpo: typeof NativeClerkModule | null = null;
+
+if (isNativeSupported) {
+ try {
+ ClerkExpo = NativeClerkModule;
+ } catch {
+ // Native module not available - plugin not configured
+ ClerkExpo = null;
+ }
+}
+
+/**
+ * Auth state change event from native SDK
+ */
+export interface NativeAuthStateEvent {
+ type: 'signedIn' | 'signedOut';
+ sessionId: string | null;
+}
+
+export interface UseNativeAuthEventsReturn {
+ /**
+ * The latest auth state event from the native SDK.
+ * Will be null until an event is received.
+ */
+ nativeAuthState: NativeAuthStateEvent | null;
+
+ /**
+ * Whether native event listening is supported (plugin installed)
+ */
+ isSupported: boolean;
+}
+
+/**
+ * Hook to listen for auth state change events from the native Clerk SDK.
+ *
+ * This provides reactive updates when the user signs in or out via native UI.
+ * Events are emitted by the native module when:
+ * - User completes sign-in (signInCompleted event from clerk-ios/clerk-android)
+ * - User completes sign-up (signUpCompleted event from clerk-ios/clerk-android)
+ * - User signs out (signedOut event from clerk-ios/clerk-android)
+ *
+ * @example
+ * ```tsx
+ * import { useNativeAuthEvents } from '@clerk/expo';
+ *
+ * function MyComponent() {
+ * const { nativeAuthState, isSupported } = useNativeAuthEvents();
+ *
+ * useEffect(() => {
+ * if (nativeAuthState?.type === 'signedIn') {
+ * console.log('User signed in via native UI');
+ * } else if (nativeAuthState?.type === 'signedOut') {
+ * console.log('User signed out via native UI');
+ * }
+ * }, [nativeAuthState]);
+ *
+ * return ;
+ * }
+ * ```
+ */
+export function useNativeAuthEvents(): UseNativeAuthEventsReturn {
+ const [nativeAuthState, setNativeAuthState] = useState(null);
+
+ useEffect(() => {
+ console.log(`[useNativeAuthEvents] INIT: isNativeSupported=${isNativeSupported}, ClerkExpo=${!!ClerkExpo}`);
+
+ if (!isNativeSupported || !ClerkExpo) {
+ console.log(`[useNativeAuthEvents] SKIP: Native not supported or ClerkExpo not available`);
+ return;
+ }
+
+ let subscription: { remove: () => void } | null = null;
+
+ try {
+ console.log(`[useNativeAuthEvents] SETUP: Creating NativeEventEmitter for ClerkExpo`);
+ const eventEmitter = new NativeEventEmitter(ClerkExpo as any);
+
+ console.log(`[useNativeAuthEvents] LISTEN: Adding listener for 'onAuthStateChange' events`);
+ subscription = eventEmitter.addListener('onAuthStateChange', (event: NativeAuthStateEvent) => {
+ console.log('[useNativeAuthEvents] EVENT_RECEIVED:', JSON.stringify(event));
+ setNativeAuthState(event);
+ });
+ console.log(`[useNativeAuthEvents] LISTEN: Listener added successfully`);
+ } catch (error) {
+ console.log('[useNativeAuthEvents] ERROR: Could not set up event listener:', error);
+ }
+
+ return () => {
+ console.log(`[useNativeAuthEvents] CLEANUP: Removing event listener`);
+ subscription?.remove();
+ };
+ }, []);
+
+ return {
+ nativeAuthState,
+ isSupported: isNativeSupported && !!ClerkExpo,
+ };
+}
diff --git a/packages/expo/src/hooks/useNativeSession.ts b/packages/expo/src/hooks/useNativeSession.ts
new file mode 100644
index 00000000000..103037b6f1f
--- /dev/null
+++ b/packages/expo/src/hooks/useNativeSession.ts
@@ -0,0 +1,141 @@
+import { useCallback, useEffect, useState } from 'react';
+import { Platform } from 'react-native';
+
+import NativeClerkModule from '../specs/NativeClerkModule';
+
+// Check if native module is supported on this platform
+const isNativeSupported = Platform.OS === 'ios' || Platform.OS === 'android';
+
+// Native session data structure (normalized)
+interface NativeSessionData {
+ sessionId?: string;
+ user?: {
+ id: string;
+ firstName?: string;
+ lastName?: string;
+ imageUrl?: string;
+ primaryEmailAddress?: string;
+ };
+}
+
+// Raw result from the native module (may vary by platform)
+interface NativeSessionRawResult {
+ sessionId?: string;
+ session?: { id: string };
+ user?: NativeSessionData['user'];
+}
+
+// Safely get the native module
+let ClerkExpo: typeof NativeClerkModule | null = null;
+
+if (isNativeSupported) {
+ try {
+ ClerkExpo = NativeClerkModule;
+ } catch {
+ // Native module not available - this is expected when expo plugin is not installed
+ }
+}
+
+export interface UseNativeSessionReturn {
+ /**
+ * Whether the native module is available (expo plugin installed)
+ */
+ isAvailable: boolean;
+
+ /**
+ * Whether the native session check is still loading
+ */
+ isLoading: boolean;
+
+ /**
+ * Whether there is an active native session
+ */
+ isSignedIn: boolean;
+
+ /**
+ * The native session ID, if available
+ */
+ sessionId: string | null;
+
+ /**
+ * The native user data, if available
+ */
+ user: NativeSessionData['user'] | null;
+
+ /**
+ * Refresh the native session state
+ */
+ refresh: () => Promise;
+}
+
+/**
+ * Hook to access native SDK session state.
+ *
+ * This hook is only useful when the @clerk/expo native plugin is installed.
+ * Without the plugin, `isAvailable` will be false and session will always be null.
+ *
+ * @example
+ * ```tsx
+ * import { useNativeSession } from '@clerk/expo';
+ *
+ * function MyComponent() {
+ * const { isAvailable, isLoading, isSignedIn, user } = useNativeSession();
+ *
+ * if (!isAvailable) {
+ * // Native plugin not installed, use regular useAuth() instead
+ * return ;
+ * }
+ *
+ * if (isLoading) {
+ * return ;
+ * }
+ *
+ * if (isSignedIn) {
+ * return Welcome {user?.firstName}!;
+ * }
+ *
+ * return ;
+ * }
+ * ```
+ */
+export function useNativeSession(): UseNativeSessionReturn {
+ const [isLoading, setIsLoading] = useState(isNativeSupported && !!ClerkExpo);
+ const [sessionId, setSessionId] = useState(null);
+ const [user, setUser] = useState(null);
+
+ const refresh = useCallback(async () => {
+ if (!isNativeSupported || !ClerkExpo?.getSession) {
+ setIsLoading(false);
+ return;
+ }
+
+ try {
+ setIsLoading(true);
+ const result = (await ClerkExpo.getSession()) as NativeSessionRawResult | null;
+ // Normalize: iOS returns { sessionId }, Android returns { session: { id } }
+ const id = result?.sessionId ?? result?.session?.id ?? null;
+ setSessionId(id);
+ setUser(result?.user ?? null);
+ } catch (error) {
+ console.log('[useNativeSession] Error fetching native session:', error);
+ setSessionId(null);
+ setUser(null);
+ } finally {
+ setIsLoading(false);
+ }
+ }, []);
+
+ // Check native session on mount
+ useEffect(() => {
+ refresh();
+ }, [refresh]);
+
+ return {
+ isAvailable: isNativeSupported && !!ClerkExpo,
+ isLoading,
+ isSignedIn: !!sessionId,
+ sessionId,
+ user,
+ refresh,
+ };
+}
diff --git a/packages/expo/src/native/AuthView.tsx b/packages/expo/src/native/AuthView.tsx
new file mode 100644
index 00000000000..23cdfd9af73
--- /dev/null
+++ b/packages/expo/src/native/AuthView.tsx
@@ -0,0 +1,370 @@
+import { useAuth } from '@clerk/react';
+import * as SecureStore from 'expo-secure-store';
+import { useCallback, useEffect, useRef } from 'react';
+import { Platform, StyleSheet, Text, View } from 'react-native';
+
+import { getClerkInstance } from '../provider/singleton';
+import NativeClerkAuthView from '../specs/NativeClerkAuthView';
+import NativeClerkModule from '../specs/NativeClerkModule';
+import type { AuthViewProps } from './AuthView.types';
+
+// Token cache key used by the Clerk JS SDK (must match createClerkInstance.ts)
+const CLERK_CLIENT_JWT_KEY = '__clerk_client_jwt';
+
+// Check if native module is supported on this platform
+const isNativeSupported = Platform.OS === 'ios' || Platform.OS === 'android';
+
+// Safely get the native module
+let ClerkExpo: typeof NativeClerkModule | null = null;
+if (isNativeSupported) {
+ try {
+ ClerkExpo = NativeClerkModule;
+ } catch {
+ ClerkExpo = null;
+ }
+}
+
+async function syncNativeSession(sessionId: string): Promise {
+ // Copy the native client's bearer token to the JS SDK's token cache
+ if (ClerkExpo?.getClientToken) {
+ const nativeClientToken = await ClerkExpo.getClientToken();
+ if (nativeClientToken) {
+ await SecureStore.setItemAsync(CLERK_CLIENT_JWT_KEY, nativeClientToken, {
+ keychainAccessible: SecureStore.AFTER_FIRST_UNLOCK,
+ });
+ }
+ }
+
+ const clerkInstance = getClerkInstance();
+ if (!clerkInstance) {
+ throw new Error('[AuthView] Clerk instance not available');
+ }
+
+ // Reload resources using the native client's token
+ const clerkRecord = clerkInstance as unknown as Record;
+ if (typeof clerkRecord.__internal_reloadInitialResources === 'function') {
+ await (clerkRecord.__internal_reloadInitialResources as () => Promise)();
+ }
+
+ if (typeof clerkInstance.setActive === 'function') {
+ await clerkInstance.setActive({ session: sessionId });
+ }
+}
+
+/**
+ * Check if an error indicates the user is already signed in.
+ * Prefers structured error code, falls back to message matching.
+ */
+function isAlreadySignedInError(error: Error & { code?: string }): boolean {
+ if (error.code === 'already_signed_in') {
+ return true;
+ }
+ return /already signed in/i.test(error.message ?? '');
+}
+
+/**
+ * A pre-built native authentication component that handles sign-in and sign-up flows.
+ *
+ * `AuthView` presents a comprehensive, native UI for authentication powered by:
+ * - **iOS**: clerk-ios (SwiftUI) - https://github.com/clerk/clerk-ios
+ * - **Android**: clerk-android (Jetpack Compose) - https://github.com/clerk/clerk-android
+ *
+ * @example Modal presentation (default)
+ * ```tsx
+ * import { AuthView } from '@clerk/expo/native';
+ *
+ * export default function SignInScreen() {
+ * return (
+ * router.replace('/home')}
+ * onError={(error) => console.error(error)}
+ * />
+ * );
+ * }
+ * ```
+ *
+ * @example Inline presentation
+ * ```tsx
+ * router.replace('/home')}
+ * />
+ * ```
+ *
+ * @see {@link https://clerk.com/docs/components/authentication/sign-in} Clerk Sign-In Documentation
+ */
+export function AuthView({
+ presentation = 'modal',
+ mode = 'signInOrUp',
+ isDismissable = true,
+ onSuccess,
+ onError,
+ onDismiss,
+ style,
+}: AuthViewProps) {
+ if (presentation === 'inline') {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
+
+// MARK: - Modal Presentation
+
+function ModalPresentation({
+ mode,
+ isDismissable,
+ onSuccess,
+ onError,
+ onDismiss,
+}: Pick) {
+ const { isSignedIn } = useAuth();
+ const authCompletedRef = useRef(false);
+ const initialSignedInRef = useRef(isSignedIn);
+ const hasStartedRef = useRef(false);
+
+ const onSuccessRef = useRef(onSuccess);
+ onSuccessRef.current = onSuccess;
+ const onErrorRef = useRef(onError);
+ onErrorRef.current = onError;
+ const onDismissRef = useRef(onDismiss);
+ onDismissRef.current = onDismiss;
+
+ useEffect(() => {
+ if (!isNativeSupported || !ClerkExpo?.presentAuth) {
+ return;
+ }
+
+ if (authCompletedRef.current) {
+ return;
+ }
+
+ if (hasStartedRef.current) {
+ return;
+ }
+
+ if (initialSignedInRef.current && isSignedIn) {
+ authCompletedRef.current = true;
+ onSuccessRef.current?.();
+ return;
+ }
+
+ if (isSignedIn && !initialSignedInRef.current) {
+ return;
+ }
+
+ hasStartedRef.current = true;
+
+ const presentModal = async () => {
+ if (ClerkExpo?.getSession) {
+ try {
+ const nativeSession = (await ClerkExpo.getSession()) as { sessionId?: string } | null;
+ const sessionId = nativeSession?.sessionId;
+ if (sessionId) {
+ if (isSignedIn) {
+ // JS SDK agrees we're signed in — sync native session and complete
+ authCompletedRef.current = true;
+ await syncNativeSession(sessionId);
+ onSuccessRef.current?.();
+ return;
+ }
+ // JS SDK is signed out but native has a stale session — clear it
+ try {
+ await ClerkExpo.signOut?.();
+ } catch {
+ // Best effort
+ }
+ }
+ } catch {
+ // Failed to check native session, continue to present modal
+ }
+ }
+
+ try {
+ const result = (await ClerkExpo.presentAuth({
+ mode: mode ?? 'signInOrUp',
+ dismissable: isDismissable ?? true,
+ })) as { sessionId?: string };
+
+ if (result.sessionId) {
+ try {
+ await syncNativeSession(result.sessionId);
+ authCompletedRef.current = true;
+ onSuccessRef.current?.();
+ } catch (syncError) {
+ console.error('[AuthView] Failed to sync session:', syncError);
+ onErrorRef.current?.(syncError as Error);
+ }
+ return;
+ }
+
+ // Modal was dismissed without completing auth (resolved with no sessionId)
+ hasStartedRef.current = false;
+ onDismissRef.current?.();
+ } catch (err) {
+ const error = err as Error & { code?: string };
+
+ if (isAlreadySignedInError(error)) {
+ authCompletedRef.current = true;
+
+ if (ClerkExpo?.getSession) {
+ try {
+ const nativeSession = (await ClerkExpo.getSession()) as { sessionId?: string } | null;
+ if (nativeSession?.sessionId) {
+ await syncNativeSession(nativeSession.sessionId);
+ onSuccessRef.current?.();
+ return;
+ }
+ } catch (syncErr) {
+ console.error('[AuthView] Failed to sync native session:', syncErr);
+ }
+ }
+ }
+
+ // Modal was dismissed (native promise rejected) — reset so remounting works
+ hasStartedRef.current = false;
+ onDismissRef.current?.();
+ onErrorRef.current?.(error);
+ }
+ };
+
+ presentModal();
+ }, [mode, isDismissable, isSignedIn]);
+
+ if (!isNativeSupported || !ClerkExpo) {
+ return (
+
+
+ {!isNativeSupported
+ ? 'Native AuthView is only available on iOS and Android'
+ : 'Native AuthView requires the @clerk/expo plugin. Add "@clerk/expo" to your app.json plugins array.'}
+
+
+ );
+ }
+
+ return null;
+}
+
+// MARK: - Inline Presentation
+
+function InlinePresentation({
+ mode,
+ isDismissable,
+ onSuccess,
+ onError,
+ style,
+}: Pick) {
+ const authCompletedRef = useRef(false);
+
+ const onSuccessRef = useRef(onSuccess);
+ onSuccessRef.current = onSuccess;
+ const onErrorRef = useRef(onError);
+ onErrorRef.current = onError;
+
+ const syncSession = useCallback(async (sessionId: string) => {
+ if (authCompletedRef.current) {
+ return;
+ }
+
+ try {
+ await syncNativeSession(sessionId);
+ authCompletedRef.current = true;
+ onSuccessRef.current?.();
+ } catch (err) {
+ console.error('[AuthView] Failed to sync session:', err);
+ onErrorRef.current?.(err as Error);
+ }
+ }, []);
+
+ const handleAuthEvent = useCallback(
+ async (event: { nativeEvent: { type: string; data: string } }) => {
+ const { type, data: rawData } = event.nativeEvent;
+ const data: Record = typeof rawData === 'string' ? JSON.parse(rawData) : rawData;
+
+ if (type === 'signInCompleted' || type === 'signUpCompleted') {
+ const sessionId = data?.sessionId;
+ if (sessionId) {
+ await syncSession(sessionId);
+ }
+ }
+ },
+ [syncSession],
+ );
+
+ // Fallback: poll native session to detect auth completion
+ useEffect(() => {
+ if (!ClerkExpo?.getSession) {
+ return;
+ }
+
+ const interval = setInterval(async () => {
+ if (authCompletedRef.current) {
+ clearInterval(interval);
+ return;
+ }
+
+ try {
+ const session = (await ClerkExpo.getSession()) as { sessionId?: string } | null;
+ if (session?.sessionId) {
+ clearInterval(interval);
+ await syncSession(session.sessionId);
+ }
+ } catch {
+ // ignore polling errors
+ }
+ }, 1500);
+
+ return () => clearInterval(interval);
+ }, [syncSession]);
+
+ if (!isNativeSupported || !NativeClerkAuthView) {
+ return (
+
+
+ {!isNativeSupported
+ ? 'Native AuthView is only available on iOS and Android'
+ : 'Native AuthView requires the @clerk/expo plugin. Add "@clerk/expo" to your app.json plugins array.'}
+
+
+ );
+ }
+
+ return (
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ text: {
+ fontSize: 16,
+ color: '#666',
+ },
+});
diff --git a/packages/expo/src/native/AuthView.types.ts b/packages/expo/src/native/AuthView.types.ts
new file mode 100644
index 00000000000..b6677fc29d3
--- /dev/null
+++ b/packages/expo/src/native/AuthView.types.ts
@@ -0,0 +1,84 @@
+import type { StyleProp, ViewStyle } from 'react-native';
+
+/**
+ * Authentication mode that determines which flows are available to the user.
+ *
+ * - `'signInOrUp'` - Allows users to choose between signing in or creating a new account (default)
+ * - `'signIn'` - Restricts to sign-in flows only
+ * - `'signUp'` - Restricts to sign-up flows only
+ */
+export type AuthViewMode = 'signIn' | 'signUp' | 'signInOrUp';
+
+/**
+ * Props for the AuthView component.
+ *
+ * AuthView presents a comprehensive native authentication UI that handles
+ * sign-in and sign-up flows with support for multiple authentication methods.
+ */
+export interface AuthViewProps {
+ /**
+ * How the auth view is presented.
+ *
+ * - `'modal'` - Presents a full-screen native modal (default)
+ * - `'inline'` - Renders directly within the React Native view hierarchy
+ *
+ * @default 'modal'
+ */
+ presentation?: 'modal' | 'inline';
+
+ /**
+ * Authentication mode that determines which flows are available.
+ *
+ * - `'signInOrUp'` - Users can choose between signing in or creating an account
+ * - `'signIn'` - Only sign-in flows are available
+ * - `'signUp'` - Only sign-up flows are available
+ *
+ * @default 'signInOrUp'
+ */
+ mode?: AuthViewMode;
+
+ /**
+ * Whether the authentication view can be dismissed by the user.
+ *
+ * When `true`, a dismiss button appears in the navigation bar and the modal
+ * can be dismissed by swiping down or tapping outside (on iOS).
+ *
+ * When `false`, the user must complete authentication to close the view.
+ * Use this for flows where authentication is required to proceed.
+ *
+ * @default true
+ */
+ isDismissable?: boolean;
+
+ /**
+ * Callback fired when authentication completes successfully.
+ *
+ * This is called after:
+ * 1. The user successfully signs in or signs up
+ * 2. The native session is synced with the JavaScript SDK
+ *
+ * After this callback, all `@clerk/expo` hooks (`useUser()`, `useAuth()`,
+ * `useOrganization()`, etc.) will reflect the authenticated state.
+ */
+ onSuccess?: () => void;
+
+ /**
+ * Callback fired when an error occurs during authentication.
+ *
+ * @param error - The error that occurred
+ */
+ onError?: (error: Error) => void;
+
+ /**
+ * Callback fired when the modal is dismissed without completing authentication.
+ *
+ * Only applies to `presentation="modal"`. Use this to update your UI state
+ * (e.g., navigate back or show a landing screen).
+ */
+ onDismiss?: () => void;
+
+ /**
+ * Style applied to the container view (inline mode only).
+ */
+ style?: StyleProp;
+}
diff --git a/packages/expo/src/native/InlineAuthView.tsx b/packages/expo/src/native/InlineAuthView.tsx
new file mode 100644
index 00000000000..ac8a53de206
--- /dev/null
+++ b/packages/expo/src/native/InlineAuthView.tsx
@@ -0,0 +1,215 @@
+import * as SecureStore from 'expo-secure-store';
+import { useCallback, useEffect, useRef } from 'react';
+import { Platform, type StyleProp, StyleSheet, Text, View, type ViewStyle } from 'react-native';
+
+import { getClerkInstance } from '../provider/singleton';
+import NativeClerkAuthView from '../specs/NativeClerkAuthView';
+import NativeClerkModule from '../specs/NativeClerkModule';
+import type { AuthViewMode } from './AuthView.types';
+
+// Check if native module is supported on this platform
+const isNativeSupported = Platform.OS === 'ios' || Platform.OS === 'android';
+
+// Token cache key used by the Clerk JS SDK (must match createClerkInstance.ts)
+const CLERK_CLIENT_JWT_KEY = '__clerk_client_jwt';
+
+// Safely get the native module
+let ClerkExpoModule: typeof NativeClerkModule | null = null;
+if (isNativeSupported) {
+ try {
+ ClerkExpoModule = NativeClerkModule;
+ } catch {
+ ClerkExpoModule = null;
+ }
+}
+
+export interface InlineAuthViewProps {
+ /**
+ * Authentication mode that determines which flows are available.
+ * @default 'signInOrUp'
+ */
+ mode?: AuthViewMode;
+
+ /**
+ * Whether the authentication view can be dismissed by the user.
+ * @default true
+ */
+ isDismissable?: boolean;
+
+ /**
+ * Callback fired when authentication completes successfully.
+ * After this callback, all `@clerk/expo` hooks will reflect the authenticated state.
+ */
+ onSuccess?: () => void;
+
+ /**
+ * Callback fired when an error occurs during authentication.
+ */
+ onError?: (error: Error) => void;
+
+ /**
+ * Style applied to the container view.
+ */
+ style?: StyleProp;
+}
+
+/**
+ * An inline native authentication component that renders in-place (not as a modal).
+ *
+ * Unlike `AuthView` which presents a full-screen modal, `InlineAuthView` renders
+ * directly within your React Native view hierarchy, allowing you to embed the
+ * native authentication UI anywhere in your layout.
+ *
+ * @example
+ * ```tsx
+ * import { InlineAuthView } from '@clerk/expo/native';
+ *
+ * export default function SignInScreen() {
+ * return (
+ *
+ * Welcome
+ * router.replace('/home')}
+ * />
+ *
+ * );
+ * }
+ * ```
+ */
+export function InlineAuthView({
+ mode = 'signInOrUp',
+ isDismissable = true,
+ onSuccess,
+ onError: _onError,
+ style,
+}: InlineAuthViewProps) {
+ const authCompletedRef = useRef(false);
+
+ // Use stable refs for callbacks
+ const onSuccessRef = useRef(onSuccess);
+ onSuccessRef.current = onSuccess;
+ const onErrorRef = useRef(_onError);
+ onErrorRef.current = _onError;
+
+ const syncSession = useCallback(async (sessionId: string) => {
+ if (authCompletedRef.current) {
+ return;
+ }
+
+ try {
+ // The native SDK (clerk-ios/clerk-android) and JS SDK (clerk-js) use separate
+ // Clerk API clients. The native session won't appear in the JS client's sessions.
+ // To fix this, we copy the native client's bearer token to the JS SDK's token cache
+ // so both SDKs use the same Clerk API client.
+ if (ClerkExpoModule?.getClientToken) {
+ const nativeClientToken = await ClerkExpoModule.getClientToken();
+ if (nativeClientToken) {
+ await SecureStore.setItemAsync(CLERK_CLIENT_JWT_KEY, nativeClientToken, {
+ keychainAccessible: SecureStore.AFTER_FIRST_UNLOCK,
+ });
+ }
+ }
+
+ // Get the raw Clerk instance (not IsomorphicClerk from useClerk())
+ // because __internal_reloadInitialResources is stripped from IsomorphicClerk
+ const clerkInstance = getClerkInstance();
+ if (!clerkInstance) {
+ throw new Error('[InlineAuthView] Clerk instance not available');
+ }
+
+ const clerkRecord = clerkInstance as unknown as Record;
+
+ // Reload the client from the API - now using the native client's token,
+ // so the JS SDK will see the same sessions as the native SDK
+ if (typeof clerkRecord.__internal_reloadInitialResources === 'function') {
+ await (clerkRecord.__internal_reloadInitialResources as () => Promise)();
+ }
+
+ if (typeof clerkInstance.setActive === 'function') {
+ await clerkInstance.setActive({ session: sessionId });
+ }
+
+ // Mark complete only after successful sync to allow retries on transient failures
+ authCompletedRef.current = true;
+ onSuccessRef.current?.();
+ } catch (err) {
+ console.error('[InlineAuthView] Failed to sync session:', err);
+ onErrorRef.current?.(err as Error);
+ }
+ }, []);
+
+ // Handle native events from the view bridge
+ const handleAuthEvent = useCallback(
+ async (event: { nativeEvent: { type: string; data: string } }) => {
+ const { type, data: rawData } = event.nativeEvent;
+ const data: Record = typeof rawData === 'string' ? JSON.parse(rawData) : rawData;
+
+ if (type === 'signInCompleted' || type === 'signUpCompleted') {
+ const sessionId = data?.sessionId;
+ if (sessionId) {
+ await syncSession(sessionId);
+ }
+ }
+ },
+ [syncSession],
+ );
+
+ // Fallback: poll native session to detect auth completion
+ // This handles cases where the native event bridge doesn't fire
+ useEffect(() => {
+ if (!ClerkExpoModule?.getSession) {
+ return;
+ }
+
+ const interval = setInterval(async () => {
+ if (authCompletedRef.current) {
+ clearInterval(interval);
+ return;
+ }
+
+ try {
+ const session = (await ClerkExpoModule.getSession()) as { sessionId?: string } | null;
+ if (session?.sessionId) {
+ clearInterval(interval);
+ await syncSession(session.sessionId);
+ }
+ } catch {
+ // ignore polling errors
+ }
+ }, 1500);
+
+ return () => clearInterval(interval);
+ }, [syncSession]);
+
+ if (!isNativeSupported || !NativeClerkAuthView) {
+ return (
+
+
+ {!isNativeSupported
+ ? 'Native InlineAuthView is only available on iOS and Android'
+ : 'Native InlineAuthView requires the @clerk/expo plugin. Add "@clerk/expo" to your app.json plugins array.'}
+
+
+ );
+ }
+
+ return (
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ },
+ text: {
+ fontSize: 16,
+ color: '#666',
+ },
+});
diff --git a/packages/expo/src/native/InlineUserProfileView.tsx b/packages/expo/src/native/InlineUserProfileView.tsx
new file mode 100644
index 00000000000..fa44c2d1062
--- /dev/null
+++ b/packages/expo/src/native/InlineUserProfileView.tsx
@@ -0,0 +1,134 @@
+import { useClerk } from '@clerk/react';
+import { useCallback, useRef } from 'react';
+import { Platform, type StyleProp, StyleSheet, Text, View, type ViewStyle } from 'react-native';
+
+import NativeClerkModule from '../specs/NativeClerkModule';
+import NativeClerkUserProfileView from '../specs/NativeClerkUserProfileView';
+
+// Check if native module is supported on this platform
+const isNativeSupported = Platform.OS === 'ios' || Platform.OS === 'android';
+
+// Safely get the native module
+let ClerkExpo: typeof NativeClerkModule | null = null;
+if (isNativeSupported) {
+ try {
+ ClerkExpo = NativeClerkModule;
+ } catch {
+ ClerkExpo = null;
+ }
+}
+
+export interface InlineUserProfileViewProps {
+ /**
+ * Whether the profile view can be dismissed by the user.
+ * @default true
+ */
+ isDismissable?: boolean;
+
+ /**
+ * Callback fired when the user signs out from the profile view.
+ * After this callback, `useAuth()` will return `isSignedIn: false`.
+ */
+ onSignOut?: () => void;
+
+ /**
+ * Callback fired when the user dismisses the profile view.
+ */
+ onDismiss?: () => void;
+
+ /**
+ * Style applied to the container view.
+ */
+ style?: StyleProp;
+}
+
+/**
+ * An inline native user profile component that renders in-place (not as a modal).
+ *
+ * Unlike `UserProfileView` which presents a full-screen modal, `InlineUserProfileView`
+ * renders directly within your React Native view hierarchy.
+ *
+ * @example
+ * ```tsx
+ * import { InlineUserProfileView } from '@clerk/expo/native';
+ *
+ * export default function ProfileScreen() {
+ * return (
+ * router.replace('/sign-in')}
+ * />
+ * );
+ * }
+ * ```
+ */
+export function InlineUserProfileView({
+ isDismissable = true,
+ onSignOut,
+ onDismiss,
+ style,
+}: InlineUserProfileViewProps) {
+ const clerk = useClerk();
+ const signOutTriggered = useRef(false);
+
+ const handleProfileEvent = useCallback(
+ async (event: { nativeEvent: { type: string; data: string } }) => {
+ const { type } = event.nativeEvent;
+
+ if (type === 'signedOut' && !signOutTriggered.current) {
+ signOutTriggered.current = true;
+
+ // Clear native session
+ try {
+ await ClerkExpo?.signOut();
+ } catch {
+ // May already be signed out
+ }
+
+ // Sign out from JS SDK
+ if (clerk?.signOut) {
+ try {
+ await clerk.signOut();
+ } catch (err) {
+ console.warn('[InlineUserProfileView] JS SDK sign out error:', err);
+ }
+ }
+
+ onSignOut?.();
+ } else if (type === 'dismissed') {
+ onDismiss?.();
+ }
+ },
+ [clerk, onSignOut, onDismiss],
+ );
+
+ if (!isNativeSupported || !NativeClerkUserProfileView) {
+ return (
+
+
+ {!isNativeSupported
+ ? 'Native InlineUserProfileView is only available on iOS and Android'
+ : 'Native InlineUserProfileView requires the @clerk/expo plugin. Add "@clerk/expo" to your app.json plugins array.'}
+
+
+ );
+ }
+
+ return (
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ },
+ text: {
+ fontSize: 16,
+ color: '#666',
+ },
+});
diff --git a/packages/expo/src/native/README.md b/packages/expo/src/native/README.md
new file mode 100644
index 00000000000..f602a73993e
--- /dev/null
+++ b/packages/expo/src/native/README.md
@@ -0,0 +1,246 @@
+# Clerk Native iOS Components
+
+This package provides **complete 1:1 access to all 107 SwiftUI components** from the official [clerk-ios SDK](https://github.com/clerk/clerk-ios) through 3 high-level components.
+
+## Architecture
+
+The clerk-ios SDK is architected with 3 public-facing views that internally compose 104+ sub-components:
+
+### 1. AuthView (SignIn Component)
+
+**Wraps 35+ internal authentication screens including:**
+
+- Sign-in flows (email, phone, username, OAuth providers)
+- Sign-up flows with verification
+- Multi-factor authentication (SMS, TOTP, backup codes)
+- Password reset and account recovery
+- Passkey authentication
+- Alternative authentication methods
+- Forgot password flows
+- Get help screens
+
+**Internal Components (automatically included):**
+
+- `AuthStartView`
+- `SignInFactorOneView`
+- `SignInFactorOnePasswordView`
+- `SignInFactorOnePasskeyView`
+- `SignInFactorCodeView`
+- `SignInFactorTwoView`
+- `SignInFactorTwoBackupCodeView`
+- `SignInFactorAlternativeMethodsView`
+- `SignInForgotPasswordView`
+- `SignInSetNewPasswordView`
+- `SignInGetHelpView`
+- `SignUpCodeView`
+- `SignUpCollectFieldView`
+- `SignUpCompleteProfileView`
+- Plus 20+ common UI components
+
+### 2. UserButton
+
+**Wraps 4+ internal components including:**
+
+- User avatar display
+- User profile popover
+- Account switcher (multi-session support)
+- Quick sign-out
+
+**Internal Components (automatically included):**
+
+- `UserButtonPopover`
+- `UserButtonAccountSwitcher`
+- `UserPreviewView`
+- `UserProfileRowView`
+
+### 3. UserProfileView
+
+**Wraps 65+ internal profile management screens including:**
+
+- Profile information display and editing
+- Email address management (add, verify, remove, set primary)
+- Phone number management (add, verify, remove, set primary)
+- Password management and updates
+- MFA settings (SMS, TOTP authenticator apps, backup codes)
+- Passkey management (add, rename, remove)
+- Connected OAuth accounts management
+- Active device sessions management
+- Account switching (multi-session mode)
+- Delete account
+- Sign out
+
+**Internal Components (automatically included):**
+
+- `UserProfileDetailView`
+- `UserProfileUpdateProfileView`
+- `UserProfileSecurityView`
+- `UserProfileAddEmailView`
+- `UserProfileEmailRow`
+- `UserProfileAddPhoneView`
+- `UserProfilePhoneRow`
+- `UserProfilePasswordSection`
+- `UserProfileChangePasswordView`
+- `UserProfileMfaSection`
+- `UserProfileMfaRow`
+- `UserProfileMfaAddSmsView`
+- `UserProfileMfaAddTotpView`
+- `UserProfileAddMfaView`
+- `BackupCodesView`
+- `UserProfilePasskeySection`
+- `UserProfilePasskeyRow`
+- `UserProfilePasskeyRenameView`
+- `UserProfileExternalAccountRow`
+- `UserProfileAddConnectedAccountView`
+- `UserProfileDevicesSection`
+- `UserProfileDeviceRow`
+- `UserProfileButtonRow`
+- `UserProfileDeleteAccountSection`
+- `UserProfileDeleteAccountConfirmationView`
+- `UserProfileSectionHeader`
+- `UserProfileVerifyView`
+- Plus 40+ common UI components
+
+### Common UI Components (19+ files)
+
+All 3 public components share these internal building blocks:
+
+- `ClerkTextField`
+- `ClerkPhoneNumberField`
+- `OTPField`
+- `AsyncButton`
+- `SocialButton`
+- `SocialButtonLayout`
+- `ErrorView`
+- `ErrorText`
+- `HeaderView`
+- `DismissButton`
+- `AppLogoView`
+- `Badge`
+- `ClerkFocusedBorder`
+- `IdentityPreviewView`
+- `OverlayProgressView`
+- `SecuredByClerkView`
+- `SpinnerView`
+- `TextDivider`
+- `WrappingHStack`
+
+### Theme System (10+ files)
+
+- `ClerkTheme`
+- `ClerkColors`
+- `ClerkFonts`
+- `ClerkDesign`
+- `ClerkThemes`
+- `PrimaryButtonStyle`
+- `SecondaryButtonStyle`
+- `NegativeButtonStyle`
+- `PressedBackgroundButtonStyle`
+- `ClerkButtonConfig`
+
+## What This Means
+
+When you import and use these 3 components, you get **full access to ALL 107 files** and every single screen, flow, and feature from clerk-ios:
+
+```typescript
+import { AuthView, UserButton, UserProfileView } from '@clerk/expo/native'
+
+// This ONE component gives you access to:
+// - 15+ sign-in screens
+// - 10+ sign-up screens
+// - 10+ MFA screens
+// - 5+ password reset screens
+// - 50+ internal UI components
+
+
+// This ONE component gives you access to:
+// - User avatar
+// - Profile popover
+// - Account switcher
+// - 4+ internal components
+
+
+// This ONE component gives you access to:
+// - 25+ profile management screens
+// - 15+ security settings screens
+// - 10+ MFA configuration screens
+// - 10+ device management screens
+// - 40+ internal UI components
+
+```
+
+## Complete Feature List
+
+Every single feature from clerk-ios is now available in React Native:
+
+### Authentication Features
+
+✅ Email + Password sign-in
+✅ Phone number sign-in with SMS OTP
+✅ Username sign-in
+✅ Email sign-up with verification
+✅ Phone sign-up with SMS verification
+✅ OAuth providers (Google, Apple, GitHub, etc.)
+✅ Passkey authentication (WebAuthn)
+✅ Multi-factor authentication (MFA)
+✅ SMS-based 2FA
+✅ TOTP authenticator apps (Google Authenticator, Authy, etc.)
+✅ Backup codes
+✅ Password reset flows
+✅ Forgot password
+✅ Account recovery
+✅ Alternative authentication methods
+
+### Profile Management Features
+
+✅ View and edit profile information
+✅ Update name, username
+✅ Manage profile image
+✅ Add/remove email addresses
+✅ Verify email addresses
+✅ Set primary email
+✅ Add/remove phone numbers
+✅ Verify phone numbers
+✅ Set primary phone
+✅ Change password
+✅ Password strength validation
+✅ Enable/disable MFA
+✅ Configure SMS 2FA
+✅ Configure TOTP 2FA
+✅ Generate backup codes
+✅ View/download backup codes
+✅ Add passkeys
+✅ Rename passkeys
+✅ Remove passkeys
+✅ Connect OAuth accounts
+✅ Disconnect OAuth accounts
+✅ View active sessions
+✅ View devices
+✅ Revoke device sessions
+✅ Sign out from specific devices
+✅ Multi-session support
+✅ Account switching
+✅ Add accounts
+✅ Delete account
+
+### UI/UX Features
+
+✅ Clerk's official design system
+✅ Light/dark theme support
+✅ Customizable themes
+✅ Responsive layouts
+✅ Native iOS look and feel
+✅ Smooth animations
+✅ Loading states
+✅ Error handling
+✅ Form validation
+✅ Accessibility support
+
+## Total Component Count
+
+- **3 Public Components** (exported from this package)
+- **104 Internal Components** (automatically included)
+- **107 Total Components** from clerk-ios
+
+## Usage Examples
+
+See the `/examples` directory for comprehensive usage examples of all features.
diff --git a/packages/expo/src/native/UserButton.tsx b/packages/expo/src/native/UserButton.tsx
new file mode 100644
index 00000000000..0e7e2a432c6
--- /dev/null
+++ b/packages/expo/src/native/UserButton.tsx
@@ -0,0 +1,295 @@
+import { useClerk, useUser } from '@clerk/react';
+import { useEffect, useState } from 'react';
+import type { StyleProp, ViewStyle } from 'react-native';
+import { Image, Platform, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
+
+import NativeClerkModule from '../specs/NativeClerkModule';
+
+// Check if native module is supported on this platform
+const isNativeSupported = Platform.OS === 'ios' || Platform.OS === 'android';
+
+// Raw result from native module (may vary by platform)
+interface NativeSessionResult {
+ sessionId?: string;
+ session?: { id: string };
+ user?: { id: string; firstName?: string; lastName?: string; imageUrl?: string; primaryEmailAddress?: string };
+}
+
+// Safely get the native module
+let ClerkExpo: typeof NativeClerkModule | null = null;
+if (isNativeSupported) {
+ try {
+ ClerkExpo = NativeClerkModule;
+ } catch {
+ ClerkExpo = null;
+ }
+}
+
+interface NativeUser {
+ id: string;
+ firstName?: string;
+ lastName?: string;
+ imageUrl?: string;
+ primaryEmailAddress?: string;
+}
+
+/**
+ * Props for the UserButton component.
+ */
+export interface UserButtonProps {
+ /**
+ * Custom style for the button container.
+ */
+ style?: StyleProp;
+
+ /**
+ * Callback fired when the user button is pressed.
+ *
+ * This is called immediately when the button is tapped, before the
+ * profile modal is presented. Use this for analytics or custom behavior.
+ */
+ onPress?: () => void;
+
+ /**
+ * Callback fired when the user signs out from the profile modal.
+ *
+ * This is called after:
+ * 1. The native session is cleared
+ * 2. The JS SDK session is cleared
+ *
+ * After this callback, `useAuth()` will return `isSignedIn: false`.
+ */
+ onSignOut?: () => void;
+}
+
+/**
+ * A pre-built native button component that displays the user's avatar and opens their profile.
+ *
+ * `UserButton` renders a circular button showing the user's profile image (or initials if
+ * no image is available). When tapped, it presents the {@link UserProfileView} modal for
+ * account management.
+ *
+ * This component is powered by:
+ * - **iOS**: clerk-ios (SwiftUI) - https://github.com/clerk/clerk-ios
+ * - **Android**: clerk-android (Jetpack Compose) - https://github.com/clerk/clerk-android
+ *
+ * ## Features
+ *
+ * - **Profile Image**: Displays the user's profile photo from their Clerk account
+ * - **Initials Fallback**: Shows user's initials when no profile image is set
+ * - **Profile Modal**: Opens {@link UserProfileView} with full account management
+ * - **Sign Out Handling**: Properly syncs sign-out between native and JS SDKs
+ *
+ * ## Avatar Display
+ *
+ * The button displays the user's avatar in this order of preference:
+ * 1. User's profile image from Clerk (if available)
+ * 2. First letter of first name + first letter of last name
+ * 3. "U" as a fallback
+ *
+ * ## Styling
+ *
+ * The button is 36x36 pixels by default with circular border radius.
+ * You can customize the size using the `style` prop:
+ *
+ * ```tsx
+ *
+ * ```
+ *
+ * @example Basic usage in a header
+ * ```tsx
+ * import { UserButton } from '@clerk/expo/native';
+ *
+ * export default function Header() {
+ * return (
+ *
+ * My App
+ *
+ *
+ * );
+ * }
+ * ```
+ *
+ * @example With sign-out handling
+ * ```tsx
+ * router.replace('/sign-in')}
+ * style={{ width: 40, height: 40 }}
+ * />
+ * ```
+ *
+ * @example With press tracking
+ * ```tsx
+ * analytics.track('profile_opened')}
+ * onSignOut={() => {
+ * analytics.track('signed_out');
+ * router.replace('/sign-in');
+ * }}
+ * />
+ * ```
+ *
+ * @see {@link UserProfileView} The profile view that opens when tapped
+ * @see {@link https://clerk.com/docs/components/user/user-button} Clerk UserButton Documentation
+ */
+export function UserButton({ onPress, onSignOut, style }: UserButtonProps) {
+ const [nativeUser, setNativeUser] = useState(null);
+ const clerk = useClerk();
+ // Use the reactive user hook from clerk-react to observe sign-out state changes
+ const { user: clerkUser } = useUser();
+
+ // Fetch native user data on mount and when clerk user changes
+ useEffect(() => {
+ const fetchUser = async () => {
+ if (!isNativeSupported || !ClerkExpo?.getSession) {
+ return;
+ }
+
+ try {
+ const result = (await ClerkExpo.getSession()) as NativeSessionResult | null;
+ const hasSession = !!(result?.sessionId || result?.session?.id);
+ if (hasSession && result?.user) {
+ setNativeUser(result.user);
+ } else {
+ // Clear local state if no native session
+ setNativeUser(null);
+ }
+ } catch (err) {
+ console.error('[UserButton] Error fetching user:', err);
+ }
+ };
+
+ fetchUser();
+ }, [clerkUser?.id]); // Re-fetch when clerk user changes (including sign-out)
+
+ // Derive the user to display - prefer native data, fall back to clerk-react data
+ const user: NativeUser | null =
+ nativeUser ??
+ (clerkUser
+ ? {
+ id: clerkUser.id,
+ firstName: clerkUser.firstName ?? undefined,
+ lastName: clerkUser.lastName ?? undefined,
+ imageUrl: clerkUser.imageUrl ?? undefined,
+ primaryEmailAddress: clerkUser.primaryEmailAddress?.emailAddress,
+ }
+ : null);
+
+ const handlePress = async () => {
+ onPress?.();
+
+ if (!isNativeSupported || !ClerkExpo?.presentUserProfile) {
+ return;
+ }
+
+ try {
+ await ClerkExpo.presentUserProfile({
+ dismissable: true,
+ });
+
+ // Check if native session still exists after modal closes
+ // If session is null, user signed out from the native UI
+ const sessionCheck = (await ClerkExpo.getSession?.()) as NativeSessionResult | null;
+ const hasNativeSession = !!(sessionCheck?.sessionId || sessionCheck?.session?.id);
+
+ if (!hasNativeSession) {
+ // Clear local state immediately for instant UI feedback
+ setNativeUser(null);
+
+ // Clear native session explicitly (may already be cleared, but ensure it)
+ try {
+ await ClerkExpo.signOut?.();
+ } catch {
+ // May already be signed out
+ }
+
+ // Sign out from JS SDK to update isSignedIn state
+ if (clerk?.signOut) {
+ try {
+ await clerk.signOut();
+ } catch {
+ // Even if signOut throws, try to force reload to clear stale state
+ const clerkRecord = clerk as unknown as Record;
+ if (typeof clerkRecord.__internal_reloadInitialResources === 'function') {
+ try {
+ await (clerkRecord.__internal_reloadInitialResources as () => Promise)();
+ } catch {
+ // Best effort
+ }
+ }
+ }
+ }
+
+ onSignOut?.();
+ }
+ } catch {
+ // Modal was dismissed by the user — not an error
+ }
+ };
+
+ // Get initials from user name
+ const getInitials = () => {
+ if (user?.firstName) {
+ const first = user.firstName.charAt(0).toUpperCase();
+ const last = user.lastName?.charAt(0).toUpperCase() || '';
+ return first + last;
+ }
+ return 'U';
+ };
+
+ // Show fallback when native modules aren't available
+ if (!isNativeSupported || !ClerkExpo) {
+ return (
+
+ ?
+
+ );
+ }
+
+ return (
+
+ {user?.imageUrl ? (
+
+ ) : (
+
+ {getInitials()}
+
+ )}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ button: {
+ width: 36,
+ height: 36,
+ borderRadius: 18,
+ overflow: 'hidden',
+ },
+ avatar: {
+ flex: 1,
+ backgroundColor: '#6366f1',
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ avatarImage: {
+ width: '100%',
+ height: '100%',
+ borderRadius: 18,
+ },
+ avatarText: {
+ color: 'white',
+ fontSize: 14,
+ fontWeight: '600',
+ },
+ text: {
+ fontSize: 14,
+ color: '#666',
+ },
+});
diff --git a/packages/expo/src/native/UserProfileView.tsx b/packages/expo/src/native/UserProfileView.tsx
new file mode 100644
index 00000000000..4306dbf7514
--- /dev/null
+++ b/packages/expo/src/native/UserProfileView.tsx
@@ -0,0 +1,289 @@
+import { useAuth, useClerk } from '@clerk/react';
+import { useCallback, useEffect, useRef } from 'react';
+import type { StyleProp, ViewStyle } from 'react-native';
+import { Platform, StyleSheet, Text, View } from 'react-native';
+
+import NativeClerkModule from '../specs/NativeClerkModule';
+import NativeClerkUserProfileView from '../specs/NativeClerkUserProfileView';
+
+// Check if native module is supported on this platform
+const isNativeSupported = Platform.OS === 'ios' || Platform.OS === 'android';
+
+// Safely get the native module
+let ClerkExpo: typeof NativeClerkModule | null = null;
+if (isNativeSupported) {
+ try {
+ ClerkExpo = NativeClerkModule;
+ } catch {
+ ClerkExpo = null;
+ }
+}
+
+/**
+ * Props for the UserProfileView component.
+ */
+export interface UserProfileViewProps {
+ /**
+ * How the profile view is presented.
+ *
+ * - `'modal'` - Presents a full-screen native modal (default)
+ * - `'inline'` - Renders directly within the React Native view hierarchy
+ *
+ * @default 'modal'
+ */
+ presentation?: 'modal' | 'inline';
+
+ /**
+ * Whether the profile view can be dismissed by the user.
+ *
+ * When `true`, a dismiss button appears in the navigation bar and the modal
+ * can be dismissed by swiping down or tapping outside (on iOS).
+ *
+ * When `false`, the user must use the sign-out action to close the view.
+ *
+ * @default true
+ */
+ isDismissable?: boolean;
+
+ /**
+ * Callback fired when the user signs out from the profile view.
+ *
+ * This is called after:
+ * 1. The native session is cleared
+ * 2. The JS SDK session is cleared
+ *
+ * After this callback, `useAuth()` will return `isSignedIn: false`.
+ */
+ onSignOut?: () => void;
+
+ /**
+ * Callback fired when the user dismisses the profile view (inline mode only).
+ */
+ onDismiss?: () => void;
+
+ /**
+ * Style applied to the container view.
+ */
+ style?: StyleProp;
+}
+
+/**
+ * A pre-built native component for managing the user's profile and account settings.
+ *
+ * `UserProfileView` presents a comprehensive, native UI for account management powered by:
+ * - **iOS**: clerk-ios (SwiftUI) - https://github.com/clerk/clerk-ios
+ * - **Android**: clerk-android (Jetpack Compose) - https://github.com/clerk/clerk-android
+ *
+ * @example Modal presentation (default)
+ * ```tsx
+ * import { UserProfileView } from '@clerk/expo/native';
+ *
+ * export default function ProfileScreen() {
+ * return (
+ * router.replace('/sign-in')}
+ * />
+ * );
+ * }
+ * ```
+ *
+ * @example Inline presentation
+ * ```tsx
+ * router.replace('/sign-in')}
+ * />
+ * ```
+ *
+ * @see {@link https://clerk.com/docs/components/user/user-profile} Clerk UserProfile Documentation
+ */
+export function UserProfileView({
+ presentation = 'modal',
+ isDismissable = true,
+ onSignOut,
+ onDismiss,
+ style,
+ ...props
+}: UserProfileViewProps) {
+ if (presentation === 'inline') {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
+
+// MARK: - Modal Presentation
+
+function ModalPresentation({
+ isDismissable,
+ onSignOut,
+ onDismiss,
+ style,
+ ...props
+}: Omit) {
+ const clerk = useClerk();
+ const { isSignedIn } = useAuth();
+ const signOutTriggered = useRef(false);
+
+ const onSignOutRef = useRef(onSignOut);
+ onSignOutRef.current = onSignOut;
+ const onDismissRef = useRef(onDismiss);
+ onDismissRef.current = onDismiss;
+ const clerkRef = useRef(clerk);
+ clerkRef.current = clerk;
+ const isSignedInRef = useRef(isSignedIn);
+ isSignedInRef.current = isSignedIn;
+
+ useEffect(() => {
+ signOutTriggered.current = false;
+ }, []);
+
+ useEffect(() => {
+ if (!isNativeSupported || !ClerkExpo?.presentUserProfile) {
+ return;
+ }
+
+ const presentModal = async () => {
+ try {
+ await ClerkExpo.presentUserProfile({
+ dismissable: isDismissable ?? true,
+ });
+
+ const sessionCheck = (await ClerkExpo.getSession?.()) as { session?: { id: string } } | null;
+ const hasNativeSession = !!sessionCheck?.session;
+
+ if (!hasNativeSession && !signOutTriggered.current) {
+ signOutTriggered.current = true;
+
+ try {
+ await ClerkExpo.signOut?.();
+ } catch {
+ // May already be signed out
+ }
+
+ const currentClerk = clerkRef.current;
+ if (currentClerk?.signOut) {
+ try {
+ await currentClerk.signOut();
+ } catch (signOutErr) {
+ console.warn('[UserProfileView] JS SDK sign out error:', signOutErr);
+ }
+ }
+
+ onSignOutRef.current?.();
+ }
+ } catch {
+ // Modal was dismissed by the user
+ onDismissRef.current?.();
+ }
+ };
+
+ void presentModal();
+ }, [isDismissable]);
+
+ if (!isNativeSupported || !ClerkExpo) {
+ return (
+
+
+ {!isNativeSupported
+ ? 'Native UserProfileView is only available on iOS and Android'
+ : 'Native UserProfileView requires the @clerk/expo plugin. Add "@clerk/expo" to your app.json plugins array.'}
+
+
+ );
+ }
+
+ return null;
+}
+
+// MARK: - Inline Presentation
+
+function InlinePresentation({
+ isDismissable,
+ onSignOut,
+ onDismiss,
+ style,
+}: Pick) {
+ const clerk = useClerk();
+ const signOutTriggered = useRef(false);
+
+ const handleProfileEvent = useCallback(
+ async (event: { nativeEvent: { type: string; data: string } }) => {
+ const { type } = event.nativeEvent;
+
+ if (type === 'signedOut' && !signOutTriggered.current) {
+ signOutTriggered.current = true;
+
+ try {
+ await ClerkExpo?.signOut();
+ } catch {
+ // May already be signed out
+ }
+
+ if (clerk?.signOut) {
+ try {
+ await clerk.signOut();
+ } catch (err) {
+ console.warn('[UserProfileView] JS SDK sign out error:', err);
+ }
+ }
+
+ onSignOut?.();
+ } else if (type === 'dismissed') {
+ onDismiss?.();
+ }
+ },
+ [clerk, onSignOut, onDismiss],
+ );
+
+ if (!isNativeSupported || !NativeClerkUserProfileView) {
+ return (
+
+
+ {!isNativeSupported
+ ? 'Native UserProfileView is only available on iOS and Android'
+ : 'Native UserProfileView requires the @clerk/expo plugin. Add "@clerk/expo" to your app.json plugins array.'}
+
+
+ );
+ }
+
+ return (
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ text: {
+ fontSize: 16,
+ color: '#666',
+ },
+});
diff --git a/packages/expo/src/native/index.ts b/packages/expo/src/native/index.ts
new file mode 100644
index 00000000000..73aadb8b186
--- /dev/null
+++ b/packages/expo/src/native/index.ts
@@ -0,0 +1,36 @@
+/**
+ * Native UI components for Clerk authentication in Expo apps.
+ *
+ * These components provide pre-built, native authentication experiences powered by:
+ * - **iOS**: clerk-ios (SwiftUI) - https://github.com/clerk/clerk-ios
+ * - **Android**: clerk-android (Jetpack Compose) - https://github.com/clerk/clerk-android
+ *
+ * ## Installation
+ *
+ * Native components require the `@clerk/expo` plugin to be configured in your `app.json`:
+ *
+ * ```json
+ * {
+ * "expo": {
+ * "plugins": ["@clerk/expo"]
+ * }
+ * }
+ * ```
+ *
+ * Then run `npx expo prebuild` to generate native code.
+ *
+ * ## Components
+ *
+ * - {@link AuthView} - Authentication flow (sign-in/sign-up), supports `presentation="modal"` (default) or `presentation="inline"`
+ * - {@link UserProfileView} - User profile and account management, supports `presentation="modal"` (default) or `presentation="inline"`
+ * - {@link UserButton} - Avatar button that opens profile
+ *
+ * @module @clerk/expo/native
+ */
+
+export { AuthView } from './AuthView';
+export type { AuthViewProps, AuthViewMode } from './AuthView.types';
+export { UserButton } from './UserButton';
+export type { UserButtonProps } from './UserButton';
+export { UserProfileView } from './UserProfileView';
+export type { UserProfileViewProps } from './UserProfileView';
diff --git a/packages/expo/src/plugin/withClerkExpo.ts b/packages/expo/src/plugin/withClerkExpo.ts
index d342ef370b4..7e538ff6ce2 100644
--- a/packages/expo/src/plugin/withClerkExpo.ts
+++ b/packages/expo/src/plugin/withClerkExpo.ts
@@ -1,12 +1,48 @@
-import { type ConfigPlugin, createRunOncePlugin, withInfoPlist } from '@expo/config-plugins';
+import { type ConfigPlugin, createRunOncePlugin, withAppBuildGradle, withInfoPlist } from '@expo/config-plugins';
import pkg from '../../package.json';
/**
- * Expo config plugin for @clerk/expo.
- *
- * This plugin configures the iOS URL scheme required for Google Sign-In.
- * The native Android module is automatically linked via expo-module.config.json.
+ * Add packaging exclusions to Android app build.gradle to resolve
+ * duplicate META-INF file conflicts from clerk-android dependencies.
+ */
+const withClerkAndroidPackaging: ConfigPlugin = config => {
+ return withAppBuildGradle(config, modConfig => {
+ let buildGradle = modConfig.modResults.contents;
+
+ // Check if exclusion already exists
+ if (buildGradle.includes('META-INF/versions/9/OSGI-INF/MANIFEST.MF')) {
+ return modConfig;
+ }
+
+ // AGP 8+ uses `packaging` DSL, older versions use `packagingOptions`
+ const packagingMatch = buildGradle.match(/packaging\s*\{/) || buildGradle.match(/packagingOptions\s*\{/);
+ if (packagingMatch) {
+ const blockName = packagingMatch[0].trim().replace(/\s*\{$/, '');
+ const resourcesExclude = `${blockName} {
+ // Clerk Android SDK: exclude duplicate META-INF files
+ resources {
+ excludes += ['META-INF/versions/9/OSGI-INF/MANIFEST.MF']
+ }`;
+
+ buildGradle = buildGradle.replace(new RegExp(`${blockName}\\s*\\{`), resourcesExclude);
+ modConfig.modResults.contents = buildGradle;
+ } else {
+ // No packaging block found; append one at the end of the android block
+ const androidBlockEnd = buildGradle.lastIndexOf('}');
+ if (androidBlockEnd !== -1) {
+ const packagingBlock = `\n packaging {\n resources {\n excludes += ['META-INF/versions/9/OSGI-INF/MANIFEST.MF']\n }\n }\n`;
+ buildGradle = buildGradle.slice(0, androidBlockEnd) + packagingBlock + buildGradle.slice(androidBlockEnd);
+ modConfig.modResults.contents = buildGradle;
+ }
+ }
+
+ return modConfig;
+ });
+};
+
+/**
+ * Configures iOS URL scheme for Google Sign-In.
*/
const withClerkGoogleSignIn: ConfigPlugin = config => {
// Get the iOS URL scheme from environment or config.extra
@@ -42,4 +78,20 @@ const withClerkGoogleSignIn: ConfigPlugin = config => {
});
};
-export default createRunOncePlugin(withClerkGoogleSignIn, pkg.name, pkg.version);
+/**
+ * Combined plugin that applies all Clerk configurations.
+ *
+ * When this plugin is used, it:
+ * 1. Configures iOS URL scheme for Google Sign-In (if env var is set)
+ * 2. Adds Android packaging exclusions to resolve dependency conflicts
+ *
+ * Native modules are registered via react-native.config.js and standard
+ * React Native autolinking (RCTViewManager / ReactPackage).
+ */
+const withClerkExpo: ConfigPlugin = config => {
+ config = withClerkGoogleSignIn(config);
+ config = withClerkAndroidPackaging(config);
+ return config;
+};
+
+export default createRunOncePlugin(withClerkExpo, pkg.name, pkg.version);
diff --git a/packages/expo/src/provider/ClerkProvider.tsx b/packages/expo/src/provider/ClerkProvider.tsx
index d76e3fa541c..97b575c0b53 100644
--- a/packages/expo/src/provider/ClerkProvider.tsx
+++ b/packages/expo/src/provider/ClerkProvider.tsx
@@ -2,9 +2,14 @@ import '../polyfills';
import type { ClerkProviderProps as ReactClerkProviderProps } from '@clerk/react';
import { InternalClerkProvider as ClerkReactProvider, type Ui } from '@clerk/react/internal';
+import * as SecureStore from 'expo-secure-store';
import * as WebBrowser from 'expo-web-browser';
+import { useEffect, useRef } from 'react';
+import { Platform } from 'react-native';
import type { TokenCache } from '../cache/types';
+import { useNativeAuthEvents } from '../hooks/useNativeAuthEvents';
+import NativeClerkModule from '../specs/NativeClerkModule';
import { isNative, isWeb } from '../utils/runtime';
import { getClerkInstance } from './singleton';
import type { BuildClerkOptions } from './singleton/types';
@@ -59,6 +64,203 @@ export function ClerkProvider(props: ClerkProviderProps(null);
+ const initStartedRef = useRef(false);
+ const sessionSyncedRef = useRef(false);
+ const prevPkRef = useRef(pk);
+
+ // Reset refs when publishable key changes (hot-swap support)
+ if (prevPkRef.current !== pk) {
+ prevPkRef.current = pk;
+ pendingNativeSessionRef.current = null;
+ initStartedRef.current = false;
+ sessionSyncedRef.current = false;
+ }
+
+ // Get the Clerk instance for syncing
+ const clerkInstance = isNative()
+ ? getClerkInstance({
+ publishableKey: pk,
+ tokenCache,
+ __experimental_passkeys,
+ __experimental_resourceCache,
+ })
+ : null;
+
+ // Track whether the component is still mounted
+ const isMountedRef = useRef(true);
+
+ // Configure native Clerk SDK and set up session sync callback
+ useEffect(() => {
+ isMountedRef.current = true;
+
+ if ((Platform.OS === 'ios' || Platform.OS === 'android') && pk && !initStartedRef.current) {
+ initStartedRef.current = true;
+
+ const configureNativeClerk = async () => {
+ try {
+ const ClerkExpo = NativeClerkModule;
+
+ if (ClerkExpo?.configure) {
+ // Read the JS SDK's client JWT to sync with the native SDK
+ let bearerToken: string | null = null;
+ try {
+ bearerToken = await SecureStore.getItemAsync('__clerk_client_jwt', {
+ keychainAccessible: SecureStore.AFTER_FIRST_UNLOCK,
+ });
+ } catch {
+ // SecureStore may not be available
+ }
+ await ClerkExpo.configure(pk, bearerToken);
+
+ if (!isMountedRef.current) {
+ return;
+ }
+
+ // Poll for native session (matching iOS's 3-second max wait)
+ const MAX_WAIT_MS = 3000;
+ const POLL_INTERVAL_MS = 100;
+ let sessionId: string | null = null;
+
+ for (let elapsed = 0; elapsed < MAX_WAIT_MS; elapsed += POLL_INTERVAL_MS) {
+ if (!isMountedRef.current) {
+ return;
+ }
+ if (ClerkExpo?.getSession) {
+ const nativeSession = (await ClerkExpo.getSession()) as {
+ sessionId?: string;
+ session?: { id: string };
+ } | null;
+ // Normalize: iOS returns { sessionId }, Android returns { session: { id } }
+ sessionId = nativeSession?.sessionId ?? nativeSession?.session?.id ?? null;
+ if (sessionId) {
+ break;
+ }
+ }
+ await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS));
+ }
+
+ if (!isMountedRef.current) {
+ return;
+ }
+
+ if (sessionId && clerkInstance) {
+ pendingNativeSessionRef.current = sessionId;
+
+ // Wait for clerk to be loaded before syncing
+ const clerkAny = clerkInstance as any;
+
+ const waitForLoad = (): Promise => {
+ return new Promise(resolve => {
+ if (clerkAny.loaded) {
+ resolve();
+ } else if (typeof clerkAny.addOnLoaded === 'function') {
+ clerkAny.addOnLoaded(() => resolve());
+ } else {
+ if (__DEV__) {
+ console.warn('[ClerkProvider] Clerk instance has no loaded property or addOnLoaded method');
+ }
+ resolve();
+ }
+ });
+ };
+
+ await waitForLoad();
+
+ if (!isMountedRef.current) {
+ return;
+ }
+
+ if (!sessionSyncedRef.current && typeof clerkInstance.setActive === 'function') {
+ sessionSyncedRef.current = true;
+ const pendingSession = pendingNativeSessionRef.current;
+
+ // If the native session is not in the client's sessions list,
+ // reload the client from the API so setActive can find it.
+ const sessionInClient = clerkInstance.client?.sessions?.some(
+ (s: { id: string }) => s.id === pendingSession,
+ );
+ if (!sessionInClient && typeof clerkAny.__internal_reloadInitialResources === 'function') {
+ await clerkAny.__internal_reloadInitialResources();
+ }
+
+ try {
+ await clerkInstance.setActive({ session: pendingSession });
+ } catch (err) {
+ console.error(`[ClerkProvider] Failed to sync native session:`, err);
+ }
+ }
+ }
+ }
+ } catch (error) {
+ const isNativeModuleNotFound =
+ error instanceof Error &&
+ (error.message.includes('Cannot find native module') ||
+ error.message.includes("TurboModuleRegistry.getEnforcing(...): 'ClerkExpo'"));
+ if (isNativeModuleNotFound) {
+ if (__DEV__) {
+ console.debug(
+ `[ClerkProvider] Native Clerk module not available. ` +
+ `To enable native features, add "@clerk/expo" to your app.json plugins array.`,
+ );
+ }
+ } else {
+ console.error(`[ClerkProvider] Failed to configure Clerk ${Platform.OS}:`, error);
+ }
+ }
+ };
+ configureNativeClerk();
+ }
+
+ return () => {
+ isMountedRef.current = false;
+ };
+ }, [pk, clerkInstance]);
+
+ // Listen for native auth state changes and sync to JS SDK
+ const { nativeAuthState } = useNativeAuthEvents();
+
+ useEffect(() => {
+ if (!nativeAuthState || !clerkInstance) {
+ return;
+ }
+
+ const syncNativeAuthToJs = async () => {
+ try {
+ if (nativeAuthState.type === 'signedIn' && nativeAuthState.sessionId && clerkInstance.setActive) {
+ // Ensure the session exists in the client before calling setActive
+ const sessionInClient = clerkInstance.client?.sessions?.some(
+ (s: { id: string }) => s.id === nativeAuthState.sessionId,
+ );
+ if (!sessionInClient) {
+ const clerkAny = clerkInstance as any;
+ if (typeof clerkAny.__internal_reloadInitialResources === 'function') {
+ await clerkAny.__internal_reloadInitialResources();
+ }
+ if (!isMountedRef.current) {
+ return;
+ }
+ }
+
+ if (!isMountedRef.current) {
+ return;
+ }
+ await clerkInstance.setActive({ session: nativeAuthState.sessionId });
+ } else if (nativeAuthState.type === 'signedOut' && clerkInstance.signOut) {
+ if (!isMountedRef.current) {
+ return;
+ }
+ await clerkInstance.signOut();
+ }
+ } catch (error) {
+ console.error(`[ClerkProvider] Failed to sync native auth state:`, error);
+ }
+ };
+
+ syncNativeAuthToJs();
+ }, [nativeAuthState, clerkInstance]);
+
if (isWeb()) {
// This is needed in order for useOAuth to work correctly on web.
WebBrowser.maybeCompleteAuthSession();
@@ -72,16 +274,7 @@ export function ClerkProvider(props: ClerkProviderProps
+ isClerkRuntimeError(err) && err.code === 'network_error';
+
const retryInitilizeResourcesFromFAPI = async () => {
- const isClerkNetworkError = (err: unknown) => isClerkRuntimeError(err) && err.code === 'network_error';
try {
await __internal_clerk?.__internal_reloadInitialResources();
- } catch (err) {
+ } catch (err: unknown) {
// Retry after 3 seconds if the error is a network error or a 5xx error
if (isClerkNetworkError(err) || !is4xxError(err)) {
// Retry after 2 seconds if the error is a network error
@@ -123,9 +125,12 @@ export function createClerkInstance(ClerkClass: typeof Clerk) {
ClientResourceCache.init({ publishableKey, storage: createResourceCache });
SessionJWTCache.init({ publishableKey, storage: createResourceCache });
- __internal_clerk.addListener(({ client }) => {
+ // At this point __internal_clerk is guaranteed to be defined (just created above)
+
+ const clerk = __internal_clerk;
+ clerk.addListener(({ client }) => {
// @ts-expect-error - This is an internal API
- const environment = __internal_clerk?.__internal_environment as EnvironmentResource;
+ const environment = clerk?.__internal_environment as EnvironmentResource;
if (environment) {
void EnvironmentResourceCache.save(environment.__internal_toSnapshot());
}
@@ -144,7 +149,7 @@ export function createClerkInstance(ClerkClass: typeof Clerk) {
}
});
- __internal_clerk.__internal_getCachedResources = async (): Promise<{
+ clerk.__internal_getCachedResources = async (): Promise<{
client: ClientJSONSnapshot | null;
environment: EnvironmentJSONSnapshot | null;
}> => {
@@ -195,6 +200,7 @@ export function createClerkInstance(ClerkClass: typeof Clerk) {
}
});
}
+ // At this point __internal_clerk is guaranteed to be defined
return __internal_clerk;
};
}
diff --git a/packages/expo/src/provider/singleton/singleton.web.ts b/packages/expo/src/provider/singleton/singleton.web.ts
index 3a307f6350b..e931cabe7bb 100644
--- a/packages/expo/src/provider/singleton/singleton.web.ts
+++ b/packages/expo/src/provider/singleton/singleton.web.ts
@@ -2,6 +2,13 @@ import type { BrowserClerk, HeadlessBrowserClerk } from '@clerk/react';
import type { BuildClerkOptions } from './types';
+// Augment the global Window type to include Clerk
+declare global {
+ interface Window {
+ Clerk?: HeadlessBrowserClerk | BrowserClerk;
+ }
+}
+
/**
* Access the existing Clerk instance from `window.Clerk` on the web.
* Unlike the native implementation, this does not create a new instance—it only returns the existing one set by ClerkProvider.
diff --git a/packages/expo/src/specs/NativeClerkAuthView.ts b/packages/expo/src/specs/NativeClerkAuthView.ts
new file mode 100644
index 00000000000..666b2d7c31e
--- /dev/null
+++ b/packages/expo/src/specs/NativeClerkAuthView.ts
@@ -0,0 +1,15 @@
+// eslint-disable-next-line simple-import-sort/imports, import/namespace, import/default, import/no-named-as-default, import/no-named-as-default-member
+import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNativeComponent';
+import type { HostComponent, ViewProps } from 'react-native';
+// eslint-disable-next-line import/namespace
+import type { BubblingEventHandler } from 'react-native/Libraries/Types/CodegenTypes';
+
+type AuthEvent = Readonly<{ type: string; data: string }>;
+
+interface NativeProps extends ViewProps {
+ mode?: string;
+ isDismissable?: boolean;
+ onAuthEvent?: BubblingEventHandler;
+}
+
+export default codegenNativeComponent('ClerkAuthView') as HostComponent;
diff --git a/packages/expo/src/specs/NativeClerkGoogleSignIn.ts b/packages/expo/src/specs/NativeClerkGoogleSignIn.ts
new file mode 100644
index 00000000000..44206cdd304
--- /dev/null
+++ b/packages/expo/src/specs/NativeClerkGoogleSignIn.ts
@@ -0,0 +1,12 @@
+import type { TurboModule } from 'react-native';
+import { TurboModuleRegistry } from 'react-native';
+
+export interface Spec extends TurboModule {
+ configure(params: object): void;
+ signIn(params: object | null): Promise