react-native-nitro-auth 0.4.0 → 0.5.1

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