@oxyhq/core 1.11.21 → 1.11.23
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 +6 -2
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/HttpService.js +52 -0
- package/dist/cjs/OxyServices.base.js +16 -0
- package/dist/cjs/mixins/OxyServices.fedcm.js +0 -80
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/HttpService.js +52 -0
- package/dist/esm/OxyServices.base.js +16 -0
- package/dist/esm/mixins/OxyServices.fedcm.js +0 -79
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/HttpService.d.ts +31 -0
- package/dist/types/OxyServices.base.d.ts +14 -0
- 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 +1 -0
- package/dist/types/mixins/OxyServices.auth.d.ts +1 -0
- 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 +6 -1
- package/dist/types/mixins/OxyServices.fedcm.d.ts +1 -24
- 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 +1 -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 +1 -0
- package/dist/types/mixins/OxyServices.utility.d.ts +1 -0
- package/package.json +1 -1
- package/src/HttpService.ts +53 -0
- package/src/OxyServices.base.ts +17 -0
- package/src/mixins/OxyServices.fedcm.ts +0 -83
- package/src/mixins/__tests__/fedcm.test.ts +0 -182
- package/src/mixins/__tests__/onTokensChanged.test.ts +130 -0
package/dist/esm/HttpService.js
CHANGED
|
@@ -106,6 +106,16 @@ export class HttpService {
|
|
|
106
106
|
this.tokenRefreshPromise = null;
|
|
107
107
|
this.tokenRefreshCooldownUntil = 0;
|
|
108
108
|
this._onTokenRefreshed = null;
|
|
109
|
+
/**
|
|
110
|
+
* Fan-out listeners notified on EVERY access-token change on this instance:
|
|
111
|
+
* explicit `setTokens`, `clearTokens`, a successful silent refresh, and the
|
|
112
|
+
* internal 401-driven clear. Unlike the single-slot `_onTokenRefreshed`
|
|
113
|
+
* (owned by AuthManager for the refresh path only), this is a Set so multiple
|
|
114
|
+
* independent observers can mirror token state without clobbering each other.
|
|
115
|
+
*
|
|
116
|
+
* Each listener receives the resulting access token, or `null` when cleared.
|
|
117
|
+
*/
|
|
118
|
+
this._tokenChangeListeners = new Set();
|
|
109
119
|
// Acting-as identity for managed accounts
|
|
110
120
|
this._actingAsUserId = null;
|
|
111
121
|
// Performance monitoring
|
|
@@ -309,6 +319,7 @@ export class HttpService {
|
|
|
309
319
|
// Refresh failed or no token — clear tokens and stale CSRF
|
|
310
320
|
this.tokenStore.clearTokens();
|
|
311
321
|
this.tokenStore.clearCsrfToken();
|
|
322
|
+
this.notifyTokenChange();
|
|
312
323
|
}
|
|
313
324
|
// On 403 with CSRF error, clear cached token and retry once
|
|
314
325
|
if (response.status === 403 && !config._isCsrfRetry) {
|
|
@@ -688,6 +699,7 @@ export class HttpService {
|
|
|
688
699
|
const { accessToken: newToken } = await response.json();
|
|
689
700
|
this.tokenStore.setTokens(newToken);
|
|
690
701
|
this._onTokenRefreshed?.(newToken);
|
|
702
|
+
this.notifyTokenChange();
|
|
691
703
|
this.logger.debug('Token refreshed');
|
|
692
704
|
return `Bearer ${newToken}`;
|
|
693
705
|
}
|
|
@@ -753,6 +765,7 @@ export class HttpService {
|
|
|
753
765
|
// Token management
|
|
754
766
|
setTokens(accessToken, refreshToken = '') {
|
|
755
767
|
this.tokenStore.setTokens(accessToken, refreshToken);
|
|
768
|
+
this.notifyTokenChange();
|
|
756
769
|
}
|
|
757
770
|
set onTokenRefreshed(callback) {
|
|
758
771
|
this._onTokenRefreshed = callback;
|
|
@@ -760,6 +773,45 @@ export class HttpService {
|
|
|
760
773
|
clearTokens() {
|
|
761
774
|
this.tokenStore.clearTokens();
|
|
762
775
|
this.tokenStore.clearCsrfToken();
|
|
776
|
+
this.notifyTokenChange();
|
|
777
|
+
}
|
|
778
|
+
/**
|
|
779
|
+
* Subscribe to access-token changes on this instance.
|
|
780
|
+
*
|
|
781
|
+
* Fires on every mutation of the access token — `setTokens`, `clearTokens`,
|
|
782
|
+
* a successful silent refresh, and the internal 401-driven clear — passing
|
|
783
|
+
* the resulting token (or `null` when cleared). Returns an unsubscribe
|
|
784
|
+
* function; call it on teardown to avoid leaks.
|
|
785
|
+
*
|
|
786
|
+
* This is the single hook downstream code (e.g. @oxyhq/services' OxyProvider)
|
|
787
|
+
* uses to keep an external token sink — such as the shared `oxyClient`
|
|
788
|
+
* singleton — in lockstep with the active session, regardless of which code
|
|
789
|
+
* path mutated the token.
|
|
790
|
+
*/
|
|
791
|
+
addTokenChangeListener(listener) {
|
|
792
|
+
this._tokenChangeListeners.add(listener);
|
|
793
|
+
return () => {
|
|
794
|
+
this._tokenChangeListeners.delete(listener);
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
/**
|
|
798
|
+
* Notify all token-change listeners with the current access token.
|
|
799
|
+
* Listener exceptions are isolated so one bad subscriber cannot break token
|
|
800
|
+
* propagation to the others or to the calling auth flow.
|
|
801
|
+
* @internal
|
|
802
|
+
*/
|
|
803
|
+
notifyTokenChange() {
|
|
804
|
+
if (this._tokenChangeListeners.size === 0)
|
|
805
|
+
return;
|
|
806
|
+
const accessToken = this.tokenStore.getAccessToken();
|
|
807
|
+
for (const listener of this._tokenChangeListeners) {
|
|
808
|
+
try {
|
|
809
|
+
listener(accessToken);
|
|
810
|
+
}
|
|
811
|
+
catch (error) {
|
|
812
|
+
this.logger.error('Token change listener threw:', error);
|
|
813
|
+
}
|
|
814
|
+
}
|
|
763
815
|
}
|
|
764
816
|
getAccessToken() {
|
|
765
817
|
return this.tokenStore.getAccessToken();
|
|
@@ -110,6 +110,22 @@ export class OxyServicesBase {
|
|
|
110
110
|
this._cachedUserId = undefined;
|
|
111
111
|
this._cachedAccessToken = null;
|
|
112
112
|
}
|
|
113
|
+
/**
|
|
114
|
+
* Subscribe to access-token changes on this client.
|
|
115
|
+
*
|
|
116
|
+
* The listener fires on every access-token mutation — explicit
|
|
117
|
+
* `setTokens`/`clearTokens`, a successful silent refresh, and the internal
|
|
118
|
+
* 401-driven clear — receiving the resulting token, or `null` when cleared.
|
|
119
|
+
* Returns an unsubscribe function.
|
|
120
|
+
*
|
|
121
|
+
* Primary use: keeping an external token sink (e.g. the shared `oxyClient`
|
|
122
|
+
* singleton) in lockstep with whichever `OxyServices` instance actually owns
|
|
123
|
+
* the session, so imperative consumers reading the singleton always observe
|
|
124
|
+
* the live token regardless of the code path that changed it.
|
|
125
|
+
*/
|
|
126
|
+
onTokensChanged(listener) {
|
|
127
|
+
return this.httpService.addTokenChangeListener(listener);
|
|
128
|
+
}
|
|
113
129
|
/**
|
|
114
130
|
* Get the current user ID from the access token.
|
|
115
131
|
* Caches the decoded value and invalidates when the token changes.
|
|
@@ -39,44 +39,6 @@ const FEDCM_LOGIN_HINT_KEY = 'oxy_fedcm_login_hint';
|
|
|
39
39
|
let fedCMRequestInProgress = false;
|
|
40
40
|
let fedCMRequestPromise = null;
|
|
41
41
|
let currentMediationMode = null;
|
|
42
|
-
/**
|
|
43
|
-
* Page-load-persistent memo for SILENT FedCM sign-in.
|
|
44
|
-
*
|
|
45
|
-
* Silent SSO (`mediation: 'silent'`) is the one FedCM flow that runs WITHOUT a
|
|
46
|
-
* user gesture — on app startup / provider mount. Multiple consumers
|
|
47
|
-
* (`@oxyhq/auth`'s `WebOxyProvider` / `useWebSSO`, `@oxyhq/services`'
|
|
48
|
-
* `useWebSSO`) can each mount and trigger it, and a remount storm (route churn,
|
|
49
|
-
* React StrictMode double-invoke, error-boundary recovery) previously turned
|
|
50
|
-
* into a `navigator.credentials.get` storm. This memo collapses every silent
|
|
51
|
-
* attempt for a given `origin + baseURL` into AT MOST ONE browser credential
|
|
52
|
-
* request per page load:
|
|
53
|
-
*
|
|
54
|
-
* - the FIRST silent call runs the real flow and stores its in-flight promise;
|
|
55
|
-
* - concurrent silent calls share that same in-flight promise;
|
|
56
|
-
* - once it settles, the memo retains the resolved value (a session OR `null`)
|
|
57
|
-
* and every subsequent silent call returns it WITHOUT re-invoking the
|
|
58
|
-
* browser.
|
|
59
|
-
*
|
|
60
|
-
* Keyed on `origin + baseURL` (not the OxyServices instance) so it survives
|
|
61
|
-
* instance churn across remounts. Intentionally never cleared: only a fresh
|
|
62
|
-
* page load — which starts a fresh module scope — can change the IdP session
|
|
63
|
-
* state that silent mediation observes.
|
|
64
|
-
*
|
|
65
|
-
* This guard is SILENT-ONLY. Interactive flows (`signInWithFedCM`,
|
|
66
|
-
* `mediation: 'optional'|'required'`, `mode: 'active'|'passive'`) must always
|
|
67
|
-
* be able to re-prompt and are never memoized here.
|
|
68
|
-
*/
|
|
69
|
-
const silentSSOMemo = new Map();
|
|
70
|
-
/**
|
|
71
|
-
* Test-only reset of the page-load silent-SSO memo. The memo is module-scoped
|
|
72
|
-
* and never cleared at runtime (a fresh page load resets it naturally), but
|
|
73
|
-
* tests sharing one module instance need to start from a clean slate.
|
|
74
|
-
*
|
|
75
|
-
* @internal
|
|
76
|
-
*/
|
|
77
|
-
export function __resetSilentSSOMemoForTests() {
|
|
78
|
-
silentSSOMemo.clear();
|
|
79
|
-
}
|
|
80
42
|
/**
|
|
81
43
|
* Federated Credential Management (FedCM) Authentication Mixin
|
|
82
44
|
*
|
|
@@ -248,47 +210,6 @@ export function OxyServicesFedCMMixin(Base) {
|
|
|
248
210
|
debug.log('Silent SSO: FedCM not supported in this browser');
|
|
249
211
|
return null;
|
|
250
212
|
}
|
|
251
|
-
// Page-load run-once guard. The first silent attempt for this
|
|
252
|
-
// origin + API runs; concurrent callers share the in-flight promise; once
|
|
253
|
-
// it settles, every later caller gets the memoized result (session OR
|
|
254
|
-
// null) WITHOUT re-invoking `navigator.credentials.get`. This is the single
|
|
255
|
-
// chokepoint for silent SSO across all consumers and remounts.
|
|
256
|
-
const memoKey = this.silentSSOMemoKey();
|
|
257
|
-
const existing = silentSSOMemo.get(memoKey);
|
|
258
|
-
if (existing) {
|
|
259
|
-
debug.log('Silent SSO: Returning memoized page-load result (no re-invocation)');
|
|
260
|
-
return existing;
|
|
261
|
-
}
|
|
262
|
-
const attempt = this._performSilentSignInWithFedCM();
|
|
263
|
-
silentSSOMemo.set(memoKey, attempt);
|
|
264
|
-
return attempt;
|
|
265
|
-
}
|
|
266
|
-
/**
|
|
267
|
-
* Build the page-load silent-SSO memo key from the current origin and the
|
|
268
|
-
* configured API base URL. Two providers pointed at the same API from the
|
|
269
|
-
* same origin share a single silent attempt per page load.
|
|
270
|
-
*
|
|
271
|
-
* @internal
|
|
272
|
-
*/
|
|
273
|
-
silentSSOMemoKey() {
|
|
274
|
-
const origin = typeof window !== 'undefined' ? window.location.origin : 'no-origin';
|
|
275
|
-
let baseURL = '';
|
|
276
|
-
try {
|
|
277
|
-
baseURL = this.getBaseURL();
|
|
278
|
-
}
|
|
279
|
-
catch {
|
|
280
|
-
baseURL = '';
|
|
281
|
-
}
|
|
282
|
-
return `${origin}|${baseURL}`;
|
|
283
|
-
}
|
|
284
|
-
/**
|
|
285
|
-
* Perform the actual silent FedCM sign-in. Always wrapped by
|
|
286
|
-
* {@link silentSignInWithFedCM}'s page-load memo — never call this directly
|
|
287
|
-
* (doing so bypasses the run-once guard).
|
|
288
|
-
*
|
|
289
|
-
* @internal
|
|
290
|
-
*/
|
|
291
|
-
async _performSilentSignInWithFedCM() {
|
|
292
213
|
const clientId = this.getClientId();
|
|
293
214
|
debug.log('Silent SSO: Starting for', clientId);
|
|
294
215
|
// Only try silent mediation (no UI) - works if user previously consented.
|