expo-native-google-signin 0.1.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +300 -0
- package/android/build.gradle +30 -0
- package/android/consumer-rules.pro +4 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/expo/modules/nativegooglesignin/ErrorMapping.kt +83 -0
- package/android/src/main/java/expo/modules/nativegooglesignin/ExpoNativeGoogleSignInModule.kt +187 -0
- package/android/src/main/java/expo/modules/nativegooglesignin/SignInOptions.kt +22 -0
- package/app.plugin.js +1 -0
- package/build/CodedError.d.ts +17 -0
- package/build/CodedError.d.ts.map +1 -0
- package/build/CodedError.js +20 -0
- package/build/CodedError.js.map +1 -0
- package/build/ExpoNativeGoogleSignInModule.d.ts +9 -0
- package/build/ExpoNativeGoogleSignInModule.d.ts.map +1 -0
- package/build/ExpoNativeGoogleSignInModule.js +8 -0
- package/build/ExpoNativeGoogleSignInModule.js.map +1 -0
- package/build/index.d.ts +10 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +52 -0
- package/build/index.js.map +1 -0
- package/build/index.web.d.ts +7 -0
- package/build/index.web.d.ts.map +1 -0
- package/build/index.web.js +16 -0
- package/build/index.web.js.map +1 -0
- package/build/types.d.ts +58 -0
- package/build/types.d.ts.map +1 -0
- package/build/types.js +22 -0
- package/build/types.js.map +1 -0
- package/expo-module.config.json +10 -0
- package/ios/ErrorMapping.swift +52 -0
- package/ios/ExpoNativeGoogleSignIn.podspec +35 -0
- package/ios/ExpoNativeGoogleSignInAppDelegateSubscriber.swift +14 -0
- package/ios/ExpoNativeGoogleSignInModule.swift +157 -0
- package/ios/SignInOptions.swift +13 -0
- package/package.json +78 -0
- package/plugin/build/index.d.ts +19 -0
- package/plugin/build/index.js +42 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Eleftheriou Apostolos
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
# expo-native-google-signin
|
|
2
|
+
|
|
3
|
+
Native Google sign-in for Expo apps on **Android** (Credential Manager) and **iOS** (the
|
|
4
|
+
`GoogleSignIn` SDK). It returns a Google ID token that any backend can verify — Supabase, Firebase,
|
|
5
|
+
or your own server.
|
|
6
|
+
|
|
7
|
+
**Why:** "Google Sign-In for Android" (`GoogleSignInClient` in `play-services-auth`) is deprecated
|
|
8
|
+
in favour of Credential Manager, and `play-services-auth` 22.0.0 already removed those classes from
|
|
9
|
+
the SDK. This module talks only to Google's first-party SDKs — no dependency on
|
|
10
|
+
`@react-native-google-signin/google-signin` or any other community wrapper. MIT-licensed.
|
|
11
|
+
|
|
12
|
+
## Status and requirements
|
|
13
|
+
|
|
14
|
+
- **Pre-1.0 — not published yet.** The first release will be `0.1.0-beta.0`, on the npm `next` tag
|
|
15
|
+
(see [CHANGELOG.md](./CHANGELOG.md)); the install commands below work once it is out. Per
|
|
16
|
+
semver-pre-1.0, a minor bump may break the API — **pin an exact version**:
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
npm install expo-native-google-signin@0.1.0-beta.0 --save-exact
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
- **Expo SDK 57.**
|
|
23
|
+
- **A development build** — this module is not available in **Expo Go**.
|
|
24
|
+
- **Android:** `minSdk` 24.
|
|
25
|
+
- **iOS:** 15.1+, and **Xcode 26+** (Expo SDK 57 itself requires it — `expo-modules-jsi` needs
|
|
26
|
+
Swift tools 6.2).
|
|
27
|
+
- **`GoogleSignIn-iOS` ~> 9.2** (pinned; see `docs/ARCHITECTURE.md` for why).
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
npx expo install expo-native-google-signin
|
|
33
|
+
# or, pinned exactly:
|
|
34
|
+
npm install expo-native-google-signin@0.1.0-beta.0 --save-exact
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Add the config plugin in `app.json` / `app.config.*`:
|
|
38
|
+
|
|
39
|
+
```json
|
|
40
|
+
{
|
|
41
|
+
"expo": {
|
|
42
|
+
"plugins": [
|
|
43
|
+
[
|
|
44
|
+
"expo-native-google-signin",
|
|
45
|
+
{ "iosUrlScheme": "com.googleusercontent.apps.YOUR_IOS_CLIENT_ID" }
|
|
46
|
+
]
|
|
47
|
+
]
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Then rebuild the native projects:
|
|
53
|
+
|
|
54
|
+
```sh
|
|
55
|
+
npx expo prebuild
|
|
56
|
+
npx expo run:android
|
|
57
|
+
npx expo run:ios
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
(or an EAS build). `iosUrlScheme` is required for the sign-in sheet to hand control back to your
|
|
61
|
+
app on iOS — without it, the plugin only logs a warning.
|
|
62
|
+
|
|
63
|
+
## Google Cloud setup
|
|
64
|
+
|
|
65
|
+
Create OAuth 2.0 client IDs in [Google Cloud Console](https://console.cloud.google.com/apis/credentials):
|
|
66
|
+
|
|
67
|
+
1. **Web client** — its ID is `webClientId`. It becomes the `aud` claim of the ID token this module
|
|
68
|
+
returns, on **both** platforms.
|
|
69
|
+
2. **iOS client** — its ID is `iosClientId`. Its reversed ID is `iosUrlScheme`
|
|
70
|
+
(`com.googleusercontent.apps.…`, shown as "URL scheme" on the client's page in the console).
|
|
71
|
+
3. **Android client** — registered with your app's **package name** and **signing SHA-1**. There is
|
|
72
|
+
no `iosClientId`-equivalent to pass at runtime for Android: Google identifies the calling app by
|
|
73
|
+
package name + SHA-1 on this client, and the ID token's audience is the **web** client ID passed
|
|
74
|
+
as `webClientId`. See `docs/ARCHITECTURE.md` → "Platform mechanics" for the full rationale.
|
|
75
|
+
|
|
76
|
+
> [!WARNING]
|
|
77
|
+
> **Play App Signing re-signs your app**, so the Android OAuth client needs **both** the
|
|
78
|
+
> upload-key SHA-1 **and** the Play app-signing SHA-1. If you only register the upload-key SHA-1,
|
|
79
|
+
> sign-in fails only for builds installed from Google Play — usually surfacing as
|
|
80
|
+
> `NO_GOOGLE_ACCOUNT` even though the device has one. Also register your debug keystore's and any
|
|
81
|
+
> EAS build profile's SHA-1s, or sign-in won't work in development builds either.
|
|
82
|
+
|
|
83
|
+
Use placeholders like `YOUR_WEB_CLIENT_ID.apps.googleusercontent.com` when configuring — never
|
|
84
|
+
commit a real client ID, SHA-1 or keystore (this repo is public).
|
|
85
|
+
|
|
86
|
+
## Usage
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
import { isErrorWithCode, signIn, signOut } from "expo-native-google-signin";
|
|
90
|
+
|
|
91
|
+
async function handleSignIn() {
|
|
92
|
+
try {
|
|
93
|
+
const result = await signIn({
|
|
94
|
+
webClientId: "YOUR_WEB_CLIENT_ID.apps.googleusercontent.com",
|
|
95
|
+
iosClientId: "YOUR_IOS_CLIENT_ID.apps.googleusercontent.com", // required on iOS only
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
if (result.type === "cancelled") {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// result.type === "success"
|
|
103
|
+
console.log(result.user.email, result.idToken);
|
|
104
|
+
// There is no `result.user.id` -- decode `result.idToken` (a JWT) and read its `sub`
|
|
105
|
+
// claim if you need the stable Google account identifier.
|
|
106
|
+
} catch (error) {
|
|
107
|
+
if (isErrorWithCode(error)) {
|
|
108
|
+
console.warn(error.code, error.message);
|
|
109
|
+
} else {
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function handleSignOut() {
|
|
116
|
+
await signOut();
|
|
117
|
+
}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## API reference
|
|
121
|
+
|
|
122
|
+
### `signIn(options): Promise<SignInResult>`
|
|
123
|
+
|
|
124
|
+
| Option | Type | Required | Notes |
|
|
125
|
+
| ------------- | -------- | ------------------ | -------------------------------------------------------------|
|
|
126
|
+
| `webClientId` | `string` | Yes (both platforms) | OAuth **web** client ID; becomes the token's `aud` claim. |
|
|
127
|
+
| `iosClientId` | `string` | iOS only | OAuth **iOS** client ID; ignored on Android. |
|
|
128
|
+
| `nonce` | `string` | No | Forwarded to the native SDK and embedded in the ID token. |
|
|
129
|
+
|
|
130
|
+
```ts
|
|
131
|
+
type SignInResult =
|
|
132
|
+
| { type: "success"; idToken: string; user: GoogleUser }
|
|
133
|
+
| { type: "cancelled" };
|
|
134
|
+
|
|
135
|
+
type GoogleUser = {
|
|
136
|
+
email: string;
|
|
137
|
+
name?: string;
|
|
138
|
+
givenName?: string;
|
|
139
|
+
familyName?: string;
|
|
140
|
+
photoUrl?: string;
|
|
141
|
+
};
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
There is deliberately no `user.id`: Android's `GoogleIdTokenCredential.id` is the account's email
|
|
145
|
+
address, not the Google subject. Decode `idToken` and read its `sub` claim instead.
|
|
146
|
+
|
|
147
|
+
A user-initiated cancel **resolves** `{ type: "cancelled" }` — it does not reject.
|
|
148
|
+
|
|
149
|
+
### `signOut(): Promise<void>`
|
|
150
|
+
|
|
151
|
+
Clears the locally cached Google credential state on both platforms.
|
|
152
|
+
|
|
153
|
+
### `isErrorWithCode(error): error is Error & { code: ErrorCode }`
|
|
154
|
+
|
|
155
|
+
Narrows a caught `error` to one with a known `code` from `ERROR_CODES` below.
|
|
156
|
+
|
|
157
|
+
### `ERROR_CODES`
|
|
158
|
+
|
|
159
|
+
`signIn`/`signOut` reject with an `Error` whose `code` is one of these. The native error message is
|
|
160
|
+
always preserved on `error.message` for logging.
|
|
161
|
+
|
|
162
|
+
| Code | When | Platform(s) |
|
|
163
|
+
| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------| ----------- |
|
|
164
|
+
| `CONFIGURATION_ERROR` | Missing/blank `webClientId`; missing/blank `iosClientId` on iOS; an invalid `nonce` type; or the module is unavailable (e.g. running in **Expo Go** instead of a development build) | Both |
|
|
165
|
+
| `NO_GOOGLE_ACCOUNT` | No Google account is available to choose from (Credential Manager's `NoCredentialException`) | Android only |
|
|
166
|
+
| `PROVIDER_UNAVAILABLE` | The credential provider isn't configured or supported on the device (e.g. no Play services, or an emulator without a Play Store image) | Android only |
|
|
167
|
+
| `NO_PRESENTER` | No current Activity (Android) / view controller (iOS) to present the sign-in UI from — e.g. the app was backgrounded mid-tap | Both |
|
|
168
|
+
| `UNEXPECTED_CREDENTIAL` | The returned credential isn't a parseable Google ID token credential, or is missing a required field (e.g. no email/profile on iOS) | Both |
|
|
169
|
+
| `SIGN_IN_FAILED` | Any other failure. **On iOS, every non-cancel failure uses this one code** — `GoogleSignIn` has a single flat error domain that doesn't distinguish "no account" or "provider unavailable" the way Android's Credential Manager does. The native domain, code and message are always kept in `error.message`. **On Android, `[16] Account reauth failed` (an unregistered package + signing SHA-1) rejects with `SIGN_IN_FAILED` rather than resolving as cancelled.** | Both |
|
|
170
|
+
|
|
171
|
+
## Backends
|
|
172
|
+
|
|
173
|
+
### Supabase — `signInWithIdToken` with a nonce
|
|
174
|
+
|
|
175
|
+
Verifying the nonce ties the ID token to this specific sign-in attempt. Generate a raw nonce,
|
|
176
|
+
SHA-256-hash it, pass the **hashed** nonce into `signIn`, and pass the **raw** nonce to Supabase (it
|
|
177
|
+
hashes it again internally to compare against the token's `nonce` claim):
|
|
178
|
+
|
|
179
|
+
```ts
|
|
180
|
+
import * as Crypto from "expo-crypto";
|
|
181
|
+
import { signIn } from "expo-native-google-signin";
|
|
182
|
+
import { createClient } from "@supabase/supabase-js";
|
|
183
|
+
|
|
184
|
+
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
|
|
185
|
+
|
|
186
|
+
async function signInWithGoogle() {
|
|
187
|
+
const rawNonce = Crypto.randomUUID();
|
|
188
|
+
const hashedNonce = await Crypto.digestStringAsync(
|
|
189
|
+
Crypto.CryptoDigestAlgorithm.SHA256,
|
|
190
|
+
rawNonce,
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
const result = await signIn({
|
|
194
|
+
webClientId: "YOUR_WEB_CLIENT_ID.apps.googleusercontent.com",
|
|
195
|
+
iosClientId: "YOUR_IOS_CLIENT_ID.apps.googleusercontent.com",
|
|
196
|
+
nonce: hashedNonce,
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
if (result.type === "cancelled") {
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const { data, error } = await supabase.auth.signInWithIdToken({
|
|
204
|
+
provider: "google",
|
|
205
|
+
token: result.idToken,
|
|
206
|
+
nonce: rawNonce,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
(`expo-crypto` and `@supabase/supabase-js` are not dependencies of this module — install them
|
|
212
|
+
yourself if you use this pattern.)
|
|
213
|
+
|
|
214
|
+
### Firebase
|
|
215
|
+
|
|
216
|
+
```ts
|
|
217
|
+
import { getAuth, GoogleAuthProvider, signInWithCredential } from "firebase/auth";
|
|
218
|
+
|
|
219
|
+
const credential = GoogleAuthProvider.credential(result.idToken);
|
|
220
|
+
await signInWithCredential(getAuth(), credential);
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
### A custom server
|
|
224
|
+
|
|
225
|
+
Verify, on the server: the ID token's signature (against Google's published JWKS), `aud` (must
|
|
226
|
+
equal your **web** client ID), `iss` (`accounts.google.com` / `https://accounts.google.com`),
|
|
227
|
+
`exp`, and — if you passed one to `signIn` — the (hashed) `nonce` claim.
|
|
228
|
+
|
|
229
|
+
## Migrating from `@react-native-google-signin/google-signin`
|
|
230
|
+
|
|
231
|
+
| `@react-native-google-signin/google-signin` | `expo-native-google-signin` |
|
|
232
|
+
| ---------------------------------------------------------------------| ----------------------------------------------------------------------------------------------|
|
|
233
|
+
| `GoogleSignin.configure({ webClientId, iosClientId })` then `GoogleSignin.signIn()` | One call: `signIn({ webClientId, iosClientId, nonce? })` — no separate configure step |
|
|
234
|
+
| Result `{ type: "success" \| "cancelled", ... }` | Same `type` values and meaning — a cancel resolves, it doesn't throw |
|
|
235
|
+
| `user.id` (Google subject) | No `id` — decode `idToken` and read its `sub` claim instead |
|
|
236
|
+
| `androidClientId` config option | Removed — not needed; Google matches by package name + SHA-1, and `webClientId` is the token audience on both platforms |
|
|
237
|
+
| `GoogleSignin.signOut()` | `signOut()` |
|
|
238
|
+
| Config plugin option `iosUrlScheme` | Same option name, same purpose |
|
|
239
|
+
|
|
240
|
+
This module exists because the free version of `@react-native-google-signin/google-signin` stays on
|
|
241
|
+
the legacy, deprecated Android sign-in API — its Credential Manager support is part of a paid
|
|
242
|
+
product (see `docs/ARCHITECTURE.md` → "Why this project exists").
|
|
243
|
+
|
|
244
|
+
## Troubleshooting
|
|
245
|
+
|
|
246
|
+
- **`NO_GOOGLE_ACCOUNT` only on builds installed from Google Play:** the Android OAuth client is
|
|
247
|
+
missing the Play app-signing SHA-1 (see the warning above) — register both SHA-1s.
|
|
248
|
+
- **`SIGN_IN_FAILED` with `[16] Account reauth failed` on Android:** the Android OAuth client is
|
|
249
|
+
missing this build's signing SHA-1 (debug, upload or Play app-signing). An Android OAuth client
|
|
250
|
+
holds one SHA-1, so register one client per key, with the same package name, in the same Google
|
|
251
|
+
Cloud project as `webClientId`.
|
|
252
|
+
- **No accounts on an emulator:** use an emulator image that includes the Play Store, and sign into
|
|
253
|
+
a Google account on it first.
|
|
254
|
+
- **`CONFIGURATION_ERROR` in Expo Go:** this module requires a development build; Expo Go can't load
|
|
255
|
+
it.
|
|
256
|
+
- **iOS never returns to the app after choosing an account:** the config plugin's `iosUrlScheme` is
|
|
257
|
+
missing or wrong — check `Info.plist`'s `CFBundleURLTypes` after `expo prebuild`.
|
|
258
|
+
- **iOS build fails to resolve packages / Xcode errors:** this module (via Expo SDK 57) requires
|
|
259
|
+
**Xcode 26+**.
|
|
260
|
+
|
|
261
|
+
## Example app
|
|
262
|
+
|
|
263
|
+
```sh
|
|
264
|
+
cd example
|
|
265
|
+
cp .env.example .env
|
|
266
|
+
# fill in EXPO_PUBLIC_WEB_CLIENT_ID, EXPO_PUBLIC_IOS_CLIENT_ID, EXPO_PUBLIC_IOS_URL_SCHEME
|
|
267
|
+
npm install
|
|
268
|
+
npx expo run:android # or: npx expo run:ios
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
## Contributing / releasing (maintainers)
|
|
272
|
+
|
|
273
|
+
See `CLAUDE.md` for the full command reference (`npm run build`, `lint`, `typecheck`, `test`,
|
|
274
|
+
`build:plugin`, `test:plugin`, etc.).
|
|
275
|
+
|
|
276
|
+
**Release:**
|
|
277
|
+
|
|
278
|
+
1. Bump the version in `package.json` (and `android/build.gradle`'s `version`/`versionName`, which
|
|
279
|
+
is not read from `package.json`), and add a `CHANGELOG.md` entry.
|
|
280
|
+
2. Merge to `main`.
|
|
281
|
+
3. Tag `vX.Y.Z` from `main` and push the tag — `release.yml` publishes to npm with provenance
|
|
282
|
+
(prereleases go to the `next` dist-tag, stable versions to `latest`), but only if the tag matches
|
|
283
|
+
`package.json`'s version and the tagged commit is on `main`.
|
|
284
|
+
|
|
285
|
+
**First publish:** npm trusted publishing needs the package to already exist, so the very first
|
|
286
|
+
version is published manually (`npm publish --access public --tag next`), or via an `NPM_TOKEN`
|
|
287
|
+
repository secret.
|
|
288
|
+
|
|
289
|
+
**After that**, configure npm trusted publishing so `release.yml` doesn't need a token: on
|
|
290
|
+
npmjs.com, package settings → **Trusted Publisher** → GitHub Actions, repo
|
|
291
|
+
`ele-ap/expo-native-google-signin`, workflow `release.yml`, environment `npm`. Then delete the
|
|
292
|
+
`NPM_TOKEN` secret.
|
|
293
|
+
|
|
294
|
+
## Disclaimer
|
|
295
|
+
|
|
296
|
+
This project is not affiliated with or endorsed by Google.
|
|
297
|
+
|
|
298
|
+
## License
|
|
299
|
+
|
|
300
|
+
[MIT](./LICENSE)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
plugins {
|
|
2
|
+
id 'com.android.library'
|
|
3
|
+
id 'expo-module-gradle-plugin'
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
group = 'expo.modules.nativegooglesignin'
|
|
7
|
+
version = '0.1.0-beta.0'
|
|
8
|
+
|
|
9
|
+
android {
|
|
10
|
+
namespace "expo.modules.nativegooglesignin"
|
|
11
|
+
defaultConfig {
|
|
12
|
+
versionCode 1
|
|
13
|
+
versionName "0.1.0-beta.0"
|
|
14
|
+
consumerProguardFiles 'consumer-rules.pro'
|
|
15
|
+
}
|
|
16
|
+
lintOptions {
|
|
17
|
+
abortOnError false
|
|
18
|
+
}
|
|
19
|
+
testOptions {
|
|
20
|
+
unitTests.returnDefaultValues = true
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
dependencies {
|
|
25
|
+
implementation 'androidx.credentials:credentials:1.6.0'
|
|
26
|
+
implementation 'androidx.credentials:credentials-play-services-auth:1.6.0'
|
|
27
|
+
implementation 'com.google.android.libraries.identity.googleid:googleid:1.2.0'
|
|
28
|
+
|
|
29
|
+
testImplementation 'junit:junit:4.13.2'
|
|
30
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
package expo.modules.nativegooglesignin
|
|
2
|
+
|
|
3
|
+
import androidx.credentials.exceptions.GetCredentialCancellationException
|
|
4
|
+
import androidx.credentials.exceptions.GetCredentialException
|
|
5
|
+
import androidx.credentials.exceptions.GetCredentialProviderConfigurationException
|
|
6
|
+
import androidx.credentials.exceptions.GetCredentialUnsupportedException
|
|
7
|
+
import androidx.credentials.exceptions.NoCredentialException
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Error codes `signIn`/`signOut` reject with. Must match `ERROR_CODES` in `src/types.ts` exactly
|
|
11
|
+
* -- see docs/ARCHITECTURE.md -> "Public API shape".
|
|
12
|
+
*/
|
|
13
|
+
object ErrorCodes {
|
|
14
|
+
const val CONFIGURATION_ERROR = "CONFIGURATION_ERROR"
|
|
15
|
+
const val NO_GOOGLE_ACCOUNT = "NO_GOOGLE_ACCOUNT"
|
|
16
|
+
const val PROVIDER_UNAVAILABLE = "PROVIDER_UNAVAILABLE"
|
|
17
|
+
const val NO_PRESENTER = "NO_PRESENTER"
|
|
18
|
+
const val UNEXPECTED_CREDENTIAL = "UNEXPECTED_CREDENTIAL"
|
|
19
|
+
const val SIGN_IN_FAILED = "SIGN_IN_FAILED"
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The outcome of mapping a Credential Manager [GetCredentialException] for `signIn`.
|
|
24
|
+
*
|
|
25
|
+
* Kept free of Android framework calls (pure logic over the exception's own properties) so it can
|
|
26
|
+
* be covered by a plain JVM unit test -- see `ErrorMappingTest`.
|
|
27
|
+
*/
|
|
28
|
+
sealed interface SignInFailure {
|
|
29
|
+
/**
|
|
30
|
+
* The user dismissed the account chooser; `signIn` should resolve `{ type: 'cancelled' }`.
|
|
31
|
+
* Not every [GetCredentialCancellationException] is a dismissal: `[16] Account reauth failed`
|
|
32
|
+
* (matched on `[16]` or "reauth failed") is a misconfiguration and maps to [Coded] -- see [mapGetCredentialException].
|
|
33
|
+
*/
|
|
34
|
+
object Cancelled : SignInFailure
|
|
35
|
+
|
|
36
|
+
/** `signIn` should reject with this coded error. */
|
|
37
|
+
data class Coded(val code: String, val message: String) : SignInFailure
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
private const val REAUTH_FAILED_HINT =
|
|
41
|
+
" -- this usually means the app's package name + signing SHA-1 aren't registered on an " +
|
|
42
|
+
"Android OAuth client in the same Google Cloud project as webClientId (register debug, " +
|
|
43
|
+
"upload and Play app-signing SHA-1s; see README Troubleshooting)."
|
|
44
|
+
|
|
45
|
+
private fun isReauthFailed(msg: String): Boolean =
|
|
46
|
+
msg.contains("[16]") || msg.contains("reauth failed", ignoreCase = true)
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Maps a [GetCredentialException] thrown by `CredentialManager.getCredentialAsync` to a
|
|
50
|
+
* [SignInFailure] -- see docs/ARCHITECTURE.md -> "Public API shape" and the plan's "Native
|
|
51
|
+
* behaviour -> Android -> Error mapping" for the rationale behind each mapping.
|
|
52
|
+
*
|
|
53
|
+
* Exception: Google Play services reports `[16] Account reauth failed` -- usually an unregistered
|
|
54
|
+
* package name + signing SHA-1 -- as a [GetCredentialCancellationException]. That is a real
|
|
55
|
+
* failure, not a user dismissal, so a cancellation whose message contains `[16]` or "reauth
|
|
56
|
+
* failed" (case-insensitive, in case a Play services version formats the code differently) maps
|
|
57
|
+
* to `SIGN_IN_FAILED` (with a hint) instead of [SignInFailure.Cancelled]. Any other cancellation
|
|
58
|
+
* stays [SignInFailure.Cancelled].
|
|
59
|
+
*/
|
|
60
|
+
fun mapGetCredentialException(e: GetCredentialException): SignInFailure = when (e) {
|
|
61
|
+
is GetCredentialCancellationException ->
|
|
62
|
+
if (isReauthFailed(e.message.orEmpty())) {
|
|
63
|
+
SignInFailure.Coded(ErrorCodes.SIGN_IN_FAILED, nativeMessage(e) + REAUTH_FAILED_HINT)
|
|
64
|
+
} else {
|
|
65
|
+
SignInFailure.Cancelled
|
|
66
|
+
}
|
|
67
|
+
is NoCredentialException -> SignInFailure.Coded(ErrorCodes.NO_GOOGLE_ACCOUNT, nativeMessage(e))
|
|
68
|
+
is GetCredentialProviderConfigurationException,
|
|
69
|
+
is GetCredentialUnsupportedException ->
|
|
70
|
+
SignInFailure.Coded(ErrorCodes.PROVIDER_UNAVAILABLE, nativeMessage(e))
|
|
71
|
+
else -> SignInFailure.Coded(ErrorCodes.SIGN_IN_FAILED, nativeMessage(e))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The native message: [Throwable.message] (which androidx sets from the exception's error
|
|
76
|
+
* message; `errorMessage` itself is a restricted API), falling back to
|
|
77
|
+
* [GetCredentialException.type]. Prefixed with the exception's simple class name, since several
|
|
78
|
+
* exception types share a single error code above.
|
|
79
|
+
*/
|
|
80
|
+
private fun nativeMessage(e: GetCredentialException): String {
|
|
81
|
+
val message = e.message ?: e.type
|
|
82
|
+
return "${e.javaClass.simpleName}: $message"
|
|
83
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
package expo.modules.nativegooglesignin
|
|
2
|
+
|
|
3
|
+
import android.os.CancellationSignal
|
|
4
|
+
import androidx.core.content.ContextCompat
|
|
5
|
+
import androidx.credentials.ClearCredentialStateRequest
|
|
6
|
+
import androidx.credentials.CredentialManager
|
|
7
|
+
import androidx.credentials.CredentialManagerCallback
|
|
8
|
+
import androidx.credentials.CustomCredential
|
|
9
|
+
import androidx.credentials.GetCredentialRequest
|
|
10
|
+
import androidx.credentials.GetCredentialResponse
|
|
11
|
+
import androidx.credentials.exceptions.ClearCredentialException
|
|
12
|
+
import androidx.credentials.exceptions.GetCredentialException
|
|
13
|
+
import com.google.android.libraries.identity.googleid.GetSignInWithGoogleOption
|
|
14
|
+
import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential
|
|
15
|
+
import com.google.android.libraries.identity.googleid.GoogleIdTokenParsingException
|
|
16
|
+
import expo.modules.kotlin.Promise
|
|
17
|
+
import expo.modules.kotlin.exception.CodedException
|
|
18
|
+
import expo.modules.kotlin.modules.Module
|
|
19
|
+
import expo.modules.kotlin.modules.ModuleDefinition
|
|
20
|
+
|
|
21
|
+
class ExpoNativeGoogleSignInModule : Module() {
|
|
22
|
+
override fun definition() = ModuleDefinition {
|
|
23
|
+
Name("ExpoNativeGoogleSignIn")
|
|
24
|
+
|
|
25
|
+
// Promise-based overloads: a lambda that only throws has type `Nothing`, which Expo's reified
|
|
26
|
+
// AsyncFunction can't accept (see CLAUDE.md -> Conventions).
|
|
27
|
+
AsyncFunction("signIn") { options: SignInOptions, promise: Promise ->
|
|
28
|
+
signIn(options, promise)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
AsyncFunction("signOut") { promise: Promise ->
|
|
32
|
+
signOut(promise)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
private fun signIn(options: SignInOptions, promise: Promise) {
|
|
37
|
+
val webClientId = options.webClientId.trim()
|
|
38
|
+
if (webClientId.isEmpty()) {
|
|
39
|
+
promise.reject(
|
|
40
|
+
CodedException(
|
|
41
|
+
ErrorCodes.CONFIGURATION_ERROR,
|
|
42
|
+
"expo-native-google-signin: `webClientId` is required on Android (pass the OAuth " +
|
|
43
|
+
"web client ID as `serverClientId` -- see docs/ARCHITECTURE.md).",
|
|
44
|
+
null
|
|
45
|
+
)
|
|
46
|
+
)
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
val activity = appContext.currentActivity
|
|
51
|
+
if (activity == null) {
|
|
52
|
+
promise.reject(
|
|
53
|
+
CodedException(
|
|
54
|
+
ErrorCodes.NO_PRESENTER,
|
|
55
|
+
"expo-native-google-signin: no current Activity to present the sign-in UI from.",
|
|
56
|
+
null
|
|
57
|
+
)
|
|
58
|
+
)
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
// Treat a blank nonce as absent.
|
|
64
|
+
val nonce = options.nonce?.trim()?.takeUnless { it.isEmpty() }
|
|
65
|
+
val signInOption = GetSignInWithGoogleOption.Builder(serverClientId = webClientId)
|
|
66
|
+
.apply { nonce?.let { setNonce(it) } }
|
|
67
|
+
.build()
|
|
68
|
+
val request = GetCredentialRequest.Builder()
|
|
69
|
+
.addCredentialOption(signInOption)
|
|
70
|
+
.build()
|
|
71
|
+
|
|
72
|
+
CredentialManager.create(activity).getCredentialAsync(
|
|
73
|
+
activity,
|
|
74
|
+
request,
|
|
75
|
+
CancellationSignal(),
|
|
76
|
+
ContextCompat.getMainExecutor(activity),
|
|
77
|
+
object : CredentialManagerCallback<GetCredentialResponse, GetCredentialException> {
|
|
78
|
+
override fun onResult(result: GetCredentialResponse) = resolveSignIn(result, promise)
|
|
79
|
+
|
|
80
|
+
override fun onError(e: GetCredentialException) = rejectSignIn(e, promise)
|
|
81
|
+
}
|
|
82
|
+
)
|
|
83
|
+
} catch (t: Throwable) {
|
|
84
|
+
promise.reject(CodedException(ErrorCodes.SIGN_IN_FAILED, t.message ?: t.toString(), t))
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private fun resolveSignIn(response: GetCredentialResponse, promise: Promise) {
|
|
89
|
+
try {
|
|
90
|
+
// 1.6.0 exposes only `credential` (singular); the `credentials` list arrived in 1.7.0-alpha03.
|
|
91
|
+
val credential = response.credential
|
|
92
|
+
if (
|
|
93
|
+
credential !is CustomCredential ||
|
|
94
|
+
credential.type != GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL
|
|
95
|
+
) {
|
|
96
|
+
promise.reject(
|
|
97
|
+
CodedException(
|
|
98
|
+
ErrorCodes.UNEXPECTED_CREDENTIAL,
|
|
99
|
+
"expo-native-google-signin: received an unexpected credential of type " +
|
|
100
|
+
"'${credential.type}'.",
|
|
101
|
+
null
|
|
102
|
+
)
|
|
103
|
+
)
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
val googleIdTokenCredential = GoogleIdTokenCredential.createFrom(credential.data)
|
|
108
|
+
|
|
109
|
+
// Omit null user fields rather than sending null, since the TS types are optional.
|
|
110
|
+
val user = mutableMapOf<String, Any?>("email" to googleIdTokenCredential.id)
|
|
111
|
+
googleIdTokenCredential.displayName?.let { user["name"] = it }
|
|
112
|
+
googleIdTokenCredential.givenName?.let { user["givenName"] = it }
|
|
113
|
+
googleIdTokenCredential.familyName?.let { user["familyName"] = it }
|
|
114
|
+
googleIdTokenCredential.profilePictureUri?.let { user["photoUrl"] = it.toString() }
|
|
115
|
+
|
|
116
|
+
promise.resolve(
|
|
117
|
+
mapOf(
|
|
118
|
+
"type" to "success",
|
|
119
|
+
"idToken" to googleIdTokenCredential.idToken,
|
|
120
|
+
"user" to user
|
|
121
|
+
)
|
|
122
|
+
)
|
|
123
|
+
} catch (e: GoogleIdTokenParsingException) {
|
|
124
|
+
promise.reject(
|
|
125
|
+
CodedException(
|
|
126
|
+
ErrorCodes.UNEXPECTED_CREDENTIAL,
|
|
127
|
+
"expo-native-google-signin: failed to parse the Google ID token credential " +
|
|
128
|
+
"(${e.message ?: e.toString()}).",
|
|
129
|
+
e
|
|
130
|
+
)
|
|
131
|
+
)
|
|
132
|
+
} catch (t: Throwable) {
|
|
133
|
+
promise.reject(CodedException(ErrorCodes.SIGN_IN_FAILED, t.message ?: t.toString(), t))
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private fun rejectSignIn(e: GetCredentialException, promise: Promise) {
|
|
138
|
+
try {
|
|
139
|
+
when (val failure = mapGetCredentialException(e)) {
|
|
140
|
+
is SignInFailure.Cancelled -> promise.resolve(mapOf("type" to "cancelled"))
|
|
141
|
+
is SignInFailure.Coded ->
|
|
142
|
+
promise.reject(CodedException(failure.code, failure.message, e))
|
|
143
|
+
}
|
|
144
|
+
} catch (t: Throwable) {
|
|
145
|
+
promise.reject(CodedException(ErrorCodes.SIGN_IN_FAILED, t.message ?: t.toString(), t))
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private fun signOut(promise: Promise) {
|
|
150
|
+
val context = appContext.reactContext
|
|
151
|
+
if (context == null) {
|
|
152
|
+
promise.reject(
|
|
153
|
+
CodedException(
|
|
154
|
+
ErrorCodes.SIGN_IN_FAILED,
|
|
155
|
+
"expo-native-google-signin: no application context available to sign out.",
|
|
156
|
+
null
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
return
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
CredentialManager.create(context).clearCredentialStateAsync(
|
|
164
|
+
ClearCredentialStateRequest(),
|
|
165
|
+
CancellationSignal(),
|
|
166
|
+
ContextCompat.getMainExecutor(context),
|
|
167
|
+
object : CredentialManagerCallback<Void?, ClearCredentialException> {
|
|
168
|
+
override fun onResult(result: Void?) {
|
|
169
|
+
promise.resolve(null)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
override fun onError(e: ClearCredentialException) {
|
|
173
|
+
promise.reject(
|
|
174
|
+
CodedException(
|
|
175
|
+
ErrorCodes.SIGN_IN_FAILED,
|
|
176
|
+
e.message ?: e.type,
|
|
177
|
+
e
|
|
178
|
+
)
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
)
|
|
183
|
+
} catch (t: Throwable) {
|
|
184
|
+
promise.reject(CodedException(ErrorCodes.SIGN_IN_FAILED, t.message ?: t.toString(), t))
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
package expo.modules.nativegooglesignin
|
|
2
|
+
|
|
3
|
+
import expo.modules.kotlin.records.Field
|
|
4
|
+
import expo.modules.kotlin.records.Record
|
|
5
|
+
import expo.modules.kotlin.types.OptimizedRecord
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Options for `signIn`. Field names and types must match `SignInOptions` in `src/types.ts`
|
|
9
|
+
* exactly, since `src/index.ts` passes the JS object straight through.
|
|
10
|
+
*
|
|
11
|
+
* `iosClientId` is accepted (so the JS object doesn't need platform-specific stripping) but
|
|
12
|
+
* ignored on Android -- see docs/ARCHITECTURE.md -> "No `androidClientId` option".
|
|
13
|
+
*/
|
|
14
|
+
@OptimizedRecord
|
|
15
|
+
data class SignInOptions(
|
|
16
|
+
@Field
|
|
17
|
+
val webClientId: String = "",
|
|
18
|
+
@Field
|
|
19
|
+
val iosClientId: String? = null,
|
|
20
|
+
@Field
|
|
21
|
+
val nonce: String? = null
|
|
22
|
+
) : Record
|
package/app.plugin.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
module.exports = require('./plugin/build');
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ErrorCode } from "./types";
|
|
2
|
+
/**
|
|
3
|
+
* A minimal stand-in for `expo-modules-core`'s `CodedError` -- same shape (an `Error` subclass
|
|
4
|
+
* with a `code` string) -- shared by `index.ts` and `index.web.ts` so `isErrorWithCode` treats
|
|
5
|
+
* JS-thrown errors (from either platform) and native-thrown errors identically.
|
|
6
|
+
*
|
|
7
|
+
* Deliberately not a dependency on `expo-modules-core` itself: this package only declares a peer
|
|
8
|
+
* dependency on `expo`, which doesn't re-export `CodedError`, and depending on
|
|
9
|
+
* `expo-modules-core` directly would break for consumers whose package manager doesn't hoist it
|
|
10
|
+
* (pnpm, Yarn PnP). Not exported from the package's public surface (`src/index.ts`) -- it's an
|
|
11
|
+
* internal implementation detail, not part of the public API.
|
|
12
|
+
*/
|
|
13
|
+
export declare class CodedError extends Error {
|
|
14
|
+
code: ErrorCode;
|
|
15
|
+
constructor(code: ErrorCode, message: string);
|
|
16
|
+
}
|
|
17
|
+
//# sourceMappingURL=CodedError.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"CodedError.d.ts","sourceRoot":"","sources":["../src/CodedError.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,SAAS,CAAC;AAEzC;;;;;;;;;;GAUG;AACH,qBAAa,UAAW,SAAQ,KAAK;IACnC,IAAI,EAAE,SAAS,CAAC;gBAEJ,IAAI,EAAE,SAAS,EAAE,OAAO,EAAE,MAAM;CAK7C"}
|