@oxyhq/core 3.4.4 → 3.4.6

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 (48) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/OxyServices.base.js +39 -0
  3. package/dist/cjs/index.js +3 -1
  4. package/dist/cjs/utils/ssoBounce.js +32 -0
  5. package/dist/cjs/utils/ssoReturn.js +4 -1
  6. package/dist/esm/.tsbuildinfo +1 -1
  7. package/dist/esm/OxyServices.base.js +39 -0
  8. package/dist/esm/index.js +1 -1
  9. package/dist/esm/utils/ssoBounce.js +30 -0
  10. package/dist/esm/utils/ssoReturn.js +5 -2
  11. package/dist/types/.tsbuildinfo +1 -1
  12. package/dist/types/HttpService.d.ts +1 -1
  13. package/dist/types/OxyServices.base.d.ts +14 -0
  14. package/dist/types/OxyServices.d.ts +2 -1
  15. package/dist/types/index.d.ts +2 -1
  16. package/dist/types/mixins/OxyServices.analytics.d.ts +1 -0
  17. package/dist/types/mixins/OxyServices.appData.d.ts +1 -0
  18. package/dist/types/mixins/OxyServices.applications.d.ts +1 -0
  19. package/dist/types/mixins/OxyServices.assets.d.ts +1 -0
  20. package/dist/types/mixins/OxyServices.auth.d.ts +1 -0
  21. package/dist/types/mixins/OxyServices.contacts.d.ts +1 -0
  22. package/dist/types/mixins/OxyServices.devices.d.ts +1 -0
  23. package/dist/types/mixins/OxyServices.features.d.ts +1 -0
  24. package/dist/types/mixins/OxyServices.fedcm.d.ts +1 -0
  25. package/dist/types/mixins/OxyServices.language.d.ts +1 -0
  26. package/dist/types/mixins/OxyServices.location.d.ts +1 -0
  27. package/dist/types/mixins/OxyServices.managedAccounts.d.ts +1 -0
  28. package/dist/types/mixins/OxyServices.payment.d.ts +1 -0
  29. package/dist/types/mixins/OxyServices.privacy.d.ts +1 -0
  30. package/dist/types/mixins/OxyServices.redirect.d.ts +1 -0
  31. package/dist/types/mixins/OxyServices.reputation.d.ts +1 -0
  32. package/dist/types/mixins/OxyServices.security.d.ts +1 -0
  33. package/dist/types/mixins/OxyServices.silent.d.ts +1 -0
  34. package/dist/types/mixins/OxyServices.sso.d.ts +1 -0
  35. package/dist/types/mixins/OxyServices.topics.d.ts +1 -0
  36. package/dist/types/mixins/OxyServices.user.d.ts +1 -0
  37. package/dist/types/mixins/OxyServices.utility.d.ts +1 -0
  38. package/dist/types/mixins/OxyServices.workspaces.d.ts +1 -0
  39. package/dist/types/utils/ssoBounce.d.ts +23 -0
  40. package/package.json +1 -1
  41. package/src/HttpService.ts +1 -1
  42. package/src/OxyServices.base.ts +50 -1
  43. package/src/OxyServices.ts +3 -1
  44. package/src/__tests__/linkedClient.test.ts +64 -0
  45. package/src/index.ts +3 -0
  46. package/src/utils/__tests__/ssoReturn.test.ts +132 -1
  47. package/src/utils/ssoBounce.ts +33 -0
  48. package/src/utils/ssoReturn.ts +5 -1
