@oxyhq/core 1.11.13 → 1.11.15

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
@@ -29,8 +29,8 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
29
29
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
30
30
  };
31
31
  Object.defineProperty(exports, "__esModule", { value: true });
32
- 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 = 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.normalizeLanguageCode = exports.getNativeLanguageName = exports.getLanguageName = exports.getLanguageMetadata = exports.SUPPORTED_LANGUAGES = exports.DeviceManager = exports.TopicSource = exports.TopicType = exports.IdentityPersistError = exports.IdentityAlreadyExistsError = exports.RecoveryPhraseService = exports.SignatureService = exports.KeyManager = exports.createCrossDomainAuth = exports.CrossDomainAuth = exports.createAuthManager = exports.AuthManager = exports.oxyClient = exports.OXY_CLOUD_URL = exports.OxyAuthenticationTimeoutError = exports.OxyAuthenticationError = exports.OxyServices = void 0;
33
- 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.retryAsync = exports.validateRequiredFields = exports.handleHttpError = exports.createApiError = exports.ErrorCodes = exports.packageInfo = exports.sessionsArraysEqual = exports.normalizeAndSortSessions = exports.mergeSessions = exports.authenticatedApiCall = exports.withAuthErrorHandling = exports.isAuthenticationError = exports.ensureValidToken = exports.AuthenticationFailedError = exports.SessionSyncRequiredError = 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 = void 0;
32
+ exports.isRateLimitError = exports.isNotFoundError = exports.isForbiddenError = exports.isUnauthorizedError = exports.isAlreadyRegisteredError = exports.getErrorMessage = exports.getErrorStatus = exports.HttpStatus = exports.getSystemColorScheme = exports.systemPrefersDarkMode = exports.getOppositeTheme = 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.DeviceManager = exports.TopicSource = exports.TopicType = exports.IdentityPersistError = exports.IdentityAlreadyExistsError = exports.RecoveryPhraseService = exports.SignatureService = exports.KeyManager = exports.ServiceCredentialMismatchError = exports.createCrossDomainAuth = exports.CrossDomainAuth = exports.createAuthManager = exports.AuthManager = exports.oxyClient = exports.OXY_CLOUD_URL = exports.OxyAuthenticationTimeoutError = exports.OxyAuthenticationError = exports.OxyServices = void 0;
33
+ 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.retryAsync = exports.validateRequiredFields = exports.handleHttpError = exports.createApiError = exports.ErrorCodes = exports.packageInfo = exports.sessionsArraysEqual = exports.normalizeAndSortSessions = exports.mergeSessions = exports.authenticatedApiCall = exports.withAuthErrorHandling = exports.isAuthenticationError = exports.ensureValidToken = exports.AuthenticationFailedError = exports.SessionSyncRequiredError = 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 = void 0;
34
34
  // Ensure crypto polyfills are loaded before anything else
35
35
  require("./crypto/polyfill");
36
36
  // --- Core API Client ---
