@oxyhq/services 23.2.0 → 24.0.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 (172) hide show
  1. package/android/src/main/java/so/oxy/identity/OxyIdentityStore.kt +21 -127
  2. package/android/src/main/java/so/oxy/session/OxyBackgroundSession.kt +184 -0
  3. package/android/src/main/java/so/oxy/session/OxyBackgroundSessionApi.kt +162 -0
  4. package/android/src/main/java/so/oxy/session/OxyBackgroundSessionModule.kt +101 -0
  5. package/android/src/main/java/so/oxy/session/OxyBackgroundSessionStore.kt +179 -0
  6. package/android/src/main/java/so/oxy/session/OxyBackgroundToken.kt +61 -0
  7. package/android/src/main/java/so/oxy/storage/OxyEncryptedPrefs.kt +241 -0
  8. package/expo-module.config.json +4 -1
  9. package/lib/commonjs/ui/components/OxyProvider.js +3 -1
  10. package/lib/commonjs/ui/components/OxyProvider.js.map +1 -1
  11. package/lib/commonjs/ui/components/ProfileButton.js +1 -1
  12. package/lib/commonjs/ui/components/ProfileButton.js.map +1 -1
  13. package/lib/commonjs/ui/components/internal/PinInput.js +3 -1
  14. package/lib/commonjs/ui/components/internal/PinInput.js.map +1 -1
  15. package/lib/commonjs/ui/components/logo/LogoText.js +4 -4
  16. package/lib/commonjs/ui/components/logo/LogoText.js.map +1 -1
  17. package/lib/commonjs/ui/components/payment/PaymentSummaryStep.js +2 -2
  18. package/lib/commonjs/ui/components/payment/PaymentSummaryStep.js.map +1 -1
  19. package/lib/commonjs/ui/context/OxyContext.js +13 -0
  20. package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
  21. package/lib/commonjs/ui/screens/AccountSettingsScreen.js +2 -3
  22. package/lib/commonjs/ui/screens/AccountSettingsScreen.js.map +1 -1
  23. package/lib/commonjs/ui/screens/ChangeAvatarScreen.js +2 -3
  24. package/lib/commonjs/ui/screens/ChangeAvatarScreen.js.map +1 -1
  25. package/lib/commonjs/ui/screens/EditProfileScreen.js +2 -3
  26. package/lib/commonjs/ui/screens/EditProfileScreen.js.map +1 -1
  27. package/lib/commonjs/ui/screens/FeedbackScreen.js +3 -1
  28. package/lib/commonjs/ui/screens/FeedbackScreen.js.map +1 -1
  29. package/lib/commonjs/ui/screens/ManageAccountScreen.js +1 -1
  30. package/lib/commonjs/ui/screens/ManageAccountScreen.js.map +1 -1
  31. package/lib/commonjs/ui/screens/PremiumSubscriptionScreen.js +2 -2
  32. package/lib/commonjs/ui/screens/PremiumSubscriptionScreen.js.map +1 -1
  33. package/lib/commonjs/ui/screens/PrivacySettingsScreen.js +8 -9
  34. package/lib/commonjs/ui/screens/PrivacySettingsScreen.js.map +1 -1
  35. package/lib/commonjs/ui/screens/ProfileScreen.js +1 -1
  36. package/lib/commonjs/ui/screens/ProfileScreen.js.map +1 -1
  37. package/lib/commonjs/ui/screens/UserListScreen.js +2 -3
  38. package/lib/commonjs/ui/screens/UserListScreen.js.map +1 -1
  39. package/lib/commonjs/ui/screens/trust/TrustCenterScreen.js +1 -1
  40. package/lib/commonjs/ui/screens/trust/TrustCenterScreen.js.map +1 -1
  41. package/lib/commonjs/ui/screens/trust/TrustLeaderboardScreen.js +2 -3
  42. package/lib/commonjs/ui/screens/trust/TrustLeaderboardScreen.js.map +1 -1
  43. package/lib/commonjs/ui/screens/trust/TrustRewardsScreen.js +1 -1
  44. package/lib/commonjs/ui/screens/trust/TrustRewardsScreen.js.map +1 -1
  45. package/lib/commonjs/ui/session/backgroundSession.js +264 -0
  46. package/lib/commonjs/ui/session/backgroundSession.js.map +1 -0
  47. package/lib/commonjs/ui/session/index.js +7 -0
  48. package/lib/commonjs/ui/session/index.js.map +1 -1
  49. package/lib/commonjs/ui/session/useBackgroundSessionSync.js +81 -0
  50. package/lib/commonjs/ui/session/useBackgroundSessionSync.js.map +1 -0
  51. package/lib/commonjs/ui/utils/userUtils.js +31 -22
  52. package/lib/commonjs/ui/utils/userUtils.js.map +1 -1
  53. package/lib/module/ui/components/OxyProvider.js +3 -1
  54. package/lib/module/ui/components/OxyProvider.js.map +1 -1
  55. package/lib/module/ui/components/ProfileButton.js +2 -2
  56. package/lib/module/ui/components/ProfileButton.js.map +1 -1
  57. package/lib/module/ui/components/internal/PinInput.js +3 -1
  58. package/lib/module/ui/components/internal/PinInput.js.map +1 -1
  59. package/lib/module/ui/components/logo/LogoText.js +4 -4
  60. package/lib/module/ui/components/logo/LogoText.js.map +1 -1
  61. package/lib/module/ui/components/payment/PaymentSummaryStep.js +2 -2
  62. package/lib/module/ui/components/payment/PaymentSummaryStep.js.map +1 -1
  63. package/lib/module/ui/context/OxyContext.js +14 -1
  64. package/lib/module/ui/context/OxyContext.js.map +1 -1
  65. package/lib/module/ui/screens/AccountSettingsScreen.js +2 -3
  66. package/lib/module/ui/screens/AccountSettingsScreen.js.map +1 -1
  67. package/lib/module/ui/screens/ChangeAvatarScreen.js +3 -4
  68. package/lib/module/ui/screens/ChangeAvatarScreen.js.map +1 -1
  69. package/lib/module/ui/screens/EditProfileScreen.js +3 -4
  70. package/lib/module/ui/screens/EditProfileScreen.js.map +1 -1
  71. package/lib/module/ui/screens/FeedbackScreen.js +3 -1
  72. package/lib/module/ui/screens/FeedbackScreen.js.map +1 -1
  73. package/lib/module/ui/screens/ManageAccountScreen.js +2 -2
  74. package/lib/module/ui/screens/ManageAccountScreen.js.map +1 -1
  75. package/lib/module/ui/screens/PremiumSubscriptionScreen.js +2 -2
  76. package/lib/module/ui/screens/PremiumSubscriptionScreen.js.map +1 -1
  77. package/lib/module/ui/screens/PrivacySettingsScreen.js +9 -10
  78. package/lib/module/ui/screens/PrivacySettingsScreen.js.map +1 -1
  79. package/lib/module/ui/screens/ProfileScreen.js +1 -1
  80. package/lib/module/ui/screens/ProfileScreen.js.map +1 -1
  81. package/lib/module/ui/screens/UserListScreen.js +2 -3
  82. package/lib/module/ui/screens/UserListScreen.js.map +1 -1
  83. package/lib/module/ui/screens/trust/TrustCenterScreen.js +1 -1
  84. package/lib/module/ui/screens/trust/TrustCenterScreen.js.map +1 -1
  85. package/lib/module/ui/screens/trust/TrustLeaderboardScreen.js +2 -3
  86. package/lib/module/ui/screens/trust/TrustLeaderboardScreen.js.map +1 -1
  87. package/lib/module/ui/screens/trust/TrustRewardsScreen.js +1 -1
  88. package/lib/module/ui/screens/trust/TrustRewardsScreen.js.map +1 -1
  89. package/lib/module/ui/session/backgroundSession.js +258 -0
  90. package/lib/module/ui/session/backgroundSession.js.map +1 -0
  91. package/lib/module/ui/session/index.js +1 -0
  92. package/lib/module/ui/session/index.js.map +1 -1
  93. package/lib/module/ui/session/useBackgroundSessionSync.js +76 -0
  94. package/lib/module/ui/session/useBackgroundSessionSync.js.map +1 -0
  95. package/lib/module/ui/utils/userUtils.js +31 -22
  96. package/lib/module/ui/utils/userUtils.js.map +1 -1
  97. package/lib/typescript/commonjs/ui/components/OxyProvider.d.ts.map +1 -1
  98. package/lib/typescript/commonjs/ui/components/ProfileButton.d.ts.map +1 -1
  99. package/lib/typescript/commonjs/ui/components/internal/PinInput.d.ts.map +1 -1
  100. package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
  101. package/lib/typescript/commonjs/ui/context/oxyContextTypes.d.ts +22 -0
  102. package/lib/typescript/commonjs/ui/context/oxyContextTypes.d.ts.map +1 -1
  103. package/lib/typescript/commonjs/ui/screens/ChangeAvatarScreen.d.ts.map +1 -1
  104. package/lib/typescript/commonjs/ui/screens/EditProfileScreen.d.ts.map +1 -1
  105. package/lib/typescript/commonjs/ui/screens/FeedbackScreen.d.ts.map +1 -1
  106. package/lib/typescript/commonjs/ui/screens/ManageAccountScreen.d.ts.map +1 -1
  107. package/lib/typescript/commonjs/ui/screens/PrivacySettingsScreen.d.ts.map +1 -1
  108. package/lib/typescript/commonjs/ui/screens/ProfileScreen.d.ts.map +1 -1
  109. package/lib/typescript/commonjs/ui/screens/trust/trustTier.d.ts +1 -1
  110. package/lib/typescript/commonjs/ui/screens/trust/trustTier.d.ts.map +1 -1
  111. package/lib/typescript/commonjs/ui/session/backgroundSession.d.ts +42 -0
  112. package/lib/typescript/commonjs/ui/session/backgroundSession.d.ts.map +1 -0
  113. package/lib/typescript/commonjs/ui/session/index.d.ts +1 -0
  114. package/lib/typescript/commonjs/ui/session/index.d.ts.map +1 -1
  115. package/lib/typescript/commonjs/ui/session/useBackgroundSessionSync.d.ts +23 -0
  116. package/lib/typescript/commonjs/ui/session/useBackgroundSessionSync.d.ts.map +1 -0
  117. package/lib/typescript/commonjs/ui/types/navigation.d.ts +7 -0
  118. package/lib/typescript/commonjs/ui/types/navigation.d.ts.map +1 -1
  119. package/lib/typescript/commonjs/ui/utils/userUtils.d.ts +7 -12
  120. package/lib/typescript/commonjs/ui/utils/userUtils.d.ts.map +1 -1
  121. package/lib/typescript/module/ui/components/OxyProvider.d.ts.map +1 -1
  122. package/lib/typescript/module/ui/components/ProfileButton.d.ts.map +1 -1
  123. package/lib/typescript/module/ui/components/internal/PinInput.d.ts.map +1 -1
  124. package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
  125. package/lib/typescript/module/ui/context/oxyContextTypes.d.ts +22 -0
  126. package/lib/typescript/module/ui/context/oxyContextTypes.d.ts.map +1 -1
  127. package/lib/typescript/module/ui/screens/ChangeAvatarScreen.d.ts.map +1 -1
  128. package/lib/typescript/module/ui/screens/EditProfileScreen.d.ts.map +1 -1
  129. package/lib/typescript/module/ui/screens/FeedbackScreen.d.ts.map +1 -1
  130. package/lib/typescript/module/ui/screens/ManageAccountScreen.d.ts.map +1 -1
  131. package/lib/typescript/module/ui/screens/PrivacySettingsScreen.d.ts.map +1 -1
  132. package/lib/typescript/module/ui/screens/ProfileScreen.d.ts.map +1 -1
  133. package/lib/typescript/module/ui/screens/trust/trustTier.d.ts +1 -1
  134. package/lib/typescript/module/ui/screens/trust/trustTier.d.ts.map +1 -1
  135. package/lib/typescript/module/ui/session/backgroundSession.d.ts +42 -0
  136. package/lib/typescript/module/ui/session/backgroundSession.d.ts.map +1 -0
  137. package/lib/typescript/module/ui/session/index.d.ts +1 -0
  138. package/lib/typescript/module/ui/session/index.d.ts.map +1 -1
  139. package/lib/typescript/module/ui/session/useBackgroundSessionSync.d.ts +23 -0
  140. package/lib/typescript/module/ui/session/useBackgroundSessionSync.d.ts.map +1 -0
  141. package/lib/typescript/module/ui/types/navigation.d.ts +7 -0
  142. package/lib/typescript/module/ui/types/navigation.d.ts.map +1 -1
  143. package/lib/typescript/module/ui/utils/userUtils.d.ts +7 -12
  144. package/lib/typescript/module/ui/utils/userUtils.d.ts.map +1 -1
  145. package/package.json +5 -5
  146. package/src/ui/components/OxyProvider.tsx +2 -0
  147. package/src/ui/components/ProfileButton.tsx +5 -2
  148. package/src/ui/components/internal/PinInput.tsx +1 -0
  149. package/src/ui/components/logo/LogoText.tsx +4 -4
  150. package/src/ui/components/payment/PaymentSummaryStep.tsx +2 -2
  151. package/src/ui/context/OxyContext.tsx +14 -0
  152. package/src/ui/context/oxyContextTypes.ts +22 -0
  153. package/src/ui/screens/AccountSettingsScreen.tsx +4 -4
  154. package/src/ui/screens/ChangeAvatarScreen.tsx +6 -3
  155. package/src/ui/screens/EditProfileScreen.tsx +6 -3
  156. package/src/ui/screens/FeedbackScreen.tsx +1 -0
  157. package/src/ui/screens/ManageAccountScreen.tsx +8 -1
  158. package/src/ui/screens/PremiumSubscriptionScreen.tsx +2 -2
  159. package/src/ui/screens/PrivacySettingsScreen.tsx +13 -11
  160. package/src/ui/screens/ProfileScreen.tsx +3 -1
  161. package/src/ui/screens/UserListScreen.tsx +2 -2
  162. package/src/ui/screens/trust/TrustCenterScreen.tsx +2 -2
  163. package/src/ui/screens/trust/TrustLeaderboardScreen.tsx +3 -3
  164. package/src/ui/screens/trust/TrustRewardsScreen.tsx +1 -1
  165. package/src/ui/screens/trust/TrustRulesScreen.tsx +1 -1
  166. package/src/ui/screens/trust/trustTier.ts +1 -1
  167. package/src/ui/session/__tests__/backgroundSession.test.ts +387 -0
  168. package/src/ui/session/backgroundSession.ts +299 -0
  169. package/src/ui/session/index.ts +1 -0
  170. package/src/ui/session/useBackgroundSessionSync.ts +92 -0
  171. package/src/ui/types/navigation.ts +7 -0
  172. package/src/ui/utils/userUtils.ts +34 -20
