@oxyhq/core 5.4.3 → 6.0.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 (186) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/HttpService.js +6 -3
  3. package/dist/cjs/OxyServices.base.js +7 -102
  4. package/dist/cjs/boot/coldBootV2.js +350 -0
  5. package/dist/cjs/boot/deviceBootReturn.js +152 -0
  6. package/dist/cjs/crypto/keyManager.js +95 -0
  7. package/dist/cjs/i18n/locales/en-US.json +13 -1
  8. package/dist/cjs/i18n/locales/es-ES.json +13 -1
  9. package/dist/cjs/i18n/locales/locales/en-US.json +13 -1
  10. package/dist/cjs/i18n/locales/locales/es-ES.json +13 -1
  11. package/dist/cjs/index.js +47 -43
  12. package/dist/cjs/mixins/OxyServices.accounts.js +6 -13
  13. package/dist/cjs/mixins/OxyServices.auth.js +66 -201
  14. package/dist/cjs/mixins/OxyServices.authorizedApps.js +38 -0
  15. package/dist/cjs/mixins/OxyServices.deviceBoot.js +119 -0
  16. package/dist/cjs/mixins/index.js +13 -17
  17. package/dist/cjs/session/SessionClient.js +44 -2
  18. package/dist/cjs/session/authStateStore.js +284 -0
  19. package/dist/cjs/session/createSessionClient.js +8 -2
  20. package/dist/cjs/session/refresh.js +264 -0
  21. package/dist/cjs/utils/accountUtils.js +1 -55
  22. package/dist/cjs/utils/authWebUrl.js +6 -15
  23. package/dist/cjs/utils/fapiAutoDetect.js +16 -64
  24. package/dist/cjs/utils/platform.js +19 -0
  25. package/dist/cjs/utils/ssoBounce.js +15 -340
  26. package/dist/cjs/utils/validationUtils.js +57 -0
  27. package/dist/esm/.tsbuildinfo +1 -1
  28. package/dist/esm/HttpService.js +6 -3
  29. package/dist/esm/OxyServices.base.js +7 -102
  30. package/dist/esm/boot/coldBootV2.js +344 -0
  31. package/dist/esm/boot/deviceBootReturn.js +146 -0
  32. package/dist/esm/crypto/keyManager.js +95 -0
  33. package/dist/esm/i18n/locales/en-US.json +13 -1
  34. package/dist/esm/i18n/locales/es-ES.json +13 -1
  35. package/dist/esm/i18n/locales/locales/en-US.json +13 -1
  36. package/dist/esm/i18n/locales/locales/es-ES.json +13 -1
  37. package/dist/esm/index.js +28 -18
  38. package/dist/esm/mixins/OxyServices.accounts.js +6 -13
  39. package/dist/esm/mixins/OxyServices.auth.js +66 -201
  40. package/dist/esm/mixins/OxyServices.authorizedApps.js +35 -0
  41. package/dist/esm/mixins/OxyServices.deviceBoot.js +116 -0
  42. package/dist/esm/mixins/index.js +13 -17
  43. package/dist/esm/session/SessionClient.js +44 -2
  44. package/dist/esm/session/authStateStore.js +278 -0
  45. package/dist/esm/session/createSessionClient.js +8 -2
  46. package/dist/esm/session/refresh.js +257 -0
  47. package/dist/esm/utils/accountUtils.js +0 -53
  48. package/dist/esm/utils/authWebUrl.js +6 -14
  49. package/dist/esm/utils/fapiAutoDetect.js +16 -63
  50. package/dist/esm/utils/platform.js +18 -0
  51. package/dist/esm/utils/ssoBounce.js +14 -324
  52. package/dist/esm/utils/validationUtils.js +56 -0
  53. package/dist/types/.tsbuildinfo +1 -1
  54. package/dist/types/HttpService.d.ts +13 -0
  55. package/dist/types/OxyServices.base.d.ts +0 -52
  56. package/dist/types/OxyServices.d.ts +0 -25
  57. package/dist/types/boot/coldBootV2.d.ts +76 -0
  58. package/dist/types/boot/deviceBootReturn.d.ts +83 -0
  59. package/dist/types/crypto/keyManager.d.ts +21 -0
  60. package/dist/types/index.d.ts +16 -21
  61. package/dist/types/mixins/OxyServices.accounts.d.ts +0 -2
  62. package/dist/types/mixins/OxyServices.analytics.d.ts +0 -2
  63. package/dist/types/mixins/OxyServices.appData.d.ts +0 -2
  64. package/dist/types/mixins/OxyServices.assets.d.ts +0 -2
  65. package/dist/types/mixins/OxyServices.auth.d.ts +35 -77
  66. package/dist/types/mixins/{OxyServices.redirect.d.ts → OxyServices.authorizedApps.d.ts} +35 -33
  67. package/dist/types/mixins/OxyServices.civic.d.ts +0 -2
  68. package/dist/types/mixins/OxyServices.connectedApps.d.ts +0 -2
  69. package/dist/types/mixins/OxyServices.contacts.d.ts +0 -2
  70. package/dist/types/mixins/OxyServices.deviceBoot.d.ts +110 -0
  71. package/dist/types/mixins/OxyServices.devices.d.ts +0 -2
  72. package/dist/types/mixins/OxyServices.features.d.ts +0 -2
  73. package/dist/types/mixins/OxyServices.identity.d.ts +0 -2
  74. package/dist/types/mixins/OxyServices.language.d.ts +0 -2
  75. package/dist/types/mixins/OxyServices.links.d.ts +0 -2
  76. package/dist/types/mixins/OxyServices.location.d.ts +0 -2
  77. package/dist/types/mixins/OxyServices.nodes.d.ts +0 -2
  78. package/dist/types/mixins/OxyServices.payment.d.ts +0 -2
  79. package/dist/types/mixins/OxyServices.privacy.d.ts +0 -2
  80. package/dist/types/mixins/OxyServices.reputation.d.ts +0 -2
  81. package/dist/types/mixins/OxyServices.security.d.ts +0 -2
  82. package/dist/types/mixins/OxyServices.topics.d.ts +0 -2
  83. package/dist/types/mixins/OxyServices.user.d.ts +0 -2
  84. package/dist/types/mixins/OxyServices.utility.d.ts +0 -2
  85. package/dist/types/mixins/index.d.ts +6 -9
  86. package/dist/types/models/interfaces.d.ts +0 -67
  87. package/dist/types/session/SessionClient.d.ts +38 -1
  88. package/dist/types/session/authStateStore.d.ts +119 -0
  89. package/dist/types/session/createSessionClient.d.ts +8 -1
  90. package/dist/types/session/refresh.d.ts +93 -0
  91. package/dist/types/utils/accountUtils.d.ts +0 -14
  92. package/dist/types/utils/authWebUrl.d.ts +6 -12
  93. package/dist/types/utils/fapiAutoDetect.d.ts +15 -38
  94. package/dist/types/utils/platform.d.ts +14 -0
  95. package/dist/types/utils/ssoBounce.d.ts +14 -262
  96. package/dist/types/utils/validationUtils.d.ts +15 -0
  97. package/package.json +2 -2
  98. package/src/HttpService.ts +19 -3
  99. package/src/OxyServices.base.ts +7 -112
  100. package/src/OxyServices.ts +0 -38
  101. package/src/boot/__tests__/coldBootV2.test.ts +317 -0
  102. package/src/boot/__tests__/deviceBootReturn.test.ts +158 -0
  103. package/src/boot/coldBootV2.ts +426 -0
  104. package/src/boot/deviceBootReturn.ts +195 -0
  105. package/src/crypto/__tests__/sharedDeviceToken.test.ts +24 -0
  106. package/src/crypto/keyManager.ts +101 -0
  107. package/src/i18n/locales/en-US.json +13 -1
  108. package/src/i18n/locales/es-ES.json +13 -1
  109. package/src/index.ts +78 -64
  110. package/src/mixins/OxyServices.accounts.ts +6 -13
  111. package/src/mixins/OxyServices.auth.ts +78 -253
  112. package/src/mixins/OxyServices.authorizedApps.ts +75 -0
  113. package/src/mixins/OxyServices.deviceBoot.ts +146 -0
  114. package/src/mixins/__tests__/OxyServices.deviceBoot.test.ts +107 -0
  115. package/src/mixins/__tests__/accounts.test.ts +17 -44
  116. package/src/mixins/__tests__/authorizedApps.test.ts +63 -0
  117. package/src/mixins/__tests__/passwordSignIn.test.ts +91 -0
  118. package/src/mixins/index.ts +16 -22
  119. package/src/models/interfaces.ts +0 -79
  120. package/src/session/SessionClient.ts +69 -3
  121. package/src/session/__tests__/SessionClient.additive.test.ts +92 -0
  122. package/src/session/__tests__/SessionClient.rest.test.ts +25 -0
  123. package/src/session/__tests__/SessionClient.socketFactory.test.ts +79 -0
  124. package/src/session/__tests__/SessionClient.state.test.ts +18 -5
  125. package/src/session/__tests__/authStateStore.test.ts +209 -0
  126. package/src/session/__tests__/refresh.test.ts +256 -0
  127. package/src/session/authStateStore.ts +335 -0
  128. package/src/session/createSessionClient.ts +9 -1
  129. package/src/session/refresh.ts +334 -0
  130. package/src/utils/__tests__/authWebUrl.test.ts +5 -29
  131. package/src/utils/__tests__/fapiAutoDetect.test.ts +5 -126
  132. package/src/utils/__tests__/validationUtils.test.ts +30 -0
  133. package/src/utils/accountUtils.ts +0 -62
  134. package/src/utils/authWebUrl.ts +6 -15
  135. package/src/utils/fapiAutoDetect.ts +16 -60
  136. package/src/utils/platform.ts +21 -0
  137. package/src/utils/ssoBounce.ts +14 -371
  138. package/src/utils/validationUtils.ts +62 -0
  139. package/dist/cjs/AuthManager.js +0 -1110
  140. package/dist/cjs/AuthManagerTypes.js +0 -13
  141. package/dist/cjs/CrossDomainAuth.js +0 -206
  142. package/dist/cjs/mixins/OxyServices.fedcm.js +0 -823
  143. package/dist/cjs/mixins/OxyServices.redirect.js +0 -95
  144. package/dist/cjs/mixins/OxyServices.silent.js +0 -204
  145. package/dist/cjs/mixins/OxyServices.sso.js +0 -208
  146. package/dist/cjs/utils/ssoEstablish.js +0 -110
  147. package/dist/cjs/utils/ssoReturn.js +0 -267
  148. package/dist/esm/AuthManager.js +0 -1105
  149. package/dist/esm/AuthManagerTypes.js +0 -12
  150. package/dist/esm/CrossDomainAuth.js +0 -201
  151. package/dist/esm/mixins/OxyServices.fedcm.js +0 -821
  152. package/dist/esm/mixins/OxyServices.redirect.js +0 -92
  153. package/dist/esm/mixins/OxyServices.silent.js +0 -202
  154. package/dist/esm/mixins/OxyServices.sso.js +0 -204
  155. package/dist/esm/utils/ssoEstablish.js +0 -107
  156. package/dist/esm/utils/ssoReturn.js +0 -263
  157. package/dist/types/AuthManager.d.ts +0 -380
  158. package/dist/types/AuthManagerTypes.d.ts +0 -81
  159. package/dist/types/CrossDomainAuth.d.ts +0 -164
  160. package/dist/types/mixins/OxyServices.fedcm.d.ts +0 -331
  161. package/dist/types/mixins/OxyServices.silent.d.ts +0 -132
  162. package/dist/types/mixins/OxyServices.sso.d.ts +0 -138
  163. package/dist/types/utils/ssoEstablish.d.ts +0 -85
  164. package/dist/types/utils/ssoReturn.d.ts +0 -146
  165. package/src/AuthManager.ts +0 -1269
  166. package/src/AuthManagerTypes.ts +0 -86
  167. package/src/CrossDomainAuth.ts +0 -243
  168. package/src/__tests__/authManager.cookiePath.test.ts +0 -390
  169. package/src/__tests__/authManager.security.test.ts +0 -377
  170. package/src/__tests__/crossDomainAuth.test.ts +0 -116
  171. package/src/__tests__/establishDeviceRefreshSlot.test.ts +0 -221
  172. package/src/mixins/OxyServices.fedcm.ts +0 -1026
  173. package/src/mixins/OxyServices.redirect.ts +0 -122
  174. package/src/mixins/OxyServices.silent.ts +0 -272
  175. package/src/mixins/OxyServices.sso.ts +0 -261
  176. package/src/mixins/__tests__/constructorAuthWebUrl.test.ts +0 -85
  177. package/src/mixins/__tests__/fedcm.test.ts +0 -667
  178. package/src/mixins/__tests__/sessionBaseUrl.test.ts +0 -61
  179. package/src/mixins/__tests__/silent.test.ts +0 -102
  180. package/src/mixins/__tests__/sso.test.ts +0 -228
  181. package/src/utils/__tests__/consumeSsoReturn.test.ts +0 -816
  182. package/src/utils/__tests__/ssoBounce.test.ts +0 -219
  183. package/src/utils/__tests__/ssoEstablish.test.ts +0 -204
  184. package/src/utils/__tests__/ssoReturn.test.ts +0 -251
  185. package/src/utils/ssoEstablish.ts +0 -174
  186. package/src/utils/ssoReturn.ts +0 -372
