react-native-nitro-auth 0.5.4 → 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 (100) hide show
  1. package/README.md +82 -47
  2. package/android/proguard-rules.pro +7 -1
  3. package/android/src/main/AndroidManifest.xml +12 -0
  4. package/android/src/main/cpp/JniOnLoad.cpp +3 -1
  5. package/android/src/main/cpp/PlatformAuth+Android.cpp +271 -78
  6. package/android/src/main/java/com/auth/AuthAdapter.kt +293 -238
  7. package/android/src/main/java/com/auth/GoogleSignInActivity.kt +9 -5
  8. package/android/src/main/java/com/auth/NitroAuthModule.kt +8 -1
  9. package/cpp/HybridAuth.cpp +79 -64
  10. package/cpp/HybridAuth.hpp +9 -7
  11. package/cpp/JSONSerializer.hpp +3 -0
  12. package/ios/AuthAdapter.swift +226 -79
  13. package/ios/PlatformAuth+iOS.mm +10 -3
  14. package/lib/commonjs/Auth.web.js +50 -10
  15. package/lib/commonjs/Auth.web.js.map +1 -1
  16. package/lib/commonjs/index.js +23 -1
  17. package/lib/commonjs/index.js.map +1 -1
  18. package/lib/commonjs/index.web.js +30 -12
  19. package/lib/commonjs/index.web.js.map +1 -1
  20. package/lib/commonjs/service.js +36 -9
  21. package/lib/commonjs/service.js.map +1 -1
  22. package/lib/commonjs/service.web.js +65 -13
  23. package/lib/commonjs/service.web.js.map +1 -1
  24. package/lib/commonjs/ui/social-button.js +19 -14
  25. package/lib/commonjs/ui/social-button.js.map +1 -1
  26. package/lib/commonjs/ui/social-button.web.js +16 -10
  27. package/lib/commonjs/ui/social-button.web.js.map +1 -1
  28. package/lib/commonjs/use-auth.js +22 -25
  29. package/lib/commonjs/use-auth.js.map +1 -1
  30. package/lib/commonjs/utils/auth-error.js +37 -0
  31. package/lib/commonjs/utils/auth-error.js.map +1 -0
  32. package/lib/commonjs/utils/logger.js +1 -0
  33. package/lib/commonjs/utils/logger.js.map +1 -1
  34. package/lib/module/Auth.web.js +50 -10
  35. package/lib/module/Auth.web.js.map +1 -1
  36. package/lib/module/global.d.js.map +1 -1
  37. package/lib/module/index.js +1 -0
  38. package/lib/module/index.js.map +1 -1
  39. package/lib/module/index.web.js +2 -1
  40. package/lib/module/index.web.js.map +1 -1
  41. package/lib/module/service.js +36 -9
  42. package/lib/module/service.js.map +1 -1
  43. package/lib/module/service.web.js +65 -13
  44. package/lib/module/service.web.js.map +1 -1
  45. package/lib/module/ui/social-button.js +19 -14
  46. package/lib/module/ui/social-button.js.map +1 -1
  47. package/lib/module/ui/social-button.web.js +16 -10
  48. package/lib/module/ui/social-button.web.js.map +1 -1
  49. package/lib/module/use-auth.js +22 -25
  50. package/lib/module/use-auth.js.map +1 -1
  51. package/lib/module/utils/auth-error.js +30 -0
  52. package/lib/module/utils/auth-error.js.map +1 -0
  53. package/lib/module/utils/logger.js +1 -0
  54. package/lib/module/utils/logger.js.map +1 -1
  55. package/lib/typescript/commonjs/Auth.web.d.ts +5 -1
  56. package/lib/typescript/commonjs/Auth.web.d.ts.map +1 -1
  57. package/lib/typescript/commonjs/index.d.ts +1 -0
  58. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  59. package/lib/typescript/commonjs/index.web.d.ts +2 -1
  60. package/lib/typescript/commonjs/index.web.d.ts.map +1 -1
  61. package/lib/typescript/commonjs/service.d.ts.map +1 -1
  62. package/lib/typescript/commonjs/service.web.d.ts +2 -18
  63. package/lib/typescript/commonjs/service.web.d.ts.map +1 -1
  64. package/lib/typescript/commonjs/ui/social-button.d.ts.map +1 -1
  65. package/lib/typescript/commonjs/ui/social-button.web.d.ts.map +1 -1
  66. package/lib/typescript/commonjs/use-auth.d.ts +2 -1
  67. package/lib/typescript/commonjs/use-auth.d.ts.map +1 -1
  68. package/lib/typescript/commonjs/utils/auth-error.d.ts +16 -0
  69. package/lib/typescript/commonjs/utils/auth-error.d.ts.map +1 -0
  70. package/lib/typescript/commonjs/utils/logger.d.ts.map +1 -1
  71. package/lib/typescript/module/Auth.web.d.ts +5 -1
  72. package/lib/typescript/module/Auth.web.d.ts.map +1 -1
  73. package/lib/typescript/module/index.d.ts +1 -0
  74. package/lib/typescript/module/index.d.ts.map +1 -1
  75. package/lib/typescript/module/index.web.d.ts +2 -1
  76. package/lib/typescript/module/index.web.d.ts.map +1 -1
  77. package/lib/typescript/module/service.d.ts.map +1 -1
  78. package/lib/typescript/module/service.web.d.ts +2 -18
  79. package/lib/typescript/module/service.web.d.ts.map +1 -1
  80. package/lib/typescript/module/ui/social-button.d.ts.map +1 -1
  81. package/lib/typescript/module/ui/social-button.web.d.ts.map +1 -1
  82. package/lib/typescript/module/use-auth.d.ts +2 -1
  83. package/lib/typescript/module/use-auth.d.ts.map +1 -1
  84. package/lib/typescript/module/utils/auth-error.d.ts +16 -0
  85. package/lib/typescript/module/utils/auth-error.d.ts.map +1 -0
  86. package/lib/typescript/module/utils/logger.d.ts.map +1 -1
  87. package/nitrogen/generated/android/NitroAuthOnLoad.cpp +22 -17
  88. package/nitrogen/generated/android/NitroAuthOnLoad.hpp +13 -4
  89. package/package.json +8 -10
  90. package/src/Auth.web.ts +77 -11
  91. package/src/global.d.ts +0 -11
  92. package/src/index.ts +5 -0
  93. package/src/index.web.ts +6 -1
  94. package/src/service.ts +37 -9
  95. package/src/service.web.ts +84 -15
  96. package/src/ui/social-button.tsx +21 -9
  97. package/src/ui/social-button.web.tsx +17 -4
  98. package/src/use-auth.ts +29 -67
  99. package/src/utils/auth-error.ts +49 -0
  100. package/src/utils/logger.ts +1 -0
