@karpeleslab/teamclaude 1.0.1 → 1.0.3
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/oauth.js +50 -19
- package/src/server.js +94 -2
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/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
|
@@ -39,6 +39,12 @@ export function createProxyServer(accountManager, config, hooks = {}) {
|
|
|
39
39
|
return;
|
|
40
40
|
}
|
|
41
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
|
+
|
|
42
48
|
// Track request
|
|
43
49
|
const reqId = ++requestCounter;
|
|
44
50
|
hooks.onRequestStart?.(reqId, { method: req.method, path: req.url });
|
|
@@ -72,6 +78,77 @@ export function createProxyServer(accountManager, config, hooks = {}) {
|
|
|
72
78
|
return server;
|
|
73
79
|
}
|
|
74
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 rawBody = Buffer.concat(bodyChunks);
|
|
93
|
+
|
|
94
|
+
// Replace the refresh token in the request with the current account's refresh token
|
|
95
|
+
// so the renewal happens for the active account, not just the one the client knows about
|
|
96
|
+
let body = rawBody;
|
|
97
|
+
try {
|
|
98
|
+
const parsed = JSON.parse(rawBody.toString());
|
|
99
|
+
const currentAccount = accountManager.accounts[accountManager.currentIndex];
|
|
100
|
+
if (parsed.grant_type === 'refresh_token' && currentAccount?.type === 'oauth' && currentAccount.refreshToken) {
|
|
101
|
+
parsed.refresh_token = currentAccount.refreshToken;
|
|
102
|
+
body = JSON.stringify(parsed);
|
|
103
|
+
console.log(`[TeamClaude] Token refresh: substituted refresh token for account "${currentAccount.name}"`);
|
|
104
|
+
}
|
|
105
|
+
} catch {}
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
// Forward to the real token endpoint
|
|
109
|
+
const upstreamRes = await fetch(TOKEN_ENDPOINT, {
|
|
110
|
+
method: 'POST',
|
|
111
|
+
headers: {
|
|
112
|
+
'Content-Type': req.headers['content-type'] || 'application/json',
|
|
113
|
+
'Accept': 'application/json, text/plain, */*',
|
|
114
|
+
'User-Agent': req.headers['user-agent'] || 'axios/1.13.6',
|
|
115
|
+
},
|
|
116
|
+
body,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const responseBody = await upstreamRes.text();
|
|
120
|
+
|
|
121
|
+
// Capture tokens from successful refresh
|
|
122
|
+
if (upstreamRes.ok) {
|
|
123
|
+
try {
|
|
124
|
+
const tokens = JSON.parse(responseBody);
|
|
125
|
+
if (tokens.access_token) {
|
|
126
|
+
accountManager.captureClientToken(tokens.access_token, tokens.refresh_token,
|
|
127
|
+
tokens.expires_at || (Date.now() + (tokens.expires_in || 3600) * 1000));
|
|
128
|
+
}
|
|
129
|
+
} catch {}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Forward response to client
|
|
133
|
+
const responseHeaders = {};
|
|
134
|
+
for (const [key, value] of upstreamRes.headers.entries()) {
|
|
135
|
+
if (key === 'transfer-encoding' || key === 'connection') continue;
|
|
136
|
+
responseHeaders[key] = value;
|
|
137
|
+
}
|
|
138
|
+
res.writeHead(upstreamRes.status, responseHeaders);
|
|
139
|
+
res.end(responseBody);
|
|
140
|
+
} catch (err) {
|
|
141
|
+
console.error('[TeamClaude] Token refresh proxy error:', err.message);
|
|
142
|
+
if (!res.headersSent) {
|
|
143
|
+
res.writeHead(502, { 'Content-Type': 'application/json' });
|
|
144
|
+
res.end(JSON.stringify({
|
|
145
|
+
type: 'error',
|
|
146
|
+
error: { type: 'proxy_error', message: `Token refresh failed: ${err.message}` },
|
|
147
|
+
}));
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
75
152
|
function logTimestamp() {
|
|
76
153
|
const d = new Date();
|
|
77
154
|
const pad = (n, w = 2) => String(n).padStart(w, '0');
|
|
@@ -131,6 +208,7 @@ async function forwardRequest(req, res, body, accountManager, upstream, retryCou
|
|
|
131
208
|
}
|
|
132
209
|
|
|
133
210
|
// Build upstream request headers
|
|
211
|
+
const isOAuth = account.type === 'oauth';
|
|
134
212
|
const headers = {};
|
|
135
213
|
for (const [key, value] of Object.entries(req.headers)) {
|
|
136
214
|
const lk = key.toLowerCase();
|
|
@@ -141,7 +219,18 @@ async function forwardRequest(req, res, body, accountManager, upstream, retryCou
|
|
|
141
219
|
if (lk === 'accept-encoding') continue;
|
|
142
220
|
headers[key] = value;
|
|
143
221
|
}
|
|
144
|
-
|
|
222
|
+
|
|
223
|
+
if (isOAuth) {
|
|
224
|
+
headers['authorization'] = `Bearer ${account.credential}`;
|
|
225
|
+
} else {
|
|
226
|
+
headers['x-api-key'] = account.credential;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Capture fresh Bearer tokens from the client to keep stored credentials up to date
|
|
230
|
+
const clientBearer = req.headers['authorization']?.match(/^Bearer (.+)/i)?.[1];
|
|
231
|
+
if (clientBearer) {
|
|
232
|
+
accountManager.captureClientToken(clientBearer);
|
|
233
|
+
}
|
|
145
234
|
|
|
146
235
|
const upstreamUrl = `${upstream}${req.url}`;
|
|
147
236
|
const method = req.method;
|
|
@@ -150,10 +239,13 @@ async function forwardRequest(req, res, body, accountManager, upstream, retryCou
|
|
|
150
239
|
const logSections = [];
|
|
151
240
|
if (logDir) {
|
|
152
241
|
const safeHeaders = { ...headers };
|
|
153
|
-
// Mask
|
|
242
|
+
// Mask credentials in logs
|
|
154
243
|
if (safeHeaders['x-api-key']) {
|
|
155
244
|
safeHeaders['x-api-key'] = safeHeaders['x-api-key'].slice(0, 15) + '...';
|
|
156
245
|
}
|
|
246
|
+
if (safeHeaders['authorization']) {
|
|
247
|
+
safeHeaders['authorization'] = safeHeaders['authorization'].slice(0, 20) + '...';
|
|
248
|
+
}
|
|
157
249
|
logSections.push(
|
|
158
250
|
`=== REQUEST (account: ${account.name}, retry: ${retryCount}) ===\n${method} ${upstreamUrl}\n${formatHeaders(safeHeaders)}`,
|
|
159
251
|
);
|