use anyhow::Context;
use base64::Engine as _;
use sha2::Digest;
use std::net::TcpListener;
use crate::config::LlmConfig;
const CLIENT_ID: &str = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
const REDIRECT_URI: &str = "https://console.anthropic.com/oauth/code/callback";
const AUTH_URL: &str = "https://console.anthropic.com/oauth/authorize";
const TOKEN_URL: &str = "https://console.anthropic.com/v1/oauth/token";
const CREATE_KEY_URL: &str =
"https://api.anthropic.com/api/oauth/claude_cli/create_api_key";
const SCOPES: &str = "org:create_api_key user:profile user:inference";
pub async fn run(model: String, base_url: Option<String>) -> anyhow::Result<()> {
let verifier = generate_verifier();
let challenge = pkce_challenge(&verifier);
let state = uuid::Uuid::new_v4().to_string();
let listener = TcpListener::bind("127.0.0.1:0")
.context("failed to bind local callback server")?;
let port = listener.local_addr()?.port();
let _local_redirect = format!("http://127.0.0.1:{port}/callback");
let auth_url = format!(
"{AUTH_URL}?response_type=code\
&client_id={CLIENT_ID}\
&redirect_uri={}\
&scope={}\
&code_challenge={challenge}\
&code_challenge_method=S256\
&state={state}",
urlencoding::encode(REDIRECT_URI),
urlencoding::encode(SCOPES),
);
println!("Opening your browser for Anthropic login...");
println!("If the browser does not open, visit:\n {auth_url}");
println!();
open_browser(&auth_url);
println!("Waiting for Anthropic to redirect back (port {port})...");
let code = wait_for_code(listener, &state)
.await
.context("did not receive OAuth callback")?;
println!("Received authorization code. Exchanging for access token...");
let client = reqwest::Client::new();
let token_resp = client
.post(TOKEN_URL)
.header("Content-Type", "application/json")
.json(&serde_json::json!({
"grant_type": "authorization_code",
"client_id": CLIENT_ID,
"code": code,
"redirect_uri": REDIRECT_URI,
"code_verifier": verifier,
}))
.send()
.await
.context("token exchange request failed")?;
let status = token_resp.status();
let body = token_resp.text().await.unwrap_or_default();
if !status.is_success() {
anyhow::bail!("token exchange failed ({status}): {body}");
}
let token_json: serde_json::Value =
serde_json::from_str(&body).context("invalid JSON in token response")?;
let access_token = token_json["access_token"]
.as_str()
.context("missing access_token in token response")?
.to_string();
println!("Token obtained. Creating permanent API key...");
let key_resp = client
.post(CREATE_KEY_URL)
.header("Authorization", format!("Bearer {access_token}"))
.header("anthropic-version", "2023-06-01")
.header("Content-Type", "application/json")
.json(&serde_json::json!({}))
.send()
.await
.context("API key creation request failed")?;
let key_status = key_resp.status();
let key_body = key_resp.text().await.unwrap_or_default();
if !key_status.is_success() {
anyhow::bail!("API key creation failed ({key_status}): {key_body}");
}
let key_json: serde_json::Value =
serde_json::from_str(&key_body).context("invalid JSON in key creation response")?;
let api_key = key_json["raw_key"]
.as_str()
.context("missing raw_key in API key creation response")?
.to_string();
crate::llm::set_llm_config(Some(LlmConfig::Anthropic {
api_key,
base_url,
model,
}))
.context("failed to save LLM config")?;
println!("Logged in. LLM provider set to Anthropic (SSO).");
Ok(())
}
fn generate_verifier() -> String {
let bytes: Vec<u8> = (0..32).map(|_| rand_byte()).collect();
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&bytes)
}
fn pkce_challenge(verifier: &str) -> String {
let hash = sha2::Sha256::digest(verifier.as_bytes());
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(hash)
}
fn open_browser(url: &str) {
let _ = std::process::Command::new("xdg-open").arg(url).spawn()
.or_else(|_| std::process::Command::new("open").arg(url).spawn())
.or_else(|_| std::process::Command::new("cmd").args(["/c", "start", url]).spawn());
}
fn rand_byte() -> u8 {
let mut buf = [0u8; 1];
getrandom::fill(&mut buf).expect("getrandom failed");
buf[0]
}
async fn wait_for_code(listener: TcpListener, expected_state: &str) -> anyhow::Result<String> {
use http_body_util::Full;
use hyper::{Request, Response, body::Bytes, server::conn::http1};
use hyper_util::rt::TokioIo;
use std::sync::{Arc, Mutex};
use tokio::net::TcpListener as TokioListener;
let code_slot: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
let code_slot_inner = Arc::clone(&code_slot);
let state_str = expected_state.to_string();
listener.set_nonblocking(true)?;
let tokio_listener = TokioListener::from_std(listener)?;
let (stream, _) = tokio::time::timeout(
std::time::Duration::from_secs(120),
tokio_listener.accept(),
)
.await
.context("timed out waiting for browser redirect")?
.context("accept error")?;
let io = TokioIo::new(stream);
let code_slot_for_svc = Arc::clone(&code_slot_inner);
let state_for_svc = state_str.clone();
let svc = hyper::service::service_fn(move |req: Request<hyper::body::Incoming>| {
let slot = Arc::clone(&code_slot_for_svc);
let st = state_for_svc.clone();
async move {
let path_and_query = req.uri().path_and_query()
.map(|pq| pq.as_str())
.unwrap_or("");
let (code, msg) = extract_code_from_query(path_and_query, &st);
if let Some(c) = code {
*slot.lock().unwrap() = Some(c);
}
Ok::<_, std::convert::Infallible>(
Response::new(Full::new(Bytes::from(msg))),
)
}
});
http1::Builder::new()
.serve_connection(io, svc)
.await
.context("error serving callback connection")?;
let code = code_slot
.lock()
.unwrap()
.take()
.context("no authorization code received in callback")?;
Ok(code)
}
fn extract_code_from_query(path_and_query: &str, expected_state: &str) -> (Option<String>, String) {
let query = path_and_query
.splitn(2, '?')
.nth(1)
.unwrap_or("");
let mut code = None;
let mut state = None;
for pair in query.split('&') {
let mut kv = pair.splitn(2, '=');
let k = kv.next().unwrap_or("");
let v = kv.next().unwrap_or("");
match k {
"code" => code = Some(urlencoding::decode(v).unwrap_or_default().into_owned()),
"state" => state = Some(v.to_string()),
_ => {}
}
}
if state.as_deref() != Some(expected_state) {
return (
None,
"Error: state mismatch. Please retry.".to_string(),
);
}
if code.is_some() {
(
code,
"<html><body><h2>Login successful!</h2><p>You can close this tab.</p></body></html>"
.to_string(),
)
} else {
(
None,
"Error: no authorization code received.".to_string(),
)
}
}