@@ -0,0 +1,335 @@
1
+ /**
2
+ * Persisted auth state — ONE shape, web + native.
3
+ *
4
+ * The device-first session model (auth-centralization wave 1) persists the
5
+ * rotating refresh-token family head per ORIGIN so a reload restores the
6
+ * session locally without a redirect. This module is the storage seam: a tiny
7
+ * `load / save / clear` interface plus platform factories, so the cold boot
8
+ * (`coldBootV2`) and the unified refresh handler (`refresh.ts`) never touch a
9
+ * platform storage API directly.
10
+ *
11
+ * Platform-agnostic — the native factory takes an INJECTED key/value store
12
+ * (`@oxyhq/services` passes a SecureStore-backed adapter) so `@oxyhq/core`
13
+ * never imports `expo-secure-store`. The web factory is self-contained
14
+ * (`localStorage`) and degrades to in-memory when storage is unavailable
15
+ * (sandboxed iframe `SecurityError`, private-mode quota, SSR).
16
+ *
17
+ * ESM-safe (no `require()`).
18
+ */
19
+
20
+ /**
21
+ * The persisted session credential set for a single origin.
22
+ *
23
+ * `refreshToken` is the rotating single-use family head; `sessionId` + `userId`
24
+ * identify the owning device session and account. `deviceToken` is the opaque,
25
+ * add-only device attribution token (mirrored to the shared keychain on native
26
+ * so every Oxy app on one phone shares one DeviceSession).
27
+ *
28
+ * `accessToken` + `expiresAt` are OPTIONAL warm-boot fields. Persisting them
29
+ * lets the cold boot plant a still-valid access token on the very first paint
30
+ * WITHOUT a blocking `/auth/refresh-token` round-trip — the proactive scheduler
31
+ * then rotates it in the background. They are a strict optimization: the store
32
+ * is fully functional (via `refreshToken`) when they are absent or stale, and
33
+ * the access token is short-lived, so persisting it adds no exposure the
34
+ * already-persisted refresh token does not (see the plan's XSS risk note — the
35
+ * refresh token is the dominant secret either way).
36
+ */
37
+ export interface PersistedAuthState {
38
+ sessionId: string;
39
+ refreshToken: string;
40
+ userId: string;
41
+ deviceToken?: string;
42
+ /** Optional warm-boot access token (short-lived; see interface docs). */
43
+ accessToken?: string;
44
+ /** Optional warm-boot access-token expiry, ISO-8601. */
45
+ expiresAt?: string;
46
+ }
47
+
48
+ /**
49
+ * The storage seam consumed by the cold boot and the refresh handler. Async
50
+ * throughout so one interface fits both synchronous web `localStorage` and
51
+ * asynchronous native SecureStore/AsyncStorage.
52
+ *
53
+ * Two lifetimes:
54
+ * - The SESSION credential blob (`load`/`save`/`clear`) is per-sign-in and is
55
+ * wiped on `clear()` (sign-out).
56
+ * - The DEVICE token (`loadDeviceToken`/`saveDeviceToken`/`clearDeviceToken`)
57
+ * is long-lived device attribution that SURVIVES `clear()`: a signed-out
58
+ * browser is still the same device, and a later in-app (cross-apex,
59
+ * cookie-less) login sends this token so the new session joins the SAME
60
+ * server-side DeviceSession. Only an explicit device signout-all
61
+ * (`clearDeviceToken`) removes it.
62
+ */
63
+ export interface AuthStateStore {
64
+ load(): Promise<PersistedAuthState | null>;
65
+ save(state: PersistedAuthState): Promise<void>;
66
+ clear(): Promise<void>;
67
+ loadDeviceToken(): Promise<string | null>;
68
+ saveDeviceToken(token: string): Promise<void>;
69
+ clearDeviceToken(): Promise<void>;
70
+ }
71
+
72
+ /**
73
+ * The minimal async key/value surface a native store must provide. Matches
74
+ * both `expo-secure-store` (wrapped) and `@react-native-async-storage`.
75
+ */
76
+ export interface NativeKeyValueStorage {
77
+ getItem(key: string): Promise<string | null>;
78
+ setItem(key: string, value: string): Promise<void>;
79
+ removeItem(key: string): Promise<void>;
80
+ }
81
+
82
+ /**
83
+ * Versioned storage key. The `.v1` suffix lets a future shape change ship a
84
+ * `.v2` key without reading a stale/incompatible `.v1` blob. Distinct from the
85
+ * `oxy_shared_*` keychain keys in `KeyManager`, so it never collides.
86
+ */
87
+ export const AUTH_STATE_STORAGE_KEY = 'oxy.auth.v1';
88
+
89
+ /**
90
+ * Storage key for the long-lived device-attribution token. Separate from
91
+ * {@link AUTH_STATE_STORAGE_KEY} because it must OUTLIVE a session `clear()`
92
+ * (sign-out) — the device is unchanged across sign-ins.
93
+ */
94
+ export const DEVICE_TOKEN_STORAGE_KEY = 'oxy.device.v1';
95
+
96
+ /**
97
+ * Parse + shape-validate a stored blob. Returns `null` for anything that is
98
+ * not a well-formed {@link PersistedAuthState} (absent, malformed JSON, wrong
99
+ * types) so a corrupt entry degrades to "signed out" rather than throwing.
100
+ */
101
+ function deserialize(raw: string | null): PersistedAuthState | null {
102
+ if (!raw) {
103
+ return null;
104
+ }
105
+ let parsed: unknown;
106
+ try {
107
+ parsed = JSON.parse(raw);
108
+ } catch {
109
+ return null;
110
+ }
111
+ if (!parsed || typeof parsed !== 'object') {
112
+ return null;
113
+ }
114
+ const candidate = parsed as Record<string, unknown>;
115
+ if (
116
+ typeof candidate.sessionId !== 'string' ||
117
+ typeof candidate.refreshToken !== 'string' ||
118
+ typeof candidate.userId !== 'string' ||
119
+ candidate.sessionId.length === 0 ||
120
+ candidate.refreshToken.length === 0 ||
121
+ candidate.userId.length === 0
122
+ ) {
123
+ return null;
124
+ }
125
+ const state: PersistedAuthState = {
126
+ sessionId: candidate.sessionId,
127
+ refreshToken: candidate.refreshToken,
128
+ userId: candidate.userId,
129
+ };
130
+ if (typeof candidate.deviceToken === 'string' && candidate.deviceToken.length > 0) {
131
+ state.deviceToken = candidate.deviceToken;
132
+ }
133
+ if (typeof candidate.accessToken === 'string' && candidate.accessToken.length > 0) {
134
+ state.accessToken = candidate.accessToken;
135
+ }
136
+ if (typeof candidate.expiresAt === 'string' && candidate.expiresAt.length > 0) {
137
+ state.expiresAt = candidate.expiresAt;
138
+ }
139
+ return state;
140
+ }
141
+
142
+ /**
143
+ * A process-lifetime, in-memory {@link AuthStateStore}. Used directly for
144
+ * tests/SSR and as the degraded fallback of the web store when `localStorage`
145
+ * is unreachable. Not durable across reloads — that is acceptable for the
146
+ * fallback because the alternative (throwing) would break cold boot entirely.
147
+ */
148
+ export function createMemoryAuthStateStore(): AuthStateStore {
149
+ let current: PersistedAuthState | null = null;
150
+ let deviceToken: string | null = null;
151
+ return {
152
+ load: async () => current,
153
+ save: async (state) => {
154
+ current = state;
155
+ },
156
+ clear: async () => {
157
+ current = null;
158
+ },
159
+ loadDeviceToken: async () => deviceToken,
160
+ saveDeviceToken: async (token) => {
161
+ deviceToken = token;
162
+ },
163
+ clearDeviceToken: async () => {
164
+ deviceToken = null;
165
+ },
166
+ };
167
+ }
168
+
169
+ /**
170
+ * Read the ambient `localStorage`, tolerating the case where merely ACCESSING
171
+ * `window.localStorage` throws. In a sandboxed / cross-origin iframe the getter
172
+ * itself raises `SecurityError` (not the method calls), so the access must be
173
+ * inside the try. Returns `null` when storage is unavailable.
174
+ */
175
+ function safeGetLocalStorage(): Storage | null {
176
+ try {
177
+ if (typeof globalThis === 'undefined') {
178
+ return null;
179
+ }
180
+ const store = (globalThis as { localStorage?: Storage }).localStorage;
181
+ return store ?? null;
182
+ } catch {
183
+ return null;
184
+ }
185
+ }
186
+
187
+ /**
188
+ * A `localStorage`-backed {@link AuthStateStore} under the versioned
189
+ * {@link AUTH_STATE_STORAGE_KEY}.
190
+ *
191
+ * Resilience:
192
+ * - If `localStorage` is unreachable (sandboxed-iframe `SecurityError`, SSR),
193
+ * the whole store degrades to an in-memory {@link createMemoryAuthStateStore}
194
+ * for this page's lifetime — never throws on construction.
195
+ * - Individual `getItem`/`setItem`/`removeItem` are each wrapped: a read that
196
+ * throws yields `null`; a write that throws (quota, private mode) is
197
+ * swallowed. The persisted-refresh lane treats a failed persist as "no
198
+ * durable state" (falls back to the bootstrap hop) rather than crashing.
199
+ */
200
+ export function createWebAuthStateStore(): AuthStateStore {
201
+ const storage = safeGetLocalStorage();
202
+ if (!storage) {
203
+ return createMemoryAuthStateStore();
204
+ }
205
+ // In-memory mirror so a FAILED persist (quota / private mode / locked store)
206
+ // does not silently lose the session for this page's lifetime. `undefined`
207
+ // means "never written this session" → fall back to storage; any set value
208
+ // (including `null` after clear) is authoritative and preferred over storage.
209
+ let sessionMirror: PersistedAuthState | null | undefined;
210
+ let deviceTokenMirror: string | null | undefined;
211
+ return {
212
+ load: async () => {
213
+ if (sessionMirror !== undefined) {
214
+ return sessionMirror;
215
+ }
216
+ try {
217
+ return deserialize(storage.getItem(AUTH_STATE_STORAGE_KEY));
218
+ } catch {
219
+ return null;
220
+ }
221
+ },
222
+ save: async (state) => {
223
+ sessionMirror = state; // mirror FIRST — authoritative even if persist fails
224
+ try {
225
+ storage.setItem(AUTH_STATE_STORAGE_KEY, JSON.stringify(state));
226
+ } catch {
227
+ // Quota / private-mode / disabled storage — non-fatal. The session
228
+ // stays live via the in-memory mirror; only reload durability is lost.
229
+ }
230
+ },
231
+ clear: async () => {
232
+ sessionMirror = null;
233
+ try {
234
+ storage.removeItem(AUTH_STATE_STORAGE_KEY);
235
+ } catch {
236
+ // Non-fatal — see save().
237
+ }
238
+ },
239
+ loadDeviceToken: async () => {
240
+ if (deviceTokenMirror !== undefined) {
241
+ return deviceTokenMirror;
242
+ }
243
+ try {
244
+ return storage.getItem(DEVICE_TOKEN_STORAGE_KEY);
245
+ } catch {
246
+ return null;
247
+ }
248
+ },
249
+ saveDeviceToken: async (token) => {
250
+ deviceTokenMirror = token;
251
+ try {
252
+ storage.setItem(DEVICE_TOKEN_STORAGE_KEY, token);
253
+ } catch {
254
+ // Non-fatal — see save().
255
+ }
256
+ },
257
+ clearDeviceToken: async () => {
258
+ deviceTokenMirror = null;
259
+ try {
260
+ storage.removeItem(DEVICE_TOKEN_STORAGE_KEY);
261
+ } catch {
262
+ // Non-fatal — see save().
263
+ }
264
+ },
265
+ };
266
+ }
267
+
268
+ /**
269
+ * A native {@link AuthStateStore} over an injected async key/value store.
270
+ *
271
+ * `@oxyhq/core` never imports `expo-secure-store`; `@oxyhq/services` constructs
272
+ * the SecureStore-backed adapter and passes it here. Every operation is wrapped
273
+ * so a storage exception degrades gracefully (read → `null`, write → swallowed)
274
+ * exactly like the web store.
275
+ */
276
+ export function createNativeAuthStateStore(storage: NativeKeyValueStorage): AuthStateStore {
277
+ // Same in-memory mirror as the web store — a locked/failed SecureStore write
278
+ // must not silently lose the session for the app's lifetime.
279
+ let sessionMirror: PersistedAuthState | null | undefined;
280
+ let deviceTokenMirror: string | null | undefined;
281
+ return {
282
+ load: async () => {
283
+ if (sessionMirror !== undefined) {
284
+ return sessionMirror;
285
+ }
286
+ try {
287
+ return deserialize(await storage.getItem(AUTH_STATE_STORAGE_KEY));
288
+ } catch {
289
+ return null;
290
+ }
291
+ },
292
+ save: async (state) => {
293
+ sessionMirror = state;
294
+ try {
295
+ await storage.setItem(AUTH_STATE_STORAGE_KEY, JSON.stringify(state));
296
+ } catch {
297
+ // Non-fatal — session stays live via the in-memory mirror.
298
+ }
299
+ },
300
+ clear: async () => {
301
+ sessionMirror = null;
302
+ try {
303
+ await storage.removeItem(AUTH_STATE_STORAGE_KEY);
304
+ } catch {
305
+ // Non-fatal.
306
+ }
307
+ },
308
+ loadDeviceToken: async () => {
309
+ if (deviceTokenMirror !== undefined) {
310
+ return deviceTokenMirror;
311
+ }
312
+ try {
313
+ return await storage.getItem(DEVICE_TOKEN_STORAGE_KEY);
314
+ } catch {
315
+ return null;
316
+ }
317
+ },
318
+ saveDeviceToken: async (token) => {
319
+ deviceTokenMirror = token;
320
+ try {
321
+ await storage.setItem(DEVICE_TOKEN_STORAGE_KEY, token);
322
+ } catch {
323
+ // Non-fatal.
324
+ }
325
+ },
326
+ clearDeviceToken: async () => {
327
+ deviceTokenMirror = null;
328
+ try {
329
+ await storage.removeItem(DEVICE_TOKEN_STORAGE_KEY);
330
+ } catch {
331
+ // Non-fatal.
332
+ }
333
+ },
334
+ };
335
+ }
@@ -1,5 +1,6 @@
1
1
  import type { OxyServices } from '../OxyServices';