@@ -1,37 +1,46 @@
1
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.
2
7
 
3
8
  package com.auth
4
9
 
10
+ import android.app.Activity
11
+ import android.app.Application
5
12
  import android.content.Context
13
+ import android.content.Intent
14
+ import android.net.Uri
6
15
  import android.os.Bundle
16
+ import android.util.Base64
7
17
  import android.util.Log
18
+ import androidx.browser.customtabs.CustomTabsIntent
19
+ import androidx.credentials.ClearCredentialStateRequest
20
+ import androidx.credentials.CredentialManager
21
+ import androidx.credentials.GetCredentialRequest
22
+ import androidx.credentials.GetCredentialResponse
8
23
  import com.google.android.gms.auth.api.signin.GoogleSignIn
9
24
  import com.google.android.gms.auth.api.signin.GoogleSignInAccount
10
25
  import com.google.android.gms.auth.api.signin.GoogleSignInClient
11
26
  import com.google.android.gms.auth.api.signin.GoogleSignInOptions
12
- import com.google.android.gms.common.GoogleApiAvailability
13
27
  import com.google.android.gms.common.ConnectionResult
28
+ import com.google.android.gms.common.GoogleApiAvailability
14
29
  import com.google.android.gms.common.api.Scope
15
- import androidx.credentials.CredentialManager
16
- import androidx.credentials.GetCredentialRequest
17
- import androidx.credentials.GetCredentialResponse
18
30
  import com.google.android.libraries.identity.googleid.GetGoogleIdOption
19
31
  import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential
20
32
  import kotlinx.coroutines.CoroutineScope
21
33
  import kotlinx.coroutines.Dispatchers
34
+ import kotlinx.coroutines.SupervisorJob
35
+ import kotlinx.coroutines.cancel
22
36
  import kotlinx.coroutines.launch
23
- import android.app.Activity
24
- import android.app.Application
25
- import android.content.Intent
26
- import android.net.Uri
27
- import androidx.browser.customtabs.CustomTabsIntent
28
- import java.util.UUID
37
+ import kotlinx.coroutines.withContext
29
38
  import org.json.JSONObject
30
- import android.util.Base64
39
+ import java.util.UUID
31
40
 
