@oxyhq/core 1.5.0 → 1.6.1

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 (92) hide show
  1. package/dist/cjs/AuthManager.js +14 -1
  2. package/dist/cjs/HttpService.js +87 -69
  3. package/dist/cjs/OxyServices.base.js +5 -4
  4. package/dist/cjs/crypto/keyManager.js +1 -13
  5. package/dist/cjs/crypto/signatureService.js +7 -20
  6. package/dist/cjs/index.js +9 -1
  7. package/dist/cjs/mixins/OxyServices.analytics.js +2 -2
  8. package/dist/cjs/mixins/OxyServices.assets.js +14 -14
  9. package/dist/cjs/mixins/OxyServices.auth.js +19 -19
  10. package/dist/cjs/mixins/OxyServices.developer.js +6 -6
  11. package/dist/cjs/mixins/OxyServices.devices.js +7 -7
  12. package/dist/cjs/mixins/OxyServices.features.js +23 -23
  13. package/dist/cjs/mixins/OxyServices.fedcm.js +1 -1
  14. package/dist/cjs/mixins/OxyServices.karma.js +6 -6
  15. package/dist/cjs/mixins/OxyServices.location.js +2 -2
  16. package/dist/cjs/mixins/OxyServices.payment.js +6 -6
  17. package/dist/cjs/mixins/OxyServices.popup.js +1 -1
  18. package/dist/cjs/mixins/OxyServices.privacy.js +6 -6
  19. package/dist/cjs/mixins/OxyServices.security.js +3 -3
  20. package/dist/cjs/mixins/OxyServices.user.js +22 -22
  21. package/dist/cjs/mixins/OxyServices.utility.js +39 -10
  22. package/dist/cjs/utils/authHelpers.js +114 -0
  23. package/dist/cjs/utils/platform.js +14 -0
  24. package/dist/esm/AuthManager.js +14 -1
  25. package/dist/esm/HttpService.js +87 -69
  26. package/dist/esm/OxyServices.base.js +5 -4
  27. package/dist/esm/crypto/keyManager.js +1 -13
  28. package/dist/esm/crypto/signatureService.js +2 -15
  29. package/dist/esm/index.js +2 -0
  30. package/dist/esm/mixins/OxyServices.analytics.js +2 -2
  31. package/dist/esm/mixins/OxyServices.assets.js +14 -14
  32. package/dist/esm/mixins/OxyServices.auth.js +19 -19
  33. package/dist/esm/mixins/OxyServices.developer.js +6 -6
  34. package/dist/esm/mixins/OxyServices.devices.js +7 -7
  35. package/dist/esm/mixins/OxyServices.features.js +23 -23
  36. package/dist/esm/mixins/OxyServices.fedcm.js +1 -1
  37. package/dist/esm/mixins/OxyServices.karma.js +6 -6
  38. package/dist/esm/mixins/OxyServices.location.js +2 -2
  39. package/dist/esm/mixins/OxyServices.payment.js +6 -6
  40. package/dist/esm/mixins/OxyServices.popup.js +1 -1
  41. package/dist/esm/mixins/OxyServices.privacy.js +6 -6
  42. package/dist/esm/mixins/OxyServices.security.js +3 -3
  43. package/dist/esm/mixins/OxyServices.user.js +22 -22
  44. package/dist/esm/mixins/OxyServices.utility.js +39 -10
  45. package/dist/esm/utils/authHelpers.js +105 -0
  46. package/dist/esm/utils/platform.js +12 -0
  47. package/dist/types/HttpService.d.ts +4 -1
  48. package/dist/types/OxyServices.base.d.ts +1 -1
  49. package/dist/types/index.d.ts +2 -0
  50. package/dist/types/mixins/OxyServices.analytics.d.ts +1 -1
  51. package/dist/types/mixins/OxyServices.assets.d.ts +1 -1
  52. package/dist/types/mixins/OxyServices.auth.d.ts +1 -1
  53. package/dist/types/mixins/OxyServices.developer.d.ts +1 -1
  54. package/dist/types/mixins/OxyServices.devices.d.ts +1 -1
  55. package/dist/types/mixins/OxyServices.features.d.ts +1 -1
  56. package/dist/types/mixins/OxyServices.fedcm.d.ts +1 -1
  57. package/dist/types/mixins/OxyServices.karma.d.ts +1 -1
  58. package/dist/types/mixins/OxyServices.language.d.ts +1 -1
  59. package/dist/types/mixins/OxyServices.location.d.ts +1 -1
  60. package/dist/types/mixins/OxyServices.payment.d.ts +1 -1
  61. package/dist/types/mixins/OxyServices.popup.d.ts +1 -1
  62. package/dist/types/mixins/OxyServices.privacy.d.ts +1 -1
  63. package/dist/types/mixins/OxyServices.redirect.d.ts +1 -1
  64. package/dist/types/mixins/OxyServices.security.d.ts +1 -1
  65. package/dist/types/mixins/OxyServices.user.d.ts +1 -1
  66. package/dist/types/mixins/OxyServices.utility.d.ts +20 -6
  67. package/dist/types/utils/authHelpers.d.ts +57 -0
  68. package/dist/types/utils/platform.d.ts +8 -0
  69. package/package.json +1 -1
  70. package/src/AuthManager.ts +14 -1
  71. package/src/HttpService.ts +85 -67
  72. package/src/OxyServices.base.ts +5 -4
  73. package/src/crypto/keyManager.ts +1 -15
  74. package/src/crypto/signatureService.ts +2 -17
  75. package/src/index.ts +11 -0
  76. package/src/mixins/OxyServices.analytics.ts +2 -2
  77. package/src/mixins/OxyServices.assets.ts +14 -14
  78. package/src/mixins/OxyServices.auth.ts +19 -19
  79. package/src/mixins/OxyServices.developer.ts +6 -6
  80. package/src/mixins/OxyServices.devices.ts +7 -7
  81. package/src/mixins/OxyServices.features.ts +23 -23
  82. package/src/mixins/OxyServices.fedcm.ts +1 -1
  83. package/src/mixins/OxyServices.karma.ts +6 -6
  84. package/src/mixins/OxyServices.location.ts +2 -2
  85. package/src/mixins/OxyServices.payment.ts +6 -6
  86. package/src/mixins/OxyServices.popup.ts +1 -1
  87. package/src/mixins/OxyServices.privacy.ts +6 -6
  88. package/src/mixins/OxyServices.security.ts +3 -3
  89. package/src/mixins/OxyServices.user.ts +22 -22
  90. package/src/mixins/OxyServices.utility.ts +41 -11
  91. package/src/utils/authHelpers.ts +140 -0
  92. package/src/utils/platform.ts +14 -0
