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 new file mode 100644 index 00000000000..0118d6314b4 --- /dev/null +++ b/.changeset/preserve-google-sign-in-errors.md @@ -0,0 +1,5 @@ +--- +'@clerk/expo-google-signin': patch +--- + +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/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..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) { - promise.reject("SIGN_IN_CANCELLED", "User cancelled the sign-in flow", 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) { - promise.reject("SIGN_IN_CANCELLED", "User cancelled the sign-in flow", 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) { - promise.reject("SIGN_IN_CANCELLED", "User cancelled the sign-in flow", e) + rejectCancellation(promise, e) } catch (e: GetCredentialException) { promise.reject("GOOGLE_SIGN_IN_ERROR", e.message ?: "Unknown error", e) } catch (e: Exception) { @@ -215,6 +215,12 @@ class ClerkGoogleSignInModule : Module() { // MARK: - Helpers + // 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) { when (val credential = result.credential) { is CustomCredential -> { 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 =