Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/brave-clouds-swim.md
Original file line numberDiff line numberDiff 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
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
android/.gradle
64 changes: 64 additions & 0 deletions packages/expo/android/build.gradle
Original file line numberDiff line numberDiff 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
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
Original file line numberDiff line numberDiff 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
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
module.exports = require('./dist/plugin/withClerkExpo');
9 changes: 9 additions & 0 deletions packages/expo/expo-module.config.json
Original file line numberDiff line numberDiff 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
Original file line numberDiff line numberDiff 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
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/brave-clouds-swim.md
Original file line numberDiff line numberDiff 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
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
android/.gradle
64 changes: 64 additions & 0 deletions packages/expo/android/build.gradle
Original file line numberDiff line numberDiff 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
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
Original file line numberDiff line numberDiff 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
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
module.exports = require('./dist/plugin/withClerkExpo');
9 changes: 9 additions & 0 deletions packages/expo/expo-module.config.json
Original file line numberDiff line numberDiff 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
Original file line numberDiff line numberDiff 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
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/brave-clouds-swim.md
Original file line numberDiff line numberDiff 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
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
android/.gradle
64 changes: 64 additions & 0 deletions packages/expo/android/build.gradle
Original file line numberDiff line numberDiff 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
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
Original file line numberDiff line numberDiff 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
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
module.exports = require('./dist/plugin/withClerkExpo');
9 changes: 9 additions & 0 deletions packages/expo/expo-module.config.json
Original file line numberDiff line numberDiff 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
Original file line numberDiff line numberDiff 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
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/brave-clouds-swim.md
Original file line numberDiff line numberDiff 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
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
android/.gradle
64 changes: 64 additions & 0 deletions packages/expo/android/build.gradle
Original file line numberDiff line numberDiff 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
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
Original file line numberDiff line numberDiff 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
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
module.exports = require('./dist/plugin/withClerkExpo');
9 changes: 9 additions & 0 deletions packages/expo/expo-module.config.json
Original file line numberDiff line numberDiff 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
Original file line numberDiff line numberDiff 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
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/brave-clouds-swim.md
Original file line numberDiff line numberDiff 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
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
android/.gradle
64 changes: 64 additions & 0 deletions packages/expo/android/build.gradle
Original file line numberDiff line numberDiff 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
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
Original file line numberDiff line numberDiff 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
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
module.exports = require('./dist/plugin/withClerkExpo');
9 changes: 9 additions & 0 deletions packages/expo/expo-module.config.json
Original file line numberDiff line numberDiff 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
Original file line numberDiff line numberDiff 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
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/brave-clouds-swim.md
Original file line numberDiff line numberDiff 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
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
android/.gradle
64 changes: 64 additions & 0 deletions packages/expo/android/build.gradle
Original file line numberDiff line numberDiff 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
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
Original file line numberDiff line numberDiff 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
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
module.exports = require('./dist/plugin/withClerkExpo');
9 changes: 9 additions & 0 deletions packages/expo/expo-module.config.json
Original file line numberDiff line numberDiff 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
Original file line numberDiff line numberDiff 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
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/brave-clouds-swim.md
Original file line numberDiff line numberDiff 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
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
android/.gradle
64 changes: 64 additions & 0 deletions packages/expo/android/build.gradle
Original file line numberDiff line numberDiff 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
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
Original file line numberDiff line numberDiff 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
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
module.exports = require('./dist/plugin/withClerkExpo');
9 changes: 9 additions & 0 deletions packages/expo/expo-module.config.json
Original file line numberDiff line numberDiff 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
Original file line numberDiff line numberDiff 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
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/brave-clouds-swim.md
Original file line numberDiff line numberDiff 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
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
android/.gradle
64 changes: 64 additions & 0 deletions packages/expo/android/build.gradle
Original file line numberDiff line numberDiff 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
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
Original file line numberDiff line numberDiff 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
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
module.exports = require('./dist/plugin/withClerkExpo');
9 changes: 9 additions & 0 deletions packages/expo/expo-module.config.json
Original file line numberDiff line numberDiff 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
Original file line numberDiff line numberDiff 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
Loading
Loading