Skip to content

Instantly share code, notes, and snippets.

@Narayan-Dhingra
Created July 30, 2026 16:27
Show Gist options
  • Select an option

  • Save Narayan-Dhingra/9678bc400df5aec82745c8f991617b38 to your computer and use it in GitHub Desktop.

Select an option

Save Narayan-Dhingra/9678bc400df5aec82745c8f991617b38 to your computer and use it in GitHub Desktop.
Apple Sign-In on Android with Kotlin Multiplatform

Mastering Apple Sign-In on Android in Kotlin Multiplatform: The Chrome Custom Tabs Approach

Building Snook-A-Look in Kotlin Multiplatform meant sharing as much UI and business logic as possible. But when it came to authentication, we hit a classic platform disparity: Apple Sign-In. On iOS, it's a breeze with native APIs. On Android, it's a completely different beast involving OAuth web flows.

We didn't want to pollute our shared Compose UI with platform-specific auth logic. We needed a clean expect/actual bridge that could seamlessly suspend a KMP coroutine, launch the web flow on Android, and resume the KMP coroutine with the auth payload.

Here is exactly how we engineered a resilient Apple Sign-In flow on Android using Chrome Custom Tabs and Kotlin Coroutines.

The Challenge: Coroutine Suspension Across Activities

When a user taps "Sign in with Apple" on Android, we must:

  1. Open a Chrome Custom Tab pointing to Apple's OAuth URL.
  2. Wait for the user to complete the flow.
  3. Catch the redirect intent when Apple pings our web callback.
  4. Unblock the KMP coroutine waiting for the result.

The danger? The Android OS might kill our app while the user is in Chrome, or the user might just manually close the tab without logging in. Our coroutine would hang forever if we didn't handle lifecycle events and state persistence properly.

The Solution: A Stateful Coroutine Registry

We built an AppleAuthRegistry to hold our CancellableContinuation by a unique state ID.

// AppleAuthRegistry.kt
object AppleAuthRegistry {
    private val pending = ConcurrentHashMap<String, CancellableContinuation<AppleAuthArtifact>>()

    fun newState(): String = UUID.randomUUID().toString()

    suspend fun await(state: String): AppleAuthArtifact = suspendCancellableCoroutine { cont ->
        pending[state] = cont
        cont.invokeOnCancellation { pending.remove(state) }
    }

    fun resume(state: String, artifact: AppleAuthArtifact) {
        pending.remove(state)?.resume(artifact)
    }

    fun cancel(state: String) {
        pending.remove(state)?.resumeWithException(
            OAuthFlowException(OAuthErrorCode.USER_CANCELLED)
        )
    }

    fun isPending(state: String): Boolean = pending.containsKey(state)
}

Bridging the KMP Gap: AppleSignInBridge

In our actual class AppleSignInBridge for Android, we persist the pending state to SharedPreferences before launching the Custom Tab. This guarantees kill-resume resilience. We also inject a DefaultLifecycleObserver to monitor when the user cancels the flow by closing the tab.

// AppleSignInBridge.android.kt
actual class AppleSignInBridge(private val activityProvider: () -> Activity?) {
    actual suspend fun getIdToken(rawNonce: String): AppleAuthArtifact {
        val activity = activityProvider() ?: throw OAuthFlowException(OAuthErrorCode.UNKNOWN, "No foreground activity")
        val state = AppleAuthRegistry.newState()
        
        // Persist state for kill-resume resilience
        activity.applicationContext
            .getSharedPreferences("snook_prefs", Context.MODE_PRIVATE)
            .edit().putString("apple_oauth_pending_state", state).apply()
            
        val nonce256 = sha256Hex(rawNonce)
        val url = buildAppleAuthUrl(state, nonce256)

        var tabLaunched = false
        val cancelObserver = object : DefaultLifecycleObserver {
            override fun onResume(owner: LifecycleOwner) {
                if (!tabLaunched) return
                // Remove on first resume after tab launch to catch user cancellation
                owner.lifecycle.removeObserver(this)
                if (AppleAuthRegistry.isPending(state)) {
                    AppleAuthRegistry.cancel(state)
                }
            }
        }
        
        (activity as? ComponentActivity)?.lifecycle?.addObserver(cancelObserver)

        return try {
            CustomTabsIntent.Builder().build().launchUrl(activity, Uri.parse(url))
            tabLaunched = true
            // Suspend the KMP coroutine here!
            AppleAuthRegistry.await(state)
        } finally {
            (activity as? ComponentActivity)?.lifecycle?.removeObserver(cancelObserver)
            AppleAuthRegistry.cancel(state) // Cleanup on exit
        }
    }
    
    // ... URL building logic omitted for brevity ...
}

Catching the Callback: AppleWebCallbackActivity

Finally, when Apple redirects to our backend and our backend deep-links back to the app, our transparent AppleWebCallbackActivity intercepts the intent, extracts the state, and resumes the coroutine via the registry. It then ensures the main task is brought back to the foreground.

// AppleWebCallbackActivity.kt
class AppleWebCallbackActivity : Activity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        handleIntent()
        finish()
    }

    private fun handleIntent() {
        val uri = intent?.data ?: return
        val state = uri.getQueryParameter("state")
        if (state != null) {
            AppleAuthRegistry.resume(
                state = state,
                artifact = AppleAuthArtifact(
                    identityToken = null,
                    stateToken = state,
                    firstName = null,
                    lastName = null,
                    email = null,
                )
            )
            // Explicitly bring MainActivity task to front so Chrome CCT doesn't stay foreground
            packageManager.getLaunchIntentForPackage(packageName)?.apply {
                addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
            }?.let { startActivity(it) }
        }
    }
}

Conclusion

By isolating the Chrome Custom Tab mechanics and lifecycle observers within the Android actual implementation, we kept our shared KMP UI completely oblivious to the platform differences. The shared code simply calls AppleSignInBridge.getIdToken() and suspends until the native platform delivers the payload. Clean, robust, and crash-resilient!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment