Skip to content
Merged
7 changes: 7 additions & 0 deletions .changeset/fix-inline-authview-sso-oauth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@clerk/expo': patch
---

- Fix iOS OAuth (SSO) sign-in failing silently when initiated from the forgot password screen of the inline `<AuthView>` component.
- Fix Android `<AuthView>` getting stuck on the "Get help" screen after sign out via `<UserProfileView>`.
- Fix a brief white flash when the inline `<AuthView>` first mounts on iOS.
4 changes: 2 additions & 2 deletions packages/expo/android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ ext {
credentialsVersion = "1.3.0"
googleIdVersion = "1.1.1"
kotlinxCoroutinesVersion = "1.7.3"
clerkAndroidApiVersion = "1.0.10"
clerkAndroidUiVersion = "1.0.10"
clerkAndroidApiVersion = "1.0.12"
clerkAndroidUiVersion = "1.0.12"
composeVersion = "1.7.0"
activityComposeVersion = "1.9.0"
lifecycleVersion = "2.8.0"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ 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.ViewModelStore
import androidx.lifecycle.ViewModelStoreOwner
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.setViewTreeLifecycleOwner
Expand Down Expand Up @@ -44,6 +46,16 @@ class ClerkAuthNativeView(context: Context) : FrameLayout(context) {

private val activity: ComponentActivity? = findActivity(context)

// Per-view ViewModelStoreOwner so the AuthView's ViewModels (including its
// navigation state) are scoped to THIS view instance, not the activity.
// Without this, the AuthView's navigation persists across mount/unmount
// cycles within the same activity, leaving the user stuck on whatever screen
// (e.g. "Get help") was last navigated to before sign-out.
private val viewModelStoreOwner = object : ViewModelStoreOwner {
private val store = ViewModelStore()
override val viewModelStore: ViewModelStore = store
}

private var recomposer: Recomposer? = null
private var recomposerJob: kotlinx.coroutines.Job? = null

Expand Down Expand Up @@ -72,23 +84,32 @@ class ClerkAuthNativeView(context: Context) : FrameLayout(context) {
override fun onDetachedFromWindow() {
recomposer?.cancel()
recomposerJob?.cancel()
// Clear our per-view ViewModelStore so any AuthView ViewModels are GC'd.
viewModelStoreOwner.viewModelStore.clear()
super.onDetachedFromWindow()
}

// Track the initial session to detect new sign-ins
// Track the initial session to detect new sign-ins. Captured at construction
// time, but may capture a stale session if the view is mounted before signOut
// has finished clearing local state — so the LaunchedEffect below uses
// session id inequality (not null-to-value) to detect new sign-ins.
private var initialSessionId: String? = Clerk.session?.id
private var authCompletedSent: Boolean = false

fun setupView() {
debugLog(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
// Detect auth completion: any session that's different from the one we
// started with (captures fresh sign-ins, sign-in-after-sign-out, etc.)
LaunchedEffect(session) {
val currentSession = session
if (currentSession != null && initialSessionId == null) {
debugLog(TAG, "Auth completed - session present: true")
val currentId = currentSession?.id
if (currentSession != null && currentId != initialSessionId && !authCompletedSent) {
debugLog(TAG, "Auth completed - new session: $currentId (initial: $initialSessionId)")
authCompletedSent = true
sendEvent("signInCompleted", mapOf(
"sessionId" to currentSession.id,
"type" to "signIn"
Expand All @@ -113,7 +134,9 @@ class ClerkAuthNativeView(context: Context) : FrameLayout(context) {

if (activity != null) {
CompositionLocalProvider(
LocalViewModelStoreOwner provides activity,
// Per-view ViewModelStore so AuthView's navigation state doesn't
// leak between mounts within the same MainActivity lifetime.
LocalViewModelStoreOwner provides viewModelStoreOwner,
LocalLifecycleOwner provides activity,
LocalSavedStateRegistryOwner provides activity,
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -245,8 +245,10 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) :
@ReactMethod
override fun getClientToken(promise: Promise) {
try {
val prefs = reactApplicationContext.getSharedPreferences("clerk_preferences", Context.MODE_PRIVATE)
val deviceToken = prefs.getString("DEVICE_TOKEN", null)
// Use the SDK's public API which handles encrypted storage transparently.
// Direct SharedPreferences reads break on clerk-android >= 1.0.11 where
// DEVICE_TOKEN is encrypted via StorageCipher.
val deviceToken = Clerk.getDeviceToken()
promise.resolve(deviceToken)
} catch (e: Exception) {
debugLog(TAG, "getClientToken failed: ${e.message}")
Expand All @@ -272,6 +274,8 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) :
coroutineScope.launch {
try {
Clerk.auth.signOut()
// Client refresh after sign-out is handled by the clerk-android
// SDK (SignOutService.signOut calls Client.getSkippingClientId).
promise.resolve(null)
} catch (e: Exception) {
promise.reject("E_SIGN_OUT_FAILED", e.message ?: "Sign out failed", e)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.clerk.api.Clerk
import com.clerk.api.network.model.client.Client
import com.clerk.ui.userprofile.UserProfileView

/**
Expand Down Expand Up @@ -71,7 +72,17 @@ class ClerkUserProfileActivity : ComponentActivity() {
// 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")
debugLog(TAG, "Sign-out detected - session became null")
// Fetch a brand-new client from the server, skipping the in-memory
// client_id header. Without skipping, the server echoes back the SAME
// client (with the previous user's in-progress signIn still attached),
// and the AuthView re-mounts into the "Get help" fallback because the
// stale signIn's status has no startingFirstFactor.
try {
Client.getSkippingClientId()
} catch (e: Exception) {
Log.w(TAG, "Client.getSkippingClientId() after UserProfile sign-out failed: ${e.message}")
}
finishWithSuccess()
}
// Update hadSession if we get a session (handles edge cases)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner
import androidx.savedstate.compose.LocalSavedStateRegistryOwner
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
import com.clerk.api.Clerk
import com.clerk.api.network.model.client.Client
import com.clerk.ui.userprofile.UserProfileView
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.ReactContext
Expand Down Expand Up @@ -77,6 +78,17 @@ class ClerkUserProfileNativeView(context: Context) : FrameLayout(context) {
LaunchedEffect(session) {
if (hadSession && session == null) {
Log.d(TAG, "Sign-out detected")
// Refresh the client from the server to clear any stale in-progress
// signIn/signUp state. Without this, when the AuthView re-mounts after
// sign-out it routes to the "Get help" fallback because the previous
// user's signIn is still in Clerk.client. Clerk.auth.signOut() (called
// internally by UserProfileView) only clears session/user state, not
// the in-progress signIn.
try {
Client.getSkippingClientId()
} catch (e: Exception) {
Log.w(TAG, "Client.getSkippingClientId() after UserProfile sign-out failed: ${e.message}")
}
sendEvent("signedOut", emptyMap())
}
if (session != null) {
Expand Down
153 changes: 107 additions & 46 deletions packages/expo/ios/ClerkExpoModule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -219,24 +219,36 @@ class ClerkExpoModule: RCTEventEmitter {
// 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
private var authEventSent: Bool = false
private var presentedAuthVC: UIViewController?
private var isInvalidated: Bool = false

@objc var onAuthEvent: RCTBubblingEventBlock?

@objc var mode: NSString? {
didSet {
currentMode = (mode as String?) ?? "signInOrUp"
if hasInitialized { updateView() }
let newMode = (mode as String?) ?? "signInOrUp"
guard newMode != currentMode else { return }
currentMode = newMode
if hasInitialized {
dismissAuthModal()
presentAuthModal()
}
}
}

@objc var isDismissable: NSNumber? {
didSet {
currentDismissable = isDismissable?.boolValue ?? true
if hasInitialized { updateView() }
let newDismissable = isDismissable?.boolValue ?? true
guard newDismissable != currentDismissable else { return }
currentDismissable = newDismissable
if hasInitialized {
dismissAuthModal()
presentAuthModal()
}
}
}

Expand All @@ -252,65 +264,114 @@ public class ClerkAuthNativeView: UIView {
super.didMoveToWindow()
if window != nil && !hasInitialized {
hasInitialized = true
updateView()
presentAuthModal()
}
}

private func updateView() {
// Remove old hosting controller
hostingController?.view.removeFromSuperview()
hostingController?.removeFromParent()
hostingController = nil
override public func removeFromSuperview() {
isInvalidated = true
dismissAuthModal()
super.removeFromSuperview()
}

// MARK: - Modal Presentation
//
// The AuthView is presented as a real modal rather than embedded inline.
// Embedding a UIHostingController as a child of a React Native view disrupts
// ASWebAuthenticationSession callbacks during OAuth flows (e.g., SSO from the
// forgot-password screen). Modal presentation provides an isolated SwiftUI
// lifecycle that handles all OAuth flows correctly.

private func presentAuthModal() {
guard let factory = clerkViewFactory else { return }

guard let returnedController = factory.createAuthView(
guard let authVC = factory.createAuthViewController(
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])

// Also emit module-level event so ClerkProvider's useNativeAuthEvents picks it up
if eventName == "signInCompleted" || eventName == "signUpCompleted" {
let sessionId = data["sessionId"] as? String
ClerkExpoModule.emitAuthStateChange(type: "signedIn", sessionId: sessionId)
completion: { [weak self] result in
guard let self = self, !self.authEventSent else { return }
switch result {
case .success(let data):
if let _ = data["cancelled"] {
// User dismissed — don't send auth event
return
}
self.authEventSent = true
self.sendAuthEvent(type: "signInCompleted", data: data)
case .failure:
break
}
}
) 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
}
authVC.modalPresentationStyle = .fullScreen
// Try to present immediately. Only wait if a previous modal is dismissing.
presentWhenReady(authVC, attempts: 0)
}

private func findViewController() -> UIViewController? {
var responder: UIResponder? = self
while let nextResponder = responder?.next {
if let vc = nextResponder as? UIViewController {
return vc
private func dismissAuthModal() {
presentedAuthVC?.dismiss(animated: false)
presentedAuthVC = nil
}

/// Presents the auth view controller as soon as it's safe to do so.
/// On initial mount this presents synchronously (no delay, no white flash).
/// If a previous modal is still dismissing, waits for its transition coordinator
/// to finish — no fixed delays.
private func presentWhenReady(_ authVC: UIViewController, attempts: Int) {
guard !isInvalidated, presentedAuthVC == nil, attempts < 30 else { return }
guard let rootVC = Self.topViewController() else {
DispatchQueue.main.async { [weak self] in
self?.presentWhenReady(authVC, attempts: attempts + 1)
}
responder = nextResponder
return
}
return nil

// If a previous modal is animating dismissal, wait for it via the
// transition coordinator instead of a fixed delay.
if let coordinator = rootVC.transitionCoordinator {
coordinator.animate(alongsideTransition: nil) { [weak self] _ in
self?.presentWhenReady(authVC, attempts: attempts + 1)
}
return
}

// If there's still a presented VC (no coordinator yet), wait one frame.
if rootVC.presentedViewController != nil {
DispatchQueue.main.async { [weak self] in
self?.presentWhenReady(authVC, attempts: attempts + 1)
}
return
}

rootVC.present(authVC, animated: false)
presentedAuthVC = authVC
}

override public func layoutSubviews() {
super.layoutSubviews()
hostingController?.view.frame = bounds
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
}

private func sendAuthEvent(type: String, data: [String: Any]) {
let jsonData = (try? JSONSerialization.data(withJSONObject: data)) ?? Data()
let jsonString = String(data: jsonData, encoding: .utf8) ?? "{}"
onAuthEvent?(["type": type, "data": jsonString])

// Also emit module-level event so ClerkProvider's useNativeAuthEvents picks it up
if type == "signInCompleted" || type == "signUpCompleted" {
let sessionId = data["sessionId"] as? String
ClerkExpoModule.emitAuthStateChange(type: "signedIn", sessionId: sessionId)
}
}
}

Expand Down
7 changes: 6 additions & 1 deletion packages/expo/ios/ClerkViewFactory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -348,7 +348,12 @@ class ClerkAuthWrapperViewController: UIHostingController<ClerkAuthWrapperView>
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
if isBeingDismissed {
completeOnce(.success(["cancelled": true]))
// Check if auth completed (session exists) vs user cancelled
if let session = Clerk.shared.session, session.id != initialSessionId {
completeOnce(.success(["sessionId": session.id, "type": "signIn"]))
} else {
completeOnce(.success(["cancelled": true]))
}
}
}

Expand Down
Loading