@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
@@ -1,377 +0,0 @@
1
- /**
2
- * AuthManager hardening tests:
3
- *
4
- * - `switchAuthuser` concurrency lock — two near-simultaneous calls share
5
- * a single in-flight promise instead of double-rotating the slot's
6
- * refresh-token family.
7
- * - BroadcastChannel cross-tab nonce gate — forged messages from a same-
8
- * origin XSS payload that doesn't know the original tab's nonce are
9
- * dropped; first sighting of a new tabId is trusted (TOFU); subsequent
10
- * messages from that tab must match.
11
- * - Cross-tab cascade debounce — repeated `accounts_restored` broadcasts
12
- * within the 2 s window only trigger ONE `/auth/refresh-all` rotation.
13
- * - Legacy `/auth/refresh` fallback hydrates the user shape via
14
- * `getCurrentUser()` instead of leaving the slot stuck on
15
- * `{ id: '', username: '' }`.
16
- */
17
-
18
- import { AuthManager } from '../AuthManager';
19
- import type { StorageAdapter } from '../AuthManager';
20
- import type { OxyServices } from '../OxyServices';
21
- import type {
22
- RefreshAllResponse,
23
- RefreshCookieResponse,
24
- User,
25
- } from '../models/interfaces';
26
-
27
- class InMemoryStorage implements StorageAdapter {
28
- private store = new Map<string, string>();
29
- getItem(key: string): string | null { return this.store.get(key) ?? null; }
30
- setItem(key: string, value: string): void { this.store.set(key, value); }
31
- removeItem(key: string): void { this.store.delete(key); }
32
- }
33
-
34
- function buildAccessToken(claims: Record<string, unknown>): string {
35
- const b64url = (value: string): string =>
36
- Buffer.from(value).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
37
- const header = b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
38
- const payload = b64url(JSON.stringify(claims));
39
- return `${header}.${payload}.signature`;
40
- }
41
-
42
- interface MockHttpService {
43
- setTokens: jest.Mock;
44
- setAuthRefreshHandler: jest.Mock;
45
- }
46
-
47
- interface MockServices {
48
- refreshAllSessions: jest.Mock<Promise<RefreshAllResponse>, []>;
49
- refreshTokenViaCookie: jest.Mock<Promise<RefreshCookieResponse | null>, [{ authuser?: number }]>;
50
- logoutSessionByAuthuser: jest.Mock<Promise<void>, [number]>;
51
- logoutAllSessionsViaCookie: jest.Mock<Promise<void>, []>;
52
- getCurrentUser: jest.Mock<Promise<User>, []>;
53
- httpService: MockHttpService;
54
- }
55
-
56
- function makeMockServices(): MockServices {
57
- return {
58
- refreshAllSessions: jest.fn(async (): Promise<RefreshAllResponse> => ({ accounts: [] })),
59
- refreshTokenViaCookie: jest.fn(),
60
- logoutSessionByAuthuser: jest.fn(async () => undefined),
61
- logoutAllSessionsViaCookie: jest.fn(async () => undefined),
62
- getCurrentUser: jest.fn(async (): Promise<User> => ({
63
- id: 'user-x',
64
- publicKey: '0xdeadbeef',
65
- username: 'hydrated',
66
- avatar: 'avatar-1',
67
- color: 'teal',
68
- } as User)),
69
- httpService: { setTokens: jest.fn(), setAuthRefreshHandler: jest.fn() },
70
- };
71
- }
72
-
73
- function makeManager(services: MockServices, options: { crossTabSync?: boolean } = {}): AuthManager {
74
- const storage = new InMemoryStorage();
75
- return new AuthManager(services as unknown as OxyServices, {
76
- storage,
77
- autoRefresh: false,
78
- crossTabSync: options.crossTabSync ?? false,
79
- });
80
- }
81
-
82
- const TOKEN_SLOT_0 = buildAccessToken({ sessionId: 'sess-slot-0', userId: 'user-0', exp: 9999999999 });
83
- const TOKEN_SLOT_1 = buildAccessToken({ sessionId: 'sess-slot-1', userId: 'user-1', exp: 9999999999 });
84
-
85
- const TWO_ACCOUNTS: RefreshAllResponse = {
86
- accounts: [
87
- {
88
- authuser: 0,
89
- accessToken: TOKEN_SLOT_0,
90
- expiresAt: '2099-01-01T00:00:00.000Z',
91
- sessionId: 'sess-slot-0',
92
- user: { id: 'user-0', username: 'alice', name: { displayName: 'Alice' }, avatar: null, color: '#1abc9c' },
93
- },
94
- {
95
- authuser: 1,
96
- accessToken: TOKEN_SLOT_1,
97
- expiresAt: '2099-01-01T00:00:00.000Z',
98
- sessionId: 'sess-slot-1',
99
- user: { id: 'user-1', username: 'bob', name: { displayName: 'Bob' }, avatar: null, color: '#3498db' },
100
- },
101
- ],
102
- };
103
-
104
- describe('AuthManager.switchAuthuser — concurrency lock', () => {
105
- it('coalesces two concurrent calls into a single refresh + rotation', async () => {
106
- const services = makeMockServices();
107
- services.refreshAllSessions.mockResolvedValueOnce(TWO_ACCOUNTS);
108
-
109
- // Return the same rotated token for any call, but make the resolution
110
- // explicit so the two concurrent callers genuinely overlap.
111
- let resolveRotation: ((value: RefreshCookieResponse) => void) | undefined;
112
- services.refreshTokenViaCookie.mockImplementationOnce(
113
- () => new Promise<RefreshCookieResponse>((resolve) => {
114
- resolveRotation = resolve;
115
- })
116
- );
117
-
118
- const manager = makeManager(services);
119
- await manager.restoreFromCookies();
120
-
121
- const p1 = manager.switchAuthuser(1);
122
- const p2 = manager.switchAuthuser(1);
123
-
124
- // Both callers MUST share the same in-flight promise object. If they
125
- // were two independent rotations, refreshTokenViaCookie would have
126
- // been invoked twice already.
127
- expect(services.refreshTokenViaCookie).toHaveBeenCalledTimes(1);
128
-
129
- if (!resolveRotation) {
130
- throw new Error('rotation pending callback not captured');
131
- }
132
- resolveRotation({
133
- accessToken: 'rotated-slot-1-token',
134
- expiresAt: '2099-01-01T00:00:00.000Z',
135
- authuser: 1,
136
- });
137
-
138
- const [r1, r2] = await Promise.all([p1, p2]);
139
-
140
- expect(r1).toEqual(r2);
141
- expect(services.refreshTokenViaCookie).toHaveBeenCalledTimes(1);
142
- expect(manager.getActiveAuthuser()).toBe(1);
143
- });
144
-
145
- it('clears the in-flight slot after a failed rotation so the next call can retry', async () => {
146
- const services = makeMockServices();
147
- services.refreshAllSessions.mockResolvedValueOnce(TWO_ACCOUNTS);
148
- services.refreshTokenViaCookie
149
- .mockResolvedValueOnce(null) // First attempt: cookie expired.
150
- .mockResolvedValueOnce({
151
- accessToken: 'rotated-slot-1-token',
152
- expiresAt: '2099-01-01T00:00:00.000Z',
153
- authuser: 1,
154
- });
155
-
156
- const manager = makeManager(services);
157
- await manager.restoreFromCookies();
158
-
159
- await expect(manager.switchAuthuser(1)).rejects.toThrow(/authuser=1/);
160
- // The lock must release; a follow-up switch is permitted.
161
- const second = await manager.switchAuthuser(1);
162
- expect(second.accessToken).toBe('rotated-slot-1-token');
163
- expect(services.refreshTokenViaCookie).toHaveBeenCalledTimes(2);
164
- });
165
- });
166
-
167
- describe('AuthManager default storage selection', () => {
168
- const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
169
-
170
- afterEach(() => {
171
- if (originalWindow) {
172
- Object.defineProperty(globalThis, 'window', originalWindow);
173
- } else {
174
- Reflect.deleteProperty(globalThis, 'window');
175
- }
176
- });
177
-
178
- it('falls back to memory storage when localStorage access throws', () => {
179
- const blockedWindow = {};
180
- Object.defineProperty(blockedWindow, 'localStorage', {
181
- configurable: true,
182
- get() {
183
- throw new DOMException('Blocked localStorage', 'SecurityError');
184
- },
185
- });
186
- Object.defineProperty(globalThis, 'window', {
187
- configurable: true,
188
- value: blockedWindow,
189
- });
190
-
191
- expect(() => new AuthManager(makeMockServices() as unknown as OxyServices, {
192
- autoRefresh: false,
193
- crossTabSync: false,
194
- })).not.toThrow();
195
- });
196
- });
197
-
198
- describe('AuthManager.switchAuthuser — hydration of unknown slots', () => {
199
- it('hydrates a slot with no prior user metadata via getCurrentUser()', async () => {
200
- const services = makeMockServices();
201
- // Skip restoreFromCookies — switch onto a slot the AuthManager has
202
- // never seen.
203
- services.refreshTokenViaCookie.mockResolvedValueOnce({
204
- accessToken: 'fresh-slot-3',
205
- expiresAt: '2099-01-01T00:00:00.000Z',
206
- authuser: 3,
207
- });
208
-
209
- const manager = makeManager(services);
210
- await manager.switchAuthuser(3);
211
-
212
- // Wait a microtask cycle so the fire-and-forget hydration completes.
213
- await new Promise((resolve) => setImmediate(resolve));
214
-
215
- expect(services.getCurrentUser).toHaveBeenCalledTimes(1);
216
- const slot3 = manager.getAccounts().find((a) => a.authuser === 3);
217
- expect(slot3?.user).not.toBeNull();
218
- expect(slot3?.user?.username).toBe('hydrated');
219
- });
220
-
221
- it('leaves user as null when getCurrentUser fails — UI falls back to public-key handle', async () => {
222
- const services = makeMockServices();
223
- services.refreshTokenViaCookie.mockResolvedValueOnce({
224
- accessToken: 'fresh-slot-3',
225
- expiresAt: '2099-01-01T00:00:00.000Z',
226
- authuser: 3,
227
- });
228
- services.getCurrentUser.mockRejectedValueOnce(new Error('network down'));
229
-
230
- const manager = makeManager(services);
231
- await manager.switchAuthuser(3);
232
-
233
- await new Promise((resolve) => setImmediate(resolve));
234
-
235
- expect(services.getCurrentUser).toHaveBeenCalledTimes(1);
236
- const slot3 = manager.getAccounts().find((a) => a.authuser === 3);
237
- // null, NOT { id: '', username: '' } — that empty-string placeholder
238
- // is the precise smell H6/H7 is fixing.
239
- expect(slot3?.user).toBeNull();
240
- });
241
- });
242
-
243
- describe('AuthManager.restoreFromCookies — debounce', () => {
244
- it('returns the cached registry without a network call when invoked twice within the debounce window', async () => {
245
- const services = makeMockServices();
246
- services.refreshAllSessions.mockResolvedValue(TWO_ACCOUNTS);
247
-
248
- const manager = makeManager(services);
249
- await manager.restoreFromCookies();
250
- expect(services.refreshAllSessions).toHaveBeenCalledTimes(1);
251
-
252
- // Second call inside the 2 s window — must short-circuit.
253
- const result = await manager.restoreFromCookies();
254
- expect(services.refreshAllSessions).toHaveBeenCalledTimes(1);
255
- expect(result.activeAuthuser).toBe(0);
256
- expect(result.accounts).toHaveLength(2);
257
- });
258
- });
259
-
260
- // --- Cross-tab BroadcastChannel nonce gate ----------------------------------
261
-
262
- type ChannelListener = (event: MessageEvent) => void;
263
-
264
- class FakeBroadcastChannel {
265
- static instances: FakeBroadcastChannel[] = [];
266
- readonly name: string;
267
- onmessage: ChannelListener | null = null;
268
- closed = false;
269
-
270
- constructor(name: string) {
271
- this.name = name;
272
- FakeBroadcastChannel.instances.push(this);
273
- }
274
-
275
- postMessage(data: unknown): void {
276
- if (this.closed) return;
277
- // Deliver to every OTHER same-name channel (BroadcastChannel never
278
- // delivers to the sender). Real BroadcastChannel.onmessage receives a
279
- // MessageEvent with `data`, so wrap the payload accordingly.
280
- const event = { data } as unknown as MessageEvent;
281
- for (const other of FakeBroadcastChannel.instances) {
282
- if (other === this || other.closed || other.name !== this.name) continue;
283
- const listener = other.onmessage;
284
- if (listener) listener(event);
285
- }
286
- }
287
-
288
- close(): void {
289
- this.closed = true;
290
- }
291
- }
292
-
293
- describe('AuthManager broadcast nonce gate', () => {
294
- const realBC = (globalThis as { BroadcastChannel?: unknown }).BroadcastChannel;
295
-
296
- beforeEach(() => {
297
- FakeBroadcastChannel.instances = [];
298
- (globalThis as { BroadcastChannel?: typeof BroadcastChannel }).BroadcastChannel =
299
- FakeBroadcastChannel as unknown as typeof BroadcastChannel;
300
- });
301
-
302
- afterEach(() => {
303
- (globalThis as { BroadcastChannel?: unknown }).BroadcastChannel = realBC;
304
- FakeBroadcastChannel.instances = [];
305
- });
306
-
307
- function getChannel(index: number): FakeBroadcastChannel {
308
- const channel = FakeBroadcastChannel.instances[index];
309
- if (!channel) throw new Error(`No FakeBroadcastChannel at index ${index}`);
310
- return channel;
311
- }
312
-
313
- it('ignores forged messages whose nonce does not match a known peer', async () => {
314
- const servicesA = makeMockServices();
315
- const servicesB = makeMockServices();
316
- servicesB.refreshAllSessions.mockResolvedValue({ accounts: [] });
317
-
318
- // Tab A and Tab B both own a BroadcastChannel.
319
- makeManager(servicesA, { crossTabSync: true });
320
- const tabB = makeManager(servicesB, { crossTabSync: true });
321
-
322
- const channelA = getChannel(0);
323
- const channelB = getChannel(1);
324
- expect(channelA.name).toBe('oxy_auth_sync');
325
- expect(channelB.name).toBe('oxy_auth_sync');
326
-
327
- // Send a legitimate message from tabId=peer-1 with nonce=N1. Tab B
328
- // (the receiver under test) has never heard from this peer before, so
329
- // it records (peer-1 → N1) and honours the message.
330
- channelA.postMessage({
331
- type: 'accounts_restored',
332
- timestamp: Date.now(),
333
- tabId: 'peer-1',
334
- nonce: 'nonce-real',
335
- });
336
-
337
- // Two microtask drains: one for the inner Promise.resolve().then(), one
338
- // for the restoreFromCookies() call it schedules.
339
- await new Promise((resolve) => setImmediate(resolve));
340
- await new Promise((resolve) => setImmediate(resolve));
341
- expect(servicesB.refreshAllSessions).toHaveBeenCalledTimes(1);
342
- servicesB.refreshAllSessions.mockClear();
343
-
344
- // Now a forged broadcast: same tabId, different nonce (the XSS payload
345
- // doesn't know the real `_broadcastNonce`).
346
- channelA.postMessage({
347
- type: 'all_signed_out',
348
- timestamp: Date.now(),
349
- tabId: 'peer-1',
350
- nonce: 'nonce-forged',
351
- });
352
-
353
- await new Promise((resolve) => setImmediate(resolve));
354
- await new Promise((resolve) => setImmediate(resolve));
355
- // The forged `all_signed_out` MUST NOT trigger a sign-out cascade.
356
- expect(servicesB.httpService.setTokens).not.toHaveBeenCalledWith('');
357
- // And no further refresh-all rotation either.
358
- expect(servicesB.refreshAllSessions).not.toHaveBeenCalled();
359
- // Sanity: tab B keeps its lifecycle intact.
360
- expect(tabB.getActiveAuthuser()).toBeNull();
361
- });
362
-
363
- it('drops messages missing tabId or nonce entirely', async () => {
364
- const services = makeMockServices();
365
- makeManager(services, { crossTabSync: true });
366
-
367
- const channel = getChannel(0);
368
- // Inject a second channel as the "attacker" — same name so postMessage
369
- // gets delivered to the real tab.
370
- const attacker = new FakeBroadcastChannel('oxy_auth_sync');
371
- attacker.postMessage({ type: 'all_signed_out', timestamp: Date.now() });
372
-
373
- await new Promise((resolve) => setImmediate(resolve));
374
- expect(services.httpService.setTokens).not.toHaveBeenCalledWith('');
375
- expect(channel).toBeDefined();
376
- });
377
- });
@@ -1,116 +0,0 @@
1
- import { CrossDomainAuth } from '../CrossDomainAuth';
2
- import type { OxyServices } from '../OxyServices';
3
- import type { SessionLoginResponse } from '../models/session';
4
-
5
- interface MockServices {
6
- signInWithFedCM: jest.Mock;
7
- signInWithRedirect: jest.Mock;
8
- silentSignInWithFedCM: jest.Mock;
9
- silentSignIn: jest.Mock;
10
- isFedCMSupported: jest.Mock;
11
- getCurrentUser: jest.Mock;
12
- handleAuthCallback: jest.Mock;
13
- restoreSession: jest.Mock;
14
- getStoredSessionId: jest.Mock;
15
- }
16
-
17
- function fakeSession(id: string): SessionLoginResponse {
18
- return {
19
- sessionId: id,
20
- deviceId: 'dev',
21
- expiresAt: new Date(Date.now() + 60000).toISOString(),
22
- accessToken: 'tok',
23
- user: { id: 'u', username: 'tester' },
24
- } as unknown as SessionLoginResponse;
25
- }
26
-
27
- function createMockServices(overrides: Partial<MockServices> = {}): MockServices {
28
- return {
29
- signInWithFedCM: jest.fn(async () => fakeSession('fedcm-sess')),
30
- signInWithRedirect: jest.fn(),
31
- silentSignInWithFedCM: jest.fn(async () => null),
32
- silentSignIn: jest.fn(async () => null),
33
- isFedCMSupported: jest.fn(() => true),
34
- getCurrentUser: jest.fn(),
35
- handleAuthCallback: jest.fn(() => null),
36
- restoreSession: jest.fn(() => false),
37
- getStoredSessionId: jest.fn(() => ''),
38
- ...overrides,
39
- };
40
- }
41
-
42
- describe('CrossDomainAuth', () => {
43
- it('uses FedCM for an explicit FedCM sign-in', async () => {
44
- const services = createMockServices();
45
- const auth = new CrossDomainAuth(services as unknown as OxyServices);
46
-
47
- const session = await auth.signIn({ method: 'fedcm' });
48
-
49
- expect(session).toBeTruthy();
50
- expect(services.signInWithFedCM).toHaveBeenCalledTimes(1);
51
- expect(services.signInWithRedirect).not.toHaveBeenCalled();
52
- });
53
-
54
- it('initiates redirect and returns null for an explicit redirect sign-in', async () => {
55
- const services = createMockServices();
56
- const auth = new CrossDomainAuth(services as unknown as OxyServices);
57
-
58
- const result = await auth.signIn({ method: 'redirect', redirectUri: 'https://app.oxy.so' });
59
-
60
- expect(result).toBeNull();
61
- expect(services.signInWithRedirect).toHaveBeenCalledWith({
62
- redirectUri: 'https://app.oxy.so',
63
- mode: 'login',
64
- });
65
- });
66
-
67
- it('auto mode goes straight to redirect and never calls FedCM, even when FedCM is supported', async () => {
68
- const services = createMockServices({ isFedCMSupported: jest.fn(() => true) });
69
- const auth = new CrossDomainAuth(services as unknown as OxyServices);
70
- const selected: string[] = [];
71
-
72
- const result = await auth.signIn({
73
- method: 'auto',
74
- onMethodSelected: (method) => selected.push(method),
75
- });
76
-
77
- expect(result).toBeNull();
78
- expect(selected).toEqual(['redirect']);
79
- expect(services.signInWithFedCM).not.toHaveBeenCalled();
80
- expect(services.signInWithRedirect).toHaveBeenCalledTimes(1);
81
- });
82
-
83
- it('autoSignIn does not call FedCM (goes to redirect) regardless of browser support', async () => {
84
- const services = createMockServices({
85
- isFedCMSupported: jest.fn(() => true),
86
- signInWithFedCM: jest.fn(async () => {
87
- throw new Error('autoSignIn must never call signInWithFedCM');
88
- }),
89
- });
90
- const auth = new CrossDomainAuth(services as unknown as OxyServices);
91
-
92
- const result = await auth.signIn({ method: 'auto' });
93
-
94
- expect(result).toBeNull();
95
- expect(services.signInWithFedCM).not.toHaveBeenCalled();
96
- expect(services.isFedCMSupported).not.toHaveBeenCalled();
97
- expect(services.signInWithRedirect).toHaveBeenCalledTimes(1);
98
- });
99
-
100
- it('getRecommendedMethod always recommends redirect', () => {
101
- const services = createMockServices({ isFedCMSupported: jest.fn(() => true) });
102
- const auth = new CrossDomainAuth(services as unknown as OxyServices);
103
-
104
- expect(auth.getRecommendedMethod().method).toBe('redirect');
105
- expect(services.isFedCMSupported).not.toHaveBeenCalled();
106
- });
107
-
108
- it('silentSignIn falls back to iframe-based silent auth without ever calling FedCM', async () => {
109
- const services = createMockServices({ isFedCMSupported: jest.fn(() => true) });
110
- const auth = new CrossDomainAuth(services as unknown as OxyServices);
111
-
112
- await auth.silentSignIn();
113
-
114
- expect(services.silentSignInWithFedCM).not.toHaveBeenCalled();
115
- });
116
- });