Skip to content

Commit 33f96aa

Browse files
committed
🤖 feat: add Anthropic OAuth authentication for Claude Pro/Max subscription
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. Key changes: - src/node/services/anthropicOAuth.ts: OAuth module with PKCE flow - startOAuthFlow(): Generate auth URL and PKCE verifier - exchangeCodeForTokens(): Exchange authorization code for tokens - refreshAccessToken(): Automatic token refresh - createOAuthFetch(): Fetch wrapper that adds OAuth auth headers - src/node/config.ts: OAuth credential storage - loadAnthropicOAuthCredentials() - saveAnthropicOAuthCredentials() - clearAnthropicOAuthCredentials() - Stored in ~/.mux/oauth.json - src/node/services/aiService.ts: OAuth integration - OAuth takes priority over API key when configured - Automatic token refresh on expiry - Logs when using OAuth authentication - IPC API for frontend integration: - oauth.anthropic.start(mode): Start OAuth flow - oauth.anthropic.exchange(code, verifier): Complete flow - oauth.anthropic.status(): Check authentication status - oauth.anthropic.logout(): Clear credentials Based on the OAuth flow used by Claude Code CLI and OpenCode. _Generated with mux_
1 parent e1117fc commit 33f96aa

File tree

9 files changed

+546
-14
lines changed

9 files changed

+546
-14
lines changed

