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.browser.min.js +1 -1
- package/dist/index.cjs +632 -101
- package/dist/index.d.ts +145 -15
- package/dist/index.mjs +627 -102
- 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
|
/**
|
|
@@ -117,7 +258,7 @@ function getFetch() {
|
|
|
117
258
|
* @typedef {Object} Keypair
|
|
118
259
|
* @property {string} privateKey - Hex-encoded private key
|
|
119
260
|
* @property {string} publicKey - Hex-encoded compressed public key
|
|
120
|
-
* @property {string}
|
|
261
|
+
* @property {string} bitcoincash_address - Bitcoin Cash address
|
|
121
262
|
*/
|
|
122
263
|
|
|
123
264
|
/**
|
|
@@ -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
|
/**
|
|
@@ -192,12 +372,14 @@ class BitcoinCashOAuthClient {
|
|
|
192
372
|
const publicKeyBytes = this.secp256k1.derivePublicKeyCompressed(privateKeyBytes);
|
|
193
373
|
|
|
194
374
|
// Convert to address
|
|
195
|
-
const
|
|
375
|
+
const bitcoincash_address = await this.publicKeyToCashAddress(publicKeyBytes);
|
|
376
|
+
|
|
377
|
+
this._log('Generated new keypair', { bitcoincash_address });
|
|
196
378
|
|
|
197
379
|
return {
|
|
198
380
|
privateKey: this.bytesToHex(privateKeyBytes),
|
|
199
381
|
publicKey: this.bytesToHex(publicKeyBytes),
|
|
200
|
-
|
|
382
|
+
bitcoincash_address,
|
|
201
383
|
};
|
|
202
384
|
}
|
|
203
385
|
|
|
@@ -251,29 +433,62 @@ class BitcoinCashOAuthClient {
|
|
|
251
433
|
}
|
|
252
434
|
|
|
253
435
|
/**
|
|
254
|
-
* Register a new user with the server
|
|
255
|
-
* @param {string}
|
|
256
|
-
* @param {string}
|
|
436
|
+
* Register a new user with the server (signature required)
|
|
437
|
+
* @param {string} bitcoincash_address - Bitcoin Cash address
|
|
438
|
+
* @param {string} privateKeyHex - Private key for signing (hex-encoded)
|
|
439
|
+
* @param {string} publicKeyHex - Public key for signature verification (hex-encoded)
|
|
440
|
+
* @param {string} userId - User-provided ID (required)
|
|
441
|
+
* @param {number} [timestamp] - Optional Unix timestamp (defaults to now)
|
|
442
|
+
* @param {string} [domain] - Optional domain for message binding
|
|
257
443
|
* @returns {Promise<Object>} Registration result with assigned userId
|
|
444
|
+
* @throws {NetworkError} If network request fails
|
|
445
|
+
* @throws {AuthenticationError} If registration fails
|
|
258
446
|
*/
|
|
259
|
-
async register(
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
447
|
+
async register(bitcoincash_address, privateKeyHex, publicKeyHex, userId, timestamp = null, domain = null) {
|
|
448
|
+
try {
|
|
449
|
+
const ts = timestamp || Math.floor(Date.now() / 1000);
|
|
450
|
+
const host = domain || this._getDefaultDomain();
|
|
451
|
+
const message = this.createAuthMessage(userId, ts, host);
|
|
452
|
+
const signature = await this.signAuthMessage(message, privateKeyHex);
|
|
453
|
+
|
|
454
|
+
this._log('Registering user', { bitcoincash_address, userId, domain: host });
|
|
455
|
+
|
|
456
|
+
const response = await this.fetchImpl(`${this.serverUrl}/auth/register`, {
|
|
457
|
+
method: "POST",
|
|
458
|
+
headers: {
|
|
459
|
+
"Content-Type": "application/json",
|
|
460
|
+
},
|
|
461
|
+
body: JSON.stringify({
|
|
462
|
+
bitcoincash_address,
|
|
463
|
+
user_id: userId,
|
|
464
|
+
timestamp: ts,
|
|
465
|
+
domain: host,
|
|
466
|
+
public_key: publicKeyHex,
|
|
467
|
+
signature: signature,
|
|
468
|
+
}),
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
if (!response.ok) {
|
|
472
|
+
const errorData = await response.json().catch(() => ({}));
|
|
473
|
+
|
|
474
|
+
if (response.status === 404) {
|
|
475
|
+
throw new UserNotFoundError(errorData.detail || 'User not found');
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
throw new AuthenticationError(
|
|
479
|
+
errorData.detail || `Registration failed: ${response.statusText}`,
|
|
480
|
+
response.status
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
this._log('User registered successfully', { bitcoincash_address, userId });
|
|
485
|
+
return await response.json();
|
|
486
|
+
} catch (error) {
|
|
487
|
+
if (error instanceof OAuthError) {
|
|
488
|
+
throw error;
|
|
489
|
+
}
|
|
490
|
+
throw new NetworkError(`Network error during registration: ${error.message}`, error);
|
|
274
491
|
}
|
|
275
|
-
|
|
276
|
-
return await response.json();
|
|
277
492
|
}
|
|
278
493
|
|
|
279
494
|
/**
|
|
@@ -318,38 +533,196 @@ class BitcoinCashOAuthClient {
|
|
|
318
533
|
* @param {number} [timestamp] - Optional timestamp
|
|
319
534
|
* @param {string} [domain] - Optional domain for message binding
|
|
320
535
|
* @returns {Promise<AuthenticationResult>} Authentication result with access_token
|
|
536
|
+
* @throws {NetworkError} If network request fails
|
|
537
|
+
* @throws {AuthenticationError} If authentication fails
|
|
321
538
|
*/
|
|
322
539
|
async authenticate(userId, privateKeyHex, publicKeyHex, timestamp = null, domain = null) {
|
|
540
|
+
this._log('Starting authentication', { userId, domain });
|
|
541
|
+
|
|
323
542
|
const ts = timestamp || Math.floor(Date.now() / 1000);
|
|
324
543
|
const message = this.createAuthMessage(userId, ts, domain);
|
|
544
|
+
|
|
545
|
+
this._log('Authentication message created', message);
|
|
546
|
+
|
|
325
547
|
const signature = await this.signAuthMessage(message, privateKeyHex);
|
|
548
|
+
|
|
549
|
+
this._log('Message signed successfully');
|
|
550
|
+
|
|
551
|
+
try {
|
|
552
|
+
const response = await this.fetchImpl(`${this.serverUrl}/auth/token`, {
|
|
553
|
+
method: "POST",
|
|
554
|
+
headers: {
|
|
555
|
+
"Content-Type": "application/json",
|
|
556
|
+
},
|
|
557
|
+
body: JSON.stringify({
|
|
558
|
+
user_id: userId,
|
|
559
|
+
timestamp: ts,
|
|
560
|
+
public_key: publicKeyHex,
|
|
561
|
+
signature: signature,
|
|
562
|
+
domain: domain || this._getDefaultDomain(), // Include domain in payload
|
|
563
|
+
}),
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
if (!response.ok) {
|
|
567
|
+
const errorData = await response.json().catch(() => ({}));
|
|
568
|
+
|
|
569
|
+
if (response.status === 401) {
|
|
570
|
+
throw new TokenExpiredError(errorData.detail || 'Authentication failed: invalid credentials');
|
|
571
|
+
}
|
|
572
|
+
if (response.status === 404) {
|
|
573
|
+
throw new UserNotFoundError(errorData.detail || 'User not found');
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
throw new AuthenticationError(
|
|
577
|
+
errorData.detail || `Authentication failed: ${response.statusText}`,
|
|
578
|
+
response.status
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const result = await response.json();
|
|
583
|
+
|
|
584
|
+
this._log('Authentication successful', {
|
|
585
|
+
expires_in: result.expires_in,
|
|
586
|
+
token_type: result.token_type
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
// Store tokens if secure storage is available
|
|
590
|
+
if (this.secureStorage) {
|
|
591
|
+
if (result.access_token) {
|
|
592
|
+
this.secureStorage.setItem(this.tokenKey, result.access_token);
|
|
593
|
+
}
|
|
594
|
+
if (result.refresh_token) {
|
|
595
|
+
this.secureStorage.setItem(this.refreshTokenKey, result.refresh_token);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// Track token expiry and schedule refresh
|
|
600
|
+
if (result.expires_in) {
|
|
601
|
+
this.tokenExpiry = Date.now() + (result.expires_in * 1000);
|
|
602
|
+
|
|
603
|
+
// Store auth params for refresh
|
|
604
|
+
this._authParams = { userId, privateKeyHex, publicKeyHex, domain };
|
|
605
|
+
|
|
606
|
+
if (this.autoRefresh) {
|
|
607
|
+
this._scheduleRefresh();
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
return result;
|
|
612
|
+
} catch (error) {
|
|
613
|
+
if (error instanceof OAuthError) {
|
|
614
|
+
throw error;
|
|
615
|
+
}
|
|
616
|
+
throw new NetworkError(`Network error during authentication: ${error.message}`, error);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
326
619
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
public_key: publicKeyHex,
|
|
336
|
-
signature: signature,
|
|
337
|
-
}),
|
|
338
|
-
});
|
|
339
|
-
|
|
340
|
-
if (!response.ok) {
|
|
341
|
-
const error = await response.text();
|
|
342
|
-
throw new Error(`Authentication failed: ${response.status} ${response.statusText} - ${error}`);
|
|
620
|
+
/**
|
|
621
|
+
* Get default domain for authentication
|
|
622
|
+
* @private
|
|
623
|
+
* @returns {string}
|
|
624
|
+
*/
|
|
625
|
+
_getDefaultDomain() {
|
|
626
|
+
if (typeof window !== 'undefined' && window?.location?.host) {
|
|
627
|
+
return window.location.host;
|
|
343
628
|
}
|
|
629
|
+
return 'oauth';
|
|
630
|
+
}
|
|
344
631
|
|
|
345
|
-
|
|
632
|
+
/**
|
|
633
|
+
* Schedule automatic token refresh
|
|
634
|
+
* @private
|
|
635
|
+
*/
|
|
636
|
+
_scheduleRefresh() {
|
|
637
|
+
// Clear any existing timer
|
|
638
|
+
if (this.refreshTimer) {
|
|
639
|
+
clearTimeout(this.refreshTimer);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
const refreshTime = this.tokenExpiry - (this.refreshThreshold * 1000) - Date.now();
|
|
346
643
|
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
644
|
+
if (refreshTime > 0) {
|
|
645
|
+
this._log(`Scheduling token refresh in ${Math.floor(refreshTime / 1000)} seconds`);
|
|
646
|
+
|
|
647
|
+
this.refreshTimer = setTimeout(() => {
|
|
648
|
+
this._performRefresh();
|
|
649
|
+
}, refreshTime);
|
|
650
|
+
} else {
|
|
651
|
+
this._log('Token expires too soon, refreshing immediately');
|
|
652
|
+
this._performRefresh();
|
|
350
653
|
}
|
|
654
|
+
}
|
|
351
655
|
|
|
352
|
-
|
|
656
|
+
/**
|
|
657
|
+
* Perform token refresh
|
|
658
|
+
* @private
|
|
659
|
+
*/
|
|
660
|
+
async _performRefresh() {
|
|
661
|
+
if (!this._authParams) {
|
|
662
|
+
this._log('No auth params available for refresh');
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
const { userId, privateKeyHex, publicKeyHex, domain } = this._authParams;
|
|
667
|
+
|
|
668
|
+
try {
|
|
669
|
+
await this.refreshAccessToken(userId, privateKeyHex, publicKeyHex, domain);
|
|
670
|
+
this._log('Token refreshed automatically');
|
|
671
|
+
} catch (error) {
|
|
672
|
+
this._log('Automatic token refresh failed', error.message);
|
|
673
|
+
// Clear stored tokens on refresh failure
|
|
674
|
+
if (this.secureStorage) {
|
|
675
|
+
this.secureStorage.removeItem(this.tokenKey);
|
|
676
|
+
this.secureStorage.removeItem(this.refreshTokenKey);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* Refresh access token with automatic retry on expiration
|
|
683
|
+
* @param {string} userId
|
|
684
|
+
* @param {string} privateKeyHex
|
|
685
|
+
* @param {string} publicKeyHex
|
|
686
|
+
* @param {string} [domain]
|
|
687
|
+
* @returns {Promise<AuthenticationResult>}
|
|
688
|
+
* @throws {AuthenticationError} If refresh fails
|
|
689
|
+
*/
|
|
690
|
+
async refreshAccessToken(userId, privateKeyHex, publicKeyHex, domain = null) {
|
|
691
|
+
// Prevent concurrent refresh attempts
|
|
692
|
+
if (this.refreshPromise) {
|
|
693
|
+
return this.refreshPromise;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
this.refreshPromise = this._doRefresh(userId, privateKeyHex, publicKeyHex, domain);
|
|
697
|
+
|
|
698
|
+
try {
|
|
699
|
+
const result = await this.refreshPromise;
|
|
700
|
+
return result;
|
|
701
|
+
} finally {
|
|
702
|
+
this.refreshPromise = null;
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* Internal refresh implementation
|
|
708
|
+
* @private
|
|
709
|
+
*/
|
|
710
|
+
async _doRefresh(userId, privateKeyHex, publicKeyHex, domain) {
|
|
711
|
+
this._log('Refreshing access token');
|
|
712
|
+
|
|
713
|
+
// Use refresh token if available, otherwise re-authenticate
|
|
714
|
+
const refreshToken = this.getRefreshToken();
|
|
715
|
+
|
|
716
|
+
if (refreshToken) {
|
|
717
|
+
try {
|
|
718
|
+
return await this.refreshToken(refreshToken);
|
|
719
|
+
} catch (error) {
|
|
720
|
+
this._log('Refresh token failed, falling back to re-authentication');
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// Fall back to full re-authentication
|
|
725
|
+
return await this.authenticate(userId, privateKeyHex, publicKeyHex, null, domain);
|
|
353
726
|
}
|
|
354
727
|
|
|
355
728
|
/**
|
|
@@ -358,93 +731,251 @@ class BitcoinCashOAuthClient {
|
|
|
358
731
|
*/
|
|
359
732
|
getToken() {
|
|
360
733
|
if (this.secureStorage) {
|
|
361
|
-
return this.secureStorage.getItem(
|
|
734
|
+
return this.secureStorage.getItem(this.tokenKey);
|
|
362
735
|
}
|
|
363
736
|
return null;
|
|
364
737
|
}
|
|
365
738
|
|
|
366
739
|
/**
|
|
367
|
-
*
|
|
368
|
-
* @
|
|
369
|
-
|
|
370
|
-
|
|
740
|
+
* Get stored refresh token
|
|
741
|
+
* @returns {string|null}
|
|
742
|
+
*/
|
|
743
|
+
getRefreshToken() {
|
|
744
|
+
if (this.secureStorage) {
|
|
745
|
+
return this.secureStorage.getItem(this.refreshTokenKey);
|
|
746
|
+
}
|
|
747
|
+
return null;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/**
|
|
751
|
+
* Validate if the current token is still valid
|
|
752
|
+
* @param {boolean} [serverCheck=false] - If true, validates with the server; if false, only checks local expiry
|
|
753
|
+
* @returns {Promise<boolean>}
|
|
371
754
|
*/
|
|
372
|
-
async
|
|
755
|
+
async isTokenValid(serverCheck = false) {
|
|
373
756
|
const token = this.getToken();
|
|
374
757
|
|
|
375
758
|
if (!token) {
|
|
376
|
-
|
|
759
|
+
this._log('Token validation: no token stored');
|
|
760
|
+
return false;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// Local expiry check
|
|
764
|
+
if (this.tokenExpiry && Date.now() >= this.tokenExpiry) {
|
|
765
|
+
this._log('Token validation: token expired locally');
|
|
766
|
+
return false;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// Server validation (optional)
|
|
770
|
+
if (serverCheck) {
|
|
771
|
+
try {
|
|
772
|
+
this._log('Token validation: checking with server');
|
|
773
|
+
const response = await this.fetchImpl(`${this.serverUrl}/auth/verify`, {
|
|
774
|
+
method: "POST",
|
|
775
|
+
headers: {
|
|
776
|
+
"Authorization": `Bearer ${token}`,
|
|
777
|
+
"Content-Type": "application/json"
|
|
778
|
+
}
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
const isValid = response.ok;
|
|
782
|
+
this._log('Token validation: server check result', isValid);
|
|
783
|
+
return isValid;
|
|
784
|
+
} catch (error) {
|
|
785
|
+
this._log('Token validation: server check failed', error.message);
|
|
786
|
+
return false;
|
|
787
|
+
}
|
|
377
788
|
}
|
|
378
789
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
790
|
+
return true;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
/**
|
|
794
|
+
* Make authenticated request with automatic retry on token expiration
|
|
795
|
+
* @param {string} endpoint - API endpoint (relative to serverUrl)
|
|
796
|
+
* @param {Object} [options] - Fetch options
|
|
797
|
+
* @param {Object} [authParams] - Parameters to re-authenticate if needed
|
|
798
|
+
* @param {string} authParams.userId - User ID
|
|
799
|
+
* @param {string} authParams.privateKeyHex - Private key in hex
|
|
800
|
+
* @param {string} authParams.publicKeyHex - Public key in hex
|
|
801
|
+
* @param {string} [authParams.domain] - Domain for authentication
|
|
802
|
+
* @returns {Promise<Response>}
|
|
803
|
+
* @throws {AuthenticationError} If no token available and no auth params provided
|
|
804
|
+
*/
|
|
805
|
+
async authenticatedRequest(endpoint, options = {}, authParams = null) {
|
|
806
|
+
const makeRequest = async () => {
|
|
807
|
+
const token = this.getToken();
|
|
808
|
+
if (!token) {
|
|
809
|
+
throw new AuthenticationError('No authentication token available', 401, 'NO_TOKEN');
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
const headers = {
|
|
813
|
+
"Authorization": `Bearer ${token}`,
|
|
814
|
+
...options.headers,
|
|
815
|
+
};
|
|
816
|
+
|
|
817
|
+
return this.fetchImpl(`${this.serverUrl}${endpoint}`, {
|
|
818
|
+
...options,
|
|
819
|
+
headers,
|
|
820
|
+
});
|
|
382
821
|
};
|
|
383
822
|
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
823
|
+
let response = await makeRequest();
|
|
824
|
+
|
|
825
|
+
// If token expired and auth params provided, re-authenticate and retry
|
|
826
|
+
if (response.status === 401 && authParams) {
|
|
827
|
+
this._log('Token expired, re-authenticating...');
|
|
828
|
+
|
|
829
|
+
await this.authenticate(
|
|
830
|
+
authParams.userId,
|
|
831
|
+
authParams.privateKeyHex,
|
|
832
|
+
authParams.publicKeyHex,
|
|
833
|
+
null,
|
|
834
|
+
authParams.domain
|
|
835
|
+
);
|
|
836
|
+
|
|
837
|
+
// Retry the request with new token
|
|
838
|
+
response = await makeRequest();
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
return response;
|
|
388
842
|
}
|
|
389
843
|
|
|
390
844
|
/**
|
|
391
|
-
* Refresh access token
|
|
845
|
+
* Refresh access token using refresh token
|
|
392
846
|
* @param {string} refreshToken
|
|
393
847
|
* @returns {Promise<AuthenticationResult>}
|
|
848
|
+
* @throws {NetworkError} If network request fails
|
|
849
|
+
* @throws {AuthenticationError} If refresh fails
|
|
394
850
|
*/
|
|
395
851
|
async refreshToken(refreshToken) {
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
852
|
+
try {
|
|
853
|
+
const response = await this.fetchImpl(`${this.serverUrl}/auth/refresh`, {
|
|
854
|
+
method: "POST",
|
|
855
|
+
headers: {
|
|
856
|
+
"Content-Type": "application/json",
|
|
857
|
+
},
|
|
858
|
+
body: JSON.stringify({
|
|
859
|
+
refresh_token: refreshToken,
|
|
860
|
+
}),
|
|
861
|
+
});
|
|
862
|
+
|
|
863
|
+
if (!response.ok) {
|
|
864
|
+
const errorData = await response.json().catch(() => ({}));
|
|
865
|
+
|
|
866
|
+
if (response.status === 401) {
|
|
867
|
+
throw new InvalidTokenError(errorData.detail || 'Invalid or expired refresh token');
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
throw new AuthenticationError(
|
|
871
|
+
errorData.detail || `Token refresh failed: ${response.statusText}`,
|
|
872
|
+
response.status
|
|
873
|
+
);
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
const result = await response.json();
|
|
877
|
+
|
|
878
|
+
this._log('Token refreshed successfully');
|
|
879
|
+
|
|
880
|
+
// Update stored tokens
|
|
881
|
+
if (this.secureStorage) {
|
|
882
|
+
if (result.access_token) {
|
|
883
|
+
this.secureStorage.setItem(this.tokenKey, result.access_token);
|
|
884
|
+
}
|
|
885
|
+
if (result.refresh_token) {
|
|
886
|
+
this.secureStorage.setItem(this.refreshTokenKey, result.refresh_token);
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// Update expiry and reschedule refresh
|
|
891
|
+
if (result.expires_in) {
|
|
892
|
+
this.tokenExpiry = Date.now() + (result.expires_in * 1000);
|
|
893
|
+
|
|
894
|
+
if (this.autoRefresh) {
|
|
895
|
+
this._scheduleRefresh();
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
return result;
|
|
900
|
+
} catch (error) {
|
|
901
|
+
if (error instanceof OAuthError) {
|
|
902
|
+
throw error;
|
|
903
|
+
}
|
|
904
|
+
throw new NetworkError(`Network error during token refresh: ${error.message}`, error);
|
|
409
905
|
}
|
|
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
906
|
}
|
|
419
907
|
|
|
420
908
|
/**
|
|
421
909
|
* Revoke token
|
|
422
910
|
* @param {string} token
|
|
423
911
|
* @returns {Promise<Object>}
|
|
912
|
+
* @throws {NetworkError} If network request fails
|
|
424
913
|
*/
|
|
425
914
|
async revokeToken(token) {
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
915
|
+
try {
|
|
916
|
+
// Clear any pending refresh
|
|
917
|
+
if (this.refreshTimer) {
|
|
918
|
+
clearTimeout(this.refreshTimer);
|
|
919
|
+
this.refreshTimer = null;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
const response = await this.fetchImpl(`${this.serverUrl}/auth/revoke`, {
|
|
923
|
+
method: "POST",
|
|
924
|
+
headers: {
|
|
925
|
+
"Content-Type": "application/json",
|
|
926
|
+
},
|
|
927
|
+
body: JSON.stringify({
|
|
928
|
+
token,
|
|
929
|
+
}),
|
|
930
|
+
});
|
|
931
|
+
|
|
932
|
+
if (!response.ok) {
|
|
933
|
+
const errorData = await response.json().catch(() => ({}));
|
|
934
|
+
throw new AuthenticationError(
|
|
935
|
+
errorData.detail || `Token revocation failed: ${response.statusText}`,
|
|
936
|
+
response.status
|
|
937
|
+
);
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
this._log('Token revoked successfully');
|
|
941
|
+
|
|
942
|
+
// Clear stored tokens
|
|
943
|
+
if (this.secureStorage) {
|
|
944
|
+
this.secureStorage.removeItem(this.tokenKey);
|
|
945
|
+
this.secureStorage.removeItem(this.refreshTokenKey);
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
this.tokenExpiry = null;
|
|
949
|
+
this._authParams = null;
|
|
950
|
+
|
|
951
|
+
return await response.json();
|
|
952
|
+
} catch (error) {
|
|
953
|
+
if (error instanceof OAuthError) {
|
|
954
|
+
throw error;
|
|
955
|
+
}
|
|
956
|
+
throw new NetworkError(`Network error during token revocation: ${error.message}`, error);
|
|
439
957
|
}
|
|
958
|
+
}
|
|
440
959
|
|
|
441
|
-
|
|
442
|
-
|
|
960
|
+
/**
|
|
961
|
+
* Clean up resources (clear timers, etc.)
|
|
962
|
+
* Call this when the client is no longer needed
|
|
963
|
+
*/
|
|
964
|
+
destroy() {
|
|
965
|
+
if (this.refreshTimer) {
|
|
966
|
+
clearTimeout(this.refreshTimer);
|
|
967
|
+
this.refreshTimer = null;
|
|
443
968
|
}
|
|
444
|
-
|
|
445
|
-
|
|
969
|
+
this._authParams = null;
|
|
970
|
+
this._log('Client destroyed');
|
|
446
971
|
}
|
|
447
972
|
}
|
|
448
973
|
|
|
974
|
+
exports.AuthenticationError = AuthenticationError;
|
|
449
975
|
exports.BitcoinCashOAuthClient = BitcoinCashOAuthClient;
|
|
976
|
+
exports.InvalidTokenError = InvalidTokenError;
|
|
977
|
+
exports.NetworkError = NetworkError;
|
|
978
|
+
exports.OAuthError = OAuthError;
|
|
979
|
+
exports.TokenExpiredError = TokenExpiredError;
|
|
980
|
+
exports.UserNotFoundError = UserNotFoundError;
|
|
450
981
|
exports.default = BitcoinCashOAuthClient;
|