react-native-nitro-auth 0.5.5 → 0.5.6

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 (82) hide show
  1. package/README.md +22 -17
  2. package/android/proguard-rules.pro +7 -1
  3. package/android/src/main/AndroidManifest.xml +12 -0
  4. package/android/src/main/cpp/PlatformAuth+Android.cpp +260 -67
  5. package/android/src/main/java/com/auth/AuthAdapter.kt +217 -146
  6. package/android/src/main/java/com/auth/GoogleSignInActivity.kt +9 -5
  7. package/cpp/HybridAuth.cpp +79 -64
  8. package/cpp/HybridAuth.hpp +9 -7
  9. package/cpp/JSONSerializer.hpp +3 -0
  10. package/ios/AuthAdapter.swift +173 -60
  11. package/ios/PlatformAuth+iOS.mm +10 -3
  12. package/lib/commonjs/Auth.web.js +50 -10
  13. package/lib/commonjs/Auth.web.js.map +1 -1
  14. package/lib/commonjs/index.web.js +30 -12
  15. package/lib/commonjs/index.web.js.map +1 -1
  16. package/lib/commonjs/service.js +9 -7
  17. package/lib/commonjs/service.js.map +1 -1
  18. package/lib/commonjs/service.web.js +65 -13
  19. package/lib/commonjs/service.web.js.map +1 -1
  20. package/lib/commonjs/ui/social-button.js +19 -14
  21. package/lib/commonjs/ui/social-button.js.map +1 -1
  22. package/lib/commonjs/ui/social-button.web.js +16 -10
  23. package/lib/commonjs/ui/social-button.web.js.map +1 -1
  24. package/lib/commonjs/use-auth.js +13 -5
  25. package/lib/commonjs/use-auth.js.map +1 -1
  26. package/lib/commonjs/utils/logger.js +1 -0
  27. package/lib/commonjs/utils/logger.js.map +1 -1
  28. package/lib/module/Auth.web.js +50 -10
  29. package/lib/module/Auth.web.js.map +1 -1
  30. package/lib/module/global.d.js.map +1 -1
  31. package/lib/module/index.js.map +1 -1
  32. package/lib/module/index.web.js +2 -1
  33. package/lib/module/index.web.js.map +1 -1
  34. package/lib/module/service.js +9 -7
  35. package/lib/module/service.js.map +1 -1
  36. package/lib/module/service.web.js +65 -13
  37. package/lib/module/service.web.js.map +1 -1
  38. package/lib/module/ui/social-button.js +19 -14
  39. package/lib/module/ui/social-button.js.map +1 -1
  40. package/lib/module/ui/social-button.web.js +16 -10
  41. package/lib/module/ui/social-button.web.js.map +1 -1
  42. package/lib/module/use-auth.js +13 -5
  43. package/lib/module/use-auth.js.map +1 -1
  44. package/lib/module/utils/logger.js +1 -0
  45. package/lib/module/utils/logger.js.map +1 -1
  46. package/lib/typescript/commonjs/Auth.web.d.ts +5 -1
  47. package/lib/typescript/commonjs/Auth.web.d.ts.map +1 -1
  48. package/lib/typescript/commonjs/index.d.ts +1 -1
  49. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  50. package/lib/typescript/commonjs/index.web.d.ts +2 -1
  51. package/lib/typescript/commonjs/index.web.d.ts.map +1 -1
  52. package/lib/typescript/commonjs/service.d.ts.map +1 -1
  53. package/lib/typescript/commonjs/service.web.d.ts +2 -18
  54. package/lib/typescript/commonjs/service.web.d.ts.map +1 -1
  55. package/lib/typescript/commonjs/ui/social-button.d.ts.map +1 -1
  56. package/lib/typescript/commonjs/ui/social-button.web.d.ts.map +1 -1
  57. package/lib/typescript/commonjs/use-auth.d.ts.map +1 -1
  58. package/lib/typescript/commonjs/utils/logger.d.ts.map +1 -1
  59. package/lib/typescript/module/Auth.web.d.ts +5 -1
  60. package/lib/typescript/module/Auth.web.d.ts.map +1 -1
  61. package/lib/typescript/module/index.d.ts +1 -1
  62. package/lib/typescript/module/index.d.ts.map +1 -1
  63. package/lib/typescript/module/index.web.d.ts +2 -1
  64. package/lib/typescript/module/index.web.d.ts.map +1 -1
  65. package/lib/typescript/module/service.d.ts.map +1 -1
  66. package/lib/typescript/module/service.web.d.ts +2 -18
  67. package/lib/typescript/module/service.web.d.ts.map +1 -1
  68. package/lib/typescript/module/ui/social-button.d.ts.map +1 -1
  69. package/lib/typescript/module/ui/social-button.web.d.ts.map +1 -1
  70. package/lib/typescript/module/use-auth.d.ts.map +1 -1
  71. package/lib/typescript/module/utils/logger.d.ts.map +1 -1
  72. package/package.json +2 -4
  73. package/src/Auth.web.ts +77 -11
  74. package/src/global.d.ts +0 -11
  75. package/src/index.ts +5 -1
  76. package/src/index.web.ts +6 -1
  77. package/src/service.ts +9 -7
  78. package/src/service.web.ts +84 -15
  79. package/src/ui/social-button.tsx +21 -9
  80. package/src/ui/social-button.web.tsx +17 -4
  81. package/src/use-auth.ts +35 -8
  82. package/src/utils/logger.ts +1 -0
