@oxyhq/core 5.5.0 → 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 (181) 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 -44
  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 +40 -1
  18. package/dist/cjs/session/authStateStore.js +284 -0
  19. package/dist/cjs/session/refresh.js +264 -0
  20. package/dist/cjs/utils/accountUtils.js +1 -55
  21. package/dist/cjs/utils/authWebUrl.js +6 -15
  22. package/dist/cjs/utils/fapiAutoDetect.js +16 -64
  23. package/dist/cjs/utils/platform.js +19 -0
  24. package/dist/cjs/utils/ssoBounce.js +15 -362
  25. package/dist/cjs/utils/validationUtils.js +57 -0
  26. package/dist/esm/.tsbuildinfo +1 -1
  27. package/dist/esm/HttpService.js +6 -3
  28. package/dist/esm/OxyServices.base.js +7 -102
  29. package/dist/esm/boot/coldBootV2.js +344 -0
  30. package/dist/esm/boot/deviceBootReturn.js +146 -0
  31. package/dist/esm/crypto/keyManager.js +95 -0
  32. package/dist/esm/i18n/locales/en-US.json +13 -1
  33. package/dist/esm/i18n/locales/es-ES.json +13 -1
  34. package/dist/esm/i18n/locales/locales/en-US.json +13 -1
  35. package/dist/esm/i18n/locales/locales/es-ES.json +13 -1
  36. package/dist/esm/index.js +28 -18
  37. package/dist/esm/mixins/OxyServices.accounts.js +6 -13
  38. package/dist/esm/mixins/OxyServices.auth.js +66 -201
  39. package/dist/esm/mixins/OxyServices.authorizedApps.js +35 -0
  40. package/dist/esm/mixins/OxyServices.deviceBoot.js +116 -0
  41. package/dist/esm/mixins/index.js +13 -17
  42. package/dist/esm/session/SessionClient.js +40 -1
  43. package/dist/esm/session/authStateStore.js +278 -0
  44. package/dist/esm/session/refresh.js +257 -0
  45. package/dist/esm/utils/accountUtils.js +0 -53
  46. package/dist/esm/utils/authWebUrl.js +6 -14
  47. package/dist/esm/utils/fapiAutoDetect.js +16 -63
  48. package/dist/esm/utils/platform.js +18 -0
  49. package/dist/esm/utils/ssoBounce.js +14 -345
  50. package/dist/esm/utils/validationUtils.js +56 -0
  51. package/dist/types/.tsbuildinfo +1 -1
  52. package/dist/types/HttpService.d.ts +13 -0
  53. package/dist/types/OxyServices.base.d.ts +0 -52
  54. package/dist/types/OxyServices.d.ts +0 -25
  55. package/dist/types/boot/coldBootV2.d.ts +76 -0
  56. package/dist/types/boot/deviceBootReturn.d.ts +83 -0
  57. package/dist/types/crypto/keyManager.d.ts +21 -0
  58. package/dist/types/index.d.ts +15 -21
  59. package/dist/types/mixins/OxyServices.accounts.d.ts +0 -2
  60. package/dist/types/mixins/OxyServices.analytics.d.ts +0 -2
  61. package/dist/types/mixins/OxyServices.appData.d.ts +0 -2
  62. package/dist/types/mixins/OxyServices.assets.d.ts +0 -2
  63. package/dist/types/mixins/OxyServices.auth.d.ts +35 -77
  64. package/dist/types/mixins/{OxyServices.redirect.d.ts → OxyServices.authorizedApps.d.ts} +35 -33
  65. package/dist/types/mixins/OxyServices.civic.d.ts +0 -2
  66. package/dist/types/mixins/OxyServices.connectedApps.d.ts +0 -2
  67. package/dist/types/mixins/OxyServices.contacts.d.ts +0 -2
  68. package/dist/types/mixins/OxyServices.deviceBoot.d.ts +110 -0
  69. package/dist/types/mixins/OxyServices.devices.d.ts +0 -2
  70. package/dist/types/mixins/OxyServices.features.d.ts +0 -2
  71. package/dist/types/mixins/OxyServices.identity.d.ts +0 -2
  72. package/dist/types/mixins/OxyServices.language.d.ts +0 -2
  73. package/dist/types/mixins/OxyServices.links.d.ts +0 -2
  74. package/dist/types/mixins/OxyServices.location.d.ts +0 -2
  75. package/dist/types/mixins/OxyServices.nodes.d.ts +0 -2
  76. package/dist/types/mixins/OxyServices.payment.d.ts +0 -2
  77. package/dist/types/mixins/OxyServices.privacy.d.ts +0 -2
  78. package/dist/types/mixins/OxyServices.reputation.d.ts +0 -2
  79. package/dist/types/mixins/OxyServices.security.d.ts +0 -2
  80. package/dist/types/mixins/OxyServices.topics.d.ts +0 -2
  81. package/dist/types/mixins/OxyServices.user.d.ts +0 -2
  82. package/dist/types/mixins/OxyServices.utility.d.ts +0 -2
  83. package/dist/types/mixins/index.d.ts +6 -9
  84. package/dist/types/models/interfaces.d.ts +0 -67
  85. package/dist/types/session/SessionClient.d.ts +25 -0
  86. package/dist/types/session/authStateStore.d.ts +119 -0
  87. package/dist/types/session/refresh.d.ts +93 -0
  88. package/dist/types/utils/accountUtils.d.ts +0 -14
  89. package/dist/types/utils/authWebUrl.d.ts +6 -12
  90. package/dist/types/utils/fapiAutoDetect.d.ts +15 -38
  91. package/dist/types/utils/platform.d.ts +14 -0
  92. package/dist/types/utils/ssoBounce.d.ts +14 -280
  93. package/dist/types/utils/validationUtils.d.ts +15 -0
  94. package/package.json +2 -2
  95. package/src/HttpService.ts +19 -3
  96. package/src/OxyServices.base.ts +7 -112
  97. package/src/OxyServices.ts +0 -38
  98. package/src/boot/__tests__/coldBootV2.test.ts +317 -0
  99. package/src/boot/__tests__/deviceBootReturn.test.ts +158 -0
  100. package/src/boot/coldBootV2.ts +426 -0
  101. package/src/boot/deviceBootReturn.ts +195 -0
  102. package/src/crypto/__tests__/sharedDeviceToken.test.ts +24 -0
  103. package/src/crypto/keyManager.ts +101 -0
  104. package/src/i18n/locales/en-US.json +13 -1
  105. package/src/i18n/locales/es-ES.json +13 -1
  106. package/src/index.ts +74 -65
  107. package/src/mixins/OxyServices.accounts.ts +6 -13
  108. package/src/mixins/OxyServices.auth.ts +78 -253
  109. package/src/mixins/OxyServices.authorizedApps.ts +75 -0
  110. package/src/mixins/OxyServices.deviceBoot.ts +146 -0
  111. package/src/mixins/__tests__/OxyServices.deviceBoot.test.ts +107 -0
  112. package/src/mixins/__tests__/accounts.test.ts +17 -44
  113. package/src/mixins/__tests__/authorizedApps.test.ts +63 -0
  114. package/src/mixins/__tests__/passwordSignIn.test.ts +91 -0
  115. package/src/mixins/index.ts +16 -22
  116. package/src/models/interfaces.ts +0 -79
  117. package/src/session/SessionClient.ts +52 -1
  118. package/src/session/__tests__/SessionClient.additive.test.ts +92 -0
  119. package/src/session/__tests__/SessionClient.rest.test.ts +25 -0
  120. package/src/session/__tests__/SessionClient.state.test.ts +18 -5
  121. package/src/session/__tests__/authStateStore.test.ts +209 -0
  122. package/src/session/__tests__/refresh.test.ts +256 -0
  123. package/src/session/authStateStore.ts +335 -0
  124. package/src/session/refresh.ts +334 -0
  125. package/src/utils/__tests__/authWebUrl.test.ts +5 -29
  126. package/src/utils/__tests__/fapiAutoDetect.test.ts +5 -126
  127. package/src/utils/__tests__/validationUtils.test.ts +30 -0
  128. package/src/utils/accountUtils.ts +0 -62
  129. package/src/utils/authWebUrl.ts +6 -15
  130. package/src/utils/fapiAutoDetect.ts +16 -60
  131. package/src/utils/platform.ts +21 -0
  132. package/src/utils/ssoBounce.ts +14 -393
  133. package/src/utils/validationUtils.ts +62 -0
  134. package/dist/cjs/AuthManager.js +0 -1110
  135. package/dist/cjs/AuthManagerTypes.js +0 -13
  136. package/dist/cjs/CrossDomainAuth.js +0 -206
  137. package/dist/cjs/mixins/OxyServices.fedcm.js +0 -823
  138. package/dist/cjs/mixins/OxyServices.redirect.js +0 -95
  139. package/dist/cjs/mixins/OxyServices.silent.js +0 -204
  140. package/dist/cjs/mixins/OxyServices.sso.js +0 -208
  141. package/dist/cjs/utils/ssoEstablish.js +0 -110
  142. package/dist/cjs/utils/ssoReturn.js +0 -275
  143. package/dist/esm/AuthManager.js +0 -1105
  144. package/dist/esm/AuthManagerTypes.js +0 -12
  145. package/dist/esm/CrossDomainAuth.js +0 -201
  146. package/dist/esm/mixins/OxyServices.fedcm.js +0 -821
  147. package/dist/esm/mixins/OxyServices.redirect.js +0 -92
  148. package/dist/esm/mixins/OxyServices.silent.js +0 -202
  149. package/dist/esm/mixins/OxyServices.sso.js +0 -204
  150. package/dist/esm/utils/ssoEstablish.js +0 -107
  151. package/dist/esm/utils/ssoReturn.js +0 -271
  152. package/dist/types/AuthManager.d.ts +0 -380
  153. package/dist/types/AuthManagerTypes.d.ts +0 -81
  154. package/dist/types/CrossDomainAuth.d.ts +0 -164
  155. package/dist/types/mixins/OxyServices.fedcm.d.ts +0 -331
  156. package/dist/types/mixins/OxyServices.silent.d.ts +0 -132
  157. package/dist/types/mixins/OxyServices.sso.d.ts +0 -138
  158. package/dist/types/utils/ssoEstablish.d.ts +0 -85
  159. package/dist/types/utils/ssoReturn.d.ts +0 -156
  160. package/src/AuthManager.ts +0 -1269
  161. package/src/AuthManagerTypes.ts +0 -86
  162. package/src/CrossDomainAuth.ts +0 -243
  163. package/src/__tests__/authManager.cookiePath.test.ts +0 -390
  164. package/src/__tests__/authManager.security.test.ts +0 -377
  165. package/src/__tests__/crossDomainAuth.test.ts +0 -116
  166. package/src/__tests__/establishDeviceRefreshSlot.test.ts +0 -221
  167. package/src/mixins/OxyServices.fedcm.ts +0 -1026
  168. package/src/mixins/OxyServices.redirect.ts +0 -122
  169. package/src/mixins/OxyServices.silent.ts +0 -272
  170. package/src/mixins/OxyServices.sso.ts +0 -261
  171. package/src/mixins/__tests__/constructorAuthWebUrl.test.ts +0 -85
  172. package/src/mixins/__tests__/fedcm.test.ts +0 -667
  173. package/src/mixins/__tests__/sessionBaseUrl.test.ts +0 -61
  174. package/src/mixins/__tests__/silent.test.ts +0 -102
  175. package/src/mixins/__tests__/sso.test.ts +0 -228
  176. package/src/utils/__tests__/consumeSsoReturn.test.ts +0 -816
  177. package/src/utils/__tests__/ssoBounce.test.ts +0 -219
  178. package/src/utils/__tests__/ssoEstablish.test.ts +0 -204
  179. package/src/utils/__tests__/ssoReturn.test.ts +0 -276
  180. package/src/utils/ssoEstablish.ts +0 -174
  181. package/src/utils/ssoReturn.ts +0 -389
