@oxyhq/core 1.11.24 → 2.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 (140) hide show
  1. package/README.md +5 -6
  2. package/dist/cjs/.tsbuildinfo +1 -1
  3. package/dist/cjs/AuthManager.js +678 -4
  4. package/dist/cjs/AuthManagerTypes.js +13 -0
  5. package/dist/cjs/CrossDomainAuth.js +45 -3
  6. package/dist/cjs/OxyServices.base.js +16 -0
  7. package/dist/cjs/i18n/locales/ar-SA.json +83 -0
  8. package/dist/cjs/i18n/locales/ca-ES.json +83 -0
  9. package/dist/cjs/i18n/locales/de-DE.json +83 -0
  10. package/dist/cjs/i18n/locales/en-US.json +83 -0
  11. package/dist/cjs/i18n/locales/es-ES.json +99 -4
  12. package/dist/cjs/i18n/locales/fr-FR.json +83 -0
  13. package/dist/cjs/i18n/locales/it-IT.json +83 -0
  14. package/dist/cjs/i18n/locales/ja-JP.json +83 -0
  15. package/dist/cjs/i18n/locales/ko-KR.json +83 -0
  16. package/dist/cjs/i18n/locales/locales/ar-SA.json +83 -1
  17. package/dist/cjs/i18n/locales/locales/ca-ES.json +83 -1
  18. package/dist/cjs/i18n/locales/locales/de-DE.json +83 -1
  19. package/dist/cjs/i18n/locales/locales/en-US.json +83 -0
  20. package/dist/cjs/i18n/locales/locales/es-ES.json +99 -4
  21. package/dist/cjs/i18n/locales/locales/fr-FR.json +83 -1
  22. package/dist/cjs/i18n/locales/locales/it-IT.json +83 -1
  23. package/dist/cjs/i18n/locales/locales/ja-JP.json +200 -117
  24. package/dist/cjs/i18n/locales/locales/ko-KR.json +83 -1
  25. package/dist/cjs/i18n/locales/locales/pt-PT.json +83 -1
  26. package/dist/cjs/i18n/locales/locales/zh-CN.json +83 -1
  27. package/dist/cjs/i18n/locales/pt-PT.json +83 -0
  28. package/dist/cjs/i18n/locales/zh-CN.json +83 -0
  29. package/dist/cjs/index.js +114 -57
  30. package/dist/cjs/mixins/OxyServices.auth.js +235 -0
  31. package/dist/cjs/mixins/OxyServices.fedcm.js +36 -0
  32. package/dist/cjs/mixins/OxyServices.popup.js +61 -1
  33. package/dist/cjs/mixins/OxyServices.user.js +18 -0
  34. package/dist/cjs/utils/accountUtils.js +64 -1
  35. package/dist/esm/.tsbuildinfo +1 -1
  36. package/dist/esm/AuthManager.js +678 -4
  37. package/dist/esm/AuthManagerTypes.js +12 -0
  38. package/dist/esm/CrossDomainAuth.js +45 -3
  39. package/dist/esm/OxyServices.base.js +16 -0
  40. package/dist/esm/i18n/locales/ar-SA.json +83 -0
  41. package/dist/esm/i18n/locales/ca-ES.json +83 -0
  42. package/dist/esm/i18n/locales/de-DE.json +83 -0
  43. package/dist/esm/i18n/locales/en-US.json +83 -0
  44. package/dist/esm/i18n/locales/es-ES.json +99 -4
  45. package/dist/esm/i18n/locales/fr-FR.json +83 -0
  46. package/dist/esm/i18n/locales/it-IT.json +83 -0
  47. package/dist/esm/i18n/locales/ja-JP.json +83 -0
  48. package/dist/esm/i18n/locales/ko-KR.json +83 -0
  49. package/dist/esm/i18n/locales/locales/ar-SA.json +83 -1
  50. package/dist/esm/i18n/locales/locales/ca-ES.json +83 -1
  51. package/dist/esm/i18n/locales/locales/de-DE.json +83 -1
  52. package/dist/esm/i18n/locales/locales/en-US.json +83 -0
  53. package/dist/esm/i18n/locales/locales/es-ES.json +99 -4
  54. package/dist/esm/i18n/locales/locales/fr-FR.json +83 -1
  55. package/dist/esm/i18n/locales/locales/it-IT.json +83 -1
  56. package/dist/esm/i18n/locales/locales/ja-JP.json +200 -117
  57. package/dist/esm/i18n/locales/locales/ko-KR.json +83 -1
  58. package/dist/esm/i18n/locales/locales/pt-PT.json +83 -1
  59. package/dist/esm/i18n/locales/locales/zh-CN.json +83 -1
  60. package/dist/esm/i18n/locales/pt-PT.json +83 -0
  61. package/dist/esm/i18n/locales/zh-CN.json +83 -0
  62. package/dist/esm/index.js +69 -26
  63. package/dist/esm/mixins/OxyServices.auth.js +235 -0
  64. package/dist/esm/mixins/OxyServices.fedcm.js +36 -0
  65. package/dist/esm/mixins/OxyServices.popup.js +61 -1
  66. package/dist/esm/mixins/OxyServices.user.js +18 -0
  67. package/dist/esm/utils/accountUtils.js +61 -0
  68. package/dist/types/.tsbuildinfo +1 -1
  69. package/dist/types/AuthManager.d.ts +243 -3
  70. package/dist/types/AuthManagerTypes.d.ts +68 -0
  71. package/dist/types/CrossDomainAuth.d.ts +23 -0
  72. package/dist/types/OxyServices.base.d.ts +14 -0
  73. package/dist/types/OxyServices.d.ts +7 -0
  74. package/dist/types/index.d.ts +28 -17
  75. package/dist/types/mixins/OxyServices.analytics.d.ts +1 -0
  76. package/dist/types/mixins/OxyServices.appData.d.ts +1 -0
  77. package/dist/types/mixins/OxyServices.assets.d.ts +4 -1
  78. package/dist/types/mixins/OxyServices.auth.d.ts +73 -1
  79. package/dist/types/mixins/OxyServices.contacts.d.ts +1 -0
  80. package/dist/types/mixins/OxyServices.developer.d.ts +1 -0
  81. package/dist/types/mixins/OxyServices.devices.d.ts +1 -0
  82. package/dist/types/mixins/OxyServices.features.d.ts +2 -5
  83. package/dist/types/mixins/OxyServices.fedcm.d.ts +34 -0
  84. package/dist/types/mixins/OxyServices.karma.d.ts +1 -0
  85. package/dist/types/mixins/OxyServices.language.d.ts +1 -0
  86. package/dist/types/mixins/OxyServices.location.d.ts +1 -0
  87. package/dist/types/mixins/OxyServices.managedAccounts.d.ts +1 -0
  88. package/dist/types/mixins/OxyServices.payment.d.ts +1 -0
  89. package/dist/types/mixins/OxyServices.popup.d.ts +40 -0
  90. package/dist/types/mixins/OxyServices.privacy.d.ts +1 -0
  91. package/dist/types/mixins/OxyServices.redirect.d.ts +1 -0
  92. package/dist/types/mixins/OxyServices.security.d.ts +1 -0
  93. package/dist/types/mixins/OxyServices.topics.d.ts +1 -0
  94. package/dist/types/mixins/OxyServices.user.d.ts +16 -1
  95. package/dist/types/mixins/OxyServices.utility.d.ts +1 -0
  96. package/dist/types/models/interfaces.d.ts +98 -0
  97. package/dist/types/models/session.d.ts +8 -0
  98. package/dist/types/utils/accountUtils.d.ts +33 -0
  99. package/package.json +9 -18
  100. package/src/AuthManager.ts +776 -7
  101. package/src/AuthManagerTypes.ts +72 -0
  102. package/src/CrossDomainAuth.ts +54 -3
  103. package/src/OxyServices.base.ts +17 -0
  104. package/src/OxyServices.ts +7 -0
  105. package/src/__tests__/authManager.cookiePath.test.ts +339 -0
  106. package/src/__tests__/authManager.security.test.ts +342 -0
  107. package/src/__tests__/crossDomainAuth.test.ts +191 -0
  108. package/src/i18n/locales/ar-SA.json +83 -1
  109. package/src/i18n/locales/ca-ES.json +83 -1
  110. package/src/i18n/locales/de-DE.json +83 -1
  111. package/src/i18n/locales/en-US.json +83 -0
  112. package/src/i18n/locales/es-ES.json +99 -4
  113. package/src/i18n/locales/fr-FR.json +83 -1
  114. package/src/i18n/locales/it-IT.json +83 -1
  115. package/src/i18n/locales/ja-JP.json +200 -117
  116. package/src/i18n/locales/ko-KR.json +83 -1
  117. package/src/i18n/locales/pt-PT.json +83 -1
  118. package/src/i18n/locales/zh-CN.json +83 -1
  119. package/src/index.ts +295 -112
  120. package/src/mixins/OxyServices.auth.ts +268 -1
  121. package/src/mixins/OxyServices.fedcm.ts +63 -0
  122. package/src/mixins/OxyServices.popup.ts +79 -1
  123. package/src/mixins/OxyServices.user.ts +33 -1
  124. package/src/mixins/__tests__/popup.test.ts +307 -0
  125. package/src/mixins/__tests__/sessionBaseUrl.test.ts +61 -0
  126. package/src/models/interfaces.ts +116 -0
  127. package/src/models/session.ts +8 -0
  128. package/src/utils/accountUtils.ts +84 -0
  129. package/dist/cjs/crypto/index.js +0 -22
  130. package/dist/cjs/shared/index.js +0 -70
  131. package/dist/cjs/utils/index.js +0 -26
  132. package/dist/esm/crypto/index.js +0 -13
  133. package/dist/esm/shared/index.js +0 -31
  134. package/dist/esm/utils/index.js +0 -7
  135. package/dist/types/crypto/index.d.ts +0 -11
  136. package/dist/types/shared/index.d.ts +0 -28
  137. package/dist/types/utils/index.d.ts +0 -6
  138. package/src/crypto/index.ts +0 -30
  139. package/src/shared/index.ts +0 -82
  140. package/src/utils/index.ts +0 -21
