react-native-oauth-essentials 1.0.11 → 1.1.1

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 (51) hide show
  1. package/README.md +384 -362
  2. package/android/src/main/java/com/oauthessentials/CredentialError.kt +4 -1
  3. package/android/src/main/java/com/oauthessentials/CredentialFactory.kt +24 -2
  4. package/android/src/main/java/com/oauthessentials/CustomTabsActivity.kt +14 -1
  5. package/android/src/main/java/com/oauthessentials/OauthEssentialsModule.kt +448 -192
  6. package/app.plugin.js +1 -0
  7. package/ios/AppleIdRetriever.swift +25 -26
  8. package/ios/CredentialError.swift +3 -0
  9. package/ios/CredentialFactory.swift +38 -20
  10. package/ios/CredentialsType.swift +1 -0
  11. package/ios/GoogleIdRetriever.swift +132 -56
  12. package/ios/HybridRetriever.swift +12 -18
  13. package/ios/OauthEssentials.h +0 -3
  14. package/ios/OauthEssentials.mm +45 -2
  15. package/ios/OauthEssentialsModule.swift +266 -57
  16. package/ios/PasskeyRetriever.swift +31 -49
  17. package/ios/PasswordRetriever.swift +10 -30
  18. package/ios/RCTPromiseSettler.swift +125 -0
  19. package/jest/index.js +149 -0
  20. package/lib/module/NativeOauthEssentials.js +10 -1
  21. package/lib/module/NativeOauthEssentials.js.map +1 -1
  22. package/lib/module/errors.js +38 -0
  23. package/lib/module/errors.js.map +1 -0
  24. package/lib/module/index.js +432 -58
  25. package/lib/module/index.js.map +1 -1
  26. package/lib/module/oauth.js +97 -0
  27. package/lib/module/oauth.js.map +1 -0
  28. package/lib/module/platform.js +108 -0
  29. package/lib/module/platform.js.map +1 -0
  30. package/lib/module/types.js +46 -1
  31. package/lib/module/types.js.map +1 -1
  32. package/lib/typescript/src/NativeOauthEssentials.d.ts +70 -16
  33. package/lib/typescript/src/NativeOauthEssentials.d.ts.map +1 -1
  34. package/lib/typescript/src/errors.d.ts +18 -0
  35. package/lib/typescript/src/errors.d.ts.map +1 -0
  36. package/lib/typescript/src/index.d.ts +235 -40
  37. package/lib/typescript/src/index.d.ts.map +1 -1
  38. package/lib/typescript/src/oauth.d.ts +32 -0
  39. package/lib/typescript/src/oauth.d.ts.map +1 -0
  40. package/lib/typescript/src/platform.d.ts +39 -0
  41. package/lib/typescript/src/platform.d.ts.map +1 -0
  42. package/lib/typescript/src/types.d.ts +190 -3
  43. package/lib/typescript/src/types.d.ts.map +1 -1
  44. package/package.json +8 -4
  45. package/plugin/withOauthEssentials.js +291 -0
  46. package/src/NativeOauthEssentials.ts +91 -17
  47. package/src/errors.ts +49 -0
  48. package/src/index.tsx +587 -80
  49. package/src/oauth.ts +140 -0
  50. package/src/platform.ts +121 -0
  51. package/src/types.ts +200 -2
@@ -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
+ }
217
+
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
+ }
219
256
 
