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