bitcoincash-oauth-client 0.1.1 → 0.2.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/index.mjs CHANGED
@@ -5,11 +5,103 @@
5
5
  */
6
6
  import { instantiateSecp256k1, generatePrivateKey, CashAddressNetworkPrefix, encodeCashAddress, CashAddressType } from '@bitauth/libauth';
7
7
 
8
+ /**
9
+ * Custom error classes for Bitcoin Cash OAuth Client
10
+ * Provides specific error types for better error handling
11
+ */
12
+
13
+ /**
14
+ * Base OAuth Error class
15
+ */
16
+ class OAuthError extends Error {
17
+ /**
18
+ * @param {string} message - Error message
19
+ * @param {string} code - Error code
20
+ * @param {number|null} statusCode - HTTP status code (if applicable)
21
+ */
22
+ constructor(message, code, statusCode = null) {
23
+ super(message);
24
+ this.name = 'OAuthError';
25
+ this.code = code;
26
+ this.statusCode = statusCode;
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Network-related errors (connection issues, timeouts, etc.)
32
+ */
33
+ class NetworkError extends OAuthError {
34
+ /**
35
+ * @param {string} message - Error message
36
+ * @param {Error|null} originalError - Original error that caused this
37
+ */
38
+ constructor(message, originalError = null) {
39
+ super(message, 'NETWORK_ERROR');
40
+ this.name = 'NetworkError';
41
+ this.originalError = originalError;
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Authentication-related errors (invalid credentials, unauthorized access)
47
+ */
48
+ class AuthenticationError extends OAuthError {
49
+ /**
50
+ * @param {string} message - Error message
51
+ * @param {number} statusCode - HTTP status code
52
+ * @param {string} code - Error code
53
+ */
54
+ constructor(message, statusCode, code = 'AUTHENTICATION_ERROR') {
55
+ super(message, code, statusCode);
56
+ this.name = 'AuthenticationError';
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Token has expired
62
+ */
63
+ class TokenExpiredError extends AuthenticationError {
64
+ /**
65
+ * @param {string} [message='Token has expired'] - Error message
66
+ */
67
+ constructor(message = 'Token has expired') {
68
+ super(message, 401, 'TOKEN_EXPIRED');
69
+ this.name = 'TokenExpiredError';
70
+ }
71
+ }
72
+
73
+ /**
74
+ * User not found error
75
+ */
76
+ class UserNotFoundError extends AuthenticationError {
77
+ /**
78
+ * @param {string} [message='User not found'] - Error message
79
+ */
80
+ constructor(message = 'User not found') {
81
+ super(message, 404, 'USER_NOT_FOUND');
82
+ this.name = 'UserNotFoundError';
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Invalid token error
88
+ */
89
+ class InvalidTokenError extends AuthenticationError {
90
+ /**
91
+ * @param {string} [message='Invalid token'] - Error message
92
+ */
93
+ constructor(message = 'Invalid token') {
94
+ super(message, 401, 'INVALID_TOKEN');
95
+ this.name = 'InvalidTokenError';
96
+ }
97
+ }
98
+
8
99
  /**
9
100
  * Environment utilities for cross-platform crypto operations
10
101
  * Handles browser vs Node.js differences
11
102
  */
12
103
 
104
+
13
105
  let cryptoModule = null;
14
106
 
15
107
  /**
@@ -32,6 +124,25 @@ async function getNodeCrypto() {
32
124
  }
33
125
  }
34
126
 
127
+ /**
128
+ * Check if running in Capacitor environment
129
+ * @returns {boolean}
130
+ */
131
+ function isCapacitor() {
132
+ return typeof window !== 'undefined' &&
133
+ window.Capacitor !== undefined &&
134
+ window.Capacitor.isNative === true;
135
+ }
136
+
137
+ /**
138
+ * Check if running in hybrid app environment (Capacitor, Cordova, etc.)
139
+ * @returns {boolean}
140
+ */
141
+ function isHybridApp() {
142
+ return isCapacitor() ||
143
+ (typeof window !== 'undefined' && window.cordova !== undefined);
144
+ }
145
+
35
146
  /**
36
147
  * Generate secure random bytes (cross-platform)
37
148
  * @param {number} length - Number of bytes to generate
@@ -90,15 +201,45 @@ async function ripemd160(data) {
90
201
 
91
202
  /**
92
203
  * Get fetch implementation for current environment
204
+ * @param {Function|null} userProvidedFetch - User-provided fetch implementation
93
205
  * @returns {Function} Fetch implementation
206
+ * @throws {NetworkError} If no fetch implementation is available
94
207
  */
95
- function getFetch() {
208
+ function getFetch(userProvidedFetch = null) {
209
+ // Always prefer user-provided fetch
210
+ if (userProvidedFetch) {
211
+ return userProvidedFetch;
212
+ }
213
+
214
+ // Check if we're in a Capacitor environment
215
+ if (isCapacitor()) {
216
+ throw new NetworkError(
217
+ 'Capacitor environment detected. ' +
218
+ 'Please provide a custom fetch implementation via options.fetch. ' +
219
+ 'Example: new BitcoinCashOAuthClient({ fetch: axiosFetch })'
220
+ );
221
+ }
222
+
223
+ // Check for hybrid app environments (Cordova, etc.)
224
+ if (isHybridApp() && !isCapacitor()) {
225
+ console.warn(
226
+ '[bitcoincash-oauth-client] Hybrid app environment detected. ' +
227
+ 'Consider providing a custom fetch implementation via options.fetch ' +
228
+ 'for better compatibility.'
229
+ );
230
+ }
231
+
96
232
  // Use global fetch if available (browser or Node.js 18+)
97
233
  if (typeof fetch !== 'undefined') {
98
- return fetch;
234
+ // Bind to globalThis to avoid Window issues in some environments
235
+ return fetch.bind(globalThis);
99
236
  }
100
237
 
101
- throw new Error('No fetch implementation available. For Node.js < 18, install node-fetch and pass it as an option.');
238
+ throw new NetworkError(
239
+ 'No fetch implementation available. ' +
240
+ 'For Node.js < 18, install node-fetch and pass it as an option: ' +
241
+ 'new BitcoinCashOAuthClient({ fetch: fetchImplementation })'
242
+ );
102
243
  }
103
244
 
104
245
  /**
@@ -113,7 +254,7 @@ function getFetch() {
113
254
  * @typedef {Object} Keypair
114
255
  * @property {string} privateKey - Hex-encoded private key
115
256
  * @property {string} publicKey - Hex-encoded compressed public key
116
- * @property {string} address - Bitcoin Cash address
257
+ * @property {string} bitcoincash_address - Bitcoin Cash address
117
258
  */
118
259
 
119
260
  /**
@@ -122,6 +263,11 @@ function getFetch() {
122
263
  * @property {string} [network="mainnet"] - Network type ("mainnet" or "testnet")
123
264
  * @property {SecureStorage} [secureStorage] - Storage interface for tokens
124
265
  * @property {Function} [fetch] - Custom fetch implementation (optional)
266
+ * @property {string} [tokenKey="oauth_token"] - Key for storing access token
267
+ * @property {string} [refreshTokenKey="oauth_refresh_token"] - Key for storing refresh token
268
+ * @property {boolean} [autoRefresh=true] - Enable automatic token refresh
269
+ * @property {number} [refreshThreshold=300] - Seconds before expiry to trigger refresh (default: 5 minutes)
270
+ * @property {boolean} [debug=false] - Enable debug logging
125
271
  */
126
272
 
127
273
  /**
@@ -137,6 +283,7 @@ function getFetch() {
137
283
  * @property {string} refresh_token - Refresh token
138
284
  * @property {number} expires_in - Token expiration in seconds
139
285
  * @property {string} token_type - Token type (e.g., "bearer")
286
+ * @property {string[]} [scopes] - Granted scopes
140
287
  */
141
288
 
142
289
  /**
@@ -152,8 +299,41 @@ class BitcoinCashOAuthClient {
152
299
  this.serverUrl = options.serverUrl || "http://localhost:8000";
153
300
  this.network = options.network || "mainnet";
154
301
  this.secureStorage = options.secureStorage || null;
155
- this.fetchImpl = options.fetch || getFetch();
302
+ this.fetchImpl = options.fetch || getFetch(options.fetch);
156
303
  this.secp256k1 = null;
304
+
305
+ // Token storage keys
306
+ this.tokenKey = options.tokenKey || "oauth_token";
307
+ this.refreshTokenKey = options.refreshTokenKey || "oauth_refresh_token";
308
+
309
+ // Auto-refresh settings
310
+ this.autoRefresh = options.autoRefresh !== false; // default true
311
+ this.refreshThreshold = options.refreshThreshold || 300; // 5 minutes before expiry
312
+ this.tokenExpiry = null;
313
+ this.refreshPromise = null;
314
+ this.refreshTimer = null;
315
+
316
+ // Debug mode
317
+ this.debug = options.debug || false;
318
+
319
+ // Store auth params for refresh
320
+ this._authParams = null;
321
+ }
322
+
323
+ /**
324
+ * Log debug messages
325
+ * @private
326
+ * @param {string} message - Message to log
327
+ * @param {*} [data] - Optional data to log
328
+ */
329
+ _log(message, data = null) {
330
+ if (this.debug) {
331
+ if (data !== null) {
332
+ console.log(`[bitcoincash-oauth-client] ${message}`, data);
333
+ } else {
334
+ console.log(`[bitcoincash-oauth-client] ${message}`);
335
+ }
336
+ }
157
337
  }
158
338
 
159
339
  /**
@@ -188,12 +368,14 @@ class BitcoinCashOAuthClient {
188
368
  const publicKeyBytes = this.secp256k1.derivePublicKeyCompressed(privateKeyBytes);
189
369
 
190
370
  // Convert to address
191
- const address = await this.publicKeyToCashAddress(publicKeyBytes);
371
+ const bitcoincash_address = await this.publicKeyToCashAddress(publicKeyBytes);
372
+
373
+ this._log('Generated new keypair', { bitcoincash_address });
192
374
 
193
375
  return {
194
376
  privateKey: this.bytesToHex(privateKeyBytes),
195
377
  publicKey: this.bytesToHex(publicKeyBytes),
196
- address,
378
+ bitcoincash_address,
197
379
  };
198
380
  }
199
381
 
@@ -247,29 +429,62 @@ class BitcoinCashOAuthClient {
247
429
  }
248
430
 
249
431
  /**
250
- * Register a new user with the server
251
- * @param {string} address - Bitcoin Cash address
252
- * @param {string} [userId] - Optional user-provided ID
432
+ * Register a new user with the server (signature required)
433
+ * @param {string} bitcoincash_address - Bitcoin Cash address
434
+ * @param {string} privateKeyHex - Private key for signing (hex-encoded)
435
+ * @param {string} publicKeyHex - Public key for signature verification (hex-encoded)
436
+ * @param {string} userId - User-provided ID (required)
437
+ * @param {number} [timestamp] - Optional Unix timestamp (defaults to now)
438
+ * @param {string} [domain] - Optional domain for message binding
253
439
  * @returns {Promise<Object>} Registration result with assigned userId
440
+ * @throws {NetworkError} If network request fails
441
+ * @throws {AuthenticationError} If registration fails
254
442
  */
255
- async register(address, userId = null) {
256
- const response = await this.fetchImpl(`${this.serverUrl}/auth/register`, {
257
- method: "POST",
258
- headers: {
259
- "Content-Type": "application/json",
260
- },
261
- body: JSON.stringify({
262
- address,
263
- user_id: userId,
264
- }),
265
- });
266
-
267
- if (!response.ok) {
268
- const error = await response.text();
269
- throw new Error(`Registration failed: ${response.status} ${response.statusText} - ${error}`);
443
+ async register(bitcoincash_address, privateKeyHex, publicKeyHex, userId, timestamp = null, domain = null) {
444
+ try {
445
+ const ts = timestamp || Math.floor(Date.now() / 1000);
446
+ const host = domain || this._getDefaultDomain();
447
+ const message = this.createAuthMessage(userId, ts, host);
448
+ const signature = await this.signAuthMessage(message, privateKeyHex);
449
+
450
+ this._log('Registering user', { bitcoincash_address, userId, domain: host });
451
+
452
+ const response = await this.fetchImpl(`${this.serverUrl}/auth/register`, {
453
+ method: "POST",
454
+ headers: {
455
+ "Content-Type": "application/json",
456
+ },
457
+ body: JSON.stringify({
458
+ bitcoincash_address,
459
+ user_id: userId,
460
+ timestamp: ts,
461
+ domain: host,
462
+ public_key: publicKeyHex,
463
+ signature: signature,
464
+ }),
465
+ });
466
+
467
+ if (!response.ok) {
468
+ const errorData = await response.json().catch(() => ({}));
469
+
470
+ if (response.status === 404) {
471
+ throw new UserNotFoundError(errorData.detail || 'User not found');
472
+ }
473
+
474
+ throw new AuthenticationError(
475
+ errorData.detail || `Registration failed: ${response.statusText}`,
476
+ response.status
477
+ );
478
+ }
479
+
480
+ this._log('User registered successfully', { bitcoincash_address, userId });
481
+ return await response.json();
482
+ } catch (error) {
483
+ if (error instanceof OAuthError) {
484
+ throw error;
485
+ }
486
+ throw new NetworkError(`Network error during registration: ${error.message}`, error);
270
487
  }
271
-
272
- return await response.json();
273
488
  }
274
489
 
275
490
  /**
@@ -314,38 +529,196 @@ class BitcoinCashOAuthClient {
314
529
  * @param {number} [timestamp] - Optional timestamp
315
530
  * @param {string} [domain] - Optional domain for message binding
316
531
  * @returns {Promise<AuthenticationResult>} Authentication result with access_token
532
+ * @throws {NetworkError} If network request fails
533
+ * @throws {AuthenticationError} If authentication fails
317
534
  */
318
535
  async authenticate(userId, privateKeyHex, publicKeyHex, timestamp = null, domain = null) {
536
+ this._log('Starting authentication', { userId, domain });
537
+
319
538
  const ts = timestamp || Math.floor(Date.now() / 1000);
320
539
  const message = this.createAuthMessage(userId, ts, domain);
540
+
541
+ this._log('Authentication message created', message);
542
+
321
543
  const signature = await this.signAuthMessage(message, privateKeyHex);
544
+
545
+ this._log('Message signed successfully');
546
+
547
+ try {
548
+ const response = await this.fetchImpl(`${this.serverUrl}/auth/token`, {
549
+ method: "POST",
550
+ headers: {
551
+ "Content-Type": "application/json",
552
+ },
553
+ body: JSON.stringify({
554
+ user_id: userId,
555
+ timestamp: ts,
556
+ public_key: publicKeyHex,
557
+ signature: signature,
558
+ domain: domain || this._getDefaultDomain(), // Include domain in payload
559
+ }),
560
+ });
561
+
562
+ if (!response.ok) {
563
+ const errorData = await response.json().catch(() => ({}));
564
+
565
+ if (response.status === 401) {
566
+ throw new TokenExpiredError(errorData.detail || 'Authentication failed: invalid credentials');
567
+ }
568
+ if (response.status === 404) {
569
+ throw new UserNotFoundError(errorData.detail || 'User not found');
570
+ }
571
+
572
+ throw new AuthenticationError(
573
+ errorData.detail || `Authentication failed: ${response.statusText}`,
574
+ response.status
575
+ );
576
+ }
577
+
578
+ const result = await response.json();
579
+
580
+ this._log('Authentication successful', {
581
+ expires_in: result.expires_in,
582
+ token_type: result.token_type
583
+ });
584
+
585
+ // Store tokens if secure storage is available
586
+ if (this.secureStorage) {
587
+ if (result.access_token) {
588
+ this.secureStorage.setItem(this.tokenKey, result.access_token);
589
+ }
590
+ if (result.refresh_token) {
591
+ this.secureStorage.setItem(this.refreshTokenKey, result.refresh_token);
592
+ }
593
+ }
594
+
595
+ // Track token expiry and schedule refresh
596
+ if (result.expires_in) {
597
+ this.tokenExpiry = Date.now() + (result.expires_in * 1000);
598
+
599
+ // Store auth params for refresh
600
+ this._authParams = { userId, privateKeyHex, publicKeyHex, domain };
601
+
602
+ if (this.autoRefresh) {
603
+ this._scheduleRefresh();
604
+ }
605
+ }
606
+
607
+ return result;
608
+ } catch (error) {
609
+ if (error instanceof OAuthError) {
610
+ throw error;
611
+ }
612
+ throw new NetworkError(`Network error during authentication: ${error.message}`, error);
613
+ }
614
+ }
322
615
 
323
- const response = await this.fetchImpl(`${this.serverUrl}/auth/token`, {
324
- method: "POST",
325
- headers: {
326
- "Content-Type": "application/json",
327
- },
328
- body: JSON.stringify({
329
- user_id: userId,
330
- timestamp: ts,
331
- public_key: publicKeyHex,
332
- signature: signature,
333
- }),
334
- });
335
-
336
- if (!response.ok) {
337
- const error = await response.text();
338
- throw new Error(`Authentication failed: ${response.status} ${response.statusText} - ${error}`);
616
+ /**
617
+ * Get default domain for authentication
618
+ * @private
619
+ * @returns {string}
620
+ */
621
+ _getDefaultDomain() {
622
+ if (typeof window !== 'undefined' && window?.location?.host) {
623
+ return window.location.host;
339
624
  }
625
+ return 'oauth';
626
+ }
340
627
 
341
- const result = await response.json();
628
+ /**
629
+ * Schedule automatic token refresh
630
+ * @private
631
+ */
632
+ _scheduleRefresh() {
633
+ // Clear any existing timer
634
+ if (this.refreshTimer) {
635
+ clearTimeout(this.refreshTimer);
636
+ }
637
+
638
+ const refreshTime = this.tokenExpiry - (this.refreshThreshold * 1000) - Date.now();
342
639
 
343
- // Store token if secure storage is available
344
- if (this.secureStorage && result.access_token) {
345
- this.secureStorage.setItem("oauth_token", result.access_token);
640
+ if (refreshTime > 0) {
641
+ this._log(`Scheduling token refresh in ${Math.floor(refreshTime / 1000)} seconds`);
642
+
643
+ this.refreshTimer = setTimeout(() => {
644
+ this._performRefresh();
645
+ }, refreshTime);
646
+ } else {
647
+ this._log('Token expires too soon, refreshing immediately');
648
+ this._performRefresh();
346
649
  }
650
+ }
347
651
 
348
- return result;
652
+ /**
653
+ * Perform token refresh
654
+ * @private
655
+ */
656
+ async _performRefresh() {
657
+ if (!this._authParams) {
658
+ this._log('No auth params available for refresh');
659
+ return;
660
+ }
661
+
662
+ const { userId, privateKeyHex, publicKeyHex, domain } = this._authParams;
663
+
664
+ try {
665
+ await this.refreshAccessToken(userId, privateKeyHex, publicKeyHex, domain);
666
+ this._log('Token refreshed automatically');
667
+ } catch (error) {
668
+ this._log('Automatic token refresh failed', error.message);
669
+ // Clear stored tokens on refresh failure
670
+ if (this.secureStorage) {
671
+ this.secureStorage.removeItem(this.tokenKey);
672
+ this.secureStorage.removeItem(this.refreshTokenKey);
673
+ }
674
+ }
675
+ }
676
+
677
+ /**
678
+ * Refresh access token with automatic retry on expiration
679
+ * @param {string} userId
680
+ * @param {string} privateKeyHex
681
+ * @param {string} publicKeyHex
682
+ * @param {string} [domain]
683
+ * @returns {Promise<AuthenticationResult>}
684
+ * @throws {AuthenticationError} If refresh fails
685
+ */
686
+ async refreshAccessToken(userId, privateKeyHex, publicKeyHex, domain = null) {
687
+ // Prevent concurrent refresh attempts
688
+ if (this.refreshPromise) {
689
+ return this.refreshPromise;
690
+ }
691
+
692
+ this.refreshPromise = this._doRefresh(userId, privateKeyHex, publicKeyHex, domain);
693
+
694
+ try {
695
+ const result = await this.refreshPromise;
696
+ return result;
697
+ } finally {
698
+ this.refreshPromise = null;
699
+ }
700
+ }
701
+
702
+ /**
703
+ * Internal refresh implementation
704
+ * @private
705
+ */
706
+ async _doRefresh(userId, privateKeyHex, publicKeyHex, domain) {
707
+ this._log('Refreshing access token');
708
+
709
+ // Use refresh token if available, otherwise re-authenticate
710
+ const refreshToken = this.getRefreshToken();
711
+
712
+ if (refreshToken) {
713
+ try {
714
+ return await this.refreshToken(refreshToken);
715
+ } catch (error) {
716
+ this._log('Refresh token failed, falling back to re-authentication');
717
+ }
718
+ }
719
+
720
+ // Fall back to full re-authentication
721
+ return await this.authenticate(userId, privateKeyHex, publicKeyHex, null, domain);
349
722
  }
350
723
 
351
724
  /**
@@ -354,92 +727,244 @@ class BitcoinCashOAuthClient {
354
727
  */
355
728
  getToken() {
356
729
  if (this.secureStorage) {
357
- return this.secureStorage.getItem("oauth_token");
730
+ return this.secureStorage.getItem(this.tokenKey);
358
731
  }
359
732
  return null;
360
733
  }
361
734
 
362
735
  /**
363
- * Make authenticated request
364
- * @param {string} endpoint - API endpoint (relative to serverUrl)
365
- * @param {Object} [options] - Fetch options
366
- * @returns {Promise<Response>}
736
+ * Get stored refresh token
737
+ * @returns {string|null}
738
+ */
739
+ getRefreshToken() {
740
+ if (this.secureStorage) {
741
+ return this.secureStorage.getItem(this.refreshTokenKey);
742
+ }
743
+ return null;
744
+ }
745
+
746
+ /**
747
+ * Validate if the current token is still valid
748
+ * @param {boolean} [serverCheck=false] - If true, validates with the server; if false, only checks local expiry
749
+ * @returns {Promise<boolean>}
367
750
  */
368
- async authenticatedRequest(endpoint, options = {}) {
751
+ async isTokenValid(serverCheck = false) {
369
752
  const token = this.getToken();
370
753
 
371
754
  if (!token) {
372
- throw new Error("No authentication token available");
755
+ this._log('Token validation: no token stored');
756
+ return false;
757
+ }
758
+
759
+ // Local expiry check
760
+ if (this.tokenExpiry && Date.now() >= this.tokenExpiry) {
761
+ this._log('Token validation: token expired locally');
762
+ return false;
763
+ }
764
+
765
+ // Server validation (optional)
766
+ if (serverCheck) {
767
+ try {
768
+ this._log('Token validation: checking with server');
769
+ const response = await this.fetchImpl(`${this.serverUrl}/auth/verify`, {
770
+ method: "POST",
771
+ headers: {
772
+ "Authorization": `Bearer ${token}`,
773
+ "Content-Type": "application/json"
774
+ }
775
+ });
776
+
777
+ const isValid = response.ok;
778
+ this._log('Token validation: server check result', isValid);
779
+ return isValid;
780
+ } catch (error) {
781
+ this._log('Token validation: server check failed', error.message);
782
+ return false;
783
+ }
373
784
  }
374
785
 
375
- const headers = {
376
- "Authorization": `Bearer ${token}`,
377
- ...options.headers,
786
+ return true;
787
+ }
788
+
789
+ /**
790
+ * Make authenticated request with automatic retry on token expiration
791
+ * @param {string} endpoint - API endpoint (relative to serverUrl)
792
+ * @param {Object} [options] - Fetch options
793
+ * @param {Object} [authParams] - Parameters to re-authenticate if needed
794
+ * @param {string} authParams.userId - User ID
795
+ * @param {string} authParams.privateKeyHex - Private key in hex
796
+ * @param {string} authParams.publicKeyHex - Public key in hex
797
+ * @param {string} [authParams.domain] - Domain for authentication
798
+ * @returns {Promise<Response>}
799
+ * @throws {AuthenticationError} If no token available and no auth params provided
800
+ */
801
+ async authenticatedRequest(endpoint, options = {}, authParams = null) {
802
+ const makeRequest = async () => {
803
+ const token = this.getToken();
804
+ if (!token) {
805
+ throw new AuthenticationError('No authentication token available', 401, 'NO_TOKEN');
806
+ }
807
+
808
+ const headers = {
809
+ "Authorization": `Bearer ${token}`,
810
+ ...options.headers,
811
+ };
812
+
813
+ return this.fetchImpl(`${this.serverUrl}${endpoint}`, {
814
+ ...options,
815
+ headers,
816
+ });
378
817
  };
379
818
 
380
- return this.fetchImpl(`${this.serverUrl}${endpoint}`, {
381
- ...options,
382
- headers,
383
- });
819
+ let response = await makeRequest();
820
+
821
+ // If token expired and auth params provided, re-authenticate and retry
822
+ if (response.status === 401 && authParams) {
823
+ this._log('Token expired, re-authenticating...');
824
+
825
+ await this.authenticate(
826
+ authParams.userId,
827
+ authParams.privateKeyHex,
828
+ authParams.publicKeyHex,
829
+ null,
830
+ authParams.domain
831
+ );
832
+
833
+ // Retry the request with new token
834
+ response = await makeRequest();
835
+ }
836
+
837
+ return response;
384
838
  }
385
839
 
386
840
  /**
387
- * Refresh access token
841
+ * Refresh access token using refresh token
388
842
  * @param {string} refreshToken
389
843
  * @returns {Promise<AuthenticationResult>}
844
+ * @throws {NetworkError} If network request fails
845
+ * @throws {AuthenticationError} If refresh fails
390
846
  */
391
847
  async refreshToken(refreshToken) {
392
- const response = await this.fetchImpl(`${this.serverUrl}/auth/refresh`, {
393
- method: "POST",
394
- headers: {
395
- "Content-Type": "application/json",
396
- },
397
- body: JSON.stringify({
398
- refresh_token: refreshToken,
399
- }),
400
- });
401
-
402
- if (!response.ok) {
403
- const error = await response.text();
404
- throw new Error(`Token refresh failed: ${response.status} ${response.statusText} - ${error}`);
848
+ try {
849
+ const response = await this.fetchImpl(`${this.serverUrl}/auth/refresh`, {
850
+ method: "POST",
851
+ headers: {
852
+ "Content-Type": "application/json",
853
+ },
854
+ body: JSON.stringify({
855
+ refresh_token: refreshToken,
856
+ }),
857
+ });
858
+
859
+ if (!response.ok) {
860
+ const errorData = await response.json().catch(() => ({}));
861
+
862
+ if (response.status === 401) {
863
+ throw new InvalidTokenError(errorData.detail || 'Invalid or expired refresh token');
864
+ }
865
+
866
+ throw new AuthenticationError(
867
+ errorData.detail || `Token refresh failed: ${response.statusText}`,
868
+ response.status
869
+ );
870
+ }
871
+
872
+ const result = await response.json();
873
+
874
+ this._log('Token refreshed successfully');
875
+
876
+ // Update stored tokens
877
+ if (this.secureStorage) {
878
+ if (result.access_token) {
879
+ this.secureStorage.setItem(this.tokenKey, result.access_token);
880
+ }
881
+ if (result.refresh_token) {
882
+ this.secureStorage.setItem(this.refreshTokenKey, result.refresh_token);
883
+ }
884
+ }
885
+
886
+ // Update expiry and reschedule refresh
887
+ if (result.expires_in) {
888
+ this.tokenExpiry = Date.now() + (result.expires_in * 1000);
889
+
890
+ if (this.autoRefresh) {
891
+ this._scheduleRefresh();
892
+ }
893
+ }
894
+
895
+ return result;
896
+ } catch (error) {
897
+ if (error instanceof OAuthError) {
898
+ throw error;
899
+ }
900
+ throw new NetworkError(`Network error during token refresh: ${error.message}`, error);
405
901
  }
406
-
407
- const result = await response.json();
408
-
409
- if (this.secureStorage && result.access_token) {
410
- this.secureStorage.setItem("oauth_token", result.access_token);
411
- }
412
-
413
- return result;
414
902
  }
415
903
 
416
904
  /**
417
905
  * Revoke token
418
906
  * @param {string} token
419
907
  * @returns {Promise<Object>}
908
+ * @throws {NetworkError} If network request fails
420
909
  */
421
910
  async revokeToken(token) {
422
- const response = await this.fetchImpl(`${this.serverUrl}/auth/revoke`, {
423
- method: "POST",
424
- headers: {
425
- "Content-Type": "application/json",
426
- },
427
- body: JSON.stringify({
428
- token,
429
- }),
430
- });
431
-
432
- if (!response.ok) {
433
- const error = await response.text();
434
- throw new Error(`Token revocation failed: ${response.status} ${response.statusText} - ${error}`);
911
+ try {
912
+ // Clear any pending refresh
913
+ if (this.refreshTimer) {
914
+ clearTimeout(this.refreshTimer);
915
+ this.refreshTimer = null;
916
+ }
917
+
918
+ const response = await this.fetchImpl(`${this.serverUrl}/auth/revoke`, {
919
+ method: "POST",
920
+ headers: {
921
+ "Content-Type": "application/json",
922
+ },
923
+ body: JSON.stringify({
924
+ token,
925
+ }),
926
+ });
927
+
928
+ if (!response.ok) {
929
+ const errorData = await response.json().catch(() => ({}));
930
+ throw new AuthenticationError(
931
+ errorData.detail || `Token revocation failed: ${response.statusText}`,
932
+ response.status
933
+ );
934
+ }
935
+
936
+ this._log('Token revoked successfully');
937
+
938
+ // Clear stored tokens
939
+ if (this.secureStorage) {
940
+ this.secureStorage.removeItem(this.tokenKey);
941
+ this.secureStorage.removeItem(this.refreshTokenKey);
942
+ }
943
+
944
+ this.tokenExpiry = null;
945
+ this._authParams = null;
946
+
947
+ return await response.json();
948
+ } catch (error) {
949
+ if (error instanceof OAuthError) {
950
+ throw error;
951
+ }
952
+ throw new NetworkError(`Network error during token revocation: ${error.message}`, error);
435
953
  }
954
+ }
436
955
 
437
- if (this.secureStorage) {
438
- this.secureStorage.removeItem("oauth_token");
956
+ /**
957
+ * Clean up resources (clear timers, etc.)
958
+ * Call this when the client is no longer needed
959
+ */
960
+ destroy() {
961
+ if (this.refreshTimer) {
962
+ clearTimeout(this.refreshTimer);
963
+ this.refreshTimer = null;
439
964
  }
440
-
441
- return await response.json();
965
+ this._authParams = null;
966
+ this._log('Client destroyed');
442
967
  }
443
968
  }
444
969
 
445
- export { BitcoinCashOAuthClient, BitcoinCashOAuthClient as default };
970
+ export { AuthenticationError, BitcoinCashOAuthClient, InvalidTokenError, NetworkError, OAuthError, TokenExpiredError, UserNotFoundError, BitcoinCashOAuthClient as default };