From 49a5a44c8a230266bac4c4c132674eb9b40e65d9 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Fri, 27 Feb 2026 13:03:01 -0800 Subject: [PATCH] feat(auth): add Anthropic OAuth support and deferred credential loading - Read Claude Code's OAuth token from ~/.claude/.credentials.json as a fallback source for auth_mode: oauth (with expiry checking) - Fix OAuth callback server to bind to localhost (not 127.0.0.1) and use JSON content type for token exchange - Null out apiKey when authToken is set to prevent SDK from falling back to ANTHROPIC_API_KEY env var (routes to wrong billing) - Add DeferredErrorClient so daemon starts even when credentials are missing, surfacing the error on first chat() call instead of crash - Prompt to complete OAuth flow immediately when setting auth_mode to oauth with no token stored Note: Anthropic currently rejects OAuth for API access (Feb 2026 policy change), but the plumbing is in place for if/when re-enabled. Co-Authored-By: Claude Sonnet 4.6 --- src/auth/anthropic.ts | 40 +++++++++++++++++++++++++++++------- src/daemon/models.ts | 39 +++++++++++++++++++++++++++++------ src/frontends/tui/minimal.ts | 28 +++++++++++++++++++++++++ src/models/anthropic.ts | 4 +++- 4 files changed, 97 insertions(+), 14 deletions(-) diff --git a/src/auth/anthropic.ts b/src/auth/anthropic.ts index ea13676..4ca1721 100644 --- a/src/auth/anthropic.ts +++ b/src/auth/anthropic.ts @@ -12,7 +12,7 @@ const AUTH_FILE = resolve(AUTH_DIR, 'auth.json'); const ANTHROPIC_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e'; const ANTHROPIC_AUTH_URL = 'https://claude.ai/oauth/authorize'; -const ANTHROPIC_TOKEN_URL = 'https://console.anthropic.com/v1/oauth/token'; +const ANTHROPIC_TOKEN_URL = 'https://claude.ai/oauth/token'; const ANTHROPIC_OAUTH_SCOPES = 'user:inference user:profile'; const OAUTH_TIMEOUT_MS = 5 * 60 * 1000; @@ -94,7 +94,7 @@ export function startCallbackServer(timeoutMs: number, signal?: AbortSignal): Pr }, { once: true }); } - server.listen(0, '127.0.0.1', () => { + server.listen(0, 'localhost', () => { const port = (server.address() as AddressInfo).port; resolveServer({ port, waitForCode }); }); @@ -123,14 +123,14 @@ export async function exchangeCodeForToken( ): Promise { const response = await fetch(ANTHROPIC_TOKEN_URL, { method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ grant_type: 'authorization_code', client_id: ANTHROPIC_CLIENT_ID, code, code_verifier: codeVerifier, redirect_uri: redirectUri, - }).toString(), + }), }); if (!response.ok) { @@ -166,7 +166,7 @@ export async function loginAnthropicOAuth( const state = randomBytes(16).toString('hex'); const { port, waitForCode } = await _startServer(OAUTH_TIMEOUT_MS, signal); - const redirectUri = `http://127.0.0.1:${port}/callback`; + const redirectUri = `http://localhost:${port}/callback`; const authUrl = new URL(ANTHROPIC_AUTH_URL); authUrl.searchParams.set('response_type', 'code'); @@ -285,12 +285,38 @@ export function getAnthropicApiKey(): string | null { ?? null; } +/** + * Read Claude Code's stored OAuth credentials from ~/.claude/.credentials.json. + * Returns the access token if present and not expired, otherwise null. + */ +function getClaudeCodeOAuthToken(): string | null { + try { + const credPath = resolve(homedir(), '.claude', '.credentials.json'); + const raw = readFileSync(credPath, 'utf-8'); + const data = JSON.parse(raw) as Record; + const oauth = data.claudeAiOauth as Record | undefined; + if (!oauth) { return null; } + + const token = oauth.accessToken; + if (typeof token !== 'string' || !token) { return null; } + + // Check expiry (expiresAt is ms since epoch) + const expiresAt = typeof oauth.expiresAt === 'number' ? oauth.expiresAt : 0; + if (expiresAt > 0 && Date.now() > expiresAt) { return null; } + + return token; + } catch { + return null; + } +} + /** * Get an Anthropic auth token from any available source. - * Priority: ANTHROPIC_AUTH_TOKEN → stored auth.json. + * Priority: ANTHROPIC_AUTH_TOKEN env → Flynn auth.json → Claude Code credentials. */ export function getAnthropicAuthToken(): string | null { return process.env.ANTHROPIC_AUTH_TOKEN ?? loadStoredAnthropicAuth()?.auth_token + ?? getClaudeCodeOAuthToken() ?? null; } diff --git a/src/daemon/models.ts b/src/daemon/models.ts index 8901460..f13c488 100644 --- a/src/daemon/models.ts +++ b/src/daemon/models.ts @@ -78,6 +78,33 @@ function resolveZaiCredential(cfg: ModelConfig): string { return raw.startsWith('Bearer ') ? raw.slice('Bearer '.length) : raw; } +/** + * A ModelClient that defers a credential error to the first chat() call. + * Used so the daemon can start even when credentials are not yet configured. + */ +class DeferredErrorClient implements ModelClient { + constructor(private readonly error: Error) {} + + chat(): Promise { + return Promise.reject(this.error); + } +} + +/** + * Like createClientFromConfig but never throws at construction time. + * If credentials are missing, returns a DeferredErrorClient that will + * surface the error on the first chat() call. + */ +export function createClientFromConfigOrDeferred(cfg: ModelConfig): ModelClient { + try { + return createClientFromConfig(cfg); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + logger.warn(`Deferred credential error for provider "${cfg.provider}": ${err.message}`); + return new DeferredErrorClient(err); + } +} + /** * Create a ModelClient from a provider config entry. * Dispatches on the `provider` field so all tiers and fallback entries @@ -391,11 +418,11 @@ export function createAutoFallbackClient(tierConfig: { provider: string; model: export function createModelRouter(config: Config): ModelRouter { const models = config.models; - const defaultClient = createClientFromConfig(models.default); + const defaultClient = createClientFromConfigOrDeferred(models.default); - const fastClient = models.fast ? createClientFromConfig(models.fast) : undefined; - const complexClient = models.complex ? createClientFromConfig(models.complex) : undefined; - const localClient = models.local ? createClientFromConfig(models.local) : undefined; + const fastClient = models.fast ? createClientFromConfigOrDeferred(models.fast) : undefined; + const complexClient = models.complex ? createClientFromConfigOrDeferred(models.complex) : undefined; + const localClient = models.local ? createClientFromConfigOrDeferred(models.local) : undefined; // Build fallback chain — each entry references a tier name or 'local' const fallbackChain: ModelClient[] = []; @@ -411,7 +438,7 @@ export function createModelRouter(config: Config): ModelRouter { fallbackChain.push(complexClient); } else if (models.local_providers?.[providerName]) { // Named provider from local_providers map - fallbackChain.push(createClientFromConfig(models.local_providers[providerName])); + fallbackChain.push(createClientFromConfigOrDeferred(models.local_providers[providerName])); } else { logger.warn(`Fallback chain entry "${providerName}" not found — skipping`); } @@ -448,7 +475,7 @@ export function createModelRouter(config: Config): ModelRouter { // User-configured inline fallback if (cfg.fallback) { - fallbackList.push(createClientFromConfig(cfg.fallback)); + fallbackList.push(createClientFromConfigOrDeferred(cfg.fallback)); } if (fallbackList.length > 0) { diff --git a/src/frontends/tui/minimal.ts b/src/frontends/tui/minimal.ts index 79d985c..2648d84 100644 --- a/src/frontends/tui/minimal.ts +++ b/src/frontends/tui/minimal.ts @@ -1043,6 +1043,34 @@ export class MinimalTui { `${colors.gray}auth_mode for ${resolvedProvider} set to ${colors.reset}${mode}` + `${colors.gray}. Restart Flynn for the change to take effect.${colors.reset}\n`, ); + + // If oauth mode is set but no token exists yet, offer to authenticate now + if (mode === 'oauth' && resolvedProvider === 'anthropic' && !loadStoredAnthropicAuthToken()) { + console.log(`${colors.gray}No Anthropic OAuth token stored yet.${colors.reset}`); + const answer = (await this.prompt( + `${colors.orange}Complete OAuth flow now?${colors.reset} ${colors.gray}(y/N)${colors.reset} `, + )).trim().toLowerCase(); + if (answer === 'y' || answer === 'yes') { + console.log(`${colors.gray}Starting Anthropic browser OAuth...${colors.reset}`); + const abortController = new AbortController(); + this.activeOperationCancel = () => abortController.abort(); + try { + await loginAnthropicOAuth((url) => { + openBrowser(url); + console.log(`${colors.gray}Opening browser. If it didn't open, visit:${colors.reset}`); + console.log(url); + console.log(`${colors.gray}Waiting for authentication (up to 5 minutes)...${colors.reset}`); + }, abortController.signal); + console.log(`${colors.gray}Anthropic auth token stored. Flynn is ready to restart.${colors.reset}\n`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.log(`${colors.gray}Anthropic OAuth failed:${colors.reset} ${message}\n`); + } finally { + this.activeOperationCancel = null; + } + } + } + return; } diff --git a/src/models/anthropic.ts b/src/models/anthropic.ts index 1bfddb7..dc9e3c2 100644 --- a/src/models/anthropic.ts +++ b/src/models/anthropic.ts @@ -69,7 +69,9 @@ export class AnthropicClient implements ModelClient { constructor(config: AnthropicClientConfig) { this.client = new Anthropic({ - apiKey: config.apiKey, + // When using authToken, explicitly null out apiKey to prevent the SDK + // from falling back to ANTHROPIC_API_KEY env var (which routes to API billing). + apiKey: config.authToken ? null : config.apiKey, authToken: config.authToken, }); this.model = config.model;