@@ -0,0 +1,342 @@
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 MockServices {
43
+ refreshAllSessions: jest.Mock<Promise<RefreshAllResponse>, []>;
44
+ refreshTokenViaCookie: jest.Mock<Promise<RefreshCookieResponse | null>, [{ authuser?: number }]>;
45
+ logoutSessionByAuthuser: jest.Mock<Promise<void>, [number]>;
46
+ logoutAllSessionsViaCookie: jest.Mock<Promise<void>, []>;
47
+ getCurrentUser: jest.Mock<Promise<User>, []>;
48
+ httpService: { setTokens: jest.Mock; onTokenRefreshed: ((t: string) => void) | undefined };
49
+ }
50
+
51
+ function makeMockServices(): MockServices {
52
+ return {
53
+ refreshAllSessions: jest.fn(async (): Promise<RefreshAllResponse> => ({ accounts: [] })),
54
+ refreshTokenViaCookie: jest.fn(),
55
+ logoutSessionByAuthuser: jest.fn(async () => undefined),
56
+ logoutAllSessionsViaCookie: jest.fn(async () => undefined),
57
+ getCurrentUser: jest.fn(async (): Promise<User> => ({
58
+ id: 'user-x',
59
+ publicKey: '0xdeadbeef',
60
+ username: 'hydrated',
61
+ avatar: 'avatar-1',
62
+ color: 'teal',
63
+ } as User)),
64
+ httpService: { setTokens: jest.fn(), onTokenRefreshed: undefined },
65
+ };
66
+ }
67
+
68
+ function makeManager(services: MockServices, options: { crossTabSync?: boolean } = {}): AuthManager {
69
+ const storage = new InMemoryStorage();
70
+ return new AuthManager(services as unknown as OxyServices, {
71
+ storage,
72
+ autoRefresh: false,
73
+ crossTabSync: options.crossTabSync ?? false,
74
+ cookieOnly: true,
75
+ });
76
+ }
77
+
78
+ const TOKEN_SLOT_0 = buildAccessToken({ sessionId: 'sess-slot-0', userId: 'user-0', exp: 9999999999 });
79
+ const TOKEN_SLOT_1 = buildAccessToken({ sessionId: 'sess-slot-1', userId: 'user-1', exp: 9999999999 });
80
+
81
+ const TWO_ACCOUNTS: RefreshAllResponse = {
82
+ accounts: [
83
+ {
84
+ authuser: 0,
85
+ accessToken: TOKEN_SLOT_0,
86
+ expiresAt: '2099-01-01T00:00:00.000Z',
87
+ sessionId: 'sess-slot-0',
88
+ user: { id: 'user-0', username: 'alice', avatar: null, color: '#1abc9c' },
89
+ },
90
+ {
91
+ authuser: 1,
92
+ accessToken: TOKEN_SLOT_1,
93
+ expiresAt: '2099-01-01T00:00:00.000Z',
94
+ sessionId: 'sess-slot-1',
95
+ user: { id: 'user-1', username: 'bob', avatar: null, color: '#3498db' },
96
+ },
97
+ ],
98
+ };
99
+
100
+ describe('AuthManager.switchAuthuser — concurrency lock', () => {
101
+ it('coalesces two concurrent calls into a single refresh + rotation', async () => {
102
+ const services = makeMockServices();
103
+ services.refreshAllSessions.mockResolvedValueOnce(TWO_ACCOUNTS);
104
+
105
+ // Return the same rotated token for any call, but make the resolution
106
+ // explicit so the two concurrent callers genuinely overlap.
107
+ let resolveRotation: ((value: RefreshCookieResponse) => void) | undefined;
108
+ services.refreshTokenViaCookie.mockImplementationOnce(
109
+ () => new Promise<RefreshCookieResponse>((resolve) => {
110
+ resolveRotation = resolve;
111
+ })
112
+ );
113
+
114
+ const manager = makeManager(services);
115
+ await manager.restoreFromCookies();
116
+
117
+ const p1 = manager.switchAuthuser(1);
118
+ const p2 = manager.switchAuthuser(1);
119
+
120
+ // Both callers MUST share the same in-flight promise object. If they
121
+ // were two independent rotations, refreshTokenViaCookie would have
122
+ // been invoked twice already.
123
+ expect(services.refreshTokenViaCookie).toHaveBeenCalledTimes(1);
124
+
125
+ if (!resolveRotation) {
126
+ throw new Error('rotation pending callback not captured');
127
+ }
128
+ resolveRotation({
129
+ accessToken: 'rotated-slot-1-token',
130
+ expiresAt: '2099-01-01T00:00:00.000Z',
131
+ authuser: 1,
132
+ });
133
+
134
+ const [r1, r2] = await Promise.all([p1, p2]);
135
+
136
+ expect(r1).toEqual(r2);
137
+ expect(services.refreshTokenViaCookie).toHaveBeenCalledTimes(1);
138
+ expect(manager.getActiveAuthuser()).toBe(1);
139
+ });
140
+
141
+ it('clears the in-flight slot after a failed rotation so the next call can retry', async () => {
142
+ const services = makeMockServices();
143
+ services.refreshAllSessions.mockResolvedValueOnce(TWO_ACCOUNTS);
144
+ services.refreshTokenViaCookie
145
+ .mockResolvedValueOnce(null) // First attempt: cookie expired.
146
+ .mockResolvedValueOnce({
147
+ accessToken: 'rotated-slot-1-token',
148
+ expiresAt: '2099-01-01T00:00:00.000Z',
149
+ authuser: 1,
150
+ });
151
+
152
+ const manager = makeManager(services);
153
+ await manager.restoreFromCookies();
154
+
155
+ await expect(manager.switchAuthuser(1)).rejects.toThrow(/authuser=1/);
156
+ // The lock must release; a follow-up switch is permitted.
157
+ const second = await manager.switchAuthuser(1);
158
+ expect(second.accessToken).toBe('rotated-slot-1-token');
159
+ expect(services.refreshTokenViaCookie).toHaveBeenCalledTimes(2);
160
+ });
161
+ });
162
+
163
+ describe('AuthManager.switchAuthuser — hydration of unknown slots', () => {
164
+ it('hydrates a slot with no prior user metadata via getCurrentUser()', async () => {
165
+ const services = makeMockServices();
166
+ // Skip restoreFromCookies — switch onto a slot the AuthManager has
167
+ // never seen.
168
+ services.refreshTokenViaCookie.mockResolvedValueOnce({
169
+ accessToken: 'fresh-slot-3',
170
+ expiresAt: '2099-01-01T00:00:00.000Z',
171
+ authuser: 3,
172
+ });
173
+
174
+ const manager = makeManager(services);
175
+ await manager.switchAuthuser(3);
176
+
177
+ // Wait a microtask cycle so the fire-and-forget hydration completes.
178
+ await new Promise((resolve) => setImmediate(resolve));
179
+
180
+ expect(services.getCurrentUser).toHaveBeenCalledTimes(1);
181
+ const slot3 = manager.getAccounts().find((a) => a.authuser === 3);
182
+ expect(slot3?.user).not.toBeNull();
183
+ expect(slot3?.user?.username).toBe('hydrated');
184
+ });
185
+
186
+ it('leaves user as null when getCurrentUser fails — UI falls back to public-key handle', async () => {
187
+ const services = makeMockServices();
188
+ services.refreshTokenViaCookie.mockResolvedValueOnce({
189
+ accessToken: 'fresh-slot-3',
190
+ expiresAt: '2099-01-01T00:00:00.000Z',
191
+ authuser: 3,
192
+ });
193
+ services.getCurrentUser.mockRejectedValueOnce(new Error('network down'));
194
+
195
+ const manager = makeManager(services);
196
+ await manager.switchAuthuser(3);
197
+
198
+ await new Promise((resolve) => setImmediate(resolve));
199
+
200
+ expect(services.getCurrentUser).toHaveBeenCalledTimes(1);
201
+ const slot3 = manager.getAccounts().find((a) => a.authuser === 3);
202
+ // null, NOT { id: '', username: '' } — that empty-string placeholder
203
+ // is the precise smell H6/H7 is fixing.
204
+ expect(slot3?.user).toBeNull();
205
+ });
206
+ });
207
+
208
+ describe('AuthManager.restoreFromCookies — debounce', () => {
209
+ it('returns the cached registry without a network call when invoked twice within the debounce window', async () => {
210
+ const services = makeMockServices();
211
+ services.refreshAllSessions.mockResolvedValue(TWO_ACCOUNTS);
212
+
213
+ const manager = makeManager(services);
214
+ await manager.restoreFromCookies();
215
+ expect(services.refreshAllSessions).toHaveBeenCalledTimes(1);
216
+
217
+ // Second call inside the 2 s window — must short-circuit.
218
+ const result = await manager.restoreFromCookies();
219
+ expect(services.refreshAllSessions).toHaveBeenCalledTimes(1);
220
+ expect(result.activeAuthuser).toBe(0);
221
+ expect(result.accounts).toHaveLength(2);
222
+ });
223
+ });
224
+
225
+ // --- Cross-tab BroadcastChannel nonce gate ----------------------------------
226
+
227
+ type ChannelListener = (event: MessageEvent) => void;
228
+
229
+ class FakeBroadcastChannel {
230
+ static instances: FakeBroadcastChannel[] = [];
231
+ readonly name: string;
232
+ onmessage: ChannelListener | null = null;
233
+ closed = false;
234
+
235
+ constructor(name: string) {
236
+ this.name = name;
237
+ FakeBroadcastChannel.instances.push(this);
238
+ }
239
+
240
+ postMessage(data: unknown): void {
241
+ if (this.closed) return;
242
+ // Deliver to every OTHER same-name channel (BroadcastChannel never
243
+ // delivers to the sender). Real BroadcastChannel.onmessage receives a
244
+ // MessageEvent with `data`, so wrap the payload accordingly.
245
+ const event = { data } as unknown as MessageEvent;
246
+ for (const other of FakeBroadcastChannel.instances) {
247
+ if (other === this || other.closed || other.name !== this.name) continue;
248
+ const listener = other.onmessage;
249
+ if (listener) listener(event);
250
+ }
251
+ }
252
+
253
+ close(): void {
254
+ this.closed = true;
255
+ }
256
+ }
257
+
258
+ describe('AuthManager broadcast nonce gate', () => {
259
+ const realBC = (globalThis as { BroadcastChannel?: unknown }).BroadcastChannel;
260
+
261
+ beforeEach(() => {
262
+ FakeBroadcastChannel.instances = [];
263
+ (globalThis as { BroadcastChannel?: typeof BroadcastChannel }).BroadcastChannel =
264
+ FakeBroadcastChannel as unknown as typeof BroadcastChannel;
265
+ });
266
+
267
+ afterEach(() => {
268
+ (globalThis as { BroadcastChannel?: unknown }).BroadcastChannel = realBC;
269
+ FakeBroadcastChannel.instances = [];
270
+ });
271
+
272
+ function getChannel(index: number): FakeBroadcastChannel {
273
+ const channel = FakeBroadcastChannel.instances[index];
274
+ if (!channel) throw new Error(`No FakeBroadcastChannel at index ${index}`);
275
+ return channel;
276
+ }
277
+
278
+ it('ignores forged messages whose nonce does not match a known peer', async () => {
279
+ const servicesA = makeMockServices();
280
+ const servicesB = makeMockServices();
281
+ servicesB.refreshAllSessions.mockResolvedValue({ accounts: [] });
282
+
283
+ // Tab A and Tab B both own a BroadcastChannel.
284
+ makeManager(servicesA, { crossTabSync: true });
285
+ const tabB = makeManager(servicesB, { crossTabSync: true });
286
+
287
+ const channelA = getChannel(0);
288
+ const channelB = getChannel(1);
289
+ expect(channelA.name).toBe('oxy_auth_sync');
290
+ expect(channelB.name).toBe('oxy_auth_sync');
291
+
292
+ // Send a legitimate message from tabId=peer-1 with nonce=N1. Tab B
293
+ // (the receiver under test) has never heard from this peer before, so
294
+ // it records (peer-1 → N1) and honours the message.
295
+ channelA.postMessage({
296
+ type: 'accounts_restored',
297
+ timestamp: Date.now(),
298
+ tabId: 'peer-1',
299
+ nonce: 'nonce-real',
300
+ });
301
+
302
+ // Two microtask drains: one for the inner Promise.resolve().then(), one
303
+ // for the restoreFromCookies() call it schedules.
304
+ await new Promise((resolve) => setImmediate(resolve));
305
+ await new Promise((resolve) => setImmediate(resolve));
306
+ expect(servicesB.refreshAllSessions).toHaveBeenCalledTimes(1);
307
+ servicesB.refreshAllSessions.mockClear();
308
+
309
+ // Now a forged broadcast: same tabId, different nonce (the XSS payload
310
+ // doesn't know the real `_broadcastNonce`).
311
+ channelA.postMessage({
312
+ type: 'all_signed_out',
313
+ timestamp: Date.now(),
314
+ tabId: 'peer-1',
315
+ nonce: 'nonce-forged',
316
+ });
317
+
318
+ await new Promise((resolve) => setImmediate(resolve));
319
+ await new Promise((resolve) => setImmediate(resolve));
320
+ // The forged `all_signed_out` MUST NOT trigger a sign-out cascade.
321
+ expect(servicesB.httpService.setTokens).not.toHaveBeenCalledWith('');
322
+ // And no further refresh-all rotation either.
323
+ expect(servicesB.refreshAllSessions).not.toHaveBeenCalled();
324
+ // Sanity: tab B keeps its lifecycle intact.
325
+ expect(tabB.getActiveAuthuser()).toBeNull();
326
+ });
327
+
328
+ it('drops messages missing tabId or nonce entirely', async () => {
329
+ const services = makeMockServices();
330
+ makeManager(services, { crossTabSync: true });
331
+
332
+ const channel = getChannel(0);
333
+ // Inject a second channel as the "attacker" — same name so postMessage
334
+ // gets delivered to the real tab.
335
+ const attacker = new FakeBroadcastChannel('oxy_auth_sync');
336
+ attacker.postMessage({ type: 'all_signed_out', timestamp: Date.now() });
337
+
338
+ await new Promise((resolve) => setImmediate(resolve));
339
+ expect(services.httpService.setTokens).not.toHaveBeenCalledWith('');
340
+ expect(channel).toBeDefined();
341
+ });
342
+ });
@@ -0,0 +1,191 @@
1
+ /**
2
+ * CrossDomainAuth orphan-popup cleanup regression tests.
3
+ *
4
+ * Locks in the security-review fix for issue #1: when a caller pre-opens a
5
+ * popup (the standard §6c pattern in WebOxyProvider / services useAuth) and
6
+ * then explicitly requests `signIn({ method: 'fedcm' })` or
7
+ * `signIn({ method: 'redirect' })`, the popup is unused — it must be closed
8
+ * so it doesn't linger as an orphaned blank window over the app UI.
9
+ *
10
+ * Equally important: the `'auto'` mode already handled this for the
11
+ * FedCM-wins path, but these tests pin the EXPLICIT-method behaviour so the
12
+ * gap (an early `return this.signInWithFedCM(options)` that skipped the
13
+ * cleanup) cannot regress.
14
+ */
15
+
16
+ import { CrossDomainAuth } from '../CrossDomainAuth';
17
+ import type { OxyServices } from '../OxyServices';
18
+ import type { SessionLoginResponse } from '../models/session';
19
+
20
+ interface MockPopup {
21
+ closed: boolean;
22
+ close: jest.Mock;
23
+ }
24
+
25
+ function createMockPopup(): MockPopup {
26
+ return {
27
+ closed: false,
28
+ close: jest.fn(function (this: MockPopup) { this.closed = true; }),
29
+ };
30
+ }
31
+
32
+ interface MockServices {
33
+ signInWithFedCM: jest.Mock;
34
+ signInWithPopup: jest.Mock;
35
+ signInWithRedirect: jest.Mock;
36
+ silentSignInWithFedCM: jest.Mock;
37
+ isFedCMSupported: jest.Mock;
38
+ openBlankPopup: jest.Mock;
39
+ getCurrentUser: jest.Mock;
40
+ handleAuthCallback: jest.Mock;
41
+ restoreSession: jest.Mock;
42
+ getStoredSessionId: jest.Mock;
43
+ }
44
+
45
+ function fakeSession(id: string): SessionLoginResponse {
46
+ return {
47
+ sessionId: id,
48
+ deviceId: 'dev',
49
+ expiresAt: new Date(Date.now() + 60000).toISOString(),
50
+ accessToken: 'tok',
51
+ user: { id: 'u', username: 'tester' },
52
+ } as unknown as SessionLoginResponse;
53
+ }
54
+
55
+ function createMockServices(overrides: Partial<MockServices> = {}): MockServices {
56
+ return {
57
+ signInWithFedCM: jest.fn(async () => fakeSession('fedcm-sess')),
58
+ signInWithPopup: jest.fn(async () => fakeSession('popup-sess')),
59
+ signInWithRedirect: jest.fn(),
60
+ silentSignInWithFedCM: jest.fn(async () => null),
61
+ isFedCMSupported: jest.fn(() => true),
62
+ openBlankPopup: jest.fn(() => null),
63
+ getCurrentUser: jest.fn(),
64
+ handleAuthCallback: jest.fn(() => null),
65
+ restoreSession: jest.fn(() => false),
66
+ getStoredSessionId: jest.fn(() => ''),
67
+ ...overrides,
68
+ };
69
+ }
70
+
71
+ describe('CrossDomainAuth — orphan popup cleanup on explicit method', () => {
72
+ it('closes the pre-opened popup after a successful explicit FedCM sign-in', async () => {
73
+ const popup = createMockPopup();
74
+ const services = createMockServices();
75
+ const auth = new CrossDomainAuth(services as unknown as OxyServices);
76
+
77
+ const session = await auth.signIn({
78
+ method: 'fedcm',
79
+ popup: popup as unknown as Window,
80
+ });
81
+
82
+ expect(session).toBeTruthy();
83
+ expect(services.signInWithFedCM).toHaveBeenCalledTimes(1);
84
+ expect(services.signInWithPopup).not.toHaveBeenCalled();
85
+ expect(popup.close).toHaveBeenCalledTimes(1);
86
+ expect(popup.closed).toBe(true);
87
+ });
88
+
89
+ it('closes the pre-opened popup even when explicit FedCM sign-in fails', async () => {
90
+ const popup = createMockPopup();
91
+ const services = createMockServices({
92
+ signInWithFedCM: jest.fn(async () => {
93
+ throw new Error('user cancelled FedCM');
94
+ }),
95
+ });
96
+ const auth = new CrossDomainAuth(services as unknown as OxyServices);
97
+
98
+ await expect(
99
+ auth.signIn({ method: 'fedcm', popup: popup as unknown as Window })
100
+ ).rejects.toThrow(/user cancelled FedCM/);
101
+
102
+ expect(popup.close).toHaveBeenCalledTimes(1);
103
+ expect(popup.closed).toBe(true);
104
+ });
105
+
106
+ it('closes the pre-opened popup before initiating a redirect sign-in', async () => {
107
+ const popup = createMockPopup();
108
+ const services = createMockServices();
109
+ const auth = new CrossDomainAuth(services as unknown as OxyServices);
110
+
111
+ const result = await auth.signIn({
112
+ method: 'redirect',
113
+ popup: popup as unknown as Window,
114
+ });
115
+
116
+ // Redirect resolves to null (page is navigating).
117
+ expect(result).toBeNull();
118
+ expect(services.signInWithRedirect).toHaveBeenCalledTimes(1);
119
+ // Popup was closed BEFORE the redirect call (no point leaving a blank
120
+ // window over the in-flight navigation).
121
+ expect(popup.close).toHaveBeenCalledTimes(1);
122
+ expect(popup.closed).toBe(true);
123
+ });
124
+
125
+ it('does NOT close the popup on explicit popup method (it is the active channel)', async () => {
126
+ const popup = createMockPopup();
127
+ const services = createMockServices();
128
+ const auth = new CrossDomainAuth(services as unknown as OxyServices);
129
+
130
+ await auth.signIn({ method: 'popup', popup: popup as unknown as Window });
131
+
132
+ expect(services.signInWithPopup).toHaveBeenCalledTimes(1);
133
+ // The popup is the active sign-in channel for this method — it must be
134
+ // left for the underlying `signInWithPopup` to manage (it cleans up via
135
+ // its own `waitForPopupAuth` cleanup path).
136
+ expect(popup.close).not.toHaveBeenCalled();
137
+ });
138
+
139
+ it('does not throw when no popup is supplied for explicit FedCM', async () => {
140
+ const services = createMockServices();
141
+ const auth = new CrossDomainAuth(services as unknown as OxyServices);
142
+
143
+ await expect(auth.signIn({ method: 'fedcm' })).resolves.toBeTruthy();
144
+ });
145
+
146
+ it('does not call `close()` on an already-closed pre-opened popup', async () => {
147
+ const popup = createMockPopup();
148
+ popup.closed = true;
149
+ const services = createMockServices();
150
+ const auth = new CrossDomainAuth(services as unknown as OxyServices);
151
+
152
+ await auth.signIn({ method: 'fedcm', popup: popup as unknown as Window });
153
+
154
+ // Already-closed handle: skip the `close()` call entirely.
155
+ expect(popup.close).not.toHaveBeenCalled();
156
+ });
157
+ });
158
+
159
+ describe('CrossDomainAuth — orphan popup cleanup in auto mode (regression)', () => {
160
+ it('closes the pre-opened popup when FedCM wins under auto mode', async () => {
161
+ const popup = createMockPopup();
162
+ const services = createMockServices();
163
+ const auth = new CrossDomainAuth(services as unknown as OxyServices);
164
+
165
+ await auth.signIn({ method: 'auto', popup: popup as unknown as Window });
166
+
167
+ expect(services.signInWithFedCM).toHaveBeenCalledTimes(1);
168
+ expect(popup.close).toHaveBeenCalledTimes(1);
169
+ });
170
+
171
+ it('closes the pre-opened popup before redirect fallback when FedCM and popup both fail', async () => {
172
+ const popup = createMockPopup();
173
+ const services = createMockServices({
174
+ signInWithFedCM: jest.fn(async () => { throw new Error('fedcm fail'); }),
175
+ signInWithPopup: jest.fn(async () => { throw new Error('popup fail'); }),
176
+ });
177
+ const auth = new CrossDomainAuth(services as unknown as OxyServices);
178
+
179
+ // Suppress the expected console.warn from the auto-mode fallback path.
180
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
181
+
182
+ const result = await auth.signIn({ method: 'auto', popup: popup as unknown as Window });
183
+
184
+ expect(result).toBeNull();
185
+ expect(services.signInWithRedirect).toHaveBeenCalledTimes(1);
186
+ expect(popup.close).toHaveBeenCalledTimes(1);
187
+ expect(popup.closed).toBe(true);
188
+
189
+ warnSpy.mockRestore();
190
+ });
191
+ });
@@ -114,7 +114,89 @@
114
114
  "email": "البريد الإلكتروني",
