@karpeleslab/teamclaude 1.0.0 → 1.0.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/package.json +1 -1
- package/src/account-manager.js +40 -0
- package/src/index.js +15 -1
- package/src/oauth.js +50 -19
- package/src/server.js +86 -17
package/package.json
CHANGED
package/src/account-manager.js
CHANGED
|
@@ -267,6 +267,46 @@ export class AccountManager {
|
|
|
267
267
|
this._onTokenRefresh = callback;
|
|
268
268
|
}
|
|
269
269
|
|
|
270
|
+
/**
|
|
271
|
+
* Capture a fresh token from a client request or intercepted token refresh.
|
|
272
|
+
* Updates the first OAuth account whose credential matches the old token,
|
|
273
|
+
* or the first expired/error OAuth account if none match.
|
|
274
|
+
*
|
|
275
|
+
* @param {string} accessToken - The new access token
|
|
276
|
+
* @param {string} [refreshToken] - New refresh token (if available from intercepted refresh)
|
|
277
|
+
* @param {number} [expiresAt] - Token expiry timestamp
|
|
278
|
+
*/
|
|
279
|
+
captureClientToken(accessToken, refreshToken, expiresAt) {
|
|
280
|
+
if (!accessToken) return;
|
|
281
|
+
|
|
282
|
+
// Check if any account already has this exact token
|
|
283
|
+
const existing = this.accounts.find(a => a.type === 'oauth' && a.credential === accessToken);
|
|
284
|
+
if (existing) {
|
|
285
|
+
// Update expiry/refresh if we have better info
|
|
286
|
+
if (expiresAt && expiresAt > (existing.expiresAt || 0)) existing.expiresAt = expiresAt;
|
|
287
|
+
if (refreshToken) existing.refreshToken = refreshToken;
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Find the best OAuth account to update: prefer expired/error accounts
|
|
292
|
+
const candidate = this.accounts.find(a =>
|
|
293
|
+
a.type === 'oauth' && (a.status === 'error' || isTokenExpiringSoon(a.expiresAt, 0))
|
|
294
|
+
) || this.accounts.find(a => a.type === 'oauth');
|
|
295
|
+
|
|
296
|
+
if (!candidate) return;
|
|
297
|
+
|
|
298
|
+
candidate.credential = accessToken;
|
|
299
|
+
if (refreshToken) candidate.refreshToken = refreshToken;
|
|
300
|
+
candidate.expiresAt = expiresAt || Date.now() + 3600 * 1000;
|
|
301
|
+
if (candidate.status === 'error') candidate.status = 'active';
|
|
302
|
+
console.log(`[TeamClaude] Captured fresh token for account "${candidate.name}"`);
|
|
303
|
+
this._onTokenRefresh?.(candidate.index, {
|
|
304
|
+
accessToken,
|
|
305
|
+
refreshToken: candidate.refreshToken,
|
|
306
|
+
expiresAt: candidate.expiresAt,
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
|
|
270
310
|
/**
|
|
271
311
|
* Add a new account at runtime.
|
|
272
312
|
*/
|
package/src/index.js
CHANGED
|
@@ -266,12 +266,14 @@ async function runCommand() {
|
|
|
266
266
|
const claudeArgs = args.slice(1);
|
|
267
267
|
if (claudeArgs[0] === '--') claudeArgs.shift();
|
|
268
268
|
|
|
269
|
+
// Only set ANTHROPIC_BASE_URL — Claude Code keeps its own OAuth token
|
|
270
|
+
// which the proxy accepts from localhost. Not setting ANTHROPIC_API_KEY
|
|
271
|
+
// lets Claude Code stay in subscription mode (full model access).
|
|
269
272
|
const child = spawn('claude', claudeArgs, {
|
|
270
273
|
stdio: 'inherit',
|
|
271
274
|
env: {
|
|
272
275
|
...process.env,
|
|
273
276
|
ANTHROPIC_BASE_URL: `http://localhost:${config.proxy.port}`,
|
|
274
|
-
ANTHROPIC_API_KEY: config.proxy.apiKey,
|
|
275
277
|
},
|
|
276
278
|
});
|
|
277
279
|
|
|
@@ -332,6 +334,7 @@ async function statusCommand() {
|
|
|
332
334
|
|
|
333
335
|
async function accountsCommand() {
|
|
334
336
|
const config = await loadOrCreateConfig();
|
|
337
|
+
const verbose = args.includes('-v') || args.includes('--verbose');
|
|
335
338
|
|
|
336
339
|
if (config.accounts.length === 0) {
|
|
337
340
|
console.log('No accounts configured.');
|
|
@@ -387,6 +390,17 @@ async function accountsCommand() {
|
|
|
387
390
|
console.log(` [${i + 1}] ${a.name} (${status}${src})`);
|
|
388
391
|
if (p?.email && p.email !== a.name) console.log(` Email: ${p.email}`);
|
|
389
392
|
if (p?.orgName) console.log(` Org: ${p.orgName}`);
|
|
393
|
+
if (verbose && a.expiresAt) {
|
|
394
|
+
const remaining = a.expiresAt - Date.now();
|
|
395
|
+
if (remaining <= 0) {
|
|
396
|
+
console.log(` Token: expired`);
|
|
397
|
+
} else {
|
|
398
|
+
const mins = Math.floor(remaining / 60000);
|
|
399
|
+
const hrs = Math.floor(mins / 60);
|
|
400
|
+
const expiry = hrs > 0 ? `${hrs}h ${mins % 60}m` : `${mins}m`;
|
|
401
|
+
console.log(` Token: expires in ${expiry}`);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
390
404
|
}
|
|
391
405
|
}
|
|
392
406
|
|
package/src/oauth.js
CHANGED
|
@@ -28,29 +28,60 @@ const DEFAULT_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
|
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
30
|
* Refresh an expired OAuth access token using the refresh token.
|
|
31
|
+
* Retries on 5xx and network errors with exponential backoff.
|
|
31
32
|
*/
|
|
32
33
|
export async function refreshAccessToken(refreshToken, endpoint = DEFAULT_TOKEN_ENDPOINT) {
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
34
|
+
const maxRetries = 2;
|
|
35
|
+
const baseDelayMs = 500;
|
|
36
|
+
|
|
37
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
38
|
+
try {
|
|
39
|
+
if (attempt > 0) {
|
|
40
|
+
const delay = baseDelayMs * 2 ** (attempt - 1);
|
|
41
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
42
|
+
}
|
|
42
43
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
const res = await fetch(endpoint, {
|
|
45
|
+
method: 'POST',
|
|
46
|
+
headers: {
|
|
47
|
+
'Content-Type': 'application/json',
|
|
48
|
+
'Accept': 'application/json, text/plain, */*',
|
|
49
|
+
'User-Agent': 'axios/1.13.6',
|
|
50
|
+
},
|
|
51
|
+
body: JSON.stringify({
|
|
52
|
+
grant_type: 'refresh_token',
|
|
53
|
+
refresh_token: refreshToken,
|
|
54
|
+
client_id: DEFAULT_CLIENT_ID,
|
|
55
|
+
}),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
if (!res.ok) {
|
|
59
|
+
if (res.status >= 500 && attempt < maxRetries) {
|
|
60
|
+
await res.body?.cancel();
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
const text = await res.text();
|
|
64
|
+
throw new Error(`Token refresh failed (${res.status}): ${text}`);
|
|
65
|
+
}
|
|
47
66
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
67
|
+
const data = await res.json();
|
|
68
|
+
return {
|
|
69
|
+
accessToken: data.access_token,
|
|
70
|
+
refreshToken: data.refresh_token || refreshToken,
|
|
71
|
+
expiresAt: data.expires_at || (Date.now() + (data.expires_in || 3600) * 1000),
|
|
72
|
+
};
|
|
73
|
+
} catch (err) {
|
|
74
|
+
const isNetworkError = err instanceof Error &&
|
|
75
|
+
(err.message.includes('fetch failed') ||
|
|
76
|
+
(err.code === 'ECONNRESET' || err.code === 'ECONNREFUSED' ||
|
|
77
|
+
err.code === 'ETIMEDOUT' || err.code === 'UND_ERR_CONNECT_TIMEOUT'));
|
|
78
|
+
|
|
79
|
+
if (attempt < maxRetries && isNetworkError) {
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
throw err;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
54
85
|
}
|
|
55
86
|
|
|
56
87
|
/**
|
package/src/server.js
CHANGED
|
@@ -19,9 +19,11 @@ export function createProxyServer(accountManager, config, hooks = {}) {
|
|
|
19
19
|
|
|
20
20
|
const server = http.createServer(async (req, res) => {
|
|
21
21
|
try {
|
|
22
|
-
// Auth check
|
|
22
|
+
// Auth check — skip for localhost connections
|
|
23
23
|
const clientKey = req.headers['x-api-key'];
|
|
24
|
-
|
|
24
|
+
const remoteAddr = req.socket.remoteAddress;
|
|
25
|
+
const isLocal = remoteAddr === '127.0.0.1' || remoteAddr === '::1' || remoteAddr === '::ffff:127.0.0.1';
|
|
26
|
+
if (proxyApiKey && clientKey !== proxyApiKey && !isLocal) {
|
|
25
27
|
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
26
28
|
res.end(JSON.stringify({
|
|
27
29
|
type: 'error',
|
|
@@ -37,6 +39,12 @@ export function createProxyServer(accountManager, config, hooks = {}) {
|
|
|
37
39
|
return;
|
|
38
40
|
}
|
|
39
41
|
|
|
42
|
+
// Intercept token refresh requests — forward to real endpoint and capture new tokens
|
|
43
|
+
if (req.method === 'POST' && req.url === '/v1/oauth/token') {
|
|
44
|
+
await handleTokenRefresh(req, res, accountManager, hooks);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
40
48
|
// Track request
|
|
41
49
|
const reqId = ++requestCounter;
|
|
42
50
|
hooks.onRequestStart?.(reqId, { method: req.method, path: req.url });
|
|
@@ -70,6 +78,64 @@ export function createProxyServer(accountManager, config, hooks = {}) {
|
|
|
70
78
|
return server;
|
|
71
79
|
}
|
|
72
80
|
|
|
81
|
+
const TOKEN_ENDPOINT = 'https://platform.claude.com/v1/oauth/token';
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Forward a token refresh request to the real token endpoint, capture new tokens,
|
|
85
|
+
* and pass the response back to the client.
|
|
86
|
+
*/
|
|
87
|
+
async function handleTokenRefresh(req, res, accountManager, hooks) {
|
|
88
|
+
const bodyChunks = [];
|
|
89
|
+
for await (const chunk of req) {
|
|
90
|
+
bodyChunks.push(chunk);
|
|
91
|
+
}
|
|
92
|
+
const body = Buffer.concat(bodyChunks);
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
// Forward to the real token endpoint
|
|
96
|
+
const upstreamRes = await fetch(TOKEN_ENDPOINT, {
|
|
97
|
+
method: 'POST',
|
|
98
|
+
headers: {
|
|
99
|
+
'Content-Type': req.headers['content-type'] || 'application/json',
|
|
100
|
+
'Accept': 'application/json, text/plain, */*',
|
|
101
|
+
'User-Agent': req.headers['user-agent'] || 'axios/1.13.6',
|
|
102
|
+
},
|
|
103
|
+
body,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const responseBody = await upstreamRes.text();
|
|
107
|
+
|
|
108
|
+
// Capture tokens from successful refresh
|
|
109
|
+
if (upstreamRes.ok) {
|
|
110
|
+
try {
|
|
111
|
+
const tokens = JSON.parse(responseBody);
|
|
112
|
+
if (tokens.access_token) {
|
|
113
|
+
accountManager.captureClientToken(tokens.access_token, tokens.refresh_token,
|
|
114
|
+
tokens.expires_at || (Date.now() + (tokens.expires_in || 3600) * 1000));
|
|
115
|
+
}
|
|
116
|
+
} catch {}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Forward response to client
|
|
120
|
+
const responseHeaders = {};
|
|
121
|
+
for (const [key, value] of upstreamRes.headers.entries()) {
|
|
122
|
+
if (key === 'transfer-encoding' || key === 'connection') continue;
|
|
123
|
+
responseHeaders[key] = value;
|
|
124
|
+
}
|
|
125
|
+
res.writeHead(upstreamRes.status, responseHeaders);
|
|
126
|
+
res.end(responseBody);
|
|
127
|
+
} catch (err) {
|
|
128
|
+
console.error('[TeamClaude] Token refresh proxy error:', err.message);
|
|
129
|
+
if (!res.headersSent) {
|
|
130
|
+
res.writeHead(502, { 'Content-Type': 'application/json' });
|
|
131
|
+
res.end(JSON.stringify({
|
|
132
|
+
type: 'error',
|
|
133
|
+
error: { type: 'proxy_error', message: `Token refresh failed: ${err.message}` },
|
|
134
|
+
}));
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
73
139
|
function logTimestamp() {
|
|
74
140
|
const d = new Date();
|
|
75
141
|
const pad = (n, w = 2) => String(n).padStart(w, '0');
|
|
@@ -101,6 +167,7 @@ async function forwardRequest(req, res, body, accountManager, upstream, retryCou
|
|
|
101
167
|
const account = accountManager.getActiveAccount();
|
|
102
168
|
if (!account) {
|
|
103
169
|
ctx.status = 429;
|
|
170
|
+
ctx.account = '(none available)';
|
|
104
171
|
const status = accountManager.getStatus();
|
|
105
172
|
const retryAfter = computeRetryAfter(status.accounts);
|
|
106
173
|
res.writeHead(429, {
|
|
@@ -128,6 +195,7 @@ async function forwardRequest(req, res, body, accountManager, upstream, retryCou
|
|
|
128
195
|
}
|
|
129
196
|
|
|
130
197
|
// Build upstream request headers
|
|
198
|
+
const isOAuth = account.type === 'oauth';
|
|
131
199
|
const headers = {};
|
|
132
200
|
for (const [key, value] of Object.entries(req.headers)) {
|
|
133
201
|
const lk = key.toLowerCase();
|
|
@@ -138,7 +206,18 @@ async function forwardRequest(req, res, body, accountManager, upstream, retryCou
|
|
|
138
206
|
if (lk === 'accept-encoding') continue;
|
|
139
207
|
headers[key] = value;
|
|
140
208
|
}
|
|
141
|
-
|
|
209
|
+
|
|
210
|
+
if (isOAuth) {
|
|
211
|
+
headers['authorization'] = `Bearer ${account.credential}`;
|
|
212
|
+
} else {
|
|
213
|
+
headers['x-api-key'] = account.credential;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Capture fresh Bearer tokens from the client to keep stored credentials up to date
|
|
217
|
+
const clientBearer = req.headers['authorization']?.match(/^Bearer (.+)/i)?.[1];
|
|
218
|
+
if (clientBearer) {
|
|
219
|
+
accountManager.captureClientToken(clientBearer);
|
|
220
|
+
}
|
|
142
221
|
|
|
143
222
|
const upstreamUrl = `${upstream}${req.url}`;
|
|
144
223
|
const method = req.method;
|
|
@@ -147,10 +226,13 @@ async function forwardRequest(req, res, body, accountManager, upstream, retryCou
|
|
|
147
226
|
const logSections = [];
|
|
148
227
|
if (logDir) {
|
|
149
228
|
const safeHeaders = { ...headers };
|
|
150
|
-
// Mask
|
|
229
|
+
// Mask credentials in logs
|
|
151
230
|
if (safeHeaders['x-api-key']) {
|
|
152
231
|
safeHeaders['x-api-key'] = safeHeaders['x-api-key'].slice(0, 15) + '...';
|
|
153
232
|
}
|
|
233
|
+
if (safeHeaders['authorization']) {
|
|
234
|
+
safeHeaders['authorization'] = safeHeaders['authorization'].slice(0, 20) + '...';
|
|
235
|
+
}
|
|
154
236
|
logSections.push(
|
|
155
237
|
`=== REQUEST (account: ${account.name}, retry: ${retryCount}) ===\n${method} ${upstreamUrl}\n${formatHeaders(safeHeaders)}`,
|
|
156
238
|
);
|
|
@@ -185,19 +267,6 @@ async function forwardRequest(req, res, body, accountManager, upstream, retryCou
|
|
|
185
267
|
logSections.push(`=== RESPONSE ${upstreamRes.status} ===\n${formatHeaders(upstreamRes.headers)}`);
|
|
186
268
|
}
|
|
187
269
|
|
|
188
|
-
// Handle 429 — retry with next account
|
|
189
|
-
if (upstreamRes.status === 429 && retryCount < maxRetries) {
|
|
190
|
-
const retryAfter = parseInt(upstreamRes.headers.get('retry-after') || '60', 10);
|
|
191
|
-
accountManager.markRateLimited(account.index, retryAfter);
|
|
192
|
-
const drainBuf = await upstreamRes.arrayBuffer();
|
|
193
|
-
if (logDir) {
|
|
194
|
-
logSections.push(`=== RESPONSE BODY (429) ===\n${Buffer.from(drainBuf).toString()}`);
|
|
195
|
-
logSections.push(`=== RETRYING with next account ===`);
|
|
196
|
-
writeRequestLog(logDir, reqId, logSections);
|
|
197
|
-
}
|
|
198
|
-
return forwardRequest(req, res, body, accountManager, upstream, retryCount + 1, hooks, reqId, ctx, logDir);
|
|
199
|
-
}
|
|
200
|
-
|
|
201
270
|
ctx.status = upstreamRes.status;
|
|
202
271
|
|
|
203
272
|
// Build response headers (skip hop-by-hop and encoding headers)
|