@@ -1,3 +1,10 @@
1
+ @file:Suppress("DEPRECATION")
2
+ // The legacy com.google.android.gms.auth.api.signin.* API is used intentionally for:
3
+ // • getLastSignedInAccount – persists session across app restarts via GMS store; no drop-in replacement
4
+ // • silentSignIn – AuthorizationClient.authorize() still requires an Activity for interactive fallback
5
+ // • revokeAccess – no equivalent in Credential Manager or Identity.getAuthorizationClient()
6
+ // All modern entry-points use Credential Manager (One-Tap). Legacy is a documented fallback only.
7
+
1
8
  package com.auth
2
9
 
3
10
  import android.app.Activity
@@ -9,6 +16,7 @@ import android.os.Bundle
9
16
  import android.util.Base64
10
17
  import android.util.Log
11
18
  import androidx.browser.customtabs.CustomTabsIntent
19
+ import androidx.credentials.ClearCredentialStateRequest
12
20
  import androidx.credentials.CredentialManager
13
21
  import androidx.credentials.GetCredentialRequest
14
22
  import androidx.credentials.GetCredentialResponse
@@ -43,14 +51,24 @@ object AuthAdapter {
43
51
  private var pendingScopes: List<String> = emptyList()
44
52
  private var pendingMicrosoftScopes: List<String> = emptyList()
45
53
 
54
+ @Volatile
55
+ private var pendingOrigin: String = "login"
56
+ @Volatile
46
57
  private var pendingPkceVerifier: String? = null
58
+ @Volatile
47
59
  private var pendingState: String? = null
60
+ @Volatile
48
61
  private var pendingNonce: String? = null
62
+ @Volatile
49
63
  private var pendingMicrosoftTenant: String? = null
64
+ @Volatile
50
65
  private var pendingMicrosoftClientId: String? = null
66
+ @Volatile
51
67
  private var pendingMicrosoftB2cDomain: String? = null
52
68
 
69
+ @Volatile
53
70
  private var inMemoryMicrosoftRefreshToken: String? = null
71
+ @Volatile
54
72
  private var inMemoryMicrosoftScopes: List<String> =
55
73
  listOf("openid", "email", "profile", "offline_access", "User.Read")
56
74
 
@@ -62,6 +80,7 @@ object AuthAdapter {
62
80
 
63
81
  @JvmStatic
64
82
  private external fun nativeOnLoginSuccess(
83
+ origin: String,
65
84
  provider: String,
66
85
  email: String?,
67
86
  name: String?,
@@ -74,7 +93,7 @@ object AuthAdapter {
74
93
  )
75
94
 
76
95
  @JvmStatic
77
- private external fun nativeOnLoginError(error: String, underlyingError: String?)
96
+ private external fun nativeOnLoginError(origin: String, error: String, underlyingError: String?)
78
97
 
79
98
  @JvmStatic
80
99
  private external fun nativeOnRefreshSuccess(idToken: String?, accessToken: String?, expirationTime: Long?)
@@ -127,22 +146,22 @@ object AuthAdapter {
127
146
  isInitialized = false
128
147
  }
129
148
 
130
- fun onSignInSuccess(account: GoogleSignInAccount, scopes: List<String>) {
149
+ fun onSignInSuccess(account: GoogleSignInAccount, scopes: List<String>, origin: String = "login") {
131
150
  appContext ?: return
132
151
  val expirationTime = getJwtExpirationTimeMs(account.idToken)
133
- nativeOnLoginSuccess("google", account.email, account.displayName,
152
+ nativeOnLoginSuccess(origin, "google", account.email, account.displayName,
134
153
  account.photoUrl?.toString(), account.idToken, null, account.serverAuthCode,
135
154
  scopes.toTypedArray(), expirationTime)
136
155
  }
137
156
 
138
- fun onSignInError(errorCode: Int, message: String?) {
157
+ fun onSignInError(errorCode: Int, message: String?, origin: String = "login") {
139
158
  val mappedError = when (errorCode) {
140
159
  12501 -> "cancelled"
141
160
  7 -> "network_error"
142
161
  8, 10 -> "configuration_error"
143
162
  else -> "unknown"
144
163
  }
145
- nativeOnLoginError(mappedError, message)
164
+ nativeOnLoginError(origin, mappedError, message)
146
165
  }
147
166
 
148
167
  @JvmStatic
@@ -159,22 +178,22 @@ object AuthAdapter {
159
178
  prompt: String? = null
160
179
  ) {
161
180
  if (provider == "apple") {
162
- nativeOnLoginError("unsupported_provider", "Apple Sign-In is not supported on Android.")
181
+ nativeOnLoginError("login", "unsupported_provider", "Apple Sign-In is not supported on Android.")
163
182
  return
164
183
  }
165
184
  if (provider == "microsoft") {
166
- loginMicrosoft(context, scopes, loginHint, tenant, prompt)
185
+ loginMicrosoft(context, scopes, loginHint, tenant, prompt, "login")
167
186
  return
168
187
  }
169
188
  if (provider != "google") {
170
- nativeOnLoginError("unsupported_provider", "Unsupported provider: $provider")
189
+ nativeOnLoginError("login", "unsupported_provider", "Unsupported provider: $provider")
171
190
  return
172
191
  }
173
192
 
174
193
  val ctx = appContext ?: context.applicationContext
175
194
  val clientId = googleClientId ?: getClientIdFromResources(ctx)
176
195
  if (clientId == null) {
177
- nativeOnLoginError("configuration_error", "Google Client ID is required. Set it in app.json plugins.")
196
+ nativeOnLoginError("login", "configuration_error", "Google Client ID is required. Set it in app.json plugins.")
178
197
  return
179
198
  }
180
199
 
@@ -182,19 +201,20 @@ object AuthAdapter {
182
201
  pendingScopes = requestedScopes
183
202
 
184
203
  if (useLegacyGoogleSignIn) {
185
- loginLegacy(context, clientId, requestedScopes, loginHint, forceAccountPicker)
204
+ loginLegacy(context, clientId, requestedScopes, loginHint, forceAccountPicker, "login")
186
205
  return
187
206
  }
188
- loginOneTap(context, clientId, requestedScopes, loginHint, forceAccountPicker, useOneTap)
207
+ loginOneTap(context, clientId, requestedScopes, loginHint, forceAccountPicker, useOneTap, "login")
189
208
  }
190
209
 
191
- private fun loginMicrosoft(context: Context, scopes: Array<String>?, loginHint: String?, tenant: String?, prompt: String?) {
210
+ private fun loginMicrosoft(context: Context, scopes: Array<String>?, loginHint: String?, tenant: String?, prompt: String?, origin: String = "login") {
192
211
  val ctx = appContext ?: context.applicationContext
193
212
  val clientId = getMicrosoftClientIdFromResources(ctx)
194
213
  if (clientId == null) {
195
- nativeOnLoginError("configuration_error", "Microsoft Client ID is required. Set it in app.json plugins.")
214
+ nativeOnLoginError(origin, "configuration_error", "Microsoft Client ID is required. Set it in app.json plugins.")
196
215
  return
197
216
  }
217
+ pendingOrigin = origin
198
218
 
199
219
  val effectiveTenant = tenant ?: getMicrosoftTenantFromResources(ctx) ?: "common"
200
220
  val effectiveScopes = scopes?.toList() ?: listOf("openid", "email", "profile", "offline_access", "User.Read")
@@ -241,7 +261,7 @@ object AuthAdapter {
241
261
  ctx.startActivity(browserIntent)
242
262
  }
243
263
  } catch (e: Exception) {
244
- nativeOnLoginError("unknown", e.message)
264
+ nativeOnLoginError(origin, "unknown", e.message)
245
265
  }
246
266
  }
247
267
 
@@ -263,19 +283,20 @@ object AuthAdapter {
263
283
  val error = uri.getQueryParameter("error")
264
284
  val errorDescription = uri.getQueryParameter("error_description")
265
285
 
286
+ val origin = pendingOrigin
266
287
  if (error != null) {
267
288
  clearPkceState()
268
- nativeOnLoginError(error, errorDescription)
289
+ nativeOnLoginError(origin, error, errorDescription)
269
290
  return
270
291
  }
271
292
  if (state != pendingState) {
272
293
  clearPkceState()
273
- nativeOnLoginError("invalid_state", "State mismatch - possible CSRF attack")
294
+ nativeOnLoginError(origin, "invalid_state", "State mismatch - possible CSRF attack")
274
295
  return
275
296
  }
276
297
  if (code == null) {
277
298
  clearPkceState()
278
- nativeOnLoginError("unknown", "No authorization code in response")
299
+ nativeOnLoginError(origin, "unknown", "No authorization code in response")
279
300
  return
280
301
  }
281
302
  exchangeCodeForTokens(code)
@@ -286,10 +307,11 @@ object AuthAdapter {
286
307
  val clientId = pendingMicrosoftClientId
287
308
  val tenant = pendingMicrosoftTenant
288
309
  val verifier = pendingPkceVerifier
310
+ val origin = pendingOrigin
289
311
 
290
312
  if (ctx == null || clientId == null || tenant == null || verifier == null) {
291
313
  clearPkceState()
292
- nativeOnLoginError("invalid_state", "Missing PKCE state for token exchange")
314
+ nativeOnLoginError(origin, "invalid_state", "Missing PKCE state for token exchange")
293
315
  return
294
316
  }
295
317
 
@@ -301,49 +323,55 @@ object AuthAdapter {
301
323
  try {
302
324
  val url = java.net.URL(tokenUrl)
303
325
  val connection = url.openConnection() as java.net.HttpURLConnection
304
- connection.requestMethod = "POST"
305
- connection.setRequestProperty("Content-Type", "application/x-www-form-urlencoded")
306
- connection.doOutput = true
307
-
308
- val postData = buildString {
309
- append("client_id=${java.net.URLEncoder.encode(clientId, "UTF-8")}")
310
- append("&code=${java.net.URLEncoder.encode(code, "UTF-8")}")
311
- append("&redirect_uri=${java.net.URLEncoder.encode(redirectUri, "UTF-8")}")
312
- append("&grant_type=authorization_code")
313
- append("&code_verifier=${java.net.URLEncoder.encode(verifier, "UTF-8")}")
314
- }
315
- connection.outputStream.use { it.write(postData.toByteArray()) }
326
+ try {
327
+ connection.connectTimeout = 15_000
328
+ connection.readTimeout = 15_000
329
+ connection.requestMethod = "POST"
330
+ connection.setRequestProperty("Content-Type", "application/x-www-form-urlencoded")
331
+ connection.doOutput = true
332
+
333
+ val postData = buildString {
334
+ append("client_id=${java.net.URLEncoder.encode(clientId, "UTF-8")}")
335
+ append("&code=${java.net.URLEncoder.encode(code, "UTF-8")}")
336
+ append("&redirect_uri=${java.net.URLEncoder.encode(redirectUri, "UTF-8")}")
337
+ append("&grant_type=authorization_code")
338
+ append("&code_verifier=${java.net.URLEncoder.encode(verifier, "UTF-8")}")
339
+ }
340
+ connection.outputStream.use { it.write(postData.toByteArray()) }
316
341
 
317
- val responseCode = connection.responseCode
318
- val responseBody = if (responseCode == 200) {
319
- connection.inputStream.bufferedReader().readText()
320
- } else {
321
- connection.errorStream?.bufferedReader()?.readText() ?: ""
322
- }
342
+ val responseCode = connection.responseCode
343
+ val responseBody = if (responseCode == 200) {
344
+ connection.inputStream.bufferedReader().use { it.readText() }
345
+ } else {
346
+ connection.errorStream?.bufferedReader()?.use { it.readText() } ?: ""
347
+ }
323
348
 
324
- withContext(Dispatchers.Main) {
325
- handleTokenResponse(responseCode, responseBody)
349
+ withContext(Dispatchers.Main) {
350
+ handleTokenResponse(responseCode, responseBody, origin)
351
+ }
352
+ } finally {
353
+ connection.disconnect()
326
354
  }
327
355
  } catch (e: Exception) {
328
356
  withContext(Dispatchers.Main) {
329
357
  clearPkceState()
330
- nativeOnLoginError("network_error", e.message)
358
+ nativeOnLoginError(origin, "network_error", e.message)
331
359
  }
332
360
  }
333
361
  }
334
362
  }
335
363
 
336
- private fun handleTokenResponse(responseCode: Int, responseBody: String) {
364
+ private fun handleTokenResponse(responseCode: Int, responseBody: String, origin: String) {
337
365
  if (responseCode != 200) {
338
366
  try {
339
367
  val json = JSONObject(responseBody)
340
368
  val error = json.optString("error", "token_error")
341
369
  val desc = json.optString("error_description", "Failed to exchange code for tokens")
342
370
  clearPkceState()
343
- nativeOnLoginError(error, desc)
371
+ nativeOnLoginError(origin, error, desc)
344
372
  } catch (e: Exception) {
345
373
  clearPkceState()
346
- nativeOnLoginError("token_error", "Failed to exchange code for tokens")
374
+ nativeOnLoginError(origin, "token_error", "Failed to exchange code for tokens")
347
375
  }
348
376
  return
349
377
  }
@@ -358,14 +386,14 @@ object AuthAdapter {
358
386
 
359
387
  if (idToken.isEmpty()) {
360
388
  clearPkceState()
361
- nativeOnLoginError("no_id_token", "No id_token in token response")
389
+ nativeOnLoginError(origin, "no_id_token", "No id_token in token response")
362
390
  return
363
391
  }
364
392
 
365
393
  val claims = decodeJwt(idToken)
366
394
  if (claims["nonce"] != pendingNonce) {
367
395
  clearPkceState()
368
- nativeOnLoginError("invalid_nonce", "Nonce mismatch - token may be replayed")
396
+ nativeOnLoginError(origin, "invalid_nonce", "Nonce mismatch - token may be replayed")
369
397
  return
370
398
  }
371
399
 
@@ -379,16 +407,17 @@ object AuthAdapter {
379
407
 
380
408
  clearPkceState()
381
409
  nativeOnLoginSuccess(
382
- "microsoft", email, name, null, idToken, accessToken, null,
410
+ origin, "microsoft", email, name, null, idToken, accessToken, null,
383
411
  pendingMicrosoftScopes.toTypedArray(), expirationTime
384
412
  )
385
413
  } catch (e: Exception) {
386
414
  clearPkceState()
387
- nativeOnLoginError("parse_error", e.message)
415
+ nativeOnLoginError(origin, "parse_error", e.message)
388
416
  }
389
417
  }
390
418
 
391
419
  private fun clearPkceState() {
420
+ pendingOrigin = "login"
392
421
  pendingPkceVerifier = null
393
422
  pendingState = null
394
423
  pendingNonce = null
@@ -410,6 +439,7 @@ object AuthAdapter {
410
439
  }
411
440
  result
412
441
  } catch (e: Exception) {
442
+ Log.w(TAG, "Failed to decode JWT: ${e.message}")
413
443
  emptyMap()
414
444
  }
415
445
  }
@@ -452,12 +482,13 @@ object AuthAdapter {
452
482
  scopes: List<String>,
453
483
  loginHint: String?,
454
484
  forceAccountPicker: Boolean,
455
- useOneTap: Boolean
485
+ useOneTap: Boolean,
486
+ origin: String = "login"
456
487
  ) {
457
488
  val activity = currentActivity ?: context as? Activity
458
489
  if (activity == null) {
459
490
  Log.w(TAG, "No Activity context available for One-Tap, falling back to legacy")
460
- return loginLegacy(context, clientId, scopes, loginHint, forceAccountPicker)
491
+ return loginLegacy(context, clientId, scopes, loginHint, forceAccountPicker, origin)
461
492
  }
462
493
 
463
494
  val credentialManager = CredentialManager.create(activity)
@@ -474,31 +505,31 @@ object AuthAdapter {
474
505
  moduleScope.launch(Dispatchers.Main) {
475
506
  try {
476
507
  val result = credentialManager.getCredential(context = activity, request = request)
477
- handleCredentialResponse(result, scopes)
508
+ handleCredentialResponse(result, scopes, origin)
478
509
  } catch (e: Exception) {
479
510
  Log.w(TAG, "One-Tap failed, falling back to legacy: ${e.message}")
480
- loginLegacy(context, clientId, scopes, loginHint, forceAccountPicker)
511
+ loginLegacy(context, clientId, scopes, loginHint, forceAccountPicker, origin)
481
512
  }
482
513
  }
483
514
  }
484
515
 
485
- @Suppress("DEPRECATION")
486
516
  private fun loginLegacy(
487
517
  context: Context,
488
518
  clientId: String,
489
519
  scopes: List<String>,
490
520
  loginHint: String?,
491
- forceAccountPicker: Boolean
521
+ forceAccountPicker: Boolean,
522
+ origin: String = "login"
492
523
  ) {
493
524
  val ctx = appContext ?: context.applicationContext
494
525
  val intent = GoogleSignInActivity.createIntent(
495
- ctx, clientId, scopes.toTypedArray(), loginHint, forceAccountPicker
526
+ ctx, clientId, scopes.toTypedArray(), loginHint, forceAccountPicker, origin
496
527
  )
497
528
  intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
498
529
  ctx.startActivity(intent)
499
530
  }
500
531
 
501
- private fun handleCredentialResponse(response: GetCredentialResponse, scopes: List<String>) {
532
+ private fun handleCredentialResponse(response: GetCredentialResponse, scopes: List<String>, origin: String) {
502
533
  val credential = response.credential
503
534
  val googleIdTokenCredential = try {
504
535
  if (credential is GoogleIdTokenCredential) {
@@ -516,7 +547,7 @@ object AuthAdapter {
516
547
  if (googleIdTokenCredential != null) {
517
548
  val expirationTime = getJwtExpirationTimeMs(googleIdTokenCredential.idToken)
518
549
  nativeOnLoginSuccess(
519
- "google",
550
+ origin, "google",
520
551
  googleIdTokenCredential.id,
521
552
  googleIdTokenCredential.displayName,
522
553
  googleIdTokenCredential.profilePictureUri?.toString(),
@@ -527,13 +558,12 @@ object AuthAdapter {
527
558
  )
528
559
  } else {
529
560
  Log.w(TAG, "Unsupported credential type: ${credential.type}")
530
- nativeOnLoginError("unknown", "Unsupported credential type: ${credential.type}")
561
+ nativeOnLoginError(origin, "unknown", "Unsupported credential type: ${credential.type}")
531
562
  }
532
563
  }
533
564
 
534
565
  // requestScopesSync uses the legacy GoogleSignIn API to check the last signed-in account
535
566
  // because Credential Manager has no equivalent for querying existing account state.
536
- @Suppress("DEPRECATION")
537
567
  @JvmStatic
538
568
  fun requestScopesSync(context: Context, scopes: Array<String>) {
539
569
  val ctx = appContext ?: context.applicationContext
@@ -541,31 +571,30 @@ object AuthAdapter {
541
571
  if (account != null) {
542
572
  val newScopes = scopes.map { Scope(it) }
543
573
  if (GoogleSignIn.hasPermissions(account, *newScopes.toTypedArray())) {
544
- onSignInSuccess(account, (pendingScopes + scopes.toList()).distinct())
574
+ onSignInSuccess(account, (pendingScopes + scopes.toList()).distinct(), "scopes")
545
575
  return
546
576
  }
547
577
  val clientId = getClientIdFromResources(ctx)
548
578
  if (clientId == null) {
549
- nativeOnLoginError("configuration_error", "Google Client ID not configured")
579
+ nativeOnLoginError("scopes", "configuration_error", "Google Client ID not configured")
550
580
  return
551
581
  }
552
582
  val allScopes = (pendingScopes + scopes.toList()).distinct()
553
- val intent = GoogleSignInActivity.createIntent(ctx, clientId, allScopes.toTypedArray(), account.email)
583
+ val intent = GoogleSignInActivity.createIntent(ctx, clientId, allScopes.toTypedArray(), account.email, origin = "scopes")
554
584
  ctx.startActivity(intent)
555
585
  return
556
586
  }
557
587
  if (inMemoryMicrosoftRefreshToken != null) {
558
588
  val mergedScopes = (inMemoryMicrosoftScopes + scopes.toList()).distinct()
559
589
  val tenant = getMicrosoftTenantFromResources(ctx)
560
- loginMicrosoft(ctx, mergedScopes.toTypedArray(), null, tenant, null)
590
+ loginMicrosoft(ctx, mergedScopes.toTypedArray(), null, tenant, null, "scopes")
561
591
  return
562
592
  }
563
- nativeOnLoginError("unknown", "No user logged in")
593
+ nativeOnLoginError("scopes", "unknown", "No user logged in")
564
594
  }
565
595
 
566
596
  // refreshTokenSync uses the legacy silentSignIn because AuthorizationClient (the recommended
567
597
  // replacement) requires an Activity context which is not always available at refresh time.
568
- @Suppress("DEPRECATION")
569
598
  @JvmStatic
570
599
  fun refreshTokenSync(context: Context) {
571
600
  val ctx = appContext ?: context.applicationContext
@@ -604,28 +633,35 @@ object AuthAdapter {
604
633
 
605
634
  @JvmStatic
606
635
  fun hasPlayServices(context: Context): Boolean {
607
- val ctx = context.applicationContext ?: appContext ?: return false
636
+ val ctx = appContext ?: context.applicationContext ?: return false
608
637
  return GoogleApiAvailability.getInstance()
609
638
  .isGooglePlayServicesAvailable(ctx) == ConnectionResult.SUCCESS
610
639
  }
611
640
 
612
- // logoutSync / revokeAccessSync use the legacy GoogleSignIn client because Credential Manager
613
- // does not expose a sign-out or revoke API for the Google ID token flow.
614
- @Suppress("DEPRECATION")
641
+ // revokeAccessSync uses the legacy GoogleSignIn client because Credential Manager has no
642
+ // equivalent revoke API for the Google ID token flow.
615
643
  @JvmStatic
616
644
  fun logoutSync(context: Context) {
617
645
  val ctx = appContext ?: context.applicationContext
646
+ // Clear Credential Manager state (covers One-Tap / passkey credentials).
647
+ moduleScope.launch {
648
+ try {
649
+ CredentialManager.create(ctx).clearCredentialState(ClearCredentialStateRequest())
650
+ } catch (e: Exception) {
651
+ Log.w(TAG, "clearCredentialState failed: ${e.message}")
652
+ }
653
+ }
654
+ // Also clear legacy GMS sign-in state so getLastSignedInAccount returns null.
618
655
  val clientId = getClientIdFromResources(ctx)
619
656
  if (clientId != null) {
620
657
  val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
621
- .requestIdToken(clientId).requestServerAuthCode(clientId).requestEmail().build()
658
+ .requestIdToken(clientId).requestEmail().build()
622
659
  GoogleSignIn.getClient(ctx, gso).signOut()
623
660
  }
624
661
  inMemoryMicrosoftRefreshToken = null
625
662
  inMemoryMicrosoftScopes = listOf("openid", "email", "profile", "offline_access", "User.Read")
626
663
  }
627
664
 
628
- @Suppress("DEPRECATION")
629
665
  @JvmStatic
630
666
  fun revokeAccessSync(context: Context) {
631
667
  val ctx = appContext ?: context.applicationContext
@@ -646,12 +682,12 @@ object AuthAdapter {
646
682
 
647
683
  @JvmStatic
648
684
  fun restoreSession(context: Context) {
649
- val ctx = context.applicationContext ?: appContext ?: context
685
+ val ctx = appContext ?: context.applicationContext ?: return
650
686
  @Suppress("DEPRECATION")
651
687
  val account = GoogleSignIn.getLastSignedInAccount(ctx)
652
688
  if (account != null) {
653
689
  val expirationTime = getJwtExpirationTimeMs(account.idToken)
654
- nativeOnLoginSuccess("google", account.email, account.displayName,
690
+ nativeOnLoginSuccess("silent", "google", account.email, account.displayName,
655
691
  account.photoUrl?.toString(), account.idToken, null, account.serverAuthCode,
656
692
  account.grantedScopes?.map { it.scopeUri }?.toTypedArray(), expirationTime)
657
693
  } else {
@@ -659,7 +695,7 @@ object AuthAdapter {
659
695
  if (refreshToken != null) {
660
696
  refreshMicrosoftToken(ctx, refreshToken)
661
697
  } else {
662
- nativeOnLoginError("unknown", "No session")
698
+ nativeOnLoginError("silent", "unknown", "No session")
663
699
  }
664
700
  }
665
701
  }
@@ -670,7 +706,7 @@ object AuthAdapter {
670
706
  val b2cDomain = getMicrosoftB2cDomainFromResources(context)
671
707
 
672
708
  if (clientId == null) {
673
- nativeOnLoginError("configuration_error", "Microsoft Client ID is required for refresh")
709
+ nativeOnLoginError("silent", "configuration_error", "Microsoft Client ID is required for refresh")
674
710
  return
675
711
  }
676
712
 
@@ -681,50 +717,67 @@ object AuthAdapter {
681
717
  try {
682
718
  val url = java.net.URL(tokenUrl)
683
719
  val connection = url.openConnection() as java.net.HttpURLConnection
684
- connection.requestMethod = "POST"
685
- connection.setRequestProperty("Content-Type", "application/x-www-form-urlencoded")
686
- connection.doOutput = true
687
-
688
- val postData = buildString {
689
- append("client_id=${java.net.URLEncoder.encode(clientId, "UTF-8")}")
690
- append("&grant_type=refresh_token")
691
- append("&refresh_token=${java.net.URLEncoder.encode(refreshToken, "UTF-8")}")
692
- }
693
- connection.outputStream.use { it.write(postData.toByteArray()) }
720
+ try {
721
+ connection.connectTimeout = 15_000
722
+ connection.readTimeout = 15_000
723
+ connection.requestMethod = "POST"
724
+ connection.setRequestProperty("Content-Type", "application/x-www-form-urlencoded")
725
+ connection.doOutput = true
726
+
727
+ val postData = buildString {
728
+ append("client_id=${java.net.URLEncoder.encode(clientId, "UTF-8")}")
729
+ append("&grant_type=refresh_token")
730
+ append("&refresh_token=${java.net.URLEncoder.encode(refreshToken, "UTF-8")}")
731
+ }
732
+ connection.outputStream.use { it.write(postData.toByteArray()) }
694
733
 
695
- val responseCode = connection.responseCode
696
- val responseBody = if (responseCode == 200) {
697
- connection.inputStream.bufferedReader().readText()
698
- } else {
699
- connection.errorStream?.bufferedReader()?.readText() ?: ""
700
- }
734
+ val responseCode = connection.responseCode
735
+ val responseBody = if (responseCode == 200) {
736
+ connection.inputStream.bufferedReader().use { it.readText() }
737
+ } else {
738
+ connection.errorStream?.bufferedReader()?.use { it.readText() } ?: ""
739
+ }
701
740
 
702
- withContext(Dispatchers.Main) {
703
- if (responseCode == 200) {
704
- val json = JSONObject(responseBody)
705
- val newIdToken = json.optString("id_token")
706
- val newAccessToken = json.optString("access_token")
707
- val newRefreshToken = json.optString("refresh_token")
708
- val expiresIn = json.optLong("expires_in", 0)
709
- val expirationTime = if (expiresIn > 0) System.currentTimeMillis() + expiresIn * 1000 else null
710
- val claims = decodeJwt(newIdToken)
711
-
712
- if (newRefreshToken.isNotEmpty()) inMemoryMicrosoftRefreshToken = newRefreshToken
713
- inMemoryMicrosoftScopes = pendingMicrosoftScopes.ifEmpty {
714
- listOf("openid", "email", "profile", "offline_access", "User.Read")
741
+ withContext(Dispatchers.Main) {
742
+ if (responseCode == 200) {
743
+ val json = JSONObject(responseBody)
744
+ val newIdToken = json.optString("id_token")
745
+ val newAccessToken = json.optString("access_token")
746
+ val newRefreshToken = json.optString("refresh_token")
747
+ val expiresIn = json.optLong("expires_in", 0)
748
+ val expirationTime = if (expiresIn > 0) System.currentTimeMillis() + expiresIn * 1000 else null
749
+ val claims = decodeJwt(newIdToken)
750
+
751
+ if (newRefreshToken.isNotEmpty()) inMemoryMicrosoftRefreshToken = newRefreshToken
752
+ inMemoryMicrosoftScopes = pendingMicrosoftScopes.ifEmpty {
753
+ listOf("openid", "email", "profile", "offline_access", "User.Read")
754
+ }
755
+
756
+ nativeOnLoginSuccess("silent", "microsoft",
757
+ claims["preferred_username"] ?: claims["email"],
758
+ claims["name"], null,
759
+ newIdToken, newAccessToken, null, null, expirationTime)
760
+ } else {
761
+ if (responseCode in 400..499) {
762
+ inMemoryMicrosoftRefreshToken = null // Token is invalid, clear it
763
+ }
764
+ val mappedError = try {
765
+ val json = org.json.JSONObject(responseBody)
766
+ val errorCode = json.optString("error", "token_error")
767
+ val errorDesc = json.optString("error_description", "Token refresh failed")
768
+ Pair(errorCode, errorDesc)
769
+ } catch (e: Exception) {
770
+ Pair("token_error", "Token refresh failed")
771
+ }
772
+ nativeOnLoginError("silent", mappedError.first, mappedError.second)
715
773
  }
716
-
717
- nativeOnLoginSuccess("microsoft",
718
- claims["preferred_username"] ?: claims["email"],
719
- claims["name"], null,
720
- newIdToken, newAccessToken, null, null, expirationTime)
721
- } else {
722
- nativeOnLoginError("refresh_failed", "Microsoft token refresh failed")
723
774
  }
775
+ } finally {
776
+ connection.disconnect()
724
777
  }
725
778
  } catch (e: Exception) {
726
779
  withContext(Dispatchers.Main) {
727
- nativeOnLoginError("network_error", e.message)
780
+ nativeOnLoginError("silent", "network_error", e.message)
728
781
  }
729
782
  }
730
783
  }
@@ -747,46 +800,64 @@ object AuthAdapter {
747
800
  try {
748
801
  val url = java.net.URL(tokenUrl)
749
802
  val connection = url.openConnection() as java.net.HttpURLConnection
750
- connection.requestMethod = "POST"
751
- connection.setRequestProperty("Content-Type", "application/x-www-form-urlencoded")
752
- connection.doOutput = true
753
-
754
- val postData = buildString {
755
- append("client_id=${java.net.URLEncoder.encode(clientId, "UTF-8")}")
756
- append("&grant_type=refresh_token")
757
- append("&refresh_token=${java.net.URLEncoder.encode(refreshToken, "UTF-8")}")
758
- }
759
- connection.outputStream.use { it.write(postData.toByteArray()) }
803
+ try {
804
+ connection.connectTimeout = 15_000
805
+ connection.readTimeout = 15_000
806
+ connection.requestMethod = "POST"
807
+ connection.setRequestProperty("Content-Type", "application/x-www-form-urlencoded")
808
+ connection.doOutput = true
809
+
810
+ val postData = buildString {
811
+ append("client_id=${java.net.URLEncoder.encode(clientId, "UTF-8")}")
812
+ append("&grant_type=refresh_token")
813
+ append("&refresh_token=${java.net.URLEncoder.encode(refreshToken, "UTF-8")}")
814
+ }
815
+ connection.outputStream.use { it.write(postData.toByteArray()) }
760
816
 
761
- val responseCode = connection.responseCode
762
- val responseBody = if (responseCode == 200) {
763
- connection.inputStream.bufferedReader().readText()
764
- } else {
765
- connection.errorStream?.bufferedReader()?.readText() ?: ""
766
- }
817
+ val responseCode = connection.responseCode
818
+ val responseBody = if (responseCode == 200) {
819
+ connection.inputStream.bufferedReader().use { it.readText() }
820
+ } else {
821
+ connection.errorStream?.bufferedReader()?.use { it.readText() } ?: ""
822
+ }
767
823
 
768
- withContext(Dispatchers.Main) {
769
- if (responseCode == 200) {
770
- val json = JSONObject(responseBody)
771
- val newIdToken = json.optString("id_token")
772
- val newAccessToken = json.optString("access_token")
773
- val newRefreshToken = json.optString("refresh_token")
774
- val expiresIn = json.optLong("expires_in", 0)
775
- val expirationTime = if (expiresIn > 0) System.currentTimeMillis() + expiresIn * 1000 else null
776
-
777
- if (newRefreshToken.isNotEmpty()) inMemoryMicrosoftRefreshToken = newRefreshToken
778
- inMemoryMicrosoftScopes = pendingMicrosoftScopes.ifEmpty {
779
- listOf("openid", "email", "profile", "offline_access", "User.Read")
824
+ withContext(Dispatchers.Main) {
825
+ if (responseCode == 200) {
826
+ val json = JSONObject(responseBody)
827
+ val newIdToken = json.optString("id_token")
828
+ val newAccessToken = json.optString("access_token")
829
+ val newRefreshToken = json.optString("refresh_token")
830
+ val expiresIn = json.optLong("expires_in", 0)
831
+ val expirationTime = if (expiresIn > 0) System.currentTimeMillis() + expiresIn * 1000 else null
832
+
833
+ if (newRefreshToken.isNotEmpty()) inMemoryMicrosoftRefreshToken = newRefreshToken
834
+ inMemoryMicrosoftScopes = pendingMicrosoftScopes.ifEmpty {
835
+ listOf("openid", "email", "profile", "offline_access", "User.Read")
836
+ }
837
+
838
+ nativeOnRefreshSuccess(
839
+ newIdToken.ifEmpty { null },
840
+ newAccessToken.ifEmpty { null },
841
+ expirationTime
842
+ )
843
+ } else {
844
+ if (responseCode in 400..499) {
845
+ inMemoryMicrosoftRefreshToken = null
846
+ }
847
+ val errorBody = responseBody
848
+ val mappedError = try {
849
+ val json = org.json.JSONObject(errorBody)
850
+ val errorCode = json.optString("error", "token_error")
851
+ val errorDesc = json.optString("error_description", "Token refresh failed")
852
+ Pair(errorCode, errorDesc)
853
+ } catch (e: Exception) {
854
+ Pair("token_error", "Token refresh failed")
855
+ }
856
+ nativeOnRefreshError(mappedError.first, mappedError.second)
780
857
  }
781
-
782
- nativeOnRefreshSuccess(
783
- newIdToken.ifEmpty { null },
784
- newAccessToken.ifEmpty { null },
785
- expirationTime
786
- )
787
- } else {
788
- nativeOnRefreshError("refresh_failed", "Microsoft token refresh failed")
789
858
  }
859
+ } finally {
860
+ connection.disconnect()
790
861
  }
791
862
  } catch (e: Exception) {
792
863
  withContext(Dispatchers.Main) {