react-native-nitro-auth 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/README.md +602 -57
  2. package/android/build.gradle +8 -2
  3. package/android/gradle.properties +2 -2
  4. package/android/src/main/cpp/JniOnLoad.cpp +1 -0
  5. package/android/src/main/cpp/PlatformAuth+Android.cpp +37 -4
  6. package/android/src/main/java/com/auth/AuthAdapter.kt +586 -69
  7. package/android/src/main/java/com/auth/GoogleSignInActivity.kt +17 -4
  8. package/android/src/main/java/com/auth/MicrosoftAuthActivity.kt +25 -0
  9. package/app.plugin.js +115 -5
  10. package/cpp/AuthCache.cpp +72 -19
  11. package/cpp/HybridAuth.cpp +20 -0
  12. package/cpp/HybridAuth.hpp +1 -0
  13. package/ios/AuthAdapter.swift +470 -53
  14. package/ios/KeychainStore.swift +43 -0
  15. package/ios/PlatformAuth+iOS.mm +34 -3
  16. package/lib/commonjs/Auth.web.js +262 -10
  17. package/lib/commonjs/Auth.web.js.map +1 -1
  18. package/lib/commonjs/index.js +7 -11
  19. package/lib/commonjs/index.js.map +1 -1
  20. package/lib/commonjs/service.js +130 -1
  21. package/lib/commonjs/service.js.map +1 -1
  22. package/lib/commonjs/service.web.js +32 -6
  23. package/lib/commonjs/service.web.js.map +1 -1
  24. package/lib/commonjs/ui/social-button.js +46 -8
  25. package/lib/commonjs/ui/social-button.js.map +1 -1
  26. package/lib/commonjs/ui/social-button.web.js +46 -8
  27. package/lib/commonjs/ui/social-button.web.js.map +1 -1
  28. package/lib/commonjs/use-auth.js +27 -2
  29. package/lib/commonjs/use-auth.js.map +1 -1
  30. package/lib/commonjs/utils/logger.js +1 -1
  31. package/lib/commonjs/utils/logger.js.map +1 -1
  32. package/lib/module/Auth.web.js +262 -10
  33. package/lib/module/Auth.web.js.map +1 -1
  34. package/lib/module/index.js +1 -1
  35. package/lib/module/index.js.map +1 -1
  36. package/lib/module/service.js +130 -1
  37. package/lib/module/service.js.map +1 -1
  38. package/lib/module/service.web.js +32 -1
  39. package/lib/module/service.web.js.map +1 -1
  40. package/lib/module/ui/social-button.js +47 -9
  41. package/lib/module/ui/social-button.js.map +1 -1
  42. package/lib/module/ui/social-button.web.js +47 -9
  43. package/lib/module/ui/social-button.web.js.map +1 -1
  44. package/lib/module/use-auth.js +27 -2
  45. package/lib/module/use-auth.js.map +1 -1
  46. package/lib/module/utils/logger.js +1 -1
  47. package/lib/module/utils/logger.js.map +1 -1
  48. package/lib/typescript/commonjs/Auth.nitro.d.ts +10 -2
  49. package/lib/typescript/commonjs/Auth.nitro.d.ts.map +1 -1
  50. package/lib/typescript/commonjs/Auth.web.d.ts +9 -1
  51. package/lib/typescript/commonjs/Auth.web.d.ts.map +1 -1
  52. package/lib/typescript/commonjs/AuthStorage.nitro.d.ts +7 -0
  53. package/lib/typescript/commonjs/AuthStorage.nitro.d.ts.map +1 -1
  54. package/lib/typescript/commonjs/index.d.ts +2 -2
  55. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  56. package/lib/typescript/commonjs/service.d.ts +8 -1
  57. package/lib/typescript/commonjs/service.d.ts.map +1 -1
  58. package/lib/typescript/commonjs/service.web.d.ts +25 -1
  59. package/lib/typescript/commonjs/service.web.d.ts.map +1 -1
  60. package/lib/typescript/commonjs/ui/social-button.d.ts.map +1 -1
  61. package/lib/typescript/commonjs/ui/social-button.web.d.ts.map +1 -1
  62. package/lib/typescript/commonjs/use-auth.d.ts +13 -8
  63. package/lib/typescript/commonjs/use-auth.d.ts.map +1 -1
  64. package/lib/typescript/commonjs/utils/logger.d.ts +5 -5
  65. package/lib/typescript/commonjs/utils/logger.d.ts.map +1 -1
  66. package/lib/typescript/module/Auth.nitro.d.ts +10 -2
  67. package/lib/typescript/module/Auth.nitro.d.ts.map +1 -1
  68. package/lib/typescript/module/Auth.web.d.ts +9 -1
  69. package/lib/typescript/module/Auth.web.d.ts.map +1 -1
  70. package/lib/typescript/module/AuthStorage.nitro.d.ts +7 -0
  71. package/lib/typescript/module/AuthStorage.nitro.d.ts.map +1 -1
  72. package/lib/typescript/module/index.d.ts +2 -2
  73. package/lib/typescript/module/index.d.ts.map +1 -1
  74. package/lib/typescript/module/service.d.ts +8 -1
  75. package/lib/typescript/module/service.d.ts.map +1 -1
  76. package/lib/typescript/module/service.web.d.ts +25 -1
  77. package/lib/typescript/module/service.web.d.ts.map +1 -1
  78. package/lib/typescript/module/ui/social-button.d.ts.map +1 -1
  79. package/lib/typescript/module/ui/social-button.web.d.ts.map +1 -1
  80. package/lib/typescript/module/use-auth.d.ts +13 -8
  81. package/lib/typescript/module/use-auth.d.ts.map +1 -1
  82. package/lib/typescript/module/utils/logger.d.ts +5 -5
  83. package/lib/typescript/module/utils/logger.d.ts.map +1 -1
  84. package/nitrogen/generated/shared/c++/AuthProvider.hpp +4 -0
  85. package/nitrogen/generated/shared/c++/HybridAuthSpec.cpp +1 -0
  86. package/nitrogen/generated/shared/c++/HybridAuthSpec.hpp +1 -0
  87. package/nitrogen/generated/shared/c++/LoginOptions.hpp +17 -3
  88. package/nitrogen/generated/shared/c++/MicrosoftPrompt.hpp +84 -0
  89. package/package.json +13 -10
  90. package/react-native-nitro-auth.podspec +4 -2
  91. package/src/Auth.nitro.ts +17 -2
  92. package/src/Auth.web.ts +388 -22
  93. package/src/AuthStorage.nitro.ts +11 -2
  94. package/src/index.ts +2 -2
  95. package/src/service.ts +168 -2
  96. package/src/service.web.ts +41 -1
  97. package/src/ui/social-button.tsx +34 -4
  98. package/src/ui/social-button.web.tsx +34 -4
  99. package/src/use-auth.ts +37 -3
  100. package/src/utils/logger.ts +5 -5
