@oxyhq/core 5.5.0 → 6.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (181) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/HttpService.js +6 -3
  3. package/dist/cjs/OxyServices.base.js +7 -102
  4. package/dist/cjs/boot/coldBootV2.js +350 -0
  5. package/dist/cjs/boot/deviceBootReturn.js +152 -0
  6. package/dist/cjs/crypto/keyManager.js +95 -0
  7. package/dist/cjs/i18n/locales/en-US.json +13 -1
  8. package/dist/cjs/i18n/locales/es-ES.json +13 -1
  9. package/dist/cjs/i18n/locales/locales/en-US.json +13 -1
  10. package/dist/cjs/i18n/locales/locales/es-ES.json +13 -1
  11. package/dist/cjs/index.js +47 -44
  12. package/dist/cjs/mixins/OxyServices.accounts.js +6 -13
  13. package/dist/cjs/mixins/OxyServices.auth.js +66 -201
  14. package/dist/cjs/mixins/OxyServices.authorizedApps.js +38 -0
  15. package/dist/cjs/mixins/OxyServices.deviceBoot.js +119 -0
  16. package/dist/cjs/mixins/index.js +13 -17
  17. package/dist/cjs/session/SessionClient.js +40 -1
  18. package/dist/cjs/session/authStateStore.js +284 -0
  19. package/dist/cjs/session/refresh.js +264 -0
  20. package/dist/cjs/utils/accountUtils.js +1 -55
  21. package/dist/cjs/utils/authWebUrl.js +6 -15
  22. package/dist/cjs/utils/fapiAutoDetect.js +16 -64
  23. package/dist/cjs/utils/platform.js +19 -0
  24. package/dist/cjs/utils/ssoBounce.js +15 -362
  25. package/dist/cjs/utils/validationUtils.js +57 -0
  26. package/dist/esm/.tsbuildinfo +1 -1
  27. package/dist/esm/HttpService.js +6 -3
  28. package/dist/esm/OxyServices.base.js +7 -102
  29. package/dist/esm/boot/coldBootV2.js +344 -0
  30. package/dist/esm/boot/deviceBootReturn.js +146 -0
  31. package/dist/esm/crypto/keyManager.js +95 -0
  32. package/dist/esm/i18n/locales/en-US.json +13 -1
  33. package/dist/esm/i18n/locales/es-ES.json +13 -1
  34. package/dist/esm/i18n/locales/locales/en-US.json +13 -1
  35. package/dist/esm/i18n/locales/locales/es-ES.json +13 -1
  36. package/dist/esm/index.js +28 -18
  37. package/dist/esm/mixins/OxyServices.accounts.js +6 -13
  38. package/dist/esm/mixins/OxyServices.auth.js +66 -201
  39. package/dist/esm/mixins/OxyServices.authorizedApps.js +35 -0
  40. package/dist/esm/mixins/OxyServices.deviceBoot.js +116 -0
  41. package/dist/esm/mixins/index.js +13 -17
  42. package/dist/esm/session/SessionClient.js +40 -1
  43. package/dist/esm/session/authStateStore.js +278 -0
  44. package/dist/esm/session/refresh.js +257 -0
  45. package/dist/esm/utils/accountUtils.js +0 -53
  46. package/dist/esm/utils/authWebUrl.js +6 -14
  47. package/dist/esm/utils/fapiAutoDetect.js +16 -63
  48. package/dist/esm/utils/platform.js +18 -0
  49. package/dist/esm/utils/ssoBounce.js +14 -345
  50. package/dist/esm/utils/validationUtils.js +56 -0
  51. package/dist/types/.tsbuildinfo +1 -1
  52. package/dist/types/HttpService.d.ts +13 -0
  53. package/dist/types/OxyServices.base.d.ts +0 -52
  54. package/dist/types/OxyServices.d.ts +0 -25
  55. package/dist/types/boot/coldBootV2.d.ts +76 -0
  56. package/dist/types/boot/deviceBootReturn.d.ts +83 -0
  57. package/dist/types/crypto/keyManager.d.ts +21 -0
  58. package/dist/types/index.d.ts +15 -21
  59. package/dist/types/mixins/OxyServices.accounts.d.ts +0 -2
  60. package/dist/types/mixins/OxyServices.analytics.d.ts +0 -2
  61. package/dist/types/mixins/OxyServices.appData.d.ts +0 -2
  62. package/dist/types/mixins/OxyServices.assets.d.ts +0 -2
  63. package/dist/types/mixins/OxyServices.auth.d.ts +35 -77
  64. package/dist/types/mixins/{OxyServices.redirect.d.ts → OxyServices.authorizedApps.d.ts} +35 -33
  65. package/dist/types/mixins/OxyServices.civic.d.ts +0 -2
  66. package/dist/types/mixins/OxyServices.connectedApps.d.ts +0 -2
  67. package/dist/types/mixins/OxyServices.contacts.d.ts +0 -2
  68. package/dist/types/mixins/OxyServices.deviceBoot.d.ts +110 -0
  69. package/dist/types/mixins/OxyServices.devices.d.ts +0 -2
  70. package/dist/types/mixins/OxyServices.features.d.ts +0 -2
  71. package/dist/types/mixins/OxyServices.identity.d.ts +0 -2
  72. package/dist/types/mixins/OxyServices.language.d.ts +0 -2
  73. package/dist/types/mixins/OxyServices.links.d.ts +0 -2
  74. package/dist/types/mixins/OxyServices.location.d.ts +0 -2
  75. package/dist/types/mixins/OxyServices.nodes.d.ts +0 -2
  76. package/dist/types/mixins/OxyServices.payment.d.ts +0 -2
  77. package/dist/types/mixins/OxyServices.privacy.d.ts +0 -2
  78. package/dist/types/mixins/OxyServices.reputation.d.ts +0 -2
  79. package/dist/types/mixins/OxyServices.security.d.ts +0 -2
  80. package/dist/types/mixins/OxyServices.topics.d.ts +0 -2
  81. package/dist/types/mixins/OxyServices.user.d.ts +0 -2
  82. package/dist/types/mixins/OxyServices.utility.d.ts +0 -2
  83. package/dist/types/mixins/index.d.ts +6 -9
  84. package/dist/types/models/interfaces.d.ts +0 -67
  85. package/dist/types/session/SessionClient.d.ts +25 -0
  86. package/dist/types/session/authStateStore.d.ts +119 -0
  87. package/dist/types/session/refresh.d.ts +93 -0
  88. package/dist/types/utils/accountUtils.d.ts +0 -14
  89. package/dist/types/utils/authWebUrl.d.ts +6 -12
  90. package/dist/types/utils/fapiAutoDetect.d.ts +15 -38
  91. package/dist/types/utils/platform.d.ts +14 -0
  92. package/dist/types/utils/ssoBounce.d.ts +14 -280
  93. package/dist/types/utils/validationUtils.d.ts +15 -0
  94. package/package.json +2 -2
  95. package/src/HttpService.ts +19 -3
  96. package/src/OxyServices.base.ts +7 -112
  97. package/src/OxyServices.ts +0 -38
  98. package/src/boot/__tests__/coldBootV2.test.ts +317 -0
  99. package/src/boot/__tests__/deviceBootReturn.test.ts +158 -0
  100. package/src/boot/coldBootV2.ts +426 -0
  101. package/src/boot/deviceBootReturn.ts +195 -0
  102. package/src/crypto/__tests__/sharedDeviceToken.test.ts +24 -0
  103. package/src/crypto/keyManager.ts +101 -0
  104. package/src/i18n/locales/en-US.json +13 -1
  105. package/src/i18n/locales/es-ES.json +13 -1
  106. package/src/index.ts +74 -65
  107. package/src/mixins/OxyServices.accounts.ts +6 -13
  108. package/src/mixins/OxyServices.auth.ts +78 -253
  109. package/src/mixins/OxyServices.authorizedApps.ts +75 -0
  110. package/src/mixins/OxyServices.deviceBoot.ts +146 -0
  111. package/src/mixins/__tests__/OxyServices.deviceBoot.test.ts +107 -0
  112. package/src/mixins/__tests__/accounts.test.ts +17 -44
  113. package/src/mixins/__tests__/authorizedApps.test.ts +63 -0
  114. package/src/mixins/__tests__/passwordSignIn.test.ts +91 -0
  115. package/src/mixins/index.ts +16 -22
  116. package/src/models/interfaces.ts +0 -79
  117. package/src/session/SessionClient.ts +52 -1
  118. package/src/session/__tests__/SessionClient.additive.test.ts +92 -0
  119. package/src/session/__tests__/SessionClient.rest.test.ts +25 -0
  120. package/src/session/__tests__/SessionClient.state.test.ts +18 -5
  121. package/src/session/__tests__/authStateStore.test.ts +209 -0
  122. package/src/session/__tests__/refresh.test.ts +256 -0
  123. package/src/session/authStateStore.ts +335 -0
  124. package/src/session/refresh.ts +334 -0
  125. package/src/utils/__tests__/authWebUrl.test.ts +5 -29
  126. package/src/utils/__tests__/fapiAutoDetect.test.ts +5 -126
  127. package/src/utils/__tests__/validationUtils.test.ts +30 -0
  128. package/src/utils/accountUtils.ts +0 -62
  129. package/src/utils/authWebUrl.ts +6 -15
  130. package/src/utils/fapiAutoDetect.ts +16 -60
  131. package/src/utils/platform.ts +21 -0
  132. package/src/utils/ssoBounce.ts +14 -393
  133. package/src/utils/validationUtils.ts +62 -0
  134. package/dist/cjs/AuthManager.js +0 -1110
  135. package/dist/cjs/AuthManagerTypes.js +0 -13
  136. package/dist/cjs/CrossDomainAuth.js +0 -206
  137. package/dist/cjs/mixins/OxyServices.fedcm.js +0 -823
  138. package/dist/cjs/mixins/OxyServices.redirect.js +0 -95
  139. package/dist/cjs/mixins/OxyServices.silent.js +0 -204
  140. package/dist/cjs/mixins/OxyServices.sso.js +0 -208
  141. package/dist/cjs/utils/ssoEstablish.js +0 -110
  142. package/dist/cjs/utils/ssoReturn.js +0 -275
  143. package/dist/esm/AuthManager.js +0 -1105
  144. package/dist/esm/AuthManagerTypes.js +0 -12
  145. package/dist/esm/CrossDomainAuth.js +0 -201
  146. package/dist/esm/mixins/OxyServices.fedcm.js +0 -821
  147. package/dist/esm/mixins/OxyServices.redirect.js +0 -92
  148. package/dist/esm/mixins/OxyServices.silent.js +0 -202
  149. package/dist/esm/mixins/OxyServices.sso.js +0 -204
  150. package/dist/esm/utils/ssoEstablish.js +0 -107
  151. package/dist/esm/utils/ssoReturn.js +0 -271
  152. package/dist/types/AuthManager.d.ts +0 -380
  153. package/dist/types/AuthManagerTypes.d.ts +0 -81
  154. package/dist/types/CrossDomainAuth.d.ts +0 -164
  155. package/dist/types/mixins/OxyServices.fedcm.d.ts +0 -331
  156. package/dist/types/mixins/OxyServices.silent.d.ts +0 -132
  157. package/dist/types/mixins/OxyServices.sso.d.ts +0 -138
  158. package/dist/types/utils/ssoEstablish.d.ts +0 -85
  159. package/dist/types/utils/ssoReturn.d.ts +0 -156
  160. package/src/AuthManager.ts +0 -1269
  161. package/src/AuthManagerTypes.ts +0 -86
  162. package/src/CrossDomainAuth.ts +0 -243
  163. package/src/__tests__/authManager.cookiePath.test.ts +0 -390
  164. package/src/__tests__/authManager.security.test.ts +0 -377
  165. package/src/__tests__/crossDomainAuth.test.ts +0 -116
  166. package/src/__tests__/establishDeviceRefreshSlot.test.ts +0 -221
  167. package/src/mixins/OxyServices.fedcm.ts +0 -1026
  168. package/src/mixins/OxyServices.redirect.ts +0 -122
  169. package/src/mixins/OxyServices.silent.ts +0 -272
  170. package/src/mixins/OxyServices.sso.ts +0 -261
  171. package/src/mixins/__tests__/constructorAuthWebUrl.test.ts +0 -85
  172. package/src/mixins/__tests__/fedcm.test.ts +0 -667
  173. package/src/mixins/__tests__/sessionBaseUrl.test.ts +0 -61
  174. package/src/mixins/__tests__/silent.test.ts +0 -102
  175. package/src/mixins/__tests__/sso.test.ts +0 -228
  176. package/src/utils/__tests__/consumeSsoReturn.test.ts +0 -816
  177. package/src/utils/__tests__/ssoBounce.test.ts +0 -219
  178. package/src/utils/__tests__/ssoEstablish.test.ts +0 -204
  179. package/src/utils/__tests__/ssoReturn.test.ts +0 -276
  180. package/src/utils/ssoEstablish.ts +0 -174
  181. package/src/utils/ssoReturn.ts +0 -389
