@oxyhq/core 5.5.0 → 7.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 (196) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/HttpService.js +7 -4
  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 +54 -45
  12. package/dist/cjs/mixins/OxyServices.accounts.js +13 -26
  13. package/dist/cjs/mixins/OxyServices.auth.js +66 -201
  14. package/dist/cjs/mixins/OxyServices.deviceBoot.js +119 -0
  15. package/dist/cjs/mixins/index.js +8 -17
  16. package/dist/cjs/server/index.js +2 -2
  17. package/dist/cjs/session/SessionClient.js +40 -1
  18. package/dist/cjs/session/authStateStore.js +284 -0
  19. package/dist/cjs/session/refresh.js +264 -0
  20. package/dist/cjs/shared/utils/debugUtils.js +3 -3
  21. package/dist/cjs/utils/accountUtils.js +1 -55
  22. package/dist/cjs/utils/authWebUrl.js +10 -39
  23. package/dist/cjs/utils/coldBoot.js +10 -8
  24. package/dist/cjs/utils/platform.js +19 -0
  25. package/dist/cjs/utils/registrableApex.js +49 -0
  26. package/dist/cjs/utils/ssoBounce.js +15 -362
  27. package/dist/cjs/utils/validationUtils.js +57 -0
  28. package/dist/esm/.tsbuildinfo +1 -1
  29. package/dist/esm/HttpService.js +7 -4
  30. package/dist/esm/OxyServices.base.js +7 -102
  31. package/dist/esm/boot/coldBootV2.js +344 -0
  32. package/dist/esm/boot/deviceBootReturn.js +146 -0
  33. package/dist/esm/crypto/keyManager.js +95 -0
  34. package/dist/esm/i18n/locales/en-US.json +13 -1
  35. package/dist/esm/i18n/locales/es-ES.json +13 -1
  36. package/dist/esm/i18n/locales/locales/en-US.json +13 -1
  37. package/dist/esm/i18n/locales/locales/es-ES.json +13 -1
  38. package/dist/esm/index.js +34 -17
  39. package/dist/esm/mixins/OxyServices.accounts.js +13 -26
  40. package/dist/esm/mixins/OxyServices.auth.js +66 -201
  41. package/dist/esm/mixins/OxyServices.deviceBoot.js +116 -0
  42. package/dist/esm/mixins/index.js +8 -17
  43. package/dist/esm/server/index.js +1 -1
  44. package/dist/esm/session/SessionClient.js +40 -1
  45. package/dist/esm/session/authStateStore.js +278 -0
  46. package/dist/esm/session/refresh.js +257 -0
  47. package/dist/esm/shared/utils/debugUtils.js +3 -3
  48. package/dist/esm/utils/accountUtils.js +0 -53
  49. package/dist/esm/utils/authWebUrl.js +9 -37
  50. package/dist/esm/utils/coldBoot.js +10 -8
  51. package/dist/esm/utils/platform.js +18 -0
  52. package/dist/esm/utils/registrableApex.js +46 -0
  53. package/dist/esm/utils/ssoBounce.js +14 -345
  54. package/dist/esm/utils/validationUtils.js +56 -0
  55. package/dist/types/.tsbuildinfo +1 -1
  56. package/dist/types/HttpService.d.ts +14 -1
  57. package/dist/types/OxyServices.base.d.ts +0 -52
  58. package/dist/types/OxyServices.d.ts +0 -25
  59. package/dist/types/boot/coldBootV2.d.ts +76 -0
  60. package/dist/types/boot/deviceBootReturn.d.ts +83 -0
  61. package/dist/types/crypto/keyManager.d.ts +21 -0
  62. package/dist/types/index.d.ts +14 -21
  63. package/dist/types/mixins/OxyServices.accounts.d.ts +24 -29
  64. package/dist/types/mixins/OxyServices.analytics.d.ts +0 -2
  65. package/dist/types/mixins/OxyServices.appData.d.ts +0 -2
  66. package/dist/types/mixins/OxyServices.assets.d.ts +0 -2
  67. package/dist/types/mixins/OxyServices.auth.d.ts +35 -77
  68. package/dist/types/mixins/OxyServices.civic.d.ts +0 -2
  69. package/dist/types/mixins/OxyServices.connectedApps.d.ts +0 -2
  70. package/dist/types/mixins/OxyServices.contacts.d.ts +0 -2
  71. package/dist/types/mixins/OxyServices.deviceBoot.d.ts +110 -0
  72. package/dist/types/mixins/OxyServices.devices.d.ts +0 -2
  73. package/dist/types/mixins/OxyServices.features.d.ts +0 -2
  74. package/dist/types/mixins/OxyServices.identity.d.ts +0 -2
  75. package/dist/types/mixins/OxyServices.language.d.ts +0 -2
  76. package/dist/types/mixins/OxyServices.links.d.ts +0 -2
  77. package/dist/types/mixins/OxyServices.location.d.ts +0 -2
  78. package/dist/types/mixins/OxyServices.nodes.d.ts +0 -2
  79. package/dist/types/mixins/OxyServices.payment.d.ts +0 -2
  80. package/dist/types/mixins/OxyServices.privacy.d.ts +0 -2
  81. package/dist/types/mixins/OxyServices.reputation.d.ts +0 -2
  82. package/dist/types/mixins/OxyServices.security.d.ts +0 -2
  83. package/dist/types/mixins/OxyServices.topics.d.ts +0 -2
  84. package/dist/types/mixins/OxyServices.user.d.ts +0 -2
  85. package/dist/types/mixins/OxyServices.utility.d.ts +0 -2
  86. package/dist/types/mixins/index.d.ts +5 -9
  87. package/dist/types/models/interfaces.d.ts +0 -67
  88. package/dist/types/models/session.d.ts +4 -5
  89. package/dist/types/server/index.d.ts +1 -1
  90. package/dist/types/session/SessionClient.d.ts +26 -1
  91. package/dist/types/session/authStateStore.d.ts +119 -0
  92. package/dist/types/session/refresh.d.ts +93 -0
  93. package/dist/types/shared/utils/debugUtils.d.ts +3 -3
  94. package/dist/types/utils/accountUtils.d.ts +2 -17
  95. package/dist/types/utils/authWebUrl.d.ts +9 -35
  96. package/dist/types/utils/coldBoot.d.ts +17 -14
  97. package/dist/types/utils/platform.d.ts +14 -0
  98. package/dist/types/utils/registrableApex.d.ts +31 -0
  99. package/dist/types/utils/ssoBounce.d.ts +14 -280
  100. package/dist/types/utils/validationUtils.d.ts +15 -0
  101. package/package.json +2 -2
  102. package/src/HttpService.ts +20 -4
  103. package/src/OxyServices.base.ts +7 -112
  104. package/src/OxyServices.ts +0 -38
  105. package/src/boot/__tests__/coldBootV2.test.ts +317 -0
  106. package/src/boot/__tests__/deviceBootReturn.test.ts +158 -0
  107. package/src/boot/coldBootV2.ts +426 -0
  108. package/src/boot/deviceBootReturn.ts +195 -0
  109. package/src/crypto/__tests__/sharedDeviceToken.test.ts +24 -0
  110. package/src/crypto/keyManager.ts +101 -0
  111. package/src/i18n/locales/en-US.json +13 -1
  112. package/src/i18n/locales/es-ES.json +13 -1
  113. package/src/index.ts +75 -65
  114. package/src/mixins/OxyServices.accounts.ts +27 -39
  115. package/src/mixins/OxyServices.auth.ts +78 -253
  116. package/src/mixins/OxyServices.deviceBoot.ts +146 -0
  117. package/src/mixins/__tests__/OxyServices.deviceBoot.test.ts +107 -0
  118. package/src/mixins/__tests__/accounts.test.ts +17 -44
  119. package/src/mixins/__tests__/passwordSignIn.test.ts +91 -0
  120. package/src/mixins/index.ts +10 -23
  121. package/src/models/interfaces.ts +0 -79
  122. package/src/models/session.ts +4 -5
  123. package/src/server/index.ts +1 -1
  124. package/src/session/SessionClient.ts +53 -2
  125. package/src/session/__tests__/SessionClient.additive.test.ts +92 -0
  126. package/src/session/__tests__/SessionClient.rest.test.ts +25 -0
  127. package/src/session/__tests__/SessionClient.state.test.ts +18 -5
  128. package/src/session/__tests__/authStateStore.test.ts +209 -0
  129. package/src/session/__tests__/refresh.test.ts +256 -0
  130. package/src/session/authStateStore.ts +335 -0
  131. package/src/session/refresh.ts +334 -0
  132. package/src/shared/utils/debugUtils.ts +3 -3
  133. package/src/utils/__tests__/authWebUrl.test.ts +5 -40
  134. package/src/utils/__tests__/registrableApex.test.ts +62 -0
  135. package/src/utils/__tests__/validationUtils.test.ts +30 -0
  136. package/src/utils/accountUtils.ts +2 -65
  137. package/src/utils/authWebUrl.ts +9 -39
  138. package/src/utils/coldBoot.ts +17 -14
  139. package/src/utils/platform.ts +21 -0
  140. package/src/utils/registrableApex.ts +45 -0
  141. package/src/utils/ssoBounce.ts +14 -393
  142. package/src/utils/validationUtils.ts +62 -0
  143. package/dist/cjs/AuthManager.js +0 -1110
  144. package/dist/cjs/AuthManagerTypes.js +0 -13
  145. package/dist/cjs/CrossDomainAuth.js +0 -206
  146. package/dist/cjs/mixins/OxyServices.fedcm.js +0 -823
  147. package/dist/cjs/mixins/OxyServices.redirect.js +0 -95
  148. package/dist/cjs/mixins/OxyServices.silent.js +0 -204
  149. package/dist/cjs/mixins/OxyServices.sso.js +0 -208
  150. package/dist/cjs/utils/fapiAutoDetect.js +0 -99
  151. package/dist/cjs/utils/ssoEstablish.js +0 -110
  152. package/dist/cjs/utils/ssoReturn.js +0 -275
  153. package/dist/esm/AuthManager.js +0 -1105
  154. package/dist/esm/AuthManagerTypes.js +0 -12
  155. package/dist/esm/CrossDomainAuth.js +0 -201
  156. package/dist/esm/mixins/OxyServices.fedcm.js +0 -821
  157. package/dist/esm/mixins/OxyServices.redirect.js +0 -92
  158. package/dist/esm/mixins/OxyServices.silent.js +0 -202
  159. package/dist/esm/mixins/OxyServices.sso.js +0 -204
  160. package/dist/esm/utils/fapiAutoDetect.js +0 -95
  161. package/dist/esm/utils/ssoEstablish.js +0 -107
  162. package/dist/esm/utils/ssoReturn.js +0 -271
  163. package/dist/types/AuthManager.d.ts +0 -380
  164. package/dist/types/AuthManagerTypes.d.ts +0 -81
  165. package/dist/types/CrossDomainAuth.d.ts +0 -164
  166. package/dist/types/mixins/OxyServices.fedcm.d.ts +0 -331
  167. package/dist/types/mixins/OxyServices.redirect.d.ts +0 -92
  168. package/dist/types/mixins/OxyServices.silent.d.ts +0 -132
  169. package/dist/types/mixins/OxyServices.sso.d.ts +0 -138
  170. package/dist/types/utils/fapiAutoDetect.d.ts +0 -56
  171. package/dist/types/utils/ssoEstablish.d.ts +0 -85
  172. package/dist/types/utils/ssoReturn.d.ts +0 -156
  173. package/src/AuthManager.ts +0 -1269
  174. package/src/AuthManagerTypes.ts +0 -86
  175. package/src/CrossDomainAuth.ts +0 -243
  176. package/src/__tests__/authManager.cookiePath.test.ts +0 -390
  177. package/src/__tests__/authManager.security.test.ts +0 -377
  178. package/src/__tests__/crossDomainAuth.test.ts +0 -116
  179. package/src/__tests__/establishDeviceRefreshSlot.test.ts +0 -221
  180. package/src/mixins/OxyServices.fedcm.ts +0 -1026
  181. package/src/mixins/OxyServices.redirect.ts +0 -122
  182. package/src/mixins/OxyServices.silent.ts +0 -272
  183. package/src/mixins/OxyServices.sso.ts +0 -261
  184. package/src/mixins/__tests__/constructorAuthWebUrl.test.ts +0 -85
  185. package/src/mixins/__tests__/fedcm.test.ts +0 -667
  186. package/src/mixins/__tests__/sessionBaseUrl.test.ts +0 -61
  187. package/src/mixins/__tests__/silent.test.ts +0 -102
  188. package/src/mixins/__tests__/sso.test.ts +0 -228
  189. package/src/utils/__tests__/consumeSsoReturn.test.ts +0 -816
  190. package/src/utils/__tests__/fapiAutoDetect.test.ts +0 -183
  191. package/src/utils/__tests__/ssoBounce.test.ts +0 -219
  192. package/src/utils/__tests__/ssoEstablish.test.ts +0 -204
  193. package/src/utils/__tests__/ssoReturn.test.ts +0 -276
  194. package/src/utils/fapiAutoDetect.ts +0 -91
  195. package/src/utils/ssoEstablish.ts +0 -174
  196. package/src/utils/ssoReturn.ts +0 -389