115
115
  "password": "كلمة المرور",
116
116
  "confirmPassword": "تأكيد كلمة المرور"
117
+ },
118
+ "revoke": "Revoke"
119
+ },
120
+ "notifications": {
121
+ "title": "Notifications",
122
+ "subtitle": "Manage push, email, and security alerts",
123
+ "updateError": "Failed to update notification preferences",
124
+ "sections": {
125
+ "channels": "Channels",
126
+ "alerts": "Alerts",
127
+ "marketing": "Marketing"
128
+ },
129
+ "items": {
130
+ "push": {
131
+ "title": "Push notifications",
132
+ "subtitle": "Real-time alerts on your devices"
133
+ },
134
+ "emailDigest": {
135
+ "title": "Email digest",
136
+ "subtitle": "Periodic summary of your account activity"
137
+ },
138
+ "securityAlerts": {
139
+ "title": "Security alerts",
140
+ "subtitle": "Sign-ins, recovery codes, and key changes"
141
+ },
142
+ "marketingEmails": {
143
+ "title": "Marketing emails",
144
+ "subtitle": "Product news and occasional offers"
145
+ }
146
+ }
147
+ },
148
+ "preferences": {
149
+ "title": "Preferences",
150
+ "subtitle": "Theme, motion, and regional settings",
151
+ "sections": {
152
+ "appearance": "Appearance",
153
+ "language": "Language",
154
+ "region": "Region"
155
+ },
156
+ "theme": {
157
+ "light": "Light",
158
+ "dark": "Dark",
159
+ "system": "System default"
160
+ },
161
+ "items": {
162
+ "theme": {
163
+ "title": "Theme"
164
+ },
165
+ "reduceMotion": {
166
+ "title": "Reduce motion",
167
+ "subtitle": "Minimise animations across Oxy apps",
168
+ "systemOn": "Following system: reduce motion is on"
169
+ },
170
+ "language": {
171
+ "title": "Language"
172
+ },
173
+ "timezone": {
174
+ "title": "Timezone",
175
+ "unknown": "Unable to detect timezone"
176
+ },
177
+ "about": {
178
+ "title": "About preferences",
179
+ "subtitle": "Preferences sync across every Oxy app you sign into"
180
+ }
181
+ }
182
+ },
183
+ "connectedApps": {
184
+ "title": "Connected apps",
185
+ "subtitle": "Manage third-party app access",
186
+ "empty": {
187
+ "title": "No connected apps",
188
+ "subtitle": "Apps you authorize to sign in with your Oxy account will appear here"
189
+ },
190
+ "item": {
191
+ "lastUsed": "Last used {{relative}}"
192
+ },
193
+ "confirm": {
194
+ "title": "Revoke access",
195
+ "message": "Revoke {{name}}'s access to your Oxy account?"
196
+ },
197
+ "toasts": {
198
+ "revoked": "Revoked access for {{name}}",
199
+ "revokeFailed": "Failed to revoke access"
117
200
  }
118
201
  }
119
202
  }
120
-