@@ -20,6 +20,7 @@ export declare function OxyServicesLocationMixin<T extends typeof OxyServicesBas
20
20
  httpService: import("../HttpService").HttpService;
21
21
  cloudURL: string;
22
22
  config: import("../OxyServices.base").OxyConfig;
23
+ __resetTokensForTests(): void;
23
24
  makeRequest<T_1>(method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE", url: string, data?: any, options?: import("../HttpService").RequestOptions): Promise<T_1>;
24
25
  getBaseURL(): string;
25
26
  getClient(): import("../HttpService").HttpService;
@@ -62,5 +63,4 @@ export declare function OxyServicesLocationMixin<T extends typeof OxyServicesBas
62
63
  [key: string]: any;
63
64
  }>;
64
65
  };
65
- __resetTokensForTests(): void;
66
66
  } & T;
@@ -67,6 +67,7 @@ export declare function OxyServicesPaymentMixin<T extends typeof OxyServicesBase
67
67
  httpService: import("../HttpService").HttpService;
68
68
  cloudURL: string;
69
69
  config: import("../OxyServices.base").OxyConfig;
70
+ __resetTokensForTests(): void;
70
71
  makeRequest<T_1>(method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE", url: string, data?: any, options?: import("../HttpService").RequestOptions): Promise<T_1>;
71
72
  getBaseURL(): string;
72
73
  getClient(): import("../HttpService").HttpService;
@@ -109,5 +110,4 @@ export declare function OxyServicesPaymentMixin<T extends typeof OxyServicesBase
109
110
  [key: string]: any;
110
111
  }>;
111
112
  };
112
- __resetTokensForTests(): void;
113
113
  } & T;
@@ -155,6 +155,7 @@ export declare function OxyServicesPopupAuthMixin<T extends typeof OxyServicesBa
155
155
  httpService: import("../HttpService").HttpService;
156
156
  cloudURL: string;
157
157
  config: import("../OxyServices.base").OxyConfig;
158
+ __resetTokensForTests(): void;
158
159
  makeRequest<T_1>(method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE", url: string, data?: any, options?: import("../HttpService").RequestOptions): Promise<T_1>;
159
160
  getBaseURL(): string;
160
161
  getClient(): import("../HttpService").HttpService;
@@ -202,6 +203,5 @@ export declare function OxyServicesPopupAuthMixin<T extends typeof OxyServicesBa
202
203
  readonly POPUP_HEIGHT: 700;
203
204
  readonly POPUP_TIMEOUT: 60000;
204
205
  readonly SILENT_TIMEOUT: 5000;
205
- __resetTokensForTests(): void;
206
206
  } & T;
