react-native-oauth-essentials 1.0.12 → 1.2.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.
Files changed (53) hide show
  1. package/OauthEssentials.podspec +17 -2
  2. package/README.md +385 -361
  3. package/android/build.gradle +15 -10
  4. package/android/src/main/java/com/oauthessentials/CredentialError.kt +4 -1
  5. package/android/src/main/java/com/oauthessentials/CredentialFactory.kt +24 -2
  6. package/android/src/main/java/com/oauthessentials/CustomTabsActivity.kt +14 -1
  7. package/android/src/main/java/com/oauthessentials/OauthEssentialsModule.kt +445 -187
  8. package/app.plugin.js +1 -0
  9. package/ios/AppleIdRetriever.swift +25 -26
  10. package/ios/CredentialError.swift +3 -0
  11. package/ios/CredentialFactory.swift +38 -20
  12. package/ios/CredentialsType.swift +1 -0
  13. package/ios/GoogleIdRetriever.swift +132 -56
  14. package/ios/HybridRetriever.swift +12 -18
  15. package/ios/OauthEssentials.h +0 -3
  16. package/ios/OauthEssentials.mm +45 -2
  17. package/ios/OauthEssentialsModule.swift +266 -57
  18. package/ios/PasskeyRetriever.swift +31 -49
  19. package/ios/PasswordRetriever.swift +10 -30
  20. package/ios/RCTPromiseSettler.swift +125 -0
  21. package/jest/index.js +149 -0
  22. package/lib/module/NativeOauthEssentials.js +10 -1
  23. package/lib/module/NativeOauthEssentials.js.map +1 -1
  24. package/lib/module/errors.js +38 -0
  25. package/lib/module/errors.js.map +1 -0
  26. package/lib/module/index.js +432 -58
  27. package/lib/module/index.js.map +1 -1
  28. package/lib/module/oauth.js +97 -0
  29. package/lib/module/oauth.js.map +1 -0
  30. package/lib/module/platform.js +108 -0
  31. package/lib/module/platform.js.map +1 -0
  32. package/lib/module/types.js +46 -1
  33. package/lib/module/types.js.map +1 -1
  34. package/lib/typescript/src/NativeOauthEssentials.d.ts +70 -16
  35. package/lib/typescript/src/NativeOauthEssentials.d.ts.map +1 -1
  36. package/lib/typescript/src/errors.d.ts +18 -0
  37. package/lib/typescript/src/errors.d.ts.map +1 -0
  38. package/lib/typescript/src/index.d.ts +235 -40
  39. package/lib/typescript/src/index.d.ts.map +1 -1
  40. package/lib/typescript/src/oauth.d.ts +32 -0
  41. package/lib/typescript/src/oauth.d.ts.map +1 -0
  42. package/lib/typescript/src/platform.d.ts +39 -0
  43. package/lib/typescript/src/platform.d.ts.map +1 -0
  44. package/lib/typescript/src/types.d.ts +190 -2
  45. package/lib/typescript/src/types.d.ts.map +1 -1
  46. package/package.json +9 -5
  47. package/plugin/withOauthEssentials.js +291 -0
  48. package/src/NativeOauthEssentials.ts +91 -17
  49. package/src/errors.ts +49 -0
  50. package/src/index.tsx +587 -80
  51. package/src/oauth.ts +140 -0
  52. package/src/platform.ts +121 -0
  53. package/src/types.ts +200 -1
@@ -1,90 +1,122 @@
1
1
  package com.oauthessentials
2
2
 
3
+ import android.accounts.Account
3
4
  import android.annotation.SuppressLint
5
+ import android.app.Activity
4
6
  import android.content.BroadcastReceiver
5
7
  import android.content.Context
6
8
  import android.content.Intent
7
9
  import android.content.IntentFilter
10
+ import android.content.pm.PackageManager
8
11
  import android.net.Uri
9
12
  import android.os.Build
13
+ import android.util.Base64
10
14
  import android.util.Log
11
15
  import android.util.Patterns
12
16
  import androidx.annotation.RequiresApi
13
17
  import androidx.core.content.ContextCompat
14
18
  import androidx.core.net.toUri
19
+ import androidx.credentials.ClearCredentialStateRequest
15
20
  import androidx.credentials.CreatePasswordRequest
16
21
  import androidx.credentials.CreatePublicKeyCredentialRequest
17
22
  import androidx.credentials.CredentialManager
23
+ import androidx.credentials.CredentialOption
18
24
  import androidx.credentials.GetCredentialRequest
25
+ import androidx.credentials.GetCredentialResponse
19
26
  import androidx.credentials.GetPasswordOption
20
27
  import androidx.credentials.GetPublicKeyCredentialOption
21
28
  import androidx.credentials.exceptions.CreateCredentialCancellationException
22
29
  import androidx.credentials.exceptions.GetCredentialCancellationException
30
+ import androidx.credentials.exceptions.NoCredentialException
31
+ import com.facebook.react.bridge.ActivityEventListener
23
32
  import com.facebook.react.bridge.Arguments
24
33
  import com.facebook.react.bridge.Promise
25
34
  import com.facebook.react.bridge.ReactApplicationContext
26
35
  import com.facebook.react.bridge.ReadableMap
27
36
  import com.facebook.react.bridge.WritableMap
37
+ import com.google.android.gms.auth.api.identity.AuthorizationRequest
38
+ import com.google.android.gms.auth.api.identity.AuthorizationResult
39
+ import com.google.android.gms.auth.api.identity.Identity
28
40
  import com.google.android.gms.common.ConnectionResult
29
41
  import com.google.android.gms.common.GoogleApiAvailability
42
+ import com.google.android.gms.common.api.Scope
30
43
  import com.google.android.libraries.identity.googleid.GetGoogleIdOption
44
+ import com.google.android.libraries.identity.googleid.GetSignInWithGoogleOption
45
+ import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential
46
+ import kotlinx.coroutines.CancellableContinuation
31
47
  import kotlinx.coroutines.CoroutineScope
32
48
  import kotlinx.coroutines.Dispatchers
33
- import kotlinx.coroutines.Job
34
49
  import kotlinx.coroutines.SupervisorJob
35
50
  import kotlinx.coroutines.cancel
36
51
  import kotlinx.coroutines.launch
