@oxyhq/core 2.3.0 → 2.3.2

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/dist/cjs/index.js CHANGED
@@ -20,7 +20,7 @@
20
20
  Object.defineProperty(exports, "__esModule", { value: true });
21
21
  exports.normalizeColorScheme = exports.normalizeTheme = exports.getContrastTextColor = exports.isLightColor = exports.withOpacity = exports.rgbToHex = exports.hexToRgb = exports.lightenColor = exports.darkenColor = exports.isAndroid = exports.isIOS = exports.isNative = exports.isWeb = exports.setPlatformOS = exports.getPlatformOS = exports.isRTLLocale = exports.normalizeLanguageCode = exports.getNativeLanguageName = exports.getLanguageName = exports.getLanguageMetadata = exports.SUPPORTED_LANGUAGES = exports.TopicSource = exports.TopicType = exports.SECURITY_EVENT_SEVERITY_MAP = exports.DeviceManager = exports.RecoveryPhraseService = exports.SignatureService = exports.IdentityPersistError = exports.IdentityAlreadyExistsError = exports.KeyManager = exports.sessionsArraysEqual = exports.normalizeAndSortSessions = exports.mergeSessions = exports.authenticatedApiCall = exports.withAuthErrorHandling = exports.isAuthenticationError = exports.ensureValidToken = exports.AuthenticationFailedError = exports.SessionSyncRequiredError = exports.OxyAppDataIdentifierError = exports.ServiceCredentialMismatchError = exports.createCrossDomainAuth = exports.CrossDomainAuth = exports.createAuthManager = exports.AuthManager = exports.oxyClient = exports.OXY_CLOUD_URL = exports.OxyAuthenticationTimeoutError = exports.OxyAuthenticationError = exports.OxyServices = void 0;
22
22
  exports.isValidURL = exports.isValidUUID = exports.isValidObject = exports.isValidArray = exports.isRequiredBoolean = exports.isRequiredNumber = exports.isRequiredString = exports.isValidPassword = exports.isValidUsername = exports.isValidEmail = exports.PASSWORD_REGEX = exports.USERNAME_REGEX = exports.EMAIL_REGEX = exports.retryAsync = exports.validateRequiredFields = exports.handleHttpError = exports.createApiError = exports.ErrorCodes = exports.safeJsonParse = exports.buildPaginationParams = exports.buildUrl = exports.buildSearchParams = exports.translate = exports.createDebugLogger = exports.debugError = exports.debugWarn = exports.debugLog = exports.isDev = exports.withRetry = exports.delay = exports.shouldAllowRequest = exports.recordSuccess = exports.recordFailure = exports.calculateBackoffInterval = exports.createCircuitBreakerState = exports.DEFAULT_CIRCUIT_BREAKER_CONFIG = exports.isRetryableError = exports.isNetworkError = exports.isServerError = exports.isRateLimitError = exports.isNotFoundError = exports.isForbiddenError = exports.isUnauthorizedError = exports.isAlreadyRegisteredError = exports.getErrorMessage = exports.getErrorStatus = exports.HttpStatus = exports.getSystemColorScheme = exports.systemPrefersDarkMode = exports.getOppositeTheme = void 0;
