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 AltStyle によって変換されたページ (->オリジナル) / アドレス: モード: デフォルト 音声ブラウザ ルビ付き 配色反転 文字拡大 モバイル
AltStyle によって変換されたページ (->オリジナル) / アドレス: モード: デフォルト 音声ブラウザ ルビ付き 配色反転 文字拡大 モバイル