220
- appleSignInPromiseSettler = RCTPromiseSettler(promise)
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)
274
318
  } catch (e: Throwable) {
275
- rejectAndEmitEvent(CredentialError.INVALID_RESULT_ERROR.code, e.toString(), promise)
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)
370
+ } catch (e: Throwable) {
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,23 +481,16 @@ 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
- var isConditional = false
399
- if (options.hasKey("isConditional")) {
400
- isConditional = options.getBoolean("isConditional")
401
- }
484
+ // `isConditional` (conditional create) só existe em androidx.credentials
485
+ // >= 1.6.0. Estamos fixados em 1.5.0 estável (ver build.gradle), então o
486
+ // parâmetro não é suportado aqui.
402
487
 
403
488
  val manager = CredentialManager.create(activity)
404
489
  val request = CreatePublicKeyCredentialRequest(
405
490
  requestJson = requestJson,
406
- preferImmediatelyAvailableCredentials = preferImmediatelyAvailableCredentials,
407
- isConditional = isConditional
491
+ preferImmediatelyAvailableCredentials = options.optBoolean("preferImmediatelyAvailableCredentials")
408
492
  )
409
- // createCredential lança UI: deve rodar na Main thread (ver hybridSignIn).
493
+ // createCredential lança UI: deve rodar na Main thread.
410
494
  val result = withContext(Dispatchers.Main) {
411
495
  manager.createCredential(
412
496
  request = request,
@@ -414,17 +498,11 @@ class OauthEssentialsModule(reactContext: ReactApplicationContext) :
414
498
  )
415
499
  }
416
500
  resolveAndEmit(CredentialFactory.fromPublicKeyCredential(result), promise)
501
+ } catch (e: CreateCredentialCancellationException) {
502
+ resolveAndEmit(CredentialFactory.fromCancelled(), promise)
417
503
  } catch (e: Throwable) {
418
- when (e) {
419
- is CreateCredentialCancellationException -> {
420
- resolveAndEmit(CredentialFactory.fromCancelled(), promise)
421
- }
422
-
423
- else -> {
424
- Log.e(LOG_TAG, e.toString())
425
- rejectAndEmitEvent(CredentialError.INVALID_RESULT_ERROR.code, e.toString(), promise)
426
- }
427
- }
504
+ Log.e(LOG_TAG, e.toString())
505
+ rejectAndEmitEvent(CredentialError.INVALID_RESULT_ERROR.code, e, promise)
428
506
  }
429
507
  }
430
508
  }
@@ -432,12 +510,11 @@ class OauthEssentialsModule(reactContext: ReactApplicationContext) :
432
510
  /**
433
511
  * Gets the passkey.
434
512
  */
435
- @RequiresApi(Build.VERSION_CODES.P)
436
513
  override fun getPassKey(options: ReadableMap, promise: Promise) {
437
514
  if (!getPasskeysSupported()) {
438
515
  rejectAndEmitEvent(
439
516
  CredentialError.NOT_SUPPORTED_ERROR.code,
440
- "Device does not support googleSignIn method.",
517
+ "Device does not support passkeys.",
441
518
  promise
442
519
  )
443
520
  return
@@ -453,11 +530,6 @@ class OauthEssentialsModule(reactContext: ReactApplicationContext) :
453
530
  try {
454
531
  val requestJson = options.getString("requestJson")
455
532
  ?: throw IllegalArgumentException("\"requestJson\" field should be set on android.")
456
- var preferImmediatelyAvailableCredentials = false
457
- if (options.hasKey("preferImmediatelyAvailableCredentials")) {
458
- preferImmediatelyAvailableCredentials =
459
- options.getBoolean("preferImmediatelyAvailableCredentials")
460
- }
461
533
 
462
534
  val manager = CredentialManager.create(activity)
463
535
  val request = GetCredentialRequest(
@@ -466,9 +538,9 @@ class OauthEssentialsModule(reactContext: ReactApplicationContext) :
466
538
  requestJson = requestJson,
467
539
  ),
468
540
  ),
469
- preferImmediatelyAvailableCredentials = preferImmediatelyAvailableCredentials
541
+ preferImmediatelyAvailableCredentials = options.optBoolean("preferImmediatelyAvailableCredentials")
470
542
  )
471
- // getCredential lança UI: deve rodar na Main thread (ver hybridSignIn).
543
+ // getCredential lança UI: deve rodar na Main thread.
472
544
  val result = withContext(Dispatchers.Main) {
473
545
  manager.getCredential(
474
546
  request = request,
@@ -476,19 +548,142 @@ class OauthEssentialsModule(reactContext: ReactApplicationContext) :
476
548
  )
477
549
  }
478
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)
479
555
  } catch (e: Throwable) {
480
- when (e) {
481
- is CreateCredentialCancellationException -> {
482
- resolveAndEmit(CredentialFactory.fromCancelled(), promise)
483
- }
556
+ Log.e(LOG_TAG, e.toString())
557
+ rejectAndEmitEvent(CredentialError.INVALID_RESULT_ERROR.code, e, promise)
558
+ }
559
+ }
560
+ }
484
561
 
485
- else -> {
486
- Log.e(LOG_TAG, e.toString())
487
- rejectAndEmitEvent(CredentialError.INVALID_RESULT_ERROR.code, e.toString(), promise)
488
- }
489
- }
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
+ )
490
683
  }
491
684
  }
685
+ if (resultCode != Activity.RESULT_OK) return null
686
+ return client.getAuthorizationResultFromIntent(data)
492
687
  }
493
688
 