@@ -172,7 +172,7 @@ export declare class HttpService {
172
172
  * Get auth header with automatic token refresh
173
173
  */
174
174
  private getAuthHeader;
175
- private refreshAccessToken;
175
+ refreshAccessToken(reason: AuthRefreshReason): Promise<string | null>;
176
176
  /**
177
177
  * Unwrap standardized API response format
178
178
  */
@@ -3,6 +3,10 @@ import { HttpService, type RequestOptions } from './HttpService';
3
3
  export interface OxyConfig extends OxyConfigBase {
4
4
  cloudURL?: string;
5
5
  }
6
+ export interface LinkedHttpClient {
7
+ client: HttpService;
8
+ dispose(): void;
9
+ }
6
10
  /**
7
11
  * Base class for OxyServices with core infrastructure
8
12
  */
@@ -40,6 +44,16 @@ export declare class OxyServicesBase {
40
44
  * Useful for advanced use cases where direct access to the HTTP service is needed
41
45
  */
42
46
  getClient(): HttpService;
47
+ /**
48
+ * Create an app/backend HTTP client linked to this Oxy session.
49
+ *
50
+ * Use this when an app has its own API origin (for example
51
+ * `https://api.syra.fm`) but authentication is owned by the canonical
52
+ * OxyServices instance mounted in OxyProvider. The returned client has its own
53
+ * base URL, cache and request queue, but its bearer token is kept in lockstep
54
+ * with this session and its 401 refresh path delegates back to this session.
55
+ */
56
+ createLinkedClient(config: OxyConfig): LinkedHttpClient;
43
57
  /**
44
58
  * Get performance metrics
45
59
  */
@@ -56,7 +56,7 @@
56
56
  *
57
57
  * See method JSDoc for more details and options.
58
58
  */
59
- import { type OxyConfig } from './OxyServices.base';
59
+ import { type LinkedHttpClient, type OxyConfig } from './OxyServices.base';
60
60
  import { OxyAuthenticationError, OxyAuthenticationTimeoutError } from './OxyServices.errors';
61
61
  import type { SessionLoginResponse } from './models/session';
62
62
  import type { FedCMAuthOptions, FedCMConfig } from './mixins/OxyServices.fedcm';
@@ -111,6 +111,7 @@ export declare class OxyServices extends OxyServicesComposed {
111
111
  constructor(config: OxyConfig);
112
112
  }
113
113
  export interface OxyServices extends InstanceType<ReturnType<typeof composeOxyServices>> {
114
+ createLinkedClient(config: OxyConfig): LinkedHttpClient;
114
115
  isFedCMSupported(): boolean;
115
116
  signInWithFedCM(options?: FedCMAuthOptions): Promise<SessionLoginResponse>;
116
117
  silentSignInWithFedCM(): Promise<SessionLoginResponse | null>;
@@ -19,6 +19,7 @@
19
19
  import './crypto/polyfill';
20
20
  export { OxyServices, OxyAuthenticationError, OxyAuthenticationTimeoutError } from './OxyServices';
21
21
  export { OXY_CLOUD_URL, oxyClient } from './OxyServices';
22
+ export type { LinkedHttpClient } from './OxyServices.base';
22
23
  export { AuthManager, createAuthManager } from './AuthManager';
23
24
  export type { StorageAdapter, AuthStateChangeCallback, AuthMethod, AuthManagerConfig, } from './AuthManager';
24
25
  export type { AuthManagerAccount, RestoreFromCookiesResult, RestoreFromCookiesOptions, SwitchAuthuserResult, } from './AuthManagerTypes';
@@ -80,7 +81,7 @@ export { CENTRAL_AUTH_URL, CENTRAL_IDP_APEX, resolveCentralAuthUrl } from './uti
80
81
  export { parseSsoReturnFragment, consumeSsoReturn } from './utils/ssoReturn';
81
82
  export type { SsoReturnKind, SsoReturnResult, ConsumeSsoReturnDeps } from './utils/ssoReturn';
82
83
  export { generateSsoState } from './mixins/OxyServices.sso';
83
- export { SSO_CALLBACK_PATH, SSO_GUARD_TTL_MS, ssoStateKey, ssoGuardKey, ssoDestKey, ssoNoSessionKey, ssoAttemptedKey, ssoNavigate, buildSsoBounceUrl, isCentralIdPOrigin, guardActive, } from './utils/ssoBounce';
84
+ export { SSO_CALLBACK_PATH, SSO_GUARD_TTL_MS, ssoStateKey, ssoGuardKey, ssoDestKey, ssoNoSessionKey, ssoAttemptedKey, ssoCallbackBootstrapKey, ssoNavigate, getSsoCallbackBootstrapScript, buildSsoBounceUrl, isCentralIdPOrigin, guardActive, } from './utils/ssoBounce';
84
85
  export { runColdBoot } from './utils/coldBoot';
85
86
  export type { ColdBootStep, ColdBootStepResult, ColdBootSession, ColdBootSkip, ColdBootOutcome, RunColdBootOptions, } from './utils/coldBoot';
86
87
  export { packageInfo } from './constants/version';
@@ -27,6 +27,7 @@ export declare function OxyServicesAnalyticsMixin<T extends typeof OxyServicesBa
27
27
  getBaseURL(): string;
28
28
  getSessionBaseUrl(): string;
29
29
  getClient(): import("../HttpService").HttpService;
30
+ createLinkedClient(config: import("../OxyServices.base").OxyConfig): import("..").LinkedHttpClient;
30
31
  getMetrics(): {
31
32
  totalRequests: number;
32
33
  successfulRequests: number;
@@ -63,6 +63,7 @@ export declare function OxyServicesAppDataMixin<T extends typeof OxyServicesBase
63
63
  getBaseURL(): string;
64
64
  getSessionBaseUrl(): string;
65
65
  getClient(): import("../HttpService").HttpService;
66
+ createLinkedClient(config: import("../OxyServices.base").OxyConfig): import("..").LinkedHttpClient;
66
67
  getMetrics(): {
67
68
  totalRequests: number;
68
69
  successfulRequests: number;
@@ -371,6 +371,7 @@ export declare function OxyServicesApplicationsMixin<T extends typeof OxyService
371
371
  getBaseURL(): string;
372
372
  getSessionBaseUrl(): string;
373
373
  getClient(): import("../HttpService").HttpService;
374
+ createLinkedClient(config: import("../OxyServices.base").OxyConfig): import("..").LinkedHttpClient;
374
375
  getMetrics(): {
375
376
  totalRequests: number;
376
377
  successfulRequests: number;
@@ -100,6 +100,7 @@ export declare function OxyServicesAssetsMixin<T extends typeof OxyServicesBase>
100
100
  getBaseURL(): string;
101
101
  getSessionBaseUrl(): string;
102
102
  getClient(): import("../HttpService").HttpService;
103
+ createLinkedClient(config: import("../OxyServices.base").OxyConfig): import("..").LinkedHttpClient;
103
104
  getMetrics(): {
104
105
  totalRequests: number;
105
106
  successfulRequests: number;
@@ -398,6 +398,7 @@ export declare function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(B
398
398
  getBaseURL(): string;
399
399
  getSessionBaseUrl(): string;
400
400
  getClient(): import("../HttpService").HttpService;
401
+ createLinkedClient(config: import("../OxyServices.base").OxyConfig): import("..").LinkedHttpClient;
401
402
  getMetrics(): {
402
403
  totalRequests: number;
403
404
  successfulRequests: number;
@@ -55,6 +55,7 @@ export declare function OxyServicesContactsMixin<T extends typeof OxyServicesBas
55
55
  getBaseURL(): string;
56
56
  getSessionBaseUrl(): string;
57
57
  getClient(): import("../HttpService").HttpService;
58
+ createLinkedClient(config: import("../OxyServices.base").OxyConfig): import("..").LinkedHttpClient;
58
59
  getMetrics(): {
59
60
  totalRequests: number;
60
61
  successfulRequests: number;
@@ -57,6 +57,7 @@ export declare function OxyServicesDevicesMixin<T extends typeof OxyServicesBase
57
57
  getBaseURL(): string;
58
58
  getSessionBaseUrl(): string;
59
59
  getClient(): import("../HttpService").HttpService;
60
+ createLinkedClient(config: import("../OxyServices.base").OxyConfig): import("..").LinkedHttpClient;
60
61
  getMetrics(): {
61
62
  totalRequests: number;
62
63
  successfulRequests: number;
@@ -184,6 +184,7 @@ export declare function OxyServicesFeaturesMixin<T extends typeof OxyServicesBas
184
184
  getBaseURL(): string;
185
185
  getSessionBaseUrl(): string;
186
186
  getClient(): import("../HttpService").HttpService;
187
+ createLinkedClient(config: import("../OxyServices.base").OxyConfig): import("..").LinkedHttpClient;
187
188
  getMetrics(): {
188
189
  totalRequests: number;
189
190
  successfulRequests: number;
@@ -259,6 +259,7 @@ export declare function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(
259
259
  getBaseURL(): string;
260
260
  getSessionBaseUrl(): string;
261
261
  getClient(): import("../HttpService").HttpService;
262
+ createLinkedClient(config: import("../OxyServices.base").OxyConfig): import("..").LinkedHttpClient;
262
263
  getMetrics(): {
263
264
  totalRequests: number;
264
265
  successfulRequests: number;
@@ -42,6 +42,7 @@ export declare function OxyServicesLanguageMixin<T extends typeof OxyServicesBas
42
42
  getBaseURL(): string;
43
43
  getSessionBaseUrl(): string;
44
44
  getClient(): import("../HttpService").HttpService;
45
+ createLinkedClient(config: import("../OxyServices.base").OxyConfig): import("..").LinkedHttpClient;
45
46
  getMetrics(): {
46
47
  totalRequests: number;
47
48
  successfulRequests: number;
@@ -25,6 +25,7 @@ export declare function OxyServicesLocationMixin<T extends typeof OxyServicesBas
25
25
  getBaseURL(): string;
26
26
  getSessionBaseUrl(): string;
27
27
  getClient(): import("../HttpService").HttpService;
28
+ createLinkedClient(config: import("../OxyServices.base").OxyConfig): import("..").LinkedHttpClient;
28
29
  getMetrics(): {
29
30
  totalRequests: number;
30
31
  successfulRequests: number;
@@ -82,6 +82,7 @@ export declare function OxyServicesManagedAccountsMixin<T extends typeof OxyServ
82
82
  getBaseURL(): string;
83
83
  getSessionBaseUrl(): string;
84
84
  getClient(): import("../HttpService").HttpService;
85
+ createLinkedClient(config: import("../OxyServices.base").OxyConfig): import("..").LinkedHttpClient;
85
86
  getMetrics(): {
86
87
  totalRequests: number;
87
88
  successfulRequests: number;
@@ -72,6 +72,7 @@ export declare function OxyServicesPaymentMixin<T extends typeof OxyServicesBase
72
72
  getBaseURL(): string;
73
73
  getSessionBaseUrl(): string;
74
74
  getClient(): import("../HttpService").HttpService;
75
+ createLinkedClient(config: import("../OxyServices.base").OxyConfig): import("..").LinkedHttpClient;
75
76
  getMetrics(): {
76
77
  totalRequests: number;
77
78
  successfulRequests: number;
@@ -83,6 +83,7 @@ export declare function OxyServicesPrivacyMixin<T extends typeof OxyServicesBase
83
83
  getBaseURL(): string;
84
84
  getSessionBaseUrl(): string;
85
85
  getClient(): import("../HttpService").HttpService;
86
+ createLinkedClient(config: import("../OxyServices.base").OxyConfig): import("..").LinkedHttpClient;
86
87
  getMetrics(): {
87
88
  totalRequests: number;
88
89
  successfulRequests: number;
@@ -44,6 +44,7 @@ export declare function OxyServicesRedirectAuthMixin<T extends typeof OxyService
44
44
  getBaseURL(): string;
45
45
  getSessionBaseUrl(): string;
46
46
  getClient(): import("../HttpService").HttpService;
47
+ createLinkedClient(config: import("../OxyServices.base").OxyConfig): import("..").LinkedHttpClient;
47
48
  getMetrics(): {
48
49
  totalRequests: number;
49
50
  successfulRequests: number;
@@ -390,6 +390,7 @@ export declare function OxyServicesReputationMixin<T extends typeof OxyServicesB
390
390
  getBaseURL(): string;
391
391
  getSessionBaseUrl(): string;
392
392
  getClient(): import("../HttpService").HttpService;
393
+ createLinkedClient(config: import("../OxyServices.base").OxyConfig): import("..").LinkedHttpClient;
393
394
  getMetrics(): {
394
395
  totalRequests: number;
395
396
  successfulRequests: number;
@@ -39,6 +39,7 @@ export declare function OxyServicesSecurityMixin<T extends typeof OxyServicesBas
39
39
  getBaseURL(): string;
40
40
  getSessionBaseUrl(): string;
41
41
  getClient(): import("../HttpService").HttpService;
42
+ createLinkedClient(config: import("../OxyServices.base").OxyConfig): import("..").LinkedHttpClient;
42
43
  getMetrics(): {
43
44
  totalRequests: number;
44
45
  successfulRequests: number;
@@ -82,6 +82,7 @@ export declare function OxyServicesSilentAuthMixin<T extends typeof OxyServicesB
82
82
  getBaseURL(): string;
83
83
  getSessionBaseUrl(): string;
84
84
  getClient(): import("../HttpService").HttpService;
85
+ createLinkedClient(config: import("../OxyServices.base").OxyConfig): import("..").LinkedHttpClient;
85
86
  getMetrics(): {
86
87
  totalRequests: number;
87
88
  successfulRequests: number;
@@ -64,6 +64,7 @@ export declare function OxyServicesSsoMixin<T extends typeof OxyServicesBase>(Ba
64
64
  getBaseURL(): string;
65
65
  getSessionBaseUrl(): string;
66
66
  getClient(): import("../HttpService").HttpService;
67
+ createLinkedClient(config: import("../OxyServices.base").OxyConfig): import("..").LinkedHttpClient;
67
68
  getMetrics(): {
68
69
  totalRequests: number;
69
70
  successfulRequests: number;
@@ -65,6 +65,7 @@ export declare function OxyServicesTopicsMixin<T extends typeof OxyServicesBase>
65
65
  getBaseURL(): string;
66
66
  getSessionBaseUrl(): string;
67
67
  getClient(): import("../HttpService").HttpService;
68
+ createLinkedClient(config: import("../OxyServices.base").OxyConfig): import("..").LinkedHttpClient;
68
69
  getMetrics(): {
69
70
  totalRequests: number;
70
71
  successfulRequests: number;
@@ -231,6 +231,7 @@ export declare function OxyServicesUserMixin<T extends typeof OxyServicesBase>(B
231
231
  getBaseURL(): string;
232
232
  getSessionBaseUrl(): string;
233
233
  getClient(): import("../HttpService").HttpService;
234
+ createLinkedClient(config: import("../OxyServices.base").OxyConfig): import("..").LinkedHttpClient;
234
235
  getMetrics(): {
235
236
  totalRequests: number;
236
237
  successfulRequests: number;
@@ -250,6 +250,7 @@ export declare function OxyServicesUtilityMixin<T extends typeof OxyServicesBase
250
250
  getBaseURL(): string;
251
251
  getSessionBaseUrl(): string;
252
252
  getClient(): import("../HttpService").HttpService;
253
+ createLinkedClient(config: import("../OxyServices.base").OxyConfig): import("..").LinkedHttpClient;
253
254
  getMetrics(): {
254
255
  totalRequests: number;
255
256
  successfulRequests: number;
@@ -159,6 +159,7 @@ export declare function OxyServicesWorkspacesMixin<T extends typeof OxyServicesB
159
159
  getBaseURL(): string;
160
160
  getSessionBaseUrl(): string;
161
161
  getClient(): import("../HttpService").HttpService;
162
+ createLinkedClient(config: import("../OxyServices.base").OxyConfig): import("..").LinkedHttpClient;
162
163
  getMetrics(): {
163
164
  totalRequests: number;
164
165
  successfulRequests: number;
@@ -79,6 +79,29 @@ export declare function ssoNoSessionKey(origin: string): string;
79
79
  * centrally) can probe again.
80
80
  */
81
81
  export declare function ssoAttemptedKey(origin: string): string;
82
+ /**
83
+ * Per-origin marker written by the pre-hydration callback bootstrap.
84
+ *
85
+ * Static Expo exports render unknown paths as `+not-found`; on
86
+ * `/__oxy/sso-callback` that can fail hydration before the React provider has a
87
+ * chance to run `consumeSsoReturn`. The bootstrap runs in the HTML head, moves
88
+ * the URL to a hydratable route while preserving the SSO fragment, and writes
89
+ * this marker so `consumeSsoReturn` still restores the original destination as
90
+ * if the page were physically on the callback path.
91
+ */
92
+ export declare function ssoCallbackBootstrapKey(origin: string): string;
93
+ /**
94
+ * Inline script for Expo/static web apps.
95
+ *
96
+ * Must run before the app bundle hydrates. It is intentionally tiny and
97
+ * dependency-free: if the browser lands on the internal callback route with an
98
+ * Oxy SSO fragment, it marks the handoff and rewrites the path to `/` while
99
+ * preserving `#oxy_sso=...`. The normal SDK cold-boot `sso-return` step then
100
+ * consumes the fragment from a route that can hydrate. If the internal route is
101
+ * reached without a valid SSO fragment, it leaves the route via a hard root
102
+ * navigation because there is no session material to preserve.
103
+ */
104
+ export declare function getSsoCallbackBootstrapScript(): string;
82
105
  /**
83
106
  * Perform the terminal top-level SSO bounce navigation.
84
107
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/core",
3
- "version": "3.4.4",
3
+ "version": "3.4.6",
4
4
  "description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -878,7 +878,7 @@ export class HttpService {
878
878
  }
879
879
  }
880
880
 
881
- private async refreshAccessToken(reason: AuthRefreshReason): Promise<string | null> {
881
+ async refreshAccessToken(reason: AuthRefreshReason): Promise<string | null> {
882
882
  if (!this.authRefreshHandler) {
883
883
  return null;
884
884
  }
@@ -6,7 +6,7 @@
6
6
  import { jwtDecode } from 'jwt-decode';
7
7
  import type { OxyConfig as OxyConfigBase, ApiError, User } from './models/interfaces';
8
8
  import { handleHttpError } from './utils/errorUtils';
9
- import { HttpService, type RequestOptions } from './HttpService';
9
+ import { HttpService, type AuthRefreshReason, type RequestOptions } from './HttpService';
10
10
  import { OxyAuthenticationError, OxyAuthenticationTimeoutError } from './OxyServices.errors';
11
11
  import { resolveCentralAuthUrl } from './utils/authWebUrl';
12
12
 
@@ -14,6 +14,11 @@ export interface OxyConfig extends OxyConfigBase {
14
14
  cloudURL?: string;
15
15
  }
16
16
 
17
+ export interface LinkedHttpClient {
18
+ client: HttpService;
19
+ dispose(): void;
20
+ }
21
+
17
22
  interface JwtPayload {
18
23
  exp?: number;
19
24
  userId?: string;
@@ -116,6 +121,50 @@ export class OxyServicesBase {
116
121
  return this.httpService;
117
122
  }
118
123
 
124
+ /**
125
+ * Create an app/backend HTTP client linked to this Oxy session.
126
+ *
127
+ * Use this when an app has its own API origin (for example
128
+ * `https://api.syra.fm`) but authentication is owned by the canonical
129
+ * OxyServices instance mounted in OxyProvider. The returned client has its own
130
+ * base URL, cache and request queue, but its bearer token is kept in lockstep
131
+ * with this session and its 401 refresh path delegates back to this session.
132
+ */
133
+ public createLinkedClient(config: OxyConfig): LinkedHttpClient {
134
+ const client = new HttpService(config);
135
+
136
+ const syncToken = (accessToken: string | null): void => {
137
+ const currentAccessToken = client.getAccessToken();
138
+ if (accessToken) {
139
+ if (currentAccessToken !== accessToken) {
140
+ client.setTokens(accessToken);
141
+ }
142
+ return;
143
+ }
144
+
145
+ if (currentAccessToken) {
146
+ client.clearTokens();
147
+ }
148
+ };
149
+
150
+ syncToken(this.getAccessToken());
151
+ const unsubscribe = this.onTokensChanged(syncToken);
152
+ client.setAuthRefreshHandler(async (reason: AuthRefreshReason) => {
153
+ const refreshed = await this.httpService.refreshAccessToken(reason);
154
+ syncToken(refreshed);
155
+ return refreshed;
156
+ });
157
+
158
+ return {
159
+ client,
160
+ dispose: () => {
161
+ unsubscribe();
162
+ client.setAuthRefreshHandler(null);
163
+ client.clearTokens();
164
+ },
165
+ };
166
+ }
167
+
119
168
  /**
120
169
  * Get performance metrics
121
170
  */
@@ -56,7 +56,7 @@
56
56
  *
57
57
  * See method JSDoc for more details and options.
58
58
  */
59
- import { OxyServicesBase, type OxyConfig } from './OxyServices.base';
59
+ import { OxyServicesBase, type LinkedHttpClient, type OxyConfig } from './OxyServices.base';
60
60
  import { OxyAuthenticationError, OxyAuthenticationTimeoutError } from './OxyServices.errors';
61
61
  import type { SessionLoginResponse } from './models/session';
62
62
  import type { FedCMAuthOptions, FedCMConfig } from './mixins/OxyServices.fedcm';
@@ -130,6 +130,8 @@ export class OxyServices extends OxyServicesComposed {
130
130
  // Explicit declarations are added for cross-domain auth methods that downstream
131
131
  // packages (auth-sdk, services) need without casting to `any`.
132
132
  export interface OxyServices extends InstanceType<ReturnType<typeof composeOxyServices>> {
133
+ createLinkedClient(config: OxyConfig): LinkedHttpClient;
134
+
133
135
  // FedCM authentication
134
136
  isFedCMSupported(): boolean;
135
137
  signInWithFedCM(options?: FedCMAuthOptions): Promise<SessionLoginResponse>;
@@ -0,0 +1,64 @@
1
+ import { OxyServices } from '../OxyServices';
2
+
3
+ function createServices(): OxyServices {
4
+ return new OxyServices({ baseURL: 'https://api.oxy.so' });
5
+ }
6
+
7
+ describe('OxyServices.createLinkedClient', () => {
8
+ it('mirrors token changes from the session owner', () => {
9
+ const oxy = createServices();
10
+ const linked = oxy.createLinkedClient({ baseURL: 'https://api.syra.fm' });
11
+
12
+ expect(linked.client.getAccessToken()).toBeNull();
13
+
14
+ oxy.setTokens('access_1');
15
+ expect(linked.client.getAccessToken()).toBe('access_1');
16
+
17
+ oxy.setTokens('access_2');
18
+ expect(linked.client.getAccessToken()).toBe('access_2');
19
+
20
+ oxy.clearTokens();
21
+ expect(linked.client.getAccessToken()).toBeNull();
22
+
23
+ linked.dispose();
24
+ });
25
+
26
+ it('copies the current token when created after sign-in', () => {
27
+ const oxy = createServices();
28
+ oxy.setTokens('existing_access');
29
+
30
+ const linked = oxy.createLinkedClient({ baseURL: 'https://api.syra.fm' });
31
+
32
+ expect(linked.client.getAccessToken()).toBe('existing_access');
33
+
34
+ linked.dispose();
35
+ });
36
+
37
+ it('delegates token refresh to the session owner', async () => {
38
+ const oxy = createServices();
39
+ const linked = oxy.createLinkedClient({ baseURL: 'https://api.syra.fm' });
40
+
41
+ oxy.getClient().setAuthRefreshHandler(async () => 'refreshed_access');
42
+
43
+ const refreshed = await linked.client.refreshAccessToken('preflight');
44
+
45
+ expect(refreshed).toBe('refreshed_access');
46
+ expect(oxy.getAccessToken()).toBe('refreshed_access');
47
+ expect(linked.client.getAccessToken()).toBe('refreshed_access');
48
+
49
+ linked.dispose();
50
+ });
51
+
52
+ it('stops mirroring after dispose', () => {
53
+ const oxy = createServices();
54
+ const linked = oxy.createLinkedClient({ baseURL: 'https://api.syra.fm' });
55
+
56
+ oxy.setTokens('before_dispose');
57
+ expect(linked.client.getAccessToken()).toBe('before_dispose');
58
+
59
+ linked.dispose();
60
+ oxy.setTokens('after_dispose');
61
+
62
+ expect(linked.client.getAccessToken()).toBeNull();
63
+ });
64
+ });
package/src/index.ts CHANGED
@@ -25,6 +25,7 @@ import './crypto/polyfill';
25
25
  // ---------------------------------------------------------------------------
26
26
  export { OxyServices, OxyAuthenticationError, OxyAuthenticationTimeoutError } from './OxyServices';
27
27
  export { OXY_CLOUD_URL, oxyClient } from './OxyServices';
28
+ export type { LinkedHttpClient } from './OxyServices.base';
28
29
 
29
30
  // ---------------------------------------------------------------------------
30
31
  // Authentication
@@ -458,7 +459,9 @@ export {
458
459
  ssoDestKey,
459
460
  ssoNoSessionKey,
460
461
  ssoAttemptedKey,
462
+ ssoCallbackBootstrapKey,
461
463
  ssoNavigate,
464
+ getSsoCallbackBootstrapScript,
462
465
  buildSsoBounceUrl,
463
466
  isCentralIdPOrigin,
464
467
  guardActive,
@@ -7,7 +7,42 @@
7
7
  * `null` for anything that is not an oxy_sso fragment.
8
8
  */
9
9
 
10
- import { parseSsoReturnFragment } from '../ssoReturn';
10
+ import type { SessionLoginResponse } from '../../models/session';
11
+ import { consumeSsoReturn, parseSsoReturnFragment } from '../ssoReturn';
12
+ import {
13
+ getSsoCallbackBootstrapScript,
14
+ ssoAttemptedKey,
15
+ ssoCallbackBootstrapKey,
16
+ ssoDestKey,
17
+ ssoNoSessionKey,
18
+ ssoStateKey,
19
+ } from '../ssoBounce';
20
+
21
+ class MemorySsoStorage implements Pick<Storage, 'getItem' | 'setItem' | 'removeItem'> {
22
+ private readonly values = new Map<string, string>();
23
+
24
+ getItem(key: string): string | null {
25
+ return this.values.get(key) ?? null;
26
+ }
27
+
28
+ setItem(key: string, value: string): void {
29
+ this.values.set(key, value);
30
+ }
31
+
32
+ removeItem(key: string): void {
33
+ this.values.delete(key);
34
+ }
35
+ }
36
+
37
+ const ORIGIN = 'https://app.mention.earth';
38
+
39
+ const exchangedSession: SessionLoginResponse = {
40
+ sessionId: 'sess_sso',
41
+ deviceId: 'device_sso',
42
+ accessToken: 'access_sso',
43
+ expiresAt: '2030-01-01T00:00:00.000Z',
44
+ user: { id: 'user_sso', username: 'sso-user' },
45
+ };
11
46
 
12
47
  describe('parseSsoReturnFragment', () => {
13
48
  describe('ok', () => {
@@ -118,3 +153,99 @@ describe('parseSsoReturnFragment', () => {
118
153
  });
119
154
  });
120
155
  });
156
+
157
+ describe('consumeSsoReturn pre-hydration callback bootstrap', () => {
158
+ it('continues an ok callback after the HTML bootstrap moved the URL to a hydratable route', async () => {
159
+ const storage = new MemorySsoStorage();
160
+ const replaceStateCalls: string[] = [];
161
+ const dispatchPopState = jest.fn();
162
+ const hardRedirect = jest.fn();
163
+ const exchangeSsoCode = jest.fn(async (): Promise<SessionLoginResponse> => exchangedSession);
164
+
165
+ storage.setItem(ssoStateKey(ORIGIN), 'state-ok');
166
+ storage.setItem(ssoDestKey(ORIGIN), `${ORIGIN}/explore?tab=home#top`);
167
+ storage.setItem(ssoCallbackBootstrapKey(ORIGIN), '1');
168
+
169
+ const session = await consumeSsoReturn(
170
+ { exchangeSsoCode },
171
+ {
172
+ isWeb: () => true,
173
+ storage,
174
+ location: {
175
+ hash: '#oxy_sso=ok&code=opaque-code&state=state-ok',
176
+ origin: ORIGIN,
177
+ pathname: '/',
178
+ search: '',
179
+ },
180
+ history: {
181
+ replaceState: (_data: unknown, _unused: string, url?: string | URL | null): void => {
182
+ replaceStateCalls.push(String(url ?? ''));
183
+ },
184
+ },
185
+ dispatchPopState,
186
+ hardRedirect,
187
+ },
188
+ );
189
+
190
+ expect(session).toBe(exchangedSession);
191
+ expect(exchangeSsoCode).toHaveBeenCalledWith('opaque-code');
192
+ expect(replaceStateCalls).toEqual(['/', '/explore?tab=home#top']);
193
+ expect(dispatchPopState).toHaveBeenCalledTimes(1);
194
+ expect(hardRedirect).not.toHaveBeenCalled();
195
+ expect(storage.getItem(ssoCallbackBootstrapKey(ORIGIN))).toBeNull();
196
+ expect(storage.getItem(ssoDestKey(ORIGIN))).toBeNull();
197
+ expect(storage.getItem(ssoNoSessionKey(ORIGIN))).toBeNull();
198
+ });
199
+
200
+ it('leaves a bootstrapped none callback with loop breakers set and no exchange', async () => {
201
+ const storage = new MemorySsoStorage();
202
+ const replaceStateCalls: string[] = [];
203
+ const dispatchPopState = jest.fn();
204
+ const hardRedirect = jest.fn();
205
+ const exchangeSsoCode = jest.fn(async (): Promise<SessionLoginResponse> => exchangedSession);
206
+
207
+ storage.setItem(ssoStateKey(ORIGIN), 'state-none');
208
+ storage.setItem(ssoDestKey(ORIGIN), `${ORIGIN}/library`);
209
+ storage.setItem(ssoCallbackBootstrapKey(ORIGIN), '1');
210
+
211
+ const session = await consumeSsoReturn(
212
+ { exchangeSsoCode },
213
+ {
214
+ isWeb: () => true,
215
+ storage,
216
+ location: {
217
+ hash: '#oxy_sso=none&state=state-none',
218
+ origin: ORIGIN,
219
+ pathname: '/',
220
+ search: '',
221
+ },
222
+ history: {
223
+ replaceState: (_data: unknown, _unused: string, url?: string | URL | null): void => {
224
+ replaceStateCalls.push(String(url ?? ''));
225
+ },
226
+ },
227
+ dispatchPopState,
228
+ hardRedirect,
229
+ },
230
+ );
231
+
232
+ expect(session).toBeNull();
233
+ expect(exchangeSsoCode).not.toHaveBeenCalled();
234
+ expect(replaceStateCalls).toEqual(['/']);
235
+ expect(dispatchPopState).not.toHaveBeenCalled();
236
+ expect(hardRedirect).toHaveBeenCalledWith(`${ORIGIN}/library`);
237
+ expect(storage.getItem(ssoCallbackBootstrapKey(ORIGIN))).toBeNull();
238
+ expect(storage.getItem(ssoDestKey(ORIGIN))).toBeNull();
239
+ expect(storage.getItem(ssoNoSessionKey(ORIGIN))).toBe('1');
240
+ expect(storage.getItem(ssoAttemptedKey(ORIGIN))).toBe('1');
241
+ });
242
+
243
+ it('exposes a pre-hydration script that preserves the SSO fragment', () => {
244
+ const script = getSsoCallbackBootstrapScript();
245
+
246
+ expect(script).toContain('/__oxy/sso-callback');
247
+ expect(script).toContain('oxy_sso=');
248
+ expect(script).toContain('window.history.replaceState');
249
+ expect(script).toContain('window.location.hash');
250
+ });
251
+ });