@oxyhq/core 5.4.3 → 6.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (186) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/HttpService.js +6 -3
  3. package/dist/cjs/OxyServices.base.js +7 -102
  4. package/dist/cjs/boot/coldBootV2.js +350 -0
  5. package/dist/cjs/boot/deviceBootReturn.js +152 -0
  6. package/dist/cjs/crypto/keyManager.js +95 -0
  7. package/dist/cjs/i18n/locales/en-US.json +13 -1
  8. package/dist/cjs/i18n/locales/es-ES.json +13 -1
  9. package/dist/cjs/i18n/locales/locales/en-US.json +13 -1
  10. package/dist/cjs/i18n/locales/locales/es-ES.json +13 -1
  11. package/dist/cjs/index.js +47 -43
  12. package/dist/cjs/mixins/OxyServices.accounts.js +6 -13
  13. package/dist/cjs/mixins/OxyServices.auth.js +66 -201
  14. package/dist/cjs/mixins/OxyServices.authorizedApps.js +38 -0
  15. package/dist/cjs/mixins/OxyServices.deviceBoot.js +119 -0
  16. package/dist/cjs/mixins/index.js +13 -17
  17. package/dist/cjs/session/SessionClient.js +44 -2
  18. package/dist/cjs/session/authStateStore.js +284 -0
  19. package/dist/cjs/session/createSessionClient.js +8 -2
  20. package/dist/cjs/session/refresh.js +264 -0
  21. package/dist/cjs/utils/accountUtils.js +1 -55
  22. package/dist/cjs/utils/authWebUrl.js +6 -15
  23. package/dist/cjs/utils/fapiAutoDetect.js +16 -64
  24. package/dist/cjs/utils/platform.js +19 -0
  25. package/dist/cjs/utils/ssoBounce.js +15 -340
  26. package/dist/cjs/utils/validationUtils.js +57 -0
  27. package/dist/esm/.tsbuildinfo +1 -1
  28. package/dist/esm/HttpService.js +6 -3
  29. package/dist/esm/OxyServices.base.js +7 -102
  30. package/dist/esm/boot/coldBootV2.js +344 -0
  31. package/dist/esm/boot/deviceBootReturn.js +146 -0
  32. package/dist/esm/crypto/keyManager.js +95 -0
  33. package/dist/esm/i18n/locales/en-US.json +13 -1
  34. package/dist/esm/i18n/locales/es-ES.json +13 -1
  35. package/dist/esm/i18n/locales/locales/en-US.json +13 -1
  36. package/dist/esm/i18n/locales/locales/es-ES.json +13 -1
  37. package/dist/esm/index.js +28 -18
  38. package/dist/esm/mixins/OxyServices.accounts.js +6 -13
  39. package/dist/esm/mixins/OxyServices.auth.js +66 -201
  40. package/dist/esm/mixins/OxyServices.authorizedApps.js +35 -0
  41. package/dist/esm/mixins/OxyServices.deviceBoot.js +116 -0
  42. package/dist/esm/mixins/index.js +13 -17
  43. package/dist/esm/session/SessionClient.js +44 -2
  44. package/dist/esm/session/authStateStore.js +278 -0
  45. package/dist/esm/session/createSessionClient.js +8 -2
  46. package/dist/esm/session/refresh.js +257 -0
  47. package/dist/esm/utils/accountUtils.js +0 -53
  48. package/dist/esm/utils/authWebUrl.js +6 -14
  49. package/dist/esm/utils/fapiAutoDetect.js +16 -63
  50. package/dist/esm/utils/platform.js +18 -0
  51. package/dist/esm/utils/ssoBounce.js +14 -324
  52. package/dist/esm/utils/validationUtils.js +56 -0
  53. package/dist/types/.tsbuildinfo +1 -1
  54. package/dist/types/HttpService.d.ts +13 -0
  55. package/dist/types/OxyServices.base.d.ts +0 -52
  56. package/dist/types/OxyServices.d.ts +0 -25
  57. package/dist/types/boot/coldBootV2.d.ts +76 -0
  58. package/dist/types/boot/deviceBootReturn.d.ts +83 -0
  59. package/dist/types/crypto/keyManager.d.ts +21 -0
  60. package/dist/types/index.d.ts +16 -21
  61. package/dist/types/mixins/OxyServices.accounts.d.ts +0 -2
  62. package/dist/types/mixins/OxyServices.analytics.d.ts +0 -2
  63. package/dist/types/mixins/OxyServices.appData.d.ts +0 -2
  64. package/dist/types/mixins/OxyServices.assets.d.ts +0 -2
  65. package/dist/types/mixins/OxyServices.auth.d.ts +35 -77
  66. package/dist/types/mixins/{OxyServices.redirect.d.ts → OxyServices.authorizedApps.d.ts} +35 -33
  67. package/dist/types/mixins/OxyServices.civic.d.ts +0 -2
  68. package/dist/types/mixins/OxyServices.connectedApps.d.ts +0 -2
  69. package/dist/types/mixins/OxyServices.contacts.d.ts +0 -2
  70. package/dist/types/mixins/OxyServices.deviceBoot.d.ts +110 -0
  71. package/dist/types/mixins/OxyServices.devices.d.ts +0 -2
  72. package/dist/types/mixins/OxyServices.features.d.ts +0 -2
  73. package/dist/types/mixins/OxyServices.identity.d.ts +0 -2
  74. package/dist/types/mixins/OxyServices.language.d.ts +0 -2
  75. package/dist/types/mixins/OxyServices.links.d.ts +0 -2
  76. package/dist/types/mixins/OxyServices.location.d.ts +0 -2
  77. package/dist/types/mixins/OxyServices.nodes.d.ts +0 -2
  78. package/dist/types/mixins/OxyServices.payment.d.ts +0 -2
  79. package/dist/types/mixins/OxyServices.privacy.d.ts +0 -2
  80. package/dist/types/mixins/OxyServices.reputation.d.ts +0 -2
  81. package/dist/types/mixins/OxyServices.security.d.ts +0 -2
  82. package/dist/types/mixins/OxyServices.topics.d.ts +0 -2
  83. package/dist/types/mixins/OxyServices.user.d.ts +0 -2
  84. package/dist/types/mixins/OxyServices.utility.d.ts +0 -2
  85. package/dist/types/mixins/index.d.ts +6 -9
  86. package/dist/types/models/interfaces.d.ts +0 -67
  87. package/dist/types/session/SessionClient.d.ts +38 -1
  88. package/dist/types/session/authStateStore.d.ts +119 -0
  89. package/dist/types/session/createSessionClient.d.ts +8 -1
  90. package/dist/types/session/refresh.d.ts +93 -0
  91. package/dist/types/utils/accountUtils.d.ts +0 -14
  92. package/dist/types/utils/authWebUrl.d.ts +6 -12
  93. package/dist/types/utils/fapiAutoDetect.d.ts +15 -38
  94. package/dist/types/utils/platform.d.ts +14 -0
  95. package/dist/types/utils/ssoBounce.d.ts +14 -262
  96. package/dist/types/utils/validationUtils.d.ts +15 -0
  97. package/package.json +2 -2
  98. package/src/HttpService.ts +19 -3
  99. package/src/OxyServices.base.ts +7 -112
  100. package/src/OxyServices.ts +0 -38
  101. package/src/boot/__tests__/coldBootV2.test.ts +317 -0
  102. package/src/boot/__tests__/deviceBootReturn.test.ts +158 -0
  103. package/src/boot/coldBootV2.ts +426 -0
  104. package/src/boot/deviceBootReturn.ts +195 -0
  105. package/src/crypto/__tests__/sharedDeviceToken.test.ts +24 -0
  106. package/src/crypto/keyManager.ts +101 -0
  107. package/src/i18n/locales/en-US.json +13 -1
  108. package/src/i18n/locales/es-ES.json +13 -1
  109. package/src/index.ts +78 -64
  110. package/src/mixins/OxyServices.accounts.ts +6 -13
  111. package/src/mixins/OxyServices.auth.ts +78 -253
  112. package/src/mixins/OxyServices.authorizedApps.ts +75 -0
  113. package/src/mixins/OxyServices.deviceBoot.ts +146 -0
  114. package/src/mixins/__tests__/OxyServices.deviceBoot.test.ts +107 -0
  115. package/src/mixins/__tests__/accounts.test.ts +17 -44
  116. package/src/mixins/__tests__/authorizedApps.test.ts +63 -0
  117. package/src/mixins/__tests__/passwordSignIn.test.ts +91 -0
  118. package/src/mixins/index.ts +16 -22
  119. package/src/models/interfaces.ts +0 -79
  120. package/src/session/SessionClient.ts +69 -3
  121. package/src/session/__tests__/SessionClient.additive.test.ts +92 -0
  122. package/src/session/__tests__/SessionClient.rest.test.ts +25 -0
  123. package/src/session/__tests__/SessionClient.socketFactory.test.ts +79 -0
  124. package/src/session/__tests__/SessionClient.state.test.ts +18 -5
  125. package/src/session/__tests__/authStateStore.test.ts +209 -0
  126. package/src/session/__tests__/refresh.test.ts +256 -0
  127. package/src/session/authStateStore.ts +335 -0
  128. package/src/session/createSessionClient.ts +9 -1
  129. package/src/session/refresh.ts +334 -0
  130. package/src/utils/__tests__/authWebUrl.test.ts +5 -29
  131. package/src/utils/__tests__/fapiAutoDetect.test.ts +5 -126
  132. package/src/utils/__tests__/validationUtils.test.ts +30 -0
  133. package/src/utils/accountUtils.ts +0 -62
  134. package/src/utils/authWebUrl.ts +6 -15
  135. package/src/utils/fapiAutoDetect.ts +16 -60
  136. package/src/utils/platform.ts +21 -0
  137. package/src/utils/ssoBounce.ts +14 -371
  138. package/src/utils/validationUtils.ts +62 -0
  139. package/dist/cjs/AuthManager.js +0 -1110
  140. package/dist/cjs/AuthManagerTypes.js +0 -13
  141. package/dist/cjs/CrossDomainAuth.js +0 -206
  142. package/dist/cjs/mixins/OxyServices.fedcm.js +0 -823
  143. package/dist/cjs/mixins/OxyServices.redirect.js +0 -95
  144. package/dist/cjs/mixins/OxyServices.silent.js +0 -204
  145. package/dist/cjs/mixins/OxyServices.sso.js +0 -208
  146. package/dist/cjs/utils/ssoEstablish.js +0 -110
  147. package/dist/cjs/utils/ssoReturn.js +0 -267
  148. package/dist/esm/AuthManager.js +0 -1105
  149. package/dist/esm/AuthManagerTypes.js +0 -12
  150. package/dist/esm/CrossDomainAuth.js +0 -201
  151. package/dist/esm/mixins/OxyServices.fedcm.js +0 -821
  152. package/dist/esm/mixins/OxyServices.redirect.js +0 -92
  153. package/dist/esm/mixins/OxyServices.silent.js +0 -202
  154. package/dist/esm/mixins/OxyServices.sso.js +0 -204
  155. package/dist/esm/utils/ssoEstablish.js +0 -107
  156. package/dist/esm/utils/ssoReturn.js +0 -263
  157. package/dist/types/AuthManager.d.ts +0 -380
  158. package/dist/types/AuthManagerTypes.d.ts +0 -81
  159. package/dist/types/CrossDomainAuth.d.ts +0 -164
  160. package/dist/types/mixins/OxyServices.fedcm.d.ts +0 -331
  161. package/dist/types/mixins/OxyServices.silent.d.ts +0 -132
  162. package/dist/types/mixins/OxyServices.sso.d.ts +0 -138
  163. package/dist/types/utils/ssoEstablish.d.ts +0 -85
  164. package/dist/types/utils/ssoReturn.d.ts +0 -146
  165. package/src/AuthManager.ts +0 -1269
  166. package/src/AuthManagerTypes.ts +0 -86
  167. package/src/CrossDomainAuth.ts +0 -243
  168. package/src/__tests__/authManager.cookiePath.test.ts +0 -390
  169. package/src/__tests__/authManager.security.test.ts +0 -377
  170. package/src/__tests__/crossDomainAuth.test.ts +0 -116
  171. package/src/__tests__/establishDeviceRefreshSlot.test.ts +0 -221
  172. package/src/mixins/OxyServices.fedcm.ts +0 -1026
  173. package/src/mixins/OxyServices.redirect.ts +0 -122
  174. package/src/mixins/OxyServices.silent.ts +0 -272
  175. package/src/mixins/OxyServices.sso.ts +0 -261
  176. package/src/mixins/__tests__/constructorAuthWebUrl.test.ts +0 -85
  177. package/src/mixins/__tests__/fedcm.test.ts +0 -667
  178. package/src/mixins/__tests__/sessionBaseUrl.test.ts +0 -61
  179. package/src/mixins/__tests__/silent.test.ts +0 -102
  180. package/src/mixins/__tests__/sso.test.ts +0 -228
  181. package/src/utils/__tests__/consumeSsoReturn.test.ts +0 -816
  182. package/src/utils/__tests__/ssoBounce.test.ts +0 -219
  183. package/src/utils/__tests__/ssoEstablish.test.ts +0 -204
  184. package/src/utils/__tests__/ssoReturn.test.ts +0 -251
  185. package/src/utils/ssoEstablish.ts +0 -174
  186. package/src/utils/ssoReturn.ts +0 -372