2
2
  import { SessionClient, type TokenTransport } from './SessionClient';
3
+ import type { SocketIOFactory } from './socketLoader';
3
4
  import { createSessionClientHost } from './sessionClientHost';
4
5
 
5
6
  /**
@@ -17,15 +18,22 @@ import { createSessionClientHost } from './sessionClientHost';
17
18
  * The host is returned alongside the client (not just the client) so the
18
19
  * caller can call `host.setCurrentAccountId(...)` as the active account
19
20
  * changes.
21
+ *
22
+ * `socketFactory` is the statically-injected `socket.io-client` `io` export.
23
+ * Consumers that bundle socket.io-client as a real dependency pass it so
24
+ * realtime sync never depends on core's lazy dynamic import of a bare
25
+ * specifier (bundler-fragile in Metro/Expo-web and Vite against the published
26
+ * dist). When omitted, the client falls back to the lazy loader.
20
27
  */
21
28
  export function createSessionClient(
22
29
  oxyServices: OxyServices,
23
30
  transport: TokenTransport,
31
+ socketFactory?: SocketIOFactory,
24
32
  ): {
25
33
  client: SessionClient;
26
34
  host: ReturnType<typeof createSessionClientHost>;
27
35
  } {
28
36
  const host = createSessionClientHost(oxyServices);
29
- const client = new SessionClient(host, { transport });
37
+ const client = new SessionClient(host, { transport, socketFactory });
30
38
  return { client, host };
31
39
  }
