Skip to content

Commit 600bb01

Browse files
authored
fix(expo): surface android google sign-in provider failures (#9464)
1 parent c9270e7 commit 600bb01

6 files changed

Lines changed: 100 additions & 4 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@clerk/expo': minor
3+
---
4+
5+
Surface Android Google Sign-In provider failures instead of silently treating them as a cancelled sign-in.
6+
7+
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`.
8+
9+
If you call `startGoogleAuthenticationFlow()` without a `try`/`catch`, add one to handle the rejection.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/expo-google-signin': patch
3+
---
4+
5+
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.

packages/expo-google-signin/android/src/main/java/expo/modules/clerk/googlesignin/ClerkGoogleSignInModule.kt

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ class ClerkGoogleSignInModule : Module() {
9898

9999
handleSignInResult(result, promise)
100100
} catch (e: GetCredentialCancellationException) {
101-
promise.reject("SIGN_IN_CANCELLED", "User cancelled the sign-in flow", e)
101+
rejectCancellation(promise, e)
102102
} catch (e: NoCredentialException) {
103103
promise.reject("NO_SAVED_CREDENTIAL_FOUND", "No saved credential found", e)
104104
} catch (e: GetCredentialException) {
@@ -145,7 +145,7 @@ class ClerkGoogleSignInModule : Module() {
145145

146146
handleSignInResult(result, promise)
147147
} catch (e: GetCredentialCancellationException) {
148-
promise.reject("SIGN_IN_CANCELLED", "User cancelled the sign-in flow", e)
148+
rejectCancellation(promise, e)
149149
} catch (e: NoCredentialException) {
150150
promise.reject("NO_SAVED_CREDENTIAL_FOUND", "No saved credential found", e)
151151
} catch (e: GetCredentialException) {
@@ -191,7 +191,7 @@ class ClerkGoogleSignInModule : Module() {
191191

192192
handleSignInResult(result, promise)
193193
} catch (e: GetCredentialCancellationException) {
194-
promise.reject("SIGN_IN_CANCELLED", "User cancelled the sign-in flow", e)
194+
rejectCancellation(promise, e)
195195
} catch (e: GetCredentialException) {
196196
promise.reject("GOOGLE_SIGN_IN_ERROR", e.message ?: "Unknown error", e)
197197
} catch (e: Exception) {
@@ -215,6 +215,12 @@ class ClerkGoogleSignInModule : Module() {
215215

216216
// MARK: - Helpers
217217

218+
// Credential Manager also reports provider failures through this exception, so the underlying
219+
// message has to reach JS for @clerk/expo to tell them apart from a dismissed chooser.
220+
private fun rejectCancellation(promise: Promise, exception: GetCredentialCancellationException) {
221+
promise.reject("SIGN_IN_CANCELLED", exception.message ?: "User cancelled the sign-in flow", exception)
222+
}
223+
218224
private fun handleSignInResult(result: GetCredentialResponse, promise: Promise) {
219225
when (val credential = result.credential) {
220226
is CustomCredential -> {

packages/expo/src/google-one-tap/ClerkGoogleOneTapSignIn.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,20 @@ export function isErrorWithCode(error: unknown): error is { code: string; messag
5656
);
5757
}
5858

59+
// Android's Credential Manager reports provider failures through the same cancellation exception it
60+
// uses for a dismissed chooser. Only Google Play services prefixes its messages with a status code,
61+
// so a prefixed message that does not say "cancelled by user" is a failure rather than a dismissal.
62+
const PLAY_SERVICES_STATUS_PREFIX = /^\s*(?:\[\d+]|\d+:)/;
63+
const CANCELLED_BY_USER = /cancell?ed by user/i;
64+
65+
function rethrowIfProviderFailure(error: { code: string; message: string }): void {
66+
if (!PLAY_SERVICES_STATUS_PREFIX.test(error.message) || CANCELLED_BY_USER.test(error.message)) {
67+
return;
68+
}
69+
70+
throw Object.assign(new Error(error.message), { code: 'GOOGLE_SIGN_IN_ERROR', cause: error });
71+
}
72+
5973
/**
6074
* Internal Google One Tap Sign-In module.
6175
*
@@ -97,6 +111,7 @@ export const ClerkGoogleOneTapSignIn = {
97111
} catch (error) {
98112
if (isErrorWithCode(error)) {
99113
if (error.code === 'SIGN_IN_CANCELLED') {
114+
rethrowIfProviderFailure(error);
100115
return { type: 'cancelled', data: null };
101116
}
102117
if (error.code === 'NO_SAVED_CREDENTIAL_FOUND') {
@@ -124,6 +139,7 @@ export const ClerkGoogleOneTapSignIn = {
124139
} catch (error) {
125140
if (isErrorWithCode(error)) {
126141
if (error.code === 'SIGN_IN_CANCELLED') {
142+
rethrowIfProviderFailure(error);
127143
return { type: 'cancelled', data: null };
128144
}
129145
if (error.code === 'NO_SAVED_CREDENTIAL_FOUND') {
@@ -151,6 +167,7 @@ export const ClerkGoogleOneTapSignIn = {
151167
} catch (error) {
152168
if (isErrorWithCode(error)) {
153169
if (error.code === 'SIGN_IN_CANCELLED') {
170+
rethrowIfProviderFailure(error);
154171
return { type: 'cancelled', data: null };
155172
}
156173
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { beforeEach, describe, expect, test, vi } from 'vitest';
2+
3+
import { ClerkGoogleOneTapSignIn } from '../ClerkGoogleOneTapSignIn';
4+
5+
const mocks = vi.hoisted(() => ({
6+
signIn: vi.fn(),
7+
createAccount: vi.fn(),
8+
presentExplicitSignIn: vi.fn(),
9+
}));
10+
11+
vi.mock('../../specs/NativeClerkGoogleSignIn', () => ({
12+
default: {
13+
configure: vi.fn(),
14+
signIn: mocks.signIn,
15+
createAccount: mocks.createAccount,
16+
presentExplicitSignIn: mocks.presentExplicitSignIn,
17+
signOut: vi.fn(),
18+
},
19+
}));
20+
21+
const nativeError = (message: string) => Object.assign(new Error(message), { code: 'SIGN_IN_CANCELLED' });
22+
23+
const methods = [
24+
['signIn', mocks.signIn, () => ClerkGoogleOneTapSignIn.signIn()],
25+
['createAccount', mocks.createAccount, () => ClerkGoogleOneTapSignIn.createAccount()],
26+
['presentExplicitSignIn', mocks.presentExplicitSignIn, () => ClerkGoogleOneTapSignIn.presentExplicitSignIn()],
27+
] as const;
28+
29+
describe('ClerkGoogleOneTapSignIn', () => {
30+
beforeEach(() => {
31+
vi.clearAllMocks();
32+
});
33+
34+
describe.each(methods)('%s', (_name, nativeMethod, call) => {
35+
// Messages androidx.credentials and Play services emit when the user dismisses the chooser.
36+
test.each([
37+
'User cancelled the sign-in flow',
38+
'activity is cancelled by the user.',
39+
'User cancelled the selector',
40+
'[16] Cancelled by user.',
41+
'[16] Canceled by user.',
42+
])('treats %j as a cancellation', async message => {
43+
nativeMethod.mockRejectedValue(nativeError(message));
44+
45+
await expect(call()).resolves.toEqual({ type: 'cancelled', data: null });
46+
});
47+
48+
// Play services reuses status 16 for failures the user did not trigger.
49+
test.each([
50+
'[16] Account reauth failed.',
51+
'16: Account reauth failed.',
52+
'[10] Developer console is not set up correctly.',
53+
])('surfaces %j as a provider failure', async message => {
54+
nativeMethod.mockRejectedValue(nativeError(message));
55+
56+
await expect(call()).rejects.toMatchObject({ code: 'GOOGLE_SIGN_IN_ERROR', message });
57+
});
58+
});
59+
});

packages/expo/src/google-one-tap/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ export type OneTapResponse = OneTapSuccessResponse | CancelledResponse | NoSaved
168168
* - `SIGN_IN_CANCELLED`: User cancelled the sign-in flow
169169
* - `NO_SAVED_CREDENTIAL_FOUND`: No saved credentials available for One Tap
170170
* - `NOT_CONFIGURED`: Module not configured before use
171-
* - `GOOGLE_SIGN_IN_ERROR`: Generic Google Sign-In error
171+
* - `GOOGLE_SIGN_IN_ERROR`: Generic Google Sign-In error, including Android provider failures such as an unregistered OAuth client
172172
* - `E_ACTIVITY_UNAVAILABLE`: Android activity unavailable (GoogleSignInActivityUnavailableException)
173173
*/
174174
export type GoogleSignInErrorCode =

0 commit comments

Comments
 (0)