‎src/browser/api.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,15 @@ const webApi: IPCApi = {
364364
voice: {
365365
transcribe: (audioBase64) => invokeIPC(IPC_CHANNELS.VOICE_TRANSCRIBE, audioBase64),
366366
},
367+
oauth: {
368+
anthropic: {
369+
start: (mode) => invokeIPC(IPC_CHANNELS.OAUTH_ANTHROPIC_START, mode),
370+
exchange: (code, verifier) =>
371+
invokeIPC(IPC_CHANNELS.OAUTH_ANTHROPIC_EXCHANGE, code, verifier),
372+
status: () => invokeIPC(IPC_CHANNELS.OAUTH_ANTHROPIC_STATUS),
373+
logout: () => invokeIPC(IPC_CHANNELS.OAUTH_ANTHROPIC_LOGOUT),
374+
},
375+
},
367376
update: {
368377
check: () => invokeIPC(IPC_CHANNELS.UPDATE_CHECK),
369378
download: () => invokeIPC(IPC_CHANNELS.UPDATE_DOWNLOAD),

‎src/browser/stories/mockFactory.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -459,6 +459,14 @@ export function createMockAPI(options: MockAPIOptions): IPCApi {
459459
voice: {
460460
transcribe: () => Promise.resolve({ success: false, error: "Not implemented in mock" }),
461461
},
462+
oauth: {
463+
anthropic: {
464+
start: () => Promise.resolve({ authUrl: "https://claude.ai/oauth", verifier: "mock" }),
465+
exchange: () => Promise.resolve({ success: false, error: "Not implemented in mock" }),
466+
status: () => Promise.resolve({ authenticated: false }),
467+
logout: () => Promise.resolve(),
468+
},
469+
},
462470
update: {
463471
check: () => Promise.resolve(undefined),
464472
download: () => Promise.resolve(undefined),

‎src/common/constants/ipc-constants.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,12 @@ export const IPC_CHANNELS = {
7171
// Voice channels
7272
VOICE_TRANSCRIBE: "voice:transcribe",
7373

74+
// OAuth channels
75+
OAUTH_ANTHROPIC_START: "oauth:anthropic:start",
76+
OAUTH_ANTHROPIC_EXCHANGE: "oauth:anthropic:exchange",
77+
OAUTH_ANTHROPIC_STATUS: "oauth:anthropic:status",
78+
OAUTH_ANTHROPIC_LOGOUT: "oauth:anthropic:logout",
79+
7480
// Dynamic channel prefixes
7581
WORKSPACE_CHAT_PREFIX: "workspace:chat:",
7682
WORKSPACE_METADATA: "workspace:metadata",

‎src/common/types/ipc.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,18 @@ export interface IPCApi {
375375
/** Transcribe audio using OpenAI Whisper. Audio should be base64-encoded webm/opus. */
376376
transcribe(audioBase64: string): Promise<Result<string, string>>;
377377
};
378+
oauth: {
379+
anthropic: {
380+
/** Start OAuth flow, returns URL to open in browser and verifier to store */
381+
start(mode?: "max" | "console"): Promise<{ authUrl: string; verifier: string }>;
382+
/** Exchange authorization code for tokens */
383+
exchange(code: string, verifier: string): Promise<Result<void, string>>;
384+
/** Get current OAuth status */
385+
status(): Promise<{ authenticated: boolean; expiresAt?: number }>;
386+
/** Logout (clear OAuth credentials) */
387+
logout(): Promise<void>;
388+
};
389+
};
378390
update: {
379391
check(): Promise<void>;
380392
download(): Promise<void>;

‎src/desktop/preload.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,16 @@ const api: IPCApi = {
164164
transcribe: (audioBase64: string) =>
165165
ipcRenderer.invoke(IPC_CHANNELS.VOICE_TRANSCRIBE, audioBase64),
166166
},
167+
oauth: {
168+
anthropic: {
169+
start: (mode?: "max" | "console") =>
170+
ipcRenderer.invoke(IPC_CHANNELS.OAUTH_ANTHROPIC_START, mode),
171+
exchange: (code: string, verifier: string) =>
172+
ipcRenderer.invoke(IPC_CHANNELS.OAUTH_ANTHROPIC_EXCHANGE, code, verifier),
173+
status: () => ipcRenderer.invoke(IPC_CHANNELS.OAUTH_ANTHROPIC_STATUS),
174+
logout: () => ipcRenderer.invoke(IPC_CHANNELS.OAUTH_ANTHROPIC_LOGOUT),
175+
},
176+
},
167177
update: {
168178
check: () => ipcRenderer.invoke(IPC_CHANNELS.UPDATE_CHECK),
169179
download: () => ipcRenderer.invoke(IPC_CHANNELS.UPDATE_DOWNLOAD),

‎src/node/config.ts‎

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import * as jsonc from "jsonc-parser";
55
import writeFileAtomic from "write-file-atomic";
66
import type { WorkspaceMetadata, FrontendWorkspaceMetadata } from "@/common/types/workspace";
77
import type { Secret, SecretsConfig } from "@/common/types/secrets";
8+
import type { AnthropicOAuthCredentials } from "@/node/services/anthropicOAuth";
89
import type { Workspace, ProjectConfig, ProjectsConfig } from "@/common/types/project";
910
import { DEFAULT_RUNTIME_CONFIG } from "@/common/constants/workspace";
1011
import { isIncompatibleRuntimeConfig } from "@/common/utils/runtimeCompatibility";
@@ -37,6 +38,7 @@ export class Config {
3738
private readonly configFile: string;
3839
private readonly providersFile: string;
3940
private readonly secretsFile: string;
41+
private readonly oauthFile: string;
4042

4143
constructor(rootDir?: string) {
4244
this.rootDir = rootDir ?? getMuxHome();
@@ -45,6 +47,7 @@ export class Config {
4547
this.configFile = path.join(this.rootDir, "config.json");
4648
this.providersFile = path.join(this.rootDir, "providers.jsonc");
4749
this.secretsFile = path.join(this.rootDir, "secrets.json");
50+
this.oauthFile = path.join(this.rootDir, "oauth.json");
4851
}
4952

5053
loadConfigOrDefault(): ProjectsConfig {
@@ -543,6 +546,89 @@ ${jsonString}`;
543546
config[projectPath] = secrets;
544547
await this.saveSecretsConfig(config);
545548
}
549+
550+
// ============================================================================
551+
// OAuth Credentials Management
552+
// ============================================================================
553+
554+
/**
555+
* Load Anthropic OAuth credentials from file
556+
* @returns OAuth credentials or null if not configured
557+
*/
558+
loadAnthropicOAuthCredentials(): AnthropicOAuthCredentials | null {
559+
try {
560+
if (fs.existsSync(this.oauthFile)) {
561+
const data = fs.readFileSync(this.oauthFile, "utf-8");
562+
const parsed = JSON.parse(data) as { anthropic?: AnthropicOAuthCredentials };
563+
return parsed.anthropic ?? null;
564+
}
565+
} catch (error) {
566+
console.error("Error loading OAuth credentials:", error);
567+
}
568+
return null;
569+
}
570+
571+
/**
572+
* Save Anthropic OAuth credentials to file
573+
* @param credentials The OAuth credentials to save
574+
*/
575+
async saveAnthropicOAuthCredentials(credentials: AnthropicOAuthCredentials): Promise<void> {
576+
try {
577+
if (!fs.existsSync(this.rootDir)) {
578+
fs.mkdirSync(this.rootDir, { recursive: true });
579+
}
580+
581+
// Load existing file to preserve other provider credentials
582+
let existing: Record<string, unknown> = {};
583+
if (fs.existsSync(this.oauthFile)) {
584+
try {
585+
existing = JSON.parse(fs.readFileSync(this.oauthFile, "utf-8")) as Record<
586+
string,
587+
unknown
588+
>;
589+
} catch {
590+
// Ignore parse errors, start fresh
591+
}
592+
}
593+
594+
existing.anthropic = credentials;
595+
await writeFileAtomic(this.oauthFile, JSON.stringify(existing, null, 2), "utf-8");
596+
} catch (error) {
597+
console.error("Error saving OAuth credentials:", error);
598+
throw error;
599+
}
600+
}
601+
602+
/**
603+
* Clear Anthropic OAuth credentials (logout)
604+
*/
605+
async clearAnthropicOAuthCredentials(): Promise<void> {
606+
try {
607+
if (fs.existsSync(this.oauthFile)) {
608+
const data = fs.readFileSync(this.oauthFile, "utf-8");
609+
const parsed = JSON.parse(data) as Record<string, unknown>;
610+
delete parsed.anthropic;
611+
if (Object.keys(parsed).length === 0) {
612+
fs.unlinkSync(this.oauthFile);
613+
} else {
614+
await writeFileAtomic(this.oauthFile, JSON.stringify(parsed, null, 2), "utf-8");
615+
}
616+
}
617+
} catch (error) {
618+
console.error("Error clearing OAuth credentials:", error);
619+
throw error;
620+
}
621+
}
622+
623+
/**
624+
* Check if Anthropic OAuth is configured and valid
625+
*/
626+
hasValidAnthropicOAuth(): boolean {
627+
const credentials = this.loadAnthropicOAuthCredentials();
628+
if (!credentials) return false;
629+
// Check if we have a refresh token (access token may be expired but we can refresh)
630+
return Boolean(credentials.refreshToken);
631+
}
546632
}
547633

548634
// Default instance for application use

‎src/node/services/aiService.ts‎

Lines changed: 43 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import type {
4444
} from "@/common/types/stream";
4545
import { applyToolPolicy, type ToolPolicy } from "@/common/utils/tools/toolPolicy";
4646
import { MockScenarioPlayer } from "./mock/mockScenarioPlayer";
47+
import { createOAuthFetch, type AnthropicOAuthCredentials } from "./anthropicOAuth";
4748
import { Agent } from "undici";
4849

4950
// Export a standalone version of getToolsForModel for use in backend
@@ -383,18 +384,20 @@ export class AIService extends EventEmitter {
383384

384385
// Handle Anthropic provider
385386
if (providerName === "anthropic") {
386-
// Anthropic API key can come from:
387-
// 1. providers.jsonc config (providerConfig.apiKey)
388-
// 2. ANTHROPIC_API_KEY env var (SDK reads this automatically)
389-
// 3. ANTHROPIC_AUTH_TOKEN env var (we pass this explicitly since SDK doesn't check it)
390-
// We allow env var passthrough so users don't need explicit config.
391-
387+
// Anthropic authentication priority:
388+
// 1. OAuth credentials (from ~/.mux/oauth.json) - uses Claude Pro/Max subscription
389+
// 2. providers.jsonc config (providerConfig.apiKey)
390+
// 3. ANTHROPIC_API_KEY env var (SDK reads this automatically)
391+
// 4. ANTHROPIC_AUTH_TOKEN env var (we pass this explicitly since SDK doesn't check it)
392+
393+
const oauthCredentials = this.config.loadAnthropicOAuthCredentials();
394+
const hasOAuth = Boolean(oauthCredentials?.refreshToken);
392395
const hasApiKeyInConfig = Boolean(providerConfig.apiKey);
393396
const hasApiKeyEnvVar = Boolean(process.env.ANTHROPIC_API_KEY);
394397
const hasAuthTokenEnvVar = Boolean(process.env.ANTHROPIC_AUTH_TOKEN);
395398

396399
// Return structured error if no credentials available anywhere
397-
if (!hasApiKeyInConfig && !hasApiKeyEnvVar && !hasAuthTokenEnvVar) {
400+
if (!hasOAuth && !hasApiKeyInConfig && !hasApiKeyEnvVar && !hasAuthTokenEnvVar) {
398401
return Err({
399402
type: "api_key_not_found",
400403
provider: providerName,
@@ -403,7 +406,7 @@ export class AIService extends EventEmitter {
403406

404407
// If SDK won't find a key (no config, no ANTHROPIC_API_KEY), use ANTHROPIC_AUTH_TOKEN
405408
let configWithApiKey = providerConfig;
406-
if (!hasApiKeyInConfig && !hasApiKeyEnvVar && hasAuthTokenEnvVar) {
409+
if (!hasOAuth && !hasApiKeyInConfig && !hasApiKeyEnvVar && hasAuthTokenEnvVar) {
407410
configWithApiKey = { ...providerConfig, apiKey: process.env.ANTHROPIC_AUTH_TOKEN };
408411
}
409412

@@ -429,15 +432,41 @@ export class AIService extends EventEmitter {
429432

430433
// Lazy-load Anthropic provider to reduce startup time
431434
const { createAnthropic } = await PROVIDER_REGISTRY.anthropic();
432-
// Wrap fetch to inject cache_control on tools and messages
433-
// (SDK doesn't translate providerOptions to cache_control for these)
434-
// Use getProviderFetch to preserve any user-configured custom fetch (e.g., proxies)
435-
const baseFetch = getProviderFetch(providerConfig);
436-
const fetchWithCacheControl = wrapFetchWithAnthropicCacheControl(baseFetch);
435+
436+
// Build the fetch chain:
437+
// 1. Start with base fetch (user-configured or default)
438+
// 2. Wrap with cache control injection
439+
// 3. If OAuth, wrap with OAuth authentication
440+
let providerFetch: typeof fetch = getProviderFetch(providerConfig);
441+
providerFetch = wrapFetchWithAnthropicCacheControl(providerFetch);
442+
443+
// Use OAuth if available (takes priority over API key)
444+
if (hasOAuth && oauthCredentials) {
445+
log.info("Using Anthropic OAuth authentication (Claude Pro/Max subscription)");
446+
const oauthFetch = createOAuthFetch(
447+
oauthCredentials,
448+
async (newCredentials: AnthropicOAuthCredentials) => {
449+
await this.config.saveAnthropicOAuthCredentials(newCredentials);
450+
},
451+
providerFetch
452+
);
453+
// OAuth doesn't need an API key - use a placeholder since SDK requires one
454+
// The OAuth fetch wrapper will replace the auth header anyway
455+
const oauthConfig = { ...normalizedConfig, apiKey: "oauth" };
456+
const provider = createAnthropic({
457+
...oauthConfig,
458+
headers,
459+
// Cast is safe: oauthFetch is compatible with SDK's expected fetch signature
460+
fetch: oauthFetch as typeof fetch,
461+
});
462+
return Ok(provider(modelId));
463+
}
464+
465+
// Standard API key authentication
437466
const provider = createAnthropic({
438467
...normalizedConfig,
439468
headers,
440-
fetch: fetchWithCacheControl,
469+
fetch: providerFetch,
441470
});
442471
return Ok(provider(modelId));
443472
}

0 commit comments

Comments
 (0)