diff --git a/src/browser/api.ts b/src/browser/api.ts index a98675cb4a..fabcb341a7 100644 --- a/src/browser/api.ts +++ b/src/browser/api.ts @@ -364,6 +364,15 @@ const webApi: IPCApi = { voice: { transcribe: (audioBase64) => invokeIPC(IPC_CHANNELS.VOICE_TRANSCRIBE, audioBase64), }, + oauth: { + anthropic: { + start: (mode) => invokeIPC(IPC_CHANNELS.OAUTH_ANTHROPIC_START, mode), + exchange: (code, verifier, state) => + invokeIPC(IPC_CHANNELS.OAUTH_ANTHROPIC_EXCHANGE, code, verifier, state), + status: () => invokeIPC(IPC_CHANNELS.OAUTH_ANTHROPIC_STATUS), + logout: () => invokeIPC(IPC_CHANNELS.OAUTH_ANTHROPIC_LOGOUT), + }, + }, update: { check: () => invokeIPC(IPC_CHANNELS.UPDATE_CHECK), download: () => invokeIPC(IPC_CHANNELS.UPDATE_DOWNLOAD), diff --git a/src/browser/stories/mockFactory.ts b/src/browser/stories/mockFactory.ts index 3de5878047..84b01979df 100644 --- a/src/browser/stories/mockFactory.ts +++ b/src/browser/stories/mockFactory.ts @@ -459,6 +459,15 @@ export function createMockAPI(options: MockAPIOptions): IPCApi { voice: { transcribe: () => Promise.resolve({ success: false, error: "Not implemented in mock" }), }, + oauth: { + anthropic: { + start: () => + Promise.resolve({ authUrl: "https://claude.ai/oauth", verifier: "mock", state: "mock" }), + exchange: () => Promise.resolve({ success: false, error: "Not implemented in mock" }), + status: () => Promise.resolve({ authenticated: false }), + logout: () => Promise.resolve(), + }, + }, update: { check: () => Promise.resolve(undefined), download: () => Promise.resolve(undefined), diff --git a/src/common/constants/ipc-constants.ts b/src/common/constants/ipc-constants.ts index c335928a09..b9b111ffd2 100644 --- a/src/common/constants/ipc-constants.ts +++ b/src/common/constants/ipc-constants.ts @@ -71,6 +71,12 @@ export const IPC_CHANNELS = { // Voice channels VOICE_TRANSCRIBE: "voice:transcribe", + // OAuth channels + OAUTH_ANTHROPIC_START: "oauth:anthropic:start", + OAUTH_ANTHROPIC_EXCHANGE: "oauth:anthropic:exchange", + OAUTH_ANTHROPIC_STATUS: "oauth:anthropic:status", + OAUTH_ANTHROPIC_LOGOUT: "oauth:anthropic:logout", + // Dynamic channel prefixes WORKSPACE_CHAT_PREFIX: "workspace:chat:", WORKSPACE_METADATA: "workspace:metadata", diff --git a/src/common/types/ipc.ts b/src/common/types/ipc.ts index d07846257e..9ef5ffc8ba 100644 --- a/src/common/types/ipc.ts +++ b/src/common/types/ipc.ts @@ -375,6 +375,20 @@ export interface IPCApi { /** Transcribe audio using OpenAI Whisper. Audio should be base64-encoded webm/opus. */ transcribe(audioBase64: string): Promise>; }; + oauth: { + anthropic: { + /** Start OAuth flow, returns URL to open in browser, verifier (secret), and state (CSRF token) */ + start( + mode?: "max" | "console" + ): Promise<{ authUrl: string; verifier: string; state: string }>; + /** Exchange authorization code for tokens (verifier and state from start()) */ + exchange(code: string, verifier: string, state: string): Promise>; + /** Get current OAuth status */ + status(): Promise<{ authenticated: boolean; expiresAt?: number }>; + /** Logout (clear OAuth credentials) */ + logout(): Promise; + }; + }; update: { check(): Promise; download(): Promise; diff --git a/src/desktop/preload.ts b/src/desktop/preload.ts index 8ac8a5f8d7..cb238d2e41 100644 --- a/src/desktop/preload.ts +++ b/src/desktop/preload.ts @@ -164,6 +164,16 @@ const api: IPCApi = { transcribe: (audioBase64: string) => ipcRenderer.invoke(IPC_CHANNELS.VOICE_TRANSCRIBE, audioBase64), }, + oauth: { + anthropic: { + start: (mode?: "max" | "console") => + ipcRenderer.invoke(IPC_CHANNELS.OAUTH_ANTHROPIC_START, mode), + exchange: (code: string, verifier: string, state: string) => + ipcRenderer.invoke(IPC_CHANNELS.OAUTH_ANTHROPIC_EXCHANGE, code, verifier, state), + status: () => ipcRenderer.invoke(IPC_CHANNELS.OAUTH_ANTHROPIC_STATUS), + logout: () => ipcRenderer.invoke(IPC_CHANNELS.OAUTH_ANTHROPIC_LOGOUT), + }, + }, update: { check: () => ipcRenderer.invoke(IPC_CHANNELS.UPDATE_CHECK), download: () => ipcRenderer.invoke(IPC_CHANNELS.UPDATE_DOWNLOAD), diff --git a/src/node/config.ts b/src/node/config.ts index 62eaa7799d..1dc483191d 100644 --- a/src/node/config.ts +++ b/src/node/config.ts @@ -5,6 +5,7 @@ import * as jsonc from "jsonc-parser"; import writeFileAtomic from "write-file-atomic"; import type { WorkspaceMetadata, FrontendWorkspaceMetadata } from "@/common/types/workspace"; import type { Secret, SecretsConfig } from "@/common/types/secrets"; +import type { AnthropicOAuthCredentials } from "@/node/services/anthropicOAuth"; import type { Workspace, ProjectConfig, ProjectsConfig } from "@/common/types/project"; import { DEFAULT_RUNTIME_CONFIG } from "@/common/constants/workspace"; import { isIncompatibleRuntimeConfig } from "@/common/utils/runtimeCompatibility"; @@ -37,6 +38,7 @@ export class Config { private readonly configFile: string; private readonly providersFile: string; private readonly secretsFile: string; + private readonly oauthFile: string; constructor(rootDir?: string) { this.rootDir = rootDir ?? getMuxHome(); @@ -45,6 +47,7 @@ export class Config { this.configFile = path.join(this.rootDir, "config.json"); this.providersFile = path.join(this.rootDir, "providers.jsonc"); this.secretsFile = path.join(this.rootDir, "secrets.json"); + this.oauthFile = path.join(this.rootDir, "oauth.json"); } loadConfigOrDefault(): ProjectsConfig { @@ -543,6 +546,89 @@ ${jsonString}`; config[projectPath] = secrets; await this.saveSecretsConfig(config); } + + // ============================================================================ + // OAuth Credentials Management + // ============================================================================ + + /** + * Load Anthropic OAuth credentials from file + * @returns OAuth credentials or null if not configured + */ + loadAnthropicOAuthCredentials(): AnthropicOAuthCredentials | null { + try { + if (fs.existsSync(this.oauthFile)) { + const data = fs.readFileSync(this.oauthFile, "utf-8"); + const parsed = JSON.parse(data) as { anthropic?: AnthropicOAuthCredentials }; + return parsed.anthropic ?? null; + } + } catch (error) { + console.error("Error loading OAuth credentials:", error); + } + return null; + } + + /** + * Save Anthropic OAuth credentials to file + * @param credentials The OAuth credentials to save + */ + async saveAnthropicOAuthCredentials(credentials: AnthropicOAuthCredentials): Promise { + try { + if (!fs.existsSync(this.rootDir)) { + fs.mkdirSync(this.rootDir, { recursive: true }); + } + + // Load existing file to preserve other provider credentials + let existing: Record = {}; + if (fs.existsSync(this.oauthFile)) { + try { + existing = JSON.parse(fs.readFileSync(this.oauthFile, "utf-8")) as Record< + string, + unknown + >; + } catch { + // Ignore parse errors, start fresh + } + } + + existing.anthropic = credentials; + await writeFileAtomic(this.oauthFile, JSON.stringify(existing, null, 2), "utf-8"); + } catch (error) { + console.error("Error saving OAuth credentials:", error); + throw error; + } + } + + /** + * Clear Anthropic OAuth credentials (logout) + */ + async clearAnthropicOAuthCredentials(): Promise { + try { + if (fs.existsSync(this.oauthFile)) { + const data = fs.readFileSync(this.oauthFile, "utf-8"); + const parsed = JSON.parse(data) as Record; + delete parsed.anthropic; + if (Object.keys(parsed).length === 0) { + fs.unlinkSync(this.oauthFile); + } else { + await writeFileAtomic(this.oauthFile, JSON.stringify(parsed, null, 2), "utf-8"); + } + } + } catch (error) { + console.error("Error clearing OAuth credentials:", error); + throw error; + } + } + + /** + * Check if Anthropic OAuth is configured and valid + */ + hasValidAnthropicOAuth(): boolean { + const credentials = this.loadAnthropicOAuthCredentials(); + if (!credentials) return false; + // Check if we have a refresh token (access token may be expired but we can refresh) + return Boolean(credentials.refreshToken); + } } // Default instance for application use diff --git a/src/node/services/aiService.ts b/src/node/services/aiService.ts index 8d54397193..93c338b7c6 100644 --- a/src/node/services/aiService.ts +++ b/src/node/services/aiService.ts @@ -44,6 +44,7 @@ import type { } from "@/common/types/stream"; import { applyToolPolicy, type ToolPolicy } from "@/common/utils/tools/toolPolicy"; import { MockScenarioPlayer } from "./mock/mockScenarioPlayer"; +import { createOAuthFetch, type AnthropicOAuthCredentials } from "./anthropicOAuth"; import { Agent } from "undici"; // Export a standalone version of getToolsForModel for use in backend @@ -383,18 +384,20 @@ export class AIService extends EventEmitter { // Handle Anthropic provider if (providerName === "anthropic") { - // Anthropic API key can come from: - // 1. providers.jsonc config (providerConfig.apiKey) - // 2. ANTHROPIC_API_KEY env var (SDK reads this automatically) - // 3. ANTHROPIC_AUTH_TOKEN env var (we pass this explicitly since SDK doesn't check it) - // We allow env var passthrough so users don't need explicit config. - + // Anthropic authentication priority: + // 1. OAuth credentials (from ~/.mux/oauth.json) - uses Claude Pro/Max subscription + // 2. providers.jsonc config (providerConfig.apiKey) + // 3. ANTHROPIC_API_KEY env var (SDK reads this automatically) + // 4. ANTHROPIC_AUTH_TOKEN env var (we pass this explicitly since SDK doesn't check it) + + const oauthCredentials = this.config.loadAnthropicOAuthCredentials(); + const hasOAuth = Boolean(oauthCredentials?.refreshToken); const hasApiKeyInConfig = Boolean(providerConfig.apiKey); const hasApiKeyEnvVar = Boolean(process.env.ANTHROPIC_API_KEY); const hasAuthTokenEnvVar = Boolean(process.env.ANTHROPIC_AUTH_TOKEN); // Return structured error if no credentials available anywhere - if (!hasApiKeyInConfig && !hasApiKeyEnvVar && !hasAuthTokenEnvVar) { + if (!hasOAuth && !hasApiKeyInConfig && !hasApiKeyEnvVar && !hasAuthTokenEnvVar) { return Err({ type: "api_key_not_found", provider: providerName, @@ -403,7 +406,7 @@ export class AIService extends EventEmitter { // If SDK won't find a key (no config, no ANTHROPIC_API_KEY), use ANTHROPIC_AUTH_TOKEN let configWithApiKey = providerConfig; - if (!hasApiKeyInConfig && !hasApiKeyEnvVar && hasAuthTokenEnvVar) { + if (!hasOAuth && !hasApiKeyInConfig && !hasApiKeyEnvVar && hasAuthTokenEnvVar) { configWithApiKey = { ...providerConfig, apiKey: process.env.ANTHROPIC_AUTH_TOKEN }; } @@ -429,15 +432,41 @@ export class AIService extends EventEmitter { // Lazy-load Anthropic provider to reduce startup time const { createAnthropic } = await PROVIDER_REGISTRY.anthropic(); - // Wrap fetch to inject cache_control on tools and messages - // (SDK doesn't translate providerOptions to cache_control for these) - // Use getProviderFetch to preserve any user-configured custom fetch (e.g., proxies) - const baseFetch = getProviderFetch(providerConfig); - const fetchWithCacheControl = wrapFetchWithAnthropicCacheControl(baseFetch); + + // Build the fetch chain: + // 1. Start with base fetch (user-configured or default) + // 2. Wrap with cache control injection + // 3. If OAuth, wrap with OAuth authentication + let providerFetch: typeof fetch = getProviderFetch(providerConfig); + providerFetch = wrapFetchWithAnthropicCacheControl(providerFetch); + + // Use OAuth if available (takes priority over API key) + if (hasOAuth && oauthCredentials) { + log.info("Using Anthropic OAuth authentication (Claude Pro/Max subscription)"); + const oauthFetch = createOAuthFetch( + oauthCredentials, + async (newCredentials: AnthropicOAuthCredentials) => { + await this.config.saveAnthropicOAuthCredentials(newCredentials); + }, + providerFetch + ); + // OAuth doesn't need an API key - use a placeholder since SDK requires one + // The OAuth fetch wrapper will replace the auth header anyway + const oauthConfig = { ...normalizedConfig, apiKey: "oauth" }; + const provider = createAnthropic({ + ...oauthConfig, + headers, + // Cast is safe: oauthFetch is compatible with SDK's expected fetch signature + fetch: oauthFetch as typeof fetch, + }); + return Ok(provider(modelId)); + } + + // Standard API key authentication const provider = createAnthropic({ ...normalizedConfig, headers, - fetch: fetchWithCacheControl, + fetch: providerFetch, }); return Ok(provider(modelId)); } diff --git a/src/node/services/anthropicOAuth.ts b/src/node/services/anthropicOAuth.ts new file mode 100644 index 0000000000..a7dc2862eb --- /dev/null +++ b/src/node/services/anthropicOAuth.ts @@ -0,0 +1,309 @@ +/** + * Anthropic OAuth Authentication + * + * Implements OAuth 2.0 + PKCE flow for authenticating with Claude Pro/Max accounts. + * This allows users to use their subscription for API calls instead of per-token billing. + * + * Flow: + * 1. Generate PKCE challenge/verifier + * 2. Open browser to auth URL + * 3. User logs in and authorizes + * 4. User copies authorization code + * 5. Exchange code for access_token + refresh_token + * 6. Use Bearer token instead of x-api-key + * 7. Refresh token when expired + * + * Based on the OAuth flow used by Claude Code CLI and OpenCode. + */ + +import * as crypto from "crypto"; + +// Claude Code's registered OAuth client ID +const ANTHROPIC_OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"; +const OAUTH_REDIRECT_URI = "https://console.anthropic.com/oauth/code/callback"; +const TOKEN_ENDPOINT = "https://console.anthropic.com/v1/oauth/token"; + +// Required beta headers for OAuth-authenticated requests +const OAUTH_BETA_HEADERS = ["oauth-2025-04-20", "interleaved-thinking-2025-05-14"]; + +/** + * OAuth token response from Anthropic + */ +export interface AnthropicOAuthTokens { + accessToken: string; + refreshToken: string; + expiresAt: number; // Unix timestamp in milliseconds +} + +/** + * Stored OAuth credentials + */ +export interface AnthropicOAuthCredentials { + type: "oauth"; + accessToken: string; + refreshToken: string; + expiresAt: number; +} + +/** + * Result of starting the OAuth authorization flow + */ +export interface OAuthAuthorizationStart { + /** URL to open in browser for user authorization */ + authUrl: string; + /** PKCE verifier to use when exchanging the code (keep secret!) */ + verifier: string; + /** Random state value for CSRF protection (echoed in callback) */ + state: string; +} + +/** + * Generate a cryptographically secure random string for PKCE + */ +function generateRandomString(length: number): string { + const bytes = crypto.randomBytes(length); + // Use URL-safe base64 encoding + return bytes + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=/g, "") + .slice(0, length); +} + +/** + * Generate SHA-256 hash and encode as base64url for PKCE challenge + */ +function generateCodeChallenge(verifier: string): string { + const hash = crypto.createHash("sha256").update(verifier).digest(); + return hash.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); +} + +/** + * Generate PKCE challenge and verifier + */ +function generatePKCE(): { verifier: string; challenge: string } { + // Use 43-128 characters for verifier (we use 64 for good entropy) + const verifier = generateRandomString(64); + const challenge = generateCodeChallenge(verifier); + return { verifier, challenge }; +} + +/** + * OAuth authorization mode + * - "max": Use claude.ai (for Pro/Max subscribers) + * - "console": Use console.anthropic.com (for API users) + */ +export type OAuthMode = "max" | "console"; + +/** + * Start the OAuth authorization flow + * + * @param mode - Whether to use claude.ai (max) or console.anthropic.com (console) + * @returns Authorization URL, PKCE verifier (secret), and state (for CSRF protection) + */ +export function startOAuthFlow(mode: OAuthMode = "max"): OAuthAuthorizationStart { + const pkce = generatePKCE(); + // Generate separate state for CSRF protection - this value is echoed in the redirect + // Keep the PKCE verifier secret and only send it during token exchange + const state = generateRandomString(32); + + const baseUrl = + mode === "console" + ? "https://console.anthropic.com/oauth/authorize" + : "https://claude.ai/oauth/authorize"; + + const url = new URL(baseUrl); + url.searchParams.set("code", "true"); + url.searchParams.set("client_id", ANTHROPIC_OAUTH_CLIENT_ID); + url.searchParams.set("response_type", "code"); + url.searchParams.set("redirect_uri", OAUTH_REDIRECT_URI); + url.searchParams.set("scope", "org:create_api_key user:profile user:inference"); + url.searchParams.set("code_challenge", pkce.challenge); + url.searchParams.set("code_challenge_method", "S256"); + url.searchParams.set("state", state); + + return { + authUrl: url.toString(), + verifier: pkce.verifier, + state, + }; +} + +/** + * Exchange authorization code for tokens + * + * @param code - The authorization code from the callback (format: code#state) + * @param verifier - The PKCE verifier from startOAuthFlow (kept secret) + * @param expectedState - The state value from startOAuthFlow (for CSRF verification) + * @returns Token response or null on failure + */ +export async function exchangeCodeForTokens( + code: string, + verifier: string, + expectedState: string +): Promise { + // Code format is "code#state" where state is echoed from the authorize request + const [authCode, returnedState] = code.split("#"); + + // Verify state matches to prevent CSRF attacks + if (returnedState !== expectedState) { + console.error("OAuth state mismatch - possible CSRF attack"); + return null; + } + + const response = await fetch(TOKEN_ENDPOINT, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + code: authCode, + state: returnedState, + grant_type: "authorization_code", + client_id: ANTHROPIC_OAUTH_CLIENT_ID, + redirect_uri: OAUTH_REDIRECT_URI, + code_verifier: verifier, + }), + }); + + if (!response.ok) { + console.error("OAuth token exchange failed:", response.status, await response.text()); + return null; + } + + const json = (await response.json()) as { + access_token: string; + refresh_token: string; + expires_in: number; + }; + + return { + accessToken: json.access_token, + refreshToken: json.refresh_token, + expiresAt: Date.now() + json.expires_in * 1000, + }; +} + +/** + * Refresh an expired access token + * + * @param refreshToken - The refresh token from previous authentication + * @returns New tokens or null on failure + */ +export async function refreshAccessToken( + refreshToken: string +): Promise { + const response = await fetch(TOKEN_ENDPOINT, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: ANTHROPIC_OAUTH_CLIENT_ID, + }), + }); + + if (!response.ok) { + console.error("OAuth token refresh failed:", response.status, await response.text()); + return null; + } + + const json = (await response.json()) as { + access_token: string; + refresh_token: string; + expires_in: number; + }; + + return { + accessToken: json.access_token, + refreshToken: json.refresh_token, + expiresAt: Date.now() + json.expires_in * 1000, + }; +} + +/** + * Check if tokens are expired or about to expire (within 5 minutes) + */ +export function isTokenExpired(expiresAt: number): boolean { + const bufferMs = 5 * 60 * 1000; // 5 minutes buffer + return Date.now() >= expiresAt - bufferMs; +} + +/** + * Create a fetch wrapper that uses OAuth Bearer token authentication. + * Automatically refreshes tokens when expired. + * + * @param credentials - Current OAuth credentials + * @param onTokenRefresh - Callback to persist refreshed tokens + * @param baseFetch - Base fetch function to wrap + * @returns Wrapped fetch function (compatible with provider fetch signature) + */ +export function createOAuthFetch( + credentials: AnthropicOAuthCredentials, + onTokenRefresh: (newCredentials: AnthropicOAuthCredentials) => Promise, + baseFetch: (input: RequestInfo | URL, init?: RequestInit) => Promise = fetch +): (input: RequestInfo | URL, init?: RequestInit) => Promise { + let currentCredentials = credentials; + + return async (input: RequestInfo | URL, init?: RequestInit): Promise => { + // Refresh token if expired + if (isTokenExpired(currentCredentials.expiresAt)) { + const newTokens = await refreshAccessToken(currentCredentials.refreshToken); + if (!newTokens) { + throw new Error("Failed to refresh OAuth token. Please re-authenticate."); + } + + currentCredentials = { + type: "oauth", + accessToken: newTokens.accessToken, + refreshToken: newTokens.refreshToken, + expiresAt: newTokens.expiresAt, + }; + + await onTokenRefresh(currentCredentials); + } + + // Build headers with OAuth authentication + const existingHeaders = init?.headers ?? {}; + const headersInit = + existingHeaders instanceof Headers + ? Object.fromEntries(existingHeaders.entries()) + : Array.isArray(existingHeaders) + ? Object.fromEntries(existingHeaders) + : existingHeaders; + + // Merge beta headers + const existingBeta = + (headersInit as Record)["anthropic-beta"] ?? ""; + const existingBetaList = existingBeta + .split(",") + .map((b) => b.trim()) + .filter(Boolean); + + const mergedBetas = [...new Set([...OAUTH_BETA_HEADERS, ...existingBetaList])].join(","); + + const headers: Record = { + ...headersInit, + Authorization: `Bearer ${currentCredentials.accessToken}`, + "anthropic-beta": mergedBetas, + }; + + // Remove x-api-key if present (OAuth uses Bearer token instead) + delete headers["x-api-key"]; + + return baseFetch(input, { + ...init, + headers, + }); + }; +} + +/** + * Get OAuth beta headers as a string (for use with existing header merging) + */ +export function getOAuthBetaHeaders(): string { + return OAUTH_BETA_HEADERS.join(","); +} diff --git a/src/node/services/ipcMain.ts b/src/node/services/ipcMain.ts index ebd89108bc..6683981f0c 100644 --- a/src/node/services/ipcMain.ts +++ b/src/node/services/ipcMain.ts @@ -44,6 +44,11 @@ import { PTYService } from "@/node/services/ptyService"; import type { TerminalWindowManager } from "@/desktop/terminalWindowManager"; import type { TerminalCreateParams, TerminalResizeParams } from "@/common/types/terminal"; import { ExtensionMetadataService } from "@/node/services/ExtensionMetadataService"; +import { + startOAuthFlow, + exchangeCodeForTokens, + type OAuthMode, +} from "@/node/services/anthropicOAuth"; import OpenAI from "openai"; /** Maximum number of retry attempts when workspace name collides */ @@ -673,6 +678,9 @@ export class IpcMain { } ); + // OAuth handlers + this.registerOAuthHandlers(ipcMain); + ipcMain.handle( IPC_CHANNELS.WORKSPACE_CREATE, async ( @@ -2343,4 +2351,75 @@ export class IpcMain { }); return events; } + + /** + * Register OAuth-related IPC handlers + */ + private registerOAuthHandlers(ipcMain: ElectronIpcMain): void { + // Start Anthropic OAuth flow + ipcMain.handle( + IPC_CHANNELS.OAUTH_ANTHROPIC_START, + (_event, mode?: OAuthMode): { authUrl: string; verifier: string; state: string } => { + log.info("[IpcMain] Starting Anthropic OAuth flow", { mode: mode ?? "max" }); + const { authUrl, verifier, state } = startOAuthFlow(mode ?? "max"); + return { authUrl, verifier, state }; + } + ); + + // Exchange authorization code for tokens + ipcMain.handle( + IPC_CHANNELS.OAUTH_ANTHROPIC_EXCHANGE, + async ( + _event, + code: string, + verifier: string, + state: string + ): Promise> => { + try { + log.info("[IpcMain] Exchanging OAuth authorization code"); + const tokens = await exchangeCodeForTokens(code, verifier, state); + + if (!tokens) { + return Err("Failed to exchange authorization code. Please try again."); + } + + // Save credentials + await this.config.saveAnthropicOAuthCredentials({ + type: "oauth", + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + expiresAt: tokens.expiresAt, + }); + + log.info("[IpcMain] Successfully authenticated with Anthropic OAuth"); + return Ok(undefined); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + log.error("[IpcMain] OAuth exchange failed", error); + return Err(`OAuth exchange failed: ${message}`); + } + } + ); + + // Get OAuth status + ipcMain.handle( + IPC_CHANNELS.OAUTH_ANTHROPIC_STATUS, + (): { authenticated: boolean; expiresAt?: number } => { + const credentials = this.config.loadAnthropicOAuthCredentials(); + if (!credentials) { + return { authenticated: false }; + } + return { + authenticated: true, + expiresAt: credentials.expiresAt, + }; + } + ); + + // Logout (clear OAuth credentials) + ipcMain.handle(IPC_CHANNELS.OAUTH_ANTHROPIC_LOGOUT, async (): Promise => { + log.info("[IpcMain] Logging out of Anthropic OAuth"); + await this.config.clearAnthropicOAuthCredentials(); + }); + } }