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.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
|
-
|
|
234
|
+
// Bind to globalThis to avoid Window issues in some environments
|
|
235
|
+
return fetch.bind(globalThis);
|
|
99
236
|
}
|
|
100
237
|
|
|
101
|
-
throw new
|
|
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}
|
|
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
|
|
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
|
-
|
|
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}
|
|
252
|
-
* @param {string}
|
|
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(
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
-
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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
|
-
|
|
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(
|
|
730
|
+
return this.secureStorage.getItem(this.tokenKey);
|
|
358
731
|
}
|
|
359
732
|
return null;
|
|
360
733
|
}
|
|
361
734
|
|
|
362
735
|
/**
|
|
363
|
-
*
|
|
364
|
-
* @
|
|
365
|
-
|
|
366
|
-
|
|
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
|
|
751
|
+
async isTokenValid(serverCheck = false) {
|
|
369
752
|
const token = this.getToken();
|
|
370
753
|
|
|
371
754
|
if (!token) {
|
|
372
|
-
|
|
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
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
-
|
|
438
|
-
|
|
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
|
-
|
|
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 };
|