delegate-sf-mcp 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/.eslintrc.json +20 -0
- package/LICENSE +24 -0
- package/README.md +76 -0
- package/auth.js +148 -0
- package/bin/config-helper.js +51 -0
- package/bin/mcp-salesforce.js +12 -0
- package/bin/setup.js +266 -0
- package/bin/status.js +134 -0
- package/docs/README.md +52 -0
- package/docs/step1.png +0 -0
- package/docs/step2.png +0 -0
- package/docs/step3.png +0 -0
- package/docs/step4.png +0 -0
- package/examples/README.md +35 -0
- package/package.json +16 -0
- package/scripts/README.md +30 -0
- package/src/auth/file-storage.js +447 -0
- package/src/auth/oauth.js +417 -0
- package/src/auth/token-manager.js +207 -0
- package/src/backup/manager.js +949 -0
- package/src/index.js +168 -0
- package/src/salesforce/client.js +388 -0
- package/src/sf-client.js +79 -0
- package/src/tools/auth.js +190 -0
- package/src/tools/backup.js +486 -0
- package/src/tools/create.js +109 -0
- package/src/tools/delegate-hygiene.js +268 -0
- package/src/tools/delegate-validate.js +212 -0
- package/src/tools/delegate-verify.js +143 -0
- package/src/tools/delete.js +72 -0
- package/src/tools/describe.js +132 -0
- package/src/tools/installation-info.js +656 -0
- package/src/tools/learn-context.js +1077 -0
- package/src/tools/learn.js +351 -0
- package/src/tools/query.js +82 -0
- package/src/tools/repair-credentials.js +77 -0
- package/src/tools/setup.js +120 -0
- package/src/tools/time_machine.js +347 -0
- package/src/tools/update.js +138 -0
- package/src/tools.js +214 -0
- package/src/utils/cache.js +120 -0
- package/src/utils/debug.js +52 -0
- package/src/utils/logger.js +19 -0
- package/tokens.json +8 -0
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import { createServer } from 'http';
|
|
3
|
+
import open from 'open';
|
|
4
|
+
import crypto from 'crypto';
|
|
5
|
+
import { logger } from '../utils/debug.js';
|
|
6
|
+
|
|
7
|
+
// Ensure fetch is available - use built-in fetch (Node.js 18+) or import node-fetch
|
|
8
|
+
const getFetch = async () => {
|
|
9
|
+
if (typeof globalThis.fetch !== 'undefined') {
|
|
10
|
+
return globalThis.fetch;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
const { default: nodeFetch } = await import('node-fetch');
|
|
15
|
+
return nodeFetch;
|
|
16
|
+
} catch (error) {
|
|
17
|
+
throw new Error('fetch is not available. Please use Node.js 18+ or install node-fetch package.');
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export class OAuthFlow {
|
|
22
|
+
constructor(clientId, clientSecret, instanceUrl, callbackPort = null) {
|
|
23
|
+
this.clientId = clientId;
|
|
24
|
+
this.clientSecret = clientSecret;
|
|
25
|
+
this.instanceUrl = instanceUrl.replace(/\/$/, ''); // Remove trailing slash
|
|
26
|
+
this.callbackPort = callbackPort || this.getPreferredPort();
|
|
27
|
+
this.state = null; // Will be generated fresh for each auth attempt
|
|
28
|
+
this.stateExpiration = null;
|
|
29
|
+
this.server = null;
|
|
30
|
+
this.retryCount = 0;
|
|
31
|
+
this.maxRetries = 3;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Get preferred port (8080 first, then random if not available)
|
|
36
|
+
*/
|
|
37
|
+
getPreferredPort() {
|
|
38
|
+
// Always try to use port 8080 first (matches most Connected App configurations)
|
|
39
|
+
return 8080;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Generate a random port between 8000-9000 as fallback
|
|
44
|
+
*/
|
|
45
|
+
getRandomPort() {
|
|
46
|
+
return Math.floor(Math.random() * 1000) + 8000;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Build the OAuth authorization URL with cache busting
|
|
51
|
+
*/
|
|
52
|
+
getAuthorizationUrl() {
|
|
53
|
+
const params = new URLSearchParams({
|
|
54
|
+
response_type: 'code',
|
|
55
|
+
client_id: this.clientId,
|
|
56
|
+
redirect_uri: `http://localhost:${this.callbackPort}/callback`,
|
|
57
|
+
scope: 'api refresh_token',
|
|
58
|
+
state: this.state,
|
|
59
|
+
prompt: 'login',
|
|
60
|
+
// Add cache busting parameter to prevent browser caching issues
|
|
61
|
+
t: Date.now().toString()
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
return `${this.instanceUrl}/services/oauth2/authorize?${params.toString()}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Validate state with expiration check
|
|
69
|
+
*/
|
|
70
|
+
isValidState(receivedState) {
|
|
71
|
+
if (Date.now() > this.stateExpiration) {
|
|
72
|
+
logger.log('⏰ OAuth state expired');
|
|
73
|
+
return { valid: false, reason: 'State expired - authentication session timed out (10 minutes). Please start a new authentication.' };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (receivedState !== this.state) {
|
|
77
|
+
logger.log('🚨 OAuth state mismatch:', {
|
|
78
|
+
received: receivedState?.substring(0, 16) + '...',
|
|
79
|
+
expected: this.state?.substring(0, 16) + '...'
|
|
80
|
+
});
|
|
81
|
+
return { valid: false, reason: 'Invalid state parameter - possible CSRF attack or browser cache issue' };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return { valid: true };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Exchange authorization code for tokens
|
|
89
|
+
*/
|
|
90
|
+
async exchangeCodeForTokens(code) {
|
|
91
|
+
const fetch = await getFetch();
|
|
92
|
+
const tokenUrl = `${this.instanceUrl}/services/oauth2/token`;
|
|
93
|
+
|
|
94
|
+
const params = new URLSearchParams({
|
|
95
|
+
grant_type: 'authorization_code',
|
|
96
|
+
client_id: this.clientId,
|
|
97
|
+
client_secret: this.clientSecret,
|
|
98
|
+
redirect_uri: `http://localhost:${this.callbackPort}/callback`,
|
|
99
|
+
code: code
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
const response = await fetch(tokenUrl, {
|
|
104
|
+
method: 'POST',
|
|
105
|
+
headers: {
|
|
106
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
107
|
+
'Accept': 'application/json'
|
|
108
|
+
},
|
|
109
|
+
body: params.toString()
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
if (!response.ok) {
|
|
113
|
+
const error = await response.text();
|
|
114
|
+
throw new Error(`Token exchange failed: ${response.status} ${error}`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const tokens = await response.json();
|
|
118
|
+
|
|
119
|
+
// Calculate expiration time
|
|
120
|
+
const expiresAt = tokens.expires_in
|
|
121
|
+
? Date.now() + (tokens.expires_in * 1000)
|
|
122
|
+
: null;
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
access_token: tokens.access_token,
|
|
126
|
+
refresh_token: tokens.refresh_token,
|
|
127
|
+
instance_url: tokens.instance_url || this.instanceUrl,
|
|
128
|
+
expires_at: expiresAt,
|
|
129
|
+
token_type: tokens.token_type || 'Bearer'
|
|
130
|
+
};
|
|
131
|
+
} catch (error) {
|
|
132
|
+
throw new Error(`Failed to exchange authorization code: ${error.message}`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Start the OAuth flow with automatic port fallback
|
|
138
|
+
*/
|
|
139
|
+
async startFlow() {
|
|
140
|
+
// Clean up any existing server
|
|
141
|
+
if (this.server) {
|
|
142
|
+
try {
|
|
143
|
+
this.server.close();
|
|
144
|
+
logger.log('🧹 Closed existing OAuth server');
|
|
145
|
+
} catch (error) {
|
|
146
|
+
logger.log('⚠️ Error closing existing server:', error.message);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Generate fresh state for this auth attempt
|
|
151
|
+
this.state = crypto.randomBytes(32).toString('hex');
|
|
152
|
+
this.stateExpiration = Date.now() + (10 * 60 * 1000);
|
|
153
|
+
|
|
154
|
+
logger.log(`🔐 Generated fresh OAuth state: ${this.state.substring(0, 16)}...`);
|
|
155
|
+
|
|
156
|
+
return new Promise((resolve, reject) => {
|
|
157
|
+
const app = express();
|
|
158
|
+
let resolved = false;
|
|
159
|
+
|
|
160
|
+
// Callback endpoint
|
|
161
|
+
app.get('/callback', async (req, res) => {
|
|
162
|
+
try {
|
|
163
|
+
const { code, state, error } = req.query;
|
|
164
|
+
|
|
165
|
+
logger.log('📥 OAuth callback received:', {
|
|
166
|
+
hasCode: !!code,
|
|
167
|
+
hasState: !!state,
|
|
168
|
+
hasError: !!error,
|
|
169
|
+
receivedState: state?.substring(0, 16) + '...',
|
|
170
|
+
expectedState: this.state?.substring(0, 16) + '...',
|
|
171
|
+
statesMatch: state === this.state
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
if (error) {
|
|
175
|
+
const errorMsg = `OAuth error: ${error}`;
|
|
176
|
+
logger.error('❌ OAuth error received:', errorMsg);
|
|
177
|
+
res.status(400).send(`<h1>Authentication Failed</h1><p>${errorMsg}</p>`);
|
|
178
|
+
if (!resolved) {
|
|
179
|
+
resolved = true;
|
|
180
|
+
reject(new Error(errorMsg));
|
|
181
|
+
}
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (state !== this.state) {
|
|
186
|
+
// Use enhanced state validation
|
|
187
|
+
const validation = this.isValidState(state);
|
|
188
|
+
const errorMsg = validation.reason;
|
|
189
|
+
|
|
190
|
+
logger.error('🚨 CSRF protection triggered:', {
|
|
191
|
+
receivedState: state,
|
|
192
|
+
expectedState: this.state,
|
|
193
|
+
receivedLength: state?.length,
|
|
194
|
+
expectedLength: this.state?.length,
|
|
195
|
+
validation: validation
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
res.status(400).send(`
|
|
199
|
+
<h1>🔐 Authentication Security Error</h1>
|
|
200
|
+
<p><strong>${errorMsg}</strong></p>
|
|
201
|
+
<details>
|
|
202
|
+
<summary>🔍 Debug Information (Click to expand)</summary>
|
|
203
|
+
<p><strong>Expected state:</strong> ${this.state?.substring(0, 16)}...</p>
|
|
204
|
+
<p><strong>Received state:</strong> ${state?.substring(0, 16)}...</p>
|
|
205
|
+
<p><strong>State expired:</strong> ${Date.now() > this.stateExpiration ? 'Yes' : 'No'}</p>
|
|
206
|
+
<p><strong>Time remaining:</strong> ${Math.max(0, Math.floor((this.stateExpiration - Date.now()) / 1000))} seconds</p>
|
|
207
|
+
<hr>
|
|
208
|
+
<p><strong>💡 Common causes and solutions:</strong></p>
|
|
209
|
+
<ul>
|
|
210
|
+
<li>🔄 <strong>Browser caching:</strong> Clear browser cache and try again</li>
|
|
211
|
+
<li>⏰ <strong>Session timeout:</strong> Authentication must complete within 10 minutes</li>
|
|
212
|
+
<li>🔀 <strong>Multiple attempts:</strong> Only one authentication session at a time</li>
|
|
213
|
+
<li>🔄 <strong>Server restart:</strong> Restart the MCP server and try again</li>
|
|
214
|
+
</ul>
|
|
215
|
+
<p><strong>🔧 Quick fix:</strong> Close this tab, restart the authentication, and complete it quickly.</p>
|
|
216
|
+
</details>
|
|
217
|
+
<br>
|
|
218
|
+
<button onclick="window.close()">Close Window</button>
|
|
219
|
+
`);
|
|
220
|
+
if (!resolved) {
|
|
221
|
+
resolved = true;
|
|
222
|
+
reject(new Error(errorMsg));
|
|
223
|
+
}
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (!code) {
|
|
228
|
+
const errorMsg = 'Authorization code not received';
|
|
229
|
+
res.status(400).send(`<h1>Authentication Failed</h1><p>${errorMsg}</p>`);
|
|
230
|
+
if (!resolved) {
|
|
231
|
+
resolved = true;
|
|
232
|
+
reject(new Error(errorMsg));
|
|
233
|
+
}
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Exchange code for tokens
|
|
238
|
+
const tokens = await this.exchangeCodeForTokens(code);
|
|
239
|
+
|
|
240
|
+
res.send(`
|
|
241
|
+
<h1>✅ Authentication Successful!</h1>
|
|
242
|
+
<p>You can now close this window and return to your terminal.</p>
|
|
243
|
+
<script>setTimeout(() => window.close(), 3000);</script>
|
|
244
|
+
`);
|
|
245
|
+
|
|
246
|
+
if (!resolved) {
|
|
247
|
+
resolved = true;
|
|
248
|
+
resolve(tokens);
|
|
249
|
+
}
|
|
250
|
+
} catch (error) {
|
|
251
|
+
res.status(500).send(`<h1>Error</h1><p>${error.message}</p>`);
|
|
252
|
+
if (!resolved) {
|
|
253
|
+
resolved = true;
|
|
254
|
+
reject(error);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
// Health check endpoint
|
|
260
|
+
app.get('/health', (req, res) => {
|
|
261
|
+
res.json({ status: 'ready', port: this.callbackPort });
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
// Create server
|
|
265
|
+
this.server = createServer(app);
|
|
266
|
+
|
|
267
|
+
// Try to start server with automatic port fallback
|
|
268
|
+
this.tryStartServer(this.server, this.callbackPort, resolve, reject);
|
|
269
|
+
|
|
270
|
+
// Handle server errors
|
|
271
|
+
this.server.on('error', (error) => {
|
|
272
|
+
if (error.code === 'EADDRINUSE' && !resolved) {
|
|
273
|
+
this.callbackPort = this.getRandomPort();
|
|
274
|
+
// Create new server instance for the new port
|
|
275
|
+
this.server = createServer(app);
|
|
276
|
+
this.tryStartServer(this.server, this.callbackPort, resolve, reject);
|
|
277
|
+
} else if (!resolved) {
|
|
278
|
+
resolved = true;
|
|
279
|
+
reject(new Error(`Server error: ${error.message}`));
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// Timeout after 5 minutes
|
|
284
|
+
setTimeout(() => {
|
|
285
|
+
if (!resolved) {
|
|
286
|
+
resolved = true;
|
|
287
|
+
reject(new Error('OAuth flow timed out after 5 minutes'));
|
|
288
|
+
}
|
|
289
|
+
}, 5 * 60 * 1000);
|
|
290
|
+
}).finally(() => {
|
|
291
|
+
// Clean up server
|
|
292
|
+
if (this.server) {
|
|
293
|
+
this.server.close();
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Try to start server on specified port
|
|
300
|
+
*/
|
|
301
|
+
tryStartServer(server, port, resolve, reject) {
|
|
302
|
+
server.listen(port, (err) => {
|
|
303
|
+
if (err) {
|
|
304
|
+
if (err.code === 'EADDRINUSE') {
|
|
305
|
+
// Port is busy, will be handled by error event
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
reject(new Error(`Failed to start callback server: ${err.message}`));
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
// Open browser for authentication (unless disabled for testing)
|
|
314
|
+
const authUrl = this.getAuthorizationUrl();
|
|
315
|
+
logger.log(`🌐 Authentication URL: ${authUrl}`);
|
|
316
|
+
|
|
317
|
+
// Check if browser opening is disabled (for testing)
|
|
318
|
+
if (process.env.NODE_ENV !== 'test' && process.env.DISABLE_BROWSER_OPEN !== 'true') {
|
|
319
|
+
logger.log('🌐 Opening browser for authentication...');
|
|
320
|
+
open(authUrl).catch(error => {
|
|
321
|
+
logger.warn('⚠️ Failed to open browser automatically:', error.message);
|
|
322
|
+
logger.log('💡 Please open the following URL manually:', authUrl);
|
|
323
|
+
});
|
|
324
|
+
} else {
|
|
325
|
+
logger.log('🚫 Browser opening disabled (test mode or DISABLE_BROWSER_OPEN=true)');
|
|
326
|
+
logger.log('💡 If this were not a test, would open:', authUrl);
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Refresh access token using refresh token
|
|
333
|
+
*/
|
|
334
|
+
async refreshAccessToken(refreshToken) {
|
|
335
|
+
const fetch = await getFetch();
|
|
336
|
+
const tokenUrl = `${this.instanceUrl}/services/oauth2/token`;
|
|
337
|
+
|
|
338
|
+
const params = new URLSearchParams({
|
|
339
|
+
grant_type: 'refresh_token',
|
|
340
|
+
client_id: this.clientId,
|
|
341
|
+
client_secret: this.clientSecret,
|
|
342
|
+
refresh_token: refreshToken
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
try {
|
|
346
|
+
const response = await fetch(tokenUrl, {
|
|
347
|
+
method: 'POST',
|
|
348
|
+
headers: {
|
|
349
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
350
|
+
'Accept': 'application/json'
|
|
351
|
+
},
|
|
352
|
+
body: params.toString()
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
if (!response.ok) {
|
|
356
|
+
const error = await response.text();
|
|
357
|
+
throw new Error(`Token refresh failed: ${response.status} ${error}`);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const tokens = await response.json();
|
|
361
|
+
|
|
362
|
+
// Calculate expiration time
|
|
363
|
+
const expiresAt = tokens.expires_in
|
|
364
|
+
? Date.now() + (tokens.expires_in * 1000)
|
|
365
|
+
: null;
|
|
366
|
+
|
|
367
|
+
return {
|
|
368
|
+
access_token: tokens.access_token,
|
|
369
|
+
expires_at: expiresAt,
|
|
370
|
+
token_type: tokens.token_type || 'Bearer'
|
|
371
|
+
};
|
|
372
|
+
} catch (error) {
|
|
373
|
+
throw new Error(`Failed to refresh access token: ${error.message}`);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Enhanced authentication with retry logic and state regeneration
|
|
379
|
+
*/
|
|
380
|
+
async authenticateWithRetry() {
|
|
381
|
+
for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
|
|
382
|
+
try {
|
|
383
|
+
logger.log(`🔄 Authentication attempt ${attempt}/${this.maxRetries}`);
|
|
384
|
+
|
|
385
|
+
// Reset state and expiration for each attempt to avoid CSRF issues
|
|
386
|
+
this.state = crypto.randomBytes(32).toString('hex');
|
|
387
|
+
this.stateExpiration = Date.now() + (10 * 60 * 1000);
|
|
388
|
+
|
|
389
|
+
logger.log(` 📝 New state generated: ${this.state.substring(0, 16)}...`);
|
|
390
|
+
logger.log(` ⏰ Expires at: ${new Date(this.stateExpiration).toISOString()}`);
|
|
391
|
+
|
|
392
|
+
const tokens = await this.startFlow();
|
|
393
|
+
logger.log('✅ Authentication successful');
|
|
394
|
+
return tokens;
|
|
395
|
+
|
|
396
|
+
} catch (error) {
|
|
397
|
+
logger.log(`❌ Attempt ${attempt} failed:`, error.message);
|
|
398
|
+
|
|
399
|
+
if (attempt === this.maxRetries) {
|
|
400
|
+
throw new Error(`Authentication failed after ${this.maxRetries} attempts: ${error.message}`);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Wait before retry (exponential backoff)
|
|
404
|
+
const waitTime = 1000 * Math.pow(2, attempt - 1);
|
|
405
|
+
logger.log(` ⏳ Waiting ${waitTime}ms before retry...`);
|
|
406
|
+
await new Promise(resolve => setTimeout(resolve, waitTime));
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Main authenticate method - uses retry logic by default
|
|
413
|
+
*/
|
|
414
|
+
async authenticate() {
|
|
415
|
+
return this.authenticateWithRetry();
|
|
416
|
+
}
|
|
417
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { FileStorageManager } from './file-storage.js';
|
|
2
|
+
import { OAuthFlow } from './oauth.js';
|
|
3
|
+
import { logger } from '../utils/debug.js';
|
|
4
|
+
|
|
5
|
+
// Ensure fetch is available - use built-in fetch (Node.js 18+) or import node-fetch
|
|
6
|
+
const getFetch = async () => {
|
|
7
|
+
if (typeof globalThis.fetch !== 'undefined') {
|
|
8
|
+
return globalThis.fetch;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
try {
|
|
12
|
+
const { default: nodeFetch } = await import('node-fetch');
|
|
13
|
+
return nodeFetch;
|
|
14
|
+
} catch (error) {
|
|
15
|
+
throw new Error('fetch is not available. Please use Node.js 18+ or install node-fetch package.');
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export class TokenManager {
|
|
20
|
+
constructor(clientId, clientSecret, instanceUrl) {
|
|
21
|
+
this.clientId = clientId;
|
|
22
|
+
this.clientSecret = clientSecret;
|
|
23
|
+
this.instanceUrl = instanceUrl;
|
|
24
|
+
this.storage = new FileStorageManager();
|
|
25
|
+
this.currentTokens = null;
|
|
26
|
+
this.refreshPromise = null; // Prevent concurrent refresh attempts
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Initialize token manager and load existing tokens
|
|
31
|
+
*/
|
|
32
|
+
async initialize() {
|
|
33
|
+
try {
|
|
34
|
+
this.currentTokens = await this.storage.getTokens();
|
|
35
|
+
if (this.currentTokens) {
|
|
36
|
+
logger.log('📋 Existing tokens loaded from storage');
|
|
37
|
+
// Check if tokens need refresh
|
|
38
|
+
if (await this.needsRefresh()) {
|
|
39
|
+
await this.refreshTokens();
|
|
40
|
+
}
|
|
41
|
+
return true;
|
|
42
|
+
} else {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
} catch (error) {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Get valid access token, refreshing if necessary
|
|
52
|
+
*/
|
|
53
|
+
async getValidAccessToken() {
|
|
54
|
+
// If no tokens, throw error
|
|
55
|
+
if (!this.currentTokens) {
|
|
56
|
+
throw new Error('No authentication tokens available. Please run setup first.');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Check if token needs refresh
|
|
60
|
+
if (await this.needsRefresh()) {
|
|
61
|
+
await this.refreshTokens();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return this.currentTokens.access_token;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Check if token needs refresh (refresh 5 minutes before expiry)
|
|
69
|
+
*/
|
|
70
|
+
async needsRefresh() {
|
|
71
|
+
if (!this.currentTokens || !this.currentTokens.expires_at) {
|
|
72
|
+
return false; // No expiry info, assume it's valid
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const bufferTime = 5 * 60 * 1000; // 5 minutes in milliseconds
|
|
76
|
+
return Date.now() >= (this.currentTokens.expires_at - bufferTime);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Refresh access token using refresh token
|
|
81
|
+
*/
|
|
82
|
+
async refreshTokens() {
|
|
83
|
+
// Prevent concurrent refresh attempts
|
|
84
|
+
if (this.refreshPromise) {
|
|
85
|
+
return this.refreshPromise;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
this.refreshPromise = this._performRefresh();
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
await this.refreshPromise;
|
|
92
|
+
} finally {
|
|
93
|
+
this.refreshPromise = null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Internal method to perform token refresh
|
|
99
|
+
*/
|
|
100
|
+
async _performRefresh() {
|
|
101
|
+
if (!this.currentTokens || !this.currentTokens.refresh_token) {
|
|
102
|
+
throw new Error('No refresh token available. Please re-authenticate.');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
const oauth = new OAuthFlow(this.clientId, this.clientSecret, this.instanceUrl);
|
|
107
|
+
const newTokens = await oauth.refreshAccessToken(this.currentTokens.refresh_token);
|
|
108
|
+
|
|
109
|
+
// Update tokens while preserving refresh token
|
|
110
|
+
this.currentTokens = {
|
|
111
|
+
...this.currentTokens,
|
|
112
|
+
access_token: newTokens.access_token,
|
|
113
|
+
expires_at: newTokens.expires_at,
|
|
114
|
+
updated_at: new Date().toISOString()
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
// Store updated tokens in file storage
|
|
118
|
+
await this.storage.storeTokens(this.currentTokens);
|
|
119
|
+
logger.log('🔄 Tokens refreshed successfully');
|
|
120
|
+
|
|
121
|
+
} catch (error) {
|
|
122
|
+
logger.error('❌ Token refresh failed:', error.message);
|
|
123
|
+
// If refresh fails, clear tokens and require re-authentication
|
|
124
|
+
await this.clearTokens();
|
|
125
|
+
throw new Error(`Token refresh failed: ${error.message}. Please run setup again.`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Perform initial OAuth flow with enhanced retry mechanism
|
|
131
|
+
*/
|
|
132
|
+
async authenticateWithOAuth() {
|
|
133
|
+
try {
|
|
134
|
+
logger.log('🚀 Starting enhanced OAuth authentication...');
|
|
135
|
+
const oauth = new OAuthFlow(this.clientId, this.clientSecret, this.instanceUrl);
|
|
136
|
+
|
|
137
|
+
// Use the enhanced authentication with retry logic
|
|
138
|
+
const tokens = await oauth.authenticateWithRetry();
|
|
139
|
+
|
|
140
|
+
logger.log('💾 Storing tokens securely...');
|
|
141
|
+
// Store tokens securely
|
|
142
|
+
await this.storage.storeTokens(tokens);
|
|
143
|
+
this.currentTokens = tokens;
|
|
144
|
+
|
|
145
|
+
logger.log('✅ OAuth authentication completed successfully');
|
|
146
|
+
return tokens;
|
|
147
|
+
} catch (error) {
|
|
148
|
+
logger.error('❌ OAuth authentication failed:', error.message);
|
|
149
|
+
throw error;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Clear all stored tokens
|
|
155
|
+
*/
|
|
156
|
+
async clearTokens() {
|
|
157
|
+
await this.storage.clearTokens();
|
|
158
|
+
this.currentTokens = null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Get current token info for debugging
|
|
163
|
+
*/
|
|
164
|
+
getTokenInfo() {
|
|
165
|
+
if (!this.currentTokens) {
|
|
166
|
+
return { authenticated: false };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
authenticated: true,
|
|
171
|
+
instance_url: this.currentTokens.instance_url,
|
|
172
|
+
expires_at: this.currentTokens.expires_at,
|
|
173
|
+
expires_in_minutes: this.currentTokens.expires_at
|
|
174
|
+
? Math.round((this.currentTokens.expires_at - Date.now()) / (1000 * 60))
|
|
175
|
+
: null,
|
|
176
|
+
stored_at: this.currentTokens.stored_at,
|
|
177
|
+
updated_at: this.currentTokens.updated_at
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Test if current tokens are valid by making a test API call
|
|
183
|
+
*/
|
|
184
|
+
async testTokens() {
|
|
185
|
+
try {
|
|
186
|
+
const fetch = await getFetch();
|
|
187
|
+
const accessToken = await this.getValidAccessToken();
|
|
188
|
+
|
|
189
|
+
// Make a simple API call to verify token validity
|
|
190
|
+
const response = await fetch(`${this.currentTokens.instance_url}/services/data/`, {
|
|
191
|
+
headers: {
|
|
192
|
+
'Authorization': `Bearer ${accessToken}`,
|
|
193
|
+
'Accept': 'application/json'
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
if (response.ok) {
|
|
198
|
+
const data = await response.json();
|
|
199
|
+
return { valid: true, apiVersions: data.length };
|
|
200
|
+
} else {
|
|
201
|
+
return { valid: false, error: response.status };
|
|
202
|
+
}
|
|
203
|
+
} catch (error) {
|
|
204
|
+
return { valid: false, error: error.message };
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|