react-native-nitro-auth 0.5.5 → 0.5.7

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