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 <noreply@anthropic.com>
This commit is contained in:
William Valentin
2026-02-27 13:03:01 -08:00
parent 487e5c2930
commit 49a5a44c8a
4 changed files with 97 additions and 14 deletions
+33 -7
View File
@@ -12,7 +12,7 @@ const AUTH_FILE = resolve(AUTH_DIR, 'auth.json');
const ANTHROPIC_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e'; const ANTHROPIC_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
const ANTHROPIC_AUTH_URL = 'https://claude.ai/oauth/authorize'; 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 ANTHROPIC_OAUTH_SCOPES = 'user:inference user:profile';
const OAUTH_TIMEOUT_MS = 5 * 60 * 1000; const OAUTH_TIMEOUT_MS = 5 * 60 * 1000;
@@ -94,7 +94,7 @@ export function startCallbackServer(timeoutMs: number, signal?: AbortSignal): Pr
}, { once: true }); }, { once: true });
} }
server.listen(0, '127.0.0.1', () => { server.listen(0, 'localhost', () => {
const port = (server.address() as AddressInfo).port; const port = (server.address() as AddressInfo).port;
resolveServer({ port, waitForCode }); resolveServer({ port, waitForCode });
}); });
@@ -123,14 +123,14 @@ export async function exchangeCodeForToken(
): Promise<string> { ): Promise<string> {
const response = await fetch(ANTHROPIC_TOKEN_URL, { const response = await fetch(ANTHROPIC_TOKEN_URL, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, headers: { 'Content-Type': 'application/json' },
body: new URLSearchParams({ body: JSON.stringify({
grant_type: 'authorization_code', grant_type: 'authorization_code',
client_id: ANTHROPIC_CLIENT_ID, client_id: ANTHROPIC_CLIENT_ID,
code, code,
code_verifier: codeVerifier, code_verifier: codeVerifier,
redirect_uri: redirectUri, redirect_uri: redirectUri,
}).toString(), }),
}); });
if (!response.ok) { if (!response.ok) {
@@ -166,7 +166,7 @@ export async function loginAnthropicOAuth(
const state = randomBytes(16).toString('hex'); const state = randomBytes(16).toString('hex');
const { port, waitForCode } = await _startServer(OAUTH_TIMEOUT_MS, signal); 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); const authUrl = new URL(ANTHROPIC_AUTH_URL);
authUrl.searchParams.set('response_type', 'code'); authUrl.searchParams.set('response_type', 'code');
@@ -285,12 +285,38 @@ export function getAnthropicApiKey(): string | null {
?? 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<string, unknown>;
const oauth = data.claudeAiOauth as Record<string, unknown> | 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. * 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 { export function getAnthropicAuthToken(): string | null {
return process.env.ANTHROPIC_AUTH_TOKEN return process.env.ANTHROPIC_AUTH_TOKEN
?? loadStoredAnthropicAuth()?.auth_token ?? loadStoredAnthropicAuth()?.auth_token
?? getClaudeCodeOAuthToken()
?? null; ?? null;
} }
+33 -6
View File
@@ -78,6 +78,33 @@ function resolveZaiCredential(cfg: ModelConfig): string {
return raw.startsWith('Bearer ') ? raw.slice('Bearer '.length) : raw; 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<never> {
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. * Create a ModelClient from a provider config entry.
* Dispatches on the `provider` field so all tiers and fallback entries * 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 { export function createModelRouter(config: Config): ModelRouter {
const models = config.models; const models = config.models;
const defaultClient = createClientFromConfig(models.default); const defaultClient = createClientFromConfigOrDeferred(models.default);
const fastClient = models.fast ? createClientFromConfig(models.fast) : undefined; const fastClient = models.fast ? createClientFromConfigOrDeferred(models.fast) : undefined;
const complexClient = models.complex ? createClientFromConfig(models.complex) : undefined; const complexClient = models.complex ? createClientFromConfigOrDeferred(models.complex) : undefined;
const localClient = models.local ? createClientFromConfig(models.local) : undefined; const localClient = models.local ? createClientFromConfigOrDeferred(models.local) : undefined;
// Build fallback chain — each entry references a tier name or 'local' // Build fallback chain — each entry references a tier name or 'local'
const fallbackChain: ModelClient[] = []; const fallbackChain: ModelClient[] = [];
@@ -411,7 +438,7 @@ export function createModelRouter(config: Config): ModelRouter {
fallbackChain.push(complexClient); fallbackChain.push(complexClient);
} else if (models.local_providers?.[providerName]) { } else if (models.local_providers?.[providerName]) {
// Named provider from local_providers map // Named provider from local_providers map
fallbackChain.push(createClientFromConfig(models.local_providers[providerName])); fallbackChain.push(createClientFromConfigOrDeferred(models.local_providers[providerName]));
} else { } else {
logger.warn(`Fallback chain entry "${providerName}" not found — skipping`); logger.warn(`Fallback chain entry "${providerName}" not found — skipping`);
} }
@@ -448,7 +475,7 @@ export function createModelRouter(config: Config): ModelRouter {
// User-configured inline fallback // User-configured inline fallback
if (cfg.fallback) { if (cfg.fallback) {
fallbackList.push(createClientFromConfig(cfg.fallback)); fallbackList.push(createClientFromConfigOrDeferred(cfg.fallback));
} }
if (fallbackList.length > 0) { if (fallbackList.length > 0) {
+28
View File
@@ -1043,6 +1043,34 @@ export class MinimalTui {
`${colors.gray}auth_mode for ${resolvedProvider} set to ${colors.reset}${mode}` + `${colors.gray}auth_mode for ${resolvedProvider} set to ${colors.reset}${mode}` +
`${colors.gray}. Restart Flynn for the change to take effect.${colors.reset}\n`, `${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; return;
} }
+3 -1
View File
@@ -69,7 +69,9 @@ export class AnthropicClient implements ModelClient {
constructor(config: AnthropicClientConfig) { constructor(config: AnthropicClientConfig) {
this.client = new Anthropic({ 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, authToken: config.authToken,
}); });
this.model = config.model; this.model = config.model;