@@ -0,0 +1,334 @@
1
+ /**
2
+ * Unified token refresh — THE single refresh implementation for web + native.
3
+ *
4
+ * Before device-first, refresh was duplicated: `@oxyhq/auth`'s
5
+ * `session/tokenRefresh.ts` (per-apex `/auth/silent` iframe) and
6
+ * `@oxyhq/services`'s `inSessionTokenRefresh.ts` (native shared-key). This
7
+ * module replaces both with ONE persisted-refresh-token rotation shared by
8
+ * every consumer:
9
+ *
10
+ * - `refreshPersistedSession` — arm 1 rotates the stored refresh-token family
11
+ * (`POST /auth/refresh-token`), planting + persisting the rotated pair; arm 2
12
+ * (native only) re-mints via the shared-keychain identity when there is no
13
+ * live refresh token. It is used BOTH reactively (wrapped as the
14
+ * `AuthRefreshHandler` installed on `HttpService`) AND proactively (the
15
+ * cold-boot `stored-tokens` step calls it directly).
16
+ * - `createAuthRefreshHandler` / `installAuthRefreshHandler` wire arm 1+2 into
17
+ * `HttpService.setAuthRefreshHandler`, keeping that layer's single-flight
18
+ * dedup + cooldown (this module does NOT reimplement them).
19
+ * - `startTokenRefreshScheduler` — a proactive scheduler (lifted from the
20
+ * better of the two prior duplicates, `@oxyhq/auth`'s `tokenRefresh.ts`),
21
+ * decoupled from any React / auth-sdk type: refreshes ~60s before `exp`,
22
+ * re-arms on token change + web tab-focus, `.unref?.()`s its timer in Node.
23
+ *
24
+ * Framework-free; no module-level mutable state.
25
+ */
26
+ import type { OxyServices } from '../OxyServices';
27
+ import type { AuthRefreshHandler, AuthRefreshReason } from '../HttpService';
28
+ import type { AuthStateStore, PersistedAuthState } from './authStateStore';
29
+ import { isNative } from '../utils/platform';
30
+ import { logger } from '../utils/loggerUtils';
31
+
32
+ /**
33
+ * Lead time (ms) before access-token expiry at which the proactive scheduler
34
+ * refreshes. Mirrors `HttpService`'s per-request `TOKEN_REFRESH_LEAD_SECONDS`
35
+ * (60s) so the scheduled refresh and the request-time preflight refresh use
36
+ * the same window — the scheduler just fires it during idle/background.
37
+ */
38
+ export const TOKEN_REFRESH_LEAD_MS = 60_000;
39
+
40
+ /**
41
+ * Max `setTimeout` delay (2^31 − 1 ms, ~24.8 days). A larger delay overflows
42
+ * the int32 timer field and fires IMMEDIATELY — with a long-TTL token that
43
+ * turns the reschedule-on-finish loop into a tight busy refresh. Clamp to it.
44
+ */
45
+ const MAX_TIMEOUT_DELAY_MS = 2_147_483_647;
46
+
47
+ /**
48
+ * Floor (ms) on ANY scheduled delay. An already-expired / in-lead-window token
49
+ * computes a non-positive `exp − now − lead`; without this floor that becomes
50
+ * `setTimeout(…, 0)`, and a FAILING refresh (offline / server error) would
51
+ * re-arm at 0 in the finally block → a tight 100%-CPU busy loop. The floor
52
+ * guarantees every re-arm yields the event loop.
53
+ */
54
+ const MIN_SCHEDULE_DELAY_MS = 1_000;
55
+
56
+ /**
57
+ * Backoff schedule (ms) applied when a scheduled refresh FAILS: first retry
58
+ * after {@link MIN_FAILURE_BACKOFF_MS}, doubling up to {@link MAX_FAILURE_BACKOFF_MS}.
59
+ * Reset to 0 on any success or token change. This is what converts the former
60
+ * zero-delay failure loop into a bounded, backing-off retry.
61
+ */
62
+ const MIN_FAILURE_BACKOFF_MS = 5_000;
63
+ const MAX_FAILURE_BACKOFF_MS = 5 * 60_000;
64
+
65
+ /**
66
+ * Error codes (in addition to HTTP 401/403) that mean the stored refresh token
67
+ * is permanently unusable — the family was revoked or a reuse was detected. On
68
+ * any of these the persisted store is CLEARED (the session is truly over);
69
+ * transient failures (network, 5xx) leave the store intact so a later attempt
70
+ * can still succeed.
71
+ */
72
+ const REVOKED_REFRESH_CODES = new Set([
73
+ 'invalid_grant',
74
+ 'refresh_token_revoked',
75
+ 'refresh_token_reuse',
76
+ 'token_reuse',
77
+ 'invalid_token',
78
+ ]);
79
+
80
+ interface HttpishError {
81
+ status?: number;
82
+ code?: string;
83
+ }
84
+
85
+ /** Does this error mean the refresh token is permanently dead (vs. transient)? */
86
+ function isRevokedRefreshError(error: unknown): boolean {
87
+ if (!error || typeof error !== 'object') {
88
+ return false;
89
+ }
90
+ const e = error as HttpishError;
91
+ if (e.status === 401 || e.status === 403) {
92
+ return true;
93
+ }
94
+ return typeof e.code === 'string' && REVOKED_REFRESH_CODES.has(e.code);
95
+ }
96
+
97
+ export interface RefreshDeps {
98
+ oxy: OxyServices;
99
+ store: AuthStateStore;
100
+ /**
101
+ * Whether to fall back to the native shared-keychain re-mint (arm 2) when
102
+ * there is no live refresh token / arm 1 is revoked. Defaults to `isNative()`
103
+ * — web has no shared keychain. Exposed for tests.
104
+ */
105
+ allowSharedKeyFallback?: boolean;
106
+ }
107
+
108
+ /**
109
+ * Rotate the persisted session and return the fresh access token, or `null`
110
+ * when no arm could produce one.
111
+ *
112
+ * Arm 1 (`POST /auth/refresh-token`): if the store holds a refresh token, rotate
113
+ * it — on success plant + persist the rotated pair; on a REVOKED error clear the
114
+ * store; on a transient error leave the store and return `null`.
115
+ *
116
+ * Arm 2 (native shared-keychain): when there is no refresh token or arm 1 was
117
+ * revoked, re-mint via `signInWithSharedIdentity` (which plants tokens). The
118
+ * shared keychain — not the per-origin store — is the durable native credential,
119
+ * so this arm does not write the store.
120
+ */
121
+ export async function refreshPersistedSession(deps: RefreshDeps): Promise<string | null> {
122
+ const { oxy, store } = deps;
123
+ const allowSharedKeyFallback = deps.allowSharedKeyFallback ?? isNative();
124
+
125
+ const persisted = await store.load();
126
+
127
+ if (persisted?.refreshToken) {
128
+ try {
129
+ const rotated = await oxy.refreshWithToken(persisted.refreshToken);
130
+ oxy.setTokens(rotated.accessToken);
131
+ const next: PersistedAuthState = {
132
+ sessionId: rotated.sessionId,
133
+ refreshToken: rotated.refreshToken,
134
+ userId: persisted.userId,
135
+ accessToken: rotated.accessToken,
136
+ expiresAt: rotated.expiresAt,
137
+ };
138
+ if (persisted.deviceToken) {
139
+ next.deviceToken = persisted.deviceToken;
140
+ }
141
+ await store.save(next);
142
+ return rotated.accessToken;
143
+ } catch (error) {
144
+ if (isRevokedRefreshError(error)) {
145
+ await store.clear();
146
+ // Fall through to the native shared-key arm below — on a shared-key
147
+ // device the refresh family being revoked does not end the session.
148
+ } else {
149
+ logger.debug(
150
+ 'Persisted refresh failed (transient) — keeping store',
151
+ { component: 'refresh', method: 'refreshPersistedSession' },
152
+ error,
153
+ );
154
+ return null;
155
+ }
156
+ }
157
+ }
158
+
159
+ if (allowSharedKeyFallback) {
160
+ try {
161
+ const session = await oxy.signInWithSharedIdentity();
162
+ if (session?.accessToken) {
163
+ return session.accessToken;
164
+ }
165
+ } catch (error) {
166
+ logger.debug(
167
+ 'Shared-key refresh fallback failed',
168
+ { component: 'refresh', method: 'refreshPersistedSession' },
169
+ error,
170
+ );
171
+ }
172
+ }
173
+
174
+ return null;
175
+ }
176
+
177
+ /**
178
+ * Build the reactive `AuthRefreshHandler` (arm 1 + arm 2). Install it via
179
+ * {@link installAuthRefreshHandler} or directly on
180
+ * `oxy.httpService.setAuthRefreshHandler`. `HttpService` owns single-flight
181
+ * dedup + cooldown, so the timer, the request-time preflight, and a 401 all
182
+ * collapse to one network attempt.
183
+ */
184
+ export function createAuthRefreshHandler(deps: RefreshDeps): AuthRefreshHandler {
185
+ return async (_reason: AuthRefreshReason): Promise<string | null> => {
186
+ return refreshPersistedSession(deps);
187
+ };
188
+ }
189
+
190
+ /**
191
+ * Install the unified refresh handler on the owner client's `HttpService`.
192
+ * Returns a disposer that removes it.
193
+ */
194
+ export function installAuthRefreshHandler(deps: RefreshDeps): () => void {
195
+ deps.oxy.httpService.setAuthRefreshHandler(createAuthRefreshHandler(deps));
196
+ return () => {
197
+ deps.oxy.httpService.setAuthRefreshHandler(null);
198
+ };
199
+ }
200
+
201
+ /** Handle returned by {@link startTokenRefreshScheduler}; `dispose()` tears it down. */
202
+ export interface TokenRefreshSchedulerHandle {
203
+ dispose(): void;
204
+ }
205
+
206
+ /**
207
+ * Start the proactive refresh scheduler against `oxy`.
208
+ *
209
+ * Schedules a single timer to fire {@link TOKEN_REFRESH_LEAD_MS} before the
210
+ * current access token's `exp`, calling
211
+ * `oxy.httpService.refreshAccessToken('preflight')` (which runs the installed
212
+ * handler; deduped + cooldown-guarded). After every attempt it reschedules
213
+ * from the possibly-rotated token. It also reschedules whenever the token
214
+ * changes (a sign-out that clears the token cancels the timer) and, on web
215
+ * tab-focus, refreshes immediately if already inside the lead window (a
216
+ * long-hidden tab throttles timers, so the token can be expired on return).
217
+ *
218
+ * No-ops cleanly when there is no token or an opaque/no-`exp` token — the
219
+ * reactive 401 path stays the only refresh trigger in that case. The timer is
220
+ * `.unref?.()`-ed so it never keeps a Node/Jest event loop alive.
221
+ */
222
+ export function startTokenRefreshScheduler(oxy: OxyServices): TokenRefreshSchedulerHandle {
223
+ let disposed = false;
224
+ let timer: ReturnType<typeof setTimeout> | null = null;
225
+ // 0 = no active backoff; grows on consecutive failures, resets on success /
226
+ // token change. Keeps a failing refresh from re-arming at zero delay.
227
+ let failureBackoffMs = 0;
228
+
229
+ const clearTimer = (): void => {
230
+ if (timer !== null) {
231
+ clearTimeout(timer);
232
+ timer = null;
233
+ }
234
+ };
235
+
236
+ /** Arm the timer for `delayMs`, flooring at {@link MIN_SCHEDULE_DELAY_MS} and capping at the int32 max. */
237
+ const armTimer = (delayMs: number): void => {
238
+ clearTimer();
239
+ const clamped = Math.min(Math.max(delayMs, MIN_SCHEDULE_DELAY_MS), MAX_TIMEOUT_DELAY_MS);
240
+ timer = setTimeout(runRefresh, clamped);
241
+ // Never keep a Node/Jest event loop alive for a background refresh timer.
242
+ timer.unref?.();
243
+ };
244
+
245
+ /** Schedule the next refresh from the current token's expiry (the healthy path). */
246
+ const scheduleFromExpiry = (): void => {
247
+ clearTimer();
248
+ if (disposed || !oxy.getAccessToken()) {
249
+ return;
250
+ }
251
+ const expSeconds = oxy.getAccessTokenExpiry();
252
+ if (expSeconds === null) {
253
+ return;
254
+ }
255
+ armTimer(expSeconds * 1000 - Date.now() - TOKEN_REFRESH_LEAD_MS);
256
+ };
257
+
258
+ const runRefresh = (): void => {
259
+ // Clear any pending timer up front so an out-of-band trigger (focus) plus
260
+ // a fired timer can never double-run.
261
+ clearTimer();
262
+ void oxy.httpService.refreshAccessToken('preflight')
263
+ .then((token) => Boolean(token))
264
+ .catch(() => false)
265
+ .then((ok) => {
266
+ if (disposed) {
267
+ return;
268
+ }
269
+ if (ok) {
270
+ // Success — drop any backoff and re-arm from the rotated token's exp.
271
+ failureBackoffMs = 0;
272
+ scheduleFromExpiry();
273
+ return;
274
+ }
275
+ // Failure — back off (never re-arm at zero) and retry.
276
+ failureBackoffMs =
277
+ failureBackoffMs === 0
278
+ ? MIN_FAILURE_BACKOFF_MS
279
+ : Math.min(failureBackoffMs * 2, MAX_FAILURE_BACKOFF_MS);
280
+ armTimer(failureBackoffMs);
281
+ });
282
+ };
283
+
284
+ /** Public (re)schedule entry: a fresh token / focus signal — drop backoff and arm from expiry. */
285
+ const schedule = (): void => {
286
+ failureBackoffMs = 0;
287
+ scheduleFromExpiry();
288
+ };
289
+
290
+ const onFocus = (): void => {
291
+ if (disposed || !oxy.getAccessToken()) {
292
+ return;
293
+ }
294
+ const expSeconds = oxy.getAccessTokenExpiry();
295
+ if (expSeconds === null) {
296
+ return;
297
+ }
298
+ const remainingMs = expSeconds * 1000 - Date.now();
299
+ if (remainingMs <= TOKEN_REFRESH_LEAD_MS) {
300
+ runRefresh();
301
+ } else {
302
+ schedule();
303
+ }
304
+ };
305
+
306
+ const unsubscribeTokens = oxy.onTokensChanged(() => {
307
+ if (!disposed) {
308
+ schedule();
309
+ }
310
+ });
311
+
312
+ let removeFocusListener: (() => void) | null = null;
313
+ if (typeof document !== 'undefined') {
314
+ const handler = (): void => {
315
+ if (document.visibilityState === 'visible') {
316
+ onFocus();
317
+ }
318
+ };
319
+ document.addEventListener('visibilitychange', handler);
320
+ removeFocusListener = () => document.removeEventListener('visibilitychange', handler);
321
+ }
322
+
323
+ schedule();
324
+
325
+ return {
326
+ dispose(): void {
327
+ disposed = true;
328
+ clearTimer();
329
+ unsubscribeTokens();
330
+ removeFocusListener?.();
331
+ removeFocusListener = null;
332
+ },
333
+ };
334
+ }