teamclaude-cloud 1.4.1

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/src/oauth.js ADDED
@@ -0,0 +1,347 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { randomBytes, createHash } from 'node:crypto';
4
+ import { exec } from 'node:child_process';
5
+ import { createInterface } from 'node:readline';
6
+ import http from 'node:http';
7
+
8
+ /**
9
+ * Import OAuth credentials from a Claude Code credentials file.
10
+ */
11
+ export async function importCredentials(filePath) {
12
+ const resolvedPath = filePath.replace(/^~/, homedir());
13
+ const raw = JSON.parse(await readFile(resolvedPath, 'utf-8'));
14
+
15
+ // Claude Code stores credentials nested under "claudeAiOauth"
16
+ const data = raw.claudeAiOauth || raw;
17
+ return {
18
+ accessToken: data.accessToken,
19
+ refreshToken: data.refreshToken,
20
+ expiresAt: data.expiresAt,
21
+ subscriptionType: data.subscriptionType,
22
+ rateLimitTier: data.rateLimitTier,
23
+ };
24
+ }
25
+
26
+ const PROFILE_URL = 'https://api.anthropic.com/api/oauth/profile';
27
+ const DEFAULT_TOKEN_ENDPOINT = 'https://platform.claude.com/v1/oauth/token';
28
+ const DEFAULT_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
29
+ // Bound the WHOLE token refresh (all retry attempts combined) so a hung token
30
+ // endpoint can't block the refresh — and every request coalesced onto it /
31
+ // holding an account slot — for long. One shared deadline, not per attempt.
32
+ const TOKEN_REFRESH_TIMEOUT_MS = 30_000;
33
+
34
+ /**
35
+ * Refresh an expired OAuth access token using the refresh token.
36
+ * Retries on 5xx and network errors with exponential backoff.
37
+ */
38
+ export async function refreshAccessToken(refreshToken, endpoint = DEFAULT_TOKEN_ENDPOINT) {
39
+ const maxRetries = 2;
40
+ const baseDelayMs = 500;
41
+ // One deadline for the entire refresh (shared by every attempt), so the whole
42
+ // operation is bounded by TOKEN_REFRESH_TIMEOUT_MS rather than that × attempts.
43
+ const deadline = AbortSignal.timeout(TOKEN_REFRESH_TIMEOUT_MS);
44
+
45
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
46
+ if (deadline.aborted) break; // total budget spent — don't start another attempt
47
+ try {
48
+ if (attempt > 0) {
49
+ // Deadline-aware retry backoff: don't sleep past the total refresh budget.
50
+ await delayUntilAborted(baseDelayMs * 2 ** (attempt - 1), deadline);
51
+ if (deadline.aborted) break;
52
+ }
53
+
54
+ const res = await fetch(endpoint, {
55
+ method: 'POST',
56
+ headers: {
57
+ 'Content-Type': 'application/json',
58
+ 'Accept': 'application/json, text/plain, */*',
59
+ 'User-Agent': 'axios/1.13.6',
60
+ },
61
+ body: JSON.stringify({
62
+ grant_type: 'refresh_token',
63
+ refresh_token: refreshToken,
64
+ client_id: DEFAULT_CLIENT_ID,
65
+ }),
66
+ signal: deadline,
67
+ });
68
+
69
+ if (!res.ok) {
70
+ if (res.status >= 500 && attempt < maxRetries) {
71
+ await res.body?.cancel();
72
+ continue;
73
+ }
74
+ const text = await res.text();
75
+ throw new Error(`Token refresh failed (${res.status}): ${text}`);
76
+ }
77
+
78
+ const data = await res.json();
79
+ return {
80
+ accessToken: data.access_token,
81
+ refreshToken: data.refresh_token || refreshToken,
82
+ expiresAt: normalizeExpiresAt(data.expires_at) || (Date.now() + (data.expires_in || 3600) * 1000),
83
+ };
84
+ } catch (err) {
85
+ const isNetworkError = err instanceof Error &&
86
+ (err.message.includes('fetch failed') ||
87
+ err.name === 'TimeoutError' || err.name === 'AbortError' ||
88
+ (err.code === 'ECONNRESET' || err.code === 'ECONNREFUSED' ||
89
+ err.code === 'ETIMEDOUT' || err.code === 'UND_ERR_CONNECT_TIMEOUT'));
90
+
91
+ // Stop retrying once the shared refresh deadline is spent — otherwise the
92
+ // retry delays would push the total past the intended bound.
93
+ if (attempt < maxRetries && isNetworkError && !deadline.aborted) {
94
+ continue;
95
+ }
96
+ throw err;
97
+ }
98
+ }
99
+ // Reached only by breaking out when the shared deadline expired.
100
+ throw new Error(`Token refresh timed out after ${TOKEN_REFRESH_TIMEOUT_MS}ms`);
101
+ }
102
+
103
+ /** Sleep `ms`, but resolve early if `signal` aborts (cleans up its timer/listener). */
104
+ function delayUntilAborted(ms, signal) {
105
+ return new Promise((resolve) => {
106
+ if (signal.aborted) return resolve();
107
+ const cleanup = () => { clearTimeout(t); signal.removeEventListener('abort', onAbort); };
108
+ const onAbort = () => { cleanup(); resolve(); };
109
+ const t = setTimeout(() => { cleanup(); resolve(); }, ms);
110
+ signal.addEventListener('abort', onAbort, { once: true });
111
+ });
112
+ }
113
+
114
+ /**
115
+ * Normalize an expires_at value to milliseconds.
116
+ * OAuth endpoints may return seconds; Claude Code credentials use milliseconds.
117
+ */
118
+ export function normalizeExpiresAt(expiresAt) {
119
+ if (!expiresAt) return expiresAt;
120
+ // If the value is plausibly in seconds (< 10^12 ≈ year 2001 in ms, year 33658 in s),
121
+ // convert to milliseconds
122
+ return expiresAt < 1e12 ? expiresAt * 1000 : expiresAt;
123
+ }
124
+
125
+ /**
126
+ * Check if an OAuth token is expiring within the given threshold.
127
+ */
128
+ export function isTokenExpiringSoon(expiresAt, thresholdMs = 5 * 60 * 1000) {
129
+ if (!expiresAt) return false;
130
+ return Date.now() + thresholdMs >= normalizeExpiresAt(expiresAt);
131
+ }
132
+
133
+ /**
134
+ * Fetch account profile for an OAuth token.
135
+ * Returns { email, name, orgName, orgType, ... } on success,
136
+ * or { error: 'reason' } on failure.
137
+ */
138
+ export async function fetchProfile(accessToken) {
139
+ try {
140
+ const res = await fetch(PROFILE_URL, {
141
+ headers: { 'Authorization': `Bearer ${accessToken}` },
142
+ });
143
+ if (!res.ok) {
144
+ let detail = '';
145
+ try {
146
+ const body = await res.json();
147
+ detail = body?.error?.message || JSON.stringify(body).slice(0, 200);
148
+ } catch {
149
+ detail = await res.text().catch(() => '');
150
+ }
151
+ return { error: `HTTP ${res.status}${detail ? ': ' + detail : ''}` };
152
+ }
153
+ const data = await res.json();
154
+ return {
155
+ accountUuid: data.account?.uuid,
156
+ email: data.account?.email,
157
+ name: data.account?.display_name,
158
+ orgName: data.organization?.name,
159
+ orgType: data.organization?.organization_type,
160
+ hasClaudeMax: data.account?.has_claude_max,
161
+ hasClaudePro: data.account?.has_claude_pro,
162
+ };
163
+ } catch (err) {
164
+ return { error: err.message || String(err) };
165
+ }
166
+ }
167
+
168
+ // OAuth config (extracted from Claude Code)
169
+ const OAUTH_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
170
+ const OAUTH_AUTHORIZE = 'https://claude.ai/oauth/authorize';
171
+ const OAUTH_TOKEN = 'https://platform.claude.com/v1/oauth/token';
172
+ const OAUTH_SCOPES = 'org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload';
173
+
174
+ /**
175
+ * Perform OAuth login via browser with PKCE flow.
176
+ * Opens the user's browser, waits for the callback, exchanges the code for tokens.
177
+ */
178
+ export async function loginOAuth() {
179
+ // Generate PKCE
180
+ const codeVerifier = randomBytes(32).toString('base64url');
181
+ const codeChallenge = createHash('sha256').update(codeVerifier).digest('base64url');
182
+ const state = randomBytes(32).toString('base64url');
183
+
184
+ // Start local callback server on a random port
185
+ const { port, codePromise, server } = await startCallbackServer(state);
186
+ const redirectUri = `http://localhost:${port}/callback`;
187
+
188
+ // Build authorization URL
189
+ const authUrl = new URL(OAUTH_AUTHORIZE);
190
+ authUrl.searchParams.set('code', 'true');
191
+ authUrl.searchParams.set('client_id', OAUTH_CLIENT_ID);
192
+ authUrl.searchParams.set('response_type', 'code');
193
+ authUrl.searchParams.set('redirect_uri', redirectUri);
194
+ authUrl.searchParams.set('scope', OAUTH_SCOPES);
195
+ authUrl.searchParams.set('code_challenge', codeChallenge);
196
+ authUrl.searchParams.set('code_challenge_method', 'S256');
197
+ authUrl.searchParams.set('state', state);
198
+
199
+ // Open browser
200
+ console.log('Opening browser for authentication...');
201
+ console.log(`If it doesn't open, visit:\n ${authUrl.toString()}\n`);
202
+ openBrowser(authUrl.toString());
203
+
204
+ // Wait for either the callback server or manual paste from stdin
205
+ let code;
206
+ try {
207
+ code = await raceWithStdinCode(codePromise, state);
208
+ } finally {
209
+ server.close();
210
+ }
211
+
212
+ // Exchange code for tokens
213
+ console.log('Exchanging authorization code for tokens...');
214
+ const tokenRes = await fetch(OAUTH_TOKEN, {
215
+ method: 'POST',
216
+ headers: { 'Content-Type': 'application/json' },
217
+ body: JSON.stringify({
218
+ code,
219
+ state,
220
+ grant_type: 'authorization_code',
221
+ client_id: OAUTH_CLIENT_ID,
222
+ redirect_uri: redirectUri,
223
+ code_verifier: codeVerifier,
224
+ }),
225
+ });
226
+
227
+ if (!tokenRes.ok) {
228
+ const text = await tokenRes.text();
229
+ throw new Error(`Token exchange failed (${tokenRes.status}): ${text}`);
230
+ }
231
+
232
+ const tokens = await tokenRes.json();
233
+ return {
234
+ accessToken: tokens.access_token,
235
+ refreshToken: tokens.refresh_token,
236
+ expiresAt: normalizeExpiresAt(tokens.expires_at) || (Date.now() + (tokens.expires_in || 3600) * 1000),
237
+ };
238
+ }
239
+
240
+ /**
241
+ * Race the callback server promise against manual code entry from stdin.
242
+ * The user can paste the full callback URL or just the authorization code.
243
+ */
244
+ function raceWithStdinCode(callbackPromise, expectedState) {
245
+ if (!process.stdin.isTTY) return callbackPromise;
246
+
247
+ return new Promise((resolve, reject) => {
248
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
249
+ let settled = false;
250
+
251
+ const settle = (fn, val) => {
252
+ if (settled) return;
253
+ settled = true;
254
+ rl.close();
255
+ fn(val);
256
+ };
257
+
258
+ rl.question('Paste authorization code here (or wait for browser callback): ', answer => {
259
+ const trimmed = answer.trim();
260
+ if (!trimmed) return; // empty input, keep waiting for callback
261
+
262
+ // Try to parse as a URL with ?code= parameter
263
+ try {
264
+ const url = new URL(trimmed);
265
+ const code = url.searchParams.get('code');
266
+ const state = url.searchParams.get('state');
267
+ if (code) {
268
+ if (expectedState && state && state !== expectedState) {
269
+ settle(reject, new Error('OAuth state mismatch'));
270
+ } else {
271
+ settle(resolve, code);
272
+ }
273
+ return;
274
+ }
275
+ } catch {}
276
+
277
+ // Treat raw input as the authorization code
278
+ settle(resolve, trimmed);
279
+ });
280
+
281
+ callbackPromise.then(
282
+ code => settle(resolve, code),
283
+ err => settle(reject, err),
284
+ );
285
+ });
286
+ }
287
+
288
+ function startCallbackServer(expectedState) {
289
+ return new Promise((resolve, reject) => {
290
+ let resolveCode, rejectCode;
291
+ const codePromise = new Promise((res, rej) => { resolveCode = res; rejectCode = rej; });
292
+
293
+ const server = http.createServer((req, res) => {
294
+ const url = new URL(req.url, `http://localhost`);
295
+
296
+ if (url.pathname === '/callback') {
297
+ const code = url.searchParams.get('code');
298
+ const error = url.searchParams.get('error');
299
+ const state = url.searchParams.get('state');
300
+
301
+ if (error) {
302
+ res.writeHead(200, { 'Content-Type': 'text/html' });
303
+ res.end('<html><body><h2>Authentication failed</h2><p>You can close this tab.</p></body></html>');
304
+ rejectCode(new Error(`OAuth error: ${error} - ${url.searchParams.get('error_description') || ''}`));
305
+ return;
306
+ }
307
+
308
+ if (expectedState && state !== expectedState) {
309
+ res.writeHead(200, { 'Content-Type': 'text/html' });
310
+ res.end('<html><body><h2>Authentication failed</h2><p>State mismatch. You can close this tab.</p></body></html>');
311
+ rejectCode(new Error('OAuth state mismatch'));
312
+ return;
313
+ }
314
+
315
+ if (code) {
316
+ res.writeHead(302, { 'Location': 'https://platform.claude.com/oauth/code/success?app=claude-code' });
317
+ res.end();
318
+ resolveCode(code);
319
+ return;
320
+ }
321
+ }
322
+
323
+ res.writeHead(404);
324
+ res.end('Not found');
325
+ });
326
+
327
+ server.listen(0, () => {
328
+ resolve({ port: server.address().port, codePromise, server });
329
+ });
330
+ server.on('error', reject);
331
+
332
+ // Timeout after 2 minutes (unref so it doesn't keep the process alive)
333
+ const timer = setTimeout(() => {
334
+ rejectCode(new Error('Login timed out after 2 minutes'));
335
+ server.close();
336
+ }, 120_000);
337
+ timer.unref();
338
+ });
339
+ }
340
+
341
+ function openBrowser(url) {
342
+ const platform = process.platform;
343
+ const cmd = platform === 'darwin' ? 'open'
344
+ : platform === 'win32' ? 'start'
345
+ : 'xdg-open';
346
+ exec(`${cmd} ${JSON.stringify(url)}`, () => {});
347
+ }