@adversity/coding-tool-x 3.0.6 → 3.1.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.
@@ -0,0 +1,378 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+ const https = require('https');
5
+ const http = require('http');
6
+ const { URL } = require('url');
7
+ const { getProviderConfig } = require('../config/oauth-providers');
8
+
9
+ // Pending OAuth flows storage with state -> flow data mapping
10
+ const pendingFlows = new Map();
11
+
12
+ // Flow expiry time: 10 minutes
13
+ const FLOW_EXPIRY_MS = 10 * 60 * 1000;
14
+
15
+ // ============================================
16
+ // PKCE Generation
17
+ // ============================================
18
+
19
+ /**
20
+ * Generate base64url encoded string from buffer
21
+ * @param {Buffer} buffer
22
+ * @returns {string}
23
+ */
24
+ function base64url(buffer) {
25
+ return buffer
26
+ .toString('base64')
27
+ .replace(/\+/g, '-')
28
+ .replace(/\//g, '_')
29
+ .replace(/=/g, '');
30
+ }
31
+
32
+ /**
33
+ * Generate PKCE code verifier and challenge
34
+ * @returns {{ codeVerifier: string, codeChallenge: string }}
35
+ */
36
+ function generatePKCE() {
37
+ // 32 bytes random -> base64url for code verifier
38
+ const codeVerifier = base64url(crypto.randomBytes(32));
39
+
40
+ // SHA-256 hash of verifier -> base64url for challenge
41
+ const hash = crypto.createHash('sha256').update(codeVerifier).digest();
42
+ const codeChallenge = base64url(hash);
43
+
44
+ return { codeVerifier, codeChallenge };
45
+ }
46
+
47
+ // ============================================
48
+ // State Management
49
+ // ============================================
50
+
51
+ /**
52
+ * Generate random state for OAuth flow
53
+ * @returns {string}
54
+ */
55
+ function generateState() {
56
+ return crypto.randomBytes(16).toString('hex');
57
+ }
58
+
59
+ /**
60
+ * Clean up expired flows (older than 10 minutes)
61
+ */
62
+ function cleanupExpiredFlows() {
63
+ const now = Date.now();
64
+ for (const [state, flow] of pendingFlows.entries()) {
65
+ if (now - flow.createdAt > FLOW_EXPIRY_MS) {
66
+ pendingFlows.delete(state);
67
+ }
68
+ }
69
+ }
70
+
71
+ // ============================================
72
+ // Auth URL Construction
73
+ // ============================================
74
+
75
+ /**
76
+ * Build OAuth authorization URL
77
+ * @param {string} provider - Provider name (claude, codex, gemini)
78
+ * @param {string} state - State parameter
79
+ * @param {string} codeChallenge - PKCE code challenge (for PKCE providers)
80
+ * @returns {string} Full authorization URL
81
+ */
82
+ function buildAuthUrl(provider, state, codeChallenge) {
83
+ const config = getProviderConfig(provider);
84
+ const url = new URL(config.authUrl);
85
+
86
+ // Common parameters
87
+ url.searchParams.set('client_id', config.clientId);
88
+ url.searchParams.set('redirect_uri', config.redirectUri);
89
+ url.searchParams.set('response_type', 'code');
90
+ url.searchParams.set('state', state);
91
+ url.searchParams.set('scope', config.scopes.join(' '));
92
+
93
+ // PKCE parameters for PKCE-enabled providers
94
+ if (config.authType === 'pkce' && codeChallenge) {
95
+ url.searchParams.set('code_challenge', codeChallenge);
96
+ url.searchParams.set('code_challenge_method', 'S256');
97
+ }
98
+
99
+ // Extra parameters from provider config
100
+ if (config.extraParams) {
101
+ for (const [key, value] of Object.entries(config.extraParams)) {
102
+ url.searchParams.set(key, value);
103
+ }
104
+ }
105
+
106
+ return url.toString();
107
+ }
108
+
109
+ // ============================================
110
+ // HTTP Request Helper
111
+ // ============================================
112
+
113
+ /**
114
+ * Make HTTP/HTTPS request
115
+ * @param {string} urlString - Full URL
116
+ * @param {Object} options - Request options
117
+ * @param {string} body - Request body
118
+ * @returns {Promise<{ statusCode: number, data: Object }>}
119
+ */
120
+ function makeRequest(urlString, options, body) {
121
+ return new Promise((resolve, reject) => {
122
+ const url = new URL(urlString);
123
+ const client = url.protocol === 'https:' ? https : http;
124
+
125
+ const reqOptions = {
126
+ hostname: url.hostname,
127
+ port: url.port || (url.protocol === 'https:' ? 443 : 80),
128
+ path: url.pathname + url.search,
129
+ method: options.method || 'POST',
130
+ headers: options.headers || {}
131
+ };
132
+
133
+ const req = client.request(reqOptions, (res) => {
134
+ let data = '';
135
+ res.on('data', (chunk) => { data += chunk; });
136
+ res.on('end', () => {
137
+ try {
138
+ const parsed = JSON.parse(data);
139
+ resolve({ statusCode: res.statusCode, data: parsed });
140
+ } catch (e) {
141
+ reject(new Error(`Failed to parse response: ${data}`));
142
+ }
143
+ });
144
+ });
145
+
146
+ req.on('error', reject);
147
+ req.on('timeout', () => {
148
+ req.destroy();
149
+ reject(new Error('Request timeout'));
150
+ });
151
+
152
+ if (body) {
153
+ req.write(body);
154
+ }
155
+ req.end();
156
+ });
157
+ }
158
+
159
+ // ============================================
160
+ // Token Exchange
161
+ // ============================================
162
+
163
+ /**
164
+ * Exchange authorization code for tokens
165
+ * @param {string} provider - Provider name
166
+ * @param {string} code - Authorization code
167
+ * @param {string} codeVerifier - PKCE code verifier (for PKCE providers)
168
+ * @returns {Promise<{ accessToken: string, refreshToken?: string, idToken?: string, expiresIn?: number, scope?: string }>}
169
+ */
170
+ async function exchangeCodeForToken(provider, code, codeVerifier) {
171
+ const config = getProviderConfig(provider);
172
+
173
+ // Build token request body
174
+ const params = new URLSearchParams();
175
+ params.set('grant_type', 'authorization_code');
176
+ params.set('code', code);
177
+ params.set('client_id', config.clientId);
178
+ params.set('redirect_uri', config.redirectUri);
179
+
180
+ // Add client_secret for standard OAuth (gemini)
181
+ if (config.clientSecret) {
182
+ params.set('client_secret', config.clientSecret);
183
+ }
184
+
185
+ // Add code_verifier for PKCE providers
186
+ if (config.authType === 'pkce' && codeVerifier) {
187
+ params.set('code_verifier', codeVerifier);
188
+ }
189
+
190
+ // Determine content type - Codex requires form-urlencoded
191
+ const contentType = config.tokenContentType || 'application/x-www-form-urlencoded';
192
+
193
+ const headers = {
194
+ 'Content-Type': contentType,
195
+ 'Accept': 'application/json'
196
+ };
197
+
198
+ const { statusCode, data } = await makeRequest(config.tokenUrl, { headers }, params.toString());
199
+
200
+ if (statusCode !== 200) {
201
+ const errorMsg = data.error_description || data.error || 'Token exchange failed';
202
+ throw new Error(`Token exchange failed (${statusCode}): ${errorMsg}`);
203
+ }
204
+
205
+ return {
206
+ accessToken: data.access_token,
207
+ refreshToken: data.refresh_token,
208
+ idToken: data.id_token,
209
+ expiresIn: data.expires_in,
210
+ scope: data.scope
211
+ };
212
+ }
213
+
214
+ // ============================================
215
+ // Token Refresh
216
+ // ============================================
217
+
218
+ /**
219
+ * Refresh access token using refresh token
220
+ * @param {string} provider - Provider name
221
+ * @param {string} refreshTokenValue - Refresh token
222
+ * @returns {Promise<{ accessToken: string, refreshToken?: string, expiresIn?: number, scope?: string }>}
223
+ */
224
+ async function refreshToken(provider, refreshTokenValue) {
225
+ const config = getProviderConfig(provider);
226
+
227
+ // Build refresh request body
228
+ const params = new URLSearchParams();
229
+ params.set('grant_type', 'refresh_token');
230
+ params.set('refresh_token', refreshTokenValue);
231
+ params.set('client_id', config.clientId);
232
+
233
+ // Add client_secret for standard OAuth (gemini)
234
+ if (config.clientSecret) {
235
+ params.set('client_secret', config.clientSecret);
236
+ }
237
+
238
+ // Determine content type
239
+ const contentType = config.tokenContentType || 'application/x-www-form-urlencoded';
240
+
241
+ const headers = {
242
+ 'Content-Type': contentType,
243
+ 'Accept': 'application/json'
244
+ };
245
+
246
+ const { statusCode, data } = await makeRequest(config.tokenUrl, { headers }, params.toString());
247
+
248
+ if (statusCode !== 200) {
249
+ const errorMsg = data.error_description || data.error || 'Token refresh failed';
250
+ throw new Error(`Token refresh failed (${statusCode}): ${errorMsg}`);
251
+ }
252
+
253
+ return {
254
+ accessToken: data.access_token,
255
+ refreshToken: data.refresh_token,
256
+ expiresIn: data.expires_in,
257
+ scope: data.scope
258
+ };
259
+ }
260
+
261
+ // ============================================
262
+ // Flow Management
263
+ // ============================================
264
+
265
+ /**
266
+ * Start a new OAuth flow
267
+ * @param {string} provider - Provider name
268
+ * @param {string} [channelId] - Optional channel ID to associate with flow
269
+ * @returns {{ state: string, authUrl: string }}
270
+ */
271
+ function startFlow(provider, channelId) {
272
+ // Clean up old flows first
273
+ cleanupExpiredFlows();
274
+
275
+ const state = generateState();
276
+ const config = getProviderConfig(provider);
277
+
278
+ let codeVerifier = null;
279
+ let codeChallenge = null;
280
+
281
+ // Generate PKCE for PKCE-enabled providers
282
+ if (config.authType === 'pkce') {
283
+ const pkce = generatePKCE();
284
+ codeVerifier = pkce.codeVerifier;
285
+ codeChallenge = pkce.codeChallenge;
286
+ }
287
+
288
+ const authUrl = buildAuthUrl(provider, state, codeChallenge);
289
+
290
+ // Store flow data
291
+ pendingFlows.set(state, {
292
+ provider,
293
+ codeVerifier,
294
+ channelId: channelId || null,
295
+ createdAt: Date.now(),
296
+ status: 'pending',
297
+ tokenId: null,
298
+ error: null
299
+ });
300
+
301
+ return { state, authUrl };
302
+ }
303
+
304
+ /**
305
+ * Get pending flow by state
306
+ * @param {string} state
307
+ * @returns {Object|null}
308
+ */
309
+ function getPendingFlow(state) {
310
+ return pendingFlows.get(state) || null;
311
+ }
312
+
313
+ /**
314
+ * Update flow with partial data
315
+ * @param {string} state
316
+ * @param {Object} updates
317
+ * @returns {boolean}
318
+ */
319
+ function updateFlow(state, updates) {
320
+ const flow = pendingFlows.get(state);
321
+ if (!flow) return false;
322
+
323
+ Object.assign(flow, updates);
324
+ return true;
325
+ }
326
+
327
+ /**
328
+ * Mark flow as completed
329
+ * @param {string} state
330
+ * @param {string} tokenId - Token ID from storage
331
+ * @returns {boolean}
332
+ */
333
+ function completeFlow(state, tokenId) {
334
+ return updateFlow(state, { status: 'completed', tokenId });
335
+ }
336
+
337
+ /**
338
+ * Mark flow as failed
339
+ * @param {string} state
340
+ * @param {string} error - Error message
341
+ * @returns {boolean}
342
+ */
343
+ function failFlow(state, error) {
344
+ return updateFlow(state, { status: 'failed', error });
345
+ }
346
+
347
+ /**
348
+ * Cancel and remove a flow
349
+ * @param {string} state
350
+ * @returns {boolean}
351
+ */
352
+ function cancelFlow(state) {
353
+ return pendingFlows.delete(state);
354
+ }
355
+
356
+ module.exports = {
357
+ // PKCE
358
+ generatePKCE,
359
+
360
+ // State
361
+ generateState,
362
+ cleanupExpiredFlows,
363
+
364
+ // Auth URL
365
+ buildAuthUrl,
366
+
367
+ // Token operations
368
+ exchangeCodeForToken,
369
+ refreshToken,
370
+
371
+ // Flow management
372
+ startFlow,
373
+ getPendingFlow,
374
+ updateFlow,
375
+ completeFlow,
376
+ failFlow,
377
+ cancelFlow
378
+ };
@@ -0,0 +1,135 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const os = require('os');
4
+
5
+ const TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1000; // 5 minutes
6
+
7
+ function getTokensFilePath() {
8
+ const dir = path.join(os.homedir(), '.claude', 'cc-tool');
9
+ if (!fs.existsSync(dir)) {
10
+ fs.mkdirSync(dir, { recursive: true });
11
+ }
12
+ return path.join(dir, 'oauth-tokens.json');
13
+ }
14
+
15
+ function loadTokens() {
16
+ const filePath = getTokensFilePath();
17
+ try {
18
+ if (fs.existsSync(filePath)) {
19
+ const content = fs.readFileSync(filePath, 'utf8');
20
+ return JSON.parse(content);
21
+ }
22
+ } catch (error) {
23
+ console.error('Error loading oauth tokens:', error);
24
+ }
25
+ return { tokens: {} };
26
+ }
27
+
28
+ function saveTokens(data) {
29
+ const filePath = getTokensFilePath();
30
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf8');
31
+
32
+ // Set file permissions to 0600 (owner read/write only) on non-Windows
33
+ if (process.platform !== 'win32') {
34
+ try {
35
+ fs.chmodSync(filePath, 0o600);
36
+ } catch (error) {
37
+ console.error('Error setting oauth tokens file permissions:', error);
38
+ }
39
+ }
40
+ }
41
+
42
+ function getToken(tokenId) {
43
+ const data = loadTokens();
44
+ return data.tokens[tokenId] || null;
45
+ }
46
+
47
+ function saveToken(tokenData) {
48
+ const data = loadTokens();
49
+ const id = tokenData.id || `token-${Date.now()}`;
50
+ const now = Date.now();
51
+
52
+ data.tokens[id] = {
53
+ ...tokenData,
54
+ id,
55
+ createdAt: tokenData.createdAt || now,
56
+ updatedAt: now
57
+ };
58
+
59
+ saveTokens(data);
60
+ return data.tokens[id];
61
+ }
62
+
63
+ function updateToken(tokenId, updates) {
64
+ const data = loadTokens();
65
+
66
+ if (!data.tokens[tokenId]) {
67
+ throw new Error('Token not found');
68
+ }
69
+
70
+ data.tokens[tokenId] = {
71
+ ...data.tokens[tokenId],
72
+ ...updates,
73
+ id: tokenId, // prevent id override
74
+ updatedAt: Date.now()
75
+ };
76
+
77
+ saveTokens(data);
78
+ return data.tokens[tokenId];
79
+ }
80
+
81
+ function deleteToken(tokenId) {
82
+ const data = loadTokens();
83
+
84
+ if (!data.tokens[tokenId]) {
85
+ throw new Error('Token not found');
86
+ }
87
+
88
+ delete data.tokens[tokenId];
89
+ saveTokens(data);
90
+ return { success: true };
91
+ }
92
+
93
+ function maskSensitiveField(value) {
94
+ if (!value || typeof value !== 'string') {
95
+ return value;
96
+ }
97
+ if (value.length <= 8) {
98
+ return '***';
99
+ }
100
+ return value.substring(0, 8) + '...';
101
+ }
102
+
103
+ function getAllTokens() {
104
+ const data = loadTokens();
105
+ return Object.values(data.tokens).map(token => ({
106
+ ...token,
107
+ accessToken: maskSensitiveField(token.accessToken),
108
+ refreshToken: maskSensitiveField(token.refreshToken)
109
+ }));
110
+ }
111
+
112
+ function getTokensByProvider(provider) {
113
+ const all = getAllTokens();
114
+ return all.filter(token => token.provider === provider);
115
+ }
116
+
117
+ function isTokenExpired(token) {
118
+ if (!token || !token.expiresAt) {
119
+ return true;
120
+ }
121
+ return Date.now() >= (token.expiresAt - TOKEN_EXPIRY_BUFFER_MS);
122
+ }
123
+
124
+ module.exports = {
125
+ loadTokens,
126
+ saveTokens,
127
+ getToken,
128
+ saveToken,
129
+ updateToken,
130
+ deleteToken,
131
+ getAllTokens,
132
+ getTokensByProvider,
133
+ isTokenExpired,
134
+ getTokensFilePath
135
+ };