@oxyhq/core 1.11.23 → 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.
- package/README.md +5 -6
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/AuthManager.js +678 -4
- package/dist/cjs/AuthManagerTypes.js +13 -0
- package/dist/cjs/CrossDomainAuth.js +45 -3
- package/dist/cjs/OxyServices.base.js +16 -0
- package/dist/cjs/i18n/locales/ar-SA.json +83 -0
- package/dist/cjs/i18n/locales/ca-ES.json +83 -0
- package/dist/cjs/i18n/locales/de-DE.json +83 -0
- package/dist/cjs/i18n/locales/en-US.json +83 -0
- package/dist/cjs/i18n/locales/es-ES.json +99 -4
- package/dist/cjs/i18n/locales/fr-FR.json +83 -0
- package/dist/cjs/i18n/locales/it-IT.json +83 -0
- package/dist/cjs/i18n/locales/ja-JP.json +83 -0
- package/dist/cjs/i18n/locales/ko-KR.json +83 -0
- package/dist/cjs/i18n/locales/locales/ar-SA.json +83 -1
- package/dist/cjs/i18n/locales/locales/ca-ES.json +83 -1
- package/dist/cjs/i18n/locales/locales/de-DE.json +83 -1
- package/dist/cjs/i18n/locales/locales/en-US.json +83 -0
- package/dist/cjs/i18n/locales/locales/es-ES.json +99 -4
- package/dist/cjs/i18n/locales/locales/fr-FR.json +83 -1
- package/dist/cjs/i18n/locales/locales/it-IT.json +83 -1
- package/dist/cjs/i18n/locales/locales/ja-JP.json +200 -117
- package/dist/cjs/i18n/locales/locales/ko-KR.json +83 -1
- package/dist/cjs/i18n/locales/locales/pt-PT.json +83 -1
- package/dist/cjs/i18n/locales/locales/zh-CN.json +83 -1
- package/dist/cjs/i18n/locales/pt-PT.json +83 -0
- package/dist/cjs/i18n/locales/zh-CN.json +83 -0
- package/dist/cjs/index.js +114 -57
- package/dist/cjs/mixins/OxyServices.auth.js +235 -0
- package/dist/cjs/mixins/OxyServices.fedcm.js +205 -73
- package/dist/cjs/mixins/OxyServices.popup.js +61 -1
- package/dist/cjs/mixins/OxyServices.user.js +18 -0
- package/dist/cjs/utils/accountUtils.js +64 -1
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/AuthManager.js +678 -4
- package/dist/esm/AuthManagerTypes.js +12 -0
- package/dist/esm/CrossDomainAuth.js +45 -3
- package/dist/esm/OxyServices.base.js +16 -0
- package/dist/esm/i18n/locales/ar-SA.json +83 -0
- package/dist/esm/i18n/locales/ca-ES.json +83 -0
- package/dist/esm/i18n/locales/de-DE.json +83 -0
- package/dist/esm/i18n/locales/en-US.json +83 -0
- package/dist/esm/i18n/locales/es-ES.json +99 -4
- package/dist/esm/i18n/locales/fr-FR.json +83 -0
- package/dist/esm/i18n/locales/it-IT.json +83 -0
- package/dist/esm/i18n/locales/ja-JP.json +83 -0
- package/dist/esm/i18n/locales/ko-KR.json +83 -0
- package/dist/esm/i18n/locales/locales/ar-SA.json +83 -1
- package/dist/esm/i18n/locales/locales/ca-ES.json +83 -1
- package/dist/esm/i18n/locales/locales/de-DE.json +83 -1
- package/dist/esm/i18n/locales/locales/en-US.json +83 -0
- package/dist/esm/i18n/locales/locales/es-ES.json +99 -4
- package/dist/esm/i18n/locales/locales/fr-FR.json +83 -1
- package/dist/esm/i18n/locales/locales/it-IT.json +83 -1
- package/dist/esm/i18n/locales/locales/ja-JP.json +200 -117
- package/dist/esm/i18n/locales/locales/ko-KR.json +83 -1
- package/dist/esm/i18n/locales/locales/pt-PT.json +83 -1
- package/dist/esm/i18n/locales/locales/zh-CN.json +83 -1
- package/dist/esm/i18n/locales/pt-PT.json +83 -0
- package/dist/esm/i18n/locales/zh-CN.json +83 -0
- package/dist/esm/index.js +69 -26
- package/dist/esm/mixins/OxyServices.auth.js +235 -0
- package/dist/esm/mixins/OxyServices.fedcm.js +205 -73
- package/dist/esm/mixins/OxyServices.popup.js +61 -1
- package/dist/esm/mixins/OxyServices.user.js +18 -0
- package/dist/esm/utils/accountUtils.js +61 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/AuthManager.d.ts +243 -3
- package/dist/types/AuthManagerTypes.d.ts +68 -0
- package/dist/types/CrossDomainAuth.d.ts +23 -0
- package/dist/types/OxyServices.base.d.ts +14 -0
- package/dist/types/OxyServices.d.ts +16 -0
- package/dist/types/index.d.ts +28 -17
- package/dist/types/mixins/OxyServices.analytics.d.ts +1 -0
- package/dist/types/mixins/OxyServices.appData.d.ts +1 -0
- package/dist/types/mixins/OxyServices.assets.d.ts +4 -1
- package/dist/types/mixins/OxyServices.auth.d.ts +73 -1
- package/dist/types/mixins/OxyServices.contacts.d.ts +1 -0
- package/dist/types/mixins/OxyServices.developer.d.ts +1 -0
- package/dist/types/mixins/OxyServices.devices.d.ts +1 -0
- package/dist/types/mixins/OxyServices.features.d.ts +2 -5
- package/dist/types/mixins/OxyServices.fedcm.d.ts +53 -1
- package/dist/types/mixins/OxyServices.karma.d.ts +1 -0
- package/dist/types/mixins/OxyServices.language.d.ts +1 -0
- package/dist/types/mixins/OxyServices.location.d.ts +1 -0
- package/dist/types/mixins/OxyServices.managedAccounts.d.ts +1 -0
- package/dist/types/mixins/OxyServices.payment.d.ts +1 -0
- package/dist/types/mixins/OxyServices.popup.d.ts +40 -0
- package/dist/types/mixins/OxyServices.privacy.d.ts +1 -0
- package/dist/types/mixins/OxyServices.redirect.d.ts +1 -0
- package/dist/types/mixins/OxyServices.security.d.ts +1 -0
- package/dist/types/mixins/OxyServices.topics.d.ts +1 -0
- package/dist/types/mixins/OxyServices.user.d.ts +16 -1
- package/dist/types/mixins/OxyServices.utility.d.ts +1 -0
- package/dist/types/models/interfaces.d.ts +98 -0
- package/dist/types/models/session.d.ts +8 -0
- package/dist/types/utils/accountUtils.d.ts +33 -0
- package/package.json +9 -18
- package/src/AuthManager.ts +776 -7
- package/src/AuthManagerTypes.ts +72 -0
- package/src/CrossDomainAuth.ts +54 -3
- package/src/OxyServices.base.ts +17 -0
- package/src/OxyServices.ts +17 -0
- package/src/__tests__/authManager.cookiePath.test.ts +339 -0
- package/src/__tests__/authManager.security.test.ts +342 -0
- package/src/__tests__/crossDomainAuth.test.ts +191 -0
- package/src/i18n/locales/ar-SA.json +83 -1
- package/src/i18n/locales/ca-ES.json +83 -1
- package/src/i18n/locales/de-DE.json +83 -1
- package/src/i18n/locales/en-US.json +83 -0
- package/src/i18n/locales/es-ES.json +99 -4
- package/src/i18n/locales/fr-FR.json +83 -1
- package/src/i18n/locales/it-IT.json +83 -1
- package/src/i18n/locales/ja-JP.json +200 -117
- package/src/i18n/locales/ko-KR.json +83 -1
- package/src/i18n/locales/pt-PT.json +83 -1
- package/src/i18n/locales/zh-CN.json +83 -1
- package/src/index.ts +295 -112
- package/src/mixins/OxyServices.auth.ts +268 -1
- package/src/mixins/OxyServices.fedcm.ts +250 -78
- package/src/mixins/OxyServices.popup.ts +79 -1
- package/src/mixins/OxyServices.user.ts +33 -1
- package/src/mixins/__tests__/fedcm.test.ts +231 -0
- package/src/mixins/__tests__/popup.test.ts +307 -0
- package/src/mixins/__tests__/sessionBaseUrl.test.ts +61 -0
- package/src/models/interfaces.ts +116 -0
- package/src/models/session.ts +8 -0
- package/src/utils/accountUtils.ts +84 -0
- package/dist/cjs/crypto/index.js +0 -22
- package/dist/cjs/shared/index.js +0 -70
- package/dist/cjs/utils/index.js +0 -26
- package/dist/esm/crypto/index.js +0 -13
- package/dist/esm/shared/index.js +0 -31
- package/dist/esm/utils/index.js +0 -7
- package/dist/types/crypto/index.d.ts +0 -11
- package/dist/types/shared/index.d.ts +0 -28
- package/dist/types/utils/index.d.ts +0 -6
- package/src/crypto/index.ts +0 -30
- package/src/shared/index.ts +0 -82
- package/src/utils/index.ts +0 -21
|
@@ -321,3 +321,234 @@ describe('OxyServices FedCM mode enum (active/passive)', () => {
|
|
|
321
321
|
expect(call.identity.mode).toBeUndefined();
|
|
322
322
|
});
|
|
323
323
|
});
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Stale-loginHint clear-and-retry regression tests.
|
|
327
|
+
*
|
|
328
|
+
* A loginHint left over from a previously-signed-in/test account (persisted in
|
|
329
|
+
* `oxy_fedcm_login_hint`) that matches NO account at the IdP makes Chrome
|
|
330
|
+
* filter out every account, grey it in the chooser, and reject
|
|
331
|
+
* `navigator.credentials.get` — indistinguishable from a user cancel. The
|
|
332
|
+
* interactive flow must recover by clearing the bad hint and retrying ONCE with
|
|
333
|
+
* no hint, so the genuinely available account becomes selectable again. These
|
|
334
|
+
* tests lock that behaviour in.
|
|
335
|
+
*/
|
|
336
|
+
describe('OxyServices FedCM stale loginHint clear-and-retry', () => {
|
|
337
|
+
afterEach(() => {
|
|
338
|
+
clearBrowserGlobals();
|
|
339
|
+
jest.restoreAllMocks();
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it('clears a stale stored hint and retries the get() once with no hint, then succeeds', async () => {
|
|
343
|
+
const hintsSeen: Array<string | undefined> = [];
|
|
344
|
+
installBrowserGlobals({
|
|
345
|
+
credentialsGet: async (opts) => {
|
|
346
|
+
const hint = opts.identity.providers[0].loginHint;
|
|
347
|
+
hintsSeen.push(hint);
|
|
348
|
+
if (hint) {
|
|
349
|
+
// First attempt carries the stale stored hint → Chrome filtered out
|
|
350
|
+
// every account and rejected the request (NotAllowedError).
|
|
351
|
+
const err = new Error('Accounts were received, but none matched the login hint.');
|
|
352
|
+
err.name = 'NotAllowedError';
|
|
353
|
+
throw err;
|
|
354
|
+
}
|
|
355
|
+
// Retry with NO hint → the available account resolves.
|
|
356
|
+
return { type: 'identity', token: 'recovered-id-token', isAutoSelected: false };
|
|
357
|
+
},
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
|
|
361
|
+
// Seed a stale hint in localStorage (a previously-signed-in/test account).
|
|
362
|
+
localStorage.setItem('oxy_fedcm_login_hint', 'stale-user-id');
|
|
363
|
+
|
|
364
|
+
const exchanged: string[] = [];
|
|
365
|
+
mintAndExchange(oxy, exchanged);
|
|
366
|
+
|
|
367
|
+
const session = await oxy.signInWithFedCM();
|
|
368
|
+
|
|
369
|
+
// The first get() saw the stale hint; the second saw none.
|
|
370
|
+
expect(hintsSeen).toEqual(['stale-user-id', undefined]);
|
|
371
|
+
// The token from the hint-less retry was exchanged for a session.
|
|
372
|
+
expect(session.sessionId).toBe('sess_mode');
|
|
373
|
+
expect(exchanged).toEqual(['recovered-id-token']);
|
|
374
|
+
// The stale hint was cleared and replaced with the freshly signed-in id.
|
|
375
|
+
expect(localStorage.getItem('oxy_fedcm_login_hint')).toBe('user_mode');
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it('does NOT retry when there was no stored hint (a genuine cancel surfaces)', async () => {
|
|
379
|
+
let callCount = 0;
|
|
380
|
+
installBrowserGlobals({
|
|
381
|
+
credentialsGet: async () => {
|
|
382
|
+
callCount += 1;
|
|
383
|
+
const err = new Error('User cancelled the sign-in.');
|
|
384
|
+
err.name = 'NotAllowedError';
|
|
385
|
+
throw err;
|
|
386
|
+
},
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
|
|
390
|
+
// No hint in storage → a NotAllowedError is a real cancel, not a mismatch,
|
|
391
|
+
// so the error must surface (no clear-and-retry).
|
|
392
|
+
jest.spyOn(oxy, 'makeRequest').mockImplementation(async (_method: string, url: string) => {
|
|
393
|
+
if (url === '/fedcm/nonce') {
|
|
394
|
+
return { nonce: 'n', expiresAt: new Date(Date.now() + 60000).toISOString() } as never;
|
|
395
|
+
}
|
|
396
|
+
throw new Error(`unexpected request to ${url}`);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
await expect(oxy.signInWithFedCM()).rejects.toThrow('User cancelled the sign-in.');
|
|
400
|
+
// Exactly one attempt — no clear-and-retry without a stored hint.
|
|
401
|
+
expect(callCount).toBe(1);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it('does NOT clear or retry a caller-supplied loginHint (only stored hints are cleared)', async () => {
|
|
405
|
+
let callCount = 0;
|
|
406
|
+
installBrowserGlobals({
|
|
407
|
+
credentialsGet: async () => {
|
|
408
|
+
callCount += 1;
|
|
409
|
+
const err = new Error('Accounts were received, but none matched the login hint.');
|
|
410
|
+
err.name = 'NotAllowedError';
|
|
411
|
+
throw err;
|
|
412
|
+
},
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
|
|
416
|
+
// A leftover stored hint must be untouched: the caller explicitly chose the
|
|
417
|
+
// hint, so we must not silently discard it nor retry behind their back.
|
|
418
|
+
localStorage.setItem('oxy_fedcm_login_hint', 'persisted-hint');
|
|
419
|
+
jest.spyOn(oxy, 'makeRequest').mockImplementation(async (_method: string, url: string) => {
|
|
420
|
+
if (url === '/fedcm/nonce') {
|
|
421
|
+
return { nonce: 'n', expiresAt: new Date(Date.now() + 60000).toISOString() } as never;
|
|
422
|
+
}
|
|
423
|
+
throw new Error(`unexpected request to ${url}`);
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
await expect(oxy.signInWithFedCM({ loginHint: 'caller-hint' })).rejects.toBeTruthy();
|
|
427
|
+
// Exactly one attempt — caller-supplied hints are never auto-cleared/retried.
|
|
428
|
+
expect(callCount).toBe(1);
|
|
429
|
+
// The stored hint was left intact.
|
|
430
|
+
expect(localStorage.getItem('oxy_fedcm_login_hint')).toBe('persisted-hint');
|
|
431
|
+
});
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* FedCM single-flight lock: interactive-aborts-silent regression tests.
|
|
436
|
+
*
|
|
437
|
+
* FedCM only allows one `navigator.credentials.get` at a time. Silent SSO runs
|
|
438
|
+
* on page load and the real round-trip can be slow (or stall in the browser).
|
|
439
|
+
* If an INTERACTIVE request (the user clicked "Sign In") arrives while a SILENT
|
|
440
|
+
* one is still in flight, it must NOT wait on the silent — awaiting a hung
|
|
441
|
+
* silent request previously deadlocked the sign-in button. Instead it aborts the
|
|
442
|
+
* in-flight silent and proceeds immediately. These tests lock that in, and pin
|
|
443
|
+
* the silent timeout so the on-load silent round-trip has enough budget.
|
|
444
|
+
*/
|
|
445
|
+
|
|
446
|
+
// `navigator.credentials.get` receives an AbortSignal we want to observe in the
|
|
447
|
+
// lock tests, which the shared `CredentialGetCall` shape omits. Model it here.
|
|
448
|
+
interface CredentialGetCallWithSignal {
|
|
449
|
+
identity: { providers: Array<{ loginHint?: string }>; mode?: string };
|
|
450
|
+
mediation: string;
|
|
451
|
+
signal?: AbortSignal;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
describe('OxyServices FedCM single-flight lock (interactive aborts silent)', () => {
|
|
455
|
+
afterEach(() => {
|
|
456
|
+
clearBrowserGlobals();
|
|
457
|
+
jest.restoreAllMocks();
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it('aborts an in-progress silent request and proceeds with the interactive one', async () => {
|
|
461
|
+
let silentSignal: AbortSignal | undefined;
|
|
462
|
+
let silentAborted = false;
|
|
463
|
+
let interactiveRan = false;
|
|
464
|
+
|
|
465
|
+
const store = new Map<string, string>();
|
|
466
|
+
const localStorageStub = {
|
|
467
|
+
getItem: (k: string) => (store.has(k) ? (store.get(k) as string) : null),
|
|
468
|
+
setItem: (k: string, v: string) => { store.set(k, v); },
|
|
469
|
+
removeItem: (k: string) => { store.delete(k); },
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
const credentialsGet = (opts: CredentialGetCallWithSignal): Promise<unknown> => {
|
|
473
|
+
if (opts.mediation === 'silent') {
|
|
474
|
+
// The silent request HANGS: it only settles when its signal is aborted,
|
|
475
|
+
// exactly like a real slow/stalled silent round-trip. This is what must
|
|
476
|
+
// NOT block the interactive request.
|
|
477
|
+
silentSignal = opts.signal;
|
|
478
|
+
return new Promise((_resolve, reject) => {
|
|
479
|
+
const signal = opts.signal;
|
|
480
|
+
if (!signal) return; // never settles without a signal (shouldn't happen)
|
|
481
|
+
signal.addEventListener('abort', () => {
|
|
482
|
+
silentAborted = true;
|
|
483
|
+
const err = new Error('The operation was aborted.');
|
|
484
|
+
err.name = 'AbortError';
|
|
485
|
+
reject(err);
|
|
486
|
+
});
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
// Interactive request resolves immediately with a usable credential.
|
|
490
|
+
interactiveRan = true;
|
|
491
|
+
return Promise.resolve({ type: 'identity', token: 'interactive-token', isAutoSelected: false });
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
const nav = { credentials: { get: (opts: CredentialGetCallWithSignal) => credentialsGet(opts) } };
|
|
495
|
+
const win = {
|
|
496
|
+
location: { origin: ORIGIN, hostname: 'accounts.oxy.so' },
|
|
497
|
+
IdentityCredential: function IdentityCredential() {},
|
|
498
|
+
navigator: nav,
|
|
499
|
+
localStorage: localStorageStub,
|
|
500
|
+
};
|
|
501
|
+
(globalThis as unknown as { window: unknown }).window = win;
|
|
502
|
+
(globalThis as unknown as { navigator: unknown }).navigator = nav;
|
|
503
|
+
(globalThis as unknown as { localStorage: unknown }).localStorage = localStorageStub;
|
|
504
|
+
(globalThis as unknown as { IdentityCredential: unknown }).IdentityCredential = win.IdentityCredential;
|
|
505
|
+
|
|
506
|
+
const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
|
|
507
|
+
const exchanged: string[] = [];
|
|
508
|
+
jest
|
|
509
|
+
.spyOn(oxy, 'makeRequest')
|
|
510
|
+
.mockImplementation(async (_method: string, url: string, data?: unknown) => {
|
|
511
|
+
if (url === '/fedcm/nonce') {
|
|
512
|
+
return { nonce: 'server-nonce', expiresAt: new Date(Date.now() + 60000).toISOString() } as never;
|
|
513
|
+
}
|
|
514
|
+
if (url === '/fedcm/exchange') {
|
|
515
|
+
exchanged.push((data as { id_token: string }).id_token);
|
|
516
|
+
return {
|
|
517
|
+
sessionId: 'sess_lock',
|
|
518
|
+
deviceId: 'dev_lock',
|
|
519
|
+
expiresAt: new Date(Date.now() + 60000).toISOString(),
|
|
520
|
+
accessToken: 'access_lock',
|
|
521
|
+
user: { id: 'user_lock', username: 'tester' },
|
|
522
|
+
} as never;
|
|
523
|
+
}
|
|
524
|
+
throw new Error(`unexpected request to ${url}`);
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
// Kick off the silent request (it will hang) WITHOUT awaiting it.
|
|
528
|
+
const silentPromise = oxy.silentSignInWithFedCM();
|
|
529
|
+
// Let the silent request reach navigator.credentials.get and register its
|
|
530
|
+
// abort listener (it awaits the nonce mint first).
|
|
531
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
532
|
+
expect(silentSignal).toBeDefined();
|
|
533
|
+
expect(silentAborted).toBe(false);
|
|
534
|
+
|
|
535
|
+
// Now the user clicks "Sign In": the interactive request must abort the
|
|
536
|
+
// hung silent one and complete on its own — never block on it.
|
|
537
|
+
const session = await oxy.signInWithFedCM();
|
|
538
|
+
|
|
539
|
+
expect(silentAborted).toBe(true);
|
|
540
|
+
expect(interactiveRan).toBe(true);
|
|
541
|
+
expect(session.sessionId).toBe('sess_lock');
|
|
542
|
+
expect(exchanged).toEqual(['interactive-token']);
|
|
543
|
+
|
|
544
|
+
// The aborted silent resolves cleanly to null (its own error path), never
|
|
545
|
+
// throwing and never leaving the lock stuck.
|
|
546
|
+
await expect(silentPromise).resolves.toBeNull();
|
|
547
|
+
});
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
describe('OxyServices FedCM silent timeout', () => {
|
|
551
|
+
it('uses a 10s silent timeout (enough budget for the real on-load round-trip)', () => {
|
|
552
|
+
expect(OxyServices.FEDCM_SILENT_TIMEOUT).toBe(10000);
|
|
553
|
+
});
|
|
554
|
+
});
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Popup mixin regression tests.
|
|
3
|
+
*
|
|
4
|
+
* Locks in the §6c fix: cross-domain sign-in popups (auth.oxy.so) were being
|
|
5
|
+
* blocked by Chrome on consumer apps (mention.earth, homiio.com, alia.onl)
|
|
6
|
+
* because the caller chain awaited FedCM / silent SSO BEFORE
|
|
7
|
+
* `signInWithPopup` reached `window.open`. The transient user-activation
|
|
8
|
+
* had been consumed by the first `await`, so the popup-blocker killed the
|
|
9
|
+
* subsequent `window.open` call.
|
|
10
|
+
*
|
|
11
|
+
* The fix exposes two new affordances on the popup mixin:
|
|
12
|
+
* 1. `openBlankPopup(width?, height?)` — a public, synchronous helper that
|
|
13
|
+
* callers invoke from the raw user-gesture handler BEFORE any await, so
|
|
14
|
+
* the popup is opened while the activation is still live.
|
|
15
|
+
* 2. `signInWithPopup({ popup })` — accepts the pre-opened window handle
|
|
16
|
+
* and navigates IT to the auth URL instead of issuing a fresh
|
|
17
|
+
* `window.open` (which would now be blocked).
|
|
18
|
+
*
|
|
19
|
+
* Backward compat: callers that omit `popup` keep the legacy behaviour (the
|
|
20
|
+
* mixin opens its own popup via `openCenteredPopup`). The browser globals
|
|
21
|
+
* are stubbed so the platform-agnostic mixin can run under the node test
|
|
22
|
+
* env.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { OxyServices } from '../../OxyServices';
|
|
26
|
+
|
|
27
|
+
const ORIGIN = 'https://mention.earth';
|
|
28
|
+
|
|
29
|
+
interface MockPopup {
|
|
30
|
+
closed: boolean;
|
|
31
|
+
close: jest.Mock;
|
|
32
|
+
location: {
|
|
33
|
+
href: string;
|
|
34
|
+
replace: jest.Mock;
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function createMockPopup(overrides: Partial<MockPopup> = {}): MockPopup {
|
|
39
|
+
return {
|
|
40
|
+
closed: false,
|
|
41
|
+
close: jest.fn(),
|
|
42
|
+
location: {
|
|
43
|
+
href: '',
|
|
44
|
+
replace: jest.fn(function (this: { href: string }, url: string) {
|
|
45
|
+
this.href = url;
|
|
46
|
+
}),
|
|
47
|
+
},
|
|
48
|
+
...overrides,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function installBrowserGlobals(options: {
|
|
53
|
+
windowOpen?: jest.Mock;
|
|
54
|
+
postMessageDispatcher?: { current: ((event: { origin: string; data: unknown }) => void) | null };
|
|
55
|
+
} = {}): void {
|
|
56
|
+
const store = new Map<string, string>();
|
|
57
|
+
const sessionStorageStub = {
|
|
58
|
+
getItem: (k: string) => (store.has(k) ? (store.get(k) as string) : null),
|
|
59
|
+
setItem: (k: string, v: string) => { store.set(k, v); },
|
|
60
|
+
removeItem: (k: string) => { store.delete(k); },
|
|
61
|
+
};
|
|
62
|
+
const messageHandlers: Array<(event: { origin: string; data: unknown }) => void> = [];
|
|
63
|
+
const win = {
|
|
64
|
+
location: { origin: ORIGIN, hostname: 'mention.earth' },
|
|
65
|
+
screenX: 0,
|
|
66
|
+
screenY: 0,
|
|
67
|
+
outerWidth: 1280,
|
|
68
|
+
outerHeight: 800,
|
|
69
|
+
sessionStorage: sessionStorageStub,
|
|
70
|
+
open: options.windowOpen ?? jest.fn(() => null),
|
|
71
|
+
addEventListener: (event: string, handler: (e: { origin: string; data: unknown }) => void) => {
|
|
72
|
+
if (event === 'message') {
|
|
73
|
+
messageHandlers.push(handler);
|
|
74
|
+
if (options.postMessageDispatcher) {
|
|
75
|
+
options.postMessageDispatcher.current = handler;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
removeEventListener: (event: string, handler: (e: { origin: string; data: unknown }) => void) => {
|
|
80
|
+
if (event === 'message') {
|
|
81
|
+
const idx = messageHandlers.indexOf(handler);
|
|
82
|
+
if (idx >= 0) messageHandlers.splice(idx, 1);
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
(globalThis as unknown as { window: unknown }).window = win;
|
|
87
|
+
(globalThis as unknown as { sessionStorage: unknown }).sessionStorage = sessionStorageStub;
|
|
88
|
+
// `crypto.randomUUID` already exists in node 20+ test env — leave it.
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function clearBrowserGlobals(): void {
|
|
92
|
+
for (const key of ['window', 'sessionStorage'] as const) {
|
|
93
|
+
delete (globalThis as Record<string, unknown>)[key];
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
describe('OxyServices popup mixin — pre-opened popup option', () => {
|
|
98
|
+
afterEach(() => {
|
|
99
|
+
clearBrowserGlobals();
|
|
100
|
+
jest.restoreAllMocks();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('navigates a pre-opened popup to the auth URL instead of opening a new one', async () => {
|
|
104
|
+
const windowOpen = jest.fn();
|
|
105
|
+
installBrowserGlobals({ windowOpen });
|
|
106
|
+
const popup = createMockPopup();
|
|
107
|
+
|
|
108
|
+
const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
|
|
109
|
+
|
|
110
|
+
// Resolve `signInWithPopup` as soon as the popup is navigated — we only
|
|
111
|
+
// care about the open path, not the full postMessage round-trip.
|
|
112
|
+
let dispatchedAuthUrl: string | null = null;
|
|
113
|
+
popup.location.replace.mockImplementation(function (this: MockPopup['location'], url: string) {
|
|
114
|
+
this.href = url;
|
|
115
|
+
dispatchedAuthUrl = url;
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// Fire the auth-success message immediately after navigation. We do this
|
|
119
|
+
// by intercepting `addEventListener` above.
|
|
120
|
+
const messagePromise = new Promise<void>((resolve) => {
|
|
121
|
+
// Patch addEventListener to capture the handler and dispatch a fake
|
|
122
|
+
// success message on the next microtask.
|
|
123
|
+
const origAdd = (globalThis as unknown as { window: { addEventListener: typeof window.addEventListener } }).window.addEventListener;
|
|
124
|
+
(globalThis as unknown as { window: { addEventListener: typeof window.addEventListener } }).window.addEventListener =
|
|
125
|
+
(event: string, handler: EventListenerOrEventListenerObject) => {
|
|
126
|
+
origAdd(event, handler);
|
|
127
|
+
if (event === 'message') {
|
|
128
|
+
queueMicrotask(() => {
|
|
129
|
+
// We need the state from the URL the popup was navigated to.
|
|
130
|
+
const url = new URL(dispatchedAuthUrl ?? '');
|
|
131
|
+
const state = url.searchParams.get('state') ?? '';
|
|
132
|
+
(handler as (e: { origin: string; data: unknown }) => void)({
|
|
133
|
+
origin: 'https://auth.oxy.so',
|
|
134
|
+
data: {
|
|
135
|
+
type: 'oxy_auth_response',
|
|
136
|
+
state,
|
|
137
|
+
session: {
|
|
138
|
+
sessionId: 'sess_pre_opened',
|
|
139
|
+
deviceId: 'dev_pre',
|
|
140
|
+
expiresAt: new Date(Date.now() + 60000).toISOString(),
|
|
141
|
+
accessToken: 'access_pre',
|
|
142
|
+
user: { id: 'user_pre', username: 'tester' },
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
resolve();
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const session = await oxy.signInWithPopup({ popup: popup as unknown as Window });
|
|
153
|
+
await messagePromise;
|
|
154
|
+
|
|
155
|
+
// The pre-opened popup was navigated — `window.open` was NEVER called.
|
|
156
|
+
expect(windowOpen).not.toHaveBeenCalled();
|
|
157
|
+
expect(popup.location.replace).toHaveBeenCalledTimes(1);
|
|
158
|
+
expect(popup.location.replace).toHaveBeenCalledWith(expect.stringContaining('https://auth.oxy.so/login'));
|
|
159
|
+
expect(session.sessionId).toBe('sess_pre_opened');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('throws a "window was closed" (cancelled) error — NOT "popup blocked" — when the pre-opened handle is already closed', async () => {
|
|
163
|
+
const windowOpen = jest.fn();
|
|
164
|
+
installBrowserGlobals({ windowOpen });
|
|
165
|
+
const popup = createMockPopup({ closed: true });
|
|
166
|
+
|
|
167
|
+
const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
|
|
168
|
+
|
|
169
|
+
// The popup DID open (the blocker allowed it) and the user closed it.
|
|
170
|
+
// The error must communicate a cancel, never a blocker rejection —
|
|
171
|
+
// consumers map "blocked" to "please allow popups" UX guidance, which
|
|
172
|
+
// would be wrong here.
|
|
173
|
+
await expect(
|
|
174
|
+
oxy.signInWithPopup({ popup: popup as unknown as Window })
|
|
175
|
+
).rejects.toThrow(/Sign-in window was closed/);
|
|
176
|
+
await expect(
|
|
177
|
+
oxy.signInWithPopup({ popup: popup as unknown as Window })
|
|
178
|
+
).rejects.not.toThrow(/Popup blocked/);
|
|
179
|
+
|
|
180
|
+
// Did not attempt to open a fresh popup either.
|
|
181
|
+
expect(windowOpen).not.toHaveBeenCalled();
|
|
182
|
+
expect(popup.location.replace).not.toHaveBeenCalled();
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('falls back to assigning `location.href` when `location.replace` throws (sandboxed environments)', async () => {
|
|
186
|
+
const windowOpen = jest.fn();
|
|
187
|
+
installBrowserGlobals({ windowOpen });
|
|
188
|
+
|
|
189
|
+
// Some sandboxed / cross-origin-locked environments make `location.replace`
|
|
190
|
+
// throw. The mixin must recover with a plain `href` assignment so the
|
|
191
|
+
// popup still gets navigated. This is the only path that exercises the
|
|
192
|
+
// catch-and-log branch in OxyServices.popup.ts.
|
|
193
|
+
const popup: MockPopup = {
|
|
194
|
+
closed: false,
|
|
195
|
+
close: jest.fn(),
|
|
196
|
+
location: {
|
|
197
|
+
href: '',
|
|
198
|
+
replace: jest.fn(() => {
|
|
199
|
+
throw new Error('SecurityError: replace blocked by sandbox');
|
|
200
|
+
}),
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
|
|
205
|
+
|
|
206
|
+
// Drive `signInWithPopup` only until the navigation happens, then abort
|
|
207
|
+
// via `closed = true` so the poll loop's cancel path resolves the promise.
|
|
208
|
+
const promise = oxy.signInWithPopup({ popup: popup as unknown as Window });
|
|
209
|
+
// Let the synchronous popup-navigation path run.
|
|
210
|
+
await Promise.resolve();
|
|
211
|
+
popup.closed = true;
|
|
212
|
+
|
|
213
|
+
await expect(promise).rejects.toThrow(/cancelled|timeout/i);
|
|
214
|
+
|
|
215
|
+
// `replace` was attempted (and threw); the fallback wrote to `href`.
|
|
216
|
+
expect(popup.location.replace).toHaveBeenCalledTimes(1);
|
|
217
|
+
expect(popup.location.href).toMatch(/^https:\/\/auth\.oxy\.so\/login/);
|
|
218
|
+
// No fresh popup was opened.
|
|
219
|
+
expect(windowOpen).not.toHaveBeenCalled();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('falls back to opening its own popup when `popup` option is omitted (classic behaviour)', async () => {
|
|
223
|
+
const fallbackPopup = createMockPopup();
|
|
224
|
+
const windowOpen = jest.fn(() => fallbackPopup);
|
|
225
|
+
installBrowserGlobals({ windowOpen });
|
|
226
|
+
|
|
227
|
+
const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
|
|
228
|
+
|
|
229
|
+
// Don't actually wait for the full flow; intercept after `window.open`
|
|
230
|
+
// is called and abort.
|
|
231
|
+
const promise = oxy.signInWithPopup();
|
|
232
|
+
// Allow the synchronous `window.open` path to run.
|
|
233
|
+
await Promise.resolve();
|
|
234
|
+
fallbackPopup.closed = true; // trigger "Authentication cancelled by user"
|
|
235
|
+
|
|
236
|
+
await expect(promise).rejects.toThrow(/cancelled|timeout/i);
|
|
237
|
+
|
|
238
|
+
expect(windowOpen).toHaveBeenCalledTimes(1);
|
|
239
|
+
// The first arg is the auth URL (not 'about:blank').
|
|
240
|
+
expect((windowOpen.mock.calls[0] as unknown[])[0]).toMatch(/^https:\/\/auth\.oxy\.so\/login/);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('throws "popup blocked" when the legacy path is used and `window.open` returns null', async () => {
|
|
244
|
+
const windowOpen = jest.fn(() => null);
|
|
245
|
+
installBrowserGlobals({ windowOpen });
|
|
246
|
+
|
|
247
|
+
const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
|
|
248
|
+
|
|
249
|
+
await expect(oxy.signInWithPopup()).rejects.toThrow(/Popup blocked/);
|
|
250
|
+
expect(windowOpen).toHaveBeenCalledTimes(1);
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
describe('OxyServices popup mixin — openBlankPopup helper', () => {
|
|
255
|
+
afterEach(() => {
|
|
256
|
+
clearBrowserGlobals();
|
|
257
|
+
jest.restoreAllMocks();
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('opens about:blank synchronously and returns the window handle', () => {
|
|
261
|
+
const fakePopup = createMockPopup();
|
|
262
|
+
const windowOpen = jest.fn(() => fakePopup);
|
|
263
|
+
installBrowserGlobals({ windowOpen });
|
|
264
|
+
|
|
265
|
+
const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
|
|
266
|
+
const popup = oxy.openBlankPopup();
|
|
267
|
+
|
|
268
|
+
expect(windowOpen).toHaveBeenCalledTimes(1);
|
|
269
|
+
const args = windowOpen.mock.calls[0] as unknown[];
|
|
270
|
+
expect(args[0]).toBe('about:blank');
|
|
271
|
+
expect(args[1]).toBe('Oxy Sign In');
|
|
272
|
+
// Features string should include the default width/height.
|
|
273
|
+
expect(typeof args[2]).toBe('string');
|
|
274
|
+
expect(args[2] as string).toContain('width=500');
|
|
275
|
+
expect(args[2] as string).toContain('height=700');
|
|
276
|
+
expect(popup).toBe(fakePopup);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('returns null when the browser blocks the popup', () => {
|
|
280
|
+
const windowOpen = jest.fn(() => null);
|
|
281
|
+
installBrowserGlobals({ windowOpen });
|
|
282
|
+
|
|
283
|
+
const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
|
|
284
|
+
const popup = oxy.openBlankPopup();
|
|
285
|
+
|
|
286
|
+
expect(popup).toBeNull();
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it('honors caller-supplied dimensions', () => {
|
|
290
|
+
const fakePopup = createMockPopup();
|
|
291
|
+
const windowOpen = jest.fn(() => fakePopup);
|
|
292
|
+
installBrowserGlobals({ windowOpen });
|
|
293
|
+
|
|
294
|
+
const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
|
|
295
|
+
oxy.openBlankPopup(640, 480);
|
|
296
|
+
|
|
297
|
+
const args = windowOpen.mock.calls[0] as unknown[];
|
|
298
|
+
expect(args[2] as string).toContain('width=640');
|
|
299
|
+
expect(args[2] as string).toContain('height=480');
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it('returns null in non-browser environments', () => {
|
|
303
|
+
// No browser globals installed.
|
|
304
|
+
const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
|
|
305
|
+
expect(oxy.openBlankPopup()).toBeNull();
|
|
306
|
+
});
|
|
307
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `OxyServices.getSessionBaseUrl()` resolution tests.
|
|
3
|
+
*
|
|
4
|
+
* Per the 2026 session architecture (docs/SESSION-ARCHITECTURE.md), every app
|
|
5
|
+
* keeps its OWN first-party session on its OWN domain. `getSessionBaseUrl()`
|
|
6
|
+
* is the configurable base URL the SDK's first-party session/refresh calls will
|
|
7
|
+
* target in a later phase:
|
|
8
|
+
* - non-`oxy.so` apps point `sessionBaseUrl` at their own same-site backend
|
|
9
|
+
* (e.g. `https://api.mention.earth`);
|
|
10
|
+
* - `*.oxy.so` apps leave it unset so it falls back to `baseURL`
|
|
11
|
+
* (`https://api.oxy.so`) — their behavior is unchanged.
|
|
12
|
+
*
|
|
13
|
+
* This phase is additive: the getter only surfaces configuration. It must NOT
|
|
14
|
+
* mutate token/auth state and must NOT alter `getBaseURL()`.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { OxyServices } from '../../OxyServices';
|
|
18
|
+
|
|
19
|
+
describe('OxyServices.getSessionBaseUrl', () => {
|
|
20
|
+
it('falls back to baseURL when sessionBaseUrl is not configured', () => {
|
|
21
|
+
const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
|
|
22
|
+
|
|
23
|
+
expect(oxy.getSessionBaseUrl()).toBe('https://api.oxy.so');
|
|
24
|
+
// Must equal the API base URL exactly — no divergence for *.oxy.so apps.
|
|
25
|
+
expect(oxy.getSessionBaseUrl()).toBe(oxy.getBaseURL());
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('returns the configured sessionBaseUrl when provided', () => {
|
|
29
|
+
const oxy = new OxyServices({
|
|
30
|
+
baseURL: 'https://api.oxy.so',
|
|
31
|
+
sessionBaseUrl: 'https://api.mention.earth',
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
expect(oxy.getSessionBaseUrl()).toBe('https://api.mention.earth');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('does not change the API base URL when sessionBaseUrl differs', () => {
|
|
38
|
+
const oxy = new OxyServices({
|
|
39
|
+
baseURL: 'https://api.oxy.so',
|
|
40
|
+
sessionBaseUrl: 'https://api.mention.earth',
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// getBaseURL (the HTTP client's request base) is independent of the
|
|
44
|
+
// session base — only the latter is overridden by config.
|
|
45
|
+
expect(oxy.getBaseURL()).toBe('https://api.oxy.so');
|
|
46
|
+
expect(oxy.getSessionBaseUrl()).not.toBe(oxy.getBaseURL());
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('is a pure read — it does not touch token/auth state', () => {
|
|
50
|
+
const oxy = new OxyServices({
|
|
51
|
+
baseURL: 'https://api.oxy.so',
|
|
52
|
+
sessionBaseUrl: 'https://api.mention.earth',
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
expect(oxy.hasValidToken()).toBe(false);
|
|
56
|
+
// Resolving the session base must not plant or clear any token.
|
|
57
|
+
oxy.getSessionBaseUrl();
|
|
58
|
+
expect(oxy.hasValidToken()).toBe(false);
|
|
59
|
+
expect(oxy.getAccessToken()).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
});
|