diff --git a/README.md b/README.md index 25e5857..de66f82 100644 --- a/README.md +++ b/README.md @@ -103,9 +103,10 @@ export ANTHROPIC_API_KEY= coven-code ``` -Or log in via OAuth (Anthropic accounts): +Or log in via OAuth after configuring a Coven Code OAuth client: ```bash +export COVEN_CODE_ANTHROPIC_OAUTH_CLIENT_ID= coven-code auth login ``` diff --git a/docs/auth.md b/docs/auth.md index 2868dee..f84024d 100644 --- a/docs/auth.md +++ b/docs/auth.md @@ -94,16 +94,15 @@ coven-code --api-key "sk-ant-api03-..." "your prompt" Coven Code supports an OAuth 2.0 PKCE flow that authenticates through either the Anthropic Console or Claude.ai in your browser. -> **Important:** The OAuth client IDs in Coven Code are registered to Anthropic's -> official Claude Code CLI application. Anthropic's authorization server may -> reject or misattribute OAuth requests originating from Coven Code. The API key -> method is the recommended path for Coven Code users. -> -> If OAuth login is attempted and fails, use Method 1 (API key) instead. +> **Important:** Coven Code must not reuse Claude Code's OAuth client ID. +> Anthropic OAuth requires a client ID registered for Coven Code and supplied +> through `COVEN_CODE_ANTHROPIC_OAUTH_CLIENT_ID`. Until that first-party client +> is configured, use Method 1 (API key). -### Claude.ai flow (default) +### Claude.ai flow ```bash +export COVEN_CODE_ANTHROPIC_OAUTH_CLIENT_ID= coven-code auth login ``` @@ -125,6 +124,7 @@ API calls. ### Console flow (creates an API key) ```bash +export COVEN_CODE_ANTHROPIC_OAUTH_CLIENT_ID= coven-code auth login --console ``` @@ -204,8 +204,8 @@ providers: ```bash # Add accounts (each login becomes its own profile) -coven-code auth login # Claude.ai (default) -coven-code auth login --console # Console / API-key flow +coven-code auth login # Claude.ai, requires COVEN_CODE_ANTHROPIC_OAUTH_CLIENT_ID +coven-code auth login --console # Console / API-key flow, requires COVEN_CODE_ANTHROPIC_OAUTH_CLIENT_ID coven-code auth login --label work # name the profile coven-code codex login # ChatGPT/Codex OAuth coven-code codex login --label personal diff --git a/src-rust/crates/api/src/lib.rs b/src-rust/crates/api/src/lib.rs index 2585bcf..00d81d5 100644 --- a/src-rust/crates/api/src/lib.rs +++ b/src-rust/crates/api/src/lib.rs @@ -549,9 +549,9 @@ pub mod client { model ) } else { - "Set ANTHROPIC_API_KEY, or use --provider to select a different provider \ - (e.g. --provider openai). Anthropic OAuth login is disabled until Coven Code \ - has its own OAuth client.".to_string() + "Set ANTHROPIC_API_KEY, configure COVEN_CODE_ANTHROPIC_OAUTH_CLIENT_ID \ + and run `coven-code auth login`, or use --provider to select a different \ + provider (e.g. --provider openai).".to_string() }; return Err(ClaudeError::Auth( format!("No API key for the selected model. {}", hint) @@ -653,9 +653,9 @@ pub mod client { } else if model.starts_with("llama") { format!("Model '{}' looks like a Llama model. Use `--provider groq` or `--provider ollama` for local.", model) } else { - "Set ANTHROPIC_API_KEY, or use --provider to select a different provider \ - (e.g. --provider openai). Anthropic OAuth login is disabled until Coven Code \ - has its own OAuth client.".to_string() + "Set ANTHROPIC_API_KEY, configure COVEN_CODE_ANTHROPIC_OAUTH_CLIENT_ID \ + and run `coven-code auth login`, or use --provider to select a different \ + provider (e.g. --provider openai).".to_string() }; return Err(ClaudeError::Auth( format!("No API key for the selected model. {}", hint) diff --git a/src-rust/crates/cli/src/main.rs b/src-rust/crates/cli/src/main.rs index ddd3ce7..b177790 100644 --- a/src-rust/crates/cli/src/main.rs +++ b/src-rust/crates/cli/src/main.rs @@ -864,7 +864,7 @@ async fn main() -> anyhow::Result<()> { - Set GOOGLE_API_KEY for Google Gemini\n\ - Set GROQ_API_KEY for Groq (fast, free tier available)\n\ - Run `coven-code --provider ollama` for local models (no key needed)\n\ - - Anthropic OAuth login is disabled until Coven Code has its own OAuth client" + - Configure COVEN_CODE_ANTHROPIC_OAUTH_CLIENT_ID, then run `coven-code auth login` for first-party Anthropic OAuth" ); } else { (String::new(), false) @@ -3840,13 +3840,11 @@ async fn run_interactive( } "anthropic" => { let tx2 = device_auth_tx.clone(); - // Anthropic OAuth requires a registered application. - // Coven Code does not have its own registered OAuth app with Anthropic. - // Users should use an API key from console.anthropic.com instead. + // Anthropic OAuth requires a registered Coven Code application. tokio::spawn(async move { let _ = tx2.send(DeviceAuthEvent::Error( - "Anthropic OAuth requires a registered application.\n\ - Use an API key instead: console.anthropic.com/settings/keys".to_string() + "Anthropic OAuth requires COVEN_CODE_ANTHROPIC_OAUTH_CLIENT_ID.\n\ + Configure a Coven Code OAuth client, or use an API key for now: console.anthropic.com/settings/keys".to_string() )).await; }); } @@ -4238,7 +4236,7 @@ async fn run_interactive( // Called before Cli::parse() so it doesn't conflict with positional `prompt`. // // Usage: -// claude auth login [--console] — Anthropic OAuth is disabled until Coven Code has its own client +// claude auth login [--console] — Anthropic OAuth with a configured Coven Code client // claude auth logout — Clear stored credentials // claude auth status [--json] — Show authentication status @@ -4325,7 +4323,7 @@ async fn handle_auth_command(args: &[String]) -> anyhow::Result<()> { fn print_auth_usage() { eprintln!("Usage: coven-code auth "); - eprintln!(" login [--console] [--label ] Authenticate (claude.ai by default)"); + eprintln!(" login [--console] [--label ] Authenticate with a configured Coven Code OAuth client"); eprintln!(" logout Remove the active account's credentials"); eprintln!(" status [--json] Show authentication status"); eprintln!(" list List all stored Anthropic accounts"); @@ -4651,12 +4649,12 @@ async fn auth_status(json_output: bool) { .filter(|tokens| !tokens.uses_bearer_auth() && tokens.api_key.is_some()) .map(|_| "/login managed key".to_string()) }); - let usable_oauth_tokens = oauth_tokens - .as_ref() - .filter(|tokens| !tokens.uses_bearer_auth()); + let usable_oauth_tokens = oauth_tokens.as_ref().filter(|tokens| { + !tokens.uses_bearer_auth() || tokens.uses_configured_oauth_client() + }); let disabled_bearer_token = oauth_tokens .as_ref() - .is_some_and(|tokens| tokens.uses_bearer_auth()); + .is_some_and(|tokens| tokens.uses_bearer_auth() && !tokens.uses_configured_oauth_client()); let token_source = usable_oauth_tokens.map(|tokens| { if tokens.uses_bearer_auth() { "claude.ai".to_string() @@ -4739,9 +4737,9 @@ async fn auth_status(json_output: bool) { if !logged_in { let hint = if active_provider == "anthropic" { if disabled_bearer_token { - "Stored claude.ai OAuth tokens are disabled; set ANTHROPIC_API_KEY.".to_string() + "Stored claude.ai OAuth tokens were minted by an unsupported client; configure COVEN_CODE_ANTHROPIC_OAUTH_CLIENT_ID and run `coven-code auth login`, or set ANTHROPIC_API_KEY for now.".to_string() } else { - "Set ANTHROPIC_API_KEY.".to_string() + "Set ANTHROPIC_API_KEY, or configure COVEN_CODE_ANTHROPIC_OAUTH_CLIENT_ID and run `coven-code auth login`.".to_string() } } else if let Some(env_var) = claurst_core::config::primary_api_key_env_var_for_provider(active_provider) diff --git a/src-rust/crates/cli/src/oauth_flow.rs b/src-rust/crates/cli/src/oauth_flow.rs index b8ddc0c..efb7030 100644 --- a/src-rust/crates/cli/src/oauth_flow.rs +++ b/src-rust/crates/cli/src/oauth_flow.rs @@ -1,11 +1,36 @@ // OAuth 2.0 PKCE login flow for the Coven Code CLI. // -// Anthropic OAuth login is disabled until Coven Code has an OAuth client -// identity issued for this application. Reusing another application's client -// ID would misidentify this client during consent and token exchange. +// Anthropic OAuth login requires a Coven Code OAuth client identity. Reusing +// another application's client ID would misidentify this client during consent +// and token exchange, so the flow stays disabled unless a first-party client ID +// is supplied through configuration. -use anyhow::bail; -use claurst_core::oauth::OAuthTokens; +use anyhow::{bail, Context}; +use claurst_core::oauth::{self, OAuthTokens}; +use serde::Deserialize; +use std::time::Duration; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::net::TcpListener; +use tracing::{debug, info, warn}; + +#[derive(Debug, Deserialize)] +struct TokenExchangeResponse { + access_token: String, + #[serde(default)] + refresh_token: Option, + expires_in: u64, + #[serde(default)] + scope: Option, + #[serde(default)] + account: Option, + #[serde(default)] + organization: Option, +} + +#[derive(Debug, Deserialize)] +struct CreateApiKeyResponse { + raw_key: Option, +} // ---- Public entry point ----------------------------------------------------- @@ -29,12 +54,405 @@ pub async fn run_oauth_login_flow(login_with_claude_ai: bool) -> anyhow::Result< /// Same as [`run_oauth_login_flow`] but lets the caller supply a human-friendly /// label for the new profile. pub async fn run_oauth_login_flow_with_label( - _login_with_claude_ai: bool, - _label: Option<&str>, + login_with_claude_ai: bool, + label: Option<&str>, ) -> anyhow::Result { - bail!( - "Anthropic OAuth login is disabled because Coven Code does not have an application-specific OAuth client. Set ANTHROPIC_API_KEY or store an Anthropic API key instead." - ) + let client_id = configured_anthropic_oauth_client_id()?; + + let code_verifier = oauth::generate_code_verifier(); + let code_challenge = oauth::generate_code_challenge(&code_verifier); + let state = oauth::generate_state(); + + let listener = TcpListener::bind("127.0.0.1:0") + .await + .context("Failed to bind OAuth callback server")?; + let port = listener.local_addr()?.port(); + + let authorize_base = if login_with_claude_ai { + oauth::CLAUDE_AI_AUTHORIZE_URL + } else { + oauth::CONSOLE_AUTHORIZE_URL + }; + let manual_url = oauth::build_auth_url_with_client_id( + authorize_base, + &client_id, + &code_challenge, + &state, + port, + true, + ); + let automatic_url = oauth::build_auth_url_with_client_id( + authorize_base, + &client_id, + &code_challenge, + &state, + port, + false, + ); + + println!("\nOpening browser for authentication..."); + println!("If the browser did not open, visit:\n\n {}\n", manual_url); + try_open_browser(&automatic_url); + + let auth_code = wait_for_auth_code_impl(listener, &state) + .await + .context("OAuth callback failed")?; + debug!("OAuth auth code received"); + + let token_resp = + exchange_code_for_tokens(&client_id, &auth_code, &state, &code_verifier, port, false) + .await + .context("Token exchange failed")?; + + let expires_at_ms = + chrono::Utc::now().timestamp_millis() + (token_resp.expires_in as i64 * 1000); + + let scopes: Vec = token_resp + .scope + .as_deref() + .unwrap_or("") + .split_whitespace() + .map(String::from) + .collect(); + + let account_uuid = token_resp + .account + .as_ref() + .and_then(|a| a.get("uuid").and_then(|v| v.as_str()).map(String::from)); + let email = token_resp.account.as_ref().and_then(|a| { + a.get("email_address") + .and_then(|v| v.as_str()) + .map(String::from) + }); + let organization_uuid = token_resp + .organization + .as_ref() + .and_then(|o| o.get("uuid").and_then(|v| v.as_str()).map(String::from)); + + let uses_bearer = scopes.iter().any(|s| s == oauth::CLAUDE_AI_INFERENCE_SCOPE); + + let api_key = if !uses_bearer { + match create_api_key(&token_resp.access_token).await { + Ok(key) => { + info!("OAuth API key created successfully"); + Some(key) + } + Err(e) => { + warn!("Failed to create API key from OAuth token: {}", e); + None + } + } + } else { + None + }; + + let tokens = OAuthTokens { + access_token: token_resp.access_token.clone(), + refresh_token: token_resp.refresh_token.clone(), + expires_at_ms: Some(expires_at_ms), + scopes: scopes.clone(), + account_uuid, + email, + organization_uuid, + subscription_type: None, + oauth_client_id: Some(client_id.clone()), + api_key: api_key.clone(), + }; + tokens + .save_and_register(label) + .await + .context("Failed to save OAuth tokens")?; + + let (credential, use_bearer_auth) = if uses_bearer { + (token_resp.access_token.clone(), true) + } else if let Some(key) = api_key { + (key, false) + } else { + bail!("Login succeeded but could not obtain a usable credential") + }; + + Ok(LoginResult { + credential, + use_bearer_auth, + tokens, + }) +} + +fn configured_anthropic_oauth_client_id() -> anyhow::Result { + std::env::var(oauth::CLIENT_ID_ENV) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + anyhow::anyhow!( + "Anthropic OAuth requires a Coven Code OAuth client ID. Set {} after registering a first-party OAuth client, or use ANTHROPIC_API_KEY for now.", + oauth::CLIENT_ID_ENV + ) + }) +} + +fn try_open_browser(url: &str) { + #[cfg(target_os = "windows")] + { + let ps_cmd = format!("Start-Process '{}'", url.replace('\'', "''")); + let _ = std::process::Command::new("powershell") + .args(["-NoProfile", "-NonInteractive", "-Command", &ps_cmd]) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn(); + } + #[cfg(target_os = "macos")] + { + let _ = std::process::Command::new("open") + .arg(url) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn(); + } + #[cfg(not(any(target_os = "windows", target_os = "macos")))] + { + let _ = std::process::Command::new("xdg-open") + .arg(url) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn(); + } +} + +async fn run_callback_server( + listener: TcpListener, + expected_state: &str, +) -> anyhow::Result { + debug!( + "OAuth callback server listening on port {}", + listener.local_addr()?.port() + ); + + let (mut socket, _) = tokio::time::timeout(Duration::from_secs(120), listener.accept()) + .await + .context("Timeout waiting for browser redirect")? + .context("Accept failed")?; + + let (reader, mut writer) = socket.split(); + let mut reader = BufReader::new(reader); + let mut request_line = String::new(); + reader.read_line(&mut request_line).await?; + + loop { + let mut header = String::new(); + reader.read_line(&mut header).await?; + if header.trim().is_empty() { + break; + } + } + + let path = request_line + .split_whitespace() + .nth(1) + .unwrap_or("") + .to_string(); + + let parsed_url = url::Url::parse(&format!("http://localhost{}", path)) + .context("Failed to parse callback URL")?; + + let code = parsed_url + .query_pairs() + .find(|(k, _)| k == "code") + .map(|(_, v)| v.to_string()); + + let received_state = parsed_url + .query_pairs() + .find(|(k, _)| k == "state") + .map(|(_, v)| v.to_string()); + + let response = format!( + "HTTP/1.1 302 Found\r\nLocation: {}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + oauth::CLAUDEAI_SUCCESS_URL + ); + writer.write_all(response.as_bytes()).await?; + + if received_state.as_deref() != Some(expected_state) { + bail!("OAuth state mismatch; possible CSRF attack"); + } + code.context("No authorization code in callback") +} + +async fn read_line_from_stdin() -> anyhow::Result { + print!(" Or paste authorization code here: "); + use std::io::Write; + std::io::stdout().flush().ok(); + + let mut line = String::new(); + let stdin = tokio::io::stdin(); + let mut reader = BufReader::new(stdin); + reader.read_line(&mut line).await?; + Ok(line) +} + +async fn exchange_code_for_tokens( + client_id: &str, + code: &str, + state: &str, + code_verifier: &str, + port: u16, + use_manual_redirect: bool, +) -> anyhow::Result { + let redirect_uri = if use_manual_redirect { + oauth::MANUAL_REDIRECT_URL.to_string() + } else { + format!("http://localhost:{}/callback", port) + }; + + let body = serde_json::json!({ + "grant_type": "authorization_code", + "code": code, + "redirect_uri": redirect_uri, + "client_id": client_id, + "code_verifier": code_verifier, + "state": state, + }); + + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(30)) + .build()?; + + let resp = client + .post(oauth::TOKEN_URL) + .header("content-type", "application/json") + .json(&body) + .send() + .await + .context("Token exchange HTTP request failed")?; + + if !resp.status().is_success() { + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + bail!("Token exchange failed ({}): {}", status, text); + } + + resp.json::() + .await + .context("Failed to parse token exchange response") +} + +async fn create_api_key(access_token: &str) -> anyhow::Result { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(30)) + .build()?; + + let resp = client + .post(oauth::API_KEY_URL) + .header("Authorization", format!("Bearer {}", access_token)) + .send() + .await + .context("API key creation request failed")?; + + if !resp.status().is_success() { + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + bail!("API key creation failed ({}): {}", status, text); + } + + let data: CreateApiKeyResponse = resp + .json() + .await + .context("Failed to parse API key response")?; + data.raw_key.context("Server returned no API key") +} + +#[allow(dead_code)] +pub async fn refresh_oauth_token(tokens: &OAuthTokens) -> anyhow::Result { + let client_id = configured_anthropic_oauth_client_id()?; + let refresh_token = tokens + .refresh_token + .as_deref() + .context("No refresh token available")?; + + let body = serde_json::json!({ + "grant_type": "refresh_token", + "refresh_token": refresh_token, + "client_id": client_id, + "scope": oauth::ALL_SCOPES.join(" "), + }); + + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(30)) + .build()?; + + let resp = client + .post(oauth::TOKEN_URL) + .header("content-type", "application/json") + .json(&body) + .send() + .await + .context("Token refresh HTTP request failed")?; + + if !resp.status().is_success() { + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + bail!("Token refresh failed ({}): {}", status, text); + } + + let token_resp: TokenExchangeResponse = resp.json().await?; + let expires_at_ms = + chrono::Utc::now().timestamp_millis() + (token_resp.expires_in as i64 * 1000); + + let scopes: Vec = token_resp + .scope + .as_deref() + .unwrap_or("") + .split_whitespace() + .map(String::from) + .collect(); + + let mut updated = tokens.clone(); + updated.access_token = token_resp.access_token; + if let Some(new_rt) = token_resp.refresh_token { + updated.refresh_token = Some(new_rt); + } + updated.expires_at_ms = Some(expires_at_ms); + updated.scopes = scopes; + + updated.save().await?; + Ok(updated) +} + +async fn wait_for_auth_code_impl( + listener: TcpListener, + expected_state: &str, +) -> anyhow::Result { + let expected_state_clone = expected_state.to_string(); + let (cb_tx, cb_rx) = tokio::sync::oneshot::channel::>(); + + tokio::spawn(async move { + let result = run_callback_server(listener, &expected_state_clone).await; + let _ = cb_tx.send(result); + }); + + let (paste_tx, paste_rx) = tokio::sync::oneshot::channel::(); + tokio::spawn(async move { + if let Ok(line) = read_line_from_stdin().await { + let trimmed = line.trim().to_string(); + if !trimmed.is_empty() { + let _ = paste_tx.send(trimmed); + } + } + }); + + tokio::select! { + result = cb_rx => { + result.unwrap_or_else(|_| Err(anyhow::anyhow!("Callback server dropped"))) + } + code = paste_rx => { + code.map_err(|_| anyhow::anyhow!("Stdin closed unexpectedly")) + } + _ = tokio::time::sleep(Duration::from_secs(120)) => { + bail!("Authentication timed out after 120 seconds") + } + } } #[cfg(test)] @@ -42,10 +460,11 @@ mod tests { use super::*; #[tokio::test] - async fn anthropic_oauth_login_is_disabled() { + async fn anthropic_oauth_login_requires_first_party_client_id() { + std::env::remove_var(oauth::CLIENT_ID_ENV); let err = run_oauth_login_flow(true) .await - .expect_err("Anthropic OAuth login should be disabled"); - assert!(err.to_string().contains("application-specific OAuth client")); + .expect_err("Anthropic OAuth login should require a first-party client ID"); + assert!(err.to_string().contains(oauth::CLIENT_ID_ENV)); } } diff --git a/src-rust/crates/commands/src/lib.rs b/src-rust/crates/commands/src/lib.rs index d5e57e2..a82bbad 100644 --- a/src-rust/crates/commands/src/lib.rs +++ b/src-rust/crates/commands/src/lib.rs @@ -2671,10 +2671,11 @@ impl SlashCommand for LoginCommand { } fn help(&self) -> &str { "Usage: /login [--console] [--codex] [--label ]\n\n\ - Start an OAuth login. Anthropic OAuth is disabled until Coven Code has\n\ - its own OAuth client identity; set ANTHROPIC_API_KEY instead. Pass\n\ - `--codex` to add a ChatGPT/Codex account. `--label work` names the\n\ - saved profile so you can `switch` to it later by that name." + Start an OAuth login. Anthropic OAuth requires a configured Coven Code\n\ + client ID via COVEN_CODE_ANTHROPIC_OAUTH_CLIENT_ID; use\n\ + ANTHROPIC_API_KEY until that client is configured. Pass `--codex` to\n\ + add a ChatGPT/Codex account. `--label work` names the saved profile so\n\ + you can `switch` to it later by that name." } async fn execute(&self, args: &str, _ctx: &mut CommandContext) -> CommandResult { diff --git a/src-rust/crates/core/src/lib.rs b/src-rust/crates/core/src/lib.rs index 373054a..c46e8c1 100644 --- a/src-rust/crates/core/src/lib.rs +++ b/src-rust/crates/core/src/lib.rs @@ -1390,8 +1390,8 @@ pub mod config { /// Async variant: also checks `~/.coven-code/oauth_tokens.json`. /// Returns `(credential, use_bearer_auth)`. - /// Stored Bearer tokens are ignored because Anthropic OAuth login is - /// disabled until Coven Code has its own OAuth client identity. + /// Stored Bearer tokens are used only when they were minted by the + /// configured Coven Code OAuth client identity. pub async fn resolve_auth_async(&self) -> Option<(String, bool)> { if self.selected_provider_id() != "anthropic" { return self.resolve_api_key().map(|key| (key, false)); @@ -1406,13 +1406,13 @@ pub mod config { } let tokens = crate::oauth::OAuthTokens::load().await?; - if tokens.uses_bearer_auth() { + if tokens.uses_bearer_auth() && !tokens.uses_configured_oauth_client() { return None; } tokens .effective_credential() - .map(|cred| (cred.to_string(), false)) + .map(|cred| (cred.to_string(), tokens.uses_bearer_auth())) } pub fn resolve_provider_api_base(&self, provider_id: &str) -> Option { @@ -3660,10 +3660,10 @@ pub mod oauth { // ---- Production OAuth endpoints & constants ---- - // Anthropic OAuth login is disabled until Coven Code has an OAuth client - // identity issued for this application. Keep this empty so OAuth URL and - // token exchange helpers cannot impersonate another application's client. + // Anthropic OAuth login requires a client identity issued for Coven Code. + // Keep this empty so default helpers cannot impersonate another app. pub const CLIENT_ID: &str = ""; + pub const CLIENT_ID_ENV: &str = "COVEN_CODE_ANTHROPIC_OAUTH_CLIENT_ID"; pub const CONSOLE_AUTHORIZE_URL: &str = "https://platform.claude.com/oauth/authorize"; pub const CLAUDE_AI_AUTHORIZE_URL: &str = "https://claude.com/cai/oauth/authorize"; pub const TOKEN_URL: &str = "https://platform.claude.com/v1/oauth/token"; @@ -3709,6 +3709,9 @@ pub mod oauth { pub organization_uuid: Option, #[serde(skip_serializing_if = "Option::is_none")] pub subscription_type: Option, + /// First-party OAuth client ID that minted this token. + #[serde(skip_serializing_if = "Option::is_none")] + pub oauth_client_id: Option, /// API key created for Console-flow users (exchanged from access token). #[serde(skip_serializing_if = "Option::is_none")] pub api_key: Option, @@ -3721,6 +3724,19 @@ pub mod oauth { self.scopes.iter().any(|s| s == CLAUDE_AI_INFERENCE_SCOPE) } + /// True when this Bearer token was minted by the configured Coven Code + /// OAuth client rather than an older borrowed-client flow. + pub fn uses_configured_oauth_client(&self) -> bool { + let Some(token_client_id) = self.oauth_client_id.as_deref() else { + return false; + }; + std::env::var(CLIENT_ID_ENV) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .is_some_and(|client_id| client_id == token_client_id) + } + /// The credential to present to the Anthropic API: /// - Console flow: the stored `api_key` (sk-ant-…) /// - Claude.ai flow: the `access_token` itself (Bearer) @@ -3922,13 +3938,32 @@ pub mod oauth { state: &str, callback_port: u16, is_manual: bool, + ) -> String { + build_auth_url_with_client_id( + authorize_base, + CLIENT_ID, + code_challenge, + state, + callback_port, + is_manual, + ) + } + + /// Build an OAuth authorization URL using a caller-supplied client ID. + pub fn build_auth_url_with_client_id( + authorize_base: &str, + client_id: &str, + code_challenge: &str, + state: &str, + callback_port: u16, + is_manual: bool, ) -> String { let mut u = url::Url::parse(authorize_base) .expect("valid OAuth authorize base URL"); { let mut q = u.query_pairs_mut(); q.append_pair("code", "true"); // tells the login page to show Claude Max upsell - q.append_pair("client_id", CLIENT_ID); + q.append_pair("client_id", client_id); q.append_pair("response_type", "code"); let redirect = if is_manual { MANUAL_REDIRECT_URL.to_string() @@ -4382,6 +4417,29 @@ mod tests { assert!(!tokens.uses_bearer_auth()); } + #[test] + fn test_oauth_bearer_token_requires_configured_client_id() { + let orig = std::env::var(crate::oauth::CLIENT_ID_ENV).ok(); + std::env::remove_var(crate::oauth::CLIENT_ID_ENV); + + let tokens = crate::oauth::OAuthTokens { + scopes: vec![crate::oauth::CLAUDE_AI_INFERENCE_SCOPE.to_string()], + oauth_client_id: Some("coven-client".to_string()), + ..Default::default() + }; + + assert!(!tokens.uses_configured_oauth_client()); + std::env::set_var(crate::oauth::CLIENT_ID_ENV, "other-client"); + assert!(!tokens.uses_configured_oauth_client()); + std::env::set_var(crate::oauth::CLIENT_ID_ENV, "coven-client"); + assert!(tokens.uses_configured_oauth_client()); + + std::env::remove_var(crate::oauth::CLIENT_ID_ENV); + if let Some(value) = orig { + std::env::set_var(crate::oauth::CLIENT_ID_ENV, value); + } + } + #[test] fn test_oauth_effective_credential_bearer() { let tokens = crate::oauth::OAuthTokens { diff --git a/src-rust/crates/core/src/oauth_config.rs b/src-rust/crates/core/src/oauth_config.rs index 3eaf360..2447c11 100644 --- a/src-rust/crates/core/src/oauth_config.rs +++ b/src-rust/crates/core/src/oauth_config.rs @@ -76,10 +76,9 @@ pub struct OAuthConfig { // Production config (mirrors PROD_OAUTH_CONFIG in oauth.ts) // --------------------------------------------------------------------------- -// Anthropic OAuth login is disabled until Coven Code has an OAuth client -// identity issued for this application. These endpoint definitions are retained -// for tests and future first-party OAuth support, but the client IDs are left -// empty so this third-party CLI does not impersonate another application. +// Anthropic OAuth login requires a client identity issued for Coven Code. +// These endpoint definitions are retained for first-party OAuth support, but +// the client IDs are left empty so this CLI does not impersonate another app. pub const PROD_OAUTH: OAuthConfig = OAuthConfig { base_api_url: "https://api.anthropic.com", // Routes through claude.com/cai/* for attribution, 307s to claude.ai in diff --git a/src-rust/crates/tui/src/onboarding_dialog.rs b/src-rust/crates/tui/src/onboarding_dialog.rs index 9c25325..71c4e88 100644 --- a/src-rust/crates/tui/src/onboarding_dialog.rs +++ b/src-rust/crates/tui/src/onboarding_dialog.rs @@ -146,8 +146,8 @@ fn render_provider_setup_page(frame: &mut Frame, area: Rect) { ]), Line::from(vec![ Span::styled(" › ", Style::default().fg(pink)), - Span::styled("set ANTHROPIC_API_KEY=", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), - Span::styled(" then restart", Style::default().fg(dim)), + Span::styled("ANTHROPIC_API_KEY", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), + Span::styled(" or configured OAuth", Style::default().fg(dim)), ]), Line::from(Span::styled(sep, Style::default().fg(Color::Rgb(45, 45, 55)))), // ── 2. OpenAI ─────────────────────────────────────────