23
- exports.packageInfo = exports.runColdBoot = exports.guardActive = exports.isCentralIdPOrigin = exports.buildSsoBounceUrl = exports.ssoNavigate = exports.ssoNoSessionKey = exports.ssoDestKey = exports.ssoGuardKey = exports.ssoStateKey = exports.SSO_GUARD_TTL_MS = exports.SSO_CALLBACK_PATH = exports.generateSsoState = exports.consumeSsoReturn = exports.parseSsoReturnFragment = exports.resolveCentralAuthUrl = exports.CENTRAL_IDP_APEX = exports.CENTRAL_AUTH_URL = exports.MULTIPART_TLDS = exports.registrableApex = exports.autoDetectAuthWebUrl = exports.getAccountColor = exports.mergeAccountsFromRefreshAll = exports.formatPublicKeyHandle = exports.getAccountFallbackHandle = exports.getAccountDisplayName = exports.createQuickAccount = exports.buildAccountsArray = exports.updateAvatarVisibility = exports.logPerformance = exports.logPayment = exports.logDevice = exports.logUser = exports.logSession = exports.logApi = exports.logAuth = exports.LogLevel = exports.logger = exports.validateAndSanitizeUserInput = exports.isValidObjectId = exports.sanitizeHTML = exports.sanitizeString = exports.isValidFileType = exports.isValidFileSize = exports.isValidDate = void 0;
23
+ exports.packageInfo = exports.runColdBoot = exports.guardActive = exports.isCentralIdPOrigin = exports.buildSsoBounceUrl = exports.ssoNavigate = exports.ssoAttemptedKey = exports.ssoNoSessionKey = exports.ssoDestKey = exports.ssoGuardKey = exports.ssoStateKey = exports.SSO_GUARD_TTL_MS = exports.SSO_CALLBACK_PATH = exports.generateSsoState = exports.consumeSsoReturn = exports.parseSsoReturnFragment = exports.resolveCentralAuthUrl = exports.CENTRAL_IDP_APEX = exports.CENTRAL_AUTH_URL = exports.MULTIPART_TLDS = exports.registrableApex = exports.autoDetectAuthWebUrl = exports.getAccountColor = exports.mergeAccountsFromRefreshAll = exports.formatPublicKeyHandle = exports.getAccountFallbackHandle = exports.getAccountDisplayName = exports.createQuickAccount = exports.buildAccountsArray = exports.updateAvatarVisibility = exports.logPerformance = exports.logPayment = exports.logDevice = exports.logUser = exports.logSession = exports.logApi = exports.logAuth = exports.LogLevel = exports.logger = exports.validateAndSanitizeUserInput = exports.isValidObjectId = exports.sanitizeHTML = exports.sanitizeString = exports.isValidFileType = exports.isValidFileSize = exports.isValidDate = void 0;
24
24
  // Ensure crypto polyfills are loaded before anything else
25
25
  require("./crypto/polyfill");
26
26
  // ---------------------------------------------------------------------------