@@ -2,12 +2,8 @@ package so.oxy.identity
2
2
 
3
3
  import android.content.Context
4
4
  import android.content.SharedPreferences
5
- import android.util.Log
6
- import androidx.security.crypto.EncryptedSharedPreferences
7
- import androidx.security.crypto.MasterKey
8
- import java.io.IOException
9
- import java.security.GeneralSecurityException
10
- import java.security.KeyStore
5
+ import so.oxy.storage.OxyEncryptedPrefs
6
+ import so.oxy.storage.RecoveryPolicy
11
7
 
12
8
  /**
13
9
  * Shared accessor for the hardware-backed EncryptedSharedPreferences that holds
@@ -15,137 +11,35 @@ import java.security.KeyStore
15
11
  *
16
12
  * Used by BOTH [OxyIdentityModule] (the JS bridge / local read + write) and
17
13
  * [OxyIdentityProvider] (the cross-process read surface) so the store name and
18
- * the AES256 encryption scheme can never drift between the two halves.
14
+ * the encryption scheme can never drift between the two halves.
19
15
  *
20
- * ## Single memoized instance (CRITICAL)
16
+ * Opening the file — the single-memoized-instance rule and the two-stage keyset
17
+ * self-heal, both load-bearing and non-obvious — lives in [OxyEncryptedPrefs],
18
+ * which is shared with the background-session store. Read its documentation
19
+ * before changing anything about how this file is opened.
21
20
  *
22
- * `EncryptedSharedPreferences.create()` must be called AT MOST ONCE per file per
23
- * process. It is NOT safe to re-instantiate: when a second instance is created
24
- * for the same file while another is live (e.g. the JS write thread and a Binder
25
- * thread serving [OxyIdentityProvider.call] concurrently), Tink's keyset load
26
- * races and the next decrypt throws `AEADBadTagException` which silently turned
27
- * every cross-app read into "no shared identity". So the instance is created once,
28
- * lazily, under a lock, keyed on the process-global application context, and
29
- * reused for all reads/writes/provider calls.
30
- *
31
- * ## Keyset self-heal (CRITICAL)
32
- *
33
- * The androidx master key that wraps this file's Tink keyset lives under the
34
- * UID-scoped default alias [MasterKey.DEFAULT_MASTER_KEY_ALIAS] — one entry for
35
- * the whole `so.oxy.shared` UID. When a NEW package joins that shared UID, that
36
- * master key can be rotated/regenerated, leaving the keyset already written into
37
- * `oxy_shared_identity.xml` wrapped under the OLD key. `create()` then fails GCM
38
- * verification (`AEADBadTag` -> `GeneralSecurityException`, or an unreadable
39
- * keyset -> `IOException`) on EVERY read/write, and `EncryptedSharedPreferences`
40
- * never self-heals — the slot stays permanently dead and cross-app "Sign in with
41
- * Oxy" silently falls back to interactive import.
42
- *
43
- * [openOrHealPrefs] recovers ONCE, in two bounded stages (no retry loop):
44
- * 1. Delete ONLY this slot's prefs file (the stale wrapped keyset) and rebuild
45
- * against the current master key — heals the common rotation case without
46
- * touching the master key, so other shared-UID members keep their keysets.
47
- * 2. If a fresh keyset STILL can't be built, the master key itself is unusable:
48
- * delete its keystore alias so a new one is generated, wipe the file again,
49
- * and rebuild. If that final rebuild throws, the exception propagates and the
50
- * existing `runCatching {}` at every call site degrades to null.
51
- *
52
- * This NEVER touches the primary self-custody identity: that lives in
53
- * expo-secure-store under a DISTINCT prefs file ("SecureStore") and DISTINCT
54
- * keystore aliases ("key_v1"/extended), not the androidx master key wiped here.
55
- * The shared slot is a derived copy that Commons re-populates from the primary on
56
- * the next boot (`migrateToSharedIdentity`), so wiping it is non-destructive.
21
+ * One consequence worth restating here: an open can legitimately return an EMPTY
22
+ * file (the self-heal wipes a keyset it cannot read). That is safe for this slot
23
+ * specifically because it is a DERIVED copy Commons re-populates it from the
24
+ * primary self-custody identity on the next boot (`migrateToSharedIdentity`), and
25
+ * that primary lives in expo-secure-store under a different file and different
26
+ * keystore aliases, untouched by any recovery here.
57
27
  */