@@ -0,0 +1,209 @@
1
+ import {
2
+ createWebAuthStateStore,
3
+ createNativeAuthStateStore,
4
+ createMemoryAuthStateStore,
5
+ AUTH_STATE_STORAGE_KEY,
6
+ DEVICE_TOKEN_STORAGE_KEY,
7
+ type PersistedAuthState,
8
+ type NativeKeyValueStorage,
9
+ } from '../authStateStore';
10
+
11
+ const SAMPLE: PersistedAuthState = {
12
+ sessionId: 's-1',
13
+ refreshToken: 'r-abcdefghijklmnop',
14
+ userId: 'u-1',
15
+ deviceToken: 'd-1234567890',
16
+ accessToken: 'a-jwt',
17
+ expiresAt: '2030-01-01T00:00:00.000Z',
18
+ };
19
+
20
+ /** A minimal in-map Storage; setItem can be made to throw (quota simulation). */
21
+ function makeFakeStorage(opts: { throwOnSet?: boolean } = {}): Storage {
22
+ const map = new Map<string, string>();
23
+ return {
24
+ getItem: (k: string) => (map.has(k) ? (map.get(k) as string) : null),
25
+ setItem: (k: string, v: string) => {
26
+ if (opts.throwOnSet) {
27
+ throw new DOMException('QuotaExceededError', 'QuotaExceededError');
28
+ }
29
+ map.set(k, v);
30
+ },
31
+ removeItem: (k: string) => {
32
+ map.delete(k);
33
+ },
34
+ clear: () => map.clear(),
35
+ key: (i: number) => Array.from(map.keys())[i] ?? null,
36
+ get length() {
37
+ return map.size;
38
+ },
39
+ } as Storage;
40
+ }
41
+
42
+ function installLocalStorage(storage: Storage): void {
43
+ Object.defineProperty(globalThis, 'localStorage', {
44
+ value: storage,
45
+ configurable: true,
46
+ writable: true,
47
+ });
48
+ }
49
+
50
+ afterEach(() => {
51
+ // Remove whatever localStorage the test installed (value or throwing getter).
52
+ if (Object.getOwnPropertyDescriptor(globalThis, 'localStorage')) {
53
+ delete (globalThis as { localStorage?: Storage }).localStorage;
54
+ }
55
+ });
56
+
57
+ describe('createWebAuthStateStore', () => {
58
+ it('round-trips a PersistedAuthState under the versioned key', async () => {
59
+ const storage = makeFakeStorage();
60
+ installLocalStorage(storage);
61
+ const store = createWebAuthStateStore();
62
+
63
+ await store.save(SAMPLE);
64
+ expect(storage.getItem(AUTH_STATE_STORAGE_KEY)).toBeTruthy();
65
+ expect(await store.load()).toEqual(SAMPLE);
66
+ });
67
+
68
+ it('clear() wipes the session but the deviceToken survives', async () => {
69
+ installLocalStorage(makeFakeStorage());
70
+ const store = createWebAuthStateStore();
71
+
72
+ await store.save(SAMPLE);
73
+ await store.saveDeviceToken('device-token-xyz');
74
+ await store.clear();
75
+
76
+ expect(await store.load()).toBeNull();
77
+ expect(await store.loadDeviceToken()).toBe('device-token-xyz');
78
+ });
79
+
80
+ it('persists the deviceToken under a separate long-lived key', async () => {
81
+ const storage = makeFakeStorage();
82
+ installLocalStorage(storage);
83
+ const store = createWebAuthStateStore();
84
+
85
+ await store.saveDeviceToken('dt');
86
+ expect(storage.getItem(DEVICE_TOKEN_STORAGE_KEY)).toBe('dt');
87
+ await store.clearDeviceToken();
88
+ expect(await store.loadDeviceToken()).toBeNull();
89
+ });
90
+
91
+ it('returns null for a malformed or incomplete blob', async () => {
92
+ const storage = makeFakeStorage();
93
+ installLocalStorage(storage);
94
+ const store = createWebAuthStateStore();
95
+
96
+ storage.setItem(AUTH_STATE_STORAGE_KEY, 'not-json');
97
+ expect(await store.load()).toBeNull();
98
+
99
+ storage.setItem(AUTH_STATE_STORAGE_KEY, JSON.stringify({ sessionId: 's', userId: 'u' }));
100
+ expect(await store.load()).toBeNull();
101
+ });
102
+
103
+ it('degrades to in-memory when accessing localStorage throws (sandboxed iframe SecurityError)', async () => {
104
+ Object.defineProperty(globalThis, 'localStorage', {
105
+ configurable: true,
106
+ get() {
107
+ throw new DOMException('The operation is insecure.', 'SecurityError');
108
+ },
109
+ });
110
+
111
+ // Construction must not throw, and the store must still function (in-memory).
112
+ const store = createWebAuthStateStore();
113
+ await expect(store.save(SAMPLE)).resolves.toBeUndefined();
114
+ expect(await store.load()).toEqual(SAMPLE);
115
+ });
116
+
117
+ it('swallows a write that throws (quota / private mode) without rejecting', async () => {
118
+ installLocalStorage(makeFakeStorage({ throwOnSet: true }));
119
+ const store = createWebAuthStateStore();
120
+
121
+ await expect(store.save(SAMPLE)).resolves.toBeUndefined();
122
+ // The write never reached storage...
123
+ expect(localStorage.getItem(AUTH_STATE_STORAGE_KEY)).toBeNull();
124
+ // ...but the in-memory mirror keeps the session live for this page's lifetime.
125
+ expect(await store.load()).toEqual(SAMPLE);
126
+ });
127
+
128
+ it('the mirror keeps the deviceToken live when the write throws', async () => {
129
+ installLocalStorage(makeFakeStorage({ throwOnSet: true }));
130
+ const store = createWebAuthStateStore();
131
+
132
+ await store.saveDeviceToken('dt-mirrored');
133
+ expect(await store.loadDeviceToken()).toBe('dt-mirrored');
134
+ });
135
+
136
+ it('a cleared session reads null even if storage later holds a stale blob (mirror wins)', async () => {
137
+ const storage = makeFakeStorage();
138
+ installLocalStorage(storage);
139
+ const store = createWebAuthStateStore();
140
+
141
+ await store.save(SAMPLE);
142
+ await store.clear();
143
+ // Something (another tab / a failed remove) leaves a stale blob behind.
144
+ storage.setItem(AUTH_STATE_STORAGE_KEY, JSON.stringify(SAMPLE));
145
+ // The authoritative in-memory mirror still reports the cleared state.
146
+ expect(await store.load()).toBeNull();
147
+ });
148
+ });
149
+
150
+ describe('createNativeAuthStateStore', () => {
151
+ function makeNativeStorage(): NativeKeyValueStorage & { map: Map<string, string> } {
152
+ const map = new Map<string, string>();
153
+ return {
154
+ map,
155
+ getItem: async (k) => (map.has(k) ? (map.get(k) as string) : null),
156
+ setItem: async (k, v) => {
157
+ map.set(k, v);
158
+ },
159
+ removeItem: async (k) => {
160
+ map.delete(k);
161
+ },
162
+ };
163
+ }
164
+
165
+ it('round-trips through the injected async storage', async () => {
166
+ const storage = makeNativeStorage();
167
+ const store = createNativeAuthStateStore(storage);
168
+
169
+ await store.save(SAMPLE);
170
+ expect(storage.map.get(AUTH_STATE_STORAGE_KEY)).toBeTruthy();
171
+ expect(await store.load()).toEqual(SAMPLE);
172
+ });
173
+
174
+ it('deviceToken survives a session clear', async () => {
175
+ const store = createNativeAuthStateStore(makeNativeStorage());
176
+ await store.save(SAMPLE);
177
+ await store.saveDeviceToken('dt-native');
178
+ await store.clear();
179
+ expect(await store.load()).toBeNull();
180
+ expect(await store.loadDeviceToken()).toBe('dt-native');
181
+ });
182
+
183
+ it('keeps the session live via the mirror when the injected storage throws (locked keychain)', async () => {
184
+ const store = createNativeAuthStateStore({
185
+ getItem: async () => {
186
+ throw new Error('secure store locked');
187
+ },
188
+ setItem: async () => {
189
+ throw new Error('secure store locked');
190
+ },
191
+ removeItem: async () => {
192
+ throw new Error('secure store locked');
193
+ },
194
+ });
195
+ await expect(store.save(SAMPLE)).resolves.toBeUndefined();
196
+ // The write threw, but the in-memory mirror preserves the session.
197
+ expect(await store.load()).toEqual(SAMPLE);
198
+ });
199
+ });
200
+
201
+ describe('createMemoryAuthStateStore', () => {
202
+ it('round-trips and clears in process memory', async () => {
203
+ const store = createMemoryAuthStateStore();
204
+ await store.save(SAMPLE);
205
+ expect(await store.load()).toEqual(SAMPLE);
206
+ await store.clear();
207
+ expect(await store.load()).toBeNull();
208
+ });
209
+ });
@@ -0,0 +1,256 @@
1
+ import type { OxyServices } from '../../OxyServices';
2
+ import type { TokenRefreshResponse } from '@oxyhq/contracts';
3
+ import type { SessionLoginResponse } from '../../models/session';
4
+ import { refreshPersistedSession, startTokenRefreshScheduler } from '../refresh';
5
+ import { createMemoryAuthStateStore, type PersistedAuthState } from '../authStateStore';
6
+
7
+ const STORED: PersistedAuthState = {
8
+ sessionId: 'sess-old',
9
+ refreshToken: 'refresh-old-abcdefghij',
10
+ userId: 'user-1',
11
+ deviceToken: 'device-abcdefghij',
12
+ };
13
+
14
+ const ROTATED: TokenRefreshResponse = {
15
+ accessToken: 'access-new',
16
+ refreshToken: 'refresh-new-abcdefghij',
17
+ expiresAt: '2030-01-01T00:00:00.000Z',
18
+ sessionId: 'sess-new',
19
+ };
20
+
21
+ interface RefreshMockOverrides {
22
+ refreshWithToken?: OxyServices['refreshWithToken'];
23
+ signInWithSharedIdentity?: OxyServices['signInWithSharedIdentity'];
24
+ }
25
+
26
+ function makeOxy(overrides: RefreshMockOverrides = {}): { oxy: OxyServices; setTokens: jest.Mock } {
27
+ const setTokens = jest.fn();
28
+ const oxy = {
29
+ setTokens,
30
+ refreshWithToken: overrides.refreshWithToken ?? (async () => ROTATED),
31
+ signInWithSharedIdentity: overrides.signInWithSharedIdentity ?? (async () => null),
32
+ } as unknown as OxyServices;
33
+ return { oxy, setTokens };
34
+ }
35
+
36
+ describe('refreshPersistedSession — arm 1 (refresh-token rotation)', () => {
37
+ it('rotates, plants, persists the new family head, and returns the new token', async () => {
38
+ const store = createMemoryAuthStateStore();
39
+ await store.save(STORED);
40
+ const { oxy, setTokens } = makeOxy();
41
+
42
+ const token = await refreshPersistedSession({ oxy, store, allowSharedKeyFallback: false });
43
+
44
+ expect(token).toBe('access-new');
45
+ expect(setTokens).toHaveBeenCalledWith('access-new');
46
+ expect(await store.load()).toEqual({
47
+ sessionId: 'sess-new',
48
+ refreshToken: 'refresh-new-abcdefghij',
49
+ userId: 'user-1',
50
+ deviceToken: 'device-abcdefghij',
51
+ accessToken: 'access-new',
52
+ expiresAt: '2030-01-01T00:00:00.000Z',
53
+ });
54
+ });
55
+
56
+ it('clears the store on a family-revoked (401) error', async () => {
57
+ const store = createMemoryAuthStateStore();
58
+ await store.save(STORED);
59
+ const { oxy } = makeOxy({
60
+ refreshWithToken: async () => {
61
+ throw Object.assign(new Error('revoked'), { status: 401 });
62
+ },
63
+ });
64
+
65
+ const token = await refreshPersistedSession({ oxy, store, allowSharedKeyFallback: false });
66
+
67
+ expect(token).toBeNull();
68
+ expect(await store.load()).toBeNull();
69
+ });
70
+
71
+ it('clears the store on an invalid_grant code', async () => {
72
+ const store = createMemoryAuthStateStore();
73
+ await store.save(STORED);
74
+ const { oxy } = makeOxy({
75
+ refreshWithToken: async () => {
76
+ throw Object.assign(new Error('bad'), { code: 'invalid_grant' });
77
+ },
78
+ });
79
+
80
+ expect(await refreshPersistedSession({ oxy, store, allowSharedKeyFallback: false })).toBeNull();
81
+ expect(await store.load()).toBeNull();
82
+ });
83
+
84
+ it('KEEPS the store on a transient (500 / network) error', async () => {
85
+ const store = createMemoryAuthStateStore();
86
+ await store.save(STORED);
87
+ const { oxy } = makeOxy({
88
+ refreshWithToken: async () => {
89
+ throw Object.assign(new Error('server'), { status: 500 });
90
+ },
91
+ });
92
+
93
+ expect(await refreshPersistedSession({ oxy, store, allowSharedKeyFallback: false })).toBeNull();
94
+ expect(await store.load()).toEqual(STORED);
95
+ });
96
+ });
97
+
98
+ describe('refreshPersistedSession — arm 2 (native shared-key fallback)', () => {
99
+ const SHARED_SESSION: SessionLoginResponse = {
100
+ sessionId: 'sess-shared',
101
+ deviceId: 'dev-1',
102
+ expiresAt: '2030-01-01T00:00:00.000Z',
103
+ user: { id: 'user-1', username: 'u', name: {}, avatar: undefined },
104
+ accessToken: 'access-shared',
105
+ };
106
+
107
+ it('re-mints via shared identity when there is no refresh token', async () => {
108
+ const store = createMemoryAuthStateStore(); // no stored refresh token
109
+ const signInWithSharedIdentity = jest.fn(async () => SHARED_SESSION);
110
+ const { oxy } = makeOxy({ signInWithSharedIdentity });
111
+
112
+ const token = await refreshPersistedSession({ oxy, store, allowSharedKeyFallback: true });
113
+
114
+ expect(token).toBe('access-shared');
115
+ expect(signInWithSharedIdentity).toHaveBeenCalledTimes(1);
116
+ });
117
+
118
+ it('falls back to shared-key after a revoked refresh token', async () => {
119
+ const store = createMemoryAuthStateStore();
120
+ await store.save(STORED);
121
+ const signInWithSharedIdentity = jest.fn(async () => SHARED_SESSION);
122
+ const { oxy } = makeOxy({
123
+ refreshWithToken: async () => {
124
+ throw Object.assign(new Error('revoked'), { status: 403 });
125
+ },
126
+ signInWithSharedIdentity,
127
+ });
128
+
129
+ const token = await refreshPersistedSession({ oxy, store, allowSharedKeyFallback: true });
130
+
131
+ expect(token).toBe('access-shared');
132
+ expect(await store.load()).toBeNull(); // revoked family was cleared
133
+ expect(signInWithSharedIdentity).toHaveBeenCalledTimes(1);
134
+ });
135
+
136
+ it('does NOT try shared-key when the fallback is disabled (web)', async () => {
137
+ const store = createMemoryAuthStateStore();
138
+ const signInWithSharedIdentity = jest.fn(async () => SHARED_SESSION);
139
+ const { oxy } = makeOxy({ signInWithSharedIdentity });
140
+
141
+ expect(await refreshPersistedSession({ oxy, store, allowSharedKeyFallback: false })).toBeNull();
142
+ expect(signInWithSharedIdentity).not.toHaveBeenCalled();
143
+ });
144
+ });
145
+
146
+ describe('startTokenRefreshScheduler', () => {
147
+ function makeSchedulerOxy(expiresInSeconds: number | null): {
148
+ oxy: OxyServices;
149
+ refreshAccessToken: jest.Mock;
150
+ } {
151
+ let cleared = false;
152
+ const refreshAccessToken = jest.fn(async () => {
153
+ cleared = true; // stop the reschedule loop after the first fire
154
+ return null;
155
+ });
156
+ const nowSec = Math.floor(Date.now() / 1000);
157
+ const oxy = {
158
+ getAccessToken: () => (cleared ? null : 'tok'),
159
+ getAccessTokenExpiry: () => (expiresInSeconds === null ? null : nowSec + expiresInSeconds),
160
+ onTokensChanged: () => () => undefined,
161
+ httpService: { refreshAccessToken },
162
+ } as unknown as OxyServices;
163
+ return { oxy, refreshAccessToken };
164
+ }
165
+
166
+ afterEach(() => {
167
+ jest.useRealTimers();
168
+ });
169
+
170
+ it('fires a refresh ~60s before expiry', async () => {
171
+ jest.useFakeTimers();
172
+ const { oxy, refreshAccessToken } = makeSchedulerOxy(120);
173
+ const handle = startTokenRefreshScheduler(oxy);
174
+
175
+ expect(refreshAccessToken).not.toHaveBeenCalled();
176
+ await jest.advanceTimersByTimeAsync(60_000);
177
+ expect(refreshAccessToken).toHaveBeenCalledWith('preflight');
178
+
179
+ handle.dispose();
180
+ });
181
+
182
+ it('no-ops cleanly for an opaque token with no exp', () => {
183
+ jest.useFakeTimers();
184
+ const { oxy, refreshAccessToken } = makeSchedulerOxy(null);
185
+ const handle = startTokenRefreshScheduler(oxy);
186
+ jest.advanceTimersByTime(10 * 60_000);
187
+ expect(refreshAccessToken).not.toHaveBeenCalled();
188
+ handle.dispose();
189
+ });
190
+
191
+ it('backs off on repeated failure — bounded attempts, never a zero-delay busy loop', async () => {
192
+ jest.useFakeTimers();
193
+ // Already-expired token + a refresh that ALWAYS fails. The old code floored
194
+ // the delay to 0 and re-armed in the finally block → a tight loop that would
195
+ // exhaust jest's 100k-timer guard. The backoff schedule fires only a handful
196
+ // of times per simulated minute.
197
+ const refreshAccessToken = jest.fn(async () => null);
198
+ const nowSec = Math.floor(Date.now() / 1000);
199
+ const oxy = {
200
+ getAccessToken: () => 'tok',
201
+ getAccessTokenExpiry: () => nowSec - 10,
202
+ onTokensChanged: () => () => undefined,
203
+ httpService: { refreshAccessToken },
204
+ } as unknown as OxyServices;
205
+
206
+ const handle = startTokenRefreshScheduler(oxy);
207
+ await jest.advanceTimersByTimeAsync(60_000);
208
+
209
+ expect(refreshAccessToken.mock.calls.length).toBeGreaterThan(0);
210
+ expect(refreshAccessToken.mock.calls.length).toBeLessThan(10);
211
+ handle.dispose();
212
+ });
213
+
214
+ it('resets the backoff after a successful refresh', async () => {
215
+ jest.useFakeTimers();
216
+ // Expiry is always ~1h from NOW, so a successful refresh genuinely pushes
217
+ // the token out (as a real rotation would). Fail once, then succeed.
218
+ const refreshAccessToken = jest
219
+ .fn<Promise<string | null>, [unknown]>()
220
+ .mockResolvedValueOnce(null)
221
+ .mockResolvedValue('fresh');
222
+ const oxy = {
223
+ getAccessToken: () => 'tok',
224
+ getAccessTokenExpiry: () => Math.floor(Date.now() / 1000) + 3600,
225
+ onTokensChanged: () => () => undefined,
226
+ httpService: { refreshAccessToken },
227
+ } as unknown as OxyServices;
228
+
229
+ const handle = startTokenRefreshScheduler(oxy);
230
+ // First fire ~ (3600-60)s out (fails → 5s backoff), then the backoff fire
231
+ // succeeds and re-arms ~1h out from the (now-fresh) expiry.
232
+ await jest.advanceTimersByTimeAsync((3600 - 60) * 1000);
233
+ await jest.advanceTimersByTimeAsync(5_000);
234
+ const callsAfterSuccess = refreshAccessToken.mock.calls.length;
235
+ expect(callsAfterSuccess).toBe(2); // one failure + one success
236
+ // A further minute must NOT produce a burst — the healthy re-arm is ~1h out.
237
+ await jest.advanceTimersByTimeAsync(60_000);
238
+ expect(refreshAccessToken.mock.calls.length).toBe(callsAfterSuccess);
239
+ handle.dispose();
240
+ });
241
+
242
+ it('calls unref() on its timer so it never holds the event loop', () => {
243
+ const unref = jest.fn();
244
+ const setTimeoutSpy = jest
245
+ .spyOn(global, 'setTimeout')
246
+ .mockReturnValue({ unref } as unknown as ReturnType<typeof setTimeout>);
247
+ const { oxy } = makeSchedulerOxy(120);
248
+
249
+ const handle = startTokenRefreshScheduler(oxy);
250
+ expect(setTimeoutSpy).toHaveBeenCalled();
251
+ expect(unref).toHaveBeenCalled();
252
+
253
+ handle.dispose();
254
+ setTimeoutSpy.mockRestore();
255
+ });
256
+ });