@@ -1,667 +0,0 @@
1
- /**
2
- * FedCM mixin regression tests.
3
- *
4
- * Locks in the fix for the broken SSO token exchange: the API's H9 hardening
5
- * (commit 21af7c48) made `/fedcm/exchange` require a server-minted,
6
- * origin-bound nonce, but the SDK was still generating a purely local nonce
7
- * that the API always rejected with `invalid_nonce`. These tests assert that
8
- * both the silent and interactive FedCM flows now:
9
- *
10
- * 1. mint a nonce from `POST /fedcm/nonce` and pass THAT nonce (not a local
11
- * UUID) to `navigator.credentials.get`;
12
- * 2. fall back to a local nonce if the mint endpoint is unreachable, rather
13
- * than throwing before the browser UI can show;
14
- * 3. resolve silent SSO cleanly to `null` (never throw into a retry loop)
15
- * when the browser returns no credential or rejects the request.
16
- *
17
- * The browser globals (`window`, `navigator.credentials`, `IdentityCredential`)
18
- * are stubbed so the platform-agnostic mixin can run under the node test env.
19
- */
20
-
21
- import { OxyServices } from '../../OxyServices';
22
-
23
- interface CredentialGetCall {
24
- identity: {
25
- providers: Array<{ configURL: string; clientId: string; nonce: string; params?: { nonce?: string } }>;
26
- mode?: string;
27
- };
28
- mediation: string;
29
- }
30
-
31
- const ORIGIN = 'https://accounts.oxy.so';
32
-
33
- function installBrowserGlobals(options: {
34
- credentialsGet: (opts: CredentialGetCall) => Promise<unknown>;
35
- }): void {
36
- const store = new Map<string, string>();
37
- const localStorageStub = {
38
- getItem: (k: string) => (store.has(k) ? (store.get(k) as string) : null),
39
- setItem: (k: string, v: string) => { store.set(k, v); },
40
- removeItem: (k: string) => { store.delete(k); },
41
- };
42
- const nav = {
43
- credentials: {
44
- get: (opts: CredentialGetCall) => options.credentialsGet(opts),
45
- },
46
- };
47
- // `isFedCMSupported()` checks: 'IdentityCredential' in window &&
48
- // 'navigator' in window && 'credentials' in navigator. The stub must expose
49
- // all three the way a real browser does.
50
- const win = {
51
- location: { origin: ORIGIN, hostname: 'accounts.oxy.so' },
52
- IdentityCredential: function IdentityCredential() {},
53
- navigator: nav,
54
- localStorage: localStorageStub,
55
- };
56
- (globalThis as unknown as { window: unknown }).window = win;
57
- (globalThis as unknown as { navigator: unknown }).navigator = nav;
58
- (globalThis as unknown as { localStorage: unknown }).localStorage = localStorageStub;
59
- (globalThis as unknown as { IdentityCredential: unknown }).IdentityCredential = win.IdentityCredential;
60
- }
61
-
62
- function clearBrowserGlobals(): void {
63
- for (const key of ['window', 'navigator', 'localStorage', 'IdentityCredential'] as const) {
64
- delete (globalThis as Record<string, unknown>)[key];
65
- }
66
- }
67
-
68
- describe('OxyServices FedCM nonce binding', () => {
69
- afterEach(() => {
70
- clearBrowserGlobals();
71
- jest.restoreAllMocks();
72
- });
73
-
74
- it('silent SSO mints a server nonce and forwards it to the browser', async () => {
75
- let credentialCall: CredentialGetCall | null = null;
76
- installBrowserGlobals({
77
- credentialsGet: async (opts) => {
78
- credentialCall = opts;
79
- // Browser returns no credential (user not logged in at IdP)
80
- return null;
81
- },
82
- });
83
-
84
- const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
85
- // Silent mediation now fast-skips when there is NO stored login hint (a
86
- // browser with no prior FedCM association can never get a silent credential,
87
- // so the nonce mint + credential request would be pure latency). Seed a hint
88
- // so the silent round-trip actually runs and we can assert the nonce binding.
89
- localStorage.setItem('oxy_fedcm_login_hint', 'prior-user-id');
90
- const makeRequest = jest
91
- .spyOn(oxy, 'makeRequest')
92
- .mockImplementation(async (_method: string, url: string) => {
93
- if (url === '/fedcm/nonce') {
94
- return { nonce: 'server-minted-nonce-123', expiresAt: new Date(Date.now() + 60000).toISOString() } as never;
95
- }
96
- throw new Error(`unexpected request to ${url}`);
97
- });
98
-
99
- const result = await oxy.silentSignInWithFedCM();
100
-
101
- expect(result).toBeNull();
102
- // The mint endpoint was hit
103
- expect(makeRequest).toHaveBeenCalledWith('POST', '/fedcm/nonce', {}, { cache: false });
104
- // The server nonce — not a random UUID — was passed to the browser
105
- expect(credentialCall).not.toBeNull();
106
- const call = credentialCall as unknown as CredentialGetCall;
107
- expect(call.identity.providers[0].nonce).toBe('server-minted-nonce-123');
108
- expect(call.identity.providers[0].params?.nonce).toBe('server-minted-nonce-123');
109
- expect(call.mediation).toBe('silent');
110
- });
111
-
112
- it('interactive sign-in mints a server nonce and exchanges the returned token', async () => {
113
- installBrowserGlobals({
114
- credentialsGet: async () => ({ type: 'identity', token: 'idp-id-token', isAutoSelected: false }),
115
- });
116
-
117
- const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
118
- const exchanged: string[] = [];
119
- jest
120
- .spyOn(oxy, 'makeRequest')
121
- .mockImplementation(async (_method: string, url: string, data?: unknown) => {
122
- if (url === '/fedcm/nonce') {
123
- return { nonce: 'server-minted-nonce-xyz', expiresAt: new Date(Date.now() + 60000).toISOString() } as never;
124
- }
125
- if (url === '/fedcm/exchange') {
126
- exchanged.push((data as { id_token: string }).id_token);
127
- return {
128
- sessionId: 'sess_1',
129
- deviceId: 'dev_1',
130
- expiresAt: new Date(Date.now() + 60000).toISOString(),
131
- accessToken: 'access_1',
132
- user: { id: 'user_1', username: 'tester' },
133
- } as never;
134
- }
135
- throw new Error(`unexpected request to ${url}`);
136
- });
137
-
138
- const session = await oxy.signInWithFedCM();
139
-
140
- expect(session.sessionId).toBe('sess_1');
141
- // The browser-issued token was exchanged for a session
142
- expect(exchanged).toEqual(['idp-id-token']);
143
- });
144
-
145
- it('falls back to a local nonce when the mint endpoint is unreachable', async () => {
146
- let credentialCall: CredentialGetCall | null = null;
147
- installBrowserGlobals({
148
- credentialsGet: async (opts) => {
149
- credentialCall = opts;
150
- return null;
151
- },
152
- });
153
-
154
- const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
155
- // Seed a login hint so the silent path runs past the no-hint fast-skip and
156
- // exercises the nonce-mint fallback.
157
- localStorage.setItem('oxy_fedcm_login_hint', 'prior-user-id');
158
- jest.spyOn(oxy, 'makeRequest').mockImplementation(async (_method: string, url: string) => {
159
- if (url === '/fedcm/nonce') {
160
- throw new Error('network down');
161
- }
162
- throw new Error(`unexpected request to ${url}`);
163
- });
164
-
165
- const result = await oxy.silentSignInWithFedCM();
166
-
167
- // Did not throw; resolved cleanly to null
168
- expect(result).toBeNull();
169
- // Still passed a (locally generated) non-empty nonce to the browser
170
- expect(credentialCall).not.toBeNull();
171
- const call = credentialCall as unknown as CredentialGetCall;
172
- expect(typeof call.identity.providers[0].nonce).toBe('string');
173
- expect(call.identity.providers[0].nonce.length).toBeGreaterThan(0);
174
- });
175
-
176
- it('silent SSO resolves to null (no throw) when the browser rejects', async () => {
177
- installBrowserGlobals({
178
- credentialsGet: async () => {
179
- const err = new Error('User not signed in');
180
- err.name = 'NotAllowedError';
181
- throw err;
182
- },
183
- });
184
-
185
- const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
186
- jest.spyOn(oxy, 'makeRequest').mockImplementation(async (_method: string, url: string) => {
187
- if (url === '/fedcm/nonce') {
188
- return { nonce: 'n', expiresAt: new Date(Date.now() + 60000).toISOString() } as never;
189
- }
190
- throw new Error(`unexpected request to ${url}`);
191
- });
192
-
193
- await expect(oxy.silentSignInWithFedCM()).resolves.toBeNull();
194
- });
195
-
196
- /**
197
- * Production-hang regression. `navigator.credentials.get()` is a
198
- * browser-internal FedCM primitive that, in some Chrome states, IGNORES its
199
- * abort signal — the awaited promise never settles. The cooperative
200
- * `setTimeout`→`controller.abort()` alone cannot unblock that await, so the
201
- * `fedcm-silent` cold-boot step (and the whole cold boot) hangs forever and
202
- * the terminal `/sso` bounce never fires.
203
- *
204
- * The hard settle guarantee (`Promise.race` against a timer that resolves
205
- * `null` at `FEDCM_SILENT_TIMEOUT + FEDCM_ABORT_SETTLE_GRACE_MS`) must resolve
206
- * the request to `null` regardless. This test models the hung primitive and
207
- * asserts `silentSignInWithFedCM()` settles to `null` within that bound.
208
- */
209
- it('hard-settles to null when navigator.credentials.get never settles (ignores abort)', async () => {
210
- jest.useFakeTimers();
211
- try {
212
- installBrowserGlobals({
213
- // Never resolves or rejects, and never observes the abort signal —
214
- // models the production hung FedCM credential request.
215
- credentialsGet: () => new Promise<unknown>(() => {}),
216
- });
217
-
218
- const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
219
- localStorage.setItem('oxy_fedcm_login_hint', 'prior-user-id');
220
- jest.spyOn(oxy, 'makeRequest').mockImplementation(async (_method: string, url: string) => {
221
- if (url === '/fedcm/nonce') {
222
- return { nonce: 'n', expiresAt: new Date(Date.now() + 60000).toISOString() } as never;
223
- }
224
- throw new Error(`unexpected request to ${url}`);
225
- });
226
-
227
- let settled = false;
228
- const promise = oxy.silentSignInWithFedCM().then((r) => {
229
- settled = true;
230
- return r;
231
- });
232
-
233
- // Before the hard-settle deadline it must still be pending — proving the
234
- // primitive really is hung (so the test would fail without the fix).
235
- await jest.advanceTimersByTimeAsync(4000);
236
- expect(settled).toBe(false);
237
-
238
- // FEDCM_SILENT_TIMEOUT (4000) + FEDCM_ABORT_SETTLE_GRACE_MS (500) = 4500.
239
- await jest.advanceTimersByTimeAsync(600);
240
- await expect(promise).resolves.toBeNull();
241
- expect(settled).toBe(true);
242
- } finally {
243
- jest.runOnlyPendingTimers();
244
- jest.useRealTimers();
245
- }
246
- });
247
- });
248
-
249
- /**
250
- * FedCM `mode` enum regression tests.
251
- *
252
- * The W3C FedCM spec renamed `IdentityCredentialRequestOptions.mode`:
253
- * `'button'` → `'active'` and `'widget'` → `'passive'`. Modern Chrome rejects
254
- * the legacy values with a synchronous `TypeError`; Chrome 125–131 only knows
255
- * the legacy values. These tests lock in that:
256
- *
257
- * 1. the interactive button flow requests the MODERN `mode: 'active'`;
258
- * 2. a `TypeError` on the modern value triggers a single retry with the
259
- * LEGACY `'button'` value (so older Chrome still works);
260
- * 3. the silent SSO path sends NO `mode` at all (mode and mediation are
261
- * independent fields).
262
- */
263
- function mintAndExchange(oxy: OxyServices, exchanged: string[]): void {
264
- jest
265
- .spyOn(oxy, 'makeRequest')
266
- .mockImplementation(async (_method: string, url: string, data?: unknown) => {
267
- if (url === '/fedcm/nonce') {
268
- return { nonce: 'server-nonce', expiresAt: new Date(Date.now() + 60000).toISOString() } as never;
269
- }
270
- if (url === '/fedcm/exchange') {
271
- exchanged.push((data as { id_token: string }).id_token);
272
- return {
273
- sessionId: 'sess_mode',
274
- deviceId: 'dev_mode',
275
- expiresAt: new Date(Date.now() + 60000).toISOString(),
276
- accessToken: 'access_mode',
277
- user: { id: 'user_mode', username: 'tester' },
278
- } as never;
279
- }
280
- throw new Error(`unexpected request to ${url}`);
281
- });
282
- }
283
-
284
- describe('OxyServices FedCM mode enum (active/passive)', () => {
285
- afterEach(() => {
286
- clearBrowserGlobals();
287
- jest.restoreAllMocks();
288
- });
289
-
290
- it('interactive sign-in requests the modern mode: "active"', async () => {
291
- const modesSeen: Array<string | undefined> = [];
292
- installBrowserGlobals({
293
- credentialsGet: async (opts) => {
294
- modesSeen.push(opts.identity.mode);
295
- return { type: 'identity', token: 'idp-id-token', isAutoSelected: false };
296
- },
297
- });
298
-
299
- const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
300
- const exchanged: string[] = [];
301
- mintAndExchange(oxy, exchanged);
302
-
303
- const session = await oxy.signInWithFedCM();
304
-
305
- expect(session.sessionId).toBe('sess_mode');
306
- // The modern W3C value — never the legacy 'button' — is sent first.
307
- expect(modesSeen).toEqual(['active']);
308
- expect(exchanged).toEqual(['idp-id-token']);
309
- });
310
-
311
- it('retries with legacy mode "button" when the browser rejects "active" with a TypeError', async () => {
312
- const modesSeen: Array<string | undefined> = [];
313
- installBrowserGlobals({
314
- credentialsGet: async (opts) => {
315
- modesSeen.push(opts.identity.mode);
316
- // First call (modern 'active'): emulate Chrome 125–131 rejecting the
317
- // unknown enum value synchronously with a TypeError.
318
- if (opts.identity.mode === 'active') {
319
- throw new TypeError(
320
- "The provided value 'active' is not a valid enum value of type IdentityCredentialRequestOptionsMode."
321
- );
322
- }
323
- // Second call (legacy 'button'): the old browser accepts it.
324
- return { type: 'identity', token: 'legacy-id-token', isAutoSelected: false };
325
- },
326
- });
327
-
328
- const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
329
- const exchanged: string[] = [];
330
- mintAndExchange(oxy, exchanged);
331
-
332
- const session = await oxy.signInWithFedCM();
333
-
334
- expect(session.sessionId).toBe('sess_mode');
335
- // Tried modern 'active' first, then fell back to legacy 'button'.
336
- expect(modesSeen).toEqual(['active', 'button']);
337
- // The token from the successful legacy retry was exchanged.
338
- expect(exchanged).toEqual(['legacy-id-token']);
339
- });
340
-
341
- it('does not retry (and surfaces the error) when a non-mode TypeError is thrown', async () => {
342
- let callCount = 0;
343
- installBrowserGlobals({
344
- credentialsGet: async () => {
345
- callCount += 1;
346
- throw new TypeError('Failed to fetch the FedCM config file.');
347
- },
348
- });
349
-
350
- const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
351
- const exchanged: string[] = [];
352
- mintAndExchange(oxy, exchanged);
353
-
354
- await expect(oxy.signInWithFedCM()).rejects.toThrow('Failed to fetch the FedCM config file.');
355
- // Only one attempt — an unrelated TypeError must not trigger the legacy retry.
356
- expect(callCount).toBe(1);
357
- });
358
-
359
- it('silent SSO sends no mode (mode and mediation are independent fields)', async () => {
360
- let credentialCall: CredentialGetCall | null = null;
361
- installBrowserGlobals({
362
- credentialsGet: async (opts) => {
363
- credentialCall = opts;
364
- return null;
365
- },
366
- });
367
-
368
- const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
369
- // Seed a login hint so the silent path runs past the no-hint fast-skip and
370
- // reaches `navigator.credentials.get` (where the mode/mediation are set).
371
- localStorage.setItem('oxy_fedcm_login_hint', 'prior-user-id');
372
- jest.spyOn(oxy, 'makeRequest').mockImplementation(async (_method: string, url: string) => {
373
- if (url === '/fedcm/nonce') {
374
- return { nonce: 'n', expiresAt: new Date(Date.now() + 60000).toISOString() } as never;
375
- }
376
- throw new Error(`unexpected request to ${url}`);
377
- });
378
-
379
- await oxy.silentSignInWithFedCM();
380
-
381
- expect(credentialCall).not.toBeNull();
382
- const call = credentialCall as unknown as CredentialGetCall;
383
- expect(call.mediation).toBe('silent');
384
- expect(call.identity.mode).toBeUndefined();
385
- });
386
- });
387
-
388
- /**
389
- * Stale-loginHint clear-and-retry regression tests.
390
- *
391
- * A loginHint left over from a previously-signed-in/test account (persisted in
392
- * `oxy_fedcm_login_hint`) that matches NO account at the IdP makes Chrome
393
- * filter out every account, grey it in the chooser, and reject
394
- * `navigator.credentials.get` — indistinguishable from a user cancel. The
395
- * interactive flow must recover by clearing the bad hint and retrying ONCE with
396
- * no hint, so the genuinely available account becomes selectable again. These
397
- * tests lock that behaviour in.
398
- */
399
- describe('OxyServices FedCM stale loginHint clear-and-retry', () => {
400
- afterEach(() => {
401
- clearBrowserGlobals();
402
- jest.restoreAllMocks();
403
- });
404
-
405
- it('clears a stale stored hint and retries the get() once with no hint, then succeeds', async () => {
406
- const hintsSeen: Array<string | undefined> = [];
407
- installBrowserGlobals({
408
- credentialsGet: async (opts) => {
409
- const hint = opts.identity.providers[0].loginHint;
410
- hintsSeen.push(hint);
411
- if (hint) {
412
- // First attempt carries the stale stored hint → Chrome filtered out
413
- // every account and rejected the request (NotAllowedError).
414
- const err = new Error('Accounts were received, but none matched the login hint.');
415
- err.name = 'NotAllowedError';
416
- throw err;
417
- }
418
- // Retry with NO hint → the available account resolves.
419
- return { type: 'identity', token: 'recovered-id-token', isAutoSelected: false };
420
- },
421
- });
422
-
423
- const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
424
- // Seed a stale hint in localStorage (a previously-signed-in/test account).
425
- localStorage.setItem('oxy_fedcm_login_hint', 'stale-user-id');
426
-
427
- const exchanged: string[] = [];
428
- mintAndExchange(oxy, exchanged);
429
-
430
- const session = await oxy.signInWithFedCM();
431
-
432
- // The first get() saw the stale hint; the second saw none.
433
- expect(hintsSeen).toEqual(['stale-user-id', undefined]);
434
- // The token from the hint-less retry was exchanged for a session.
435
- expect(session.sessionId).toBe('sess_mode');
436
- expect(exchanged).toEqual(['recovered-id-token']);
437
- // The stale hint was cleared and replaced with the freshly signed-in id.
438
- expect(localStorage.getItem('oxy_fedcm_login_hint')).toBe('user_mode');
439
- });
440
-
441
- it('does NOT retry when there was no stored hint (a genuine cancel surfaces)', async () => {
442
- let callCount = 0;
443
- installBrowserGlobals({
444
- credentialsGet: async () => {
445
- callCount += 1;
446
- const err = new Error('User cancelled the sign-in.');
447
- err.name = 'NotAllowedError';
448
- throw err;
449
- },
450
- });
451
-
452
- const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
453
- // No hint in storage → a NotAllowedError is a real cancel, not a mismatch,
454
- // so the error must surface (no clear-and-retry).
455
- jest.spyOn(oxy, 'makeRequest').mockImplementation(async (_method: string, url: string) => {
456
- if (url === '/fedcm/nonce') {
457
- return { nonce: 'n', expiresAt: new Date(Date.now() + 60000).toISOString() } as never;
458
- }
459
- throw new Error(`unexpected request to ${url}`);
460
- });
461
-
462
- await expect(oxy.signInWithFedCM()).rejects.toThrow('User cancelled the sign-in.');
463
- // Exactly one attempt — no clear-and-retry without a stored hint.
464
- expect(callCount).toBe(1);
465
- });
466
-
467
- it('does NOT clear or retry a caller-supplied loginHint (only stored hints are cleared)', async () => {
468
- let callCount = 0;
469
- installBrowserGlobals({
470
- credentialsGet: async () => {
471
- callCount += 1;
472
- const err = new Error('Accounts were received, but none matched the login hint.');
473
- err.name = 'NotAllowedError';
474
- throw err;
475
- },
476
- });
477
-
478
- const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
479
- // A leftover stored hint must be untouched: the caller explicitly chose the
480
- // hint, so we must not silently discard it nor retry behind their back.
481
- localStorage.setItem('oxy_fedcm_login_hint', 'persisted-hint');
482
- jest.spyOn(oxy, 'makeRequest').mockImplementation(async (_method: string, url: string) => {
483
- if (url === '/fedcm/nonce') {
484
- return { nonce: 'n', expiresAt: new Date(Date.now() + 60000).toISOString() } as never;
485
- }
486
- throw new Error(`unexpected request to ${url}`);
487
- });
488
-
489
- await expect(oxy.signInWithFedCM({ loginHint: 'caller-hint' })).rejects.toBeTruthy();
490
- // Exactly one attempt — caller-supplied hints are never auto-cleared/retried.
491
- expect(callCount).toBe(1);
492
- // The stored hint was left intact.
493
- expect(localStorage.getItem('oxy_fedcm_login_hint')).toBe('persisted-hint');
494
- });
495
- });
496
-
497
- /**
498
- * FedCM single-flight lock: interactive-aborts-silent regression tests.
499
- *
500
- * FedCM only allows one `navigator.credentials.get` at a time. Silent SSO runs
501
- * on page load and the real round-trip can be slow (or stall in the browser).
502
- * If an INTERACTIVE request (the user clicked "Sign In") arrives while a SILENT
503
- * one is still in flight, it must NOT wait on the silent — awaiting a hung
504
- * silent request previously deadlocked the sign-in button. Instead it aborts the
505
- * in-flight silent and proceeds immediately. These tests lock that in, and pin
506
- * the silent timeout so the on-load silent round-trip has enough budget.
507
- */
508
-
509
- // `navigator.credentials.get` receives an AbortSignal we want to observe in the
510
- // lock tests, which the shared `CredentialGetCall` shape omits. Model it here.
511
- interface CredentialGetCallWithSignal {
512
- identity: { providers: Array<{ loginHint?: string }>; mode?: string };
513
- mediation: string;
514
- signal?: AbortSignal;
515
- }
516
-
517
- describe('OxyServices FedCM single-flight lock (interactive aborts silent)', () => {
518
- afterEach(() => {
519
- clearBrowserGlobals();
520
- jest.restoreAllMocks();
521
- });
522
-
523
- it('aborts an in-progress silent request and proceeds with the interactive one', async () => {
524
- let silentSignal: AbortSignal | undefined;
525
- let silentAborted = false;
526
- let interactiveRan = false;
527
-
528
- const store = new Map<string, string>();
529
- // Seed a stored login hint so the silent request runs past the no-hint
530
- // fast-skip and actually reaches `navigator.credentials.get` (where it then
531
- // hangs, which is what this test needs to observe being aborted).
532
- store.set('oxy_fedcm_login_hint', 'prior-user-id');
533
- const localStorageStub = {
534
- getItem: (k: string) => (store.has(k) ? (store.get(k) as string) : null),
535
- setItem: (k: string, v: string) => { store.set(k, v); },
536
- removeItem: (k: string) => { store.delete(k); },
537
- };
538
-
539
- const credentialsGet = (opts: CredentialGetCallWithSignal): Promise<unknown> => {
540
- if (opts.mediation === 'silent') {
541
- // The silent request HANGS: it only settles when its signal is aborted,
542
- // exactly like a real slow/stalled silent round-trip. This is what must
543
- // NOT block the interactive request.
544
- silentSignal = opts.signal;
545
- return new Promise((_resolve, reject) => {
546
- const signal = opts.signal;
547
- if (!signal) return; // never settles without a signal (shouldn't happen)
548
- signal.addEventListener('abort', () => {
549
- silentAborted = true;
550
- const err = new Error('The operation was aborted.');
551
- err.name = 'AbortError';
552
- reject(err);
553
- });
554
- });
555
- }
556
- // Interactive request resolves immediately with a usable credential.
557
- interactiveRan = true;
558
- return Promise.resolve({ type: 'identity', token: 'interactive-token', isAutoSelected: false });
559
- };
560
-
561
- const nav = { credentials: { get: (opts: CredentialGetCallWithSignal) => credentialsGet(opts) } };
562
- const win = {
563
- location: { origin: ORIGIN, hostname: 'accounts.oxy.so' },
564
- IdentityCredential: function IdentityCredential() {},
565
- navigator: nav,
566
- localStorage: localStorageStub,
567
- };
568
- (globalThis as unknown as { window: unknown }).window = win;
569
- (globalThis as unknown as { navigator: unknown }).navigator = nav;
570
- (globalThis as unknown as { localStorage: unknown }).localStorage = localStorageStub;
571
- (globalThis as unknown as { IdentityCredential: unknown }).IdentityCredential = win.IdentityCredential;
572
-
573
- const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
574
- const exchanged: string[] = [];
575
- jest
576
- .spyOn(oxy, 'makeRequest')
577
- .mockImplementation(async (_method: string, url: string, data?: unknown) => {
578
- if (url === '/fedcm/nonce') {
579
- return { nonce: 'server-nonce', expiresAt: new Date(Date.now() + 60000).toISOString() } as never;
580
- }
581
- if (url === '/fedcm/exchange') {
582
- exchanged.push((data as { id_token: string }).id_token);
583
- return {
584
- sessionId: 'sess_lock',
585
- deviceId: 'dev_lock',
586
- expiresAt: new Date(Date.now() + 60000).toISOString(),
587
- accessToken: 'access_lock',
588
- user: { id: 'user_lock', username: 'tester' },
589
- } as never;
590
- }
591
- throw new Error(`unexpected request to ${url}`);
592
- });
593
-
594
- // Kick off the silent request (it will hang) WITHOUT awaiting it.
595
- const silentPromise = oxy.silentSignInWithFedCM();
596
- // Let the silent request reach navigator.credentials.get and register its
597
- // abort listener (it awaits the nonce mint first).
598
- await new Promise((resolve) => setTimeout(resolve, 0));
599
- expect(silentSignal).toBeDefined();
600
- expect(silentAborted).toBe(false);
601
-
602
- // Now the user clicks "Sign In": the interactive request must abort the
603
- // hung silent one and complete on its own — never block on it.
604
- const session = await oxy.signInWithFedCM();
605
-
606
- expect(silentAborted).toBe(true);
607
- expect(interactiveRan).toBe(true);
608
- expect(session.sessionId).toBe('sess_lock');
609
- expect(exchanged).toEqual(['interactive-token']);
610
-
611
- // The aborted silent resolves cleanly to null (its own error path), never
612
- // throwing and never leaving the lock stuck.
613
- await expect(silentPromise).resolves.toBeNull();
614
- });
615
- });
616
-
617
- describe('OxyServices FedCM silent timeout', () => {
618
- it('uses a 4s silent timeout (above the >3s live round-trip, tight enough to bound cold-boot latency)', () => {
619
- // 4s keeps the >3s measured live success margin while bounding the dead
620
- // wait on a logged-out browser. Lowered from 10s, which alone could account
621
- // for most of a 20-30s serial cold-boot stall. Must never drop below 4s.
622
- expect(OxyServices.FEDCM_SILENT_TIMEOUT).toBe(4000);
623
- expect(OxyServices.FEDCM_SILENT_TIMEOUT).toBeGreaterThanOrEqual(4000);
624
- });
625
- });
626
-
627
- /**
628
- * Silent FedCM no-login-hint fast-skip regression test.
629
- *
630
- * A browser that has never completed a FedCM sign-in for any Oxy account has no
631
- * stored login hint, so silent mediation can never return a credential — the
632
- * IdP has nothing to silently re-issue. Doing the full round-trip anyway (mint a
633
- * nonce, then a `navigator.credentials.get` that aborts after the silent
634
- * timeout) is pure latency in the cold-boot critical path. The silent path must
635
- * return `null` immediately WITHOUT minting a nonce or calling
636
- * `navigator.credentials.get`.
637
- */
638
- describe('OxyServices FedCM silent fast-skip with no login hint', () => {
639
- afterEach(() => {
640
- clearBrowserGlobals();
641
- jest.restoreAllMocks();
642
- });
643
-
644
- it('returns null immediately without minting a nonce or calling credentials.get when no hint is stored', async () => {
645
- let credentialsGetCalled = false;
646
- installBrowserGlobals({
647
- credentialsGet: async () => {
648
- credentialsGetCalled = true;
649
- return null;
650
- },
651
- });
652
-
653
- const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
654
- // No stored login hint (fresh empty localStorage) → fast-skip.
655
- const makeRequest = jest.spyOn(oxy, 'makeRequest').mockImplementation(async (_method: string, url: string) => {
656
- throw new Error(`unexpected request to ${url}`);
657
- });
658
-
659
- const result = await oxy.silentSignInWithFedCM();
660
-
661
- expect(result).toBeNull();
662
- // The nonce mint was NOT attempted and the browser credential UI was never
663
- // touched — the round-trip was skipped entirely.
664
- expect(makeRequest).not.toHaveBeenCalled();
665
- expect(credentialsGetCalled).toBe(false);
666
- });
667
- });