-
Notifications
You must be signed in to change notification settings - Fork 431
feat(expo): Implement Google Sign-In support for Android and iOS #7208
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
chriscanin
merged 53 commits into
main
from
chris/mobile-289-expo-google-universal-sign-in
Jan 5, 2026
Merged
Changes from all commits
Commits
Show all changes
53 commits
Select commit
Hold shift + click to select a range
d8097c9
feat(expo): Implement Google Sign-In support for Android and iOS
chriscanin 83eea32
test(expo): Enhance mocks for expo-modules-core in test setup
chriscanin d2c85f0
chore(package): remove local dependency on @clerk/clerk-expo
chriscanin 39f3a64
feat(expo): Refactor Google Sign-In to use web-based OAuth flow for A…
chriscanin 07ab559
feat: add Clerk Google Sign-In module for Expo
chriscanin 1808484
feat(expo): Add iOS support for Clerk Google Sign-In and enhance conf…
chriscanin 45e8a4f
feat(expo): Implement iOS URL scheme configuration for Google Sign-In…
chriscanin 27182a3
feat(google-signin): Add GoogleSignInActivityUnavailableException and…
chriscanin ddce471
feat(tests): Refactor Google Sign-In tests to use Clerk's One Tap Sig…
chriscanin 3d79d5b
feat(google-signin): Add native Google Sign-In support for iOS and An…
chriscanin 20069aa
fix(build.gradle): centralize dependency versions for easier updates
chriscanin 4f01a6e
refactor(types): Update imports from '@clerk/types' to '@clerk/shared…
chriscanin b02e6fb
fix(tests): update nonce value in useSignInWithGoogle test to use UUI…
chriscanin 8898517
fix(deps): correct package name for @clerk/expo and add native Google…
chriscanin b33d407
refactor(imports): update imports from '@clerk/clerk-react' to '@cler…
chriscanin cf0b9fa
feat(legacy): Add legacy module support and update nonce generation t…
chriscanin 3443932
Resolves:
chriscanin ecb9aa0
refactor(types): Simplify type exports for Google authentication hook…
chriscanin d819e55
refactor(auth): streamline Google Sign-In logic by consolidating auth…
chriscanin 7d1a476
refactor(expo): restructure plugin to match standard RN package pattern
chriscanin 55d9cba
Merge branch 'main' into chris/mobile-289-expo-google-universal-sign-in
chriscanin b7737f2
fix(tests): Update import statement in useSignInWithGoogle test to ma…
chriscanin 542a664
chore(dependencies): Update @expo/config-plugins, @expo/config-types,…
chriscanin 62478be
Merge branch 'main' into chris/mobile-289-expo-google-universal-sign-in
chriscanin 57ca39d
Merge branch 'main' into chris/mobile-289-expo-google-universal-sign-in
chriscanin 32c626b
Merge branch 'main' into chris/mobile-289-expo-google-universal-sign-in
chriscanin c932cdb
chore(version): Bump @clerk/clerk-js version from 2.19.23 to 5.114.0
chriscanin 888e0b8
chore(version): Downgrade @clerk/expo version from 2.19.26 to 2.19.10
chriscanin 18864ba
Merge branch 'main' into chris/mobile-289-expo-google-universal-sign-in
chriscanin e0e99c1
fix(plugin): update package import path for @clerk/expo
chriscanin 31f233d
refactor(google-one-tap): update module description and usage guidance
chriscanin 29a7c9f
feat(google-one-tap): add new error code for Android activity unavail…
chriscanin 8ab520f
refactor(google-one-tap): simplify Google Sign-In hooks for Android a…
chriscanin 37484d3
chore(package): remove redundant package.json entries
chriscanin a62253e
chore(package): clean up package.json by removing unnecessary entries
chriscanin 9bdc051
chore(package): update peerDependencies in package.json for expo
chriscanin 85129a3
feat(google-one-tap): enhance Google client ID retrieval in useSignIn…
chriscanin 40ad13b
fix(google-one-tap): enhance error handling in Google Sign-In
chriscanin db69b54
Merge branch 'main' into chris/mobile-289-expo-google-universal-sign-in
chriscanin 2d71e61
chore(pnpm): update expo version in pnpm-lock.yaml
chriscanin 5b20334
refactor(expo): update app.plugin.js path and clean up package.json
chriscanin 3408d62
Merge remote-tracking branch 'origin/main' into chris/mobile-289-expo…
chriscanin a2eb579
Merge branch 'main' into chris/mobile-289-expo-google-universal-sign-in
chriscanin b0e2ffc
Merge branch 'main' into chris/mobile-289-expo-google-universal-sign-in
chriscanin c401182
Merge branch 'main' into chris/mobile-289-expo-google-universal-sign-in
chriscanin 568f85a
refactor(expo): update import statement for package.json in withClerk…
chriscanin 3514955
refactor(expo): remove unused SignUpUnsafeMetadata type from Google s…
chriscanin 3cd75c2
Merge branch 'main' into chris/mobile-289-expo-google-universal-sign-in
chriscanin 7f0ba45
chore(expo): add app.plugin.js entry to package.json exports
chriscanin b192d31
Merge branch 'main' into chris/mobile-289-expo-google-universal-sign-in
chriscanin a0f6f2c
resolve merge conflicts.
chriscanin 2bb67e7
Merge branch 'main' into chris/mobile-289-expo-google-universal-sign-in
chriscanin ec7f753
test(expo): enhance useSignInWithGoogle tests with isClerkAPIResponse…
chriscanin File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| '@clerk/expo': minor | ||
| --- | ||
|
|
||
| Add native Google Sign-In support for iOS and Android using built-in native modules. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| android/.gradle |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,64 @@ | ||
| apply plugin: 'com.android.library' | ||
| apply plugin: 'kotlin-android' | ||
|
|
||
| group = 'com.clerk.expo' | ||
| version = '1.0.0' | ||
|
|
||
| // Dependency versions - centralized for easier updates | ||
| // See: https://docs.gradle.org/current/userguide/version_catalogs.html for app-level version catalogs | ||
| ext { | ||
| credentialsVersion = "1.3.0" | ||
| googleIdVersion = "1.1.1" | ||
| kotlinxCoroutinesVersion = "1.7.3" | ||
| } | ||
|
|
||
| def safeExtGet(prop, fallback) { | ||
| rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback | ||
| } | ||
|
|
||
| android { | ||
| namespace "expo.modules.clerk.googlesignin" | ||
|
|
||
| compileSdk safeExtGet("compileSdkVersion", 36) | ||
|
|
||
| defaultConfig { | ||
| minSdk safeExtGet("minSdkVersion", 24) | ||
| targetSdk safeExtGet("targetSdkVersion", 36) | ||
| versionCode 1 | ||
| versionName "1.0.0" | ||
| } | ||
|
|
||
| buildTypes { | ||
| release { | ||
| minifyEnabled false | ||
| } | ||
| } | ||
|
|
||
| compileOptions { | ||
| sourceCompatibility JavaVersion.VERSION_17 | ||
| targetCompatibility JavaVersion.VERSION_17 | ||
| } | ||
|
|
||
| kotlinOptions { | ||
| jvmTarget = "17" | ||
| } | ||
|
|
||
| sourceSets { | ||
| main { | ||
| java.srcDirs = ['src/main/java'] | ||
| } | ||
| } | ||
| } | ||
|
|
||
| dependencies { | ||
| // Expo modules core | ||
| implementation project(':expo-modules-core') | ||
|
|
||
| // Credential Manager for Google Sign-In with nonce support | ||
| implementation "androidx.credentials:credentials:$credentialsVersion" | ||
| implementation "androidx.credentials:credentials-play-services-auth:$credentialsVersion" | ||
| implementation "com.google.android.libraries.identity.googleid:googleid:$googleIdVersion" | ||
|
|
||
| // Coroutines for async operations | ||
| implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlinxCoroutinesVersion" | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| <manifest xmlns:android="http://schemas.android.com/apk/res/android"> | ||
| </manifest> |
264 changes: 264 additions & 0 deletions
264
...ges/expo/android/src/main/java/expo/modules/clerk/googlesignin/ClerkGoogleSignInModule.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,264 @@ | ||
| package expo.modules.clerk.googlesignin | ||
|
|
||
| import android.content.Context | ||
| import androidx.credentials.ClearCredentialStateRequest | ||
| import androidx.credentials.CredentialManager | ||
| import androidx.credentials.CustomCredential | ||
| import androidx.credentials.GetCredentialRequest | ||
| import androidx.credentials.GetCredentialResponse | ||
| import androidx.credentials.exceptions.GetCredentialCancellationException | ||
| import androidx.credentials.exceptions.GetCredentialException | ||
| import androidx.credentials.exceptions.NoCredentialException | ||
| 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 = "" | ||
|
|
||
| @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) | ||
|
|
||
| override fun definition() = ModuleDefinition { | ||
| Name("ClerkGoogleSignIn") | ||
|
|
||
| // Configure the module | ||
| Function("configure") { params: ConfigureParams -> | ||
| webClientId = params.webClientId | ||
| hostedDomain = params.hostedDomain | ||
| autoSelectEnabled = params.autoSelectEnabled ?: false | ||
| } | ||
|
|
||
| // Sign in - attempts automatic sign-in with saved credentials | ||
| AsyncFunction("signIn") { params: SignInParams?, promise: Promise -> | ||
| val clientId = webClientId ?: run { | ||
| promise.reject(GoogleSignInNotConfiguredException()) | ||
| return@AsyncFunction | ||
| } | ||
|
|
||
| val activity = appContext.currentActivity ?: run { | ||
| promise.reject(GoogleSignInActivityUnavailableException()) | ||
| return@AsyncFunction | ||
| } | ||
|
|
||
| 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")) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // Create account - shows account creation UI | ||
| AsyncFunction("createAccount") { params: CreateAccountParams?, promise: Promise -> | ||
| val clientId = webClientId ?: run { | ||
| promise.reject(GoogleSignInNotConfiguredException()) | ||
| return@AsyncFunction | ||
| } | ||
|
|
||
| val activity = appContext.currentActivity ?: run { | ||
| promise.reject(GoogleSignInActivityUnavailableException()) | ||
| return@AsyncFunction | ||
| } | ||
|
|
||
| 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")) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // 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 | ||
| } | ||
|
|
||
| val activity = appContext.currentActivity ?: run { | ||
| promise.reject(GoogleSignInActivityUnavailableException()) | ||
| return@AsyncFunction | ||
| } | ||
|
|
||
| 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")) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // 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")) | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private fun handleSignInResult(result: GetCredentialResponse, promise: Promise) { | ||
| when (val credential = result.credential) { | ||
| is CustomCredential -> { | ||
| if (credential.type == GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL) { | ||
| 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() | ||
| ) | ||
| ) | ||
| )) | ||
| } catch (e: GoogleIdTokenParsingException) { | ||
| promise.reject(GoogleSignInException("Failed to parse Google ID token: ${e.message}")) | ||
| } | ||
| } else { | ||
| promise.reject(GoogleSignInException("Unexpected credential type: ${credential.type}")) | ||
| } | ||
| } | ||
| else -> { | ||
| promise.reject(GoogleSignInException("Unexpected credential type")) | ||
| } | ||
| } | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| module.exports = require('./dist/plugin/withClerkExpo'); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| { | ||
| "platforms": ["android", "ios"], | ||
| "android": { | ||
| "modules": ["expo.modules.clerk.googlesignin.ClerkGoogleSignInModule"] | ||
| }, | ||
| "ios": { | ||
| "modules": ["ClerkGoogleSignInModule"] | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| require 'json' | ||
|
|
||
| package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json'))) | ||
|
|
||
| Pod::Spec.new do |s| | ||
| s.name = 'ClerkGoogleSignIn' | ||
| s.version = package['version'] | ||
| s.summary = 'Native Google Sign-In module for Clerk Expo' | ||
| s.description = 'Native Google Sign-In functionality using Google Sign-In SDK with nonce support for Clerk authentication' | ||
| s.license = package['license'] | ||
| s.author = package['author'] | ||
| s.homepage = package['homepage'] | ||
| s.platforms = { :ios => '13.4' } | ||
| s.swift_version = '5.4' | ||
| 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' | ||
| end |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.