207
207
  export { OxyServicesPopupAuthMixin as PopupAuthMixin };
@@ -78,6 +78,7 @@ export declare function OxyServicesPrivacyMixin<T extends typeof OxyServicesBase
78
78
  httpService: import("../HttpService").HttpService;
79
79
  cloudURL: string;
80
80
  config: import("../OxyServices.base").OxyConfig;
81
+ __resetTokensForTests(): void;
81
82
  makeRequest<T_1>(method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE", url: string, data?: any, options?: import("../HttpService").RequestOptions): Promise<T_1>;
82
83
  getBaseURL(): string;
83
84
  getClient(): import("../HttpService").HttpService;
@@ -120,5 +121,4 @@ export declare function OxyServicesPrivacyMixin<T extends typeof OxyServicesBase
120
121
  [key: string]: any;
121
122
  }>;
122
123
  };
123
- __resetTokensForTests(): void;
124
124
  } & T;
@@ -194,6 +194,7 @@ export declare function OxyServicesRedirectAuthMixin<T extends typeof OxyService
194
194
  httpService: import("../HttpService").HttpService;
195
195
  cloudURL: string;
196
196
  config: import("../OxyServices.base").OxyConfig;
197
+ __resetTokensForTests(): void;
197
198
  makeRequest<T_1>(method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE", url: string, data?: any, options?: import("../HttpService").RequestOptions): Promise<T_1>;
198
199
  getBaseURL(): string;
199
200
  getClient(): import("../HttpService").HttpService;
@@ -242,6 +243,5 @@ export declare function OxyServicesRedirectAuthMixin<T extends typeof OxyService
242
243
  readonly STATE_STORAGE_KEY: "oxy_auth_state";
243
244
  readonly PRE_AUTH_URL_KEY: "oxy_pre_auth_url";
244
245
  readonly NONCE_STORAGE_KEY: "oxy_auth_nonce";
245
- __resetTokensForTests(): void;
246
246
  } & T;
247
247
  export { OxyServicesRedirectAuthMixin as RedirectAuthMixin };
@@ -34,6 +34,7 @@ export declare function OxyServicesSecurityMixin<T extends typeof OxyServicesBas
34
34
  httpService: import("../HttpService").HttpService;
35
35
  cloudURL: string;
36
36
  config: import("../OxyServices.base").OxyConfig;
37
+ __resetTokensForTests(): void;
37
38
  makeRequest<T_1>(method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE", url: string, data?: any, options?: import("../HttpService").RequestOptions): Promise<T_1>;
38
39
  getBaseURL(): string;
39
40
  getClient(): import("../HttpService").HttpService;
@@ -76,5 +77,4 @@ export declare function OxyServicesSecurityMixin<T extends typeof OxyServicesBas
76
77
  [key: string]: any;
77
78
  }>;
78
79
  };
79
- __resetTokensForTests(): void;
80
80
  } & T;
@@ -138,6 +138,7 @@ export declare function OxyServicesUserMixin<T extends typeof OxyServicesBase>(B
138
138
  httpService: import("../HttpService").HttpService;
139
139
  cloudURL: string;
140
140
  config: import("../OxyServices.base").OxyConfig;
141
+ __resetTokensForTests(): void;
141
142
  makeRequest<T_1>(method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE", url: string, data?: any, options?: import("../HttpService").RequestOptions): Promise<T_1>;
142
143
  getBaseURL(): string;
143
144
  getClient(): import("../HttpService").HttpService;
@@ -180,5 +181,4 @@ export declare function OxyServicesUserMixin<T extends typeof OxyServicesBase>(B
180
181
  [key: string]: any;
181
182
  }>;
182
183
  };
183
- __resetTokensForTests(): void;
184
184
  } & T;
@@ -43,25 +43,36 @@ export declare function OxyServicesUtilityMixin<T extends typeof OxyServicesBase
43
43
  * Validates JWT tokens against the Oxy API and attaches user data to requests.
44
44
  * Uses server-side session validation for security (not just JWT decode).
45
45
  *
46
+ * **Design note — jwtDecode vs jwt.verify:**
47
+ * This middleware intentionally uses `jwtDecode()` (decode-only, no signature
48
+ * verification) for user tokens. This is by design, NOT a security gap:
49
+ * - Third-party apps using `oxy.auth()` don't have the Oxy JWT secret
50
+ * - Security comes from API-based session validation (`validateSession()`)
51
+ * which checks the session server-side on every request
52
+ * - Service tokens (type: 'service') DO use cryptographic HMAC verification
53
+ * via the `jwtSecret` option, since they are stateless
54
+ * - The backend's own `authMiddleware` uses `jwt.verify()` because it has
55
+ * direct access to `ACCESS_TOKEN_SECRET`
56
+ *
46
57
  * @example