52
+ import kotlinx.coroutines.suspendCancellableCoroutine
37
53
  import kotlinx.coroutines.withContext
54
+ import java.security.MessageDigest
38
55
  import java.security.SecureRandom
39
- import java.util.Base64
40
- import java.util.UUID
56
+ import kotlin.coroutines.resume
57
+ import kotlin.coroutines.resumeWithException
41
58
 
42
59
  class OauthEssentialsModule(reactContext: ReactApplicationContext) :
43
60
  NativeOauthEssentialsSpec(reactContext) {
44
61
 
45
- private var appleSignInPromiseSettler: RCTPromiseSettler? = null
62
+ /**
63
+ * Browser flows (web Apple sign in and generic OAuth) share CustomTabsActivity,
64
+ * so only one can be pending at a time.
65
+ */
66
+ private enum class BrowserSessionKind { APPLE, OAUTH }
67
+
68
+ private var browserSessionSettler: RCTPromiseSettler? = null
69
+ private var browserSessionKind: BrowserSessionKind? = null
70
+ private var authorizationContinuation: CancellableContinuation<Pair<Int, Intent?>>? = null
46
71
  private val moduleScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
47
72
 
48
73
  private val receiver = object : BroadcastReceiver() {
49
74
  override fun onReceive(context: Context?, intent: Intent?) {
50
75
  intent?.action?.let { action ->
51
76
  Log.d(LOG_TAG, "Handling intent action ${action} received")
77
+ val settler = browserSessionSettler
78
+ if (settler == null || settler.settled()) {
79
+ Log.d(LOG_TAG, "No pending browser session for ${action}")
80
+ return
81
+ }
82
+ val kind = browserSessionKind
52
83
  when (action) {
53
84
  WEB_APPLE_DEEP_LINK_RECEIVED_INTENT -> {
54
- val url = intent.getStringExtra("url")
85
+ val url = intent.getStringExtra("url") ?: return
55
86
  Log.d(LOG_TAG, "Receiving deep link ${url}")
56
- intent.getStringExtra("url")?.let { url ->
87
+ if (kind == BrowserSessionKind.OAUTH) {
88
+ settler.resolve(CredentialFactory.fromRedirect(url))
89
+ } else {
57
90
  val uri = url.toUri()
58
- appleSignInPromiseSettler?.let {
59
- if (!it.settled()) {
60
- Log.d(LOG_TAG, "Sending deep link callback")
61
- val result = Arguments.createMap().apply {
62
- putString("type", CredentialFactory.CredentialsType.WEB_APPLE_ID.code)
63
- putMap("data", Arguments.createMap().apply {
64
- putString("url", uri.toString())
65
- putString("scheme", uri.scheme)
66
- putString("host", uri.host)
67
- putString("path", uri.path)
68
- putString("query", uri.query)
69
- })
70
- }
71
- resolveAndEmit(result, it)
72
- } else {
73
- Log.d(LOG_TAG, "Cannot call deep link callback")
74
- }
91
+ val result = Arguments.createMap().apply {
92
+ putString("type", CredentialFactory.CredentialsType.WEB_APPLE_ID.code)
93
+ putMap("data", Arguments.createMap().apply {
94
+ putString("url", uri.toString())
95
+ putString("scheme", uri.scheme)
96
+ putString("host", uri.host)
97
+ putString("path", uri.path)
98
+ putString("query", uri.query)
99
+ })
75
100
  }
101
+ resolveAndEmit(result, settler)
76
102
  }
77
103
  }
78
104
 
79
105
  WEB_APPLE_CANCELLED_INTENT -> {
80
106
  Log.d(LOG_TAG, "Receiving cancellation from CustomTabsActivity")
81
- appleSignInPromiseSettler?.let {
82
- if (!it.settled()) {
83
- Log.d(LOG_TAG, "Sending cancellation callback")
84
- resolveAndEmit(CredentialFactory.fromCancelled(), it)
85
- } else {
86
- Log.d(LOG_TAG, "Cannot call cancellation callback, promise already settled")
87
- }
107
+ if (kind == BrowserSessionKind.OAUTH) {
108
+ settler.resolve(CredentialFactory.fromCancelled())
109
+ } else {
110
+ resolveAndEmit(CredentialFactory.fromCancelled(), settler)
111
+ }
112
+ }
113
+
114
+ WEB_BROWSER_UNAVAILABLE_INTENT -> {
115
+ val code = CredentialError.BROWSER_UNAVAILABLE.code
116
+ val message = "No browser available to open the url"
117
+ settler.reject(code, message, null, null)
118
+ if (kind == BrowserSessionKind.APPLE) {
119
+ emitFailure(code, message)
88
120
  }
89
121
  }
90
122
 
@@ -96,23 +128,37 @@ class OauthEssentialsModule(reactContext: ReactApplicationContext) :
96
128
  }
97
129
  }
98
130
 
131
+ private val activityEventListener = object : ActivityEventListener {
132
+ override fun onActivityResult(activity: Activity, requestCode: Int, resultCode: Int, data: Intent?) {
133
+ if (requestCode == AUTHORIZATION_REQUEST_CODE) {
134
+ authorizationContinuation?.let {
135
+ authorizationContinuation = null
136
+ it.resume(resultCode to data)
137
+ }
138
+ }
139
+ }
140
+
141
+ override fun onNewIntent(intent: Intent) = Unit
142
+ }
143
+
99
144
  @SuppressLint("ObsoleteSdkInt")
100
- override fun getTypedExportedConstants(): MutableMap<String, Boolean> {
101
- hasGooglePlayServices()
102
- val hasCredentialManager = getCredentialManagerSupported()
103
- val constants: MutableMap<String, Boolean> = mutableMapOf(
145
+ override fun getTypedExportedConstants(): MutableMap<String, Any> {
146
+ // Passwords and hybrid sign in go through Credential Manager, which needs Play Services.
147
+ val hasCredentialManager = getCredentialManagerSupported() && hasGooglePlayServices()
148
+ return mutableMapOf(
104
149
  "GOOGLE_PLAY_SERVICES_SUPPORTED" to hasGooglePlayServices(),
105
150
  "PASSWORD_SUPPORTED" to hasCredentialManager,
106
151
  "GOOGLE_ID_SUPPORTED" to googleIdSupported(),
107
152
  "APPLE_ID_SUPPORTED" to getAppleIdSupported(),
108
153
  "HYBRID_SUPPORTED" to hasCredentialManager,
109
- "PASSKEYS_SUPPORTED" to getPasskeysSupported()
154
+ "PASSKEYS_SUPPORTED" to getPasskeysSupported(),
155
+ "OAUTH_REDIRECT_URI" to "${reactApplicationContext.packageName}://oauth/callback"
110
156
  )
111
- return constants
112
157
  }
113
158
 
114
- @RequiresApi(Build.VERSION_CODES.O)
115
- override fun hybridSignIn(clientId: String, options: ReadableMap, promise: Promise) {
159
+ override fun hybridSignIn(jsClientId: String, options: ReadableMap, promise: Promise) {
160
+ val clientId = resolveWebClientId(jsClientId, promise) ?: return
161
+
116
162
  if (!hasGooglePlayServices()) {
117
163
  rejectAndEmitEvent(
118
164
  CredentialError.NOT_SUPPORTED_ERROR.code,
@@ -121,14 +167,6 @@ class OauthEssentialsModule(reactContext: ReactApplicationContext) :
121
167
  )
122
168
  return
123
169
  }
124
- if (!getCredentialManagerSupported()) {
125
- rejectAndEmitEvent(
126
- CredentialError.NOT_SUPPORTED_ERROR.code,
127
- "Device does not support Credential Manager, only android >=12 allowed.",
128
- promise
129
- )
130
- return
131
- }
132
170
 
133
171
  val activity = reactApplicationContext.currentActivity
134
172
  if (activity == null) {
@@ -143,46 +181,21 @@ class OauthEssentialsModule(reactContext: ReactApplicationContext) :
143
181
  moduleScope.launch {
144
182
  try {
145
183
  val manager = CredentialManager.create(activity)
146
- val randomBytes = ByteArray(32)
147
- // Usa SecureRandom() (não-bloqueante) em vez de getInstanceStrong(),
148
- // que pode bloquear esperando entropia (/dev/random) e pendurar o fluxo.
149
- SecureRandom().nextBytes(randomBytes)
150
- val randomNonce = Base64.getUrlEncoder().withoutPadding().encodeToString(randomBytes)
151
-
152
- val googleIdOption: GetGoogleIdOption = GetGoogleIdOption.Builder()
153
- .setNonce(randomNonce)
154
- .setServerClientId(clientId)
155
- .setFilterByAuthorizedAccounts(options.getBoolean("authorizedAccounts"))
156
- .setAutoSelectEnabled(options.getBoolean("autoSelectEnabled"))
157
- .build()
158
-
159
- val request: GetCredentialRequest = GetCredentialRequest.Builder()
160
- .addCredentialOption(googleIdOption)
161
- .addCredentialOption(GetPasswordOption())
162
- .build()
163
-
164
- // getCredential precisa lançar a Activity de UI (chooser), então deve
165
- // rodar na Main thread — igual ao googleSignIn. Chamar em Dispatchers.IO
166
- // fazia o chooser abrir sem se vincular à UI e travar (ANR na abertura).
167
- val result = withContext(Dispatchers.Main) {
168
- manager.getCredential(
169
- request = request,
170
- context = activity
171
- )
172
- }
173
- resolveAndEmit(
174
- CredentialFactory.fromCredentialResponse(result), promise
184
+ val result = getCredential(
185
+ activity,
186
+ manager,
187
+ googleIdOption(clientId, options, silent = false),
188
+ GetPasswordOption()
175
189
  )
190
+ resolveAndEmit(CredentialFactory.fromCredentialResponse(result), promise)
176
191
  } catch (e: GetCredentialCancellationException) {
177
192
  Log.d(LOG_TAG, e.toString())
178
193
  resolveAndEmit(CredentialFactory.fromCancelled(), promise)
194
+ } catch (e: NoCredentialException) {
195
+ rejectAndEmitEvent(CredentialError.NO_SIGN_IN_AVAILABLE.code, e, promise)
179
196
  } catch (e: Throwable) {
180
197
  Log.d(LOG_TAG, e.toString())
181
- rejectAndEmitEvent(
182
- CredentialError.INVALID_RESULT_ERROR.code,
183
- e,
184
- promise
185
- )
198
+ rejectAndEmitEvent(CredentialError.INVALID_RESULT_ERROR.code, e, promise)
186
199
  }
187
200
  }
188
201
  }
@@ -191,33 +204,58 @@ class OauthEssentialsModule(reactContext: ReactApplicationContext) :
191
204
  * Performs the apple sign in.
192
205
  * The signing in is redirecting to the device default browser to handle it.
193
206
  */
194
- @RequiresApi(Build.VERSION_CODES.O)
195
- override fun appleSignIn(androidWebUrl: String?, promise: Promise) {
196
- appleSignInPromiseSettler?.let {
197
- if (!it.settled()) {
198
- it.resolve(CredentialFactory.fromCancelled())
199
- }
200
- }
201
- appleSignInPromiseSettler = null
202
-
203
- val activity = this.reactApplicationContext.currentActivity
204
- if (activity === null) {
205
- rejectAndEmitEvent(
206
- CredentialError.NO_ACTIVITY_ERROR.code,
207
- "Could not find app activity.",
208
- promise
209
- )
210
- return
211
- }
212
- var uri: Uri
207
+ override fun appleSignIn(androidWebUrl: String?, options: ReadableMap, promise: Promise) {
208
+ val uri: Uri
213
209
  try {
214
210
  uri = parseWebUrl(androidWebUrl)
215
211
  } catch (e: Throwable) {
216
212
  rejectAndEmitEvent(CredentialError.INVALID_RESULT_ERROR.code, e, promise)
217
213
  return
218
214
  }
215
+ openBrowserSession(uri, BrowserSessionKind.APPLE, promise)
216
+ }
219
217
 
220
- appleSignInPromiseSettler = RCTPromiseSettler(promise)
218
+ override fun getAppleIdCredentialState(userId: String, promise: Promise) {
219
+ promise.reject(
220
+ CredentialError.NOT_SUPPORTED_ERROR.code,
221
+ "Apple ID credential state is only available on iOS"
222
+ )
223
+ }
224
+
225
+ /**
226
+ * Opens an authorization url in a Custom Tab and resolves with the redirect url.
227
+ * The redirect must reach CustomTabsActivity (OAUTH_REDIRECT_URI or an app intent filter).
228
+ * It does not emit events: oauthSignIn finishes the flow in JS.
229
+ */
230
+ override fun openAuthSession(url: String, options: ReadableMap, promise: Promise) {
231
+ val uri = url.toUri()
232
+ if (uri.scheme != "https" && uri.scheme != "http") {
233
+ promise.reject(CredentialError.INVALID_RESULT_ERROR.code, "Invalid authorization url: $url")
234
+ return
235
+ }
236
+ openBrowserSession(uri, BrowserSessionKind.OAUTH, promise)
237
+ }
238
+
239
+ private fun openBrowserSession(uri: Uri, kind: BrowserSessionKind, promise: Promise) {
240
+ val activity = reactApplicationContext.currentActivity
241
+ if (activity == null) {
242
+ val code = CredentialError.NO_ACTIVITY_ERROR.code
243
+ promise.reject(code, "Could not find app activity.")
244
+ if (kind == BrowserSessionKind.APPLE) {
245
+ emitFailure(code, "Could not find app activity.")
246
+ }
247
+ return
248
+ }
249
+
250
+ // A new browser session replaces a pending one, which resolves as cancelled.
251
+ browserSessionSettler?.let {
252
+ if (!it.settled()) {
253
+ it.resolve(CredentialFactory.fromCancelled())
254
+ }
255
+ }
256
+
257
+ browserSessionSettler = RCTPromiseSettler(promise)
258
+ browserSessionKind = kind
221
259
  activity.runOnUiThread {
222
260
  val intent = Intent(activity, CustomTabsActivity::class.java).apply {
223
261
  data = uri
@@ -230,10 +268,10 @@ class OauthEssentialsModule(reactContext: ReactApplicationContext) :
230
268
  /**
231
269
  * Performs the google sign in.
232
270
  */
233
- @Suppress("DEPRECATION")
234
- @RequiresApi(Build.VERSION_CODES.O)
235
- override fun googleSignIn(clientId: String, options: ReadableMap, promise: Promise) {
271
+ override fun googleSignIn(jsClientId: String, options: ReadableMap, promise: Promise) {
236
272
  Log.d(LOG_TAG, "Calling googleSignIn")
273
+ val clientId = resolveWebClientId(jsClientId, promise) ?: return
274
+
237
275
  if (!googleIdSupported() || !getCredentialManagerSupported()) {
238
276
  rejectAndEmitEvent(
239
277
  CredentialError.NOT_SUPPORTED_ERROR.code,
@@ -252,27 +290,86 @@ class OauthEssentialsModule(reactContext: ReactApplicationContext) :
252
290
  moduleScope.launch {
253
291
  try {
254
292
  val manager = CredentialManager.create(activity)
293
+ val response = if (options.optString("mode") == "button") {
294
+ getCredential(activity, manager, signInWithGoogleOption(clientId, options))
295
+ } else {
296
+ try {
297
+ getCredential(activity, manager, googleIdOption(clientId, options, silent = false))
298
+ } catch (e: NoCredentialException) {
299
+ // No account on the device or the bottom sheet is in cooldown after being dismissed.
300
+ // The button flow has no cooldown and lets the user add an account.
301
+ if (options.optBoolean("authorizedAccounts")) throw e
302
+ Log.d(LOG_TAG, "No credential for the bottom sheet, falling back to the button flow")
303
+ getCredential(activity, manager, signInWithGoogleOption(clientId, options))
304
+ }
305
+ }
255
306
 
256
- val googleIdOption: GetGoogleIdOption = GetGoogleIdOption.Builder()
257
- .setNonce(UUID.randomUUID().toString())
258
- .setServerClientId(clientId)
259
- .setFilterByAuthorizedAccounts(options.getBoolean("authorizedAccounts"))
260
- .setAutoSelectEnabled(options.getBoolean("autoSelectEnabled"))
261
- .build()
262
-
263
- val request: GetCredentialRequest = GetCredentialRequest.Builder()
264
- .addCredentialOption(googleIdOption)
265
- .build()
266
-
267
- val result = withContext(Dispatchers.Main) {
268
- manager.getCredential(activity, request)
307
+ val authorization = authorizeIfNeeded(activity, clientId, response, options, interactive = true)
308
+ if (authorization == null && needsAuthorization(options)) {
309
+ resolveAndEmit(CredentialFactory.fromCancelled(), promise)
310
+ return@launch
269
311
  }
270
- resolveAndEmit(CredentialFactory.fromCredentialResponse(result), promise)
312
+ resolveAndEmit(CredentialFactory.fromCredentialResponse(response, authorization), promise)
271
313
  } catch (e: GetCredentialCancellationException) {
272
314
  Log.d(LOG_TAG, e.toString())
273
315
  resolveAndEmit(CredentialFactory.fromCancelled(), promise)
316
+ } catch (e: NoCredentialException) {
317
+ rejectAndEmitEvent(CredentialError.NO_SIGN_IN_AVAILABLE.code, e, promise)
318
+ } catch (e: Throwable) {
319
+ rejectAndEmitEvent(CredentialError.INVALID_RESULT_ERROR.code, e, promise)
320
+ }
321
+ }
322
+ }
323
+
324
+ /**
325
+ * Signs in with an account that already signed in to the app, auto selecting it when possible.
326
+ */
327
+ override fun googleSilentSignIn(jsClientId: String, options: ReadableMap, promise: Promise) {
328
+ val clientId = resolveWebClientId(jsClientId, promise) ?: return
329
+
330
+ if (!googleIdSupported() || !getCredentialManagerSupported()) {
331
+ rejectAndEmitEvent(
332
+ CredentialError.NOT_SUPPORTED_ERROR.code,
333
+ "Device does not support googleSilentSignIn method.",
334
+ promise
335
+ )
336
+ return
337
+ }
338
+
339
+ val activity = reactApplicationContext.currentActivity
340
+ if (activity == null) {
341
+ rejectAndEmitEvent(CredentialError.NO_ACTIVITY_ERROR.code, "Activity not available", promise)
342
+ return
343
+ }
344
+
345
+ moduleScope.launch {
346
+ try {
347
+ val manager = CredentialManager.create(activity)
348
+ val response = getCredential(activity, manager, googleIdOption(clientId, options, silent = true))
349
+ val authorization = authorizeIfNeeded(activity, clientId, response, options, interactive = false)
350
+ resolveAndEmit(CredentialFactory.fromCredentialResponse(response, authorization), promise)
351
+ } catch (e: GetCredentialCancellationException) {
352
+ resolveAndEmit(CredentialFactory.fromCancelled(), promise)
353
+ } catch (e: NoCredentialException) {
354
+ rejectAndEmitEvent(CredentialError.NO_SIGN_IN_AVAILABLE.code, e, promise)
355
+ } catch (e: Throwable) {
356
+ rejectAndEmitEvent(CredentialError.INVALID_RESULT_ERROR.code, e, promise)
357
+ }
358
+ }
359
+ }
360
+
361
+ /**
362
+ * Clears the Credential Manager state, so auto select does not sign the user back in.
363
+ */
364
+ override fun signOut(promise: Promise) {
365
+ moduleScope.launch {
366
+ try {
367
+ CredentialManager.create(reactApplicationContext)
368
+ .clearCredentialState(ClearCredentialStateRequest())
369
+ promise.resolve(true)
274
370
  } catch (e: Throwable) {
275
- rejectAndEmitEvent(CredentialError.INVALID_RESULT_ERROR.code, e.toString(), promise)
371
+ Log.e(LOG_TAG, e.toString())
372
+ promise.reject(CredentialError.INVALID_RESULT_ERROR.code, e)
276
373
  }
277
374
  }
278
375
  }
@@ -284,7 +381,7 @@ class OauthEssentialsModule(reactContext: ReactApplicationContext) :
284
381
  if (!googleIdSupported()) {
285
382
  rejectAndEmitEvent(
286
383
  CredentialError.NOT_SUPPORTED_ERROR.code,
287
- "Device does not support googleSignIn method.",
384
+ "Device does not support getPassword method.",
288
385
  promise
289
386
  )
290
387
  return
@@ -303,23 +400,18 @@ class OauthEssentialsModule(reactContext: ReactApplicationContext) :
303
400
  moduleScope.launch {
304
401
  try {
305
402
  val manager = CredentialManager.create(activity)
306
- val request = GetCredentialRequest(
307
- listOf(GetPasswordOption())
308
- )
309
-
310
- // getCredential lança UI: deve rodar na Main thread (ver hybridSignIn).
311
- val result = withContext(Dispatchers.Main) {
312
- manager.getCredential(
313
- request = request,
314
- context = activity
315
- )
316
- }
403
+ val result = getCredential(activity, manager, GetPasswordOption())
317
404
  resolveAndEmit(
318
405
  CredentialFactory.fromCredentialResponse(result), promise
319
406
  )
407
+ } catch (e: GetCredentialCancellationException) {
408
+ resolveAndEmit(CredentialFactory.fromCancelled(), promise)
409
+ } catch (e: NoCredentialException) {
410
+ // Same as iOS: no saved password is reported as cancelled.
411
+ resolveAndEmit(CredentialFactory.fromCancelled(), promise)
320
412
  } catch (e: Throwable) {
321
413
  Log.d(LOG_TAG, e.toString())
322
- promise.resolve(false)
414
+ rejectAndEmitEvent(CredentialError.INVALID_RESULT_ERROR.code, e, promise)
323
415
  }
324
416
  }
325
417
  }
@@ -331,7 +423,7 @@ class OauthEssentialsModule(reactContext: ReactApplicationContext) :
331
423
  if (!googleIdSupported()) {
332
424
  rejectAndEmitEvent(
333
425
  CredentialError.NOT_SUPPORTED_ERROR.code,
334
- "Device does not support googleSignIn method.",
426
+ "Device does not support passwordSignIn method.",
335
427
  promise
336
428
  )
337
429
  return
@@ -351,7 +443,7 @@ class OauthEssentialsModule(reactContext: ReactApplicationContext) :
351
443
  password = password
352
444
  )
353
445
 
354
- // createCredential lança UI: deve rodar na Main thread (ver hybridSignIn).
446
+ // createCredential lança UI: deve rodar na Main thread.
355
447
  withContext(Dispatchers.Main) {
356
448
  manager.createCredential(
357
449
  request = request,
@@ -369,12 +461,11 @@ class OauthEssentialsModule(reactContext: ReactApplicationContext) :
369
461
  /**
370
462
  * Creates the passkey.
371
463
  */
372
- @RequiresApi(Build.VERSION_CODES.P)
373
464
  override fun createPassKey(options: ReadableMap, promise: Promise) {
374
465
  if (!getPasskeysSupported()) {
375
466
  rejectAndEmitEvent(
376
467
  CredentialError.NOT_SUPPORTED_ERROR.code,
377
- "Device does not support googleSignIn method.",
468
+ "Device does not support passkeys.",
378
469
  promise
379
470
  )
380
471
  return
@@ -390,11 +481,6 @@ class OauthEssentialsModule(reactContext: ReactApplicationContext) :
390
481
  try {
391
482
  val requestJson = options.getString("requestJson")
392
483
  ?: throw IllegalArgumentException("\"requestJson\" field should be set on android.")
393
- var preferImmediatelyAvailableCredentials = false
394
- if (options.hasKey("preferImmediatelyAvailableCredentials")) {
395
- preferImmediatelyAvailableCredentials =
396
- options.getBoolean("preferImmediatelyAvailableCredentials")
397
- }
398
484
  // `isConditional` (conditional create) só existe em androidx.credentials
399
485
  // >= 1.6.0. Estamos fixados em 1.5.0 estável (ver build.gradle), então o
400
486
  // parâmetro não é suportado aqui.
@@ -402,9 +488,9 @@ class OauthEssentialsModule(reactContext: ReactApplicationContext) :
402
488
  val manager = CredentialManager.create(activity)
403
489
  val request = CreatePublicKeyCredentialRequest(
404
490
  requestJson = requestJson,
405
- preferImmediatelyAvailableCredentials = preferImmediatelyAvailableCredentials
491
+ preferImmediatelyAvailableCredentials = options.optBoolean("preferImmediatelyAvailableCredentials")
406
492
  )
407
- // createCredential lança UI: deve rodar na Main thread (ver hybridSignIn).
493
+ // createCredential lança UI: deve rodar na Main thread.
408
494
  val result = withContext(Dispatchers.Main) {
409
495
  manager.createCredential(
410
496
  request = request,
@@ -412,17 +498,11 @@ class OauthEssentialsModule(reactContext: ReactApplicationContext) :
412
498
  )
413
499
  }
414
500
  resolveAndEmit(CredentialFactory.fromPublicKeyCredential(result), promise)
501
+ } catch (e: CreateCredentialCancellationException) {
502
+ resolveAndEmit(CredentialFactory.fromCancelled(), promise)
415
503
  } catch (e: Throwable) {
416
- when (e) {
417
- is CreateCredentialCancellationException -> {
418
- resolveAndEmit(CredentialFactory.fromCancelled(), promise)
419
- }
420
-
421
- else -> {
422
- Log.e(LOG_TAG, e.toString())
423
- rejectAndEmitEvent(CredentialError.INVALID_RESULT_ERROR.code, e.toString(), promise)
424
- }
425
- }
504
+ Log.e(LOG_TAG, e.toString())
505
+ rejectAndEmitEvent(CredentialError.INVALID_RESULT_ERROR.code, e, promise)
426
506
  }
427
507
  }
428
508
  }
@@ -430,12 +510,11 @@ class OauthEssentialsModule(reactContext: ReactApplicationContext) :
430
510
  /**
431
511
  * Gets the passkey.
432
512
  */
433
- @RequiresApi(Build.VERSION_CODES.P)
434
513
  override fun getPassKey(options: ReadableMap, promise: Promise) {
435
514
  if (!getPasskeysSupported()) {
436
515
  rejectAndEmitEvent(
437
516
  CredentialError.NOT_SUPPORTED_ERROR.code,
438
- "Device does not support googleSignIn method.",
517
+ "Device does not support passkeys.",
439
518
  promise
440
519
  )
441
520
  return
@@ -451,11 +530,6 @@ class OauthEssentialsModule(reactContext: ReactApplicationContext) :
451
530
  try {
452
531
  val requestJson = options.getString("requestJson")
453
532
  ?: throw IllegalArgumentException("\"requestJson\" field should be set on android.")
454
- var preferImmediatelyAvailableCredentials = false
455
- if (options.hasKey("preferImmediatelyAvailableCredentials")) {
456
- preferImmediatelyAvailableCredentials =
457
- options.getBoolean("preferImmediatelyAvailableCredentials")
458
- }
459
533
 
460
534
  val manager = CredentialManager.create(activity)
461
535
  val request = GetCredentialRequest(
@@ -464,9 +538,9 @@ class OauthEssentialsModule(reactContext: ReactApplicationContext) :
464
538
  requestJson = requestJson,
465
539
  ),
466
540
  ),
467
- preferImmediatelyAvailableCredentials = preferImmediatelyAvailableCredentials
541
+ preferImmediatelyAvailableCredentials = options.optBoolean("preferImmediatelyAvailableCredentials")
468
542
  )
469
- // getCredential lança UI: deve rodar na Main thread (ver hybridSignIn).
543
+ // getCredential lança UI: deve rodar na Main thread.
470
544
  val result = withContext(Dispatchers.Main) {
471
545
  manager.getCredential(
472
546
  request = request,
@@ -474,19 +548,142 @@ class OauthEssentialsModule(reactContext: ReactApplicationContext) :
474
548
  )
475
549
  }
476
550
  resolveAndEmit(CredentialFactory.fromPublicKeyCredential(result), promise)
551
+ } catch (e: GetCredentialCancellationException) {
552
+ resolveAndEmit(CredentialFactory.fromCancelled(), promise)
553
+ } catch (e: NoCredentialException) {
554
+ rejectAndEmitEvent(CredentialError.NO_SIGN_IN_AVAILABLE.code, e, promise)
477
555
  } catch (e: Throwable) {
478
- when (e) {
479
- is CreateCredentialCancellationException -> {
480
- resolveAndEmit(CredentialFactory.fromCancelled(), promise)
481
- }
556
+ Log.e(LOG_TAG, e.toString())
557
+ rejectAndEmitEvent(CredentialError.INVALID_RESULT_ERROR.code, e, promise)
558
+ }
559
+ }
560
+ }
482
561
 
483
- else -> {
484
- Log.e(LOG_TAG, e.toString())
485
- rejectAndEmitEvent(CredentialError.INVALID_RESULT_ERROR.code, e.toString(), promise)
486
- }
487
- }
562
+ /**
563
+ * Passkey AutoFill is iOS only.
564
+ */
565
+ override fun cancelPassKeyAutoFill(promise: Promise) {
566
+ promise.resolve(false)
567
+ }
568
+
569
+ /**
570
+ * Generates a PKCE code verifier, its S256 challenge and a random state.
571
+ */
572
+ override fun generatePkce(promise: Promise) {
573
+ try {
574
+ val codeVerifier = randomBase64Url(32)
575
+ val challenge = MessageDigest.getInstance("SHA-256")
576
+ .digest(codeVerifier.toByteArray(Charsets.US_ASCII))
577
+ promise.resolve(Arguments.createMap().apply {
578
+ putString("codeVerifier", codeVerifier)
579
+ putString("codeChallenge", base64Url(challenge))
580
+ putString("state", randomBase64Url(16))
581
+ })
582
+ } catch (e: Throwable) {
583
+ promise.reject(CredentialError.INVALID_RESULT_ERROR.code, e)
584
+ }
585
+ }
586
+
587
+ private fun randomBase64Url(byteCount: Int): String {
588
+ val bytes = ByteArray(byteCount)
589
+ // SecureRandom() não bloqueia, ao contrário de getInstanceStrong().
590
+ SecureRandom().nextBytes(bytes)
591
+ return base64Url(bytes)
592
+ }
593
+
594
+ private fun base64Url(bytes: ByteArray): String =
595
+ Base64.encodeToString(bytes, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
596
+
597
+ /**
598
+ * Bottom sheet option: lists the device Google accounts.
599
+ */
600
+ private fun googleIdOption(clientId: String, options: ReadableMap, silent: Boolean): GetGoogleIdOption {
601
+ val builder = GetGoogleIdOption.Builder()
602
+ .setServerClientId(clientId)
603
+ .setFilterByAuthorizedAccounts(silent || options.optBoolean("authorizedAccounts"))
604
+ .setAutoSelectEnabled(silent || options.optBoolean("autoSelectEnabled"))
605
+ options.optString("nonce")?.let { builder.setNonce(it) }
606
+ return builder.build()
607
+ }
608
+
609
+ /**
610
+ * "Sign in with Google" button option: always shows the sign in flow.
611
+ */
612
+ private fun signInWithGoogleOption(clientId: String, options: ReadableMap): GetSignInWithGoogleOption {
613
+ val builder = GetSignInWithGoogleOption.Builder(clientId)
614
+ options.optString("nonce")?.let { builder.setNonce(it) }
615
+ options.optString("hostedDomain")?.let { builder.setHostedDomainFilter(it) }
616
+ return builder.build()
617
+ }
618
+
619
+ /**
620
+ * getCredential launches UI, so it must run on the main thread.
621
+ */
622
+ private suspend fun getCredential(
623
+ activity: Activity,
624
+ manager: CredentialManager,
625
+ vararg credentialOptions: CredentialOption
626
+ ): GetCredentialResponse {
627
+ val request = GetCredentialRequest.Builder().apply {
628
+ credentialOptions.forEach { addCredentialOption(it) }
629
+ }.build()
630
+ return withContext(Dispatchers.Main) {
631
+ manager.getCredential(activity, request)
632
+ }
633
+ }
634
+
635
+ private fun needsAuthorization(options: ReadableMap): Boolean =
636
+ options.optStringList("scopes").isNotEmpty() || options.optBoolean("offlineAccess")
637
+
638
+ /**
639
+ * Requests the access token, granted scopes and server auth code when scopes or offline access
640
+ * were requested. Returns null when not needed, when the user declined the consent screen or,
641
+ * on silent sign in, when consent would be required.
642
+ */
643
+ private suspend fun authorizeIfNeeded(
644
+ activity: Activity,
645
+ clientId: String,
646
+ response: GetCredentialResponse,
647
+ options: ReadableMap,
648
+ interactive: Boolean
649
+ ): AuthorizationResult? {
650
+ if (!needsAuthorization(options)) return null
651
+
652
+ val scopes = options.optStringList("scopes").ifEmpty { listOf("openid", "email", "profile") }
653
+ val builder = AuthorizationRequest.Builder()
654
+ .setRequestedScopes(scopes.map { Scope(it) })
655
+ if (options.optBoolean("offlineAccess")) {
656
+ builder.requestOfflineAccess(options.optString("serverClientId") ?: clientId)
657
+ }
658
+ options.optString("hostedDomain")?.let { builder.filterByHostedDomain(it) }
659
+ val credential = response.credential
660
+ if (credential.type == GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL) {
661
+ val googleCredential = GoogleIdTokenCredential.createFrom(credential.data)
662
+ builder.setAccount(Account(googleCredential.id, "com.google"))
663
+ }
664
+
665
+ val client = Identity.getAuthorizationClient(activity)
666
+ val result = suspendCancellableCoroutine { continuation ->
667
+ client.authorize(builder.build())
668
+ .addOnSuccessListener { continuation.resume(it) }
669
+ .addOnFailureListener { continuation.resumeWithException(it) }
670
+ }
671
+ if (!result.hasResolution()) return result
672
+ if (!interactive) return null
673
+
674
+ val pendingIntent = result.pendingIntent ?: return null
675
+ val (resultCode, data) = withContext(Dispatchers.Main) {
676
+ suspendCancellableCoroutine { continuation ->
677
+ authorizationContinuation?.cancel()
678
+ authorizationContinuation = continuation
679
+ continuation.invokeOnCancellation { authorizationContinuation = null }
680
+ activity.startIntentSenderForResult(
681
+ pendingIntent.intentSender, AUTHORIZATION_REQUEST_CODE, null, 0, 0, 0
682
+ )
488
683
  }
489
684
  }
685
+ if (resultCode != Activity.RESULT_OK) return null
686
+ return client.getAuthorizationResultFromIntent(data)
490
687
  }
491
688
 
492
689
  /**
@@ -498,7 +695,6 @@ class OauthEssentialsModule(reactContext: ReactApplicationContext) :
498
695
  return result == ConnectionResult.SUCCESS
499
696
  }
500
697
 
501
- @RequiresApi(Build.VERSION_CODES.O)
502
698
  override fun initialize() {
503
699
  super.initialize()
504
700
 
@@ -510,9 +706,11 @@ class OauthEssentialsModule(reactContext: ReactApplicationContext) :
510
706
  IntentFilter().apply {
511
707
  addAction(WEB_APPLE_DEEP_LINK_RECEIVED_INTENT)
512
708
  addAction(WEB_APPLE_CANCELLED_INTENT)
709
+ addAction(WEB_BROWSER_UNAVAILABLE_INTENT)
513
710
  },
514
711
  ContextCompat.RECEIVER_NOT_EXPORTED
515
712
  )
713
+ reactApplicationContext.addActivityEventListener(activityEventListener)
516
714
  }
517
715
 
518
716
  override fun invalidate() {
@@ -521,10 +719,11 @@ class OauthEssentialsModule(reactContext: ReactApplicationContext) :
521
719
 
522
720
  Log.d(LOG_TAG, "Cancelling all coroutines and credential operations")
523
721
  moduleScope.cancel()
722
+ reactApplicationContext.removeActivityEventListener(activityEventListener)
524
723
 
525
- appleSignInPromiseSettler?.let {
724
+ browserSessionSettler?.let {
526
725
  if (!it.settled()) {
527
- Log.d(LOG_TAG, "Rejecting pending Apple sign-in promise without event")
726
+ Log.d(LOG_TAG, "Rejecting pending browser session promise without event")
528
727
  it.reject(
529
728
  "CANCELLED",
530
729
  "Module is being invalidated",
@@ -533,10 +732,10 @@ class OauthEssentialsModule(reactContext: ReactApplicationContext) :
533
732
  )
534
733
  }
535
734
  }
536
- appleSignInPromiseSettler = null
735
+ browserSessionSettler = null
537
736
 
538
737
  Log.d(LOG_TAG, "Unregistering deep link broadcast receiver")
539
- reactApplicationContext.unregisterReceiver(receiver)
738
+ reactApplicationContext.applicationContext.unregisterReceiver(receiver)
540
739
  }
541
740
 
542
741
  /**
@@ -563,15 +762,71 @@ class OauthEssentialsModule(reactContext: ReactApplicationContext) :
563
762
  return Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && getCredentialManagerSupported()
564
763
  }
565
764
 
566
- /**
567
- * Reject and emit event.
568
- */
569
- private fun rejectAndEmitEvent(code: String, e: Throwable, promise: Promise) {
570
- promise.reject(code, e)
765
+ private fun ReadableMap.optBoolean(key: String): Boolean =
766
+ hasKey(key) && !isNull(key) && getBoolean(key)
767
+
768
+ private fun ReadableMap.optString(key: String): String? =
769
+ if (hasKey(key) && !isNull(key)) getString(key)?.takeIf { it.isNotEmpty() } else null
770
+
771
+ private fun ReadableMap.optStringList(key: String): List<String> {
772
+ if (!hasKey(key) || isNull(key)) return emptyList()
773
+ val array = getArray(key) ?: return emptyList()
774
+ return (0 until array.size()).mapNotNull { array.getString(it) }
775
+ }
571
776
 
777
+ private fun emitFailure(code: String, message: String) {
572
778
  val error = Arguments.createMap()
573
779
  error.putString("code", code)
574
- error.putString("message", e.toString())
780
+ error.putString("message", message)
781
+ emitOnCredentialFailure(error)
782
+ }
783
+
784
+ /**
785
+ * Credential Manager needs the web client id. JS passes '' when it is not given, then it is read
786
+ * from the GOOGLE_WEB_CLIENT_ID meta-data (written by the Expo plugin) or the
787
+ * `default_web_client_id` string (generated by the Firebase google-services Gradle plugin).
788
+ * Rejects CONFIGURATION_ERROR and returns null when none is set.
789
+ */
790
+ private fun resolveWebClientId(jsClientId: String, promise: Promise): String? {
791
+ val clientId = jsClientId.trim().ifEmpty { null }
792
+ ?: webClientIdFromMetaData()
793
+ ?: webClientIdFromFirebase()
794
+ if (clientId != null) return clientId
795
+ rejectAndEmitEvent(
796
+ CredentialError.CONFIGURATION_ERROR.code,
797
+ "Missing Google web client id (the web client id, not the Android one): pass { webClientId }, " +
798
+ "add <meta-data android:name=\"$GOOGLE_WEB_CLIENT_ID_META_DATA\" android:value=\"...\" /> " +
799
+ "to <application> in AndroidManifest.xml (the Expo plugin does it), or use the Firebase " +
800
+ "google-services.json with the google-services Gradle plugin (default_web_client_id).",
801
+ promise
802
+ )
803
+ return null
804
+ }
805
+
806
+ @Suppress("DEPRECATION")
807
+ private fun webClientIdFromMetaData(): String? {
808
+ return try {
809
+ val info = reactApplicationContext.packageManager.getApplicationInfo(
810
+ reactApplicationContext.packageName,
811
+ PackageManager.GET_META_DATA
812
+ )
813
+ info.metaData?.getString(GOOGLE_WEB_CLIENT_ID_META_DATA)?.trim()?.ifEmpty { null }
814
+ } catch (e: PackageManager.NameNotFoundException) {
815
+ null
816
+ }
817
+ }
818
+
819
+ @SuppressLint("DiscouragedApi")
820
+ private fun webClientIdFromFirebase(): String? {
821
+ val resources = reactApplicationContext.resources
822
+ val id = resources.getIdentifier("default_web_client_id", "string", reactApplicationContext.packageName)
823
+ if (id == 0) return null
824
+ return resources.getString(id).trim().ifEmpty { null }
825
+ }
826
+
827
+ private fun rejectAndEmitEvent(code: String, e: Throwable, promise: Promise) {
828
+ promise.reject(code, e.message ?: e.toString(), e)
829
+ emitFailure(code, e.message ?: e.toString())
575
830
  }
576
831
 
577
832
  /**
@@ -579,11 +834,7 @@ class OauthEssentialsModule(reactContext: ReactApplicationContext) :
579
834
  */
580
835
  private fun rejectAndEmitEvent(code: String, e: String, promise: Promise) {
581
836
  promise.reject(code, e)
582
-
583
- val error = Arguments.createMap()
584
- error.putString("code", code)
585
- error.putString("message", e)
586
- emitOnCredentialFailure(error)
837
+ emitFailure(code, e)
587
838
  }
588
839
 
589
840
  /**
@@ -608,11 +859,18 @@ class OauthEssentialsModule(reactContext: ReactApplicationContext) :
608
859
 
609
860
  const val LOG_TAG = "NativeOauthEssentials"
610
861
 
611
- // Apple OAuth intents
862
+ // <application> meta-data with the Google web client id, used when JS passes no client id.
863
+ const val GOOGLE_WEB_CLIENT_ID_META_DATA = "com.oauthessentials.GOOGLE_WEB_CLIENT_ID"
864
+
865
+ // Browser session intents (web Apple sign in and generic OAuth)
612
866
  const val WEB_APPLE_DEEP_LINK_RECEIVED_INTENT =
613
867
  "com.oauthessentials.WEB_APPLE_DEEP_LINK_RECEIVED_INTENT"
614
868
  const val WEB_APPLE_CANCELLED_INTENT =
615
869
  "com.oauthessentials.WEB_APPLE_CANCELLED_INTENT"
870
+ const val WEB_BROWSER_UNAVAILABLE_INTENT =
871
+ "com.oauthessentials.WEB_BROWSER_UNAVAILABLE_INTENT"
616
872
  const val DISPLAY_CUSTOM_TABS_INTENT = "com.oauthessentials.DISPLAY_CUSTOM_TABS_INTENT"
873
+
874
+ private const val AUTHORIZATION_REQUEST_CODE = 0x0A07
617
875
  }
618
876
  }