-
Notifications
You must be signed in to change notification settings - Fork 410
feat(clerk-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
Open
chriscanin
wants to merge
13
commits into
main
Choose a base branch
from
chris/mobile-289-expo-google-universal-sign-in
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+1,829
−2
Open
Changes from all commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
2d0053e
feat(expo): Implement Google Sign-In support for Android and iOS
chriscanin 577bf29
test(expo): Enhance mocks for expo-modules-core in test setup
chriscanin e8e1a67
chore(package): remove local dependency on @clerk/clerk-expo
chriscanin 88bf538
feat(expo): Refactor Google Sign-In to use web-based OAuth flow for A…
chriscanin 71d9cdc
feat: add Clerk Google Sign-In module for Expo
chriscanin 04bb659
feat(expo): Add iOS support for Clerk Google Sign-In and enhance conf…
chriscanin 6811d5f
feat(expo): Implement iOS URL scheme configuration for Google Sign-In…
chriscanin 14a5ae6
Merge remote-tracking branch 'origin/main' into chris/mobile-289-expo…
chriscanin 7a9f7de
feat(google-signin): Add GoogleSignInActivityUnavailableException and…
chriscanin 5292f47
Merge branch 'main' into chris/mobile-289-expo-google-universal-sign-in
chriscanin 7724619
feat(tests): Refactor Google Sign-In tests to use Clerk's One Tap Sig…
chriscanin bc11a78
feat(google-signin): Add native Google Sign-In support for iOS and An…
chriscanin 732dc62
Merge branch 'main' into chris/mobile-289-expo-google-universal-sign-in
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/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,56 @@ | ||
| apply plugin: 'com.android.library' | ||
| apply plugin: 'kotlin-android' | ||
|
|
||
| group = 'com.clerk.expo' | ||
| version = '1.0.0' | ||
|
|
||
| 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:1.3.0" | ||
| implementation "androidx.credentials:credentials-play-services-auth:1.3.0" | ||
| implementation "com.google.android.libraries.identity.googleid:googleid:1.1.1" | ||
|
|
||
| // Coroutines for async operations | ||
| implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3" | ||
| } | ||
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,44 @@ | ||
| const { createRunOncePlugin, withInfoPlist } = require('@expo/config-plugins'); | ||
|
|
||
| /** | ||
| * Expo config plugin for @clerk/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. | ||
| */ | ||
| function withClerkGoogleSignIn(config) { | ||
| // Get the iOS URL scheme from environment or config.extra | ||
| // We capture it here before entering the mod callback | ||
| const iosUrlScheme = | ||
| process.env.EXPO_PUBLIC_CLERK_GOOGLE_IOS_URL_SCHEME || config.extra?.EXPO_PUBLIC_CLERK_GOOGLE_IOS_URL_SCHEME; | ||
|
|
||
| if (!iosUrlScheme) { | ||
| // No URL scheme configured, skip iOS configuration | ||
| return config; | ||
| } | ||
|
|
||
| // Add iOS URL scheme for Google Sign-In | ||
| config = withInfoPlist(config, modConfig => { | ||
| if (!Array.isArray(modConfig.modResults.CFBundleURLTypes)) { | ||
| modConfig.modResults.CFBundleURLTypes = []; | ||
| } | ||
|
|
||
| // Check if the scheme is already added to avoid duplicates | ||
| const schemeExists = modConfig.modResults.CFBundleURLTypes.some(urlType => | ||
| urlType.CFBundleURLSchemes?.includes(iosUrlScheme), | ||
| ); | ||
|
|
||
| if (!schemeExists) { | ||
| // Add Google Sign-In URL scheme | ||
| modConfig.modResults.CFBundleURLTypes.push({ | ||
| CFBundleURLSchemes: [iosUrlScheme], | ||
| }); | ||
| } | ||
|
|
||
| return modConfig; | ||
| }); | ||
|
|
||
| return config; | ||
| } | ||
|
|
||
| module.exports = createRunOncePlugin(withClerkGoogleSignIn, '@clerk/clerk-expo', '1.0.0'); |
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"] | ||
| } | ||
| } |
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
unrelated but you can use a version catalog (https://docs.gradle.org/current/userguide/version_catalogs.html) and then setup automated updates for these dependencies