UNPKG

@juspay/neurolink

Version:

Universal AI Development Platform with working MCP integration, multi-provider support, voice (TTS/STT/realtime), and professional CLI. 58+ external MCP servers discoverable, multimodal file processing, RAG pipelines. Build, test, and deploy AI applicatio

1,143 lines (1,142 loc) 43.8 kB
/** * Anthropic OAuth 2.0 Authentication for Claude Pro/Max Subscriptions * * This module implements OAuth 2.0 flow with PKCE support for authenticating * Claude Pro and Max subscription users through console.anthropic.com. * * OAuth Flow: * 1. Generate PKCE code verifier and challenge * 2. User is redirected to Anthropic authorization URL * 3. User authenticates and grants permissions * 4. Callback receives authorization code * 5. Code is exchanged for access and refresh tokens * 6. Tokens are used for API authentication * * @module auth/anthropicOAuth */ import { createHash, createHmac, randomBytes, randomUUID } from "crypto"; import { createServer } from "http"; import { OAuthError, OAuthConfigurationError, OAuthTokenExchangeError, OAuthTokenRefreshError, OAuthTokenRevocationError, OAuthCallbackServerError, } from "../types/index.js"; import { logger } from "../utils/logger.js"; import { withSpan } from "../telemetry/withSpan.js"; import { tracers } from "../telemetry/tracers.js"; /** * HTML-escape a string to prevent XSS when embedding in HTML responses. */ function escapeHtml(str) { return str .replace(/&/g, "&amp;") .replace(/</g, "&lt;") .replace(/>/g, "&gt;") .replace(/"/g, "&quot;") .replace(/'/g, "&#39;"); } /** * Redact likely tokens/secrets from a string before logging. * Replaces JWTs and long opaque token strings. */ function redactTokens(s) { return s .replace(/[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+/g, "[JWT]") .replace(/\b[A-Za-z0-9\-_]{32,}\b/g, "[TOKEN]"); } // ============================================================================= // OAUTH CONSTANTS (Claude Code Official) // ============================================================================= /** * Claude Code's official OAuth client ID * Used to authenticate with Anthropic's OAuth system */ export const CLAUDE_CODE_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"; /** * Anthropic OAuth authorization URL for Claude Pro/Max */ export const ANTHROPIC_AUTH_URL = "https://claude.ai/oauth/authorize"; /** * Anthropic OAuth token endpoint (primary — lighter Cloudflare) */ export const ANTHROPIC_TOKEN_URL = "https://api.anthropic.com/v1/oauth/token"; /** * Anthropic OAuth token endpoint (fallback) */ export const ANTHROPIC_TOKEN_URL_FALLBACK = "https://console.anthropic.com/v1/oauth/token"; /** * Anthropic OAuth redirect URI (official callback) */ export const ANTHROPIC_REDIRECT_URI = "https://console.anthropic.com/oauth/code/callback"; /** * Default OAuth scopes for Claude subscription access */ export const DEFAULT_SCOPES = [ "org:create_api_key", "user:profile", "user:inference", ]; /** * User-Agent string to spoof Claude CLI */ export const CLAUDE_CODE_VERSION = "2.1.87.6d6"; export const CLAUDE_CODE_ENTRYPOINT = "sdk-cli"; export const CLAUDE_CLI_USER_AGENT = "claude-cli/2.1.87 (external, sdk-cli)"; const CLAUDE_CODE_IDENTITY_TTL_MS = 3_600_000; const CLAUDE_CODE_IDENTITY_CACHE_MAX_ENTRIES = 1024; const CLAUDE_CODE_IDENTITY_NAMESPACE = "neurolink-claude-code-identity-v1"; const claudeCodeIdentityCache = new Map(); function stableIdentityDigest(input) { // These identifiers are deterministic pseudonyms for Claude Code metadata, // not password hashes or authentication secrets. return createHmac("sha256", CLAUDE_CODE_IDENTITY_NAMESPACE) .update(input) .digest("hex"); } function hexToUuid(hex) { const trimmed = hex.replace(/-/g, "").slice(0, 32).padEnd(32, "0"); return `${trimmed.slice(0, 8)}-${trimmed.slice(8, 12)}-${trimmed.slice(12, 16)}-${trimmed.slice(16, 20)}-${trimmed.slice(20, 32)}`; } function isUuid(value) { return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value); } function buildMetadataUserId(identity) { return JSON.stringify({ device_id: identity.deviceId, account_uuid: identity.accountUuid, session_id: identity.sessionId, }); } export function parseClaudeCodeUserId(userId) { if (typeof userId !== "string") { return null; } try { const parsed = JSON.parse(userId); if (typeof parsed.device_id !== "string" || !/^[0-9a-f]{64}$/i.test(parsed.device_id) || typeof parsed.account_uuid !== "string" || !isUuid(parsed.account_uuid) || typeof parsed.session_id !== "string" || !isUuid(parsed.session_id)) { return null; } return { deviceId: parsed.device_id, accountUuid: parsed.account_uuid, sessionId: parsed.session_id, metadataUserId: buildMetadataUserId({ deviceId: parsed.device_id, accountUuid: parsed.account_uuid, sessionId: parsed.session_id, }), }; } catch { return null; } } export function getOrCreateClaudeCodeIdentity(seed, options) { const parsedExisting = parseClaudeCodeUserId(options?.existingUserId); if (parsedExisting) { if (options?.preferredSessionId && isUuid(options.preferredSessionId)) { return { deviceId: parsedExisting.deviceId, accountUuid: parsedExisting.accountUuid, sessionId: options.preferredSessionId, metadataUserId: buildMetadataUserId({ deviceId: parsedExisting.deviceId, accountUuid: parsedExisting.accountUuid, sessionId: options.preferredSessionId, }), }; } return parsedExisting; } const now = Date.now(); const cacheKey = seed || "default"; const cached = claudeCodeIdentityCache.get(cacheKey); if (cached && cached.expiresAt > now) { if (options?.preferredSessionId && isUuid(options.preferredSessionId)) { return { deviceId: cached.deviceId, accountUuid: cached.accountUuid, sessionId: options.preferredSessionId, metadataUserId: buildMetadataUserId({ deviceId: cached.deviceId, accountUuid: cached.accountUuid, sessionId: options.preferredSessionId, }), }; } return cached; } const deviceId = stableIdentityDigest(`${cacheKey}:device`); const accountUuid = hexToUuid(stableIdentityDigest(`${cacheKey}:account`)); const sessionId = options?.preferredSessionId && isUuid(options.preferredSessionId) ? options.preferredSessionId : randomUUID(); const identity = { deviceId, accountUuid, sessionId, metadataUserId: buildMetadataUserId({ deviceId, accountUuid, sessionId, }), expiresAt: now + CLAUDE_CODE_IDENTITY_TTL_MS, }; enforceClaudeCodeIdentityCacheLimit(now); claudeCodeIdentityCache.set(cacheKey, identity); return identity; } export function purgeExpiredClaudeCodeIdentities(now = Date.now()) { let removed = 0; for (const [cacheKey, identity] of claudeCodeIdentityCache.entries()) { if (identity.expiresAt <= now) { claudeCodeIdentityCache.delete(cacheKey); removed += 1; } } return removed; } function enforceClaudeCodeIdentityCacheLimit(now = Date.now()) { purgeExpiredClaudeCodeIdentities(now); while (claudeCodeIdentityCache.size >= CLAUDE_CODE_IDENTITY_CACHE_MAX_ENTRIES) { const oldestKey = claudeCodeIdentityCache.keys().next().value; if (!oldestKey) { break; } claudeCodeIdentityCache.delete(oldestKey); } } export function buildStableClaudeCodeBillingHeader(originalText) { const version = originalText?.match(/cc_version=([^;]+)/)?.[1]?.trim() || CLAUDE_CODE_VERSION; const entrypoint = originalText?.match(/cc_entrypoint=([^;]+)/)?.[1]?.trim() || CLAUDE_CODE_ENTRYPOINT; return `x-anthropic-billing-header: cc_version=${version}; cc_entrypoint=${entrypoint}; cch=00000;`; } /** * Required beta headers for OAuth API requests. * The "oauth-2025-04-20" header is CRITICAL for OAuth authentication. */ export const OAUTH_BETA_HEADERS = "oauth-2025-04-20"; export const CLAUDE_CODE_OAUTH_BETAS = [ "oauth-2025-04-20", "claude-code-20250219", "context-management-2025-06-27", "prompt-caching-scope-2026-01-05", "advanced-tool-use-2025-11-20", "effort-2025-11-24", ]; /** * Tool name prefix required for OAuth API requests */ export const MCP_TOOL_PREFIX = "mcp_"; /** * @deprecated Use ANTHROPIC_AUTH_URL instead */ export const ANTHROPIC_OAUTH_BASE_URL = "https://console.anthropic.com/oauth"; /** * @deprecated Use ANTHROPIC_REDIRECT_URI instead */ export const DEFAULT_REDIRECT_URI = "https://console.anthropic.com/oauth/code/callback"; /** * Default local callback server port (for local testing only) */ export const DEFAULT_CALLBACK_PORT = 8787; // ============================================================================= // MAIN OAUTH CLASS // ============================================================================= /** * AnthropicOAuth - OAuth 2.0 authentication for Claude Pro/Max subscriptions * * Implements OAuth 2.0 authorization code flow with PKCE support for * authenticating users with Claude Pro or Max subscriptions. * * @example * ```typescript * const oauth = new AnthropicOAuth({ * clientId: "your-client-id", * redirectUri: "http://localhost:8787/callback", * }); * * // Generate PKCE parameters * const codeVerifier = AnthropicOAuth.generateCodeVerifier(); * const codeChallenge = await AnthropicOAuth.generateCodeChallenge(codeVerifier); * * // Generate auth URL * const authUrl = oauth.generateAuthUrl({ * codeChallenge, * state: "random-state", * }); * * // After user authenticates, exchange code for tokens * const tokens = await oauth.exchangeCodeForTokens(code, codeVerifier); * ``` */ export class AnthropicOAuth { clientId; clientSecret; redirectUri; scopes; authorizationUrl; tokenUrl; validationUrl; revocationUrl; constructor(config = {}) { // Get client ID from config or environment, defaulting to Claude Code's official client ID this.clientId = config.clientId || process.env.ANTHROPIC_OAUTH_CLIENT_ID || CLAUDE_CODE_CLIENT_ID; if (!this.clientId) { throw new OAuthConfigurationError("Missing OAuth client ID. Set ANTHROPIC_OAUTH_CLIENT_ID environment variable or provide clientId in config."); } // Client secret is optional (for public clients using PKCE) this.clientSecret = config.clientSecret || process.env.ANTHROPIC_OAUTH_CLIENT_SECRET; // Get redirect URI from config or environment or use official redirect URI this.redirectUri = config.redirectUri || process.env.ANTHROPIC_OAUTH_REDIRECT_URI || ANTHROPIC_REDIRECT_URI; // Configure scopes this.scopes = config.scopes || [...DEFAULT_SCOPES]; // Configure endpoints (using Claude Code's official endpoints) this.authorizationUrl = config.authorizationUrl || ANTHROPIC_AUTH_URL; this.tokenUrl = config.tokenUrl || ANTHROPIC_TOKEN_URL; this.validationUrl = config.validationUrl || "https://console.anthropic.com/v1/oauth/validate"; this.revocationUrl = config.revocationUrl || "https://console.anthropic.com/v1/oauth/revoke"; logger.debug("AnthropicOAuth initialized", { clientId: this.clientId.substring(0, 8) + "...", redirectUri: this.redirectUri, scopes: this.scopes, }); } // ============================================================================= // PKCE METHODS (STATIC) // ============================================================================= /** * Generates a cryptographically secure code verifier for PKCE * * The code verifier is a high-entropy random string between 43-128 characters * using URL-safe characters (A-Z, a-z, 0-9, "-", ".", "_", "~"). * * @returns A random code verifier string (64 characters) * * @example * ```typescript * const codeVerifier = AnthropicOAuth.generateCodeVerifier(); * // Returns something like "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" * ``` */ static generateCodeVerifier() { // Generate 32 random bytes and convert to base64url (43-44 chars) // Using 48 bytes gives us 64 characters which is well within spec const buffer = randomBytes(48); return buffer .toString("base64") .replace(/\+/g, "-") .replace(/\//g, "_") .replace(/=/g, ""); } /** * Generates a PKCE code challenge from a code verifier * * Uses SHA-256 hashing as per RFC 7636. The challenge is the * base64url-encoded SHA-256 hash of the code verifier. * * @param verifier - The code verifier to generate challenge from * @returns Promise resolving to the code challenge string * * @example * ```typescript * const verifier = AnthropicOAuth.generateCodeVerifier(); * const challenge = await AnthropicOAuth.generateCodeChallenge(verifier); * ``` */ static async generateCodeChallenge(verifier) { if (!verifier || verifier.length < 43 || verifier.length > 128) { throw new OAuthError("Code verifier must be between 43-128 characters", "INVALID_CODE_VERIFIER"); } // Create SHA-256 hash of the verifier const hash = createHash("sha256").update(verifier).digest(); // Base64URL encode the hash return hash .toString("base64") .replace(/\+/g, "-") .replace(/\//g, "_") .replace(/=/g, ""); } /** * Generates both code verifier and challenge for PKCE * * Convenience method that generates both PKCE parameters at once. * * @returns Promise resolving to PKCE parameters object * * @example * ```typescript * const pkce = await AnthropicOAuth.generatePKCE(); * console.log(pkce.codeVerifier); * console.log(pkce.codeChallenge); * ``` */ static async generatePKCE() { const codeVerifier = AnthropicOAuth.generateCodeVerifier(); const codeChallenge = await AnthropicOAuth.generateCodeChallenge(codeVerifier); return { codeVerifier, codeChallenge, codeChallengeMethod: "S256", }; } // ============================================================================= // AUTHORIZATION URL GENERATION // ============================================================================= /** * Generates the OAuth authorization URL with PKCE support * * Builds the complete authorization URL including all required parameters * for the OAuth 2.0 authorization code flow with PKCE. * * @param config - Authorization URL configuration * @param state - Optional state parameter for CSRF protection * @returns The complete authorization URL * * @example * ```typescript * const pkce = await AnthropicOAuth.generatePKCE(); * const authUrl = oauth.generateAuthUrl({ * codeChallenge: pkce.codeChallenge, * state: crypto.randomUUID(), * }); * // Redirect user to authUrl * ``` */ generateAuthUrl(config = {}, state) { // Generate state if not provided const stateParam = state || this.generateState(); const params = new URLSearchParams({ response_type: "code", client_id: this.clientId, redirect_uri: this.redirectUri, scope: this.scopes.join(" "), state: stateParam, }); // Add PKCE code challenge if provided if (config.codeChallenge) { params.append("code_challenge", config.codeChallenge); params.append("code_challenge_method", "S256"); } // Add any additional parameters if (config.additionalParams) { for (const [key, value] of Object.entries(config.additionalParams)) { params.append(key, value); } } const url = `${this.authorizationUrl}?${params.toString()}`; logger.debug("Generated authorization URL", { url: url.substring(0, 80) + "...", hasPKCE: !!config.codeChallenge, }); return url; } // ============================================================================= // TOKEN EXCHANGE // ============================================================================= /** * Exchanges an authorization code for access and refresh tokens * * Performs the token exchange step of the OAuth flow. For public clients * using PKCE, the code verifier must be provided. * * @param code - The authorization code from the OAuth callback * @param codeVerifier - The PKCE code verifier used to generate the challenge * @param config - Optional additional configuration * @returns Promise resolving to the parsed OAuth tokens * @throws OAuthTokenExchangeError if the exchange fails * * @example * ```typescript * const tokens = await oauth.exchangeCodeForTokens( * authorizationCode, * pkce.codeVerifier * ); * console.log("Access token:", tokens.accessToken); * console.log("Expires at:", tokens.expiresAt); * ``` */ async exchangeCodeForTokens(code, codeVerifier, config = {}) { return withSpan({ name: "neurolink.auth.oauth.exchange_code", tracer: tracers.auth, attributes: { "auth.oauth.grant_type": "authorization_code" }, }, async () => this._exchangeCodeForTokens(code, codeVerifier, config)); } async _exchangeCodeForTokens(code, codeVerifier, config = {}) { if (!code) { throw new OAuthTokenExchangeError("Authorization code is required"); } if (!codeVerifier) { throw new OAuthTokenExchangeError("Code verifier is required for PKCE token exchange"); } logger.debug("Exchanging authorization code for tokens"); const body = { grant_type: "authorization_code", code: code, redirect_uri: config.redirectUri || this.redirectUri, client_id: config.clientId || this.clientId, code_verifier: codeVerifier, }; // Add client secret if available (confidential clients) const clientSecret = config.clientSecret || this.clientSecret; if (clientSecret) { body.client_secret = clientSecret; } const urls = this.getTokenUrls(config.tokenUrl); let lastError; for (const url of urls) { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 10_000); try { const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json", }, body: new URLSearchParams(body).toString(), signal: controller.signal, }); if (!response.ok) { const errorBody = await response.text(); logger.error("Token exchange failed", { url, status: response.status, error: redactTokens(errorBody).slice(0, 500), }); lastError = new OAuthTokenExchangeError(`Token exchange failed: ${response.status} - ${errorBody}`, response.status); continue; } const tokenResponse = await response.json(); const tokens = this.parseTokenResponse(tokenResponse); logger.info("Token exchange successful", { expiresAt: tokens.expiresAt.toISOString(), hasRefreshToken: !!tokens.refreshToken, }); return tokens; } catch (error) { if (error instanceof OAuthError) { lastError = error; continue; } lastError = new OAuthTokenExchangeError(`Failed to exchange authorization code: ${error instanceof Error ? error.message : String(error)}`); continue; } finally { clearTimeout(timeoutId); } } throw lastError instanceof OAuthError ? lastError : new OAuthTokenExchangeError(`Failed to exchange authorization code: ${lastError instanceof Error ? lastError.message : String(lastError)}`); } // ============================================================================= // TOKEN REFRESH // ============================================================================= /** * Refreshes an expired access token using a refresh token * * @param refreshToken - The refresh token from a previous authentication * @param config - Optional configuration overrides * @returns Promise resolving to new OAuth tokens * @throws OAuthTokenRefreshError if the refresh fails * * @example * ```typescript * if (AnthropicOAuth.isTokenExpired(tokens.expiresAt)) { * const newTokens = await oauth.refreshAccessToken(tokens.refreshToken); * console.log("New access token:", newTokens.accessToken); * } * ``` */ async refreshAccessToken(refreshToken, config = {}) { return withSpan({ name: "neurolink.auth.oauth.refresh", tracer: tracers.auth, attributes: { "auth.oauth.grant_type": "refresh_token" }, }, async () => this._refreshAccessToken(refreshToken, config)); } async _refreshAccessToken(refreshToken, config = {}) { if (!refreshToken) { throw new OAuthTokenRefreshError("Refresh token is required"); } logger.debug("Refreshing access token"); const body = { grant_type: "refresh_token", refresh_token: refreshToken, client_id: config.clientId || this.clientId, }; // Add client secret if available const clientSecret = config.clientSecret || this.clientSecret; if (clientSecret) { body.client_secret = clientSecret; } const headers = { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json", "User-Agent": CLAUDE_CLI_USER_AGENT, }; const urls = this.getTokenUrls(config.tokenUrl); let lastError; for (const url of urls) { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 10_000); try { const response = await fetch(url, { method: "POST", headers, body: new URLSearchParams(body).toString(), signal: controller.signal, }); if (!response.ok) { const errorBody = await response.text(); logger.error("Token refresh failed", { url, status: response.status, error: redactTokens(errorBody).slice(0, 500), }); lastError = new OAuthTokenRefreshError(`Token refresh failed: ${response.status} - ${errorBody}`, response.status); // Try fallback URL if available continue; } const tokenResponse = await response.json(); const tokens = this.parseTokenResponse(tokenResponse); logger.info("Access token refreshed successfully", { expiresAt: tokens.expiresAt.toISOString(), }); return tokens; } catch (error) { if (error instanceof OAuthError) { lastError = error; // Try fallback URL if available continue; } lastError = new OAuthTokenRefreshError(`Failed to refresh access token: ${error instanceof Error ? error.message : String(error)}`); continue; } finally { clearTimeout(timeoutId); } } // All URLs exhausted — throw the last error throw lastError instanceof OAuthError ? lastError : new OAuthTokenRefreshError(`Failed to refresh access token: ${lastError instanceof Error ? lastError.message : String(lastError)}`); } // ============================================================================= // TOKEN VALIDATION // ============================================================================= /** * Validates an access token and returns token information * * Checks if the token is still valid by calling the validation endpoint. * Returns user information if available. * * @param accessToken - The access token to validate * @returns Promise resolving to validation result * * @example * ```typescript * const result = await oauth.validateToken(accessToken); * if (result.isValid) { * console.log("Token is valid, expires in:", result.expiresIn, "seconds"); * console.log("User email:", result.user?.email); * } else { * console.log("Token is invalid:", result.error); * } * ``` */ async validateToken(accessToken) { if (!accessToken) { return false; } logger.debug("Validating access token"); try { const response = await fetch(this.validationUrl, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json", Authorization: `Bearer ${accessToken}`, "User-Agent": CLAUDE_CLI_USER_AGENT, }, body: new URLSearchParams({ token: accessToken, }).toString(), }); if (!response.ok) { logger.debug("Token validation failed", { status: response.status, }); return false; } logger.debug("Token is valid"); return true; } catch (error) { logger.warn("Token validation request failed", { error: error instanceof Error ? error.message : String(error), }); return false; } } /** * Validates token and returns detailed information * * @param accessToken - The access token to validate * @returns Promise resolving to detailed validation result */ async validateTokenWithDetails(accessToken) { if (!accessToken) { return { isValid: false, error: "Access token is required", }; } logger.debug("Validating access token with details"); try { const response = await fetch(this.validationUrl, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json", Authorization: `Bearer ${accessToken}`, "User-Agent": CLAUDE_CLI_USER_AGENT, }, body: new URLSearchParams({ token: accessToken, }).toString(), }); if (!response.ok) { logger.debug("Token validation failed", { status: response.status, }); return { isValid: false, error: `Token validation failed: ${response.status}`, }; } const validationData = await response.json(); return { isValid: true, expiresIn: validationData.expires_in, scopes: validationData.scope?.split(" ") || [], user: validationData.user ? { id: validationData.user.id, email: validationData.user.email, subscription: validationData.user.subscription, } : undefined, }; } catch (error) { logger.warn("Token validation request failed", { error: error instanceof Error ? error.message : String(error), }); return { isValid: false, error: `Validation request failed: ${error instanceof Error ? error.message : String(error)}`, }; } } // ============================================================================= // TOKEN REVOCATION // ============================================================================= /** * Revokes an access token or refresh token * * @param token - The token to revoke * @param tokenType - Type of token ("access_token" or "refresh_token") * @returns Promise that resolves when revocation is complete * @throws OAuthTokenRevocationError if revocation fails */ async revokeToken(token, tokenType = "access_token") { return withSpan({ name: "neurolink.auth.oauth.revoke", tracer: tracers.auth, attributes: { "auth.oauth.token_type": tokenType }, }, async () => this._revokeToken(token, tokenType)); } async _revokeToken(token, tokenType = "access_token") { if (!token) { throw new OAuthTokenRevocationError("Token is required for revocation"); } logger.debug("Revoking token", { tokenType }); const body = { token: token, token_type_hint: tokenType, client_id: this.clientId, }; if (this.clientSecret) { body.client_secret = this.clientSecret; } try { const response = await fetch(this.revocationUrl, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json", }, body: new URLSearchParams(body).toString(), }); // RFC 7009: Revocation endpoint should return 200 even if token was already revoked if (!response.ok && response.status !== 200) { const errorBody = await response.text(); logger.error("Token revocation failed", { status: response.status, error: redactTokens(errorBody).slice(0, 500), }); throw new OAuthTokenRevocationError(`Token revocation failed: ${response.status} - ${errorBody}`, response.status); } logger.info("Token revoked successfully", { tokenType }); } catch (error) { if (error instanceof OAuthError) { throw error; } throw new OAuthTokenRevocationError(`Failed to revoke token: ${error instanceof Error ? error.message : String(error)}`); } } // ============================================================================= // HELPER METHODS // ============================================================================= /** * Build the list of token endpoint URLs to try, with optional fallback. * * When a custom tokenUrl was provided (via config param OR constructor), never * fall back to the default Anthropic endpoint — leaking credentials to an * unexpected endpoint is a security risk. */ getTokenUrls(configTokenUrl) { if (configTokenUrl) { return [configTokenUrl]; } const isCustomConstructorUrl = this.tokenUrl !== ANTHROPIC_TOKEN_URL; if (isCustomConstructorUrl) { return [this.tokenUrl]; } return [this.tokenUrl, ANTHROPIC_TOKEN_URL_FALLBACK]; } /** * Parses a token response into structured OAuthFlowTokens */ parseTokenResponse(response) { const expiresAt = new Date(Date.now() + response.expires_in * 1000); return { accessToken: response.access_token, tokenType: response.token_type || "Bearer", expiresAt: expiresAt, refreshToken: response.refresh_token, scopes: response.scope?.split(" ") || this.scopes, }; } /** * Generates a random state parameter for CSRF protection */ generateState() { return randomBytes(32).toString("base64url"); } /** * Checks if a token is expired or about to expire * * @param expiresAt - Token expiration date * @param bufferSeconds - Buffer time before actual expiration (default: 60 seconds) * @returns True if token is expired or will expire within buffer time */ static isTokenExpired(expiresAt, bufferSeconds = 60) { const bufferMs = bufferSeconds * 1000; return Date.now() >= expiresAt.getTime() - bufferMs; } /** * Gets the configured client ID */ getClientId() { return this.clientId; } /** * Gets the configured redirect URI */ getRedirectUri() { return this.redirectUri; } /** * Gets the configured scopes */ getScopes() { return this.scopes; } } // ============================================================================= // LOCAL CALLBACK SERVER HELPER // ============================================================================= /** * Creates and starts a local HTTP server to receive OAuth callbacks * * This helper function starts a temporary HTTP server that listens for * the OAuth callback and extracts the authorization code. * * @param port - Port to listen on (default: 8787) * @param path - Path to listen on (default: "/callback") * @param timeout - Timeout in milliseconds (default: 5 minutes) * @returns Promise resolving to the callback result with authorization code * * @example * ```typescript * // Start callback server before redirecting user * const callbackPromise = startCallbackServer(); * * // Generate auth URL and redirect user * const authUrl = oauth.generateAuthUrl({ codeChallenge }); * console.log("Please visit:", authUrl); * * // Wait for callback * const result = await callbackPromise; * console.log("Got authorization code:", result.code); * * // Exchange for tokens * const tokens = await oauth.exchangeCodeForTokens(result.code, codeVerifier); * ``` */ export function startCallbackServer(port = DEFAULT_CALLBACK_PORT, path = "/callback", timeout = 5 * 60 * 1000) { return new Promise((resolve, reject) => { let server = null; let timeoutId = null; const cleanup = () => { if (timeoutId) { clearTimeout(timeoutId); timeoutId = null; } if (server) { server.close(); server = null; } }; // Set timeout timeoutId = setTimeout(() => { cleanup(); reject(new OAuthCallbackServerError(`Callback server timed out after ${timeout / 1000} seconds`)); }, timeout); server = createServer((req, res) => { // Only handle the callback path const url = new URL(req.url || "/", `http://localhost:${port}`); if (url.pathname !== path) { res.writeHead(404); res.end("Not Found"); return; } // Extract authorization code and state const code = url.searchParams.get("code"); const state = url.searchParams.get("state"); const error = url.searchParams.get("error"); const errorDescription = url.searchParams.get("error_description"); if (error) { // OAuth error response — HTML-escape user-provided values to prevent XSS const safeError = escapeHtml(error); const safeDescription = errorDescription ? escapeHtml(errorDescription) : "Please try again."; res.writeHead(400, { "Content-Type": "text/html" }); res.end(` <!DOCTYPE html> <html> <head><title>Authentication Error</title></head> <body> <h1>Authentication Failed</h1> <p>Error: ${safeError}</p> <p>${safeDescription}</p> <p>You can close this window.</p> </body> </html> `); cleanup(); reject(new OAuthCallbackServerError(`OAuth error: ${error} - ${errorDescription}`)); return; } if (!code) { res.writeHead(400, { "Content-Type": "text/html" }); res.end(` <!DOCTYPE html> <html> <head><title>Missing Authorization Code</title></head> <body> <h1>Authentication Failed</h1> <p>No authorization code received.</p> <p>You can close this window.</p> </body> </html> `); cleanup(); reject(new OAuthCallbackServerError("No authorization code in callback")); return; } // Success response res.writeHead(200, { "Content-Type": "text/html" }); res.end(` <!DOCTYPE html> <html> <head><title>Authentication Successful</title></head> <body> <h1>Authentication Successful!</h1> <p>You have been authenticated successfully.</p> <p>You can close this window and return to the CLI.</p> <script>window.close();</script> </body> </html> `); cleanup(); resolve({ code, state: state || undefined, }); }); server.on("error", (error) => { cleanup(); reject(new OAuthCallbackServerError(`Failed to start callback server: ${error.message}`)); }); server.listen(port, () => { logger.info(`OAuth callback server listening on port ${port}`); }); }); } /** * Stops the callback server if running * Note: The server automatically stops after receiving a callback or timing out */ export async function stopCallbackServer(server) { return new Promise((resolve, reject) => { server.close((error) => { if (error) { reject(new OAuthCallbackServerError(`Failed to stop callback server: ${error.message}`)); } else { logger.info("OAuth callback server stopped"); resolve(); } }); }); } // ============================================================================= // HELPER FUNCTIONS // ============================================================================= /** * Creates an AnthropicOAuth instance with default configuration from environment * * @param overrides - Optional configuration overrides * @returns Configured AnthropicOAuth instance * * @example * ```typescript * const oauth = createAnthropicOAuth(); * const authUrl = oauth.generateAuthUrl({ codeChallenge }); * ``` */ export function createAnthropicOAuth(overrides = {}) { return new AnthropicOAuth(overrides); } /** * Anthropic OAuth configuration creator for providerConfig pattern * * @returns Provider configuration options for Anthropic OAuth */ export function createAnthropicOAuthConfig() { return { providerName: "Anthropic OAuth", envVarName: "ANTHROPIC_OAUTH_CLIENT_ID", setupUrl: ANTHROPIC_OAUTH_BASE_URL, description: "Claude Pro/Max OAuth Client Credentials", instructions: [ `1. Visit: ${ANTHROPIC_OAUTH_BASE_URL}`, "2. Create an OAuth application", "3. Copy the Client ID", `4. Set redirect URI to: ${DEFAULT_REDIRECT_URI}`, "5. Set ANTHROPIC_OAUTH_CLIENT_ID environment variable", ], fallbackEnvVars: [], }; } /** * Checks if Anthropic OAuth credentials are configured * * @returns True if OAuth client ID is available */ export function hasAnthropicOAuthCredentials() { return !!process.env.ANTHROPIC_OAUTH_CLIENT_ID; } /** * Performs a complete OAuth flow including callback server * * This is a convenience function that handles the entire OAuth flow: * 1. Generates PKCE parameters * 2. Starts the callback server * 3. Opens the browser (if possible) * 4. Waits for the callback * 5. Exchanges the code for tokens * * @param oauth - AnthropicOAuth instance * @param options - Flow options * @returns Promise resolving to OAuth tokens * * @example * ```typescript * const oauth = createAnthropicOAuth(); * const tokens = await performOAuthFlow(oauth); * console.log("Authenticated! Token expires at:", tokens.expiresAt); * ``` */ export async function performOAuthFlow(oauth, options = {}) { const { port = DEFAULT_CALLBACK_PORT, timeout = 5 * 60 * 1000, openBrowser = true, } = options; // Generate PKCE parameters const pkce = await AnthropicOAuth.generatePKCE(); // Generate state for CSRF protection const state = randomBytes(32).toString("base64url"); // Start callback server const callbackPromise = startCallbackServer(port, "/callback", timeout); // Generate auth URL const authUrl = oauth.generateAuthUrl({ codeChallenge: pkce.codeChallenge, }, state); // Try to open browser if (openBrowser) { try { const open = (await import("open")).default; await open(authUrl); logger.info("Browser opened for authentication"); } catch { logger.warn("Could not open browser automatically"); logger.always("\nPlease open this URL in your browser to authenticate:"); logger.always(authUrl); logger.always(); } } else { logger.always("\nPlease open this URL in your browser to authenticate:"); logger.always(authUrl); logger.always(); } // Wait for callback const callbackResult = await callbackPromise; // Verify state if (!callbackResult.state || callbackResult.state !== state) { throw new OAuthError("State mismatch - possible CSRF attack", "STATE_MISMATCH"); } // Exchange code for tokens const tokens = await oauth.exchangeCodeForTokens(callbackResult.code, pkce.codeVerifier); return tokens; }