494
689
  /**
@@ -500,7 +695,6 @@ class OauthEssentialsModule(reactContext: ReactApplicationContext) :
500
695
  return result == ConnectionResult.SUCCESS
501
696
  }
502
697
 
503
- @RequiresApi(Build.VERSION_CODES.O)
504
698
  override fun initialize() {
505
699
  super.initialize()
506
700
 
@@ -512,9 +706,11 @@ class OauthEssentialsModule(reactContext: ReactApplicationContext) :
512
706
  IntentFilter().apply {
513
707
  addAction(WEB_APPLE_DEEP_LINK_RECEIVED_INTENT)
514
708
  addAction(WEB_APPLE_CANCELLED_INTENT)
709
+ addAction(WEB_BROWSER_UNAVAILABLE_INTENT)
515
710
  },
516
711
  ContextCompat.RECEIVER_NOT_EXPORTED
517
712
  )
713
+ reactApplicationContext.addActivityEventListener(activityEventListener)
518
714
  }
519
715
 
520
716
  override fun invalidate() {
@@ -523,10 +719,11 @@ class OauthEssentialsModule(reactContext: ReactApplicationContext) :
523
719
 
524
720
  Log.d(LOG_TAG, "Cancelling all coroutines and credential operations")
525
721
  moduleScope.cancel()
722
+ reactApplicationContext.removeActivityEventListener(activityEventListener)
526
723
 
527
- appleSignInPromiseSettler?.let {
724
+ browserSessionSettler?.let {
528
725
  if (!it.settled()) {
529
- Log.d(LOG_TAG, "Rejecting pending Apple sign-in promise without event")
726
+ Log.d(LOG_TAG, "Rejecting pending browser session promise without event")
530
727
  it.reject(
531
728
  "CANCELLED",
532
729
  "Module is being invalidated",
@@ -535,10 +732,10 @@ class OauthEssentialsModule(reactContext: ReactApplicationContext) :
535
732
  )
536
733
  }
537
734
  }
538
- appleSignInPromiseSettler = null
735
+ browserSessionSettler = null
539
736
 
540
737
  Log.d(LOG_TAG, "Unregistering deep link broadcast receiver")
541
- reactApplicationContext.unregisterReceiver(receiver)
738
+ reactApplicationContext.applicationContext.unregisterReceiver(receiver)
542
739
  }
543
740
 
544
741
  /**
@@ -565,15 +762,71 @@ class OauthEssentialsModule(reactContext: ReactApplicationContext) :
565
762
  return Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && getCredentialManagerSupported()
566
763
  }
567
764
 
568
- /**
569
- * Reject and emit event.
570
- */
571
- private fun rejectAndEmitEvent(code: String, e: Throwable, promise: Promise) {
572
- 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
+ }
573
776
 
777
+ private fun emitFailure(code: String, message: String) {
574
778
  val error = Arguments.createMap()
575
779
  error.putString("code", code)
576
- 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())
577
830
  }
578
831
 
579
832
  /**
@@ -581,11 +834,7 @@ class OauthEssentialsModule(reactContext: ReactApplicationContext) :
581
834
  */
582
835
  private fun rejectAndEmitEvent(code: String, e: String, promise: Promise) {
583
836
  promise.reject(code, e)
584
-
585
- val error = Arguments.createMap()
586
- error.putString("code", code)
587
- error.putString("message", e)
588
- emitOnCredentialFailure(error)
837
+ emitFailure(code, e)
589
838
  }
590
839
 
591
840
  /**
@@ -610,11 +859,18 @@ class OauthEssentialsModule(reactContext: ReactApplicationContext) :
610
859
 
611
860
  const val LOG_TAG = "NativeOauthEssentials"
612
861
 
613
- // 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)
614
866
  const val WEB_APPLE_DEEP_LINK_RECEIVED_INTENT =
615
867
  "com.oauthessentials.WEB_APPLE_DEEP_LINK_RECEIVED_INTENT"
616
868
  const val WEB_APPLE_CANCELLED_INTENT =
617
869
  "com.oauthessentials.WEB_APPLE_CANCELLED_INTENT"
870
+ const val WEB_BROWSER_UNAVAILABLE_INTENT =
871
+ "com.oauthessentials.WEB_BROWSER_UNAVAILABLE_INTENT"
618
872
  const val DISPLAY_CUSTOM_TABS_INTENT = "com.oauthessentials.DISPLAY_CUSTOM_TABS_INTENT"
873
+
874
+ private const val AUTHORIZATION_REQUEST_CODE = 0x0A07
619
875
  }
620
876
  }