58
28
  internal object OxyIdentityStore {
59
29
  const val PREFS_NAME = "oxy_shared_identity"
60
30
  const val KEY_PRIVATE = "priv"
61
31
  const val KEY_PUBLIC = "pub"
62
32
 
63
- private const val TAG = "OxyIdentityStore"
64
- private const val ANDROID_KEYSTORE = "AndroidKeyStore"
65
-
66
- @Volatile private var cachedPrefs: SharedPreferences? = null
67
-
68
- private fun prefs(context: Context): SharedPreferences {
69
- cachedPrefs?.let { return it }
70
- return synchronized(this) {
71
- cachedPrefs ?: openOrHealPrefs(context.applicationContext).also { cachedPrefs = it }
72
- }
73
- }
74
-
75
33
  /**
76
- * Opens the EncryptedSharedPreferences, self-healing a corrupted/unreadable
77
- * keyset once (see the "Keyset self-heal" note above). Both exception types the
78
- * androidx `create()` declares `GeneralSecurityException` (AEAD/GCM tag
79
- * mismatch) and `IOException` (unparseable keyset) route into recovery.
34
+ * [RecoveryPolicy.RegenerateSharedMasterKey] preserves this store's original
35
+ * behaviour: when its keyset cannot be rebuilt, the master key is regenerated.
36
+ * That is defensible HERE and nowhere else so far, because this slot going dead
37
+ * already breaks cross-app "Sign in with Oxy" for every Oxy app on the device —
38
+ * the collateral is not worse than the failure it recovers from — and because
39
+ * this slot is itself re-populated from the primary identity on the next boot.
80
40
  */
81
- private fun openOrHealPrefs(appContext: Context): SharedPreferences {
82
- return try {
83
- buildEncryptedPrefs(appContext)
84
- } catch (corrupt: GeneralSecurityException) {
85
- healCorruptedKeyset(appContext, corrupt)
86
- } catch (corrupt: IOException) {
87
- healCorruptedKeyset(appContext, corrupt)
88
- }
89
- }
90
-
91
- /**
92
- * Stage 1: wipe ONLY this slot's prefs file (which holds the stale wrapped
93
- * keyset) and rebuild against the current master key. Escalates to stage 2 if a
94
- * fresh keyset still can't be built.
95
- */
96
- private fun healCorruptedKeyset(appContext: Context, cause: Exception): SharedPreferences {
97
- Log.w(
98
- TAG,
99
- "shared-identity keyset unreadable (rotated master key); cleared and regenerated: ${cause.message}",
100
- cause
101
- )
102
- appContext.deleteSharedPreferences(PREFS_NAME)
103
- return try {
104
- buildEncryptedPrefs(appContext)
105
- } catch (stillCorrupt: GeneralSecurityException) {
106
- regenerateMasterKeyAndRebuild(appContext, stillCorrupt)
107
- } catch (stillCorrupt: IOException) {
108
- regenerateMasterKeyAndRebuild(appContext, stillCorrupt)
109
- }
110
- }
111
-
112
- /**
113
- * Stage 2 (belt-and-suspenders): the androidx master key itself is unusable, so
114
- * delete its keystore alias to force a fresh one, wipe the now-stale file again,
115
- * and rebuild. The alias is used ONLY by androidx EncryptedSharedPreferences —
116
- * expo-secure-store (the primary identity) uses its own distinct aliases — so
117
- * this never touches the self-custody key. A throw here propagates to the
118
- * call-site `runCatching {}`, which degrades to null.
119
- */
120
- private fun regenerateMasterKeyAndRebuild(appContext: Context, cause: Exception): SharedPreferences {
121
- Log.w(
122
- TAG,
123
- "shared-identity keyset still unreadable after prefs reset; deleting master key " +
124
- "'${MasterKey.DEFAULT_MASTER_KEY_ALIAS}' and regenerating: ${cause.message}",
125
- cause
126
- )
127
- KeyStore.getInstance(ANDROID_KEYSTORE).apply {
128
- load(null)
129
- if (containsAlias(MasterKey.DEFAULT_MASTER_KEY_ALIAS)) {
130
- deleteEntry(MasterKey.DEFAULT_MASTER_KEY_ALIAS)
131
- }
132
- }
133
- appContext.deleteSharedPreferences(PREFS_NAME)
134
- return buildEncryptedPrefs(appContext)
135
- }
136
-
137
- private fun buildEncryptedPrefs(appContext: Context): SharedPreferences {
138
- val masterKey = MasterKey.Builder(appContext)
139
- .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
140
- .build()
141
- return EncryptedSharedPreferences.create(
142
- appContext,
143
- PREFS_NAME,
144
- masterKey,
145
- EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
146
- EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
147
- )
148
- }
41
+ private fun prefs(context: Context): SharedPreferences =
42
+ OxyEncryptedPrefs.open(context, PREFS_NAME, RecoveryPolicy.RegenerateSharedMasterKey)
149
43
 
150
44
  /** Read the stored keypair as (privateKey, publicKey), or null when absent/blank. */
151
45
  fun read(context: Context): Pair<String, String>? {
@@ -0,0 +1,184 @@
1
+ package so.oxy.session
2
+
3
+ import android.content.Context
4
+ import android.util.Log
5
+ import java.util.concurrent.TimeUnit
6
+ import kotlinx.coroutines.sync.Mutex
7
+ import kotlinx.coroutines.sync.withLock
8
+
9
+ /**
10
+ * How native background code — a WorkManager worker refreshing a home-screen
11
+ * widget, a sync job — authenticates when there is no JS runtime in the process.
12
+ *
13
+ * ## What this is
14
+ *
15
+ * The app, while running, provisions a background credential and hands it to this
16
+ * store (see `OxyBackgroundSessionModule`). Background code then exchanges it for
17
+ * a short access token whenever it needs one. That is the whole surface:
18
+ *
19
+ * ```kotlin
20
+ * when (val token = OxyBackgroundSession.accessToken(applicationContext)) {
21
+ * is OxyBackgroundToken.Available -> fetch(token.baseUrl, token.accessToken, token.accountId)
22
+ * is OxyBackgroundToken.SignedOut -> renderSignedOut() // final; Result.success()
23
+ * is OxyBackgroundToken.Transient -> Result.retry() // keep last content
24
+ * is OxyBackgroundToken.Malformed -> Result.failure() // wire contract moved
25
+ * }
26
+ * ```
27
+ *
28
+ * ## What it deliberately cannot do
29
+ *
30
+ * It cannot create a session. Only the app, holding a bearer, can provision a
31
+ * credential — so background code can EXTEND a session the user established
32
+ * in-app but can never bootstrap one from nothing.
33
+ *
34
+ * It cannot damage the app's session. It never reads or writes the device
35
+ * credential, and the credential it does use is not rotated by anyone, so a
36
+ * worker killed mid-mint loses nothing. That property is the reason this exists
37
+ * as a separate credential instead of background code simply reading the device
38
+ * secret.
39
+ *
40
+ * It does not narrow authority. The minted token is a FULL user access token:
41
+ * access tokens carry no scopes today, so this yields exactly what the device
42
+ * credential yields while it is valid. The gains are isolation, a bounded
43
+ * lifetime and independent revocability — NOT "read-only". Do not read
44
+ * "background" as a permission boundary.
45
+ *
46
+ * ## Account correctness
47
+ *
48
+ * Every mint is re-authorized server-side against the live device session, so a
49
+ * stale record cannot resurrect a signed-out account. [OxyBackgroundToken.Available.accountId]
50
+ * is the server's answer, not a local guess — key cached content by it and drop
51
+ * content belonging to another account.
52
+ */
53
+ object OxyBackgroundSession {
54
+ private const val TAG = "OxyBackgroundSession"
55
+
56
+ /**
57
+ * Refuse to reuse a cached token this close to expiry, so it cannot die midway
58
+ * through the request it was fetched for.
59
+ */
60
+ private val TOKEN_REUSE_FLOOR_MS = TimeUnit.SECONDS.toMillis(60)
61
+
62
+ /**
63
+ * Serializes mints within a process so several widgets refreshing together make
64
+ * ONE network call and share its result, instead of one call each — which would
65
+ * also burn the server's per-device rate limit. Waiters re-check the cache after
66
+ * acquiring, so only the first actually mints.
67
+ */
68
+ private val mintMutex = Mutex()
69
+
70
+ /**
71
+ * A usable access token for the account this device's app last provisioned, or
72
+ * the reason there is not one. Never throws.
73
+ */
74
+ suspend fun accessToken(context: Context): OxyBackgroundToken {
75
+ val appContext = context.applicationContext
76
+ val credential = readCredential(appContext) ?: return OxyBackgroundToken.SignedOut
77
+
78
+ cachedToken(appContext, credential)?.let { return it }
79
+
80
+ return mintMutex.withLock {
81
+ // Another caller may have minted while this one waited for the lock.
82
+ cachedToken(appContext, credential)?.let { return@withLock it }
83
+ mintLocked(appContext, credential)
84
+ }
85
+ }
86
+
87
+ /**
88
+ * The account a background caller is currently provisioned for, without a
89
+ * network call — for keying cached content, or deciding whether cached content
90
+ * belongs to the current account at all. Null when there is no credential.
91
+ */
92
+ fun activeAccountId(context: Context): String? =
93
+ runCatching { OxyBackgroundSessionStore.readAccountId(context.applicationContext) }
94
+ .getOrNull()
95
+
96
+ /**
97
+ * Whether a credential is present, so a widget can draw its signed-out state
98
+ * without attempting a fetch. A `true` here is not a promise that the next mint
99
+ * succeeds — the server is still the authority.
100
+ */
101
+ fun hasCredential(context: Context): Boolean = activeAccountId(context) != null
102
+
103
+ /**
104
+ * Read the credential, degrading to null on a storage failure.
105
+ *
106
+ * The keystore can be transiently unavailable (direct-boot, a device being
107
+ * upgraded), and [so.oxy.storage.OxyEncryptedPrefs] can legitimately hand back
108
+ * an emptied file after healing an unreadable keyset. Both mean "no credential
109
+ * right now" rather than a crash in a background worker.
110
+ */
111
+ private fun readCredential(appContext: Context): OxyBackgroundSessionStore.Credential? =
112
+ runCatching { OxyBackgroundSessionStore.readCredential(appContext) }
113
+ .onFailure { Log.w(TAG, "background credential store unreadable", it) }
114
+ .getOrNull()
115
+
116
+ private fun cachedToken(
117
+ appContext: Context,
118
+ credential: OxyBackgroundSessionStore.Credential,
119
+ ): OxyBackgroundToken.Available? {
120
+ val cached = runCatching {
121
+ OxyBackgroundSessionStore.readToken(appContext, credential.accountId, TOKEN_REUSE_FLOOR_MS)
122
+ }.getOrNull() ?: return null
123
+ return OxyBackgroundToken.Available(
124
+ accessToken = cached.accessToken,
125
+ expiresAt = cached.expiresAt,
126
+ accountId = cached.accountId,
127
+ baseUrl = credential.baseUrl,
128
+ )
129
+ }
130
+
131
+ /**
132
+ * Mint and map the outcome. Called with [mintMutex] held.
133
+ *
134
+ * Only [OxyBackgroundSessionApi.MintOutcome.Revoked] — an explicit server
135
+ * verdict — clears the record. Transient and malformed outcomes KEEP it, so a
136
+ * deploy window or a contract change can never sign a user out of their
137
+ * widgets.
138
+ */
139
+ private suspend fun mintLocked(
140
+ appContext: Context,
141
+ credential: OxyBackgroundSessionStore.Credential,
142
+ ): OxyBackgroundToken =
143
+ when (val outcome = OxyBackgroundSessionApi.mint(credential)) {
144
+ is OxyBackgroundSessionApi.MintOutcome.Minted -> {
145
+ runCatching {
146
+ OxyBackgroundSessionStore.writeToken(
147
+ appContext,
148
+ OxyBackgroundSessionStore.CachedToken(
149
+ accessToken = outcome.accessToken,
150
+ expiresAt = outcome.expiresAt,
151
+ accountId = outcome.accountId,
152
+ ),
153
+ )
154
+ }.onFailure {
155
+ // Caching is an optimization; a failure to store costs one extra mint
156
+ // next time and must not fail the token the caller is waiting for.
157
+ Log.w(TAG, "could not cache the minted background token", it)
158
+ }
159
+ OxyBackgroundToken.Available(
160
+ accessToken = outcome.accessToken,
161
+ expiresAt = outcome.expiresAt,
162
+ accountId = outcome.accountId,
163
+ baseUrl = credential.baseUrl,
164
+ )
165
+ }
166
+
167
+ OxyBackgroundSessionApi.MintOutcome.Revoked -> {
168
+ Log.i(TAG, "background credential was revoked by the server; clearing it")
169
+ runCatching { OxyBackgroundSessionStore.clear(appContext) }
170
+ .onFailure { Log.w(TAG, "could not clear the revoked background credential", it) }
171
+ OxyBackgroundToken.SignedOut
172
+ }
173
+
174
+ is OxyBackgroundSessionApi.MintOutcome.Transient -> {
175
+ Log.w(TAG, "background token mint failed transiently; keeping the credential", outcome.cause)
176
+ OxyBackgroundToken.Transient(outcome.cause)
177
+ }
178
+
179
+ is OxyBackgroundSessionApi.MintOutcome.Malformed -> {
180
+ Log.e(TAG, "background token response could not be read; keeping the credential", outcome.cause)
181
+ OxyBackgroundToken.Malformed(outcome.cause)
182
+ }
183
+ }
184
+ }
@@ -0,0 +1,162 @@
1
+ package so.oxy.session
2
+
3
+ import kotlinx.coroutines.Dispatchers
4
+ import kotlinx.coroutines.withContext
5
+ import org.json.JSONException
6
+ import org.json.JSONObject
7
+ import java.io.IOException
8
+ import java.net.HttpURLConnection
9
+ import java.net.URL
10
+ import java.text.ParseException
11
+ import java.text.SimpleDateFormat
12
+ import java.util.Locale
13
+ import java.util.TimeZone
14
+ import java.util.concurrent.TimeUnit
15
+
16
+ /**
17
+ * The one network call background code makes: `POST /session/device/background-token`.
18
+ *
19
+ * Bearer-less and cookie-less — possession of the provisioned secret IS the
20
+ * proof. Unlike `POST /session/device/token` this NEVER rotates: the presented
21
+ * secret stays valid until it expires or is revoked, which is what makes it safe
22
+ * to call from a process the OS may kill at any moment.
23
+ *
24
+ * `HttpURLConnection` rather than an HTTP client library, for the same reason
25
+ * Mention's widget uses it: this is one small POST, and adding an HTTP stack to
26
+ * every consuming app's release APK would cost more than it saves.
27
+ */
28
+ internal object OxyBackgroundSessionApi {
29
+ private val CONNECT_TIMEOUT_MS = TimeUnit.SECONDS.toMillis(10).toInt()
30
+ private val READ_TIMEOUT_MS = TimeUnit.SECONDS.toMillis(15).toInt()
31
+
32
+ /**
33
+ * The two server verdicts that PROVE the credential is finished, and the only
34
+ * two that may delete it.
35
+ *
36
+ * This mirrors `@oxyhq/core`'s `refresh.ts` rule deliberately, and for the same
37
+ * hard-won reason: ANY other 401 — a proxy or middleware 401, an ALB answering
38
+ * for a starting instance, an HTML error page during a deploy — is NOT proof
39
+ * that a credential is bad. Treating an ambiguous 401 as revocation is what
40
+ * logged users out ecosystem-wide once already. Here the blast radius is
41
+ * smaller (a widget going blank rather than a session dying) but the rule is
42
+ * the same, so the failure mode reads identically to anyone debugging it.
43
+ *
44
+ * These strings are wire contract with oxy-api, not log text.
45
+ */
46
+ private const val ERROR_CREDENTIAL_INVALID = "background_credential_invalid"
47
+ private const val ERROR_ACCOUNT_NOT_ON_DEVICE = "account_not_on_device"
48
+
49
+ internal sealed interface MintOutcome {
50
+ data class Minted(val accessToken: String, val expiresAt: Long, val accountId: String) : MintOutcome
51
+ /** An explicit server verdict: this credential is finished. Delete it. */
52
+ data object Revoked : MintOutcome
53
+ data class Transient(val cause: Exception) : MintOutcome
54
+ data class Malformed(val cause: Exception) : MintOutcome
55
+ }
56
+
57
+ suspend fun mint(credential: OxyBackgroundSessionStore.Credential): MintOutcome =
58
+ withContext(Dispatchers.IO) {
59
+ val endpoint = "${credential.baseUrl.trimEnd('/')}/session/device/background-token"
60
+ val body = JSONObject()
61
+ .put("deviceId", credential.deviceId)
62
+ .put("secret", credential.secret)
63
+ .toString()
64
+
65
+ val connection: HttpURLConnection = try {
66
+ (URL(endpoint).openConnection() as HttpURLConnection).apply {
67
+ requestMethod = "POST"
68
+ connectTimeout = CONNECT_TIMEOUT_MS
69
+ readTimeout = READ_TIMEOUT_MS
70
+ doOutput = true
71
+ // No Authorization header and no cookies, by design.
72
+ setRequestProperty("Content-Type", "application/json")
73
+ setRequestProperty("Accept", "application/json")
74
+ }
75
+ } catch (cause: IOException) {
76
+ return@withContext MintOutcome.Transient(cause)
77
+ }
78
+
79
+ try {
80
+ connection.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) }
81
+ val status = connection.responseCode
82
+ if (status != HttpURLConnection.HTTP_OK) {
83
+ return@withContext classifyFailure(status, readErrorBody(connection))
84
+ }
85
+ val payload = connection.inputStream.bufferedReader().use { it.readText() }
86
+ parseMintResponse(payload)
87
+ } catch (cause: IOException) {
88
+ // Connect/read failure, DNS, TLS, a dropped socket mid-body.
89
+ MintOutcome.Transient(cause)
90
+ } finally {
91
+ connection.disconnect()
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Read the error body, tolerating its absence — `errorStream` is null for some
97
+ * statuses, and a failure to read the explanation of a failure must not become
98
+ * a different failure.
99
+ */
100
+ private fun readErrorBody(connection: HttpURLConnection): String =
101
+ try {
102
+ connection.errorStream?.bufferedReader()?.use { it.readText() } ?: ""
103
+ } catch (cause: IOException) {
104
+ ""
105
+ }
106
+
107
+ /**
108
+ * A non-200 becomes [MintOutcome.Revoked] ONLY on an explicit verdict; every
109
+ * other status and every unrecognised 401 is [MintOutcome.Transient] so the
110
+ * credential is kept. Substring matching on the raw body (rather than parsing
111
+ * it) is deliberate: it is what core does, and it survives the body being an
112
+ * error page rather than the JSON envelope.
113
+ */
114
+ private fun classifyFailure(status: Int, body: String): MintOutcome {
115
+ if (status == HttpURLConnection.HTTP_UNAUTHORIZED &&
116
+ (body.contains(ERROR_CREDENTIAL_INVALID) || body.contains(ERROR_ACCOUNT_NOT_ON_DEVICE))
117
+ ) {
118
+ return MintOutcome.Revoked
119
+ }
120
+ return MintOutcome.Transient(IOException("POST /session/device/background-token responded $status"))
121
+ }
122
+
123
+ private fun parseMintResponse(payload: String): MintOutcome =
124
+ try {
125
+ val data = JSONObject(payload).getJSONObject("data")
126
+ val accessToken = data.getString("accessToken")
127
+ val accountId = data.getString("accountId")
128
+ val expiresAt = parseIsoInstant(data.getString("expiresAt"))
129
+ if (accessToken.isEmpty() || accountId.isEmpty()) {
130
+ MintOutcome.Malformed(JSONException("background-token response had an empty accessToken or accountId"))
131
+ } else {
132
+ MintOutcome.Minted(accessToken, expiresAt, accountId)
133
+ }
134
+ } catch (cause: JSONException) {
135
+ MintOutcome.Malformed(cause)
136
+ } catch (cause: ParseException) {
137
+ MintOutcome.Malformed(cause)
138
+ }
139
+
140
+ /**
141
+ * Parse the server's `expiresAt` into epoch millis.
142
+ *
143
+ * The shape is fixed rather than guessed: oxy-api emits it with JavaScript's
144
+ * `Date.toISOString()`, which is specified to produce exactly
145
+ * `yyyy-MM-ddTHH:mm:ss.sssZ`. `SimpleDateFormat` (rather than `java.time`)
146
+ * because `Instant.parse` needs API 26 or core-library desugaring, and this
147
+ * module must compile for every consuming app's minSdk without imposing a
148
+ * build-config requirement on them.
149
+ *
150
+ * Anything else throws [ParseException], which the caller classifies as
151
+ * `Malformed` — never a silent fallback to a made-up expiry, which would either
152
+ * throw away a valid token or keep using a dead one.
153
+ */
154
+ private fun parseIsoInstant(value: String): Long {
155
+ val format = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.US).apply {
156
+ timeZone = TimeZone.getTimeZone("UTC")
157
+ isLenient = false
158
+ }
159
+ val parsed = format.parse(value) ?: throw ParseException("unparseable expiresAt: $value", 0)
160
+ return parsed.time
161
+ }
162
+ }
@@ -0,0 +1,101 @@
1
+ package so.oxy.session
2
+
3
+ import android.content.Context
4
+ import expo.modules.kotlin.exception.Exceptions
5
+ import expo.modules.kotlin.modules.Module
6
+ import expo.modules.kotlin.modules.ModuleDefinition
7
+ import expo.modules.kotlin.records.Field
8
+ import expo.modules.kotlin.records.Record
9
+
10
+ /**
11
+ * JS side of the background session credential.
12
+ *
13
+ * JS is the only writer of the credential and native background code is the only
14
+ * consumer, so this bridge is deliberately one-way: put, clear, and a read of the
15
+ * account id (which JS needs to notice that the stored credential belongs to a
16
+ * different account than the one now signed in). There is no `get` — JS has no
17
+ * use for the secret it just wrote, and not exposing it keeps the secret's only
18
+ * reader native.
19
+ *
20
+ * Every function is a no-op-or-null on failure rather than throwing: the app must
21
+ * work identically whether or not background credentials are available, and a
22
+ * keystore hiccup must never break sign-in.
23
+ */
24
+ class OxyBackgroundSessionModule : Module() {
25
+ private val context: Context
26
+ get() = appContext.reactContext ?: throw Exceptions.ReactContextLost()
27
+
28
+ /**
29
+ * The provisioned credential as it crosses the bridge.
30
+ *
31
+ * `expiresAt` is epoch MILLIS, already parsed from the server's ISO-8601 string
32
+ * on the JS side — JS has a `Date`, so there is no reason to hand native a
33
+ * string to re-parse.
34
+ */
35
+ data class CredentialInput(
36
+ @Field val baseUrl: String = "",
37
+ @Field val deviceId: String = "",
38
+ @Field val secret: String = "",
39
+ @Field val accountId: String = "",
40
+ @Field val expiresAt: Double = 0.0,
41
+ ) : Record
42
+
43
+ override fun definition() = ModuleDefinition {
44
+ Name("OxyBackgroundSession")
45
+
46
+ /**
47
+ * Store (replacing) the credential. Rejects an incomplete or already-expired
48
+ * one rather than persisting something no mint could ever use.
49
+ */
50
+ AsyncFunction("put") { credential: CredentialInput ->
51
+ val expiresAt = credential.expiresAt.toLong()
52
+ if (
53
+ credential.baseUrl.isEmpty() ||
54
+ credential.deviceId.isEmpty() ||
55
+ credential.secret.isEmpty() ||
56
+ credential.accountId.isEmpty() ||
57
+ expiresAt <= System.currentTimeMillis()
58
+ ) {
59
+ return@AsyncFunction false
60
+ }
61
+ runCatching {
62
+ OxyBackgroundSessionStore.writeCredential(
63
+ context,
64
+ OxyBackgroundSessionStore.Credential(
65
+ baseUrl = credential.baseUrl,
66
+ deviceId = credential.deviceId,
67
+ secret = credential.secret,
68
+ accountId = credential.accountId,
69
+ expiresAt = expiresAt,
70
+ ),
71
+ )
72
+ }.isSuccess
73
+ }
74
+
75
+ /**
76
+ * Drop the credential and any cached token. Called on sign-out and BEFORE an
77
+ * account switch, so the window in which background code could serve the
78
+ * previous account's data is as short as a local write.
79
+ */
80
+ AsyncFunction("clear") {
81
+ runCatching { OxyBackgroundSessionStore.clear(context) }.isSuccess
82
+ }
83
+
84
+ /**
85
+ * What is currently stored — the account it belongs to and when it expires
86
+ * (epoch millis) — or null when there is no usable credential.
87
+ *
88
+ * This is what lets JS decide whether to provision at all: a credential for
89
+ * the signed-in account with plenty of life left needs no network call. It
90
+ * deliberately does NOT return the secret; native is its only reader.
91
+ */
92
+ AsyncFunction("peek") {
93
+ val stored = runCatching { OxyBackgroundSessionStore.readCredential(context) }.getOrNull()
94
+ ?: return@AsyncFunction null
95
+ mapOf(
96
+ "accountId" to stored.accountId,
97
+ "expiresAt" to stored.expiresAt.toDouble(),
98
+ )
99
+ }
100
+ }
101
+ }