@@ -1,61 +0,0 @@
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
- });
@@ -1,102 +0,0 @@
1
- /**
2
- * Silent iframe auth tests.
3
- *
4
- * The cross-domain durable-restore iframe (`/auth/silent` at the per-apex host)
5
- * posts a message on success. On a failed load, it never posts, so
6
- * `waitForIframeAuth` must resolve `null` immediately instead of waiting for
7
- * the full timeout.
8
- */
9
-
10
- import { OxyServices } from '../../OxyServices';
11
-
12
- const ORIGIN = 'https://mention.earth';
13
-
14
- function installBrowserGlobals(options: {
15
- postMessageDispatcher?: { current: ((event: { origin: string; data: unknown }) => void) | null };
16
- } = {}): void {
17
- const store = new Map<string, string>();
18
- const sessionStorageStub = {
19
- getItem: (k: string) => (store.has(k) ? (store.get(k) as string) : null),
20
- setItem: (k: string, v: string) => { store.set(k, v); },
21
- removeItem: (k: string) => { store.delete(k); },
22
- };
23
- const messageHandlers: Array<(event: { origin: string; data: unknown }) => void> = [];
24
- const win = {
25
- location: { origin: ORIGIN, hostname: 'mention.earth' },
26
- sessionStorage: sessionStorageStub,
27
- addEventListener: (event: string, handler: (e: { origin: string; data: unknown }) => void) => {
28
- if (event === 'message') {
29
- messageHandlers.push(handler);
30
- if (options.postMessageDispatcher) {
31
- options.postMessageDispatcher.current = handler;
32
- }
33
- }
34
- },
35
- removeEventListener: (event: string, handler: (e: { origin: string; data: unknown }) => void) => {
36
- if (event === 'message') {
37
- const idx = messageHandlers.indexOf(handler);
38
- if (idx >= 0) messageHandlers.splice(idx, 1);
39
- }
40
- },
41
- };
42
- (globalThis as unknown as { window: unknown }).window = win;
43
- (globalThis as unknown as { sessionStorage: unknown }).sessionStorage = sessionStorageStub;
44
- }
45
-
46
- function clearBrowserGlobals(): void {
47
- for (const key of ['window', 'sessionStorage'] as const) {
48
- delete (globalThis as Record<string, unknown>)[key];
49
- }
50
- }
51
-
52
- interface FakeIframe {
53
- onerror: ((this: unknown, ...args: unknown[]) => unknown) | null;
54
- onabort: ((this: unknown, ...args: unknown[]) => unknown) | null;
55
- }
56
-
57
- describe('OxyServices waitForIframeAuth fail-fast on iframe load error', () => {
58
- afterEach(() => {
59
- clearBrowserGlobals();
60
- jest.restoreAllMocks();
61
- });
62
-
63
- it('resolves null immediately when the iframe fires onerror', async () => {
64
- installBrowserGlobals();
65
-
66
- const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
67
- const iframe: FakeIframe = { onerror: null, onabort: null };
68
-
69
- const settled = oxy.waitForIframeAuth(
70
- iframe as unknown as HTMLIFrameElement,
71
- 100000,
72
- 'https://auth.mention.earth',
73
- );
74
-
75
- await Promise.resolve();
76
- expect(typeof iframe.onerror).toBe('function');
77
- iframe.onerror?.call(iframe);
78
-
79
- await expect(settled).resolves.toBeNull();
80
- expect(iframe.onerror).toBeNull();
81
- expect(iframe.onabort).toBeNull();
82
- });
83
-
84
- it('resolves null immediately when the iframe fires onabort', async () => {
85
- installBrowserGlobals();
86
-
87
- const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
88
- const iframe: FakeIframe = { onerror: null, onabort: null };
89
-
90
- const settled = oxy.waitForIframeAuth(
91
- iframe as unknown as HTMLIFrameElement,
92
- 100000,
93
- 'https://auth.mention.earth',
94
- );
95
-
96
- await Promise.resolve();
97
- expect(typeof iframe.onabort).toBe('function');
98
- iframe.onabort?.call(iframe);
99
-
100
- await expect(settled).resolves.toBeNull();
101
- });
102
- });
@@ -1,228 +0,0 @@
1
- /**
2
- * `OxyServices.exchangeSsoCode` / `generateSsoState` — central SSO client.
3
- *
4
- * `exchangeSsoCode(code)` POSTs the opaque single-use code to the session base
5
- * URL (`getSessionBaseUrl()` — i.e. `api.oxy.so` by default) at `/sso/exchange`
6
- * with NO credentials, then plants the returned access token via
7
- * `httpService.setTokens(...)` — mirroring `exchangeIdTokenForSession` /
8
- * `verifyChallenge`. NO token/JWT ever travels in the URL; the real token only
9
- * arrives in this exchange response body.
10
- */
11
-
12
- import { OxyServices } from '../../OxyServices';
13
- import { generateSsoState } from '../OxyServices.sso';
14
- import { ssoStateKey } from '../../utils/ssoBounce';
15
-
16
- interface FetchCall {
17
- url: string;
18
- init: RequestInit;
19
- }
20
-
21
- function mockFetchOnce(body: unknown, ok = true, status = 200): { calls: FetchCall[] } {
22
- const calls: FetchCall[] = [];
23
- const fetchMock = jest.fn(async (url: string, init: RequestInit) => {
24
- calls.push({ url, init });
25
- return {
26
- ok,
27
- status,
28
- json: async () => body,
29
- } as unknown as Response;
30
- });
31
- (globalThis as unknown as { fetch: typeof fetch }).fetch = fetchMock as unknown as typeof fetch;
32
- return { calls };
33
- }
34
-
35
- const VALID_BODY = {
36
- accessToken: 'access_sso',
37
- sessionId: 'sess_sso',
38
- expiresAt: new Date(Date.now() + 60_000).toISOString(),
39
- authuser: 0,
40
- user: {
41
- id: 'user_sso',
42
- username: 'ssouser',
43
- name: { displayName: 'SSO User', first: 'SSO', last: 'User', full: 'SSO User' },
44
- avatar: 'file_1',
45
- },
46
- };
47
-
48
- describe('OxyServices.exchangeSsoCode', () => {
49
- const originalFetch = globalThis.fetch;
50
-
51
- afterEach(() => {
52
- (globalThis as unknown as { fetch: typeof fetch }).fetch = originalFetch;
53
- jest.restoreAllMocks();
54
- });
55
-
56
- it('POSTs the code to the API base /sso/exchange with no credentials', async () => {
57
- const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
58
- const { calls } = mockFetchOnce(VALID_BODY);
59
-
60
- await oxy.exchangeSsoCode('opaque-code-123');
61
-
62
- expect(calls).toHaveLength(1);
63
- expect(calls[0].url).toBe('https://api.oxy.so/sso/exchange');
64
- expect(calls[0].init.method).toBe('POST');
65
- expect(calls[0].init.credentials).toBe('omit');
66
- expect(JSON.parse(String(calls[0].init.body))).toEqual({ code: 'opaque-code-123' });
67
- });
68
-
69
- it('targets the configured sessionBaseUrl when set', async () => {
70
- const oxy = new OxyServices({
71
- baseURL: 'https://api.oxy.so',
72
- sessionBaseUrl: 'https://api.mention.earth',
73
- });
74
- const { calls } = mockFetchOnce(VALID_BODY);
75
-
76
- await oxy.exchangeSsoCode('opaque-code-123');
77
-
78
- expect(calls[0].url).toBe('https://api.mention.earth/sso/exchange');
79
- });
80
-
81
- it('plants the access token via setTokens and returns the session', async () => {
82
- const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
83
- expect(oxy.hasValidToken()).toBe(false);
84
- mockFetchOnce(VALID_BODY);
85
-
86
- const session = await oxy.exchangeSsoCode('opaque-code-123');
87
-
88
- expect(oxy.hasValidToken()).toBe(true);
89
- expect(oxy.getAccessToken()).toBe('access_sso');
90
- expect(session.sessionId).toBe('sess_sso');
91
- expect(session.accessToken).toBe('access_sso');
92
- expect(session.user).toEqual({
93
- id: 'user_sso',
94
- username: 'ssouser',
95
- name: { displayName: 'SSO User', first: 'SSO', last: 'User', full: 'SSO User' },
96
- avatar: 'file_1',
97
- });
98
- expect(session.expiresAt).toBe(VALID_BODY.expiresAt);
99
- });
100
-
101
- it('maps a user delivered as _id', async () => {
102
- const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
103
- mockFetchOnce({
104
- accessToken: 'access_sso',
105
- sessionId: 'sess_sso',
106
- user: { _id: 'mongo_id', username: 'ssouser', name: { displayName: 'SSO User' } },
107
- });
108
-
109
- const session = await oxy.exchangeSsoCode('opaque-code-123');
110
-
111
- expect(session.user.id).toBe('mongo_id');
112
- });
113
-
114
- it('rejects an empty code without calling fetch', async () => {
115
- const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
116
- const { calls } = mockFetchOnce(VALID_BODY);
117
-
118
- await expect(oxy.exchangeSsoCode('')).rejects.toThrow();
119
- expect(calls).toHaveLength(0);
120
- });
121
-
122
- it('rejects a browser SSO exchange when stored state is not echoed', async () => {
123
- const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
124
- const { calls } = mockFetchOnce(VALID_BODY);
125
- const storage = new Map([[ssoStateKey('https://rp.example'), 'expected-state']]);
126
- const previousWindow = (globalThis as unknown as { window?: unknown }).window;
127
-
128
- Object.defineProperty(globalThis, 'window', {
129
- configurable: true,
130
- value: {
131
- location: { origin: 'https://rp.example' },
132
- sessionStorage: {
133
- getItem: (key: string) => storage.get(key) ?? null,
134
- },
135
- },
136
- });
137
-
138
- try {
139
- await expect(oxy.exchangeSsoCode('opaque-code-123', 'attacker-state')).rejects.toThrow(
140
- 'SSO exchange state mismatch',
141
- );
142
- expect(calls).toHaveLength(0);
143
- expect(oxy.hasValidToken()).toBe(false);
144
- } finally {
145
- Object.defineProperty(globalThis, 'window', {
146
- configurable: true,
147
- value: previousWindow,
148
- });
149
- }
150
- });
151
-
152
- it('throws and does not plant a token on a non-2xx response', async () => {
153
- const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
154
- mockFetchOnce({ error: 'invalid_code' }, false, 400);
155
-
156
- await expect(oxy.exchangeSsoCode('bad-code')).rejects.toThrow();
157
- expect(oxy.hasValidToken()).toBe(false);
158
- });
159
-
160
- it('throws when the response carries no access token', async () => {
161
- const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
162
- mockFetchOnce({ sessionId: 'sess_sso', user: { id: 'u', username: 'x' } });
163
-
164
- await expect(oxy.exchangeSsoCode('opaque-code-123')).rejects.toThrow();
165
- expect(oxy.hasValidToken()).toBe(false);
166
- });
167
- });
168
-
169
- describe('OxyServices.requestSsoEstablishUrl', () => {
170
- const ESTABLISH_URL =
171
- 'https://auth.oxy.so/sso/establish?et=jwt&return_to=https%3A%2F%2Faccounts.oxy.so%2F__oxy%2Fsso-callback&state=s';
172
-
173
- it('POSTs origin + state to /sso/establish-token (bearer, cache-free) and returns the URL', async () => {
174
- const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
175
- const spy = jest
176
- .spyOn(oxy, 'makeRequest')
177
- .mockResolvedValue({ establishUrl: ESTABLISH_URL } as never);
178
-
179
- const result = await oxy.requestSsoEstablishUrl('https://accounts.oxy.so', 's');
180
-
181
- expect(result).toEqual({ establishUrl: ESTABLISH_URL });
182
- expect(spy).toHaveBeenCalledWith(
183
- 'POST',
184
- '/sso/establish-token',
185
- { origin: 'https://accounts.oxy.so', state: 's' },
186
- { cache: false },
187
- );
188
- spy.mockRestore();
189
- });
190
-
191
- it('rejects an empty origin or state without calling the API', async () => {
192
- const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
193
- const spy = jest.spyOn(oxy, 'makeRequest');
194
-
195
- await expect(oxy.requestSsoEstablishUrl('', 's')).rejects.toThrow();
196
- await expect(oxy.requestSsoEstablishUrl('https://accounts.oxy.so', '')).rejects.toThrow();
197
- expect(spy).not.toHaveBeenCalled();
198
- spy.mockRestore();
199
- });
200
-
201
- it('throws when the server returns no establishUrl', async () => {
202
- const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
203
- const spy = jest.spyOn(oxy, 'makeRequest').mockResolvedValue({} as never);
204
-
205
- await expect(oxy.requestSsoEstablishUrl('https://accounts.oxy.so', 's')).rejects.toThrow();
206
- spy.mockRestore();
207
- });
208
- });
209
-
210
- describe('generateSsoState', () => {
211
- it('returns a non-empty unique string (module-level helper)', () => {
212
- const a = generateSsoState();
213
- const b = generateSsoState();
214
-
215
- expect(typeof a).toBe('string');
216
- expect(a.length).toBeGreaterThan(0);
217
- expect(a).not.toBe(b);
218
- });
219
-
220
- it('is also reachable as an instance method delegating to generateState', () => {
221
- const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
222
-
223
- const state = oxy.generateSsoState();
224
-
225
- expect(typeof state).toBe('string');
226
- expect(state.length).toBeGreaterThan(0);
227
- });
228
- });