@@ -1,7 +1,9 @@
1
1
  package com.auth
2
2
 
3
3
  import android.content.Context
4
+ import android.content.SharedPreferences
4
5
  import android.os.Bundle
6
+ import android.os.Build
5
7
  import android.util.Log
6
8
  import com.google.android.gms.auth.api.signin.GoogleSignIn
7
9
  import com.google.android.gms.auth.api.signin.GoogleSignInAccount
@@ -20,15 +22,74 @@ import kotlinx.coroutines.Dispatchers
20
22
  import kotlinx.coroutines.launch
21
23
  import android.app.Activity
22
24
  import android.app.Application
25
+ import android.content.Intent
26
+ import android.net.Uri
27
+ import androidx.browser.customtabs.CustomTabsIntent
28
+ import androidx.security.crypto.EncryptedSharedPreferences
29
+ import androidx.security.crypto.MasterKeys
30
+ import java.util.UUID
31
+ import org.json.JSONArray
32
+ import org.json.JSONObject
33
+ import android.util.Base64
23
34
 
24
35
  object AuthAdapter {
25
36
  private const val TAG = "AuthAdapter"
26
37
  private const val PREF_NAME = "nitro_auth"
38
+ private const val SECURE_PREF_NAME = "nitro_auth_secure"
27
39
 
28
40
  private var appContext: Context? = null
29
41
  private var currentActivity: Activity? = null
30
42
  private var googleSignInClient: GoogleSignInClient? = null
31
43
  private var pendingScopes: List<String> = emptyList()
44
+ private var pendingMicrosoftScopes: List<String> = emptyList()
45
+
46
+ private var pendingPkceVerifier: String? = null
47
+ private var pendingState: String? = null
48
+ private var pendingNonce: String? = null
49
+ private var pendingMicrosoftTenant: String? = null
50
+ private var pendingMicrosoftClientId: String? = null
51
+ private var pendingMicrosoftB2cDomain: String? = null
52
+
53
+ private fun getPrefs(context: Context): SharedPreferences {
54
+ return try {
55
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
56
+ val masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC)
57
+ val securePrefs = EncryptedSharedPreferences.create(
58
+ SECURE_PREF_NAME,
59
+ masterKeyAlias,
60
+ context,
61
+ EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
62
+ EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
63
+ )
64
+ migrateLegacyPrefsIfNeeded(context, securePrefs)
65
+ securePrefs
66
+ } else {
67
+ context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
68
+ }
69
+ } catch (e: Exception) {
70
+ Log.w(TAG, "Failed to initialize encrypted storage, falling back to SharedPreferences", e)
71
+ context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
72
+ }
73
+ }
74
+
75
+ private fun migrateLegacyPrefsIfNeeded(context: Context, securePrefs: SharedPreferences) {
76
+ val legacyPrefs = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
77
+ if (legacyPrefs.all.isEmpty() || securePrefs.all.isNotEmpty()) {
78
+ return
79
+ }
80
+ val editor = securePrefs.edit()
81
+ for ((key, value) in legacyPrefs.all) {
82
+ when (value) {
83
+ is String -> editor.putString(key, value)
84
+ is Boolean -> editor.putBoolean(key, value)
85
+ is Int -> editor.putInt(key, value)
86
+ is Long -> editor.putLong(key, value)
87
+ is Float -> editor.putFloat(key, value)
88
+ }
89
+ }
90
+ editor.apply()
91
+ legacyPrefs.edit().clear().apply()
92
+ }
32
93
 
33
94
  @JvmStatic
34
95
  private external fun nativeInitialize(context: Context)