@@ -250,6 +250,7 @@ Object.defineProperty(exports, "ssoStateKey", { enumerable: true, get: function
250
250
  Object.defineProperty(exports, "ssoGuardKey", { enumerable: true, get: function () { return ssoBounce_1.ssoGuardKey; } });
251
251
  Object.defineProperty(exports, "ssoDestKey", { enumerable: true, get: function () { return ssoBounce_1.ssoDestKey; } });
252
252
  Object.defineProperty(exports, "ssoNoSessionKey", { enumerable: true, get: function () { return ssoBounce_1.ssoNoSessionKey; } });
253
+ Object.defineProperty(exports, "ssoAttemptedKey", { enumerable: true, get: function () { return ssoBounce_1.ssoAttemptedKey; } });
253
254
  Object.defineProperty(exports, "ssoNavigate", { enumerable: true, get: function () { return ssoBounce_1.ssoNavigate; } });
254
255
  Object.defineProperty(exports, "buildSsoBounceUrl", { enumerable: true, get: function () { return ssoBounce_1.buildSsoBounceUrl; } });
255
256
  Object.defineProperty(exports, "isCentralIdPOrigin", { enumerable: true, get: function () { return ssoBounce_1.isCentralIdPOrigin; } });
@@ -27,11 +27,15 @@
27
27
  * the session, then restores the original destination.
28
28
  *
29
29
  * Loop proof (logged-out): first load all steps skip → `sso-bounce` sets
30
- * guard/state/dest and navigates; the IdP (no central session) returns
30
+ * guard/state/dest + the outcome-independent attempted-flag
31
+ * ({@link ssoAttemptedKey}) and navigates; the IdP (no central session) returns
31
32
  * `#oxy_sso=none`; the callback load's `sso-return` sees `none`, sets the
32
33
  * NO_SESSION flag ({@link ssoNoSessionKey}), and `sso-bounce` is then disabled.
33
34
  * Exactly ONE bounce, no loop. An interrupted bounce (user hit back
34
35
  * mid-redirect) self-heals once the {@link SSO_GUARD_TTL_MS} guard TTL lapses.
36
+ * The attempted-flag is the definitive, outcome-INDEPENDENT loop breaker: it is
37
+ * set pre-bounce so even if the return-side NO_SESSION write never lands, the
38
+ * bounce can never re-fire this tab after the self-heal TTL lapses.
35
39
  *
36
40
  * All state lives in `sessionStorage` (per tab, cleared on tab close) and is
37
41
  * keyed per-origin so two RPs hosted in the same browser never collide. The
@@ -44,6 +48,7 @@ exports.ssoStateKey = ssoStateKey;
44
48
  exports.ssoGuardKey = ssoGuardKey;
45
49
  exports.ssoDestKey = ssoDestKey;
46
50
  exports.ssoNoSessionKey = ssoNoSessionKey;
51
+ exports.ssoAttemptedKey = ssoAttemptedKey;
47
52
  exports.ssoNavigate = ssoNavigate;
48
53
  exports.buildSsoBounceUrl = buildSsoBounceUrl;
49
54
  exports.isCentralIdPOrigin = isCentralIdPOrigin;
@@ -69,6 +74,7 @@ const STATE_KEY_PREFIX = 'oxy_sso_state:';
69
74
  const GUARD_KEY_PREFIX = 'oxy_sso_guard:';
70
75
  const DEST_KEY_PREFIX = 'oxy_sso_dest:';
71
76
  const NO_SESSION_KEY_PREFIX = 'oxy_sso_no_session:';
77
+ const ATTEMPTED_KEY_PREFIX = 'oxy_sso_attempted:';
72
78
  /** Per-origin CSRF state key (matched on return to defeat fragment forgery). */
73
79
  function ssoStateKey(origin) {
74
80
  return `${STATE_KEY_PREFIX}${origin}`;
@@ -89,6 +95,18 @@ function ssoDestKey(origin) {
89
95
  function ssoNoSessionKey(origin) {
90
96
  return `${NO_SESSION_KEY_PREFIX}${origin}`;
91
97
  }
98
+ /**
99
+ * Per-origin, OUTCOME-INDEPENDENT once-guard. Set in `sessionStorage` BEFORE
100
+ * the terminal SSO bounce navigates. Gates the bounce so the silent
101
+ * cross-domain probe fires AT MOST ONCE per tab session — independent of
102
+ * whether the return-side NO_SESSION flag ever lands. The definitive loop
103
+ * breaker; survives the 30s self-heal `ssoGuardKey` TTL. Cleared only on an
104
+ * explicit sign-out/clear so a later cold boot (after the user signs in
105
+ * centrally) can probe again.
106
+ */
107
+ function ssoAttemptedKey(origin) {
108
+ return `${ATTEMPTED_KEY_PREFIX}${origin}`;
109
+ }
92
110
  /**
93
111
  * Perform the terminal top-level SSO bounce navigation.
94
112
  *
@@ -90,14 +90,20 @@ function parseSsoReturnFragment(hash) {
90
90
  * or a `Referer` header even if a later step throws.
91
91
  * - `state` must match (CSRF). A mismatch or a missing code sets the
92
92
  * NO_SESSION flag so `sso-bounce` is disabled (no rebounce loop).
93
- * - `none`/`error` outcomes set the NO_SESSION flag (the load2 half of the
94
- * loop proof).
93
+ * - `none`/`error` outcomes set BOTH the NO_SESSION flag and the
94
+ * outcome-independent attempted-flag (the load2 half of the loop proof).
95
95
  * - A throwing exchange is caught, reported via `onExchangeError`, and
96
96
  * treated exactly like "no session" (never loops, never rethrows).
97
- * - After a successful exchange landing on {@link SSO_CALLBACK_PATH}, the real
98
- * destination is restored from the DEST key same-origin only (an
99
- * attacker-planted cross-origin or relative-evil dest is rejected). The
100
- * DEST key is removed unconditionally.
97
+ * - On EVERY consumed outcome (ok, none, error, state-mismatch, no-code,
98
+ * failed-exchange, no-sessionId) not just okif the page landed on
99
+ * {@link SSO_CALLBACK_PATH}, the real pre-bounce destination is restored
100
+ * from the DEST key so the user is never stranded on the internal callback
101
+ * path. Same-origin only (an attacker-planted cross-origin or relative-evil
102
+ * dest is rejected). The DEST key is removed unconditionally.
103
+ * - After a same-origin dest restore (which uses `history.replaceState`, that
104
+ * does NOT itself emit `popstate`), a synthetic `popstate` is dispatched so
105
+ * URL-driven routers (Expo Router / React Navigation web) re-sync to the
106
+ * restored route. It is NOT dispatched when the dest is rejected/absent.
101
107
  *
102
108
  * Total: this function NEVER throws. Off-web it is a no-op returning `null`.
103
109
  *
@@ -116,6 +122,21 @@ async function consumeSsoReturn(oxy, deps = {}) {
116
122
  const location = deps.location ?? window.location;
117
123
  const history = deps.history ?? window.history;
118
124
  const onExchangeError = deps.onExchangeError;
125
+ // Default: emit a synthetic `popstate` so URL-driven routers re-sync after a
126
+ // `history.replaceState` (which does NOT emit `popstate` on its own). Feature-
127
+ // detected end to end so it never throws in any environment.
128
+ const dispatchPopState = deps.dispatchPopState ??
129
+ (() => {
130
+ if (typeof window === 'undefined' || typeof window.dispatchEvent !== 'function') {
131
+ return;
132
+ }
133
+ if (typeof PopStateEvent !== 'undefined') {
134
+ window.dispatchEvent(new PopStateEvent('popstate'));
135
+ }
136
+ else if (typeof Event !== 'undefined') {
137
+ window.dispatchEvent(new Event('popstate'));
138
+ }
139
+ });
119
140
  const ret = parseSsoReturnFragment(location.hash);
120
141
  if (!ret) {
121
142
  // Not an oxy_sso fragment — nothing to do (do NOT touch any flags).
@@ -133,17 +154,47 @@ async function consumeSsoReturn(oxy, deps = {}) {
133
154
  storage.removeItem((0, ssoBounce_1.ssoGuardKey)(origin));
134
155
  const markNoSession = () => {
135
156
  storage.setItem((0, ssoBounce_1.ssoNoSessionKey)(origin), '1');
157
+ // A return was consumed, so the probe definitively happened. Set the
158
+ // outcome-independent attempted-flag too so the bounce can never re-fire
159
+ // even if some consumer path skipped setting it pre-bounce.
160
+ storage.setItem((0, ssoBounce_1.ssoAttemptedKey)(origin), '1');
161
+ };
162
+ // Restore the user's real pre-bounce destination so they are never stranded
163
+ // on the internal callback path — invoked on EVERY consumed outcome, not just
164
+ // success. Same-origin only — never honour a cross-origin/protocol-relative
165
+ // dest that could have been planted to redirect the user. The DEST key is
166
+ // removed unconditionally. After a successful same-origin restore a synthetic
167
+ // `popstate` is dispatched so URL-driven routers re-sync.
168
+ const restoreDest = () => {
169
+ if (location.pathname === ssoBounce_1.SSO_CALLBACK_PATH) {
170
+ const dest = storage.getItem((0, ssoBounce_1.ssoDestKey)(origin));
171
+ if (dest) {
172
+ try {
173
+ const destUrl = new URL(dest, origin);
174
+ if (destUrl.origin === origin) {
175
+ history.replaceState(null, '', destUrl.pathname + destUrl.search + destUrl.hash);
176
+ dispatchPopState();
177
+ }
178
+ }
179
+ catch {
180
+ // Malformed stored destination — leave the URL on the callback path.
181
+ }
182
+ }
183
+ }
184
+ storage.removeItem((0, ssoBounce_1.ssoDestKey)(origin));
136
185
  };
137
186
  if (ret.kind === 'none' || ret.kind === 'error') {
138
187
  // The central IdP had no session (or the bounce failed). Record it so we do
139
188
  // not bounce again this tab — the definitive loop breaker.
140
189
  markNoSession();
190
+ restoreDest();
141
191
  return null;
142
192
  }
143
193
  if (!stateOk || !ret.code) {
144
194
  // Forged / replayed / stale fragment, or a malformed ok with no code. Treat
145
195
  // exactly like "no session": never exchange, never loop.
146
196
  markNoSession();
197
+ restoreDest();
147
198
  return null;
148
199
  }
149
200
  let session;
@@ -153,31 +204,14 @@ async function consumeSsoReturn(oxy, deps = {}) {
153
204
  catch (error) {
154
205
  onExchangeError?.(error);
155
206
  markNoSession();
207
+ restoreDest();
156
208
  return null;
157
209
  }
158
210
  if (!session?.sessionId) {
159
211
  markNoSession();
212
+ restoreDest();
160
213
  return null;
161
214
  }
162
- // If we landed on the internal callback path, restore the user's real
163
- // destination (captured at bounce time). Same-origin only — never honour a
164
- // cross-origin destination that could have been planted to redirect the
165
- // freshly signed-in user. `new URL(dest, origin)` tolerates relative dests
166
- // and is still re-checked against the page origin.
167
- if (location.pathname === ssoBounce_1.SSO_CALLBACK_PATH) {
168
- const dest = storage.getItem((0, ssoBounce_1.ssoDestKey)(origin));
169
- if (dest) {
170
- try {
171
- const destUrl = new URL(dest, origin);
172
- if (destUrl.origin === origin) {
173
- history.replaceState(null, '', destUrl.pathname + destUrl.search + destUrl.hash);
174
- }
175
- }
176
- catch {
177
- // Malformed stored destination — leave the URL on the callback path.
178
- }
179
- }
180
- }
181
- storage.removeItem((0, ssoBounce_1.ssoDestKey)(origin));
215
+ restoreDest();
182
216
  return session;
183
217
  }