32
41
  object AuthAdapter {
33
42
  private const val TAG = "AuthAdapter"
34
-
43
+
35
44
  @Volatile
36
45
  private var isInitialized = false
37
46
 
@@ -41,36 +50,50 @@ object AuthAdapter {
41
50
  private var lifecycleCallbacks: Application.ActivityLifecycleCallbacks? = null
42
51
  private var pendingScopes: List<String> = emptyList()
43
52
  private var pendingMicrosoftScopes: List<String> = emptyList()
44
-
53
+
54
+ @Volatile
55
+ private var pendingOrigin: String = "login"
56
+ @Volatile
45
57
  private var pendingPkceVerifier: String? = null
58
+ @Volatile
46
59
  private var pendingState: String? = null
60
+ @Volatile
47
61
  private var pendingNonce: String? = null
62
+ @Volatile
48
63
  private var pendingMicrosoftTenant: String? = null
64
+ @Volatile
49
65
  private var pendingMicrosoftClientId: String? = null
66
+ @Volatile
50
67
  private var pendingMicrosoftB2cDomain: String? = null
51
-
68
+
69
+ @Volatile
52
70
  private var inMemoryMicrosoftRefreshToken: String? = null
71
+ @Volatile
53
72
  private var inMemoryMicrosoftScopes: List<String> =
54
73
  listOf("openid", "email", "profile", "offline_access", "User.Read")
55
74
 
75
+ // Module-scoped coroutine scope — cancelled on module invalidation via dispose().
76
+ private var moduleScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
77
+
56
78
  @JvmStatic
57
79
  private external fun nativeInitialize(context: Context)
58
-
80
+
59
81
  @JvmStatic
60
82
  private external fun nativeOnLoginSuccess(
61
- provider: String,
62
- email: String?,
63
- name: String?,
64
- photo: String?,
83
+ origin: String,
84
+ provider: String,
85
+ email: String?,
86
+ name: String?,
87
+ photo: String?,
65
88
  idToken: String?,
66
89
  accessToken: String?,
67
90
  serverAuthCode: String?,
68
91
  scopes: Array<String>?,
69
92
  expirationTime: Long?
70
93
  )
71
-
94
+
72
95
  @JvmStatic
73
- private external fun nativeOnLoginError(error: String, underlyingError: String?)
96
+ private external fun nativeOnLoginError(origin: String, error: String, underlyingError: String?)
74
97
 
75
98
  @JvmStatic
76
99
  private external fun nativeOnRefreshSuccess(idToken: String?, accessToken: String?, expirationTime: Long?)
@@ -80,9 +103,7 @@ object AuthAdapter {
80
103
 
81
104
  @Synchronized
82
105
  fun initialize(context: Context) {
83
- if (isInitialized) {
84
- return
85
- }
106
+ if (isInitialized) return
86
107
 
87
108
  val applicationContext = context.applicationContext
88
109
  appContext = applicationContext
@@ -102,29 +123,45 @@ object AuthAdapter {
102
123
  }
103
124
 
104
125
  try {
105
- System.loadLibrary("NitroAuth")
126
+ // The native library is already loaded by NitroAuthOnLoad.initializeNative()
127
+ // before this method is called from NitroAuthModule. We only need to wire
128
+ // the Android context so that native methods can call back into the JVM.
106
129
  nativeInitialize(applicationContext)
107
130
  isInitialized = true
108
131
  } catch (e: Exception) {
109
- Log.e(TAG, "Failed to load NitroAuth library", e)
132
+ Log.e(TAG, "Failed to initialize NitroAuth native bridge", e)
110
133
  }
111
134
  }
112
135
 
113
- fun onSignInSuccess(account: GoogleSignInAccount, scopes: List<String>) {
136
+ fun dispose() {
137
+ moduleScope.cancel()
138
+ moduleScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
139
+
140
+ val app = appContext as? Application
141
+ lifecycleCallbacks?.let { app?.unregisterActivityLifecycleCallbacks(it) }
142
+ lifecycleCallbacks = null
143
+ currentActivity = null
144
+ appContext = null
145
+ googleSignInClient = null
146
+ isInitialized = false
147
+ }
148
+
149
+ fun onSignInSuccess(account: GoogleSignInAccount, scopes: List<String>, origin: String = "login") {
114
150
  appContext ?: return
115
151
  val expirationTime = getJwtExpirationTimeMs(account.idToken)
116
- nativeOnLoginSuccess("google", account.email, account.displayName,
117
- account.photoUrl?.toString(), account.idToken, null, account.serverAuthCode, scopes.toTypedArray(), expirationTime)
152
+ nativeOnLoginSuccess(origin, "google", account.email, account.displayName,
153
+ account.photoUrl?.toString(), account.idToken, null, account.serverAuthCode,
154
+ scopes.toTypedArray(), expirationTime)
118
155
  }
119
156
 
120
- fun onSignInError(errorCode: Int, message: String?) {
157
+ fun onSignInError(errorCode: Int, message: String?, origin: String = "login") {
121
158
  val mappedError = when (errorCode) {
122
159
  12501 -> "cancelled"
123
160
  7 -> "network_error"
124
161
  8, 10 -> "configuration_error"
125
162
  else -> "unknown"
126
163
  }
127
- nativeOnLoginError(mappedError, message)
164
+ nativeOnLoginError(origin, mappedError, message)
128
165
  }
129
166
 
130
167
  @JvmStatic
@@ -141,24 +178,22 @@ object AuthAdapter {
141
178
  prompt: String? = null
142
179
  ) {
143
180
  if (provider == "apple") {
144
- nativeOnLoginError("unsupported_provider", "Apple Sign-In is not supported on Android.")
181
+ nativeOnLoginError("login", "unsupported_provider", "Apple Sign-In is not supported on Android.")
145
182
  return
146
183
  }
147
-
148
184
  if (provider == "microsoft") {
149
- loginMicrosoft(context, scopes, loginHint, tenant, prompt)
185
+ loginMicrosoft(context, scopes, loginHint, tenant, prompt, "login")
150
186
  return
151
187
  }
152
-
153
188
  if (provider != "google") {
154
- nativeOnLoginError("unsupported_provider", "Unsupported provider: $provider")
189
+ nativeOnLoginError("login", "unsupported_provider", "Unsupported provider: $provider")
155
190
  return
156
191
  }
157
192
 
158
193
  val ctx = appContext ?: context.applicationContext
159
194
  val clientId = googleClientId ?: getClientIdFromResources(ctx)
160
195
  if (clientId == null) {
161
- 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.")
162
197
  return
163
198
  }
164
199
 
@@ -166,20 +201,20 @@ object AuthAdapter {
166
201
  pendingScopes = requestedScopes
167
202
 
168
203
  if (useLegacyGoogleSignIn) {
169
- loginLegacy(context, clientId, requestedScopes, loginHint, forceAccountPicker)
204
+ loginLegacy(context, clientId, requestedScopes, loginHint, forceAccountPicker, "login")
170
205
  return
171
206
  }
172
-
173
- loginOneTap(context, clientId, requestedScopes, loginHint, forceAccountPicker, useOneTap)
207
+ loginOneTap(context, clientId, requestedScopes, loginHint, forceAccountPicker, useOneTap, "login")
174
208
  }
175
209
 
176
- 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") {
177
211
  val ctx = appContext ?: context.applicationContext
178
212
  val clientId = getMicrosoftClientIdFromResources(ctx)
179
213
  if (clientId == null) {
180
- 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.")
181
215
  return
182
216
  }
217
+ pendingOrigin = origin
183
218
 
184
219
  val effectiveTenant = tenant ?: getMicrosoftTenantFromResources(ctx) ?: "common"
185
220
  val effectiveScopes = scopes?.toList() ?: listOf("openid", "email", "profile", "offline_access", "User.Read")
@@ -226,7 +261,7 @@ object AuthAdapter {
226
261
  ctx.startActivity(browserIntent)
227
262
  }
228
263
  } catch (e: Exception) {
229
- nativeOnLoginError("unknown", e.message)
264
+ nativeOnLoginError(origin, "unknown", e.message)
230
265
  }
231
266
  }
232
267
 
@@ -248,24 +283,22 @@ object AuthAdapter {
248
283
  val error = uri.getQueryParameter("error")
249
284
  val errorDescription = uri.getQueryParameter("error_description")
250
285
 
286
+ val origin = pendingOrigin
251
287
  if (error != null) {
252
288
  clearPkceState()
253
- nativeOnLoginError(error, errorDescription)
289
+ nativeOnLoginError(origin, error, errorDescription)
254
290
  return
255
291
  }
256
-
257
292
  if (state != pendingState) {
258
293
  clearPkceState()
259
- nativeOnLoginError("invalid_state", "State mismatch - possible CSRF attack")
294
+ nativeOnLoginError(origin, "invalid_state", "State mismatch - possible CSRF attack")
260
295
  return
261
296
  }
262
-
263
297
  if (code == null) {
264
298
  clearPkceState()
265
- nativeOnLoginError("unknown", "No authorization code in response")
299
+ nativeOnLoginError(origin, "unknown", "No authorization code in response")
266
300
  return
267
301
  }
268
-
269
302
  exchangeCodeForTokens(code)
270
303
  }
271
304
 
@@ -274,10 +307,11 @@ object AuthAdapter {
274
307
  val clientId = pendingMicrosoftClientId
275
308
  val tenant = pendingMicrosoftTenant
276
309
  val verifier = pendingPkceVerifier
310
+ val origin = pendingOrigin
277
311
 
278
312
  if (ctx == null || clientId == null || tenant == null || verifier == null) {
279
313
  clearPkceState()
280
- nativeOnLoginError("invalid_state", "Missing PKCE state for token exchange")
314
+ nativeOnLoginError(origin, "invalid_state", "Missing PKCE state for token exchange")
281
315
  return
282
316
  }
283
317
 
@@ -285,54 +319,59 @@ object AuthAdapter {
285
319
  val authBaseUrl = getMicrosoftAuthBaseUrl(tenant, pendingMicrosoftB2cDomain)
286
320
  val tokenUrl = "${authBaseUrl}oauth2/v2.0/token"
287
321
 
288
- CoroutineScope(Dispatchers.IO).launch {
322
+ moduleScope.launch {
289
323
  try {
290
324
  val url = java.net.URL(tokenUrl)
291
325
  val connection = url.openConnection() as java.net.HttpURLConnection
292
- connection.requestMethod = "POST"
293
- connection.setRequestProperty("Content-Type", "application/x-www-form-urlencoded")
294
- connection.doOutput = true
295
-
296
- val postData = buildString {
297
- append("client_id=${java.net.URLEncoder.encode(clientId, "UTF-8")}")
298
- append("&code=${java.net.URLEncoder.encode(code, "UTF-8")}")
299
- append("&redirect_uri=${java.net.URLEncoder.encode(redirectUri, "UTF-8")}")
300
- append("&grant_type=authorization_code")
301
- append("&code_verifier=${java.net.URLEncoder.encode(verifier, "UTF-8")}")
302
- }
303
-
304
- 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()) }
305
341
 
306
- val responseCode = connection.responseCode
307
- val responseBody = if (responseCode == 200) {
308
- connection.inputStream.bufferedReader().readText()
309
- } else {
310
- connection.errorStream?.bufferedReader()?.readText() ?: ""
311
- }
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
+ }
312
348
 
313
- CoroutineScope(Dispatchers.Main).launch {
314
- handleTokenResponse(responseCode, responseBody)
349
+ withContext(Dispatchers.Main) {
350
+ handleTokenResponse(responseCode, responseBody, origin)
351
+ }
352
+ } finally {
353
+ connection.disconnect()
315
354
  }
316
355
  } catch (e: Exception) {
317
- CoroutineScope(Dispatchers.Main).launch {
356
+ withContext(Dispatchers.Main) {
318
357
  clearPkceState()
319
- nativeOnLoginError("network_error", e.message)
358
+ nativeOnLoginError(origin, "network_error", e.message)
320
359
  }
321
360
  }
322
361
  }
323
362
  }
324
363
 
325
- private fun handleTokenResponse(responseCode: Int, responseBody: String) {
364
+ private fun handleTokenResponse(responseCode: Int, responseBody: String, origin: String) {
326
365
  if (responseCode != 200) {
327
366
  try {
328
367
  val json = JSONObject(responseBody)
329
368
  val error = json.optString("error", "token_error")
330
369
  val desc = json.optString("error_description", "Failed to exchange code for tokens")
331
370
  clearPkceState()
332
- nativeOnLoginError(error, desc)
371
+ nativeOnLoginError(origin, error, desc)
333
372
  } catch (e: Exception) {
334
373
  clearPkceState()
335
- nativeOnLoginError("token_error", "Failed to exchange code for tokens")
374
+ nativeOnLoginError(origin, "token_error", "Failed to exchange code for tokens")
336
375
  }
337
376
  return
338
377
  }
@@ -347,55 +386,38 @@ object AuthAdapter {
347
386
 
348
387
  if (idToken.isEmpty()) {
349
388
  clearPkceState()
350
- nativeOnLoginError("no_id_token", "No id_token in token response")
389
+ nativeOnLoginError(origin, "no_id_token", "No id_token in token response")
351
390
  return
352
391
  }
353
392
 
354
393
  val claims = decodeJwt(idToken)
355
- val tokenNonce = claims["nonce"]
356
- if (tokenNonce != pendingNonce) {
394
+ if (claims["nonce"] != pendingNonce) {
357
395
  clearPkceState()
358
- nativeOnLoginError("invalid_nonce", "Nonce mismatch - token may be replayed")
396
+ nativeOnLoginError(origin, "invalid_nonce", "Nonce mismatch - token may be replayed")
359
397
  return
360
398
  }
361
399
 
362
400
  val email = claims["preferred_username"] ?: claims["email"]
363
401
  val name = claims["name"]
364
402
 
365
- if (refreshToken.isNotEmpty()) {
366
- inMemoryMicrosoftRefreshToken = refreshToken
367
- }
403
+ if (refreshToken.isNotEmpty()) inMemoryMicrosoftRefreshToken = refreshToken
368
404
  inMemoryMicrosoftScopes = pendingMicrosoftScopes.ifEmpty {
369
405
  listOf("openid", "email", "profile", "offline_access", "User.Read")
370
406
  }
371
407
 
372
408
  clearPkceState()
373
409
  nativeOnLoginSuccess(
374
- "microsoft",
375
- email,
376
- name,
377
- null,
378
- idToken,
379
- accessToken,
380
- null,
381
- pendingMicrosoftScopes.toTypedArray(),
382
- expirationTime
410
+ origin, "microsoft", email, name, null, idToken, accessToken, null,
411
+ pendingMicrosoftScopes.toTypedArray(), expirationTime
383
412
  )
384
413
  } catch (e: Exception) {
385
414
  clearPkceState()
386
- nativeOnLoginError("parse_error", e.message)
415
+ nativeOnLoginError(origin, "parse_error", e.message)
387
416
  }
388
417
  }
389
418
 
390
- private fun saveMicrosoftRefreshToken(refreshToken: String) {
391
- inMemoryMicrosoftRefreshToken = refreshToken
392
- }
393
-
394
- private fun getMicrosoftRefreshToken(): String? {
395
- return inMemoryMicrosoftRefreshToken
396
- }
397
-
398
419
  private fun clearPkceState() {
420
+ pendingOrigin = "login"
399
421
  pendingPkceVerifier = null
400
422
  pendingState = null
401
423
  pendingNonce = null
@@ -417,15 +439,13 @@ object AuthAdapter {
417
439
  }
418
440
  result
419
441
  } catch (e: Exception) {
442
+ Log.w(TAG, "Failed to decode JWT: ${e.message}")
420
443
  emptyMap()
421
444
  }
422
445
  }
423
446
 
424
447
  private fun getJwtExpirationTimeMs(idToken: String?): Long? {
425
- if (idToken.isNullOrEmpty()) {
426
- return null
427
- }
428
-
448
+ if (idToken.isNullOrEmpty()) return null
429
449
  val expSeconds = decodeJwt(idToken)["exp"]?.toLongOrNull() ?: return null
430
450
  return expSeconds * 1000
431
451
  }
@@ -449,7 +469,6 @@ object AuthAdapter {
449
469
  if (tenant.startsWith("https://")) {
450
470
  return if (tenant.endsWith("/")) tenant else "$tenant/"
451
471
  }
452
-
453
472
  return if (b2cDomain != null) {
454
473
  "https://$b2cDomain/tfp/$tenant/"
455
474
  } else {
@@ -463,14 +482,15 @@ object AuthAdapter {
463
482
  scopes: List<String>,
464
483
  loginHint: String?,
465
484
  forceAccountPicker: Boolean,
466
- useOneTap: Boolean
485
+ useOneTap: Boolean,
486
+ origin: String = "login"
467
487
  ) {
468
488
  val activity = currentActivity ?: context as? Activity
469
489
  if (activity == null) {
470
490
  Log.w(TAG, "No Activity context available for One-Tap, falling back to legacy")
471
- return loginLegacy(context, clientId, scopes, loginHint, forceAccountPicker)
491
+ return loginLegacy(context, clientId, scopes, loginHint, forceAccountPicker, origin)
472
492
  }
473
-
493
+
474
494
  val credentialManager = CredentialManager.create(activity)
475
495
  val googleIdOption = GetGoogleIdOption.Builder()
476
496
  .setFilterByAuthorizedAccounts(false)
@@ -482,13 +502,13 @@ object AuthAdapter {
482
502
  .addCredentialOption(googleIdOption)
483
503
  .build()
484
504
 
485
- CoroutineScope(Dispatchers.Main).launch {
505
+ moduleScope.launch(Dispatchers.Main) {
486
506
  try {
487
507
  val result = credentialManager.getCredential(context = activity, request = request)
488
- handleCredentialResponse(result, scopes)
508
+ handleCredentialResponse(result, scopes, origin)
489
509
  } catch (e: Exception) {
490
510
  Log.w(TAG, "One-Tap failed, falling back to legacy: ${e.message}")
491
- loginLegacy(context, clientId, scopes, loginHint, forceAccountPicker)
511
+ loginLegacy(context, clientId, scopes, loginHint, forceAccountPicker, origin)
492
512
  }
493
513
  }
494
514
  }
@@ -498,21 +518,18 @@ object AuthAdapter {
498
518
  clientId: String,
499
519
  scopes: List<String>,
500
520
  loginHint: String?,
501
- forceAccountPicker: Boolean
521
+ forceAccountPicker: Boolean,
522
+ origin: String = "login"
502
523
  ) {
503
524
  val ctx = appContext ?: context.applicationContext
504
525
  val intent = GoogleSignInActivity.createIntent(
505
- ctx,
506
- clientId,
507
- scopes.toTypedArray(),
508
- loginHint,
509
- forceAccountPicker
526
+ ctx, clientId, scopes.toTypedArray(), loginHint, forceAccountPicker, origin
510
527
  )
511
528
  intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
512
529
  ctx.startActivity(intent)
513
530
  }
514
531
 
515
- private fun handleCredentialResponse(response: GetCredentialResponse, scopes: List<String>) {
532
+ private fun handleCredentialResponse(response: GetCredentialResponse, scopes: List<String>, origin: String) {
516
533
  val credential = response.credential
517
534
  val googleIdTokenCredential = try {
518
535
  if (credential is GoogleIdTokenCredential) {
@@ -530,22 +547,23 @@ object AuthAdapter {
530
547
  if (googleIdTokenCredential != null) {
531
548
  val expirationTime = getJwtExpirationTimeMs(googleIdTokenCredential.idToken)
532
549
  nativeOnLoginSuccess(
533
- "google",
550
+ origin, "google",
534
551
  googleIdTokenCredential.id,
535
552
  googleIdTokenCredential.displayName,
536
553
  googleIdTokenCredential.profilePictureUri?.toString(),
537
554
  googleIdTokenCredential.idToken,
538
- null,
539
- null,
555
+ null, null,
540
556
  scopes.toTypedArray(),
541
557
  expirationTime
542
558
  )
543
559
  } else {
544
560
  Log.w(TAG, "Unsupported credential type: ${credential.type}")
545
- nativeOnLoginError("unknown", "Unsupported credential type: ${credential.type}")
561
+ nativeOnLoginError(origin, "unknown", "Unsupported credential type: ${credential.type}")
546
562
  }
547
563
  }
548
564
 
565
+ // requestScopesSync uses the legacy GoogleSignIn API to check the last signed-in account
566
+ // because Credential Manager has no equivalent for querying existing account state.
549
567
  @JvmStatic
550
568
  fun requestScopesSync(context: Context, scopes: Array<String>) {
551
569
  val ctx = appContext ?: context.applicationContext
@@ -553,28 +571,30 @@ object AuthAdapter {
553
571
  if (account != null) {
554
572
  val newScopes = scopes.map { Scope(it) }
555
573
  if (GoogleSignIn.hasPermissions(account, *newScopes.toTypedArray())) {
556
- onSignInSuccess(account, (pendingScopes + scopes.toList()).distinct())
574
+ onSignInSuccess(account, (pendingScopes + scopes.toList()).distinct(), "scopes")
557
575
  return
558
576
  }
559
577
  val clientId = getClientIdFromResources(ctx)
560
578
  if (clientId == null) {
561
- nativeOnLoginError("configuration_error", "Google Client ID not configured")
579
+ nativeOnLoginError("scopes", "configuration_error", "Google Client ID not configured")
562
580
  return
563
581
  }
564
582
  val allScopes = (pendingScopes + scopes.toList()).distinct()
565
- val intent = GoogleSignInActivity.createIntent(ctx, clientId, allScopes.toTypedArray(), account.email)
583
+ val intent = GoogleSignInActivity.createIntent(ctx, clientId, allScopes.toTypedArray(), account.email, origin = "scopes")
566
584
  ctx.startActivity(intent)
567
585
  return
568
586
  }
569
587
  if (inMemoryMicrosoftRefreshToken != null) {
570
588
  val mergedScopes = (inMemoryMicrosoftScopes + scopes.toList()).distinct()
571
589
  val tenant = getMicrosoftTenantFromResources(ctx)
572
- loginMicrosoft(ctx, mergedScopes.toTypedArray(), null, tenant, null)
590
+ loginMicrosoft(ctx, mergedScopes.toTypedArray(), null, tenant, null, "scopes")
573
591
  return
574
592
  }
575
- nativeOnLoginError("unknown", "No user logged in")
593
+ nativeOnLoginError("scopes", "unknown", "No user logged in")
576
594
  }
577
595
 
596
+ // refreshTokenSync uses the legacy silentSignIn because AuthorizationClient (the recommended
597
+ // replacement) requires an Activity context which is not always available at refresh time.
578
598
  @JvmStatic
579
599
  fun refreshTokenSync(context: Context) {
580
600
  val ctx = appContext ?: context.applicationContext
@@ -596,18 +616,14 @@ object AuthAdapter {
596
616
  googleSignInClient!!.silentSignIn().addOnCompleteListener { task ->
597
617
  if (task.isSuccessful) {
598
618
  val acc = task.result
599
- nativeOnRefreshSuccess(
600
- acc?.idToken,
601
- null,
602
- getJwtExpirationTimeMs(acc?.idToken),
603
- )
619
+ nativeOnRefreshSuccess(acc?.idToken, null, getJwtExpirationTimeMs(acc?.idToken))
604
620
  } else {
605
621
  nativeOnRefreshError("network_error", task.exception?.message ?: "Silent sign-in failed")
606
622
  }
607
623
  }
608
624
  return
609
625
  }
610
- val refreshToken = getMicrosoftRefreshToken()
626
+ val refreshToken = inMemoryMicrosoftRefreshToken
611
627
  if (refreshToken != null) {
612
628
  refreshMicrosoftTokenForRefresh(ctx, refreshToken)
613
629
  return
@@ -617,22 +633,29 @@ object AuthAdapter {
617
633
 
618
634
  @JvmStatic
619
635
  fun hasPlayServices(context: Context): Boolean {
620
- val ctx = context.applicationContext ?: appContext ?: return false
621
- val availability = GoogleApiAvailability.getInstance()
622
- val result = availability.isGooglePlayServicesAvailable(ctx)
623
- return result == ConnectionResult.SUCCESS
636
+ val ctx = appContext ?: context.applicationContext ?: return false
637
+ return GoogleApiAvailability.getInstance()
638
+ .isGooglePlayServicesAvailable(ctx) == ConnectionResult.SUCCESS
624
639
  }
625
640
 
641
+ // revokeAccessSync uses the legacy GoogleSignIn client because Credential Manager has no
642
+ // equivalent revoke API for the Google ID token flow.
626
643
  @JvmStatic
627
644
  fun logoutSync(context: Context) {
628
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.
629
655
  val clientId = getClientIdFromResources(ctx)
630
656
  if (clientId != null) {
631
657
  val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
632
- .requestIdToken(clientId)
633
- .requestServerAuthCode(clientId)
634
- .requestEmail()
635
- .build()
658
+ .requestIdToken(clientId).requestEmail().build()
636
659
  GoogleSignIn.getClient(ctx, gso).signOut()
637
660
  }
638
661
  inMemoryMicrosoftRefreshToken = null
@@ -645,10 +668,7 @@ object AuthAdapter {
645
668
  val clientId = getClientIdFromResources(ctx)
646
669
  if (clientId != null) {
647
670
  val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
648
- .requestIdToken(clientId)
649
- .requestServerAuthCode(clientId)
650
- .requestEmail()
651
- .build()
671
+ .requestIdToken(clientId).requestServerAuthCode(clientId).requestEmail().build()
652
672
  GoogleSignIn.getClient(ctx, gso).revokeAccess()
653
673
  }
654
674
  inMemoryMicrosoftRefreshToken = null
@@ -662,19 +682,20 @@ object AuthAdapter {
662
682
 
663
683
  @JvmStatic
664
684
  fun restoreSession(context: Context) {
665
- val ctx = context.applicationContext ?: appContext ?: context
685
+ val ctx = appContext ?: context.applicationContext ?: return
686
+ @Suppress("DEPRECATION")
666
687
  val account = GoogleSignIn.getLastSignedInAccount(ctx)
667
688
  if (account != null) {
668
689
  val expirationTime = getJwtExpirationTimeMs(account.idToken)
669
- nativeOnLoginSuccess("google", account.email, account.displayName,
670
- account.photoUrl?.toString(), account.idToken, null, account.serverAuthCode,
671
- account.grantedScopes?.map { it.scopeUri }?.toTypedArray(), expirationTime)
690
+ nativeOnLoginSuccess("silent", "google", account.email, account.displayName,
691
+ account.photoUrl?.toString(), account.idToken, null, account.serverAuthCode,
692
+ account.grantedScopes?.map { it.scopeUri }?.toTypedArray(), expirationTime)
672
693
  } else {
673
- val refreshToken = getMicrosoftRefreshToken()
694
+ val refreshToken = inMemoryMicrosoftRefreshToken
674
695
  if (refreshToken != null) {
675
696
  refreshMicrosoftToken(ctx, refreshToken)
676
697
  } else {
677
- nativeOnLoginError("unknown", "No session")
698
+ nativeOnLoginError("silent", "unknown", "No session")
678
699
  }
679
700
  }
680
701
  }
@@ -683,66 +704,80 @@ object AuthAdapter {
683
704
  val clientId = getMicrosoftClientIdFromResources(context)
684
705
  val tenant = getMicrosoftTenantFromResources(context) ?: "common"
685
706
  val b2cDomain = getMicrosoftB2cDomainFromResources(context)
686
-
707
+
687
708
  if (clientId == null) {
688
- nativeOnLoginError("configuration_error", "Microsoft Client ID is required for refresh")
709
+ nativeOnLoginError("silent", "configuration_error", "Microsoft Client ID is required for refresh")
689
710
  return
690
711
  }
691
712
 
692
713
  val authBaseUrl = getMicrosoftAuthBaseUrl(tenant, b2cDomain)
693
714
  val tokenUrl = "${authBaseUrl}oauth2/v2.0/token"
694
715
 
695
- CoroutineScope(Dispatchers.IO).launch {
716
+ moduleScope.launch {
696
717
  try {
697
718
  val url = java.net.URL(tokenUrl)
698
719
  val connection = url.openConnection() as java.net.HttpURLConnection
699
- connection.requestMethod = "POST"
700
- connection.setRequestProperty("Content-Type", "application/x-www-form-urlencoded")
701
- connection.doOutput = true
702
-
703
- val postData = buildString {
704
- append("client_id=${java.net.URLEncoder.encode(clientId, "UTF-8")}")
705
- append("&grant_type=refresh_token")
706
- append("&refresh_token=${java.net.URLEncoder.encode(refreshToken, "UTF-8")}")
707
- }
708
-
709
- 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()) }
710
733
 
711
- val responseCode = connection.responseCode
712
- val responseBody = if (responseCode == 200) {
713
- connection.inputStream.bufferedReader().readText()
714
- } else {
715
- connection.errorStream?.bufferedReader()?.readText() ?: ""
716
- }
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
+ }
717
740
 
718
- CoroutineScope(Dispatchers.Main).launch {
719
- if (responseCode == 200) {
720
- val json = JSONObject(responseBody)
721
- val newIdToken = json.optString("id_token")
722
- val newAccessToken = json.optString("access_token")
723
- val newRefreshToken = json.optString("refresh_token")
724
- val expiresIn = json.optLong("expires_in", 0)
725
- val expirationTime = if (expiresIn > 0) System.currentTimeMillis() + expiresIn * 1000 else null
726
-
727
- val claims = decodeJwt(newIdToken)
728
- val email = claims["preferred_username"] ?: claims["email"]
729
- val name = claims["name"]
730
-
731
- if (newRefreshToken.isNotEmpty()) {
732
- saveMicrosoftRefreshToken(newRefreshToken)
733
- }
734
- inMemoryMicrosoftScopes = pendingMicrosoftScopes.ifEmpty {
735
- 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)
736
773
  }
737
-
738
- nativeOnLoginSuccess("microsoft", email, name, null, newIdToken, newAccessToken, null, null, expirationTime)
739
- } else {
740
- nativeOnLoginError("refresh_failed", "Microsoft token refresh failed")
741
774
  }
775
+ } finally {
776
+ connection.disconnect()
742
777
  }
743
778
  } catch (e: Exception) {
744
- CoroutineScope(Dispatchers.Main).launch {
745
- nativeOnLoginError("network_error", e.message)
779
+ withContext(Dispatchers.Main) {
780
+ nativeOnLoginError("silent", "network_error", e.message)
746
781
  }
747
782
  }
748
783
  }
@@ -752,63 +787,83 @@ object AuthAdapter {
752
787
  val clientId = getMicrosoftClientIdFromResources(context)
753
788
  val tenant = getMicrosoftTenantFromResources(context) ?: "common"
754
789
  val b2cDomain = getMicrosoftB2cDomainFromResources(context)
790
+
755
791
  if (clientId == null) {
756
792
  nativeOnRefreshError("configuration_error", "Microsoft Client ID not configured")
757
793
  return
758
794
  }
795
+
759
796
  val authBaseUrl = getMicrosoftAuthBaseUrl(tenant, b2cDomain)
760
797
  val tokenUrl = "${authBaseUrl}oauth2/v2.0/token"
761
- CoroutineScope(Dispatchers.IO).launch {
798
+
799
+ moduleScope.launch {
762
800
  try {
763
801
  val url = java.net.URL(tokenUrl)
764
802
  val connection = url.openConnection() as java.net.HttpURLConnection
765
- connection.requestMethod = "POST"
766
- connection.setRequestProperty("Content-Type", "application/x-www-form-urlencoded")
767
- connection.doOutput = true
768
- val postData = buildString {
769
- append("client_id=${java.net.URLEncoder.encode(clientId, "UTF-8")}")
770
- append("&grant_type=refresh_token")
771
- append("&refresh_token=${java.net.URLEncoder.encode(refreshToken, "UTF-8")}")
772
- }
773
- connection.outputStream.use { it.write(postData.toByteArray()) }
774
- val responseCode = connection.responseCode
775
- val responseBody = if (responseCode == 200) {
776
- connection.inputStream.bufferedReader().readText()
777
- } else {
778
- connection.errorStream?.bufferedReader()?.readText() ?: ""
779
- }
780
- CoroutineScope(Dispatchers.Main).launch {
781
- if (responseCode == 200) {
782
- val json = JSONObject(responseBody)
783
- val newIdToken = json.optString("id_token")
784
- val newAccessToken = json.optString("access_token")
785
- val newRefreshToken = json.optString("refresh_token")
786
- val expiresIn = json.optLong("expires_in", 0)
787
- val expirationTime = if (expiresIn > 0) System.currentTimeMillis() + expiresIn * 1000 else null
788
- val claims = decodeJwt(newIdToken)
789
- val email = claims["preferred_username"] ?: claims["email"]
790
- val name = claims["name"]
791
- if (newRefreshToken.isNotEmpty()) {
792
- saveMicrosoftRefreshToken(newRefreshToken)
793
- }
794
- inMemoryMicrosoftScopes = pendingMicrosoftScopes.ifEmpty {
795
- listOf("openid", "email", "profile", "offline_access", "User.Read")
796
- }
797
- nativeOnRefreshSuccess(
798
- newIdToken.ifEmpty { null },
799
- newAccessToken.ifEmpty { null },
800
- expirationTime
801
- )
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()) }
816
+
817
+ val responseCode = connection.responseCode
818
+ val responseBody = if (responseCode == 200) {
819
+ connection.inputStream.bufferedReader().use { it.readText() }
802
820
  } else {
803
- nativeOnRefreshError("refresh_failed", "Microsoft token refresh failed")
821
+ connection.errorStream?.bufferedReader()?.use { it.readText() } ?: ""
822
+ }
823
+
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)
857
+ }
804
858
  }
859
+ } finally {
860
+ connection.disconnect()
805
861
  }
806
862
  } catch (e: Exception) {
807
- CoroutineScope(Dispatchers.Main).launch {
863
+ withContext(Dispatchers.Main) {
808
864
  nativeOnRefreshError("network_error", e.message)
809
865
  }
810
866
  }
811
867
  }
812
868
  }
813
-
814
869
  }