47
58
  * ```typescript
48
59
  * import { OxyServices } from '@oxyhq/core';
49
60
  *
50
61
  * const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
51
62
  *
52
- * // Protect all routes under /api/protected
53
- * app.use('/api/protected', oxy.auth());
63
+ * // Protect all routes under /protected
64
+ * app.use('/protected', oxy.auth());
54
65
  *
55
66
  * // Access user in route handler
56
- * app.get('/api/protected/me', (req, res) => {
67
+ * app.get('/protected/me', (req, res) => {
57
68
  * res.json({ userId: req.userId, user: req.user });
58
69
  * });
59
70
  *
60
71
  * // Load full user profile from API
61
- * app.use('/api/admin', oxy.auth({ loadUser: true }));
72
+ * app.use('/admin', oxy.auth({ loadUser: true }));
62
73
  *
63
74
  * // Optional auth - attach user if present, don't block if absent
64
- * app.use('/api/public', oxy.auth({ optional: true }));
75
+ * app.use('/public', oxy.auth({ optional: true }));
65
76
  * ```
66
77
  *
67
78
  * @param options Optional configuration
@@ -74,6 +85,8 @@ export declare function OxyServicesUtilityMixin<T extends typeof OxyServicesBase
74
85
  * Returns a middleware function for Socket.IO that validates JWT tokens
75
86
  * from the handshake auth object and attaches user data to the socket.
76
87
  *
88
+ * Uses `jwtDecode()` + API session validation (same rationale as `auth()`).
89
+ *
77
90
  * @example
