2026-04-24 16:15:10 +00:00
|
|
|
//! OAuth I/O — the ONLY place in `service/oauth/` that may perform side effects.
|
|
|
|
|
//!
|
|
|
|
|
//! Side effects here include: reading the system clock, making HTTP requests to
|
|
|
|
|
//! the Anthropic token endpoint, and reading/writing `~/.claude/.credentials.json`.
|
|
|
|
|
//! All business logic and branching belong in `mod.rs`, `pkce.rs`, or `flow.rs`.
|
|
|
|
|
|
|
|
|
|
use super::Error;
|
2026-04-27 17:36:00 +00:00
|
|
|
use super::flow::{AccountInfo, build_account_info};
|
2026-04-24 16:15:10 +00:00
|
|
|
use super::pkce::SCOPES;
|
2026-04-27 17:36:00 +00:00
|
|
|
use crate::llm::oauth::{self, CredentialsFile, PoolAccount};
|
2026-04-24 16:15:10 +00:00
|
|
|
use crate::slog;
|
|
|
|
|
|
|
|
|
|
/// Raw token exchange result returned by the Anthropic OAuth endpoint.
|
|
|
|
|
#[derive(serde::Deserialize)]
|
|
|
|
|
pub(super) struct TokenExchangeResult {
|
|
|
|
|
pub access_token: String,
|
|
|
|
|
pub refresh_token: Option<String>,
|
|
|
|
|
pub expires_in: u64,
|
|
|
|
|
#[allow(dead_code)]
|
|
|
|
|
pub token_type: Option<String>,
|
|
|
|
|
#[allow(dead_code)]
|
|
|
|
|
pub scope: Option<String>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Return the current Unix-epoch time in milliseconds.
|
|
|
|
|
pub(super) fn current_time_ms() -> u64 {
|
|
|
|
|
std::time::SystemTime::now()
|
|
|
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
|
|
|
.map(|d| d.as_millis() as u64)
|
|
|
|
|
.unwrap_or(0)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Exchange an authorization code for tokens via the Anthropic token endpoint.
|
|
|
|
|
///
|
|
|
|
|
/// Returns the raw token response on success. Network or HTTP errors are
|
|
|
|
|
/// mapped to typed [`Error`] variants.
|
|
|
|
|
pub(super) async fn exchange_code_for_tokens(
|
|
|
|
|
code: &str,
|
|
|
|
|
redirect_uri: &str,
|
|
|
|
|
code_verifier: &str,
|
|
|
|
|
csrf_state: &str,
|
|
|
|
|
) -> Result<TokenExchangeResult, Error> {
|
|
|
|
|
use super::pkce::CLIENT_ID;
|
|
|
|
|
const TOKEN_ENDPOINT: &str = "https://platform.claude.com/v1/oauth/token";
|
|
|
|
|
|
|
|
|
|
slog!("[oauth] Exchanging authorization code for tokens");
|
|
|
|
|
|
|
|
|
|
let client = reqwest::Client::new();
|
|
|
|
|
let resp = client
|
|
|
|
|
.post(TOKEN_ENDPOINT)
|
|
|
|
|
.header("Content-Type", "application/json")
|
|
|
|
|
.json(&serde_json::json!({
|
|
|
|
|
"grant_type": "authorization_code",
|
|
|
|
|
"code": code,
|
|
|
|
|
"client_id": CLIENT_ID,
|
|
|
|
|
"redirect_uri": redirect_uri,
|
|
|
|
|
"code_verifier": code_verifier,
|
|
|
|
|
"state": csrf_state,
|
|
|
|
|
}))
|
|
|
|
|
.send()
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| Error::Network(format!("Failed to contact Anthropic: {e}")))?;
|
|
|
|
|
|
|
|
|
|
let status = resp.status();
|
|
|
|
|
let body = resp.text().await.unwrap_or_default();
|
|
|
|
|
|
|
|
|
|
slog!(
|
|
|
|
|
"[oauth] Token exchange response (HTTP {}): {}",
|
|
|
|
|
status,
|
|
|
|
|
body
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if !status.is_success() {
|
|
|
|
|
return Err(Error::InvalidGrant(format!(
|
|
|
|
|
"Anthropic returned HTTP {status}. Please try again."
|
|
|
|
|
)));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
serde_json::from_str(&body)
|
|
|
|
|
.map_err(|e| Error::Parse(format!("Unexpected response from Anthropic: {e}")))
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-27 17:36:00 +00:00
|
|
|
/// Persist a token exchange result to `~/.claude/.credentials.json` and to the
|
|
|
|
|
/// multi-account pool (`~/.claude/oauth_pool.json`).
|
2026-04-24 16:15:10 +00:00
|
|
|
///
|
2026-04-27 17:36:00 +00:00
|
|
|
/// Writes the credentials file for Claude Code CLI compatibility, then upserts
|
|
|
|
|
/// the account into the pool keyed by `email`. If `email` is empty a fallback
|
|
|
|
|
/// key is derived from the first 16 characters of the access token.
|
|
|
|
|
pub(super) fn save_credentials(
|
|
|
|
|
token: &TokenExchangeResult,
|
|
|
|
|
now_ms: u64,
|
|
|
|
|
email: &str,
|
|
|
|
|
) -> Result<(), Error> {
|
|
|
|
|
let expires_at = now_ms + (token.expires_in * 1000);
|
|
|
|
|
let scopes: Vec<String> = SCOPES.split(' ').map(|s| s.to_string()).collect();
|
|
|
|
|
|
|
|
|
|
// Write to .credentials.json for Claude Code CLI compatibility.
|
2026-04-24 16:15:10 +00:00
|
|
|
let creds = CredentialsFile {
|
|
|
|
|
claude_ai_oauth: oauth::OAuthCredentials {
|
|
|
|
|
access_token: token.access_token.clone(),
|
|
|
|
|
refresh_token: token.refresh_token.clone().unwrap_or_default(),
|
2026-04-27 17:36:00 +00:00
|
|
|
expires_at,
|
|
|
|
|
scopes: scopes.clone(),
|
2026-04-24 16:15:10 +00:00
|
|
|
subscription_type: None,
|
|
|
|
|
rate_limit_tier: None,
|
|
|
|
|
},
|
|
|
|
|
};
|
2026-04-27 17:36:00 +00:00
|
|
|
oauth::write_credentials(&creds).map_err(Error::TokenStorage)?;
|
|
|
|
|
|
|
|
|
|
// Build the pool key: prefer email, fall back to token prefix.
|
|
|
|
|
let key = if email.is_empty() {
|
|
|
|
|
format!(
|
|
|
|
|
"account-{}",
|
|
|
|
|
&token.access_token[..token.access_token.len().min(16)]
|
|
|
|
|
)
|
|
|
|
|
} else {
|
|
|
|
|
email.to_string()
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let account = PoolAccount {
|
|
|
|
|
email: key,
|
|
|
|
|
access_token: token.access_token.clone(),
|
|
|
|
|
refresh_token: token.refresh_token.clone().unwrap_or_default(),
|
|
|
|
|
expires_at,
|
|
|
|
|
scopes,
|
|
|
|
|
subscription_type: None,
|
|
|
|
|
rate_limit_tier: None,
|
|
|
|
|
rate_limited: false,
|
|
|
|
|
};
|
|
|
|
|
oauth::upsert_pool_account(account).map_err(Error::TokenStorage)?;
|
|
|
|
|
|
|
|
|
|
Ok(())
|
2026-04-24 16:15:10 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-27 17:36:00 +00:00
|
|
|
/// Attempt to fetch the authenticated user's email from Anthropic's userinfo endpoint.
|
2026-04-24 16:15:10 +00:00
|
|
|
///
|
2026-04-27 17:36:00 +00:00
|
|
|
/// Uses the access token as a Bearer credential. Returns `None` when the
|
|
|
|
|
/// request fails or the response does not contain an email field.
|
|
|
|
|
pub(super) async fn fetch_user_email(access_token: &str) -> Option<String> {
|
|
|
|
|
const USERINFO_URL: &str = "https://claude.ai/api/me";
|
|
|
|
|
|
|
|
|
|
#[derive(serde::Deserialize)]
|
|
|
|
|
struct UserInfo {
|
|
|
|
|
email: Option<String>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let client = reqwest::Client::new();
|
|
|
|
|
let resp = client
|
|
|
|
|
.get(USERINFO_URL)
|
|
|
|
|
.header("Authorization", format!("Bearer {access_token}"))
|
|
|
|
|
.send()
|
|
|
|
|
.await
|
|
|
|
|
.ok()?;
|
|
|
|
|
|
|
|
|
|
if !resp.status().is_success() {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let info: UserInfo = resp.json().await.ok()?;
|
|
|
|
|
info.email.filter(|e| !e.is_empty())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Load all accounts from the pool and compute their [`AccountInfo`].
|
|
|
|
|
///
|
|
|
|
|
/// Falls back to reading the legacy `.credentials.json` when the pool is empty
|
|
|
|
|
/// or missing, synthesising a single unauthenticated-or-authenticated entry so
|
|
|
|
|
/// existing deployments that have not yet run a new login continue to work.
|
|
|
|
|
pub(super) fn load_all_accounts() -> Vec<AccountInfo> {
|
|
|
|
|
let now_ms = current_time_ms();
|
|
|
|
|
|
|
|
|
|
match oauth::read_pool() {
|
|
|
|
|
Ok(pool) if !pool.accounts.is_empty() => {
|
|
|
|
|
let mut accounts: Vec<AccountInfo> = pool
|
|
|
|
|
.accounts
|
|
|
|
|
.values()
|
|
|
|
|
.map(|a| build_account_info(a, now_ms))
|
|
|
|
|
.collect();
|
|
|
|
|
accounts.sort_by(|a, b| a.email.cmp(&b.email));
|
|
|
|
|
accounts
|
|
|
|
|
}
|
|
|
|
|
_ => {
|
|
|
|
|
// Pool empty or unreadable — fall back to legacy single-account file.
|
|
|
|
|
match oauth::read_credentials() {
|
|
|
|
|
Ok(creds) => {
|
|
|
|
|
let status = super::flow::build_flow_status(&creds, now_ms);
|
|
|
|
|
vec![AccountInfo {
|
|
|
|
|
email: "default".to_string(),
|
|
|
|
|
status: if status.expired {
|
|
|
|
|
super::flow::AccountStatus::Expired
|
|
|
|
|
} else {
|
|
|
|
|
super::flow::AccountStatus::Active
|
|
|
|
|
},
|
|
|
|
|
expires_at: status.expires_at,
|
|
|
|
|
has_refresh_token: status.has_refresh_token,
|
|
|
|
|
}]
|
|
|
|
|
}
|
|
|
|
|
Err(_) => vec![],
|
|
|
|
|
}
|
2026-04-24 16:15:10 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|