@@ -48,6 +48,8 @@ Object.defineProperty(exports, "createAuthManager", { enumerable: true, get: fun
48
48
  var CrossDomainAuth_1 = require("./CrossDomainAuth");
49
49
  Object.defineProperty(exports, "CrossDomainAuth", { enumerable: true, get: function () { return CrossDomainAuth_1.CrossDomainAuth; } });
50
50
  Object.defineProperty(exports, "createCrossDomainAuth", { enumerable: true, get: function () { return CrossDomainAuth_1.createCrossDomainAuth; } });
51
+ var OxyServices_auth_1 = require("./mixins/OxyServices.auth");
52
+ Object.defineProperty(exports, "ServiceCredentialMismatchError", { enumerable: true, get: function () { return OxyServices_auth_1.ServiceCredentialMismatchError; } });
51
53
  // --- Crypto / Identity ---
52
54
  var crypto_1 = require("./crypto");
53
55
  Object.defineProperty(exports, "KeyManager", { enumerable: true, get: function () { return crypto_1.KeyManager; } });
@@ -71,6 +73,7 @@ Object.defineProperty(exports, "getLanguageMetadata", { enumerable: true, get: f
71
73
  Object.defineProperty(exports, "getLanguageName", { enumerable: true, get: function () { return languageUtils_1.getLanguageName; } });
72
74
  Object.defineProperty(exports, "getNativeLanguageName", { enumerable: true, get: function () { return languageUtils_1.getNativeLanguageName; } });
73
75
  Object.defineProperty(exports, "normalizeLanguageCode", { enumerable: true, get: function () { return languageUtils_1.normalizeLanguageCode; } });
76
+ Object.defineProperty(exports, "isRTLLocale", { enumerable: true, get: function () { return languageUtils_1.isRTLLocale; } });
74
77
  // --- Platform Detection ---
75
78
  var platform_1 = require("./utils/platform");
76
79
  Object.defineProperty(exports, "getPlatformOS", { enumerable: true, get: function () { return platform_1.getPlatformOS; } });
@@ -1,43 +1,92 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ServiceCredentialMismatchError = void 0;
3
4
  exports.OxyServicesAuthMixin = OxyServicesAuthMixin;
4
5
  const OxyServices_errors_1 = require("../OxyServices.errors");
6
+ const platformCrypto_1 = require("../utils/platformCrypto");
7
+ const loggerUtils_1 = require("../utils/loggerUtils");
8
+ /**
9
+ * Sentinel error raised when getServiceToken() is called with a known apiKey
10
+ * but a non-matching secret. Indicates either credential drift in the caller
11
+ * or a cross-tenant cache lookup attempt. Surface as a 401-equivalent.
12
+ */
13
+ class ServiceCredentialMismatchError extends Error {
14
+ constructor() {
15
+ super('Service credential mismatch: provided secret does not match the secret stored for this apiKey');
16
+ this.name = 'ServiceCredentialMismatchError';
17
+ }
18
+ }
19
+ exports.ServiceCredentialMismatchError = ServiceCredentialMismatchError;
5
20
  function OxyServicesAuthMixin(Base) {
6
21
  return class extends Base {
7
22
  constructor(...args) {
8
23
  super(...args);
9
- /** @internal */ this._serviceToken = null;
10
- /** @internal */ this._serviceTokenExp = 0;
11
- /** @internal */ this._serviceApiKey = null;
12
- /** @internal */ this._serviceApiSecret = null;
13
24
  /**
14
- * In-flight promise for service token fetch. Used to deduplicate concurrent
15
- * calls to getServiceToken() — pattern mirrors AuthManager.refreshToken().
25
+ * Per-credential token cache.
26
+ *
27
+ * Keyed by SHA-256(apiKey). Each entry carries:
28
+ * - the issued service JWT
29
+ * - its expiry timestamp
30
+ * - the secret that produced it (Buffer for constant-time compare)
31
+ * - an optional in-flight promise to deduplicate concurrent refreshes
32
+ *
33
+ * The previous implementation kept ONE token/exp pair per OxyServices
34
+ * instance. That meant calling `getServiceToken(keyA, secretA)` populated
35
+ * the cache, and a subsequent `getServiceToken(keyB, secretB)` (different
36
+ * tenant) would receive tenant A's token. This is fixed by routing every
37
+ * lookup through the Map.
38
+ *
16
39
  * @internal
17
40
  */
18
- this._serviceTokenPromise = null;
41
+ this._serviceTokenCache = new Map();
42
+ /** @internal Raw apiKey stored by configureServiceAuth() for use by getServiceToken() */
43
+ this._serviceApiKey = null;
44
+ /** @internal Raw apiSecret stored by configureServiceAuth() for use by getServiceToken() */
45
+ this._serviceApiSecret = null;
46
+ }
47
+ /**
48
+ * Hash an apiKey into a stable Map cache key. Uses Node's SHA-256 — service
49
+ * tokens are only ever issued by a Node host (the SDK on web/RN never has
50
+ * the apiSecret in the first place), so we can rely on Node crypto here.
51
+ *
52
+ * @internal
53
+ */
54
+ async _hashApiKey(apiKey) {
55
+ const nodeCrypto = await (0, platformCrypto_1.loadNodeCrypto)();
56
+ return nodeCrypto.createHash('sha256').update(apiKey).digest('hex');
19
57
  }
20
58
  /**
21
59
  * Configure service credentials for internal service-to-service communication.
22
60
  * Call this once at startup so that getServiceToken() and makeServiceRequest()
23
61
  * can automatically obtain and refresh tokens.
24
62
  *
63
+ * Calling this with credentials that differ from a previously-configured pair
64
+ * is allowed — each `(apiKey, apiSecret)` pair is cached independently, so
65
+ * legitimate multi-tenant hosts that need to switch credentials cannot leak
66
+ * one tenant's token to another tenant on the same instance.
67
+ *
25
68
  * @param apiKey - DeveloperApp API key (oxy_dk_*)
26
69
  * @param apiSecret - DeveloperApp API secret
27
70
  */
28
71
  configureServiceAuth(apiKey, apiSecret) {
29
72
  this._serviceApiKey = apiKey;
30
73
  this._serviceApiSecret = apiSecret;
31
- // Invalidate any cached token
32
- this._serviceToken = null;
33
- this._serviceTokenExp = 0;
34
74
  }
35
75
  /**
36
76
  * Get a service token for internal service-to-service communication.
37
- * Tokens are short-lived (1h) and automatically cached/refreshed.
77
+ * Tokens are short-lived (1h) and automatically cached/refreshed per
78
+ * `(apiKey, apiSecret)` pair.
79
+ *
80
+ * Concurrent callers for the same credential pair share a single in-flight
81
+ * request to avoid hammering `/auth/service-token` when the cache is empty
82
+ * or expired.
38
83
  *
39
- * Concurrent callers share a single in-flight request to avoid hammering
40
- * `/auth/service-token` when the cache is empty or expired.
84
+ * **Security guarantee:** if the cache already holds a token for this
85
+ * apiKey but the supplied apiSecret does not constant-time match the
86
+ * secret that originally produced that token, this method throws
87
+ * `ServiceCredentialMismatchError` instead of returning the cached token.
88
+ * This prevents an attacker who learned a peer's apiKey from extracting
89
+ * their service token by polling with a wrong secret.
41
90
  *
42
91
  * @param apiKey - DeveloperApp API key (optional if configureServiceAuth was called)
43
92
  * @param apiSecret - DeveloperApp API secret (optional if configureServiceAuth was called)
@@ -48,20 +97,62 @@ function OxyServicesAuthMixin(Base) {
48
97
  if (!key || !secret) {
49
98
  throw new Error('Service credentials not provided. Call configureServiceAuth() or pass apiKey and apiSecret.');
50
99
  }
51
- // Return cached token if still valid (with 60s buffer)
52
- if (this._serviceToken && this._serviceTokenExp > Date.now() + 60000) {
53
- return this._serviceToken;
54
- }
55
- // If a fetch is already in-flight, share the same promise
56
- if (this._serviceTokenPromise) {
57
- return this._serviceTokenPromise;
100
+ const cacheKey = await this._hashApiKey(key);
101
+ const now = Date.now();
102
+ const providedSecretBuf = Buffer.from(secret, 'utf8');
103
+ let entry = this._serviceTokenCache.get(cacheKey);
104
+ // Verify the secret on every cache hit, regardless of token freshness.
105
+ // Constant-time compare prevents timing oracles on the stored secret.
106
+ if (entry) {
107
+ const nodeCrypto = await (0, platformCrypto_1.loadNodeCrypto)();
108
+ const storedSecretBuf = entry.secretBuf;
109
+ const lengthMatch = storedSecretBuf.length === providedSecretBuf.length;
110
+ // Always run timingSafeEqual on equal-length inputs to keep timing flat.
111
+ // When lengths differ, run against a zero-padded copy of the same length
112
+ // to avoid an early-return timing signal.
113
+ const compareBuf = lengthMatch
114
+ ? providedSecretBuf
115
+ : Buffer.alloc(storedSecretBuf.length);
116
+ const compareResult = nodeCrypto.timingSafeEqual(storedSecretBuf, compareBuf);
117
+ if (!lengthMatch || !compareResult) {
118
+ loggerUtils_1.logger.warn('[oxy.auth] Service token cache hit with mismatched secret', {
119
+ component: 'auth',
120
+ method: 'getServiceToken',
121
+ });
122
+ throw new ServiceCredentialMismatchError();
123
+ }
124
+ // Return cached token if still valid (with 60s buffer for clock drift)
125
+ if (entry.token && entry.expiresAt > now + 60000) {
126
+ return entry.token;
127
+ }
128
+ // If a fetch is already in-flight for this credential, share its result
129
+ if (entry.pending) {
130
+ return entry.pending;
131
+ }
58
132
  }
59
- this._serviceTokenPromise = this._doFetchServiceToken(key, secret);
133
+ else {
134
+ // First time seeing this apiKey on this instance — seed an empty entry
135
+ // so concurrent callers serialize on the same promise.
136
+ entry = {
137
+ token: '',
138
+ expiresAt: 0,
139
+ secretBuf: providedSecretBuf,
140
+ pending: null,
141
+ };
142
+ this._serviceTokenCache.set(cacheKey, entry);
143
+ }
144
+ const pending = this._doFetchServiceToken(key, secret, cacheKey, providedSecretBuf);
145
+ entry.pending = pending;
60
146
  try {
61
- return await this._serviceTokenPromise;
147
+ return await pending;
62
148
  }
63
149
  finally {
64
- this._serviceTokenPromise = null;
150
+ // Clear the in-flight slot; the entry itself (with fresh token / expiry)
151
+ // is updated inside _doFetchServiceToken before we land here.
152
+ const settled = this._serviceTokenCache.get(cacheKey);
153
+ if (settled) {
154
+ settled.pending = null;
155
+ }
65
156
  }
66
157
  }
67
158
  /**
@@ -69,11 +160,26 @@ function OxyServicesAuthMixin(Base) {
69
160
  * Separated so getServiceToken() can deduplicate concurrent calls.
70
161
  * @internal
71
162
  */
72
- async _doFetchServiceToken(key, secret) {
163
+ async _doFetchServiceToken(key, secret, cacheKey, secretBuf) {
73
164
  const response = await this.makeRequest('POST', '/auth/service-token', { apiKey: key, apiSecret: secret }, { cache: false, retry: false });
74
- this._serviceToken = response.token;
75
- this._serviceTokenExp = Date.now() + response.expiresIn * 1000;
76
- return this._serviceToken;
165
+ const expiresAt = Date.now() + response.expiresIn * 1000;
166
+ // Update the entry in-place so any caller that already grabbed a reference
167
+ // (via `_serviceTokenCache.get(...)`) sees the fresh state.
168
+ const entry = this._serviceTokenCache.get(cacheKey);
169
+ if (entry) {
170
+ entry.token = response.token;
171
+ entry.expiresAt = expiresAt;
172
+ entry.secretBuf = secretBuf;
173
+ }
174
+ else {
175
+ this._serviceTokenCache.set(cacheKey, {
176
+ token: response.token,
177
+ expiresAt,
178
+ secretBuf,
179
+ pending: null,
180
+ });
181
+ }
182
+ return response.token;
77
183
  }
78
184
  /**
79
185
  * Make an authenticated request on behalf of a user using a service token.
@@ -172,8 +172,48 @@ function OxyServicesPopupAuthMixin(Base) {
172
172
  document.body.appendChild(iframe);
173
173
  try {
174
174
  const session = await this.waitForIframeAuth(iframe, timeout, clientId);
175
- if (session && session.accessToken) {
176
- this.httpService.setTokens(session.accessToken);
175
+ // Bail early on incomplete responses. The iframe contract requires
176
+ // both an access token and a session id; anything less is unusable.
177
+ // Returning `null` here (without installing the token) prevents a
178
+ // stale credential from being committed to HttpService when the
179
+ // user is actually signed out — that pattern caused a `getCurrentUser`
180
+ // -> 401 -> token-clear loop in consumer apps because callers gated
181
+ // on `session?.user` and never installed the user via
182
+ // `handleAuthSuccess`, while HttpService quietly held the token.
183
+ const accessToken = session ? session.accessToken : undefined;
184
+ if (!session || !accessToken || !session.sessionId) {
185
+ return null;
186
+ }
187
+ // Snapshot the previous token so we can roll back if the user
188
+ // lookup below fails — this avoids leaving a half-committed session
189
+ // (token installed, user missing) which would let the next
190
+ // authenticated request 401 with no way to recover.
191
+ const previousAccessToken = this.httpService.getAccessToken();
192
+ this.httpService.setTokens(accessToken);
193
+ // The iframe typically returns `{ sessionId, accessToken }` without
194
+ // user data. Fetch the user explicitly so callers receive a
195
+ // fully-formed session and never need a second `/users/me` round
196
+ // trip. If this fails the session is unusable — revert the token
197
+ // and return null so the caller treats this exactly like a
198
+ // missing-session response.
199
+ if (!session.user) {
200
+ try {
201
+ const userData = await this.makeRequest('GET', `/session/user/${session.sessionId}`, undefined, { cache: false, retry: false });
202
+ if (!userData) {
203
+ throw new Error('Empty user response');
204
+ }
205
+ session.user = userData;
206
+ }
207
+ catch (userError) {
208
+ debug.warn('silentSignIn: failed to fetch user data, rolling back token', userError);
209
+ if (previousAccessToken) {
210
+ this.httpService.setTokens(previousAccessToken);
211
+ }
212
+ else {
213
+ this.httpService.clearTokens();
214
+ }
215
+ return null;
216
+ }
177
217
  }
178
218
  return session;
179
219
  }