78
91
  * ```typescript
79
92
  * import { OxyServices } from '@oxyhq/core';
@@ -111,10 +124,12 @@ export declare function OxyServicesUtilityMixin<T extends typeof OxyServicesBase
111
124
  */
112
125
  serviceAuth(options?: {
113
126
  debug?: boolean;
127
+ jwtSecret?: string;
114
128
  }): (req: any, res: any, next: any) => Promise<void>;
115
129
  httpService: import("../HttpService").HttpService;
116
130
  cloudURL: string;
117
131
  config: import("../OxyServices.base").OxyConfig;
132
+ __resetTokensForTests(): void;
118
133
  makeRequest<T_1>(method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE", url: string, data?: any, options?: import("../HttpService").RequestOptions): Promise<T_1>;
119
134
  getBaseURL(): string;
120
135
  getClient(): import("../HttpService").HttpService;
@@ -157,6 +172,5 @@ export declare function OxyServicesUtilityMixin<T extends typeof OxyServicesBase
157
172
  [key: string]: any;
158
173
  }>;
159
174
  };
160
- __resetTokensForTests(): void;
161
175
  } & T;
162
176
  export {};
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Authentication helper utilities for common token validation
3
+ * and authentication error handling patterns.
4
+ */
5
+ import type { OxyServices } from '../OxyServices';
6
+ /**
7
+ * Error thrown when session sync is required
8
+ */
9
+ export declare class SessionSyncRequiredError extends Error {
10
+ constructor(message?: string);
11
+ }
12
+ /**
13
+ * Error thrown when authentication fails
14
+ */
15
+ export declare class AuthenticationFailedError extends Error {
16
+ constructor(message?: string);
17
+ }
18
+ /**
19
+ * Ensures a valid token exists before making authenticated API calls.
20
+ * If no valid token exists and an active session ID is available,
21
+ * attempts to refresh the token using the session.
22
+ *
23
+ * @throws {SessionSyncRequiredError} If the session needs to be synced (offline session)
24
+ */
25
+ export declare function ensureValidToken(oxyServices: OxyServices, activeSessionId: string | null | undefined): Promise<void>;
26
+ /**
27
+ * Options for handling API authentication errors
28
+ */
29
+ export interface HandleApiErrorOptions {
30
+ syncSession?: () => Promise<unknown>;
31
+ activeSessionId?: string | null;
32
+ oxyServices?: OxyServices;
33
+ }
34
+ /**
35
+ * Checks if an error is an authentication error (401 or auth-related message)
36
+ */
37
+ export declare function isAuthenticationError(error: unknown): boolean;
38
+ /**
39
+ * Wraps an API call with authentication error handling.
40
+ * On auth failure, optionally attempts to sync the session and retry.
41
+ *
42
+ * @throws {AuthenticationFailedError} If authentication fails and cannot be recovered
43
+ */
44
+ export declare function withAuthErrorHandling<T>(apiCall: () => Promise<T>, options?: HandleApiErrorOptions): Promise<T>;
45
+ /**
46
+ * Combines token validation and auth error handling for a complete authenticated API call.
47
+ *
48
+ * @example
49
+ * ```ts
50
+ * return await authenticatedApiCall(
51
+ * oxyServices,
52
+ * activeSessionId,
53
+ * () => oxyServices.updateProfile(updates)
54
+ * );
55
+ * ```
56
+ */
57
+ export declare function authenticatedApiCall<T>(oxyServices: OxyServices, activeSessionId: string | null | undefined, apiCall: () => Promise<T>, syncSession?: () => Promise<unknown>): Promise<T>;
@@ -27,6 +27,14 @@ export declare function isIOS(): boolean;
27
27
  * Check if running on Android
28
28
  */
29
29
  export declare function isAndroid(): boolean;
30
+ /**
31
+ * Check if running in React Native
32
+ */
33
+ export declare function isReactNative(): boolean;
34
+ /**
35
+ * Check if running in Node.js
36
+ */
37
+ export declare function isNodeJS(): boolean;
30
38
  /**
31
39
  * Set the platform OS explicitly
32
40
  * Called by React Native entry point to register the platform
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/core",
3
- "version": "1.5.0",
3
+ "version": "1.6.1",
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",
@@ -308,7 +308,7 @@ export class AuthManager {
308
308
  // Use session-based token endpoint which handles auto-refresh server-side
309
309
  const response = await httpService.request<{ accessToken: string; expiresAt: string }>({
310
310
  method: 'GET',
311
- url: `/api/session/token/${sessionId}`,
311
+ url: `/session/token/${sessionId}`,
312
312
  cache: false,
313
313
  retry: false,
314
314
  });
@@ -366,6 +366,19 @@ export class AuthManager {
366
366
  this.refreshTimer = null;
367
367
  }
368
368
 
369
+ // Invalidate current session on the server (best-effort)
370
+ try {
371
+ const sessionJson = await this.storage.getItem(STORAGE_KEYS.SESSION);
372
+ if (sessionJson) {
373
+ const session = JSON.parse(sessionJson);
374
+ if (session.sessionId && typeof (this.oxyServices as any).logoutSession === 'function') {
375
+ await (this.oxyServices as any).logoutSession(session.sessionId);
376
+ }
377
+ }
378
+ } catch {
379
+ // Best-effort: don't block local signout on network failure
380
+ }
381
+
369
382
  // Revoke FedCM credential if supported
370
383
  try {
371
384
  const services = this.oxyServices as OxyServicesWithFedCM;
@@ -54,27 +54,21 @@ interface RequestConfig extends RequestOptions {
54
54
  params?: Record<string, unknown>;
55
55
  /** @internal Used to prevent infinite auth retry loops */
56
56
  _isAuthRetry?: boolean;
57
+ /** @internal Used to prevent infinite CSRF retry loops */
58
+ _isCsrfRetry?: boolean;
57
59
  }
58
60
 
59
61
  /**
60
- * Token store for authentication (singleton)
62
+ * Token store for authentication (instance-based)
63
+ * Each HttpService gets its own TokenStore to prevent conflicts
64
+ * when multiple OxyServices instances coexist server-side.
61
65
  */
62
66
  class TokenStore {
63
- private static instance: TokenStore;
64
67
  private accessToken: string | null = null;
65
68
  private refreshToken: string | null = null;
66
69
  private csrfToken: string | null = null;
67
70
  private csrfTokenFetchPromise: Promise<string | null> | null = null;
68
71
 
69
- private constructor() {}
70
-
71
- static getInstance(): TokenStore {
72
- if (!TokenStore.instance) {
73
- TokenStore.instance = new TokenStore();
74
- }
75
- return TokenStore.instance;
76
- }
77
-
78
72
  setTokens(accessToken: string, refreshToken = ''): void {
79
73
  this.accessToken = accessToken;
80
74
  this.refreshToken = refreshToken;
@@ -134,6 +128,7 @@ export class HttpService {
134
128
  private logger: SimpleLogger;
135
129
  private config: OxyConfig;
136
130
  private tokenRefreshPromise: Promise<string | null> | null = null;
131
+ private tokenRefreshCooldownUntil: number = 0;
137
132
  private _onTokenRefreshed: ((accessToken: string) => void) | null = null;
138
133
 
139
134
  // Performance monitoring
@@ -149,7 +144,7 @@ export class HttpService {
149
144
  constructor(config: OxyConfig) {
150
145
  this.config = config;
151
146
  this.baseURL = config.baseURL;
152
- this.tokenStore = TokenStore.getInstance();
147
+ this.tokenStore = new TokenStore();
153
148
 
154
149
  this.logger = new SimpleLogger(
155
150
  config.enableLogging || false,
@@ -346,6 +341,20 @@ export class HttpService {
346
341
  this.tokenStore.clearTokens();
347
342
  }
348
343
 
344
+ // On 403 with CSRF error, clear cached token and retry once
345
+ if (response.status === 403 && !config._isCsrfRetry) {
346
+ try {
347
+ const clonedResponse = response.clone();
348
+ const errBody = await clonedResponse.json() as { code?: string } | null;
349
+ if (errBody?.code === 'CSRF_TOKEN_INVALID' || errBody?.code === 'CSRF_TOKEN_MISSING') {
350
+ this.tokenStore.clearCsrfToken();
351
+ return this.request<T>({ ...config, _isCsrfRetry: true, retry: false });
352
+ }
353
+ } catch {
354
+ // Failed to parse error body — not a CSRF error
355
+ }
356
+ }
357
+
349
358
  // Try to parse error response (handle empty/malformed JSON)
350
359
  let errorMessage = `HTTP ${response.status}: ${response.statusText}`;
351
360
  const contentType = response.headers.get('content-type');
@@ -518,52 +527,58 @@ export class HttpService {
518
527
  }
519
528
 
520
529
  const fetchPromise = (async () => {
521
- try {
522
- if (isDev()) console.log('[HttpService] Fetching CSRF token from:', `${this.baseURL}/api/csrf-token`);
530
+ const maxAttempts = 2;
531
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
532
+ try {
533
+ if (isDev()) console.log('[HttpService] Fetching CSRF token from:', `${this.baseURL}/csrf-token`, `(attempt ${attempt})`);
523
534
 
524
- // Use AbortController for timeout (more compatible than AbortSignal.timeout)
525
- const controller = new AbortController();
526
- const timeoutId = setTimeout(() => controller.abort(), 5000);
535
+ // Use AbortController for timeout (more compatible than AbortSignal.timeout)
536
+ const controller = new AbortController();
537
+ const timeoutId = setTimeout(() => controller.abort(), 5000);
527
538
 
528
- const response = await fetch(`${this.baseURL}/api/csrf-token`, {
529
- method: 'GET',
530
- headers: { 'Accept': 'application/json' },
531
- credentials: 'include', // Required to receive and send cookies
532
- signal: controller.signal,
533
- });
539
+ const response = await fetch(`${this.baseURL}/csrf-token`, {
540
+ method: 'GET',
541
+ headers: { 'Accept': 'application/json' },
542
+ credentials: 'include', // Required to receive and send cookies
543
+ signal: controller.signal,
544
+ });
534
545
 
535
- clearTimeout(timeoutId);
546
+ clearTimeout(timeoutId);
536
547
 
537
- if (isDev()) console.log('[HttpService] CSRF fetch response:', response.status, response.ok);
548
+ if (isDev()) console.log('[HttpService] CSRF fetch response:', response.status, response.ok);
538
549
 
539
- if (response.ok) {
540
- const data = await response.json() as { csrfToken?: string };
541
- if (isDev()) console.log('[HttpService] CSRF response data:', data);
542
- const token = data.csrfToken || null;
543
- this.tokenStore.setCsrfToken(token);
544
- this.logger.debug('CSRF token fetched');
545
- return token;
546
- }
550
+ if (response.ok) {
551
+ const data = await response.json() as { csrfToken?: string };
552
+ if (isDev()) console.log('[HttpService] CSRF response data:', data);
553
+ const token = data.csrfToken || null;
554
+ this.tokenStore.setCsrfToken(token);
555
+ this.logger.debug('CSRF token fetched');
556
+ return token;
557
+ }
547
558
 
548
- // Also check response header for CSRF token
549
- const headerToken = response.headers.get('X-CSRF-Token');
550
- if (headerToken) {
551
- this.tokenStore.setCsrfToken(headerToken);
552
- this.logger.debug('CSRF token from header');
553
- return headerToken;
554
- }
559
+ // Also check response header for CSRF token
560
+ const headerToken = response.headers.get('X-CSRF-Token');
561
+ if (headerToken) {
562
+ this.tokenStore.setCsrfToken(headerToken);
563
+ this.logger.debug('CSRF token from header');
564
+ return headerToken;
565
+ }
555
566
 
556
- if (isDev()) console.log('[HttpService] CSRF fetch failed with status:', response.status);
557
- this.logger.warn('Failed to fetch CSRF token:', response.status);
558
- return null;
559
- } catch (error) {
560
- if (isDev()) console.log('[HttpService] CSRF fetch error:', error);
561
- this.logger.warn('CSRF token fetch error:', error);
562
- return null;
563
- } finally {
564
- this.tokenStore.setCsrfTokenFetchPromise(null);
567
+ if (isDev()) console.log('[HttpService] CSRF fetch failed with status:', response.status);
568
+ this.logger.warn('Failed to fetch CSRF token:', response.status);
569
+ } catch (error) {
570
+ if (isDev()) console.log('[HttpService] CSRF fetch error:', error);
571
+ this.logger.warn('CSRF token fetch error:', error);
572
+ }
573
+ // Wait before retry (500ms)
574
+ if (attempt < maxAttempts) {
575
+ await new Promise(resolve => setTimeout(resolve, 500));
576
+ }
565
577
  }
566
- })();
578
+ return null;
579
+ })().finally(() => {
580
+ this.tokenStore.setCsrfTokenFetchPromise(null);
581
+ });
567
582
 
568
583
  this.tokenStore.setCsrfTokenFetchPromise(fetchPromise);
569
584
  return fetchPromise;
@@ -584,16 +599,23 @@ export class HttpService {
584
599
 
585
600
  // If token expires in less than 60 seconds, refresh it
586
601
  if (decoded.exp && decoded.exp - currentTime < 60 && decoded.sessionId) {
587
- // Deduplicate concurrent refresh attempts
588
- if (!this.tokenRefreshPromise) {
589
- this.tokenRefreshPromise = this._refreshTokenFromSession(decoded.sessionId);
602
+ // Skip if we recently failed a refresh (5s cooldown to prevent storms)
603
+ if (Date.now() < this.tokenRefreshCooldownUntil) {
604
+ return `Bearer ${accessToken}`;
590
605
  }
591
- try {
592
- const result = await this.tokenRefreshPromise;
593
- if (result) return result;
594
- } finally {
595
- this.tokenRefreshPromise = null;
606
+ // Deduplicate concurrent refresh attempts. The promise is shared
607
+ // across all concurrent callers and cleared only after it settles,
608
+ // so every awaiter receives the same result.
609
+ if (!this.tokenRefreshPromise) {
610
+ this.tokenRefreshPromise = this._refreshTokenFromSession(decoded.sessionId)
611
+ .then((result) => {
612
+ if (!result) this.tokenRefreshCooldownUntil = Date.now() + 5000;
613
+ return result;
614
+ })
615
+ .finally(() => { this.tokenRefreshPromise = null; });
596
616
  }
617
+ const result = await this.tokenRefreshPromise;
618
+ if (result) return result;
597
619
  }
598
620
 
599
621
  return `Bearer ${accessToken}`;
@@ -605,7 +627,7 @@ export class HttpService {
605
627
 
606
628
  private async _refreshTokenFromSession(sessionId: string): Promise<string | null> {
607
629
  try {
608
- const refreshUrl = `${this.baseURL}/api/session/token/${sessionId}`;
630
+ const refreshUrl = `${this.baseURL}/session/token/${sessionId}`;
609
631
  const response = await fetch(refreshUrl, {
610
632
  method: 'GET',
611
633
  headers: { 'Accept': 'application/json' },
@@ -778,14 +800,10 @@ export class HttpService {
778
800
  return { ...this.requestMetrics };
779
801
  }
780
802
 
781
- // Test-only utility
782
- static __resetTokensForTests(): void {
783
- try {
784
- TokenStore.getInstance().clearTokens();
785
- } catch (error) {
786
- // Silently fail in test cleanup - this is expected behavior
787
- // TokenStore might not be initialized in some test scenarios
788
- }
803
+ // Test-only utility — clears tokens on this instance
804
+ __resetTokensForTests(): void {
805
+ this.tokenStore.clearTokens();
806
+ this.tokenStore.clearCsrfToken();
789
807
  }
790
808
  }
791
809
 
@@ -41,9 +41,10 @@ export class OxyServicesBase {
41
41
  this.httpService = new HttpService(config);
42
42
  }
43
43
 
44
- // Test-only utility to reset global tokens between jest tests
45
- static __resetTokensForTests(): void {
46
- HttpService.__resetTokensForTests();
44
+ // Test-only utility to reset tokens on this instance between jest tests
45
+ // Note: tokens are now per-instance, so create new instances in tests for isolation
46
+ __resetTokensForTests(): void {
47
+ this.httpService.__resetTokensForTests();
47
48
  }
48
49
 
49
50
  /**
@@ -304,7 +305,7 @@ export class OxyServicesBase {
304
305
  }
305
306
 
306
307
  try {
307
- const res = await this.makeRequest<{ valid: boolean }>('GET', '/api/auth/validate', undefined, {
308
+ const res = await this.makeRequest<{ valid: boolean }>('GET', '/auth/validate', undefined, {
308
309
  cache: false,
309
310
  retry: false,
310
311
  });
@@ -7,7 +7,7 @@
7
7
 
8
8
  import { ec as EC } from 'elliptic';
9
9
  import type { ECKeyPair } from 'elliptic';
10
- import { isWeb, isIOS, isAndroid } from '../utils/platform';
10
+ import { isWeb, isIOS, isAndroid, isReactNative, isNodeJS } from '../utils/platform';
11
11
  import { logger } from '../utils/loggerUtils';
12
12
  import { isDev } from '../shared/utils/debugUtils';
13
13
 
@@ -64,20 +64,6 @@ async function initSecureStore(): Promise<typeof import('expo-secure-store')> {
64
64
  return SecureStore;
65
65
  }
66
66
 
67
- /**
68
- * Check if we're in a React Native environment
69
- */
70
- function isReactNative(): boolean {
71
- return typeof navigator !== 'undefined' && navigator.product === 'ReactNative';
72
- }
73
-
74
- /**
75
- * Check if we're in a Node.js environment
76
- */
77
- function isNodeJS(): boolean {
78
- return typeof process !== 'undefined' && process.versions != null && process.versions.node != null;
79
- }
80
-
81
67
  /**
82
68
  * Check if we're on web platform
83
69
  * Identity storage is only available on native platforms (iOS/Android)
@@ -7,26 +7,13 @@
7
7
 
8
8
  import { ec as EC } from 'elliptic';
9
9
  import { KeyManager } from './keyManager';
10
+ import { isReactNative, isNodeJS } from '../utils/platform';
10
11
 
11
12
  // Lazy import for expo-crypto
12
13
  let ExpoCrypto: typeof import('expo-crypto') | null = null;
13
14
 
14
15
  const ec = new EC('secp256k1');
15
16
 
16
- /**
17
- * Check if we're in a React Native environment
18
- */
19
- function isReactNative(): boolean {
20
- return typeof navigator !== 'undefined' && navigator.product === 'ReactNative';
21
- }
22
-
23
- /**
24
- * Check if we're in a Node.js environment
25
- */
26
- function isNodeJS(): boolean {
27
- return typeof process !== 'undefined' && process.versions != null && process.versions.node != null;
28
- }
29
-
30
17
  /**
31
18
  * Initialize expo-crypto module
32
19
  */
@@ -55,9 +42,7 @@ async function sha256(message: string): Promise<string> {
55
42
  // In Node.js, use Node's crypto module
56
43
  if (isNodeJS()) {
57
44
  try {
58
- // eslint-disable-next-line @typescript-eslint/no-implied-eval
59
- const getCrypto = new Function('return require("crypto")');
60
- const nodeCrypto = getCrypto();
45
+ const nodeCrypto = await import('crypto');
61
46
  return nodeCrypto.createHash('sha256').update(message).digest('hex');
62
47
  } catch {
63
48
  // Fall through to Web Crypto API
package/src/index.ts CHANGED
@@ -123,6 +123,17 @@ export {
123
123
  // --- i18n ---
124
124
  export { translate } from './i18n';
125
125
 
126
+ // --- Auth Helpers ---
127
+ export {
128
+ SessionSyncRequiredError,
129
+ AuthenticationFailedError,
130
+ ensureValidToken,
131
+ isAuthenticationError,
132
+ withAuthErrorHandling,
133
+ authenticatedApiCall,
134
+ } from './utils/authHelpers';
135
+ export type { HandleApiErrorOptions } from './utils/authHelpers';
136
+
126
137
  // --- Session Utilities ---
127
138
  export { mergeSessions, normalizeAndSortSessions, sessionsArraysEqual } from './utils/sessionUtils';
128
139
 
@@ -19,7 +19,7 @@ export function OxyServicesAnalyticsMixin<T extends typeof OxyServicesBase>(Base
19
19
  */
20
20
  async trackEvent(eventName: string, properties?: Record<string, any>): Promise<void> {
21
21
  try {
22
- await this.makeRequest('POST', '/api/analytics/events', {
22
+ await this.makeRequest('POST', '/analytics/events', {
23
23
  event: eventName,
24
24
  properties
25
25
  }, { cache: false, retry: false }); // Don't retry analytics events
@@ -40,7 +40,7 @@ export function OxyServicesAnalyticsMixin<T extends typeof OxyServicesBase>(Base
40
40
  if (startDate) params.startDate = startDate;
41
41
  if (endDate) params.endDate = endDate;
42
42
 
43
- return await this.makeRequest('GET', '/api/analytics', params, {
43
+ return await this.makeRequest('GET', '/analytics', params, {
44
44
  cache: true,
45
45
  cacheTTL: CACHE_TIMES.LONG,
46
46
  });