From ce02b5144e5f3318f046492447cc4a02ae7e6da4 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Sun, 16 Aug 2026 15:01:16 -0700 Subject: [PATCH 1/2] fix(expo-google-signin): preserve provider failures Android Credential Manager providers can return non-user failures through GetCredentialCancellationException. The native module currently reports every instance as SIGN_IN_CANCELLED, causing @clerk/expo to treat those failures as an ordinary chooser dismissal and return no session or error. This change keeps explicit Cancelled by user and Canceled by user responses as SIGN_IN_CANCELLED and reports other messages as GOOGLE_SIGN_IN_ERROR. The same classification is used by signIn, createAccount, and presentExplicitSignIn. Co-authored-by: Eliot Gevers <84166025+eliotgevers@users.noreply.github.com> --- .changeset/preserve-google-sign-in-errors.md | 5 ++++ .../expo-google-signin/android/build.gradle | 1 + .../googlesignin/ClerkGoogleSignInModule.kt | 23 ++++++++++++++++--- .../ClerkGoogleSignInModuleTest.kt | 21 +++++++++++++++++ 4 files changed, 47 insertions(+), 3 deletions(-) create mode 100644 .changeset/preserve-google-sign-in-errors.md create mode 100644 packages/expo-google-signin/android/src/test/java/expo/modules/clerk/googlesignin/ClerkGoogleSignInModuleTest.kt diff --git a/.changeset/preserve-google-sign-in-errors.md b/.changeset/preserve-google-sign-in-errors.md new file mode 100644 index 00000000000..1fe1638a22b --- /dev/null +++ b/.changeset/preserve-google-sign-in-errors.md @@ -0,0 +1,5 @@ +--- +'@clerk/expo-google-signin': patch +--- + +Fix Android Google Sign-In provider failures being reported as user cancellation. diff --git a/packages/expo-google-signin/android/build.gradle b/packages/expo-google-signin/android/build.gradle index 81ad78c0c7a..bfa50c59cf5 100644 --- a/packages/expo-google-signin/android/build.gradle +++ b/packages/expo-google-signin/android/build.gradle @@ -36,4 +36,5 @@ dependencies { implementation "androidx.credentials:credentials-play-services-auth:1.3.0" implementation "com.google.android.libraries.identity.googleid:googleid:1.1.1" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3" + testImplementation "junit:junit:4.13.2" } diff --git a/packages/expo-google-signin/android/src/main/java/expo/modules/clerk/googlesignin/ClerkGoogleSignInModule.kt b/packages/expo-google-signin/android/src/main/java/expo/modules/clerk/googlesignin/ClerkGoogleSignInModule.kt index 68be6942cf4..85cc3ab13f6 100644 --- a/packages/expo-google-signin/android/src/main/java/expo/modules/clerk/googlesignin/ClerkGoogleSignInModule.kt +++ b/packages/expo-google-signin/android/src/main/java/expo/modules/clerk/googlesignin/ClerkGoogleSignInModule.kt @@ -98,7 +98,7 @@ class ClerkGoogleSignInModule : Module() { handleSignInResult(result, promise) } catch (e: GetCredentialCancellationException) { - promise.reject("SIGN_IN_CANCELLED", "User cancelled the sign-in flow", e) + rejectCredentialCancellation(promise, e) } catch (e: NoCredentialException) { promise.reject("NO_SAVED_CREDENTIAL_FOUND", "No saved credential found", e) } catch (e: GetCredentialException) { @@ -145,7 +145,7 @@ class ClerkGoogleSignInModule : Module() { handleSignInResult(result, promise) } catch (e: GetCredentialCancellationException) { - promise.reject("SIGN_IN_CANCELLED", "User cancelled the sign-in flow", e) + rejectCredentialCancellation(promise, e) } catch (e: NoCredentialException) { promise.reject("NO_SAVED_CREDENTIAL_FOUND", "No saved credential found", e) } catch (e: GetCredentialException) { @@ -191,7 +191,7 @@ class ClerkGoogleSignInModule : Module() { handleSignInResult(result, promise) } catch (e: GetCredentialCancellationException) { - promise.reject("SIGN_IN_CANCELLED", "User cancelled the sign-in flow", e) + rejectCredentialCancellation(promise, e) } catch (e: GetCredentialException) { promise.reject("GOOGLE_SIGN_IN_ERROR", e.message ?: "Unknown error", e) } catch (e: Exception) { @@ -215,6 +215,17 @@ class ClerkGoogleSignInModule : Module() { // MARK: - Helpers + private fun rejectCredentialCancellation(promise: Promise, exception: GetCredentialCancellationException) { + val message = exception.message ?: "Google Sign-In failed" + + if (isExplicitUserCancellation(message)) { + promise.reject("SIGN_IN_CANCELLED", "User cancelled the sign-in flow", exception) + return + } + + promise.reject("GOOGLE_SIGN_IN_ERROR", message, exception) + } + private fun handleSignInResult(result: GetCredentialResponse, promise: Promise) { when (val credential = result.credential) { is CustomCredential -> { @@ -255,3 +266,9 @@ class ClerkGoogleSignInModule : Module() { } } } + +private val explicitUserCancellationPattern = + Regex("^(?:\\[\\d+]\\s*)?cancel(?:l)?ed by user\\.?$", RegexOption.IGNORE_CASE) + +internal fun isExplicitUserCancellation(message: String): Boolean = + explicitUserCancellationPattern.matches(message.trim()) diff --git a/packages/expo-google-signin/android/src/test/java/expo/modules/clerk/googlesignin/ClerkGoogleSignInModuleTest.kt b/packages/expo-google-signin/android/src/test/java/expo/modules/clerk/googlesignin/ClerkGoogleSignInModuleTest.kt new file mode 100644 index 00000000000..61dcac7ac73 --- /dev/null +++ b/packages/expo-google-signin/android/src/test/java/expo/modules/clerk/googlesignin/ClerkGoogleSignInModuleTest.kt @@ -0,0 +1,21 @@ +package expo.modules.clerk.googlesignin + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ClerkGoogleSignInModuleTest { + @Test + fun identifiesExplicitUserCancellation() { + assertTrue(isExplicitUserCancellation("[16] Cancelled by user.")) + assertTrue(isExplicitUserCancellation("Canceled by user")) + assertTrue(isExplicitUserCancellation(" cancelled by user ")) + } + + @Test + fun preservesProviderFailures() { + assertFalse(isExplicitUserCancellation("[16] Account reauth failed.")) + assertFalse(isExplicitUserCancellation("Developer console is not set up correctly.")) + assertFalse(isExplicitUserCancellation("Request was not cancelled by user")) + } +} From 4d525fb02c2f463c9ad064552e0bb4afe6193586 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Sun, 16 Aug 2026 15:07:02 -0700 Subject: [PATCH 2/2] fix(expo): classify google sign-in provider failures in js Moves the cancellation-vs-failure decision out of Kotlin and into @clerk/expo. The native module now passes the underlying Credential Manager message through on SIGN_IN_CANCELLED instead of discarding it, and ClerkGoogleOneTapSignIn decides whether it describes a dismissed chooser or a provider failure. Only Google Play services prefixes its messages with a status code, so the check defaults to a cancellation and only escalates on a prefixed message that does not say "cancelled by user". androidx's own dismissal messages carry no prefix, so back-press and selector dismissals stay silent. Keeping the heuristic in JS means it runs under vitest in CI, where the Kotlin one could not, and it can be corrected in a normal @clerk/expo release rather than a native rebuild. Both version-skew combinations degrade to today's behaviour rather than regressing. --- .changeset/expo-google-provider-failures.md | 9 +++ .changeset/preserve-google-sign-in-errors.md | 2 +- .../expo-google-signin/android/build.gradle | 1 - .../googlesignin/ClerkGoogleSignInModule.kt | 25 +++----- .../ClerkGoogleSignInModuleTest.kt | 21 ------- .../google-one-tap/ClerkGoogleOneTapSignIn.ts | 17 ++++++ .../__tests__/ClerkGoogleOneTapSignIn.test.ts | 59 +++++++++++++++++++ packages/expo/src/google-one-tap/types.ts | 2 +- 8 files changed, 94 insertions(+), 42 deletions(-) create mode 100644 .changeset/expo-google-provider-failures.md delete mode 100644 packages/expo-google-signin/android/src/test/java/expo/modules/clerk/googlesignin/ClerkGoogleSignInModuleTest.kt create mode 100644 packages/expo/src/google-one-tap/__tests__/ClerkGoogleOneTapSignIn.test.ts diff --git a/.changeset/expo-google-provider-failures.md b/.changeset/expo-google-provider-failures.md new file mode 100644 index 00000000000..9fbc10552eb --- /dev/null +++ b/.changeset/expo-google-provider-failures.md @@ -0,0 +1,9 @@ +--- +'@clerk/expo': minor +--- + +Surface Android Google Sign-In provider failures instead of silently treating them as a cancelled sign-in. + +Android's Credential Manager reports failures such as an unregistered OAuth client through the same cancellation exception it uses for a dismissed account chooser, so `startGoogleAuthenticationFlow()` resolved with no session and no error. Those failures now reject with a `GOOGLE_SIGN_IN_ERROR`, while dismissing the chooser still resolves with `createdSessionId: null`. + +If you call `startGoogleAuthenticationFlow()` without a `try`/`catch`, add one to handle the rejection. diff --git a/.changeset/preserve-google-sign-in-errors.md b/.changeset/preserve-google-sign-in-errors.md index 1fe1638a22b..0118d6314b4 100644 --- a/.changeset/preserve-google-sign-in-errors.md +++ b/.changeset/preserve-google-sign-in-errors.md @@ -2,4 +2,4 @@ '@clerk/expo-google-signin': patch --- -Fix Android Google Sign-In provider failures being reported as user cancellation. +Pass the underlying Android Credential Manager message through when a sign-in is cancelled, so `@clerk/expo` can tell a provider failure apart from a dismissed account chooser. Upgrade `@clerk/expo` alongside this and rebuild your native app to get the fix. diff --git a/packages/expo-google-signin/android/build.gradle b/packages/expo-google-signin/android/build.gradle index bfa50c59cf5..81ad78c0c7a 100644 --- a/packages/expo-google-signin/android/build.gradle +++ b/packages/expo-google-signin/android/build.gradle @@ -36,5 +36,4 @@ dependencies { implementation "androidx.credentials:credentials-play-services-auth:1.3.0" implementation "com.google.android.libraries.identity.googleid:googleid:1.1.1" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3" - testImplementation "junit:junit:4.13.2" } diff --git a/packages/expo-google-signin/android/src/main/java/expo/modules/clerk/googlesignin/ClerkGoogleSignInModule.kt b/packages/expo-google-signin/android/src/main/java/expo/modules/clerk/googlesignin/ClerkGoogleSignInModule.kt index 85cc3ab13f6..4e295ca7e5d 100644 --- a/packages/expo-google-signin/android/src/main/java/expo/modules/clerk/googlesignin/ClerkGoogleSignInModule.kt +++ b/packages/expo-google-signin/android/src/main/java/expo/modules/clerk/googlesignin/ClerkGoogleSignInModule.kt @@ -98,7 +98,7 @@ class ClerkGoogleSignInModule : Module() { handleSignInResult(result, promise) } catch (e: GetCredentialCancellationException) { - rejectCredentialCancellation(promise, e) + rejectCancellation(promise, e) } catch (e: NoCredentialException) { promise.reject("NO_SAVED_CREDENTIAL_FOUND", "No saved credential found", e) } catch (e: GetCredentialException) { @@ -145,7 +145,7 @@ class ClerkGoogleSignInModule : Module() { handleSignInResult(result, promise) } catch (e: GetCredentialCancellationException) { - rejectCredentialCancellation(promise, e) + rejectCancellation(promise, e) } catch (e: NoCredentialException) { promise.reject("NO_SAVED_CREDENTIAL_FOUND", "No saved credential found", e) } catch (e: GetCredentialException) { @@ -191,7 +191,7 @@ class ClerkGoogleSignInModule : Module() { handleSignInResult(result, promise) } catch (e: GetCredentialCancellationException) { - rejectCredentialCancellation(promise, e) + rejectCancellation(promise, e) } catch (e: GetCredentialException) { promise.reject("GOOGLE_SIGN_IN_ERROR", e.message ?: "Unknown error", e) } catch (e: Exception) { @@ -215,15 +215,10 @@ class ClerkGoogleSignInModule : Module() { // MARK: - Helpers - private fun rejectCredentialCancellation(promise: Promise, exception: GetCredentialCancellationException) { - val message = exception.message ?: "Google Sign-In failed" - - if (isExplicitUserCancellation(message)) { - promise.reject("SIGN_IN_CANCELLED", "User cancelled the sign-in flow", exception) - return - } - - promise.reject("GOOGLE_SIGN_IN_ERROR", message, exception) + // Credential Manager also reports provider failures through this exception, so the underlying + // message has to reach JS for @clerk/expo to tell them apart from a dismissed chooser. + private fun rejectCancellation(promise: Promise, exception: GetCredentialCancellationException) { + promise.reject("SIGN_IN_CANCELLED", exception.message ?: "User cancelled the sign-in flow", exception) } private fun handleSignInResult(result: GetCredentialResponse, promise: Promise) { @@ -266,9 +261,3 @@ class ClerkGoogleSignInModule : Module() { } } } - -private val explicitUserCancellationPattern = - Regex("^(?:\\[\\d+]\\s*)?cancel(?:l)?ed by user\\.?$", RegexOption.IGNORE_CASE) - -internal fun isExplicitUserCancellation(message: String): Boolean = - explicitUserCancellationPattern.matches(message.trim()) diff --git a/packages/expo-google-signin/android/src/test/java/expo/modules/clerk/googlesignin/ClerkGoogleSignInModuleTest.kt b/packages/expo-google-signin/android/src/test/java/expo/modules/clerk/googlesignin/ClerkGoogleSignInModuleTest.kt deleted file mode 100644 index 61dcac7ac73..00000000000 --- a/packages/expo-google-signin/android/src/test/java/expo/modules/clerk/googlesignin/ClerkGoogleSignInModuleTest.kt +++ /dev/null @@ -1,21 +0,0 @@ -package expo.modules.clerk.googlesignin - -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test - -class ClerkGoogleSignInModuleTest { - @Test - fun identifiesExplicitUserCancellation() { - assertTrue(isExplicitUserCancellation("[16] Cancelled by user.")) - assertTrue(isExplicitUserCancellation("Canceled by user")) - assertTrue(isExplicitUserCancellation(" cancelled by user ")) - } - - @Test - fun preservesProviderFailures() { - assertFalse(isExplicitUserCancellation("[16] Account reauth failed.")) - assertFalse(isExplicitUserCancellation("Developer console is not set up correctly.")) - assertFalse(isExplicitUserCancellation("Request was not cancelled by user")) - } -} diff --git a/packages/expo/src/google-one-tap/ClerkGoogleOneTapSignIn.ts b/packages/expo/src/google-one-tap/ClerkGoogleOneTapSignIn.ts index 5961f63f31b..e11ec6a2618 100644 --- a/packages/expo/src/google-one-tap/ClerkGoogleOneTapSignIn.ts +++ b/packages/expo/src/google-one-tap/ClerkGoogleOneTapSignIn.ts @@ -56,6 +56,20 @@ export function isErrorWithCode(error: unknown): error is { code: string; messag ); } +// Android's Credential Manager reports provider failures through the same cancellation exception it +// uses for a dismissed chooser. Only Google Play services prefixes its messages with a status code, +// so a prefixed message that does not say "cancelled by user" is a failure rather than a dismissal. +const PLAY_SERVICES_STATUS_PREFIX = /^\s*(?:\[\d+]|\d+:)/; +const CANCELLED_BY_USER = /cancell?ed by user/i; + +function rethrowIfProviderFailure(error: { code: string; message: string }): void { + if (!PLAY_SERVICES_STATUS_PREFIX.test(error.message) || CANCELLED_BY_USER.test(error.message)) { + return; + } + + throw Object.assign(new Error(error.message), { code: 'GOOGLE_SIGN_IN_ERROR', cause: error }); +} + /** * Internal Google One Tap Sign-In module. * @@ -97,6 +111,7 @@ export const ClerkGoogleOneTapSignIn = { } catch (error) { if (isErrorWithCode(error)) { if (error.code === 'SIGN_IN_CANCELLED') { + rethrowIfProviderFailure(error); return { type: 'cancelled', data: null }; } if (error.code === 'NO_SAVED_CREDENTIAL_FOUND') { @@ -124,6 +139,7 @@ export const ClerkGoogleOneTapSignIn = { } catch (error) { if (isErrorWithCode(error)) { if (error.code === 'SIGN_IN_CANCELLED') { + rethrowIfProviderFailure(error); return { type: 'cancelled', data: null }; } if (error.code === 'NO_SAVED_CREDENTIAL_FOUND') { @@ -151,6 +167,7 @@ export const ClerkGoogleOneTapSignIn = { } catch (error) { if (isErrorWithCode(error)) { if (error.code === 'SIGN_IN_CANCELLED') { + rethrowIfProviderFailure(error); return { type: 'cancelled', data: null }; } } diff --git a/packages/expo/src/google-one-tap/__tests__/ClerkGoogleOneTapSignIn.test.ts b/packages/expo/src/google-one-tap/__tests__/ClerkGoogleOneTapSignIn.test.ts new file mode 100644 index 00000000000..09b29e805a4 --- /dev/null +++ b/packages/expo/src/google-one-tap/__tests__/ClerkGoogleOneTapSignIn.test.ts @@ -0,0 +1,59 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import { ClerkGoogleOneTapSignIn } from '../ClerkGoogleOneTapSignIn'; + +const mocks = vi.hoisted(() => ({ + signIn: vi.fn(), + createAccount: vi.fn(), + presentExplicitSignIn: vi.fn(), +})); + +vi.mock('../../specs/NativeClerkGoogleSignIn', () => ({ + default: { + configure: vi.fn(), + signIn: mocks.signIn, + createAccount: mocks.createAccount, + presentExplicitSignIn: mocks.presentExplicitSignIn, + signOut: vi.fn(), + }, +})); + +const nativeError = (message: string) => Object.assign(new Error(message), { code: 'SIGN_IN_CANCELLED' }); + +const methods = [ + ['signIn', mocks.signIn, () => ClerkGoogleOneTapSignIn.signIn()], + ['createAccount', mocks.createAccount, () => ClerkGoogleOneTapSignIn.createAccount()], + ['presentExplicitSignIn', mocks.presentExplicitSignIn, () => ClerkGoogleOneTapSignIn.presentExplicitSignIn()], +] as const; + +describe('ClerkGoogleOneTapSignIn', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe.each(methods)('%s', (_name, nativeMethod, call) => { + // Messages androidx.credentials and Play services emit when the user dismisses the chooser. + test.each([ + 'User cancelled the sign-in flow', + 'activity is cancelled by the user.', + 'User cancelled the selector', + '[16] Cancelled by user.', + '[16] Canceled by user.', + ])('treats %j as a cancellation', async message => { + nativeMethod.mockRejectedValue(nativeError(message)); + + await expect(call()).resolves.toEqual({ type: 'cancelled', data: null }); + }); + + // Play services reuses status 16 for failures the user did not trigger. + test.each([ + '[16] Account reauth failed.', + '16: Account reauth failed.', + '[10] Developer console is not set up correctly.', + ])('surfaces %j as a provider failure', async message => { + nativeMethod.mockRejectedValue(nativeError(message)); + + await expect(call()).rejects.toMatchObject({ code: 'GOOGLE_SIGN_IN_ERROR', message }); + }); + }); +}); diff --git a/packages/expo/src/google-one-tap/types.ts b/packages/expo/src/google-one-tap/types.ts index 816114a83f9..f42b1adcf76 100644 --- a/packages/expo/src/google-one-tap/types.ts +++ b/packages/expo/src/google-one-tap/types.ts @@ -168,7 +168,7 @@ export type OneTapResponse = OneTapSuccessResponse | CancelledResponse | NoSaved * - `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 + * - `GOOGLE_SIGN_IN_ERROR`: Generic Google Sign-In error, including Android provider failures such as an unregistered OAuth client * - `E_ACTIVITY_UNAVAILABLE`: Android activity unavailable (GoogleSignInActivityUnavailableException) */ export type GoogleSignInErrorCode =