-
Notifications
You must be signed in to change notification settings - Fork 425
feat(clerk-expo): Implement Google Sign-In support for Android and iOS (#7208) #7538
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
Open
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
3022bf4
feat(expo): Implement Google Sign-In support for Android and iOS (#7208)
chriscanin edf4231
fix(changeset): Correct package name for Google Sign-In support in iO...
chriscanin a0738fc
pnpm lock resolution
chriscanin 4dd824f
Merge branch 'release/core-2' into chris/expo-google-signin-core2
chriscanin 668b41b
Merge branch 'release/core-2' into chris/expo-google-signin-core2
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
There are no files selected for viewing
5 changes: 5 additions & 0 deletions
.changeset/brave-clouds-swim.md
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. |
1 change: 1 addition & 0 deletions
packages/expo/.gitignore
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 |
64 changes: 64 additions & 0 deletions
packages/expo/android/build.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" | ||
| } |
2 changes: 2 additions & 0 deletions
packages/expo/android/src/main/AndroidManifest.xml
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
...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")) | ||
| } | ||
| } | ||
| } | ||
| } |
1 change: 1 addition & 0 deletions
packages/expo/app.plugin.js
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'); |
9 changes: 9 additions & 0 deletions
packages/expo/expo-module.config.json
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"] | ||
| } | ||
| } |
22 changes: 22 additions & 0 deletions
packages/expo/ios/ClerkGoogleSignIn.podspec
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.