@juspay/neurolink 9.14.0 → 9.15.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/CHANGELOG.md +6 -0
- package/README.md +15 -15
- package/dist/auth/anthropicOAuth.d.ts +377 -0
- package/dist/auth/anthropicOAuth.js +914 -0
- package/dist/auth/index.d.ts +20 -0
- package/dist/auth/index.js +29 -0
- package/dist/auth/tokenStore.d.ts +225 -0
- package/dist/auth/tokenStore.js +521 -0
- package/dist/cli/commands/auth.d.ts +50 -0
- package/dist/cli/commands/auth.js +1115 -0
- package/dist/cli/factories/authCommandFactory.d.ts +52 -0
- package/dist/cli/factories/authCommandFactory.js +146 -0
- package/dist/cli/factories/commandFactory.d.ts +6 -0
- package/dist/cli/factories/commandFactory.js +92 -2
- package/dist/cli/parser.js +11 -2
- package/dist/constants/enums.d.ts +20 -0
- package/dist/constants/enums.js +30 -0
- package/dist/constants/index.d.ts +3 -1
- package/dist/constants/index.js +11 -1
- package/dist/index.d.ts +1 -1
- package/dist/lib/auth/anthropicOAuth.d.ts +377 -0
- package/dist/lib/auth/anthropicOAuth.js +915 -0
- package/dist/lib/auth/index.d.ts +20 -0
- package/dist/lib/auth/index.js +30 -0
- package/dist/lib/auth/tokenStore.d.ts +225 -0
- package/dist/lib/auth/tokenStore.js +522 -0
- package/dist/lib/constants/enums.d.ts +20 -0
- package/dist/lib/constants/enums.js +30 -0
- package/dist/lib/constants/index.d.ts +3 -1
- package/dist/lib/constants/index.js +11 -1
- package/dist/lib/index.d.ts +1 -1
- package/dist/lib/models/anthropicModels.d.ts +267 -0
- package/dist/lib/models/anthropicModels.js +528 -0
- package/dist/lib/providers/anthropic.d.ts +123 -2
- package/dist/lib/providers/anthropic.js +800 -10
- package/dist/lib/types/errors.d.ts +62 -0
- package/dist/lib/types/errors.js +107 -0
- package/dist/lib/types/index.d.ts +2 -1
- package/dist/lib/types/index.js +2 -0
- package/dist/lib/types/providers.d.ts +107 -0
- package/dist/lib/types/providers.js +69 -0
- package/dist/lib/types/subscriptionTypes.d.ts +893 -0
- package/dist/lib/types/subscriptionTypes.js +8 -0
- package/dist/lib/utils/providerConfig.d.ts +167 -0
- package/dist/lib/utils/providerConfig.js +619 -9
- package/dist/models/anthropicModels.d.ts +267 -0
- package/dist/models/anthropicModels.js +527 -0
- package/dist/providers/anthropic.d.ts +123 -2
- package/dist/providers/anthropic.js +800 -10
- package/dist/types/errors.d.ts +62 -0
- package/dist/types/errors.js +107 -0
- package/dist/types/index.d.ts +2 -1
- package/dist/types/index.js +2 -0
- package/dist/types/providers.d.ts +107 -0
- package/dist/types/providers.js +69 -0
- package/dist/types/subscriptionTypes.d.ts +893 -0
- package/dist/types/subscriptionTypes.js +7 -0
- package/dist/utils/providerConfig.d.ts +167 -0
- package/dist/utils/providerConfig.js +619 -9
- package/package.json +2 -1
|
@@ -0,0 +1,1115 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* NeuroLink Auth Command
|
|
4
|
+
*
|
|
5
|
+
* Unified authentication command for AI providers supporting:
|
|
6
|
+
* - API key authentication (traditional)
|
|
7
|
+
* - OAuth 2.1 authentication with PKCE (for Claude subscription)
|
|
8
|
+
*
|
|
9
|
+
* Subcommands:
|
|
10
|
+
* - login: Authenticate with a provider
|
|
11
|
+
* - logout: Clear stored credentials
|
|
12
|
+
* - status: Show authentication status
|
|
13
|
+
* - refresh: Manually refresh OAuth tokens
|
|
14
|
+
*
|
|
15
|
+
* Currently supports:
|
|
16
|
+
* - Anthropic (API key + OAuth)
|
|
17
|
+
*/
|
|
18
|
+
import fs from "fs";
|
|
19
|
+
import path from "path";
|
|
20
|
+
import { execFile } from "child_process";
|
|
21
|
+
import { randomBytes, createHash } from "crypto";
|
|
22
|
+
import inquirer from "inquirer";
|
|
23
|
+
import chalk from "chalk";
|
|
24
|
+
import ora from "ora";
|
|
25
|
+
import { logger } from "../../lib/utils/logger.js";
|
|
26
|
+
import { defaultTokenStore } from "../../lib/auth/tokenStore.js";
|
|
27
|
+
import { CLAUDE_CODE_CLIENT_ID, ANTHROPIC_AUTH_URL, ANTHROPIC_TOKEN_URL, ANTHROPIC_REDIRECT_URI, CLAUDE_CLI_USER_AGENT, OAUTH_BETA_HEADERS, } from "../../lib/auth/anthropicOAuth.js";
|
|
28
|
+
// =============================================================================
|
|
29
|
+
// CONSTANTS
|
|
30
|
+
// =============================================================================
|
|
31
|
+
const NEUROLINK_CONFIG_DIR = path.join(process.env.HOME || process.env.USERPROFILE || ".", ".neurolink");
|
|
32
|
+
const ENV_FILE_PATH = path.join(process.cwd(), ".env");
|
|
33
|
+
// Anthropic OAuth Configuration (Claude Code Official) - For direct OAuth usage
|
|
34
|
+
// Uses claude.ai/oauth/authorize for Claude Pro/Max subscription access
|
|
35
|
+
const ANTHROPIC_OAUTH_CONFIG = {
|
|
36
|
+
clientId: CLAUDE_CODE_CLIENT_ID,
|
|
37
|
+
// NOTE: Uses claude.ai NOT console.anthropic.com for direct OAuth
|
|
38
|
+
authorizationUrl: ANTHROPIC_AUTH_URL,
|
|
39
|
+
tokenUrl: ANTHROPIC_TOKEN_URL,
|
|
40
|
+
redirectUri: ANTHROPIC_REDIRECT_URI,
|
|
41
|
+
// Scopes for direct OAuth (no API key creation needed)
|
|
42
|
+
scope: "user:profile user:inference",
|
|
43
|
+
userAgent: CLAUDE_CLI_USER_AGENT,
|
|
44
|
+
betaHeaders: OAUTH_BETA_HEADERS,
|
|
45
|
+
};
|
|
46
|
+
// Anthropic Console OAuth Configuration - For API key creation flow
|
|
47
|
+
// This uses console.anthropic.com for authorization which grants org:create_api_key scope
|
|
48
|
+
const ANTHROPIC_CONSOLE_OAUTH_CONFIG = {
|
|
49
|
+
clientId: CLAUDE_CODE_CLIENT_ID,
|
|
50
|
+
// Authorization URL for console (required for API key creation scope)
|
|
51
|
+
authorizationUrl: "https://console.anthropic.com/oauth/authorize",
|
|
52
|
+
tokenUrl: ANTHROPIC_TOKEN_URL,
|
|
53
|
+
redirectUri: ANTHROPIC_REDIRECT_URI,
|
|
54
|
+
// Required scopes - org:create_api_key is needed for API key creation
|
|
55
|
+
scope: "org:create_api_key user:profile user:inference",
|
|
56
|
+
userAgent: CLAUDE_CLI_USER_AGENT,
|
|
57
|
+
// API key creation endpoint
|
|
58
|
+
createApiKeyUrl: "https://api.anthropic.com/api/oauth/claude_cli/create_api_key",
|
|
59
|
+
};
|
|
60
|
+
// Supported providers
|
|
61
|
+
const SUPPORTED_PROVIDERS = ["anthropic"];
|
|
62
|
+
// =============================================================================
|
|
63
|
+
// SUBCOMMAND HANDLERS
|
|
64
|
+
// =============================================================================
|
|
65
|
+
/**
|
|
66
|
+
* Handle the login subcommand
|
|
67
|
+
* `neurolink auth login <provider>`
|
|
68
|
+
*/
|
|
69
|
+
export async function handleLogin(argv) {
|
|
70
|
+
try {
|
|
71
|
+
const provider = argv.provider?.toLowerCase();
|
|
72
|
+
// Validate provider
|
|
73
|
+
if (!SUPPORTED_PROVIDERS.includes(provider)) {
|
|
74
|
+
logger.error(chalk.red(`Unsupported provider: ${provider}. Supported: ${SUPPORTED_PROVIDERS.join(", ")}`));
|
|
75
|
+
process.exit(1);
|
|
76
|
+
}
|
|
77
|
+
// If method is specified, use it directly
|
|
78
|
+
if (argv.method) {
|
|
79
|
+
if (argv.method === "api-key") {
|
|
80
|
+
await handleApiKeyAuth(provider, !argv.nonInteractive);
|
|
81
|
+
}
|
|
82
|
+
else if (argv.method === "oauth") {
|
|
83
|
+
await handleOAuthAuth(provider);
|
|
84
|
+
}
|
|
85
|
+
else if (argv.method === "create-api-key") {
|
|
86
|
+
await handleCreateApiKeyOAuth(provider);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
// Interactive mode - ask user which method they prefer
|
|
91
|
+
await handleInteractiveAuth(provider);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
logger.error(chalk.red("Authentication failed:"));
|
|
96
|
+
logger.error(chalk.red(error instanceof Error ? error.message : "Unknown error"));
|
|
97
|
+
process.exit(1);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Handle the logout subcommand
|
|
102
|
+
* `neurolink auth logout <provider>`
|
|
103
|
+
*/
|
|
104
|
+
export async function handleLogout(argv) {
|
|
105
|
+
try {
|
|
106
|
+
const provider = argv.provider?.toLowerCase();
|
|
107
|
+
// Validate provider
|
|
108
|
+
if (!SUPPORTED_PROVIDERS.includes(provider)) {
|
|
109
|
+
logger.error(chalk.red(`Unsupported provider: ${provider}. Supported: ${SUPPORTED_PROVIDERS.join(", ")}`));
|
|
110
|
+
process.exit(1);
|
|
111
|
+
}
|
|
112
|
+
logger.always(chalk.blue(`\nClearing ${provider} credentials...\n`));
|
|
113
|
+
const spinner = argv.quiet
|
|
114
|
+
? null
|
|
115
|
+
: ora("Removing stored credentials...").start();
|
|
116
|
+
try {
|
|
117
|
+
// Clear stored credentials file
|
|
118
|
+
const credentialsFile = path.join(NEUROLINK_CONFIG_DIR, `${provider}-credentials.json`);
|
|
119
|
+
if (fs.existsSync(credentialsFile)) {
|
|
120
|
+
fs.unlinkSync(credentialsFile);
|
|
121
|
+
if (spinner) {
|
|
122
|
+
spinner.succeed("Stored credentials removed");
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
if (spinner) {
|
|
127
|
+
spinner.info("No stored credentials found");
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
// Also clear from TokenStore if OAuth
|
|
131
|
+
try {
|
|
132
|
+
await defaultTokenStore.clearTokens(provider);
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
// Ignore if no tokens stored
|
|
136
|
+
}
|
|
137
|
+
// Check for environment variable
|
|
138
|
+
const envVar = getEnvVarName(provider);
|
|
139
|
+
const hasEnvKey = !!process.env[envVar];
|
|
140
|
+
if (hasEnvKey) {
|
|
141
|
+
logger.always("");
|
|
142
|
+
logger.always(chalk.yellow(`Note: ${envVar} is still set in your environment or .env file.`));
|
|
143
|
+
logger.always(chalk.yellow("You may need to manually remove it from your shell profile or .env file."));
|
|
144
|
+
// Offer to remove from .env if it exists
|
|
145
|
+
if (fs.existsSync(ENV_FILE_PATH)) {
|
|
146
|
+
const { removeFromEnv } = await inquirer.prompt([
|
|
147
|
+
{
|
|
148
|
+
type: "confirm",
|
|
149
|
+
name: "removeFromEnv",
|
|
150
|
+
message: `Remove ${envVar} from .env file?`,
|
|
151
|
+
default: false,
|
|
152
|
+
},
|
|
153
|
+
]);
|
|
154
|
+
if (removeFromEnv) {
|
|
155
|
+
await removeFromEnvFile(envVar);
|
|
156
|
+
logger.always(chalk.green(`Removed ${envVar} from .env file`));
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
logger.always("");
|
|
161
|
+
logger.always(chalk.green(`${provider} credentials cleared successfully.`));
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
if (spinner) {
|
|
165
|
+
spinner.fail("Failed to clear credentials");
|
|
166
|
+
}
|
|
167
|
+
throw error;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
logger.error(chalk.red("Logout failed:"));
|
|
172
|
+
logger.error(chalk.red(error instanceof Error ? error.message : "Unknown error"));
|
|
173
|
+
process.exit(1);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Handle the status subcommand
|
|
178
|
+
* `neurolink auth status [provider]`
|
|
179
|
+
*/
|
|
180
|
+
export async function handleStatus(argv) {
|
|
181
|
+
try {
|
|
182
|
+
const provider = argv.provider?.toLowerCase();
|
|
183
|
+
// If provider specified, show just that provider
|
|
184
|
+
const providersToCheck = provider
|
|
185
|
+
? [provider]
|
|
186
|
+
: [...SUPPORTED_PROVIDERS];
|
|
187
|
+
const results = [];
|
|
188
|
+
for (const p of providersToCheck) {
|
|
189
|
+
const status = await getAuthStatus(p);
|
|
190
|
+
results.push(status);
|
|
191
|
+
}
|
|
192
|
+
// Output results
|
|
193
|
+
if (argv.format === "json") {
|
|
194
|
+
logger.always(JSON.stringify(results, null, 2));
|
|
195
|
+
}
|
|
196
|
+
else {
|
|
197
|
+
logger.always(chalk.bold("\nAuthentication Status:\n"));
|
|
198
|
+
for (const status of results) {
|
|
199
|
+
const providerName = status.provider.charAt(0).toUpperCase() + status.provider.slice(1);
|
|
200
|
+
const statusIcon = status.isAuthenticated
|
|
201
|
+
? chalk.green("[Authenticated]")
|
|
202
|
+
: chalk.yellow("[Not Authenticated]");
|
|
203
|
+
logger.always(`${chalk.cyan(providerName)} ${statusIcon}`);
|
|
204
|
+
if (status.isAuthenticated) {
|
|
205
|
+
logger.always(` Method: ${status.method}`);
|
|
206
|
+
if (status.subscriptionTier) {
|
|
207
|
+
logger.always(` Subscription: ${chalk.blue(status.subscriptionTier)}`);
|
|
208
|
+
}
|
|
209
|
+
if (status.method === "oauth") {
|
|
210
|
+
if (status.tokenExpiry) {
|
|
211
|
+
const isExpired = status.needsRefresh;
|
|
212
|
+
const expiryLabel = isExpired
|
|
213
|
+
? chalk.red("Expired")
|
|
214
|
+
: chalk.green(status.tokenExpiry);
|
|
215
|
+
logger.always(` Token Expires: ${expiryLabel}`);
|
|
216
|
+
}
|
|
217
|
+
if (status.hasRefreshToken) {
|
|
218
|
+
logger.always(` Refresh Token: ${chalk.green("Available")}`);
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
logger.always(` Refresh Token: ${chalk.yellow("Not available")}`);
|
|
222
|
+
}
|
|
223
|
+
if (status.needsRefresh && status.hasRefreshToken) {
|
|
224
|
+
logger.always(chalk.yellow(` Run 'neurolink auth refresh ${status.provider}' to refresh tokens`));
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
else {
|
|
229
|
+
logger.always(chalk.blue(` Run 'neurolink auth login ${status.provider}' to authenticate`));
|
|
230
|
+
}
|
|
231
|
+
logger.always("");
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
catch (error) {
|
|
236
|
+
logger.error(chalk.red("Status check failed:"));
|
|
237
|
+
logger.error(chalk.red(error instanceof Error ? error.message : "Unknown error"));
|
|
238
|
+
process.exit(1);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Handle the refresh subcommand
|
|
243
|
+
* `neurolink auth refresh <provider>`
|
|
244
|
+
*/
|
|
245
|
+
export async function handleRefresh(argv) {
|
|
246
|
+
try {
|
|
247
|
+
const provider = argv.provider?.toLowerCase();
|
|
248
|
+
// Validate provider
|
|
249
|
+
if (!SUPPORTED_PROVIDERS.includes(provider)) {
|
|
250
|
+
logger.error(chalk.red(`Unsupported provider: ${provider}. Supported: ${SUPPORTED_PROVIDERS.join(", ")}`));
|
|
251
|
+
process.exit(1);
|
|
252
|
+
}
|
|
253
|
+
logger.always(chalk.blue(`\nRefreshing ${provider} OAuth tokens...\n`));
|
|
254
|
+
const spinner = argv.quiet ? null : ora("Reading stored tokens...").start();
|
|
255
|
+
try {
|
|
256
|
+
// Get stored credentials
|
|
257
|
+
const credentials = await getStoredCredentials(provider);
|
|
258
|
+
if (!credentials || credentials.type !== "oauth") {
|
|
259
|
+
if (spinner) {
|
|
260
|
+
spinner.fail("No OAuth credentials found");
|
|
261
|
+
}
|
|
262
|
+
logger.error(chalk.red(`No OAuth authentication found for ${provider}. Use 'neurolink auth login ${provider} --method oauth' first.`));
|
|
263
|
+
process.exit(1);
|
|
264
|
+
}
|
|
265
|
+
if (!credentials.oauth?.refreshToken) {
|
|
266
|
+
if (spinner) {
|
|
267
|
+
spinner.fail("No refresh token available");
|
|
268
|
+
}
|
|
269
|
+
logger.error(chalk.red("No refresh token available. Please re-authenticate with OAuth."));
|
|
270
|
+
process.exit(1);
|
|
271
|
+
}
|
|
272
|
+
if (spinner) {
|
|
273
|
+
spinner.text = "Refreshing access token...";
|
|
274
|
+
}
|
|
275
|
+
// Refresh the token with Claude CLI User-Agent
|
|
276
|
+
// IMPORTANT: Uses JSON body, not URLSearchParams
|
|
277
|
+
const tokenResponse = await fetch(ANTHROPIC_OAUTH_CONFIG.tokenUrl, {
|
|
278
|
+
method: "POST",
|
|
279
|
+
headers: {
|
|
280
|
+
"Content-Type": "application/json",
|
|
281
|
+
Accept: "application/json",
|
|
282
|
+
"User-Agent": ANTHROPIC_OAUTH_CONFIG.userAgent,
|
|
283
|
+
},
|
|
284
|
+
body: JSON.stringify({
|
|
285
|
+
grant_type: "refresh_token",
|
|
286
|
+
refresh_token: credentials.oauth.refreshToken,
|
|
287
|
+
client_id: ANTHROPIC_OAUTH_CONFIG.clientId,
|
|
288
|
+
}),
|
|
289
|
+
});
|
|
290
|
+
if (!tokenResponse.ok) {
|
|
291
|
+
const errorText = await tokenResponse.text();
|
|
292
|
+
throw new Error(`Token refresh failed: ${tokenResponse.status} - ${errorText}`);
|
|
293
|
+
}
|
|
294
|
+
const tokenData = (await tokenResponse.json());
|
|
295
|
+
// Update stored tokens
|
|
296
|
+
const newTokens = {
|
|
297
|
+
accessToken: tokenData.access_token,
|
|
298
|
+
refreshToken: tokenData.refresh_token || credentials.oauth.refreshToken,
|
|
299
|
+
expiresAt: tokenData.expires_in
|
|
300
|
+
? Date.now() + tokenData.expires_in * 1000
|
|
301
|
+
: undefined,
|
|
302
|
+
tokenType: tokenData.token_type || "Bearer",
|
|
303
|
+
scope: tokenData.scope,
|
|
304
|
+
};
|
|
305
|
+
await saveStoredCredentials(provider, {
|
|
306
|
+
type: "oauth",
|
|
307
|
+
oauth: newTokens,
|
|
308
|
+
provider,
|
|
309
|
+
subscriptionTier: credentials.subscriptionTier,
|
|
310
|
+
createdAt: credentials.createdAt,
|
|
311
|
+
updatedAt: Date.now(),
|
|
312
|
+
});
|
|
313
|
+
if (spinner) {
|
|
314
|
+
spinner.succeed("Access token refreshed successfully!");
|
|
315
|
+
}
|
|
316
|
+
logger.always("");
|
|
317
|
+
logger.always(chalk.green("Token refresh complete!"));
|
|
318
|
+
if (newTokens.expiresAt) {
|
|
319
|
+
logger.always(` New expiry: ${new Date(newTokens.expiresAt).toLocaleString()}`);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
catch (error) {
|
|
323
|
+
if (spinner) {
|
|
324
|
+
spinner.fail("Token refresh failed");
|
|
325
|
+
}
|
|
326
|
+
throw error;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
catch (error) {
|
|
330
|
+
logger.error(chalk.red("Token refresh failed:"));
|
|
331
|
+
logger.error(chalk.red(error instanceof Error ? error.message : "Unknown error"));
|
|
332
|
+
process.exit(1);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
// =============================================================================
|
|
336
|
+
// LEGACY HANDLER (for backward compatibility)
|
|
337
|
+
// =============================================================================
|
|
338
|
+
/**
|
|
339
|
+
* Legacy main auth command handler
|
|
340
|
+
* @deprecated Use subcommand handlers instead
|
|
341
|
+
*/
|
|
342
|
+
export async function handleAuth(argv) {
|
|
343
|
+
// Map legacy flags to subcommands
|
|
344
|
+
if (argv.status) {
|
|
345
|
+
await handleStatus({
|
|
346
|
+
provider: argv.provider,
|
|
347
|
+
format: "text",
|
|
348
|
+
quiet: false,
|
|
349
|
+
debug: argv.debug,
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
else if (argv.logout) {
|
|
353
|
+
await handleLogout({
|
|
354
|
+
provider: argv.provider,
|
|
355
|
+
format: "text",
|
|
356
|
+
quiet: false,
|
|
357
|
+
debug: argv.debug,
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
else {
|
|
361
|
+
await handleLogin({
|
|
362
|
+
provider: argv.provider,
|
|
363
|
+
method: argv.method,
|
|
364
|
+
format: "text",
|
|
365
|
+
quiet: false,
|
|
366
|
+
nonInteractive: argv.nonInteractive,
|
|
367
|
+
debug: argv.debug,
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
// =============================================================================
|
|
372
|
+
// AUTHENTICATION METHODS
|
|
373
|
+
// =============================================================================
|
|
374
|
+
/**
|
|
375
|
+
* Interactive authentication - ask user which method they prefer
|
|
376
|
+
*/
|
|
377
|
+
async function handleInteractiveAuth(provider) {
|
|
378
|
+
logger.always(chalk.blue(`\n${provider.charAt(0).toUpperCase() + provider.slice(1)} Authentication Setup\n`));
|
|
379
|
+
const currentStatus = await checkExistingAuth(provider);
|
|
380
|
+
if (currentStatus.hasValidAuth) {
|
|
381
|
+
logger.always(chalk.green("You already have valid authentication configured."));
|
|
382
|
+
logger.always(` Type: ${currentStatus.type}`);
|
|
383
|
+
if (currentStatus.type === "api-key") {
|
|
384
|
+
logger.always(` Key: ${maskCredential(currentStatus.credential || "")}`);
|
|
385
|
+
}
|
|
386
|
+
logger.always("");
|
|
387
|
+
const { reconfigure } = await inquirer.prompt([
|
|
388
|
+
{
|
|
389
|
+
type: "confirm",
|
|
390
|
+
name: "reconfigure",
|
|
391
|
+
message: "Would you like to reconfigure authentication?",
|
|
392
|
+
default: false,
|
|
393
|
+
},
|
|
394
|
+
]);
|
|
395
|
+
if (!reconfigure) {
|
|
396
|
+
logger.always(chalk.blue("Keeping existing configuration."));
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
// Show authentication method options
|
|
401
|
+
const { method } = await inquirer.prompt([
|
|
402
|
+
{
|
|
403
|
+
type: "list",
|
|
404
|
+
name: "method",
|
|
405
|
+
message: "Select authentication method:",
|
|
406
|
+
choices: [
|
|
407
|
+
{
|
|
408
|
+
name: "API Key - Traditional authentication with API key (pay-per-use)",
|
|
409
|
+
value: "api-key",
|
|
410
|
+
},
|
|
411
|
+
{
|
|
412
|
+
name: "Claude Pro/Max OAuth - Use subscription directly (Recommended for Pro/Max users)",
|
|
413
|
+
value: "oauth",
|
|
414
|
+
},
|
|
415
|
+
{
|
|
416
|
+
name: "Create API Key (via OAuth) - Creates a real API key using your account",
|
|
417
|
+
value: "create-api-key",
|
|
418
|
+
},
|
|
419
|
+
],
|
|
420
|
+
},
|
|
421
|
+
]);
|
|
422
|
+
if (method === "api-key") {
|
|
423
|
+
await handleApiKeyAuth(provider, true);
|
|
424
|
+
}
|
|
425
|
+
else if (method === "create-api-key") {
|
|
426
|
+
await handleCreateApiKeyOAuth(provider);
|
|
427
|
+
}
|
|
428
|
+
else {
|
|
429
|
+
await handleOAuthAuth(provider);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* Handle API key authentication
|
|
434
|
+
*/
|
|
435
|
+
async function handleApiKeyAuth(provider, interactive) {
|
|
436
|
+
logger.always(chalk.blue("\nAPI Key Authentication\n"));
|
|
437
|
+
if (provider === "anthropic") {
|
|
438
|
+
logger.always(chalk.yellow("To get your Anthropic API key:"));
|
|
439
|
+
logger.always("1. Visit: https://console.anthropic.com/");
|
|
440
|
+
logger.always("2. Sign in to your Anthropic account");
|
|
441
|
+
logger.always("3. Go to 'API Keys' section");
|
|
442
|
+
logger.always("4. Click 'Create Key' and copy the API key (starts with sk-ant-)");
|
|
443
|
+
logger.always("");
|
|
444
|
+
}
|
|
445
|
+
if (!interactive) {
|
|
446
|
+
const envKey = process.env.ANTHROPIC_API_KEY?.trim();
|
|
447
|
+
if (envKey) {
|
|
448
|
+
await saveStoredCredentials(provider, {
|
|
449
|
+
type: "api-key",
|
|
450
|
+
apiKey: envKey,
|
|
451
|
+
provider,
|
|
452
|
+
createdAt: Date.now(),
|
|
453
|
+
updatedAt: Date.now(),
|
|
454
|
+
});
|
|
455
|
+
logger.always(chalk.green("Using ANTHROPIC_API_KEY from environment."));
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
throw new Error("Non-interactive mode requires ANTHROPIC_API_KEY environment variable when using --method api-key");
|
|
459
|
+
}
|
|
460
|
+
const { apiKey } = await inquirer.prompt([
|
|
461
|
+
{
|
|
462
|
+
type: "password",
|
|
463
|
+
name: "apiKey",
|
|
464
|
+
message: `Enter your ${provider} API key:`,
|
|
465
|
+
validate: (input) => {
|
|
466
|
+
if (!input.trim()) {
|
|
467
|
+
return "API key is required";
|
|
468
|
+
}
|
|
469
|
+
if (provider === "anthropic" && !input.startsWith("sk-ant-")) {
|
|
470
|
+
return "Anthropic API key should start with 'sk-ant-'";
|
|
471
|
+
}
|
|
472
|
+
if (input.trim().length < 20) {
|
|
473
|
+
return "API key seems too short";
|
|
474
|
+
}
|
|
475
|
+
return true;
|
|
476
|
+
},
|
|
477
|
+
},
|
|
478
|
+
]);
|
|
479
|
+
const trimmedKey = apiKey.trim();
|
|
480
|
+
// Validate the API key
|
|
481
|
+
const spinner = ora("Validating API key...").start();
|
|
482
|
+
try {
|
|
483
|
+
const isValid = await validateApiKey(provider, trimmedKey);
|
|
484
|
+
if (!isValid) {
|
|
485
|
+
spinner.fail("API key validation failed");
|
|
486
|
+
logger.error(chalk.red("The API key could not be validated. Please check and try again."));
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
spinner.succeed("API key validated successfully");
|
|
490
|
+
}
|
|
491
|
+
catch (error) {
|
|
492
|
+
spinner.fail("API key validation failed");
|
|
493
|
+
logger.error(chalk.red(error instanceof Error ? error.message : "Validation error"));
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
// Ask where to store the key
|
|
497
|
+
const { storageOption } = await inquirer.prompt([
|
|
498
|
+
{
|
|
499
|
+
type: "list",
|
|
500
|
+
name: "storageOption",
|
|
501
|
+
message: "Where would you like to store the API key?",
|
|
502
|
+
choices: [
|
|
503
|
+
{ name: ".env file (project-level)", value: "env" },
|
|
504
|
+
{ name: "NeuroLink config (user-level)", value: "config" },
|
|
505
|
+
{ name: "Both", value: "both" },
|
|
506
|
+
],
|
|
507
|
+
},
|
|
508
|
+
]);
|
|
509
|
+
const spinnerSave = ora("Saving API key...").start();
|
|
510
|
+
try {
|
|
511
|
+
if (storageOption === "env" || storageOption === "both") {
|
|
512
|
+
await saveToEnvFile(provider, trimmedKey);
|
|
513
|
+
}
|
|
514
|
+
if (storageOption === "config" || storageOption === "both") {
|
|
515
|
+
await saveStoredCredentials(provider, {
|
|
516
|
+
type: "api-key",
|
|
517
|
+
apiKey: trimmedKey,
|
|
518
|
+
provider,
|
|
519
|
+
createdAt: Date.now(),
|
|
520
|
+
updatedAt: Date.now(),
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
spinnerSave.succeed("API key saved successfully");
|
|
524
|
+
logger.always("");
|
|
525
|
+
logger.always(chalk.green("Authentication configured successfully!"));
|
|
526
|
+
showUsageExample(provider);
|
|
527
|
+
}
|
|
528
|
+
catch (error) {
|
|
529
|
+
spinnerSave.fail("Failed to save API key");
|
|
530
|
+
throw error;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* Handle API key creation via OAuth flow
|
|
535
|
+
* This authenticates via console.anthropic.com, gets an OAuth token with org:create_api_key scope,
|
|
536
|
+
* then uses that to create a real API key. This is the recommended method for Claude Pro/Max users.
|
|
537
|
+
*
|
|
538
|
+
* Based on opencode-anthropic-auth@0.0.8 implementation.
|
|
539
|
+
* Uses manual code entry since localhost redirect is not registered with Anthropic OAuth.
|
|
540
|
+
*/
|
|
541
|
+
async function handleCreateApiKeyOAuth(provider) {
|
|
542
|
+
logger.always(chalk.blue("\nCreate API Key (via OAuth) - Claude Pro/Max\n"));
|
|
543
|
+
if (provider === "anthropic") {
|
|
544
|
+
logger.always(chalk.cyan("This will authenticate using your Claude Pro or Max subscription"));
|
|
545
|
+
logger.always(chalk.cyan("and create an API key for use with the Anthropic API.\n"));
|
|
546
|
+
logger.always(chalk.yellow("Note: After signing in, you'll see an authorization code."));
|
|
547
|
+
logger.always(chalk.yellow("Copy that code and paste it back here.\n"));
|
|
548
|
+
}
|
|
549
|
+
const spinner = ora("Starting OAuth flow...").start();
|
|
550
|
+
// Generate PKCE challenge - OpenCode sets state = verifier
|
|
551
|
+
const codeVerifier = randomBytes(32).toString("base64url");
|
|
552
|
+
const codeChallenge = createHash("sha256")
|
|
553
|
+
.update(codeVerifier)
|
|
554
|
+
.digest("base64url");
|
|
555
|
+
// Build authorization URL using CONSOLE config (for API key creation scope)
|
|
556
|
+
// Based on opencode-anthropic-auth implementation
|
|
557
|
+
const authUrl = new URL(ANTHROPIC_CONSOLE_OAUTH_CONFIG.authorizationUrl);
|
|
558
|
+
authUrl.searchParams.set("code", "true"); // Required param
|
|
559
|
+
authUrl.searchParams.set("client_id", ANTHROPIC_CONSOLE_OAUTH_CONFIG.clientId);
|
|
560
|
+
authUrl.searchParams.set("response_type", "code");
|
|
561
|
+
authUrl.searchParams.set("redirect_uri", ANTHROPIC_CONSOLE_OAUTH_CONFIG.redirectUri);
|
|
562
|
+
authUrl.searchParams.set("scope", ANTHROPIC_CONSOLE_OAUTH_CONFIG.scope);
|
|
563
|
+
authUrl.searchParams.set("code_challenge", codeChallenge);
|
|
564
|
+
authUrl.searchParams.set("code_challenge_method", "S256");
|
|
565
|
+
// OpenCode sets state = verifier for simplicity
|
|
566
|
+
authUrl.searchParams.set("state", codeVerifier);
|
|
567
|
+
spinner.text = "Opening browser for authentication...";
|
|
568
|
+
// Open browser
|
|
569
|
+
try {
|
|
570
|
+
await openBrowser(authUrl.toString());
|
|
571
|
+
spinner.succeed("Browser opened for authentication");
|
|
572
|
+
}
|
|
573
|
+
catch {
|
|
574
|
+
spinner.warn("Could not open browser automatically");
|
|
575
|
+
logger.always("");
|
|
576
|
+
logger.always(chalk.yellow("Please open this URL manually:"));
|
|
577
|
+
logger.always(chalk.cyan(authUrl.toString()));
|
|
578
|
+
}
|
|
579
|
+
logger.always("");
|
|
580
|
+
logger.always(chalk.blue("═".repeat(60)));
|
|
581
|
+
logger.always(chalk.blue.bold(" Complete authentication in your browser"));
|
|
582
|
+
logger.always(chalk.blue("═".repeat(60)));
|
|
583
|
+
logger.always("");
|
|
584
|
+
logger.always("1. Sign in to your Anthropic account in the browser");
|
|
585
|
+
logger.always("2. Authorize the application");
|
|
586
|
+
logger.always("3. Copy the authorization code shown on the page");
|
|
587
|
+
logger.always("4. Paste the code below");
|
|
588
|
+
logger.always("");
|
|
589
|
+
// Prompt user to enter the authorization code
|
|
590
|
+
const { authCode } = await inquirer.prompt([
|
|
591
|
+
{
|
|
592
|
+
type: "input",
|
|
593
|
+
name: "authCode",
|
|
594
|
+
message: "Paste the authorization code:",
|
|
595
|
+
validate: (input) => {
|
|
596
|
+
if (!input.trim()) {
|
|
597
|
+
return "Authorization code is required";
|
|
598
|
+
}
|
|
599
|
+
if (input.trim().length < 10) {
|
|
600
|
+
return "Authorization code seems too short";
|
|
601
|
+
}
|
|
602
|
+
return true;
|
|
603
|
+
},
|
|
604
|
+
},
|
|
605
|
+
]);
|
|
606
|
+
const exchangeSpinner = ora("Exchanging authorization code for tokens...").start();
|
|
607
|
+
// Parse code#state format (e.g., "abc123#xyz789")
|
|
608
|
+
// IMPORTANT: OpenCode sets state = verifier in the auth URL, so the state
|
|
609
|
+
// returned in code#state IS the original verifier used for the code challenge!
|
|
610
|
+
const trimmedCode = authCode.trim();
|
|
611
|
+
const splits = trimmedCode.split("#");
|
|
612
|
+
const actualCode = splits[0];
|
|
613
|
+
const codeState = splits[1] || codeVerifier;
|
|
614
|
+
// Use the state as the verifier (since we set state = verifier in the auth URL)
|
|
615
|
+
const actualVerifier = codeState;
|
|
616
|
+
// Exchange code for tokens using JSON body (per opencode-anthropic-auth)
|
|
617
|
+
const tokenResponse = await fetch(ANTHROPIC_CONSOLE_OAUTH_CONFIG.tokenUrl, {
|
|
618
|
+
method: "POST",
|
|
619
|
+
headers: {
|
|
620
|
+
"Content-Type": "application/json",
|
|
621
|
+
},
|
|
622
|
+
body: JSON.stringify({
|
|
623
|
+
code: actualCode,
|
|
624
|
+
state: codeState,
|
|
625
|
+
grant_type: "authorization_code",
|
|
626
|
+
client_id: ANTHROPIC_CONSOLE_OAUTH_CONFIG.clientId,
|
|
627
|
+
redirect_uri: ANTHROPIC_CONSOLE_OAUTH_CONFIG.redirectUri,
|
|
628
|
+
code_verifier: actualVerifier,
|
|
629
|
+
}),
|
|
630
|
+
});
|
|
631
|
+
if (!tokenResponse.ok) {
|
|
632
|
+
const errorText = await tokenResponse.text();
|
|
633
|
+
exchangeSpinner.fail("Token exchange failed");
|
|
634
|
+
throw new Error(`Token exchange failed: ${tokenResponse.status} - ${errorText}`);
|
|
635
|
+
}
|
|
636
|
+
const tokenData = (await tokenResponse.json());
|
|
637
|
+
exchangeSpinner.succeed("OAuth tokens obtained successfully");
|
|
638
|
+
// Now create an API key using the OAuth token
|
|
639
|
+
const apiKeySpinner = ora("Creating API key...").start();
|
|
640
|
+
try {
|
|
641
|
+
const apiKeyResponse = await fetch(ANTHROPIC_CONSOLE_OAUTH_CONFIG.createApiKeyUrl, {
|
|
642
|
+
method: "POST",
|
|
643
|
+
headers: {
|
|
644
|
+
"Content-Type": "application/json",
|
|
645
|
+
Authorization: `Bearer ${tokenData.access_token}`,
|
|
646
|
+
"User-Agent": ANTHROPIC_CONSOLE_OAUTH_CONFIG.userAgent,
|
|
647
|
+
},
|
|
648
|
+
});
|
|
649
|
+
if (!apiKeyResponse.ok) {
|
|
650
|
+
const errorText = await apiKeyResponse.text();
|
|
651
|
+
apiKeySpinner.fail("API key creation failed");
|
|
652
|
+
throw new Error(`API key creation failed: ${apiKeyResponse.status} - ${errorText}`);
|
|
653
|
+
}
|
|
654
|
+
const apiKeyData = (await apiKeyResponse.json());
|
|
655
|
+
if (!apiKeyData.raw_key) {
|
|
656
|
+
apiKeySpinner.fail("API key creation failed");
|
|
657
|
+
throw new Error("No API key returned from creation endpoint");
|
|
658
|
+
}
|
|
659
|
+
apiKeySpinner.succeed("API key created successfully!");
|
|
660
|
+
// Auto-save to both locations for convenience
|
|
661
|
+
const spinnerSave = ora("Saving API key...").start();
|
|
662
|
+
try {
|
|
663
|
+
// Save to .env file (project-level)
|
|
664
|
+
await saveToEnvFile(provider, apiKeyData.raw_key);
|
|
665
|
+
// Save to NeuroLink config (user-level)
|
|
666
|
+
await saveStoredCredentials(provider, {
|
|
667
|
+
type: "api-key",
|
|
668
|
+
apiKey: apiKeyData.raw_key,
|
|
669
|
+
provider,
|
|
670
|
+
subscriptionTier: "pro", // Default for OAuth API-key flow; user can override via CLI
|
|
671
|
+
createdAt: Date.now(),
|
|
672
|
+
updatedAt: Date.now(),
|
|
673
|
+
});
|
|
674
|
+
spinnerSave.succeed("API key saved to .env and NeuroLink config");
|
|
675
|
+
logger.always("");
|
|
676
|
+
logger.always(chalk.green("═".repeat(60)));
|
|
677
|
+
logger.always(chalk.green.bold(" API key created and saved successfully!"));
|
|
678
|
+
logger.always(chalk.green("═".repeat(60)));
|
|
679
|
+
logger.always("");
|
|
680
|
+
logger.always(` API Key: ${chalk.cyan(maskCredential(apiKeyData.raw_key))}`);
|
|
681
|
+
logger.always(` Created via: ${chalk.blue("Claude Pro/Max OAuth")}`);
|
|
682
|
+
showUsageExample(provider);
|
|
683
|
+
}
|
|
684
|
+
catch (error) {
|
|
685
|
+
spinnerSave.fail("Failed to save API key");
|
|
686
|
+
throw error;
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
catch (error) {
|
|
690
|
+
if (error instanceof Error) {
|
|
691
|
+
throw error;
|
|
692
|
+
}
|
|
693
|
+
throw new Error(`Failed to create API key: ${String(error)}`);
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
/**
|
|
697
|
+
* Handle OAuth authentication using code-based flow
|
|
698
|
+
* User authenticates in browser and copies the authorization code back to CLI
|
|
699
|
+
* Uses claude.ai/oauth/authorize for Claude Pro/Max subscription access
|
|
700
|
+
*/
|
|
701
|
+
async function handleOAuthAuth(provider) {
|
|
702
|
+
logger.always(chalk.blue("\nClaude Pro/Max OAuth Authentication\n"));
|
|
703
|
+
if (provider === "anthropic") {
|
|
704
|
+
logger.always(chalk.cyan("This will authenticate using your Claude Pro or Max subscription."));
|
|
705
|
+
logger.always(chalk.cyan("Your subscription includes API access at no extra cost!\n"));
|
|
706
|
+
logger.always(chalk.yellow("Note: After signing in, you'll see an authorization code."));
|
|
707
|
+
logger.always(chalk.yellow("Copy that code and paste it back here.\n"));
|
|
708
|
+
}
|
|
709
|
+
const { proceed } = await inquirer.prompt([
|
|
710
|
+
{
|
|
711
|
+
type: "confirm",
|
|
712
|
+
name: "proceed",
|
|
713
|
+
message: "Continue with OAuth authentication?",
|
|
714
|
+
default: true,
|
|
715
|
+
},
|
|
716
|
+
]);
|
|
717
|
+
if (!proceed) {
|
|
718
|
+
logger.always(chalk.yellow("OAuth authentication cancelled."));
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
const spinner = ora("Starting OAuth flow...").start();
|
|
722
|
+
// Generate PKCE challenge - state = verifier (OpenCode's approach)
|
|
723
|
+
const codeVerifier = randomBytes(32).toString("base64url");
|
|
724
|
+
const codeChallenge = createHash("sha256")
|
|
725
|
+
.update(codeVerifier)
|
|
726
|
+
.digest("base64url");
|
|
727
|
+
// Build authorization URL using claude.ai (NOT console.anthropic.com)
|
|
728
|
+
// This is the direct OAuth flow for Claude Pro/Max
|
|
729
|
+
const authUrl = new URL(ANTHROPIC_OAUTH_CONFIG.authorizationUrl);
|
|
730
|
+
authUrl.searchParams.set("code", "true");
|
|
731
|
+
authUrl.searchParams.set("client_id", ANTHROPIC_OAUTH_CONFIG.clientId);
|
|
732
|
+
authUrl.searchParams.set("response_type", "code");
|
|
733
|
+
authUrl.searchParams.set("redirect_uri", ANTHROPIC_OAUTH_CONFIG.redirectUri);
|
|
734
|
+
authUrl.searchParams.set("scope", ANTHROPIC_OAUTH_CONFIG.scope);
|
|
735
|
+
authUrl.searchParams.set("code_challenge", codeChallenge);
|
|
736
|
+
authUrl.searchParams.set("code_challenge_method", "S256");
|
|
737
|
+
// OpenCode sets state = verifier for simplicity
|
|
738
|
+
authUrl.searchParams.set("state", codeVerifier);
|
|
739
|
+
spinner.text = "Opening browser for authentication...";
|
|
740
|
+
// Open browser
|
|
741
|
+
try {
|
|
742
|
+
await openBrowser(authUrl.toString());
|
|
743
|
+
spinner.succeed("Browser opened for authentication");
|
|
744
|
+
}
|
|
745
|
+
catch {
|
|
746
|
+
spinner.warn("Could not open browser automatically");
|
|
747
|
+
logger.always("");
|
|
748
|
+
logger.always(chalk.yellow("Please open this URL manually:"));
|
|
749
|
+
logger.always(chalk.cyan(authUrl.toString()));
|
|
750
|
+
}
|
|
751
|
+
logger.always("");
|
|
752
|
+
logger.always(chalk.blue("═".repeat(60)));
|
|
753
|
+
logger.always(chalk.blue.bold(" Complete authentication in your browser"));
|
|
754
|
+
logger.always(chalk.blue("═".repeat(60)));
|
|
755
|
+
logger.always("");
|
|
756
|
+
logger.always("1. Sign in to your Claude account in the browser");
|
|
757
|
+
logger.always("2. Authorize the application");
|
|
758
|
+
logger.always("3. Copy the authorization code shown on the page");
|
|
759
|
+
logger.always("4. Paste the code below");
|
|
760
|
+
logger.always("");
|
|
761
|
+
// Prompt user to enter the authorization code
|
|
762
|
+
const { authCode } = await inquirer.prompt([
|
|
763
|
+
{
|
|
764
|
+
type: "input",
|
|
765
|
+
name: "authCode",
|
|
766
|
+
message: "Paste the authorization code:",
|
|
767
|
+
validate: (input) => {
|
|
768
|
+
if (!input.trim()) {
|
|
769
|
+
return "Authorization code is required";
|
|
770
|
+
}
|
|
771
|
+
if (input.trim().length < 10) {
|
|
772
|
+
return "Authorization code seems too short";
|
|
773
|
+
}
|
|
774
|
+
return true;
|
|
775
|
+
},
|
|
776
|
+
},
|
|
777
|
+
]);
|
|
778
|
+
const trimmedCode = authCode.trim();
|
|
779
|
+
const exchangeSpinner = ora("Exchanging authorization code for tokens...").start();
|
|
780
|
+
// Parse code#state format (e.g., "abc123#xyz789")
|
|
781
|
+
// OpenCode sets state = verifier in the auth URL, so the state
|
|
782
|
+
// returned in code#state IS the original verifier used for the code challenge
|
|
783
|
+
const codeParts = trimmedCode.split("#");
|
|
784
|
+
const actualCode = codeParts[0];
|
|
785
|
+
const codeState = codeParts[1] || codeVerifier;
|
|
786
|
+
// Use the state as the verifier (since we set state = verifier in the auth URL)
|
|
787
|
+
const actualVerifier = codeState;
|
|
788
|
+
// Exchange code for tokens with Claude CLI User-Agent
|
|
789
|
+
// IMPORTANT: Uses JSON body, not URLSearchParams
|
|
790
|
+
const tokenResponse = await fetch(ANTHROPIC_OAUTH_CONFIG.tokenUrl, {
|
|
791
|
+
method: "POST",
|
|
792
|
+
headers: {
|
|
793
|
+
"Content-Type": "application/json",
|
|
794
|
+
},
|
|
795
|
+
body: JSON.stringify({
|
|
796
|
+
code: actualCode,
|
|
797
|
+
state: codeState,
|
|
798
|
+
grant_type: "authorization_code",
|
|
799
|
+
client_id: ANTHROPIC_OAUTH_CONFIG.clientId,
|
|
800
|
+
redirect_uri: ANTHROPIC_OAUTH_CONFIG.redirectUri,
|
|
801
|
+
code_verifier: actualVerifier,
|
|
802
|
+
}),
|
|
803
|
+
});
|
|
804
|
+
if (!tokenResponse.ok) {
|
|
805
|
+
const errorText = await tokenResponse.text();
|
|
806
|
+
exchangeSpinner.fail("Token exchange failed");
|
|
807
|
+
throw new Error(`Token exchange failed: ${tokenResponse.status} - ${errorText}`);
|
|
808
|
+
}
|
|
809
|
+
const tokenData = (await tokenResponse.json());
|
|
810
|
+
// Save tokens
|
|
811
|
+
const tokens = {
|
|
812
|
+
accessToken: tokenData.access_token,
|
|
813
|
+
refreshToken: tokenData.refresh_token,
|
|
814
|
+
expiresAt: tokenData.expires_in
|
|
815
|
+
? Date.now() + tokenData.expires_in * 1000
|
|
816
|
+
: undefined,
|
|
817
|
+
tokenType: tokenData.token_type || "Bearer",
|
|
818
|
+
scope: tokenData.scope,
|
|
819
|
+
};
|
|
820
|
+
// Detect subscription tier if possible
|
|
821
|
+
const subscriptionTier = await detectSubscriptionTier(tokens.accessToken);
|
|
822
|
+
await saveStoredCredentials(provider, {
|
|
823
|
+
type: "oauth",
|
|
824
|
+
oauth: tokens,
|
|
825
|
+
provider,
|
|
826
|
+
subscriptionTier,
|
|
827
|
+
createdAt: Date.now(),
|
|
828
|
+
updatedAt: Date.now(),
|
|
829
|
+
});
|
|
830
|
+
exchangeSpinner.succeed("OAuth authentication successful!");
|
|
831
|
+
logger.always("");
|
|
832
|
+
logger.always(chalk.green("═".repeat(60)));
|
|
833
|
+
logger.always(chalk.green.bold(" Authentication configured successfully!"));
|
|
834
|
+
logger.always(chalk.green("═".repeat(60)));
|
|
835
|
+
logger.always("");
|
|
836
|
+
if (subscriptionTier) {
|
|
837
|
+
logger.always(` Subscription Tier: ${chalk.blue(subscriptionTier)}`);
|
|
838
|
+
}
|
|
839
|
+
logger.always(` Token expires: ${tokens.expiresAt ? new Date(tokens.expiresAt).toLocaleString() : "Never"}`);
|
|
840
|
+
logger.always(` Refresh token: ${tokens.refreshToken ? chalk.green("Available") : chalk.yellow("Not available")}`);
|
|
841
|
+
showUsageExample(provider);
|
|
842
|
+
}
|
|
843
|
+
// =============================================================================
|
|
844
|
+
// HELPER FUNCTIONS
|
|
845
|
+
// =============================================================================
|
|
846
|
+
/**
|
|
847
|
+
* Get authentication status for a provider
|
|
848
|
+
* Priority: OAuth > stored API key > environment API key
|
|
849
|
+
*/
|
|
850
|
+
async function getAuthStatus(provider) {
|
|
851
|
+
const result = {
|
|
852
|
+
provider,
|
|
853
|
+
isAuthenticated: false,
|
|
854
|
+
method: "none",
|
|
855
|
+
};
|
|
856
|
+
// Check stored credentials FIRST (OAuth takes priority over API key)
|
|
857
|
+
const stored = await getStoredCredentials(provider);
|
|
858
|
+
if (stored) {
|
|
859
|
+
// OAuth credentials take highest priority
|
|
860
|
+
if (stored.type === "oauth" && stored.oauth) {
|
|
861
|
+
result.isAuthenticated = true;
|
|
862
|
+
result.method = "oauth";
|
|
863
|
+
result.subscriptionTier = stored.subscriptionTier;
|
|
864
|
+
result.hasRefreshToken = !!stored.oauth.refreshToken;
|
|
865
|
+
if (stored.oauth.expiresAt) {
|
|
866
|
+
result.tokenExpiry = new Date(stored.oauth.expiresAt).toLocaleString();
|
|
867
|
+
result.needsRefresh = Date.now() >= stored.oauth.expiresAt;
|
|
868
|
+
}
|
|
869
|
+
return result;
|
|
870
|
+
}
|
|
871
|
+
// Stored API key is second priority
|
|
872
|
+
if (stored.type === "api-key" && stored.apiKey) {
|
|
873
|
+
result.isAuthenticated = true;
|
|
874
|
+
result.method = "api-key";
|
|
875
|
+
return result;
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
// Fall back to environment API key
|
|
879
|
+
const envKey = getEnvApiKey(provider);
|
|
880
|
+
if (envKey) {
|
|
881
|
+
result.isAuthenticated = true;
|
|
882
|
+
result.method = "api-key";
|
|
883
|
+
return result;
|
|
884
|
+
}
|
|
885
|
+
return result;
|
|
886
|
+
}
|
|
887
|
+
/**
|
|
888
|
+
* Detect subscription tier from token (if API supports it)
|
|
889
|
+
*/
|
|
890
|
+
async function detectSubscriptionTier(accessToken) {
|
|
891
|
+
try {
|
|
892
|
+
// Attempt to call an endpoint that returns user info
|
|
893
|
+
// This is a placeholder - actual implementation depends on Anthropic's API
|
|
894
|
+
const response = await fetch("https://api.anthropic.com/v1/me", {
|
|
895
|
+
headers: {
|
|
896
|
+
Authorization: `Bearer ${accessToken}`,
|
|
897
|
+
"Content-Type": "application/json",
|
|
898
|
+
},
|
|
899
|
+
});
|
|
900
|
+
if (response.ok) {
|
|
901
|
+
const data = (await response.json());
|
|
902
|
+
if (data.subscription) {
|
|
903
|
+
return data.subscription;
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
catch {
|
|
908
|
+
// Ignore errors - subscription detection is optional
|
|
909
|
+
}
|
|
910
|
+
return undefined;
|
|
911
|
+
}
|
|
912
|
+
/**
|
|
913
|
+
* Open URL in the default browser (cross-platform)
|
|
914
|
+
*/
|
|
915
|
+
async function openBrowser(url) {
|
|
916
|
+
return new Promise((resolve, reject) => {
|
|
917
|
+
const platform = process.platform;
|
|
918
|
+
let command;
|
|
919
|
+
let args;
|
|
920
|
+
switch (platform) {
|
|
921
|
+
case "darwin":
|
|
922
|
+
command = "open";
|
|
923
|
+
args = [url];
|
|
924
|
+
break;
|
|
925
|
+
case "win32":
|
|
926
|
+
command = "cmd";
|
|
927
|
+
args = ["/c", "start", "", url];
|
|
928
|
+
break;
|
|
929
|
+
default:
|
|
930
|
+
// Linux and other Unix-like systems
|
|
931
|
+
command = "xdg-open";
|
|
932
|
+
args = [url];
|
|
933
|
+
}
|
|
934
|
+
// Use execFile instead of exec to prevent command injection
|
|
935
|
+
execFile(command, args, (error) => {
|
|
936
|
+
if (error) {
|
|
937
|
+
reject(error);
|
|
938
|
+
}
|
|
939
|
+
else {
|
|
940
|
+
resolve();
|
|
941
|
+
}
|
|
942
|
+
});
|
|
943
|
+
});
|
|
944
|
+
}
|
|
945
|
+
/**
|
|
946
|
+
* Get environment variable name for a provider
|
|
947
|
+
*/
|
|
948
|
+
function getEnvVarName(provider) {
|
|
949
|
+
switch (provider) {
|
|
950
|
+
case "anthropic":
|
|
951
|
+
return "ANTHROPIC_API_KEY";
|
|
952
|
+
default:
|
|
953
|
+
return `${provider.toUpperCase()}_API_KEY`;
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
/**
|
|
957
|
+
* Get API key from environment
|
|
958
|
+
*/
|
|
959
|
+
function getEnvApiKey(provider) {
|
|
960
|
+
return process.env[getEnvVarName(provider)];
|
|
961
|
+
}
|
|
962
|
+
/**
|
|
963
|
+
* Get stored credentials from file
|
|
964
|
+
*/
|
|
965
|
+
async function getStoredCredentials(provider) {
|
|
966
|
+
const credentialsFile = path.join(NEUROLINK_CONFIG_DIR, `${provider}-credentials.json`);
|
|
967
|
+
try {
|
|
968
|
+
if (fs.existsSync(credentialsFile)) {
|
|
969
|
+
const data = fs.readFileSync(credentialsFile, "utf-8");
|
|
970
|
+
return JSON.parse(data);
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
catch (error) {
|
|
974
|
+
logger.debug(`Failed to read credentials: ${error}`);
|
|
975
|
+
}
|
|
976
|
+
return null;
|
|
977
|
+
}
|
|
978
|
+
/**
|
|
979
|
+
* Save credentials to file
|
|
980
|
+
*/
|
|
981
|
+
async function saveStoredCredentials(provider, credentials) {
|
|
982
|
+
// Ensure config directory exists
|
|
983
|
+
if (!fs.existsSync(NEUROLINK_CONFIG_DIR)) {
|
|
984
|
+
fs.mkdirSync(NEUROLINK_CONFIG_DIR, { recursive: true });
|
|
985
|
+
}
|
|
986
|
+
const credentialsFile = path.join(NEUROLINK_CONFIG_DIR, `${provider}-credentials.json`);
|
|
987
|
+
fs.writeFileSync(credentialsFile, JSON.stringify(credentials, null, 2), {
|
|
988
|
+
mode: 0o600, // Restrict permissions
|
|
989
|
+
});
|
|
990
|
+
}
|
|
991
|
+
/**
|
|
992
|
+
* Check existing authentication status
|
|
993
|
+
*/
|
|
994
|
+
async function checkExistingAuth(provider) {
|
|
995
|
+
const envKey = getEnvApiKey(provider);
|
|
996
|
+
if (envKey) {
|
|
997
|
+
return { hasValidAuth: true, type: "api-key", credential: envKey };
|
|
998
|
+
}
|
|
999
|
+
const stored = await getStoredCredentials(provider);
|
|
1000
|
+
if (stored) {
|
|
1001
|
+
if (stored.type === "api-key" && stored.apiKey) {
|
|
1002
|
+
return { hasValidAuth: true, type: "api-key", credential: stored.apiKey };
|
|
1003
|
+
}
|
|
1004
|
+
if (stored.type === "oauth" && stored.oauth) {
|
|
1005
|
+
// Check if token is still valid
|
|
1006
|
+
if (stored.oauth.expiresAt) {
|
|
1007
|
+
const isExpired = Date.now() >= stored.oauth.expiresAt;
|
|
1008
|
+
if (!isExpired || stored.oauth.refreshToken) {
|
|
1009
|
+
return { hasValidAuth: true, type: "oauth" };
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
else {
|
|
1013
|
+
return { hasValidAuth: true, type: "oauth" };
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
return { hasValidAuth: false };
|
|
1018
|
+
}
|
|
1019
|
+
/**
|
|
1020
|
+
* Validate API key by making a test request
|
|
1021
|
+
*/
|
|
1022
|
+
async function validateApiKey(provider, apiKey) {
|
|
1023
|
+
if (provider === "anthropic") {
|
|
1024
|
+
try {
|
|
1025
|
+
// Simple validation - check message API
|
|
1026
|
+
const response = await fetch("https://api.anthropic.com/v1/messages", {
|
|
1027
|
+
method: "POST",
|
|
1028
|
+
headers: {
|
|
1029
|
+
"Content-Type": "application/json",
|
|
1030
|
+
"x-api-key": apiKey,
|
|
1031
|
+
"anthropic-version": "2023-06-01",
|
|
1032
|
+
},
|
|
1033
|
+
body: JSON.stringify({
|
|
1034
|
+
model: "claude-3-haiku-20240307",
|
|
1035
|
+
max_tokens: 1,
|
|
1036
|
+
messages: [{ role: "user", content: "Hi" }],
|
|
1037
|
+
}),
|
|
1038
|
+
});
|
|
1039
|
+
// 200 means valid, 401 means invalid key
|
|
1040
|
+
// Other errors (rate limit, etc.) still mean the key format is valid
|
|
1041
|
+
return response.status !== 401;
|
|
1042
|
+
}
|
|
1043
|
+
catch {
|
|
1044
|
+
// Network error - assume key format is valid
|
|
1045
|
+
return true;
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
return true;
|
|
1049
|
+
}
|
|
1050
|
+
/**
|
|
1051
|
+
* Save API key to .env file
|
|
1052
|
+
*/
|
|
1053
|
+
async function saveToEnvFile(provider, apiKey) {
|
|
1054
|
+
const envVar = getEnvVarName(provider);
|
|
1055
|
+
let content = "";
|
|
1056
|
+
if (fs.existsSync(ENV_FILE_PATH)) {
|
|
1057
|
+
content = fs.readFileSync(ENV_FILE_PATH, "utf-8");
|
|
1058
|
+
}
|
|
1059
|
+
// Check if variable already exists
|
|
1060
|
+
const regex = new RegExp(`^${envVar}=.*$`, "m");
|
|
1061
|
+
if (regex.test(content)) {
|
|
1062
|
+
// Replace existing
|
|
1063
|
+
content = content.replace(regex, `${envVar}=${apiKey}`);
|
|
1064
|
+
}
|
|
1065
|
+
else {
|
|
1066
|
+
// Add new
|
|
1067
|
+
if (content && !content.endsWith("\n")) {
|
|
1068
|
+
content += "\n";
|
|
1069
|
+
}
|
|
1070
|
+
content += `${envVar}=${apiKey}\n`;
|
|
1071
|
+
}
|
|
1072
|
+
fs.writeFileSync(ENV_FILE_PATH, content);
|
|
1073
|
+
}
|
|
1074
|
+
/**
|
|
1075
|
+
* Remove variable from .env file
|
|
1076
|
+
*/
|
|
1077
|
+
async function removeFromEnvFile(envVar) {
|
|
1078
|
+
if (!fs.existsSync(ENV_FILE_PATH)) {
|
|
1079
|
+
return;
|
|
1080
|
+
}
|
|
1081
|
+
let content = fs.readFileSync(ENV_FILE_PATH, "utf-8");
|
|
1082
|
+
const regex = new RegExp(`^${envVar}=.*\n?`, "m");
|
|
1083
|
+
content = content.replace(regex, "");
|
|
1084
|
+
fs.writeFileSync(ENV_FILE_PATH, content);
|
|
1085
|
+
}
|
|
1086
|
+
/**
|
|
1087
|
+
* Mask credential for display
|
|
1088
|
+
*/
|
|
1089
|
+
function maskCredential(credential) {
|
|
1090
|
+
if (!credential || credential.length < 8) {
|
|
1091
|
+
return "****";
|
|
1092
|
+
}
|
|
1093
|
+
const knownPrefixes = ["sk-ant-", "sk-"];
|
|
1094
|
+
const prefix = knownPrefixes.find((p) => credential.startsWith(p)) ??
|
|
1095
|
+
credential.slice(0, 4);
|
|
1096
|
+
const end = credential.slice(-4);
|
|
1097
|
+
const stars = "*".repeat(Math.max(4, credential.length - prefix.length - 4));
|
|
1098
|
+
return `${prefix}${stars}${end}`;
|
|
1099
|
+
}
|
|
1100
|
+
/**
|
|
1101
|
+
* Show usage example after successful authentication
|
|
1102
|
+
*/
|
|
1103
|
+
function showUsageExample(provider) {
|
|
1104
|
+
logger.always("");
|
|
1105
|
+
logger.always(chalk.green("You can now use the NeuroLink CLI with this provider:"));
|
|
1106
|
+
logger.always(chalk.cyan(` neurolink generate "Hello!" --provider ${provider}`));
|
|
1107
|
+
logger.always(chalk.cyan(` neurolink generate "Explain quantum computing" --provider ${provider}`));
|
|
1108
|
+
logger.always("");
|
|
1109
|
+
logger.always(chalk.blue("To check authentication status:"));
|
|
1110
|
+
logger.always(chalk.cyan(` neurolink auth status ${provider}`));
|
|
1111
|
+
logger.always("");
|
|
1112
|
+
logger.always(chalk.blue("To logout:"));
|
|
1113
|
+
logger.always(chalk.cyan(` neurolink auth logout ${provider}`));
|
|
1114
|
+
}
|
|
1115
|
+
//# sourceMappingURL=auth.js.map
|