@@ -96,12 +157,17 @@ object AuthAdapter {
96
157
  }
97
158
 
98
159
  @JvmStatic
99
- fun loginSync(context: Context, provider: String, googleClientId: String?, scopes: Array<String>?, loginHint: String?, useOneTap: Boolean) {
160
+ fun loginSync(context: Context, provider: String, googleClientId: String?, scopes: Array<String>?, loginHint: String?, useOneTap: Boolean, forceAccountPicker: Boolean = false, tenant: String? = null, prompt: String? = null) {
100
161
  if (provider == "apple") {
101
162
  nativeOnLoginError("unsupported_provider", "Apple Sign-In is not supported on Android.")
102
163
  return
103
164
  }
104
165
 
166
+ if (provider == "microsoft") {
167
+ loginMicrosoft(context, scopes, loginHint, tenant, prompt)
168
+ return
169
+ }
170
+
105
171
  if (provider != "google") {
106
172
  nativeOnLoginError("unsupported_provider", "Unsupported provider: $provider")
107
173
  return
@@ -117,15 +183,293 @@ object AuthAdapter {
117
183
  val requestedScopes = scopes?.toList() ?: listOf("email", "profile")
118
184
  pendingScopes = requestedScopes
119
185
 
120
- if (useOneTap) {
186
+ if (useOneTap && !forceAccountPicker) {
121
187
  loginOneTap(context, clientId, requestedScopes)
122
188
  } else {
123
- val intent = GoogleSignInActivity.createIntent(ctx, clientId, requestedScopes.toTypedArray(), loginHint)
189
+ val intent = GoogleSignInActivity.createIntent(ctx, clientId, requestedScopes.toTypedArray(), loginHint, forceAccountPicker)
124
190
  intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
125
191
  ctx.startActivity(intent)
126
192
  }
127
193
  }
128
194
 
195
+ private fun loginMicrosoft(context: Context, scopes: Array<String>?, loginHint: String?, tenant: String?, prompt: String?) {
196
+ val ctx = appContext ?: context.applicationContext
197
+ val clientId = getMicrosoftClientIdFromResources(ctx)
198
+ if (clientId == null) {
199
+ nativeOnLoginError("configuration_error", "Microsoft Client ID is required. Set it in app.json plugins.")
200
+ return
201
+ }
202
+
203
+ val effectiveTenant = tenant ?: getMicrosoftTenantFromResources(ctx) ?: "common"
204
+ val effectiveScopes = scopes?.toList() ?: listOf("openid", "email", "profile", "offline_access", "User.Read")
205
+ val effectivePrompt = prompt ?: "select_account"
206
+ pendingMicrosoftScopes = effectiveScopes
207
+
208
+ val codeVerifier = generateCodeVerifier()
209
+ val codeChallenge = generateCodeChallenge(codeVerifier)
210
+ val state = UUID.randomUUID().toString()
211
+ val nonce = UUID.randomUUID().toString()
212
+ pendingPkceVerifier = codeVerifier
213
+ pendingState = state
214
+ pendingNonce = nonce
215
+ pendingMicrosoftTenant = effectiveTenant
216
+ pendingMicrosoftClientId = clientId
217
+
218
+ val b2cDomain = getMicrosoftB2cDomainFromResources(ctx)
219
+ pendingMicrosoftB2cDomain = b2cDomain
220
+ val authBaseUrl = getMicrosoftAuthBaseUrl(effectiveTenant, b2cDomain)
221
+ val redirectUri = "msauth://${ctx.packageName}/${clientId}"
222
+
223
+ val authUrl = Uri.parse("${authBaseUrl}oauth2/v2.0/authorize").buildUpon()
224
+ .appendQueryParameter("client_id", clientId)
225
+ .appendQueryParameter("redirect_uri", redirectUri)
226
+ .appendQueryParameter("response_type", "code")
227
+ .appendQueryParameter("response_mode", "query")
228
+ .appendQueryParameter("scope", effectiveScopes.joinToString(" "))
229
+ .appendQueryParameter("state", state)
230
+ .appendQueryParameter("nonce", nonce)
231
+ .appendQueryParameter("code_challenge", codeChallenge)
232
+ .appendQueryParameter("code_challenge_method", "S256")
233
+ .appendQueryParameter("prompt", effectivePrompt)
234
+ .apply { if (loginHint != null) appendQueryParameter("login_hint", loginHint) }
235
+ .build()
236
+
237
+ try {
238
+ val activity = currentActivity
239
+ if (activity != null) {
240
+ val customTabsIntent = CustomTabsIntent.Builder().build()
241
+ customTabsIntent.launchUrl(activity, authUrl)
242
+ } else {
243
+ val browserIntent = Intent(Intent.ACTION_VIEW, authUrl)
244
+ browserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
245
+ ctx.startActivity(browserIntent)
246
+ }
247
+ } catch (e: Exception) {
248
+ nativeOnLoginError("unknown", e.message)
249
+ }
250
+ }
251
+
252
+ private fun generateCodeVerifier(): String {
253
+ val bytes = ByteArray(32)
254
+ java.security.SecureRandom().nextBytes(bytes)
255
+ return Base64.encodeToString(bytes, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
256
+ }
257
+
258
+ private fun generateCodeChallenge(verifier: String): String {
259
+ val bytes = java.security.MessageDigest.getInstance("SHA-256").digest(verifier.toByteArray(Charsets.US_ASCII))
260
+ return Base64.encodeToString(bytes, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
261
+ }
262
+
263
+ @JvmStatic
264
+ fun handleMicrosoftRedirect(uri: Uri) {
265
+ val code = uri.getQueryParameter("code")
266
+ val state = uri.getQueryParameter("state")
267
+ val error = uri.getQueryParameter("error")
268
+ val errorDescription = uri.getQueryParameter("error_description")
269
+
270
+ if (error != null) {
271
+ clearPkceState()
272
+ nativeOnLoginError(error, errorDescription)
273
+ return
274
+ }
275
+
276
+ if (state != pendingState) {
277
+ clearPkceState()
278
+ nativeOnLoginError("invalid_state", "State mismatch - possible CSRF attack")
279
+ return
280
+ }
281
+
282
+ if (code == null) {
283
+ clearPkceState()
284
+ nativeOnLoginError("unknown", "No authorization code in response")
285
+ return
286
+ }
287
+
288
+ exchangeCodeForTokens(code)
289
+ }
290
+
291
+ private fun exchangeCodeForTokens(code: String) {
292
+ val ctx = appContext
293
+ val clientId = pendingMicrosoftClientId
294
+ val tenant = pendingMicrosoftTenant
295
+ val verifier = pendingPkceVerifier
296
+
297
+ if (ctx == null || clientId == null || tenant == null || verifier == null) {
298
+ clearPkceState()
299
+ nativeOnLoginError("invalid_state", "Missing PKCE state for token exchange")
300
+ return
301
+ }
302
+
303
+ val redirectUri = "msauth://${ctx.packageName}/${clientId}"
304
+ val authBaseUrl = getMicrosoftAuthBaseUrl(tenant, pendingMicrosoftB2cDomain)
305
+ val tokenUrl = "${authBaseUrl}oauth2/v2.0/token"
306
+
307
+ CoroutineScope(Dispatchers.IO).launch {
308
+ try {
309
+ val url = java.net.URL(tokenUrl)
310
+ val connection = url.openConnection() as java.net.HttpURLConnection
311
+ connection.requestMethod = "POST"
312
+ connection.setRequestProperty("Content-Type", "application/x-www-form-urlencoded")
313
+ connection.doOutput = true
314
+
315
+ val postData = buildString {
316
+ append("client_id=${java.net.URLEncoder.encode(clientId, "UTF-8")}")
317
+ append("&code=${java.net.URLEncoder.encode(code, "UTF-8")}")
318
+ append("&redirect_uri=${java.net.URLEncoder.encode(redirectUri, "UTF-8")}")
319
+ append("&grant_type=authorization_code")
320
+ append("&code_verifier=${java.net.URLEncoder.encode(verifier, "UTF-8")}")
321
+ }
322
+
323
+ connection.outputStream.use { it.write(postData.toByteArray()) }
324
+
325
+ val responseCode = connection.responseCode
326
+ val responseBody = if (responseCode == 200) {
327
+ connection.inputStream.bufferedReader().readText()
328
+ } else {
329
+ connection.errorStream?.bufferedReader()?.readText() ?: ""
330
+ }
331
+
332
+ CoroutineScope(Dispatchers.Main).launch {
333
+ handleTokenResponse(responseCode, responseBody)
334
+ }
335
+ } catch (e: Exception) {
336
+ CoroutineScope(Dispatchers.Main).launch {
337
+ clearPkceState()
338
+ nativeOnLoginError("network_error", e.message)
339
+ }
340
+ }
341
+ }
342
+ }
343
+
344
+ private fun handleTokenResponse(responseCode: Int, responseBody: String) {
345
+ if (responseCode != 200) {
346
+ try {
347
+ val json = JSONObject(responseBody)
348
+ val error = json.optString("error", "token_error")
349
+ val desc = json.optString("error_description", "Failed to exchange code for tokens")
350
+ clearPkceState()
351
+ nativeOnLoginError(error, desc)
352
+ } catch (e: Exception) {
353
+ clearPkceState()
354
+ nativeOnLoginError("token_error", "Failed to exchange code for tokens")
355
+ }
356
+ return
357
+ }
358
+
359
+ try {
360
+ val json = JSONObject(responseBody)
361
+ val idToken = json.optString("id_token")
362
+ val accessToken = json.optString("access_token")
363
+ val refreshToken = json.optString("refresh_token")
364
+ val expiresIn = json.optLong("expires_in", 0)
365
+ val expirationTime = if (expiresIn > 0) System.currentTimeMillis() + expiresIn * 1000 else null
366
+
367
+ if (idToken.isEmpty()) {
368
+ clearPkceState()
369
+ nativeOnLoginError("no_id_token", "No id_token in token response")
370
+ return
371
+ }
372
+
373
+ val claims = decodeJwt(idToken)
374
+ val tokenNonce = claims["nonce"]
375
+ if (tokenNonce != pendingNonce) {
376
+ clearPkceState()
377
+ nativeOnLoginError("invalid_nonce", "Nonce mismatch - token may be replayed")
378
+ return
379
+ }
380
+
381
+ val email = claims["preferred_username"] ?: claims["email"]
382
+ val name = claims["name"]
383
+
384
+ val ctx = appContext
385
+ if (ctx != null) {
386
+ saveUser(ctx, "microsoft", email, name, null, idToken, refreshToken, pendingMicrosoftScopes)
387
+ if (refreshToken.isNotEmpty()) {
388
+ saveMicrosoftRefreshToken(ctx, refreshToken)
389
+ }
390
+ }
391
+
392
+ clearPkceState()
393
+ nativeOnLoginSuccess(
394
+ "microsoft",
395
+ email,
396
+ name,
397
+ null,
398
+ idToken,
399
+ accessToken,
400
+ null,
401
+ pendingMicrosoftScopes.toTypedArray(),
402
+ expirationTime
403
+ )
404
+ } catch (e: Exception) {
405
+ clearPkceState()
406
+ nativeOnLoginError("parse_error", e.message)
407
+ }
408
+ }
409
+
410
+ private fun saveMicrosoftRefreshToken(context: Context, refreshToken: String) {
411
+ val prefs = getPrefs(context)
412
+ prefs.edit().putString("microsoft_refresh_token", refreshToken).apply()
413
+ }
414
+
415
+ private fun getMicrosoftRefreshToken(context: Context): String? {
416
+ val prefs = getPrefs(context)
417
+ return prefs.getString("microsoft_refresh_token", null)
418
+ }
419
+
420
+ private fun clearPkceState() {
421
+ pendingPkceVerifier = null
422
+ pendingState = null
423
+ pendingNonce = null
424
+ pendingMicrosoftTenant = null
425
+ pendingMicrosoftClientId = null
426
+ pendingMicrosoftB2cDomain = null
427
+ }
428
+
429
+ private fun decodeJwt(token: String): Map<String, String> {
430
+ return try {
431
+ val parts = token.split(".")
432
+ if (parts.size < 2) return emptyMap()
433
+ val payload = String(Base64.decode(parts[1], Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP))
434
+ val json = JSONObject(payload)
435
+ val result = mutableMapOf<String, String>()
436
+ json.keys().forEach { key ->
437
+ val value = json.optString(key)
438
+ if (value.isNotEmpty()) result[key] = value
439
+ }
440
+ result
441
+ } catch (e: Exception) {
442
+ emptyMap()
443
+ }
444
+ }
445
+
446
+ private fun getMicrosoftClientIdFromResources(context: Context): String? {
447
+ val resId = context.resources.getIdentifier("nitro_auth_microsoft_client_id", "string", context.packageName)
448
+ return if (resId != 0) context.getString(resId) else null
449
+ }
450
+
451
+ private fun getMicrosoftTenantFromResources(context: Context): String? {
452
+ val resId = context.resources.getIdentifier("nitro_auth_microsoft_tenant", "string", context.packageName)
453
+ return if (resId != 0) context.getString(resId) else null
454
+ }
455
+
456
+ private fun getMicrosoftB2cDomainFromResources(context: Context): String? {
457
+ val resId = context.resources.getIdentifier("nitro_auth_microsoft_b2c_domain", "string", context.packageName)
458
+ return if (resId != 0) context.getString(resId) else null
459
+ }
460
+
461
+ private fun getMicrosoftAuthBaseUrl(tenant: String, b2cDomain: String?): String {
462
+ if (tenant.startsWith("https://")) {
463
+ return if (tenant.endsWith("/")) tenant else "$tenant/"
464
+ }
465
+
466
+ return if (b2cDomain != null) {
467
+ "https://$b2cDomain/tfp/$tenant/"
468
+ } else {
469
+ "https://login.microsoftonline.com/$tenant/"
470
+ }
471
+ }
472
+
129
473
  private fun loginOneTap(context: Context, clientId: String, scopes: List<String>) {
130
474
  val activity = currentActivity ?: context as? Activity
131
475
  if (activity == null) {
@@ -199,58 +543,72 @@ object AuthAdapter {
199
543
  fun requestScopesSync(context: Context, scopes: Array<String>) {
200
544
  val ctx = appContext ?: context.applicationContext
201
545
  val account = GoogleSignIn.getLastSignedInAccount(ctx)
202
- if (account == null) {
203
- nativeOnLoginError("unknown", "No user logged in")
204
- return
205
- }
206
-
207
- val newScopes = scopes.map { Scope(it) }
208
- if (GoogleSignIn.hasPermissions(account, *newScopes.toTypedArray())) {
209
- onSignInSuccess(account, (pendingScopes + scopes.toList()).distinct())
546
+ if (account != null) {
547
+ val newScopes = scopes.map { Scope(it) }
548
+ if (GoogleSignIn.hasPermissions(account, *newScopes.toTypedArray())) {
549
+ onSignInSuccess(account, (pendingScopes + scopes.toList()).distinct())
550
+ return
551
+ }
552
+ val clientId = getClientIdFromResources(ctx)
553
+ if (clientId == null) {
554
+ nativeOnLoginError("configuration_error", "Google Client ID not configured")
555
+ return
556
+ }
557
+ val allScopes = (pendingScopes + scopes.toList()).distinct()
558
+ val intent = GoogleSignInActivity.createIntent(ctx, clientId, allScopes.toTypedArray(), account.email)
559
+ ctx.startActivity(intent)
210
560
  return
211
561
  }
212
-
213
- val clientId = getClientIdFromResources(ctx)
214
- if (clientId == null) {
215
- nativeOnLoginError("configuration_error", "Google Client ID not configured")
562
+ val userJson = getUserJson(ctx)
563
+ if (userJson != null && userJson.contains("\"provider\":\"microsoft\"")) {
564
+ val currentScopes = extractScopesFromUserJson(userJson)
565
+ val defaultMicrosoftScopes = listOf("openid", "email", "profile", "offline_access", "User.Read")
566
+ val existing = if (currentScopes.isEmpty()) defaultMicrosoftScopes else currentScopes
567
+ val mergedScopes = (existing + scopes.toList()).distinct()
568
+ val tenant = getMicrosoftTenantFromResources(ctx)
569
+ loginMicrosoft(ctx, mergedScopes.toTypedArray(), null, tenant, null)
216
570
  return
217
571
  }
218
-
219
- val allScopes = (pendingScopes + scopes.toList()).distinct()
220
- val intent = GoogleSignInActivity.createIntent(ctx, clientId, allScopes.toTypedArray(), account.email)
221
- ctx.startActivity(intent)
572
+ nativeOnLoginError("unknown", "No user logged in")
222
573
  }
223
574
 
224
575
  @JvmStatic
225
576
  fun refreshTokenSync(context: Context) {
226
577
  val ctx = appContext ?: context.applicationContext
227
- if (googleSignInClient == null) {
228
- val account = GoogleSignIn.getLastSignedInAccount(ctx)
229
- if (account == null) {
230
- nativeOnRefreshError("unknown", "No user logged in")
231
- return
578
+ val account = GoogleSignIn.getLastSignedInAccount(ctx)
579
+ if (account != null) {
580
+ if (googleSignInClient == null) {
581
+ val clientId = getClientIdFromResources(ctx)
582
+ if (clientId == null) {
583
+ nativeOnRefreshError("configuration_error", "Google Client ID not configured")
584
+ return
585
+ }
586
+ val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
587
+ .requestIdToken(clientId)
588
+ .requestServerAuthCode(clientId)
589
+ .requestEmail()
590
+ .build()
591
+ googleSignInClient = GoogleSignIn.getClient(ctx, gso)
232
592
  }
233
- val clientId = getClientIdFromResources(ctx)
234
- if (clientId == null) {
235
- nativeOnRefreshError("configuration_error", "Google Client ID not configured")
236
- return
593
+ googleSignInClient!!.silentSignIn().addOnCompleteListener { task ->
594
+ if (task.isSuccessful) {
595
+ val acc = task.result
596
+ nativeOnRefreshSuccess(acc?.idToken, null, null)
597
+ } else {
598
+ nativeOnRefreshError("network_error", task.exception?.message ?: "Silent sign-in failed")
599
+ }
237
600
  }
238
- val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
239
- .requestIdToken(clientId)
240
- .requestServerAuthCode(clientId)
241
- .requestEmail()
242
- .build()
243
- googleSignInClient = GoogleSignIn.getClient(ctx, gso)
601
+ return
244
602
  }
245
-
246
- googleSignInClient!!.silentSignIn().addOnCompleteListener { task ->
247
- if (task.isSuccessful) {
248
- val account = task.result
249
- nativeOnRefreshSuccess(account?.idToken, null, null)
250
- } else {
251
- nativeOnRefreshError("network_error", task.exception?.message ?: "Silent sign-in failed")
603
+ val userJson = getUserJson(ctx)
604
+ if (userJson != null && userJson.contains("\"provider\":\"microsoft\"")) {
605
+ val refreshToken = getMicrosoftRefreshToken(ctx)
606
+ if (refreshToken != null) {
607
+ refreshMicrosoftTokenForRefresh(ctx, refreshToken)
608
+ return
252
609
  }
253
610
  }
611
+ nativeOnRefreshError("unknown", "No user logged in")
254
612
  }
255
613
 
256
614
  @JvmStatic
@@ -298,19 +656,19 @@ object AuthAdapter {
298
656
 
299
657
  @JvmStatic
300
658
  fun getUserJson(context: Context): String? {
301
- val pref = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
659
+ val pref = getPrefs(context)
302
660
  return pref.getString("user_json", null)
303
661
  }
304
662
 
305
663
  @JvmStatic
306
664
  fun setUserJson(context: Context, json: String) {
307
- val pref = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
665
+ val pref = getPrefs(context)
308
666
  pref.edit().putString("user_json", json).apply()
309
667
  }
310
668
 
311
669
  @JvmStatic
312
670
  fun clearUser(context: Context) {
313
- val pref = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
671
+ val pref = getPrefs(context)
314
672
  pref.edit().clear().apply()
315
673
  }
316
674
 
@@ -325,42 +683,201 @@ object AuthAdapter {
325
683
  } else {
326
684
  val json = getUserJson(ctx)
327
685
  if (json != null) {
328
- val provider = if (json.contains("\"provider\":\"google\"")) "google" else "apple"
329
- val email = extractJsonValue(json, "email")
330
- val name = extractJsonValue(json, "name")
331
- val photo = extractJsonValue(json, "photo")
332
- val idToken = extractJsonValue(json, "idToken")
333
- val serverAuthCode = extractJsonValue(json, "serverAuthCode")
334
- nativeOnLoginSuccess(provider, email, name, photo, idToken, null, serverAuthCode, null, null)
686
+ val provider = try {
687
+ val parsed = JSONObject(json)
688
+ parsed.optString("provider")
689
+ } catch (_: Exception) {
690
+ ""
691
+ }
692
+ val effectiveProvider = when (provider) {
693
+ "google" -> "google"
694
+ "microsoft" -> "microsoft"
695
+ "apple" -> "apple"
696
+ else -> "apple"
697
+ }
698
+
699
+ if (effectiveProvider == "microsoft") {
700
+ val refreshToken = getMicrosoftRefreshToken(ctx)
701
+ if (refreshToken != null) {
702
+ refreshMicrosoftToken(ctx, refreshToken)
703
+ } else {
704
+ val email = extractJsonValue(json, "email")
705
+ val name = extractJsonValue(json, "name")
706
+ val idToken = extractJsonValue(json, "idToken")
707
+ nativeOnLoginSuccess(effectiveProvider, email, name, null, idToken, null, null, null, null)
708
+ }
709
+ } else {
710
+ val email = extractJsonValue(json, "email")
711
+ val name = extractJsonValue(json, "name")
712
+ val photo = extractJsonValue(json, "photo")
713
+ val idToken = extractJsonValue(json, "idToken")
714
+ val serverAuthCode = extractJsonValue(json, "serverAuthCode")
715
+ nativeOnLoginSuccess(effectiveProvider, email, name, photo, idToken, null, serverAuthCode, null, null)
716
+ }
335
717
  } else {
336
718
  nativeOnLoginError("unknown", "No session")
337
719
  }
338
720
  }
339
721
  }
340
722
 
723
+ private fun refreshMicrosoftToken(context: Context, refreshToken: String) {
724
+ val clientId = getMicrosoftClientIdFromResources(context)
725
+ val tenant = getMicrosoftTenantFromResources(context) ?: "common"
726
+ val b2cDomain = getMicrosoftB2cDomainFromResources(context)
727
+
728
+ if (clientId == null) {
729
+ nativeOnLoginError("configuration_error", "Microsoft Client ID is required for refresh")
730
+ return
731
+ }
732
+
733
+ val authBaseUrl = getMicrosoftAuthBaseUrl(tenant, b2cDomain)
734
+ val tokenUrl = "${authBaseUrl}oauth2/v2.0/token"
735
+
736
+ CoroutineScope(Dispatchers.IO).launch {
737
+ try {
738
+ val url = java.net.URL(tokenUrl)
739
+ val connection = url.openConnection() as java.net.HttpURLConnection
740
+ connection.requestMethod = "POST"
741
+ connection.setRequestProperty("Content-Type", "application/x-www-form-urlencoded")
742
+ connection.doOutput = true
743
+
744
+ val postData = buildString {
745
+ append("client_id=${java.net.URLEncoder.encode(clientId, "UTF-8")}")
746
+ append("&grant_type=refresh_token")
747
+ append("&refresh_token=${java.net.URLEncoder.encode(refreshToken, "UTF-8")}")
748
+ }
749
+
750
+ connection.outputStream.use { it.write(postData.toByteArray()) }
751
+
752
+ val responseCode = connection.responseCode
753
+ val responseBody = if (responseCode == 200) {
754
+ connection.inputStream.bufferedReader().readText()
755
+ } else {
756
+ connection.errorStream?.bufferedReader()?.readText() ?: ""
757
+ }
758
+
759
+ CoroutineScope(Dispatchers.Main).launch {
760
+ if (responseCode == 200) {
761
+ val json = JSONObject(responseBody)
762
+ val newIdToken = json.optString("id_token")
763
+ val newAccessToken = json.optString("access_token")
764
+ val newRefreshToken = json.optString("refresh_token")
765
+ val expiresIn = json.optLong("expires_in", 0)
766
+ val expirationTime = if (expiresIn > 0) System.currentTimeMillis() + expiresIn * 1000 else null
767
+
768
+ val claims = decodeJwt(newIdToken)
769
+ val email = claims["preferred_username"] ?: claims["email"]
770
+ val name = claims["name"]
771
+
772
+ if (newRefreshToken.isNotEmpty()) {
773
+ saveMicrosoftRefreshToken(context, newRefreshToken)
774
+ }
775
+ saveUser(context, "microsoft", email, name, null, newIdToken, newRefreshToken, null)
776
+
777
+ nativeOnLoginSuccess("microsoft", email, name, null, newIdToken, newAccessToken, null, null, expirationTime)
778
+ } else {
779
+ nativeOnLoginError("refresh_failed", "Microsoft token refresh failed")
780
+ }
781
+ }
782
+ } catch (e: Exception) {
783
+ CoroutineScope(Dispatchers.Main).launch {
784
+ nativeOnLoginError("network_error", e.message)
785
+ }
786
+ }
787
+ }
788
+ }
789
+
790
+ private fun refreshMicrosoftTokenForRefresh(context: Context, refreshToken: String) {
791
+ val clientId = getMicrosoftClientIdFromResources(context)
792
+ val tenant = getMicrosoftTenantFromResources(context) ?: "common"
793
+ val b2cDomain = getMicrosoftB2cDomainFromResources(context)
794
+ if (clientId == null) {
795
+ nativeOnRefreshError("configuration_error", "Microsoft Client ID not configured")
796
+ return
797
+ }
798
+ val authBaseUrl = getMicrosoftAuthBaseUrl(tenant, b2cDomain)
799
+ val tokenUrl = "${authBaseUrl}oauth2/v2.0/token"
800
+ CoroutineScope(Dispatchers.IO).launch {
801
+ try {
802
+ val url = java.net.URL(tokenUrl)
803
+ val connection = url.openConnection() as java.net.HttpURLConnection
804
+ connection.requestMethod = "POST"
805
+ connection.setRequestProperty("Content-Type", "application/x-www-form-urlencoded")
806
+ connection.doOutput = true
807
+ val postData = buildString {
808
+ append("client_id=${java.net.URLEncoder.encode(clientId, "UTF-8")}")
809
+ append("&grant_type=refresh_token")
810
+ append("&refresh_token=${java.net.URLEncoder.encode(refreshToken, "UTF-8")}")
811
+ }
812
+ connection.outputStream.use { it.write(postData.toByteArray()) }
813
+ val responseCode = connection.responseCode
814
+ val responseBody = if (responseCode == 200) {
815
+ connection.inputStream.bufferedReader().readText()
816
+ } else {
817
+ connection.errorStream?.bufferedReader()?.readText() ?: ""
818
+ }
819
+ CoroutineScope(Dispatchers.Main).launch {
820
+ if (responseCode == 200) {
821
+ val json = JSONObject(responseBody)
822
+ val newIdToken = json.optString("id_token")
823
+ val newAccessToken = json.optString("access_token")
824
+ val newRefreshToken = json.optString("refresh_token")
825
+ val expiresIn = json.optLong("expires_in", 0)
826
+ val expirationTime = if (expiresIn > 0) System.currentTimeMillis() + expiresIn * 1000 else null
827
+ val claims = decodeJwt(newIdToken)
828
+ val email = claims["preferred_username"] ?: claims["email"]
829
+ val name = claims["name"]
830
+ if (newRefreshToken.isNotEmpty()) {
831
+ saveMicrosoftRefreshToken(context, newRefreshToken)
832
+ }
833
+ saveUser(context, "microsoft", email, name, null, newIdToken, newRefreshToken, null)
834
+ nativeOnRefreshSuccess(
835
+ newIdToken.ifEmpty { null },
836
+ newAccessToken.ifEmpty { null },
837
+ expirationTime
838
+ )
839
+ } else {
840
+ nativeOnRefreshError("refresh_failed", "Microsoft token refresh failed")
841
+ }
842
+ }
843
+ } catch (e: Exception) {
844
+ CoroutineScope(Dispatchers.Main).launch {
845
+ nativeOnRefreshError("network_error", e.message)
846
+ }
847
+ }
848
+ }
849
+ }
850
+
341
851
  private fun extractJsonValue(json: String, key: String): String? {
342
- val pattern = "\"$key\":\"([^\"]*)\""
343
- val regex = Regex(pattern)
344
- return regex.find(json)?.groupValues?.get(1)
852
+ return try {
853
+ val value = JSONObject(json).optString(key, "")
854
+ if (value.isEmpty()) null else value
855
+ } catch (_: Exception) {
856
+ null
857
+ }
858
+ }
859
+
860
+ private fun extractScopesFromUserJson(json: String): List<String> {
861
+ return try {
862
+ val jsonObj = JSONObject(json)
863
+ val arr = jsonObj.optJSONArray("scopes") ?: return emptyList()
864
+ (0 until arr.length()).mapNotNull { i -> arr.optString(i).takeIf { it.isNotEmpty() } }
865
+ } catch (_: Exception) {
866
+ emptyList()
867
+ }
345
868
  }
346
869
 
347
870
  private fun saveUser(context: Context, provider: String, email: String?, name: String?,
348
871
  photo: String?, idToken: String?, serverAuthCode: String?, scopes: List<String>?) {
349
- val pref = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
350
- val json = StringBuilder()
351
- json.append("{")
352
- json.append("\"provider\":\"$provider\"")
353
- if (email != null) json.append(",\"email\":\"$email\"")
354
- if (name != null) json.append(",\"name\":\"$name\"")
355
- if (photo != null) json.append(",\"photo\":\"$photo\"")
356
- if (idToken != null) json.append(",\"idToken\":\"$idToken\"")
357
- if (serverAuthCode != null) json.append(",\"serverAuthCode\":\"$serverAuthCode\"")
358
- if (scopes != null) {
359
- json.append(",\"scopes\":[")
360
- json.append(scopes.joinToString(",") { "\"$it\"" })
361
- json.append("]")
362
- }
363
- json.append("}")
872
+ val pref = getPrefs(context)
873
+ val json = JSONObject()
874
+ json.put("provider", provider)
875
+ if (email != null) json.put("email", email)
876
+ if (name != null) json.put("name", name)
877
+ if (photo != null) json.put("photo", photo)
878
+ if (idToken != null) json.put("idToken", idToken)
879
+ if (serverAuthCode != null) json.put("serverAuthCode", serverAuthCode)
880
+ if (scopes != null) json.put("scopes", JSONArray(scopes))
364
881
  pref.edit().putString("user_json", json.toString()).apply()
365
882
  }
366
883
  }