@@ -32,12 +32,25 @@ describe('SessionClient state', () => {
32
32
  expect(seen.at(-1)?.revision).toBe(1);
33
33
  });
34
34
 
35
- it('ignores a stale or equal revision (last-writer-wins)', () => {
35
+ it('ignores a stale or equal revision (last-writer-wins) WITHIN the same device', () => {
36
36
  const c = new TestClient(makeHost());
37
- c.apply(STATE(5));
38
- expect(c.apply(STATE(5))).toBe(false);
39
- expect(c.apply(STATE(4))).toBe(false);
40
- expect(c.getState()?.revision).toBe(5);
37
+ c.apply({ ...STATE(5), deviceId: 'A' });
38
+ expect(c.apply({ ...STATE(5), deviceId: 'A' })).toBe(false);
39
+ expect(c.apply({ ...STATE(4), deviceId: 'A' })).toBe(false);
40
+ expect(c.apply({ ...STATE(6), deviceId: 'A' })).toBe(true);
41
+ expect(c.getState()?.revision).toBe(6);
42
+ });
43
+
44
+ it('accepts a LOWER-revision state from a DIFFERENT device (revision baseline resets cross-device)', () => {
45
+ const c = new TestClient(makeHost());
46
+ // Device A at a high revision.
47
+ expect(c.apply({ ...STATE(10), deviceId: 'A' })).toBe(true);
48
+ expect(c.getState()?.deviceId).toBe('A');
49
+ // Device B, freshly converged (revision 1), must win over device A's
50
+ // stale-but-higher revision — the cross-device comparison is not monotone.
51
+ expect(c.apply({ ...STATE(1), deviceId: 'B' })).toBe(true);
52
+ expect(c.getState()?.deviceId).toBe('B');
53
+ expect(c.getState()?.revision).toBe(1);
41
54
  });
42
55
 
43
56
  it('rejects an invalid (unvalidated) state without applying', () => {
@@ -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
+ });