diff --git a/.changeset/brave-clouds-swim.md b/.changeset/brave-clouds-swim.md
new file mode 100644
index 00000000000..953cc9428af
--- /dev/null
+++ b/.changeset/brave-clouds-swim.md
@@ -0,0 +1,5 @@
+---
+'@clerk/clerk-expo': minor
+---
+
+Add native Google Sign-In support for iOS and Android using built-in native modules.
diff --git a/packages/expo/.gitignore b/packages/expo/.gitignore
new file mode 100644
index 00000000000..7f93ee04caf
--- /dev/null
+++ b/packages/expo/.gitignore
@@ -0,0 +1 @@
+android/.gradle
\ No newline at end of file
diff --git a/packages/expo/android/build.gradle b/packages/expo/android/build.gradle
new file mode 100644
index 00000000000..ee1fab8fa00
--- /dev/null
+++ b/packages/expo/android/build.gradle
@@ -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"
+}
diff --git a/packages/expo/android/src/main/AndroidManifest.xml b/packages/expo/android/src/main/AndroidManifest.xml
new file mode 100644
index 00000000000..a2f47b6057d
--- /dev/null
+++ b/packages/expo/android/src/main/AndroidManifest.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/googlesignin/ClerkGoogleSignInModule.kt b/packages/expo/android/src/main/java/expo/modules/clerk/googlesignin/ClerkGoogleSignInModule.kt
new file mode 100644
index 00000000000..3234fea2214
--- /dev/null
+++ b/packages/expo/android/src/main/java/expo/modules/clerk/googlesignin/ClerkGoogleSignInModule.kt
@@ -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"))
+ }
+ }
+ }
+}
diff --git a/packages/expo/app.plugin.js b/packages/expo/app.plugin.js
new file mode 100644
index 00000000000..65835131de7
--- /dev/null
+++ b/packages/expo/app.plugin.js
@@ -0,0 +1 @@
+module.exports = require('./dist/plugin/withClerkExpo');
diff --git a/packages/expo/expo-module.config.json b/packages/expo/expo-module.config.json
new file mode 100644
index 00000000000..e59f14eef13
--- /dev/null
+++ b/packages/expo/expo-module.config.json
@@ -0,0 +1,9 @@
+{
+ "platforms": ["android", "ios"],
+ "android": {
+ "modules": ["expo.modules.clerk.googlesignin.ClerkGoogleSignInModule"]
+ },
+ "ios": {
+ "modules": ["ClerkGoogleSignInModule"]
+ }
+}
diff --git a/packages/expo/ios/ClerkGoogleSignIn.podspec b/packages/expo/ios/ClerkGoogleSignIn.podspec
new file mode 100644
index 00000000000..be0f3551b2b
--- /dev/null
+++ b/packages/expo/ios/ClerkGoogleSignIn.podspec
@@ -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
diff --git a/packages/expo/ios/ClerkGoogleSignInModule.swift b/packages/expo/ios/ClerkGoogleSignInModule.swift
new file mode 100644
index 00000000000..c06f85b8031
--- /dev/null
+++ b/packages/expo/ios/ClerkGoogleSignInModule.swift
@@ -0,0 +1,229 @@
+import ExpoModulesCore
+import GoogleSignIn
+
+public class ClerkGoogleSignInModule: Module {
+ private var clientId: String?
+ private var hostedDomain: String?
+
+ public func definition() -> ModuleDefinition {
+ Name("ClerkGoogleSignIn")
+
+ // Configure the module
+ Function("configure") { (params: ConfigureParams) in
+ self.clientId = params.iosClientId ?? params.webClientId
+ self.hostedDomain = params.hostedDomain
+
+ // Set the configuration globally
+ // clientID: iOS client ID for OAuth flow
+ // serverClientID: Web client ID for token audience (what Clerk backend verifies)
+ if let clientId = self.clientId {
+ let config = GIDConfiguration(
+ clientID: clientId,
+ serverClientID: params.webClientId
+ )
+ GIDSignIn.sharedInstance.configuration = config
+ }
+ }
+
+ // Sign in - attempts sign-in with hint if available
+ AsyncFunction("signIn") { (params: SignInParams?, promise: Promise) in
+ guard self.clientId != nil else {
+ promise.reject(NotConfiguredException())
+ return
+ }
+
+ DispatchQueue.main.async {
+ guard let presentingVC = self.getPresentingViewController() else {
+ promise.reject(GoogleSignInException(message: "No presenting view controller available"))
+ return
+ }
+
+ // Build sign-in hint if filtering by authorized accounts
+ let hint: String? = params?.filterByAuthorizedAccounts == true
+ ? GIDSignIn.sharedInstance.currentUser?.profile?.email
+ : nil
+
+ GIDSignIn.sharedInstance.signIn(
+ withPresenting: presentingVC,
+ hint: hint,
+ additionalScopes: nil,
+ nonce: params?.nonce
+ ) { result, error in
+ self.handleSignInResult(result: result, error: error, promise: promise)
+ }
+ }
+ }
+
+ // Create account - shows account creation UI (same as sign in on iOS)
+ AsyncFunction("createAccount") { (params: CreateAccountParams?, promise: Promise) in
+ guard self.clientId != nil else {
+ promise.reject(NotConfiguredException())
+ return
+ }
+
+ DispatchQueue.main.async {
+ guard let presentingVC = self.getPresentingViewController() else {
+ promise.reject(GoogleSignInException(message: "No presenting view controller available"))
+ return
+ }
+
+ GIDSignIn.sharedInstance.signIn(
+ withPresenting: presentingVC,
+ hint: nil,
+ additionalScopes: nil,
+ nonce: params?.nonce
+ ) { result, error in
+ self.handleSignInResult(result: result, error: error, promise: promise)
+ }
+ }
+ }
+
+ // Explicit sign-in - uses standard Google Sign-In flow
+ AsyncFunction("presentExplicitSignIn") { (params: ExplicitSignInParams?, promise: Promise) in
+ guard self.clientId != nil else {
+ promise.reject(NotConfiguredException())
+ return
+ }
+
+ DispatchQueue.main.async {
+ guard let presentingVC = self.getPresentingViewController() else {
+ promise.reject(GoogleSignInException(message: "No presenting view controller available"))
+ return
+ }
+
+ GIDSignIn.sharedInstance.signIn(
+ withPresenting: presentingVC,
+ hint: nil,
+ additionalScopes: nil,
+ nonce: params?.nonce
+ ) { result, error in
+ self.handleSignInResult(result: result, error: error, promise: promise)
+ }
+ }
+ }
+
+ // Sign out - clears credential state
+ AsyncFunction("signOut") { (promise: Promise) in
+ GIDSignIn.sharedInstance.signOut()
+ promise.resolve(nil)
+ }
+ }
+
+ private func getPresentingViewController() -> UIViewController? {
+ guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
+ let window = scene.windows.first,
+ let rootVC = window.rootViewController else {
+ return nil
+ }
+
+ var topVC = rootVC
+ while let presentedVC = topVC.presentedViewController {
+ topVC = presentedVC
+ }
+ return topVC
+ }
+
+ private func handleSignInResult(result: GIDSignInResult?, error: Error?, promise: Promise) {
+ if let error = error {
+ let nsError = error as NSError
+
+ // Check for user cancellation
+ if nsError.domain == kGIDSignInErrorDomain && nsError.code == GIDSignInError.canceled.rawValue {
+ promise.reject(SignInCancelledException())
+ return
+ }
+
+ promise.reject(GoogleSignInException(message: error.localizedDescription))
+ return
+ }
+
+ guard let result = result,
+ let idToken = result.user.idToken?.tokenString else {
+ promise.reject(GoogleSignInException(message: "No ID token received"))
+ return
+ }
+
+ let user = result.user
+ let profile = user.profile
+
+ let response: [String: Any] = [
+ "type": "success",
+ "data": [
+ "idToken": idToken,
+ "user": [
+ "id": user.userID ?? "",
+ "email": profile?.email ?? "",
+ "name": profile?.name ?? "",
+ "givenName": profile?.givenName ?? "",
+ "familyName": profile?.familyName ?? "",
+ "photo": profile?.imageURL(withDimension: 200)?.absoluteString ?? NSNull()
+ ] as [String: Any]
+ ] as [String: Any]
+ ]
+
+ promise.resolve(response)
+ }
+}
+
+// MARK: - Records
+
+struct ConfigureParams: Record {
+ @Field
+ var webClientId: String = ""
+
+ @Field
+ var iosClientId: String?
+
+ @Field
+ var hostedDomain: String?
+
+ @Field
+ var autoSelectEnabled: Bool?
+}
+
+struct SignInParams: Record {
+ @Field
+ var nonce: String?
+
+ @Field
+ var filterByAuthorizedAccounts: Bool?
+}
+
+struct CreateAccountParams: Record {
+ @Field
+ var nonce: String?
+}
+
+struct ExplicitSignInParams: Record {
+ @Field
+ var nonce: String?
+}
+
+// MARK: - Exceptions
+
+class SignInCancelledException: Exception {
+ override var code: String { "SIGN_IN_CANCELLED" }
+ override var reason: String { "User cancelled the sign-in flow" }
+}
+
+class NoSavedCredentialException: Exception {
+ override var code: String { "NO_SAVED_CREDENTIAL_FOUND" }
+ override var reason: String { "No saved credential found" }
+}
+
+class NotConfiguredException: Exception {
+ override var code: String { "NOT_CONFIGURED" }
+ override var reason: String { "Google Sign-In is not configured. Call configure() first." }
+}
+
+class GoogleSignInException: Exception {
+ private let errorMessage: String
+
+ init(message: String) {
+ self.errorMessage = message
+ super.init()
+ }
+
+ override var code: String { "GOOGLE_SIGN_IN_ERROR" }
+ override var reason: String { errorMessage }
+}
diff --git a/packages/expo/package.json b/packages/expo/package.json
index 3c57e4daae6..ef767f3976b 100644
--- a/packages/expo/package.json
+++ b/packages/expo/package.json
@@ -55,7 +55,12 @@
"./experimental": {
"types": "./dist/experimental.d.ts",
"default": "./dist/experimental.js"
- }
+ },
+ "./legacy": {
+ "types": "./dist/legacy.d.ts",
+ "default": "./dist/legacy.js"
+ },
+ "./app.plugin.js": "./app.plugin.js"
},
"main": "./dist/index.js",
"source": "./src/index.ts",
@@ -67,7 +72,11 @@
"passkeys",
"secure-store",
"resource-cache",
- "token-cache"
+ "token-cache",
+ "android",
+ "ios",
+ "expo-module.config.json",
+ "app.plugin.js"
],
"scripts": {
"build": "tsup",
@@ -93,11 +102,14 @@
},
"devDependencies": {
"@clerk/expo-passkeys": "workspace:*",
+ "@expo/config-plugins": "^54.0.4",
"@types/base-64": "^1.0.2",
"expo-apple-authentication": "^7.2.4",
"expo-auth-session": "^5.4.0",
+ "expo-constants": "^18.0.0",
"expo-crypto": "^15.0.7",
"expo-local-authentication": "^13.8.0",
+ "expo-modules-core": "^3.0.0",
"expo-secure-store": "^12.8.1",
"expo-web-browser": "^12.8.2",
"react-native": "^0.81.4"
@@ -106,8 +118,10 @@
"@clerk/expo-passkeys": ">=0.0.6",
"expo-apple-authentication": ">=7.0.0",
"expo-auth-session": ">=5",
+ "expo-constants": ">=12",
"expo-crypto": ">=12",
"expo-local-authentication": ">=13.5.0",
+ "expo-modules-core": ">=3.0.0",
"expo-secure-store": ">=12.4.0",
"expo-web-browser": ">=12.5.0",
"react": "catalog:peer-react",
@@ -121,6 +135,9 @@
"expo-apple-authentication": {
"optional": true
},
+ "expo-constants": {
+ "optional": true
+ },
"expo-crypto": {
"optional": true
},
diff --git a/packages/expo/src/google-one-tap/ClerkGoogleOneTapSignIn.ts b/packages/expo/src/google-one-tap/ClerkGoogleOneTapSignIn.ts
new file mode 100644
index 00000000000..5dc89a69982
--- /dev/null
+++ b/packages/expo/src/google-one-tap/ClerkGoogleOneTapSignIn.ts
@@ -0,0 +1,189 @@
+import { requireNativeModule } from 'expo-modules-core';
+
+import type {
+ CancelledResponse,
+ ConfigureParams,
+ CreateAccountParams,
+ ExplicitSignInParams,
+ NoSavedCredentialFound,
+ OneTapResponse,
+ OneTapSuccessResponse,
+ SignInParams,
+} from './types';
+
+// Type for the native module methods
+interface ClerkGoogleSignInNativeModule {
+ configure(params: ConfigureParams): void;
+ signIn(params: SignInParams): Promise;
+ createAccount(params: CreateAccountParams): Promise;
+ presentExplicitSignIn(params: ExplicitSignInParams): Promise;
+ signOut(): Promise;
+}
+
+// Lazy-load the native module to avoid crashes when not available
+let _nativeModule: ClerkGoogleSignInNativeModule | null = null;
+
+function getNativeModule(): ClerkGoogleSignInNativeModule {
+ if (!_nativeModule) {
+ _nativeModule = requireNativeModule('ClerkGoogleSignIn');
+ }
+ return _nativeModule;
+}
+
+/**
+ * Check if a response indicates the user cancelled the sign-in flow.
+ */
+export function isCancelledResponse(response: OneTapResponse): response is CancelledResponse {
+ return response.type === 'cancelled';
+}
+
+/**
+ * Check if a response indicates no saved credential was found.
+ */
+export function isNoSavedCredentialFoundResponse(response: OneTapResponse): response is NoSavedCredentialFound {
+ return response.type === 'noSavedCredentialFound';
+}
+
+/**
+ * Check if a response is a successful sign-in.
+ */
+export function isSuccessResponse(response: OneTapResponse): response is OneTapSuccessResponse {
+ return response.type === 'success';
+}
+
+/**
+ * Check if an error has code and message properties (Google Sign-In error).
+ */
+export function isErrorWithCode(error: unknown): error is { code: string; message: string } {
+ return (
+ error !== null &&
+ typeof error === 'object' &&
+ 'code' in error &&
+ typeof (error as { code: unknown }).code === 'string' &&
+ 'message' in error &&
+ typeof (error as { message: unknown }).message === 'string'
+ );
+}
+
+/**
+ * Internal Google One Tap Sign-In module.
+ *
+ * This module provides native Google Sign-In functionality using Google's
+ * Credential Manager API with full nonce support for replay attack protection.
+ *
+ * @internal This is an internal module. Use the `useSignInWithGoogle` hook instead.
+ * @platform Android, iOS
+ */
+export const ClerkGoogleOneTapSignIn = {
+ /**
+ * Configure Google Sign-In. Must be called before any sign-in methods.
+ *
+ * @param params - Configuration parameters
+ * @param params.webClientId - The web client ID from Google Cloud Console (required)
+ * @param params.hostedDomain - Optional domain restriction
+ * @param params.autoSelectEnabled - Auto-select for single credential (default: false)
+ */
+ configure(params: ConfigureParams): void {
+ getNativeModule().configure(params);
+ },
+
+ /**
+ * Attempt to sign in with saved credentials (One Tap).
+ *
+ * This method will show the One Tap UI if there are saved credentials,
+ * or return a "noSavedCredentialFound" response if there are none.
+ *
+ * @param params - Sign-in parameters
+ * @param params.nonce - Cryptographic nonce for replay protection
+ * @param params.filterByAuthorizedAccounts - Only show previously authorized accounts (default: true)
+ *
+ * @returns Promise resolving to OneTapResponse
+ */
+ async signIn(params?: SignInParams): Promise {
+ try {
+ return await getNativeModule().signIn(params ?? {});
+ } catch (error) {
+ if (isErrorWithCode(error)) {
+ if (error.code === 'SIGN_IN_CANCELLED') {
+ return { type: 'cancelled', data: null };
+ }
+ if (error.code === 'NO_SAVED_CREDENTIAL_FOUND') {
+ return { type: 'noSavedCredentialFound', data: null };
+ }
+ }
+ throw error;
+ }
+ },
+
+ /**
+ * Create a new account (shows all Google accounts).
+ *
+ * This method shows the account picker with all available Google accounts,
+ * not just previously authorized ones.
+ *
+ * @param params - Create account parameters
+ * @param params.nonce - Cryptographic nonce for replay protection
+ *
+ * @returns Promise resolving to OneTapResponse
+ */
+ async createAccount(params?: CreateAccountParams): Promise {
+ try {
+ return await getNativeModule().createAccount(params ?? {});
+ } catch (error) {
+ if (isErrorWithCode(error)) {
+ if (error.code === 'SIGN_IN_CANCELLED') {
+ return { type: 'cancelled', data: null };
+ }
+ if (error.code === 'NO_SAVED_CREDENTIAL_FOUND') {
+ return { type: 'noSavedCredentialFound', data: null };
+ }
+ }
+ throw error;
+ }
+ },
+
+ /**
+ * Present explicit sign-in UI (Google Sign-In button flow).
+ *
+ * This shows the full Google Sign-In UI, similar to clicking a
+ * "Sign in with Google" button.
+ *
+ * @param params - Explicit sign-in parameters
+ * @param params.nonce - Cryptographic nonce for replay protection
+ *
+ * @returns Promise resolving to OneTapResponse
+ */
+ async presentExplicitSignIn(params?: ExplicitSignInParams): Promise {
+ try {
+ return await getNativeModule().presentExplicitSignIn(params ?? {});
+ } catch (error) {
+ if (isErrorWithCode(error)) {
+ if (error.code === 'SIGN_IN_CANCELLED') {
+ return { type: 'cancelled', data: null };
+ }
+ }
+ throw error;
+ }
+ },
+
+ /**
+ * Sign out and clear credential state.
+ *
+ * This disables automatic sign-in until the user signs in again.
+ */
+ async signOut(): Promise {
+ await getNativeModule().signOut();
+ },
+};
+
+export type {
+ ConfigureParams,
+ SignInParams,
+ CreateAccountParams,
+ ExplicitSignInParams,
+ OneTapResponse,
+ OneTapSuccessResponse,
+ CancelledResponse,
+ NoSavedCredentialFound,
+ GoogleUser,
+} from './types';
diff --git a/packages/expo/src/google-one-tap/index.ts b/packages/expo/src/google-one-tap/index.ts
new file mode 100644
index 00000000000..1877117ef61
--- /dev/null
+++ b/packages/expo/src/google-one-tap/index.ts
@@ -0,0 +1,21 @@
+export {
+ ClerkGoogleOneTapSignIn,
+ isCancelledResponse,
+ isNoSavedCredentialFoundResponse,
+ isSuccessResponse,
+ isErrorWithCode,
+} from './ClerkGoogleOneTapSignIn';
+
+export type {
+ ConfigureParams,
+ SignInParams,
+ CreateAccountParams,
+ ExplicitSignInParams,
+ OneTapResponse,
+ OneTapSuccessResponse,
+ CancelledResponse,
+ NoSavedCredentialFound,
+ GoogleUser,
+ GoogleSignInError,
+ GoogleSignInErrorCode,
+} from './types';
diff --git a/packages/expo/src/google-one-tap/types.ts b/packages/expo/src/google-one-tap/types.ts
new file mode 100644
index 00000000000..fdc8e95df74
--- /dev/null
+++ b/packages/expo/src/google-one-tap/types.ts
@@ -0,0 +1,169 @@
+/**
+ * Configuration parameters for Google One Tap Sign-In.
+ */
+export type ConfigureParams = {
+ /**
+ * The web client ID from Google Cloud Console.
+ * This is required for Google Sign-In to work.
+ * On iOS, this is used as the serverClientID for token audience.
+ */
+ webClientId: string;
+
+ /**
+ * The iOS client ID from Google Cloud Console.
+ * This is only used on iOS for the OAuth flow.
+ * If not provided, webClientId will be used.
+ * @platform iOS
+ */
+ iosClientId?: string;
+
+ /**
+ * Optional hosted domain to restrict sign-in to a specific domain.
+ */
+ hostedDomain?: string;
+
+ /**
+ * Whether to enable auto-select for returning users.
+ * When true, if only one credential is available, it will be automatically selected.
+ * @default false
+ */
+ autoSelectEnabled?: boolean;
+};
+
+/**
+ * Parameters for the signIn method.
+ */
+export type SignInParams = {
+ /**
+ * A cryptographically random string used to mitigate replay attacks.
+ * The nonce will be included in the ID token.
+ */
+ nonce?: string;
+
+ /**
+ * Whether to filter credentials to only show accounts that have been
+ * previously authorized for this app.
+ * @default true
+ */
+ filterByAuthorizedAccounts?: boolean;
+};
+
+/**
+ * Parameters for the createAccount method.
+ */
+export type CreateAccountParams = {
+ /**
+ * A cryptographically random string used to mitigate replay attacks.
+ * The nonce will be included in the ID token.
+ */
+ nonce?: string;
+};
+
+/**
+ * Parameters for the presentExplicitSignIn method.
+ */
+export type ExplicitSignInParams = {
+ /**
+ * A cryptographically random string used to mitigate replay attacks.
+ * The nonce will be included in the ID token.
+ */
+ nonce?: string;
+};
+
+/**
+ * User information returned from Google Sign-In.
+ */
+export type GoogleUser = {
+ /**
+ * The user's unique Google identifier (OIDC "sub" claim).
+ * This is distinct from the user's email address.
+ */
+ id: string;
+
+ /**
+ * The user's email address.
+ */
+ email: string;
+
+ /**
+ * The user's full display name.
+ */
+ name: string | null;
+
+ /**
+ * The user's given (first) name.
+ */
+ givenName: string | null;
+
+ /**
+ * The user's family (last) name.
+ */
+ familyName: string | null;
+
+ /**
+ * URL to the user's profile picture.
+ */
+ photo: string | null;
+};
+
+/**
+ * Successful sign-in response.
+ */
+export type OneTapSuccessResponse = {
+ type: 'success';
+ data: {
+ /**
+ * The Google ID token containing user information and nonce.
+ */
+ idToken: string;
+
+ /**
+ * The user's information.
+ */
+ user: GoogleUser;
+ };
+};
+
+/**
+ * Response when the user cancels the sign-in flow.
+ */
+export type CancelledResponse = {
+ type: 'cancelled';
+ data: null;
+};
+
+/**
+ * Response when no saved credential is found.
+ */
+export type NoSavedCredentialFound = {
+ type: 'noSavedCredentialFound';
+ data: null;
+};
+
+/**
+ * Union type for all possible One Tap responses.
+ */
+export type OneTapResponse = OneTapSuccessResponse | CancelledResponse | NoSavedCredentialFound;
+
+/**
+ * Error codes that can be thrown by the Google Sign-In module.
+ *
+ * - `SIGN_IN_CANCELLED`: User cancelled the sign-in flow
+ * - `NO_SAVED_CREDENTIAL_FOUND`: No saved credentials available for One Tap
+ * - `NOT_CONFIGURED`: Module not configured before use
+ * - `GOOGLE_SIGN_IN_ERROR`: Generic Google Sign-In error
+ * - `E_ACTIVITY_UNAVAILABLE`: Android activity unavailable (GoogleSignInActivityUnavailableException)
+ */
+export type GoogleSignInErrorCode =
+ | 'SIGN_IN_CANCELLED'
+ | 'NO_SAVED_CREDENTIAL_FOUND'
+ | 'NOT_CONFIGURED'
+ | 'GOOGLE_SIGN_IN_ERROR'
+ | 'E_ACTIVITY_UNAVAILABLE';
+
+/**
+ * Error thrown by the Google Sign-In module.
+ */
+export interface GoogleSignInError extends Error {
+ code: GoogleSignInErrorCode;
+}
diff --git a/packages/expo/src/hooks/__tests__/useSignInWithGoogle.test.ts b/packages/expo/src/hooks/__tests__/useSignInWithGoogle.test.ts
new file mode 100644
index 00000000000..3cc59983b12
--- /dev/null
+++ b/packages/expo/src/hooks/__tests__/useSignInWithGoogle.test.ts
@@ -0,0 +1,290 @@
+import { renderHook } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
+
+import { useSignInWithGoogle } from '../useSignInWithGoogle.android';
+
+const mocks = vi.hoisted(() => {
+ return {
+ useClerk: vi.fn(),
+ ClerkGoogleOneTapSignIn: {
+ configure: vi.fn(),
+ presentExplicitSignIn: vi.fn(),
+ },
+ isSuccessResponse: vi.fn(),
+ isClerkAPIResponseError: vi.fn(),
+ };
+});
+
+vi.mock('@clerk/clerk-react', () => {
+ return {
+ useClerk: mocks.useClerk,
+ };
+});
+
+vi.mock('@clerk/shared/error', async importOriginal => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ isClerkAPIResponseError: mocks.isClerkAPIResponseError,
+ };
+});
+
+vi.mock('../../google-one-tap', async importOriginal => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ ClerkGoogleOneTapSignIn: mocks.ClerkGoogleOneTapSignIn,
+ isSuccessResponse: mocks.isSuccessResponse,
+ };
+});
+
+vi.mock('react-native', () => {
+ return {
+ Platform: {
+ OS: 'android',
+ },
+ };
+});
+
+vi.mock('expo-modules-core', () => {
+ return {
+ EventEmitter: vi.fn(),
+ requireNativeModule: vi.fn(),
+ };
+});
+
+vi.mock('expo-constants', () => {
+ return {
+ default: {
+ expoConfig: {
+ extra: {
+ EXPO_PUBLIC_CLERK_GOOGLE_WEB_CLIENT_ID: 'mock-web-client-id.apps.googleusercontent.com',
+ },
+ },
+ },
+ };
+});
+
+vi.mock('expo-crypto', () => {
+ return {
+ randomUUID: vi.fn(() => 'mock-uuid-nonce'),
+ digestStringAsync: vi.fn(() => Promise.resolve('mock-hashed-nonce')),
+ CryptoDigestAlgorithm: {
+ SHA256: 'SHA256',
+ },
+ };
+});
+
+describe('useSignInWithGoogle', () => {
+ const mockSignIn = {
+ create: vi.fn(),
+ createdSessionId: 'test-session-id',
+ firstFactorVerification: {
+ status: 'verified',
+ },
+ };
+
+ const mockSignUp = {
+ create: vi.fn(),
+ createdSessionId: null,
+ };
+
+ const mockSetActive = vi.fn();
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+
+ mocks.useClerk.mockReturnValue({
+ loaded: true,
+ setActive: mockSetActive,
+ client: {
+ signIn: mockSignIn,
+ signUp: mockSignUp,
+ },
+ });
+
+ // Default to false - tests that need this can override
+ mocks.isClerkAPIResponseError.mockReturnValue(false);
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ describe('startGoogleAuthenticationFlow', () => {
+ test('should return the hook with startGoogleAuthenticationFlow function', () => {
+ const { result } = renderHook(() => useSignInWithGoogle());
+
+ expect(result.current).toHaveProperty('startGoogleAuthenticationFlow');
+ expect(typeof result.current.startGoogleAuthenticationFlow).toBe('function');
+ });
+
+ test('should successfully sign in existing user', async () => {
+ const mockIdToken = 'mock-id-token';
+ mocks.ClerkGoogleOneTapSignIn.presentExplicitSignIn.mockResolvedValue({
+ type: 'success',
+ data: { idToken: mockIdToken },
+ });
+ mocks.isSuccessResponse.mockReturnValue(true);
+
+ mockSignIn.create.mockResolvedValue(undefined);
+ mockSignIn.firstFactorVerification.status = 'verified';
+ mockSignIn.createdSessionId = 'test-session-id';
+
+ const { result } = renderHook(() => useSignInWithGoogle());
+
+ const response = await result.current.startGoogleAuthenticationFlow();
+
+ expect(mocks.ClerkGoogleOneTapSignIn.configure).toHaveBeenCalledWith({
+ webClientId: 'mock-web-client-id.apps.googleusercontent.com',
+ });
+ expect(mocks.ClerkGoogleOneTapSignIn.presentExplicitSignIn).toHaveBeenCalledWith({
+ nonce: 'mock-uuid-nonce',
+ });
+ expect(mockSignIn.create).toHaveBeenCalledWith({
+ strategy: 'google_one_tap',
+ token: mockIdToken,
+ });
+ expect(response.createdSessionId).toBe('test-session-id');
+ expect(response.setActive).toBe(mockSetActive);
+ });
+
+ test('should handle transfer flow for new user', async () => {
+ const mockIdToken = 'mock-id-token';
+ mocks.ClerkGoogleOneTapSignIn.presentExplicitSignIn.mockResolvedValue({
+ type: 'success',
+ data: { idToken: mockIdToken },
+ });
+ mocks.isSuccessResponse.mockReturnValue(true);
+
+ mockSignIn.create.mockResolvedValue(undefined);
+ mockSignIn.firstFactorVerification.status = 'transferable';
+
+ const mockSignUpWithSession = { ...mockSignUp, createdSessionId: 'new-user-session-id' };
+ mocks.useClerk.mockReturnValue({
+ loaded: true,
+ setActive: mockSetActive,
+ client: {
+ signIn: mockSignIn,
+ signUp: mockSignUpWithSession,
+ },
+ });
+
+ const { result } = renderHook(() => useSignInWithGoogle());
+
+ const response = await result.current.startGoogleAuthenticationFlow({
+ unsafeMetadata: { source: 'test' },
+ });
+
+ expect(mockSignIn.create).toHaveBeenCalledWith({
+ strategy: 'google_one_tap',
+ token: mockIdToken,
+ });
+ expect(mockSignUpWithSession.create).toHaveBeenCalledWith({
+ transfer: true,
+ unsafeMetadata: { source: 'test' },
+ });
+ expect(response.createdSessionId).toBe('new-user-session-id');
+ });
+
+ test('should handle user cancellation gracefully', async () => {
+ mocks.ClerkGoogleOneTapSignIn.presentExplicitSignIn.mockResolvedValue({
+ type: 'cancelled',
+ data: null,
+ });
+ mocks.isSuccessResponse.mockReturnValue(false);
+
+ const { result } = renderHook(() => useSignInWithGoogle());
+
+ const response = await result.current.startGoogleAuthenticationFlow();
+
+ expect(response.createdSessionId).toBe(null);
+ expect(response.setActive).toBe(mockSetActive);
+ });
+
+ test('should handle SIGN_IN_CANCELLED error code', async () => {
+ const cancelError = Object.assign(new Error('User canceled'), { code: 'SIGN_IN_CANCELLED' });
+ mocks.ClerkGoogleOneTapSignIn.presentExplicitSignIn.mockRejectedValue(cancelError);
+
+ const { result } = renderHook(() => useSignInWithGoogle());
+
+ const response = await result.current.startGoogleAuthenticationFlow();
+
+ expect(response.createdSessionId).toBe(null);
+ expect(response.setActive).toBe(mockSetActive);
+ });
+
+ test('should return early when clerk is not loaded', async () => {
+ mocks.useClerk.mockReturnValue({
+ loaded: false,
+ setActive: mockSetActive,
+ client: null,
+ });
+
+ const { result } = renderHook(() => useSignInWithGoogle());
+
+ const response = await result.current.startGoogleAuthenticationFlow();
+
+ expect(mocks.ClerkGoogleOneTapSignIn.configure).not.toHaveBeenCalled();
+ expect(mocks.ClerkGoogleOneTapSignIn.presentExplicitSignIn).not.toHaveBeenCalled();
+ expect(response.createdSessionId).toBe(null);
+ });
+
+ test('should fall back to signUp when external_account_not_found error occurs', async () => {
+ const mockIdToken = 'mock-id-token';
+ mocks.ClerkGoogleOneTapSignIn.presentExplicitSignIn.mockResolvedValue({
+ type: 'success',
+ data: { idToken: mockIdToken },
+ });
+ mocks.isSuccessResponse.mockReturnValue(true);
+
+ // Mock signIn.create to throw external_account_not_found Clerk error
+ const externalAccountError = {
+ errors: [{ code: 'external_account_not_found' }],
+ message: 'External account not found',
+ };
+ mockSignIn.create.mockRejectedValue(externalAccountError);
+
+ // Mock isClerkAPIResponseError to return true for this error
+ mocks.isClerkAPIResponseError.mockReturnValue(true);
+
+ // Mock signUp.create to succeed with a new session
+ const mockSignUpWithSession = {
+ ...mockSignUp,
+ create: vi.fn().mockResolvedValue(undefined),
+ createdSessionId: 'new-signup-session-id',
+ };
+ mocks.useClerk.mockReturnValue({
+ loaded: true,
+ setActive: mockSetActive,
+ client: {
+ signIn: mockSignIn,
+ signUp: mockSignUpWithSession,
+ },
+ });
+
+ const { result } = renderHook(() => useSignInWithGoogle());
+
+ const response = await result.current.startGoogleAuthenticationFlow({
+ unsafeMetadata: { referral: 'google' },
+ });
+
+ // Verify signIn.create was called first
+ expect(mockSignIn.create).toHaveBeenCalledWith({
+ strategy: 'google_one_tap',
+ token: mockIdToken,
+ });
+
+ // Verify signUp.create was called as fallback with the token
+ expect(mockSignUpWithSession.create).toHaveBeenCalledWith({
+ strategy: 'google_one_tap',
+ token: mockIdToken,
+ unsafeMetadata: { referral: 'google' },
+ });
+
+ // Verify the session was created
+ expect(response.createdSessionId).toBe('new-signup-session-id');
+ expect(response.setActive).toBe(mockSetActive);
+ });
+ });
+});
diff --git a/packages/expo/src/hooks/index.ts b/packages/expo/src/hooks/index.ts
index 2cdac716738..7207350ae0a 100644
--- a/packages/expo/src/hooks/index.ts
+++ b/packages/expo/src/hooks/index.ts
@@ -12,6 +12,7 @@ export {
} from '@clerk/clerk-react';
export * from './useSignInWithApple';
+export * from './useSignInWithGoogle';
export * from './useSSO';
export * from './useOAuth';
export * from './useAuth';
diff --git a/packages/expo/src/hooks/useSignInWithGoogle.android.ts b/packages/expo/src/hooks/useSignInWithGoogle.android.ts
new file mode 100644
index 00000000000..e7f032cbaf4
--- /dev/null
+++ b/packages/expo/src/hooks/useSignInWithGoogle.android.ts
@@ -0,0 +1,48 @@
+import { createUseSignInWithGoogle } from './useSignInWithGoogle.shared';
+export type {
+ StartGoogleAuthenticationFlowParams,
+ StartGoogleAuthenticationFlowReturnType,
+} from './useSignInWithGoogle.types';
+
+/**
+ * Hook for native Google Authentication on Android using Clerk's built-in Google One Tap module.
+ *
+ * This hook provides a simplified way to authenticate users with their Google account
+ * using the native Android Google Sign-In UI with Credential Manager. The authentication
+ * flow automatically handles the ID token exchange with Clerk's backend and manages
+ * the transfer flow between sign-in and sign-up.
+ *
+ * Features:
+ * - Native Google One Tap UI
+ * - Built-in nonce support for replay attack protection
+ * - No additional dependencies required
+ *
+ * @example
+ * ```tsx
+ * import { useSignInWithGoogle } from '@clerk/clerk-expo';
+ * import { Button } from 'react-native';
+ *
+ * function GoogleSignInButton() {
+ * const { startGoogleAuthenticationFlow } = useSignInWithGoogle();
+ *
+ * const onPress = async () => {
+ * try {
+ * const { createdSessionId, setActive } = await startGoogleAuthenticationFlow();
+ *
+ * if (createdSessionId && setActive) {
+ * await setActive({ session: createdSessionId });
+ * }
+ * } catch (err) {
+ * console.error('Google Authentication error:', err);
+ * }
+ * };
+ *
+ * return ;
+ * }
+ * ```
+ *
+ * @platform Android - This is the Android-specific implementation using Credential Manager
+ *
+ * @returns An object containing the `startGoogleAuthenticationFlow` function
+ */
+export const useSignInWithGoogle = createUseSignInWithGoogle({ requiresIosClientId: false });
diff --git a/packages/expo/src/hooks/useSignInWithGoogle.ios.ts b/packages/expo/src/hooks/useSignInWithGoogle.ios.ts
new file mode 100644
index 00000000000..f278815e27f
--- /dev/null
+++ b/packages/expo/src/hooks/useSignInWithGoogle.ios.ts
@@ -0,0 +1,48 @@
+import { createUseSignInWithGoogle } from './useSignInWithGoogle.shared';
+export type {
+ StartGoogleAuthenticationFlowParams,
+ StartGoogleAuthenticationFlowReturnType,
+} from './useSignInWithGoogle.types';
+
+/**
+ * Hook for native Google Authentication on iOS using Clerk's built-in Google Sign-In module.
+ *
+ * This hook provides a simplified way to authenticate users with their Google account
+ * using the native iOS Google Sign-In UI. The authentication flow automatically
+ * handles the ID token exchange with Clerk's backend and manages the transfer flow
+ * between sign-in and sign-up.
+ *
+ * Features:
+ * - Native Google Sign-In UI
+ * - Built-in nonce support for replay attack protection
+ * - No additional dependencies required
+ *
+ * @example
+ * ```tsx
+ * import { useSignInWithGoogle } from '@clerk/clerk-expo';
+ * import { Button } from 'react-native';
+ *
+ * function GoogleSigninButton() {
+ * const { startGoogleAuthenticationFlow } = useSignInWithGoogle();
+ *
+ * const onPress = async () => {
+ * try {
+ * const { createdSessionId, setActive } = await startGoogleAuthenticationFlow();
+ *
+ * if (createdSessionId && setActive) {
+ * await setActive({ session: createdSessionId });
+ * }
+ * } catch (err) {
+ * console.error('Google Authentication error:', err);
+ * }
+ * };
+ *
+ * return ;
+ * }
+ * ```
+ *
+ * @platform iOS - This is the iOS-specific implementation using Google Sign-In SDK
+ *
+ * @returns An object containing the `startGoogleAuthenticationFlow` function
+ */
+export const useSignInWithGoogle = createUseSignInWithGoogle({ requiresIosClientId: true });
diff --git a/packages/expo/src/hooks/useSignInWithGoogle.shared.ts b/packages/expo/src/hooks/useSignInWithGoogle.shared.ts
new file mode 100644
index 00000000000..6d24c6edb73
--- /dev/null
+++ b/packages/expo/src/hooks/useSignInWithGoogle.shared.ts
@@ -0,0 +1,212 @@
+import { useClerk } from '@clerk/clerk-react';
+import { isClerkAPIResponseError } from '@clerk/shared/error';
+import type { ClientResource, SetActive } from '@clerk/shared/types';
+
+import { ClerkGoogleOneTapSignIn, isErrorWithCode, isSuccessResponse } from '../google-one-tap';
+import { errorThrower } from '../utils/errors';
+import type {
+ StartGoogleAuthenticationFlowParams,
+ StartGoogleAuthenticationFlowReturnType,
+} from './useSignInWithGoogle.types';
+
+export type GoogleClientIds = {
+ webClientId: string;
+ iosClientId?: string;
+};
+
+export type GoogleAuthenticationFlowContext = {
+ client: ClientResource;
+ setActive: SetActive;
+};
+
+type PlatformConfig = {
+ requiresIosClientId: boolean;
+};
+
+/**
+ * Helper to get Google client IDs from expo-constants or process.env.
+ * Dynamically imports expo-constants to keep it optional.
+ */
+async function getGoogleClientIds(): Promise<{ webClientId?: string; iosClientId?: string }> {
+ let webClientId: string | undefined;
+ let iosClientId: string | undefined;
+
+ // Try to get values from expo-constants first
+ try {
+ const ConstantsModule = await import('expo-constants');
+ const Constants = ConstantsModule.default as {
+ expoConfig?: { extra?: Record };
+ };
+ webClientId =
+ Constants?.expoConfig?.extra?.EXPO_PUBLIC_CLERK_GOOGLE_WEB_CLIENT_ID ||
+ process.env.EXPO_PUBLIC_CLERK_GOOGLE_WEB_CLIENT_ID;
+ iosClientId =
+ Constants?.expoConfig?.extra?.EXPO_PUBLIC_CLERK_GOOGLE_IOS_CLIENT_ID ||
+ process.env.EXPO_PUBLIC_CLERK_GOOGLE_IOS_CLIENT_ID;
+ } catch {
+ // expo-constants not available, fall back to process.env only
+ webClientId = process.env.EXPO_PUBLIC_CLERK_GOOGLE_WEB_CLIENT_ID;
+ iosClientId = process.env.EXPO_PUBLIC_CLERK_GOOGLE_IOS_CLIENT_ID;
+ }
+
+ return { webClientId, iosClientId };
+}
+
+/**
+ * Factory function to create the useSignInWithGoogle hook with platform-specific configuration.
+ *
+ * @internal
+ */
+export function createUseSignInWithGoogle(platformConfig: PlatformConfig) {
+ return function useSignInWithGoogle() {
+ const clerk = useClerk();
+
+ async function startGoogleAuthenticationFlow(
+ startGoogleAuthenticationFlowParams?: StartGoogleAuthenticationFlowParams,
+ ): Promise {
+ const { client, loaded, setActive } = clerk;
+
+ if (!loaded || !client) {
+ return {
+ createdSessionId: null,
+ setActive,
+ };
+ }
+
+ // Get environment variables from expo-constants or process.env
+ const { webClientId, iosClientId } = await getGoogleClientIds();
+
+ if (!webClientId) {
+ return errorThrower.throw(
+ 'Google Sign-In credentials not found. Please set EXPO_PUBLIC_CLERK_GOOGLE_WEB_CLIENT_ID in your .env file.',
+ );
+ }
+
+ if (platformConfig.requiresIosClientId && !iosClientId) {
+ return errorThrower.throw(
+ 'Google Sign-In credentials not found. Please set EXPO_PUBLIC_CLERK_GOOGLE_IOS_CLIENT_ID in your .env file.',
+ );
+ }
+
+ return executeGoogleAuthenticationFlow(
+ { client, setActive },
+ { webClientId, iosClientId },
+ startGoogleAuthenticationFlowParams,
+ );
+ }
+
+ return {
+ startGoogleAuthenticationFlow,
+ };
+ };
+}
+
+/**
+ * Core implementation of Google Authentication flow shared between iOS and Android.
+ *
+ * @internal
+ */
+export async function executeGoogleAuthenticationFlow(
+ context: GoogleAuthenticationFlowContext,
+ clientIds: GoogleClientIds,
+ params?: StartGoogleAuthenticationFlowParams,
+): Promise {
+ const { client, setActive } = context;
+ const { signIn, signUp } = client;
+
+ // Configure Google Sign-In with client IDs
+ ClerkGoogleOneTapSignIn.configure(clientIds);
+
+ // Generate a cryptographic nonce for replay attack protection
+ const { randomUUID } = await import('expo-crypto');
+ const nonce = randomUUID();
+
+ try {
+ // Present Google Sign-In UI with nonce
+ const response = await ClerkGoogleOneTapSignIn.presentExplicitSignIn({
+ nonce,
+ });
+
+ // User cancelled
+ if (!isSuccessResponse(response)) {
+ return {
+ createdSessionId: null,
+ setActive,
+ signIn,
+ signUp,
+ };
+ }
+
+ const { idToken } = response.data;
+
+ try {
+ // Try to sign in with the Google One Tap strategy
+ await signIn.create({
+ strategy: 'google_one_tap',
+ token: idToken,
+ });
+
+ // Check if we need to transfer to SignUp (user doesn't exist yet)
+ const userNeedsToBeCreated = signIn.firstFactorVerification.status === 'transferable';
+
+ if (userNeedsToBeCreated) {
+ // User doesn't exist - create a new SignUp with transfer
+ await signUp.create({
+ transfer: true,
+ unsafeMetadata: params?.unsafeMetadata,
+ });
+
+ return {
+ createdSessionId: signUp.createdSessionId,
+ setActive,
+ signIn,
+ signUp,
+ };
+ }
+
+ // User exists - return the SignIn session
+ return {
+ createdSessionId: signIn.createdSessionId,
+ setActive,
+ signIn,
+ signUp,
+ };
+ } catch (signInError: unknown) {
+ // Handle the case where the user doesn't exist (external_account_not_found)
+ if (
+ isClerkAPIResponseError(signInError) &&
+ signInError.errors?.some(err => err.code === 'external_account_not_found')
+ ) {
+ // User doesn't exist - create a new SignUp with the token
+ await signUp.create({
+ strategy: 'google_one_tap',
+ token: idToken,
+ unsafeMetadata: params?.unsafeMetadata,
+ });
+
+ return {
+ createdSessionId: signUp.createdSessionId,
+ setActive,
+ signIn,
+ signUp,
+ };
+ }
+
+ // Re-throw if it's a different error
+ throw signInError;
+ }
+ } catch (error: unknown) {
+ // Handle Google Sign-In cancellation errors
+ if (isErrorWithCode(error) && error.code === 'SIGN_IN_CANCELLED') {
+ return {
+ createdSessionId: null,
+ setActive,
+ signIn,
+ signUp,
+ };
+ }
+
+ // Re-throw other errors
+ throw error;
+ }
+}
diff --git a/packages/expo/src/hooks/useSignInWithGoogle.ts b/packages/expo/src/hooks/useSignInWithGoogle.ts
new file mode 100644
index 00000000000..0872a3e0e19
--- /dev/null
+++ b/packages/expo/src/hooks/useSignInWithGoogle.ts
@@ -0,0 +1,71 @@
+import type { SetActive, SignInResource, SignUpResource } from '@clerk/shared/types';
+
+import { errorThrower } from '../utils/errors';
+
+type SignUpUnsafeMetadata = Record;
+
+export type StartGoogleAuthenticationFlowParams = {
+ unsafeMetadata?: SignUpUnsafeMetadata;
+};
+
+export type StartGoogleAuthenticationFlowReturnType = {
+ createdSessionId: string | null;
+ setActive?: SetActive;
+ signIn?: SignInResource;
+ signUp?: SignUpResource;
+};
+
+/**
+ * Stub for Google Authentication hook on unsupported platforms.
+ *
+ * Native Google Authentication is only available on iOS and Android.
+ * For web platforms, use the OAuth-based Google Sign-In flow instead via useSSO.
+ *
+ * @example
+ * ```tsx
+ * import { useSSO } from '@clerk/clerk-expo';
+ * import { Button } from 'react-native';
+ *
+ * function GoogleSignInButton() {
+ * const { startSSOFlow } = useSSO();
+ *
+ * const onPress = async () => {
+ * try {
+ * const { createdSessionId, setActive } = await startSSOFlow({
+ * strategy: 'oauth_google'
+ * });
+ *
+ * if (createdSessionId && setActive) {
+ * await setActive({ session: createdSessionId });
+ * }
+ * } catch (err) {
+ * console.error('Google Authentication error:', err);
+ * }
+ * };
+ *
+ * return ;
+ * }
+ * ```
+ *
+ * @platform iOS, Android - This hook only works on iOS and Android. On other platforms, it will throw an error.
+ *
+ * @returns An object containing the `startGoogleAuthenticationFlow` function that throws an error
+ */
+export function useSignInWithGoogle(): {
+ startGoogleAuthenticationFlow: (
+ startGoogleAuthenticationFlowParams?: StartGoogleAuthenticationFlowParams,
+ ) => Promise;
+} {
+ function startGoogleAuthenticationFlow(
+ _startGoogleAuthenticationFlowParams?: StartGoogleAuthenticationFlowParams,
+ ): Promise {
+ return errorThrower.throw(
+ 'Native Google Authentication is only available on iOS and Android. ' +
+ 'For web and other platforms, please use the OAuth-based flow with useSSO and strategy: "oauth_google".',
+ );
+ }
+
+ return {
+ startGoogleAuthenticationFlow,
+ };
+}
diff --git a/packages/expo/src/hooks/useSignInWithGoogle.types.ts b/packages/expo/src/hooks/useSignInWithGoogle.types.ts
new file mode 100644
index 00000000000..522f1a12385
--- /dev/null
+++ b/packages/expo/src/hooks/useSignInWithGoogle.types.ts
@@ -0,0 +1,12 @@
+import type { SetActive, SignInResource, SignUpResource } from '@clerk/shared/types';
+
+export type StartGoogleAuthenticationFlowParams = {
+ unsafeMetadata?: SignUpUnsafeMetadata;
+};
+
+export type StartGoogleAuthenticationFlowReturnType = {
+ createdSessionId: string | null;
+ setActive?: SetActive;
+ signIn?: SignInResource;
+ signUp?: SignUpResource;
+};
diff --git a/packages/expo/src/plugin/withClerkExpo.ts b/packages/expo/src/plugin/withClerkExpo.ts
new file mode 100644
index 00000000000..d342ef370b4
--- /dev/null
+++ b/packages/expo/src/plugin/withClerkExpo.ts
@@ -0,0 +1,45 @@
+import { type ConfigPlugin, createRunOncePlugin, withInfoPlist } from '@expo/config-plugins';
+
+import pkg from '../../package.json';
+
+/**
+ * Expo config plugin for @clerk/expo.
+ *
+ * This plugin configures the iOS URL scheme required for Google Sign-In.
+ * The native Android module is automatically linked via expo-module.config.json.
+ */
+const withClerkGoogleSignIn: ConfigPlugin = config => {
+ // Get the iOS URL scheme from environment or config.extra
+ // We capture it here before entering the mod callback
+ const iosUrlScheme =
+ process.env.EXPO_PUBLIC_CLERK_GOOGLE_IOS_URL_SCHEME ||
+ (config as { extra?: Record }).extra?.EXPO_PUBLIC_CLERK_GOOGLE_IOS_URL_SCHEME;
+
+ if (!iosUrlScheme) {
+ // No URL scheme configured, skip iOS configuration
+ return config;
+ }
+
+ // Add iOS URL scheme for Google Sign-In
+ return withInfoPlist(config, modConfig => {
+ if (!Array.isArray(modConfig.modResults.CFBundleURLTypes)) {
+ modConfig.modResults.CFBundleURLTypes = [];
+ }
+
+ // Check if the scheme is already added to avoid duplicates
+ const schemeExists = modConfig.modResults.CFBundleURLTypes.some(urlType =>
+ urlType.CFBundleURLSchemes?.includes(iosUrlScheme),
+ );
+
+ if (!schemeExists) {
+ // Add Google Sign-In URL scheme
+ modConfig.modResults.CFBundleURLTypes.push({
+ CFBundleURLSchemes: [iosUrlScheme],
+ });
+ }
+
+ return modConfig;
+ });
+};
+
+export default createRunOncePlugin(withClerkGoogleSignIn, pkg.name, pkg.version);
diff --git a/packages/expo/vitest.setup.mts b/packages/expo/vitest.setup.mts
index 3a6868a9500..226887d7877 100644
--- a/packages/expo/vitest.setup.mts
+++ b/packages/expo/vitest.setup.mts
@@ -1,6 +1,20 @@
-import { beforeAll } from 'vitest';
+import { beforeAll, vi } from 'vitest';
globalThis.PACKAGE_NAME = '@clerk/clerk-expo';
globalThis.PACKAGE_VERSION = '0.0.0-test';
+// Mock globalThis.expo for expo-modules-core
+if (!globalThis.expo) {
+ // @ts-expect-error - Mocking expo for tests
+ globalThis.expo = {
+ EventEmitter: vi.fn(),
+ };
+}
+
+// Define __DEV__ for expo-modules-core
+if (typeof globalThis.__DEV__ === 'undefined') {
+ // @ts-expect-error - Mocking __DEV__ for tests
+ globalThis.__DEV__ = false;
+}
+
beforeAll(() => {});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 0e18b9953f2..de97648c66d 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -548,7 +548,7 @@ importers:
version: 26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)
minimatch:
specifier: ^10.0.3
- version: 10.0.3
+ version: 10.1.1
webpack-merge:
specifier: ^5.10.0
version: 5.10.0
@@ -656,6 +656,9 @@ importers:
'@clerk/expo-passkeys':
specifier: workspace:*
version: link:../expo-passkeys
+ '@expo/config-plugins':
+ specifier: ^54.0.4
+ version: 54.0.4
'@types/base-64':
specifier: ^1.0.2
version: 1.0.2
@@ -665,12 +668,18 @@ importers:
expo-auth-session:
specifier: ^5.4.0
version: 5.4.0(expo@54.0.13(@babel/core@7.28.4)(bufferutil@4.0.9)(graphql@16.11.0)(react-native@0.81.4(@babel/core@7.28.4)(@react-native-community/cli@12.3.7(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@18.3.26)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10))(react@18.3.1)(utf-8-validate@5.0.10))
+ expo-constants:
+ specifier: ^18.0.0
+ version: 18.0.9(expo@54.0.13(@babel/core@7.28.4)(bufferutil@4.0.9)(graphql@16.11.0)(react-native@0.81.4(@babel/core@7.28.4)(@react-native-community/cli@12.3.7(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@18.3.26)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10))(react@18.3.1)(utf-8-validate@5.0.10))(react-native@0.81.4(@babel/core@7.28.4)(@react-native-community/cli@12.3.7(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@18.3.26)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10))
expo-crypto:
specifier: ^15.0.7
version: 15.0.7(expo@54.0.13(@babel/core@7.28.4)(bufferutil@4.0.9)(graphql@16.11.0)(react-native@0.81.4(@babel/core@7.28.4)(@react-native-community/cli@12.3.7(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@18.3.26)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10))(react@18.3.1)(utf-8-validate@5.0.10))
expo-local-authentication:
specifier: ^13.8.0
version: 13.8.0(expo@54.0.13(@babel/core@7.28.4)(bufferutil@4.0.9)(graphql@16.11.0)(react-native@0.81.4(@babel/core@7.28.4)(@react-native-community/cli@12.3.7(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@18.3.26)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10))(react@18.3.1)(utf-8-validate@5.0.10))
+ expo-modules-core:
+ specifier: ^3.0.0
+ version: 3.0.21(react-native@0.81.4(@babel/core@7.28.4)(@react-native-community/cli@12.3.7(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@18.3.26)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10))(react@18.3.1)
expo-secure-store:
specifier: ^12.8.1
version: 12.8.1(expo@54.0.13(@babel/core@7.28.4)(bufferutil@4.0.9)(graphql@16.11.0)(react-native@0.81.4(@babel/core@7.28.4)(@react-native-community/cli@12.3.7(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@18.3.26)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10))(react@18.3.1)(utf-8-validate@5.0.10))
@@ -2630,8 +2639,8 @@ packages:
'@expo/code-signing-certificates@0.0.5':
resolution: {integrity: sha512-BNhXkY1bblxKZpltzAx98G2Egj9g1Q+JRcvR7E99DOj862FTCX+ZPsAUtPTr7aHxwtrL7+fL3r0JSmM9kBm+Bw==}
- '@expo/config-plugins@54.0.2':
- resolution: {integrity: sha512-jD4qxFcURQUVsUFGMcbo63a/AnviK8WUGard+yrdQE3ZrB/aurn68SlApjirQQLEizhjI5Ar2ufqflOBlNpyPg==}
+ '@expo/config-plugins@54.0.4':
+ resolution: {integrity: sha512-g2yXGICdoOw5i3LkQSDxl2Q5AlQCrG7oniu0pCPPO+UxGb7He4AFqSvPSy8HpRUj55io17hT62FTjYRD+d6j3Q==}
'@expo/config-plugins@7.9.2':
resolution: {integrity: sha512-sRU/OAp7kJxrCUiCTUZqvPMKPdiN1oTmNfnbkG4oPdfWQTpid3jyCH7ZxJEN5SI6jrY/ZsK5B/JPgjDUhuWLBQ==}
@@ -2645,8 +2654,8 @@ packages:
'@expo/config-types@52.0.5':
resolution: {integrity: sha512-AMDeuDLHXXqd8W+0zSjIt7f37vUd/BP8p43k68NHpyAvQO+z8mbQZm3cNQVAMySeayK2XoPigAFB1JF2NFajaA==}
- '@expo/config-types@54.0.8':
- resolution: {integrity: sha512-lyIn/x/Yz0SgHL7IGWtgTLg6TJWC9vL7489++0hzCHZ4iGjVcfZmPTUfiragZ3HycFFj899qN0jlhl49IHa94A==}
+ '@expo/config-types@54.0.10':
+ resolution: {integrity: sha512-/J16SC2an1LdtCZ67xhSkGXpALYUVUNyZws7v+PVsFZxClYehDSoKLqyRaGkpHlYrCc08bS0RF5E0JV6g50psA==}
'@expo/config@10.0.11':
resolution: {integrity: sha512-nociJ4zr/NmbVfMNe9j/+zRlt7wz/siISu7PjdWE4WE+elEGxWWxsGzltdJG0llzrM+khx8qUiFK5aiVcdMBww==}
@@ -2695,8 +2704,8 @@ packages:
'@expo/image-utils@0.8.7':
resolution: {integrity: sha512-SXOww4Wq3RVXLyOaXiCCuQFguCDh8mmaHBv54h/R29wGl4jRY8GEyQEx8SypV/iHt1FbzsU/X3Qbcd9afm2W2w==}
- '@expo/json-file@10.0.7':
- resolution: {integrity: sha512-z2OTC0XNO6riZu98EjdNHC05l51ySeTto6GP7oSQrCvQgG9ARBwD1YvMQaVZ9wU7p/4LzSf1O7tckL3B45fPpw==}
+ '@expo/json-file@10.0.8':
+ resolution: {integrity: sha512-9LOTh1PgKizD1VXfGQ88LtDH0lRwq9lsTb4aichWTWSWqy3Ugfkhfm3BhzBIkJJfQQ5iJu3m/BoRlEIjoCGcnQ==}
'@expo/json-file@8.3.3':
resolution: {integrity: sha512-eZ5dld9AD0PrVRiIWpRkm5aIoWBw3kAyd8VkuWEy92sEthBKDDDHAnK2a0dw0Eil6j7rK7lS/Qaq/Zzngv2h5A==}
@@ -2739,8 +2748,8 @@ packages:
'@expo/plist@0.2.2':
resolution: {integrity: sha512-ZZGvTO6vEWq02UAPs3LIdja+HRO18+LRI5QuDl6Hs3Ps7KX7xU6Y6kjahWKY37Rx2YjNpX07dGpBFzzC+vKa2g==}
- '@expo/plist@0.4.7':
- resolution: {integrity: sha512-dGxqHPvCZKeRKDU1sJZMmuyVtcASuSYh1LPFVaM1DuffqPL36n6FMEL0iUqq2Tx3xhWk8wCnWl34IKplUjJDdA==}
+ '@expo/plist@0.4.8':
+ resolution: {integrity: sha512-pfNtErGGzzRwHP+5+RqswzPDKkZrx+Cli0mzjQaus1ZWFsog5ibL+nVT3NcporW51o8ggnt7x813vtRbPiyOrQ==}
'@expo/prebuild-config@54.0.5':
resolution: {integrity: sha512-eCvbVUf01j1nSrs4mG/rWwY+SfgE30LM6JcElLrnNgNnaDWzt09E/c8n3ZeTLNKENwJaQQ1KIn2VE461/4VnWQ==}
@@ -9201,6 +9210,10 @@ packages:
engines: {node: 20 || >=22}
hasBin: true
+ glob@13.0.0:
+ resolution: {integrity: sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==}
+ engines: {node: 20 || >=22}
+
glob@7.1.6:
resolution: {integrity: sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==}
deprecated: Glob versions prior to v9 are no longer supported
@@ -11306,8 +11319,8 @@ packages:
resolution: {integrity: sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ==}
engines: {node: 20 || >=22}
- minimatch@10.0.3:
- resolution: {integrity: sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==}
+ minimatch@10.1.1:
+ resolution: {integrity: sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==}
engines: {node: 20 || >=22}
minimatch@3.1.2:
@@ -17456,17 +17469,17 @@ snapshots:
'@0no-co/graphql.web': 1.1.2(graphql@16.11.0)
'@expo/code-signing-certificates': 0.0.5
'@expo/config': 12.0.10
- '@expo/config-plugins': 54.0.2
+ '@expo/config-plugins': 54.0.4
'@expo/devcert': 1.1.4
'@expo/env': 2.0.7
'@expo/image-utils': 0.8.7
- '@expo/json-file': 10.0.7
+ '@expo/json-file': 10.0.8
'@expo/mcp-tunnel': 0.0.8(bufferutil@4.0.9)(utf-8-validate@5.0.10)
'@expo/metro': 54.0.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)
'@expo/metro-config': 54.0.6(bufferutil@4.0.9)(expo@54.0.13(@babel/core@7.28.4)(bufferutil@4.0.9)(graphql@16.11.0)(react-native@0.81.4(@babel/core@7.28.4)(@react-native-community/cli@12.3.7(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@18.3.26)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10))(react@18.3.1)(utf-8-validate@5.0.10))(utf-8-validate@5.0.10)
'@expo/osascript': 2.3.7
'@expo/package-manager': 1.9.8
- '@expo/plist': 0.4.7
+ '@expo/plist': 0.4.8
'@expo/prebuild-config': 54.0.5(expo@54.0.13(@babel/core@7.28.4)(bufferutil@4.0.9)(graphql@16.11.0)(react-native@0.81.4(@babel/core@7.28.4)(@react-native-community/cli@12.3.7(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@18.3.26)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10))(react@18.3.1)(utf-8-validate@5.0.10))
'@expo/schema-utils': 0.1.7
'@expo/spawn-async': 1.7.2
@@ -17532,16 +17545,16 @@ snapshots:
node-forge: 1.3.1
nullthrows: 1.1.1
- '@expo/config-plugins@54.0.2':
+ '@expo/config-plugins@54.0.4':
dependencies:
- '@expo/config-types': 54.0.8
- '@expo/json-file': 10.0.7
- '@expo/plist': 0.4.7
+ '@expo/config-types': 54.0.10
+ '@expo/json-file': 10.0.8
+ '@expo/plist': 0.4.8
'@expo/sdk-runtime-versions': 1.0.0
chalk: 4.1.2
debug: 4.4.3(supports-color@8.1.1)
getenv: 2.0.0
- glob: 10.4.5
+ glob: 13.0.0
resolve-from: 5.0.0
semver: 7.7.3
slash: 3.0.0
@@ -17596,7 +17609,7 @@ snapshots:
'@expo/config-types@52.0.5': {}
- '@expo/config-types@54.0.8': {}
+ '@expo/config-types@54.0.10': {}
'@expo/config@10.0.11':
dependencies:
@@ -17619,9 +17632,9 @@ snapshots:
'@expo/config@12.0.10':
dependencies:
'@babel/code-frame': 7.10.4
- '@expo/config-plugins': 54.0.2
- '@expo/config-types': 54.0.8
- '@expo/json-file': 10.0.7
+ '@expo/config-plugins': 54.0.4
+ '@expo/config-types': 54.0.10
+ '@expo/json-file': 10.0.8
deepmerge: 4.3.1
getenv: 2.0.0
glob: 10.4.5
@@ -17763,7 +17776,7 @@ snapshots:
temp-dir: 2.0.0
unique-string: 2.0.0
- '@expo/json-file@10.0.7':
+ '@expo/json-file@10.0.8':
dependencies:
'@babel/code-frame': 7.10.4
json5: 2.2.3
@@ -17819,7 +17832,7 @@ snapshots:
'@babel/generator': 7.28.3
'@expo/config': 12.0.10
'@expo/env': 2.0.7
- '@expo/json-file': 10.0.7
+ '@expo/json-file': 10.0.8
'@expo/metro': 54.0.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)
'@expo/spawn-async': 1.7.2
browserslist: 4.26.0
@@ -17868,7 +17881,7 @@ snapshots:
'@expo/package-manager@1.9.8':
dependencies:
- '@expo/json-file': 10.0.7
+ '@expo/json-file': 10.0.8
'@expo/spawn-async': 1.7.2
chalk: 4.1.2
npm-package-arg: 11.0.3
@@ -17887,7 +17900,7 @@ snapshots:
base64-js: 1.5.1
xmlbuilder: 14.0.0
- '@expo/plist@0.4.7':
+ '@expo/plist@0.4.8':
dependencies:
'@xmldom/xmldom': 0.8.10
base64-js: 1.5.1
@@ -17896,10 +17909,10 @@ snapshots:
'@expo/prebuild-config@54.0.5(expo@54.0.13(@babel/core@7.28.4)(bufferutil@4.0.9)(graphql@16.11.0)(react-native@0.81.4(@babel/core@7.28.4)(@react-native-community/cli@12.3.7(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@18.3.26)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10))(react@18.3.1)(utf-8-validate@5.0.10))':
dependencies:
'@expo/config': 12.0.10
- '@expo/config-plugins': 54.0.2
- '@expo/config-types': 54.0.8
+ '@expo/config-plugins': 54.0.4
+ '@expo/config-types': 54.0.10
'@expo/image-utils': 0.8.7
- '@expo/json-file': 10.0.7
+ '@expo/json-file': 10.0.8
'@react-native/normalize-colors': 0.81.4
debug: 4.4.3(supports-color@8.1.1)
expo: 54.0.13(@babel/core@7.28.4)(bufferutil@4.0.9)(graphql@16.11.0)(react-native@0.81.4(@babel/core@7.28.4)(@react-native-community/cli@12.3.7(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@18.3.26)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10))(react@18.3.1)(utf-8-validate@5.0.10)
@@ -25301,7 +25314,7 @@ snapshots:
'@babel/runtime': 7.27.6
'@expo/cli': 54.0.11(bufferutil@4.0.9)(expo@54.0.13(@babel/core@7.28.4)(bufferutil@4.0.9)(graphql@16.11.0)(react-native@0.81.4(@babel/core@7.28.4)(@react-native-community/cli@12.3.7(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@18.3.26)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10))(react@18.3.1)(utf-8-validate@5.0.10))(graphql@16.11.0)(react-native@0.81.4(@babel/core@7.28.4)(@react-native-community/cli@12.3.7(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@18.3.26)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10))(utf-8-validate@5.0.10)
'@expo/config': 12.0.10
- '@expo/config-plugins': 54.0.2
+ '@expo/config-plugins': 54.0.4
'@expo/devtools': 0.1.7(react-native@0.81.4(@babel/core@7.28.4)(@react-native-community/cli@12.3.7(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@18.3.26)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10))(react@18.3.1)
'@expo/fingerprint': 0.15.1
'@expo/metro': 54.0.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)
@@ -25942,11 +25955,17 @@ snapshots:
dependencies:
foreground-child: 3.1.1
jackspeak: 4.0.2
- minimatch: 10.0.3
+ minimatch: 10.1.1
minipass: 7.1.2
package-json-from-dist: 1.0.0
path-scurry: 2.0.0
+ glob@13.0.0:
+ dependencies:
+ minimatch: 10.1.1
+ minipass: 7.1.2
+ path-scurry: 2.0.0
+
glob@7.1.6:
dependencies:
fs.realpath: 1.0.0
@@ -28666,7 +28685,7 @@ snapshots:
dependencies:
brace-expansion: 2.0.1
- minimatch@10.0.3:
+ minimatch@10.1.1:
dependencies:
'@isaacs/brace-expansion': 5.0.0