use std::sync::{Arc, LazyLock};
use std::time::Duration;
use anyhow::Result;
use serde::Deserialize;
use crate::claude::{LinkState, classify_credentials_link};
use crate::lock::with_state_lock;
use crate::profile::{
AppConfig, OAuthToken, clear_staged_credentials, save_profile, stage_rotated_credentials,
};
use crate::runtime::{RotationGuard, has_live_session};
use crate::usage::{
ANTHROPIC_ORIGIN, ActivityStore, OpResult, OpResultSender, ProfileActivity, RefetchQueue,
await_request_slot, clear_activity, mark_activity, now_ms,
};
const TOKEN_ENDPOINT: &str = "https://api.anthropic.com/v1/oauth/token";
const LOGIN_TOKEN_ENDPOINT: &str = "https://platform.claude.com/v1/oauth/token";
pub(crate) const CLIENT_ID: &str = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
const MESSAGES_ENDPOINT: &str = "https://api.anthropic.com/v1/messages";
const KICK_MODEL: &str = "claude-haiku-4-5-20251001";
const KICK_SYSTEM_PROMPT: &str = "You are Claude Code, Anthropic's official CLI for Claude.";
const ROTATION_STEP_DELAY_MS: u64 = 2000;
#[derive(Deserialize)]
pub(crate) struct TokenResponse {
pub(crate) access_token: String,
pub(crate) refresh_token: String,
pub(crate) expires_in: u64,
#[serde(default)]
pub(crate) scope: Option<String>,
}
fn token_parse_error(e: serde_json::Error, status: u16, body_len: usize) -> anyhow::Error {
let kind = match e.classify() {
serde_json::error::Category::Io => "io",
serde_json::error::Category::Syntax => "malformed json",
serde_json::error::Category::Data => "unexpected shape",
serde_json::error::Category::Eof => "truncated",
};
anyhow::anyhow!(
"token endpoint returned HTTP {status} but its body did not parse as a token \
response ({kind} at line {}, column {}); {body_len} bytes withheld \
(contains live credentials)",
e.line(),
e.column(),
)
}
static AGENT: LazyLock<ureq::Agent> = LazyLock::new(|| {
ureq::Agent::config_builder()
.timeout_connect(Some(Duration::from_secs(4)))
.timeout_recv_response(Some(Duration::from_secs(15)))
.http_status_as_error(false)
.build()
.into()
});
fn http_error(status: u16, body: &str) -> anyhow::Error {
let detail: String = body
.lines()
.next()
.unwrap_or("")
.chars()
.take(200)
.collect();
if detail.is_empty() {
anyhow::anyhow!("HTTP {status}")
} else {
anyhow::anyhow!("HTTP {status}: {detail}")
}
}
pub(crate) enum RefreshError {
Invalid(String),
Transient(anyhow::Error),
}
impl From<RefreshError> for anyhow::Error {
fn from(e: RefreshError) -> Self {
match e {
RefreshError::Invalid(msg) => anyhow::anyhow!(msg),
RefreshError::Transient(e) => e,
}
}
}
pub(crate) fn refresh_result(
refresh_token: &str,
) -> std::result::Result<TokenResponse, RefreshError> {
let body = serde_json::to_string(&serde_json::json!({
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"client_id": CLIENT_ID,
}))
.map_err(|e| RefreshError::Transient(e.into()))?;
let mut response = AGENT
.post(TOKEN_ENDPOINT)
.header("Content-Type", "application/json")
.send(&body)
.map_err(|e| RefreshError::Transient(anyhow::Error::from(e)))?;
let status = response.status().as_u16();
let text = response
.body_mut()
.read_to_string()
.map_err(|e| RefreshError::Transient(anyhow::Error::from(e)))?;
if refresh_rejection_is_terminal(status, &text) {
return Err(RefreshError::Invalid(http_error(status, &text).to_string()));
}
if status >= 400 {
return Err(RefreshError::Transient(http_error(status, &text)));
}
serde_json::from_str(&text)
.map_err(|e| RefreshError::Transient(token_parse_error(e, status, text.len())))
}
fn refresh_rejection_is_terminal(status: u16, body: &str) -> bool {
matches!(status, 400 | 401) || (status == 403 && body.contains("invalid_grant"))
}
pub(crate) fn refresh(refresh_token: &str) -> Result<TokenResponse> {
refresh_result(refresh_token).map_err(Into::into)
}
pub(crate) fn exchange_code(
code: &str,
code_verifier: &str,
redirect_uri: &str,
state: &str,
) -> Result<TokenResponse> {
let body = serde_json::to_string(&serde_json::json!({
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
"code_verifier": code_verifier,
"client_id": CLIENT_ID,
"state": state,
}))?;
let mut response = AGENT
.post(LOGIN_TOKEN_ENDPOINT)
.header("Content-Type", "application/json")
.send(&body)
.map_err(anyhow::Error::from)?;
let status = response.status().as_u16();
let text = response
.body_mut()
.read_to_string()
.map_err(anyhow::Error::from)?;
if status >= 400 {
return Err(http_error(status, &text));
}
serde_json::from_str(&text).map_err(|e| token_parse_error(e, status, text.len()))
}
enum KickError {
Status(u16),
Other(anyhow::Error),
}
impl From<KickError> for anyhow::Error {
fn from(e: KickError) -> Self {
match e {
KickError::Status(s) => anyhow::anyhow!("HTTP {s}"),
KickError::Other(e) => e,
}
}
}
fn kick(access_token: &str) -> std::result::Result<(), KickError> {
await_request_slot(ANTHROPIC_ORIGIN);
let body = serde_json::to_string(&serde_json::json!({
"model": KICK_MODEL,
"max_tokens": 1,
"system": [{ "type": "text", "text": KICK_SYSTEM_PROMPT }],
"messages": [{ "role": "user", "content": "x" }],
}))
.map_err(|e| KickError::Other(e.into()))?;
let status = AGENT
.post(MESSAGES_ENDPOINT)
.header("Content-Type", "application/json")
.header("Authorization", &format!("Bearer {access_token}"))
.header("anthropic-version", "2023-06-01")
.header("anthropic-beta", "oauth-2025-04-20")
.send(&body)
.map_err(|e| KickError::Other(anyhow::Error::from(e)))?
.status()
.as_u16();
if status >= 400 {
return Err(KickError::Status(status));
}
Ok(())
}
#[must_use]
pub(crate) struct KickResult {
pub(crate) opened: bool,
pub(crate) rotated: Option<(String, Option<String>)>,
}
impl KickResult {
fn not_opened() -> Self {
Self {
opened: false,
rotated: None,
}
}
}
pub(crate) fn auto_start_kick(
config: &crate::profile::ConfigHandle,
name: &str,
access_token: &str,
refresh_token: Option<&str>,
access_expires_at: Option<i64>,
activity: Option<&ActivityStore>,
) -> KickResult {
match kick(access_token) {
Ok(()) => {
return KickResult {
opened: true,
rotated: None,
};
}
Err(KickError::Status(401)) => {}
Err(KickError::Status(429))
if access_expires_at.is_some_and(|exp| now_ms() as i64 >= exp) => {}
Err(_) => return KickResult::not_opened(),
}
let Some(rt) = refresh_token else {
return KickResult::not_opened();
};
std::thread::sleep(std::time::Duration::from_millis(ROTATION_STEP_DELAY_MS));
let Ok(rotation_guard) = RotationGuard::acquire(name) else {
return KickResult::not_opened();
};
if has_live_session(name) {
return KickResult::not_opened();
}
if let Some(activity) = activity {
mark_activity(activity, name, ProfileActivity::Refreshing);
}
let refreshed = refresh(rt);
if let Some(activity) = activity {
mark_activity(activity, name, ProfileActivity::Fetching);
}
let tok = match refreshed {
Ok(t) => t,
Err(_) => return KickResult::not_opened(),
};
let access = tok.access_token.clone();
let new_refresh = tok.refresh_token.clone();
let rotated = Some((access.clone(), Some(new_refresh)));
if apply_rotated_tokens_locked(config, name, tok).is_err() {
return KickResult {
opened: false,
rotated,
};
}
drop(rotation_guard);
std::thread::sleep(std::time::Duration::from_millis(ROTATION_STEP_DELAY_MS));
let opened = kick(&access).is_ok();
std::thread::sleep(std::time::Duration::from_millis(ROTATION_STEP_DELAY_MS));
KickResult { opened, rotated }
}
enum RotateOutcome {
GuardBusy,
Persisted(bool),
}
fn rotate_one_inner(
config: &crate::profile::ConfigHandle,
name: &str,
activity: Option<&ActivityStore>,
sender: &OpResultSender,
) -> RotateOutcome {
let Ok(_rotation_guard) = RotationGuard::acquire(name) else {
return RotateOutcome::GuardBusy;
};
let token = {
#[allow(clippy::expect_used, reason = "mutex poisoning is unrecoverable")]
let cfg = config.lock().expect("config mutex poisoned");
with_state_lock(|| {
if has_live_session(name) {
return Ok::<_, anyhow::Error>(None);
}
let rt = cfg
.find(name)
.and_then(|p| p.refresh_token().map(str::to_string));
if rt.is_some()
&& let Some(activity) = activity
{
mark_activity(activity, name, ProfileActivity::Refreshing);
}
Ok(rt)
})
.ok()
.flatten()
};
let Some(rt) = token else {
return RotateOutcome::Persisted(false);
};
let outcome = refresh(&rt).and_then(|tok| apply_rotated_tokens_locked(config, name, tok));
let applied = outcome.is_ok();
if let Some(activity) = activity {
clear_activity(activity, name);
}
let _ = sender.send(OpResult {
name: name.to_string(),
outcome,
});
RotateOutcome::Persisted(applied)
}
pub(crate) fn rotation_candidates(config: &AppConfig, force: bool) -> Vec<(String, String)> {
let skip_active = !force && active_link_diverged(config);
config
.profiles
.iter()
.filter_map(|p| {
if skip_active && config.is_active(&p.name) {
return None;
}
if has_live_session(&p.name) {
return None;
}
Some((p.name.to_string(), p.refresh_token()?.to_string()))
})
.collect()
}
pub(crate) fn refresh_all(
config: &crate::profile::ConfigHandle,
force: bool,
refetch: &RefetchQueue,
activity: &ActivityStore,
sender: &OpResultSender,
) -> Vec<String> {
let snapshots = {
#[allow(clippy::expect_used, reason = "mutex poisoning is unrecoverable")]
let cfg = config.lock().expect("config mutex poisoned");
rotation_candidates(&cfg, force)
};
if snapshots.is_empty() {
return Vec::new();
}
for (name, _) in &snapshots {
mark_activity(activity, name, ProfileActivity::Refreshing);
}
let handles: Vec<(String, _)> = snapshots
.into_iter()
.map(|(name, _rt)| {
let config = Arc::clone(config);
let activity = Arc::clone(activity);
let sender = sender.clone();
let name_for_handle = name.clone();
let h = std::thread::spawn(move || {
let outcome = rotate_one_inner(&config, &name, Some(&activity), &sender);
(name, outcome)
});
(name_for_handle, h)
})
.collect();
let mut refreshed = Vec::new();
for (name, h) in handles {
match h.join() {
Ok((n, RotateOutcome::Persisted(true))) => refreshed.push(n),
Ok((n, RotateOutcome::GuardBusy)) => {
let _ = sender.send(OpResult {
name: n.clone(),
outcome: Err(anyhow::anyhow!("failed to acquire rotation lock")),
});
clear_activity(activity, &n);
}
Ok((n, RotateOutcome::Persisted(false))) => clear_activity(activity, &n),
Err(_) => {
clear_activity(activity, &name);
}
}
}
if let Ok(mut q) = refetch.lock() {
for name in &refreshed {
q.insert(name.clone());
}
}
refreshed
}
pub(crate) fn rotate_one(
config: &crate::profile::ConfigHandle,
name: &str,
refetch: &RefetchQueue,
activity: &ActivityStore,
sender: &OpResultSender,
) -> bool {
mark_activity(activity, name, ProfileActivity::Refreshing);
let persisted = match rotate_one_inner(config, name, Some(activity), sender) {
RotateOutcome::Persisted(true) => true,
RotateOutcome::GuardBusy => {
let _ = sender.send(OpResult {
name: name.to_string(),
outcome: Err(anyhow::anyhow!("failed to acquire rotation lock")),
});
clear_activity(activity, name);
false
}
RotateOutcome::Persisted(false) => {
clear_activity(activity, name);
false
}
};
if persisted && let Ok(mut q) = refetch.lock() {
q.insert(name.to_string());
}
persisted
}
pub(crate) fn prime_window(config: &crate::profile::ConfigHandle, name: &str) -> bool {
let (access_token, refresh_token, expires_at) = {
#[allow(clippy::expect_used, reason = "mutex poisoning is unrecoverable")]
let cfg = config.lock().expect("config mutex poisoned");
match with_state_lock(|| {
let Some(profile) = cfg.find(name) else {
return Ok::<_, anyhow::Error>(None);
};
if !profile.is_oauth() || !profile.auto_start {
return Ok(None);
}
let Some(token) = profile.access_token().map(str::to_string) else {
return Ok(None);
};
let refresh = profile.refresh_token().map(str::to_string);
Ok(Some((token, refresh, profile.access_token_expires_at())))
}) {
Ok(Some(t)) => t,
_ => return false,
}
};
auto_start_kick(
config,
name,
&access_token,
refresh_token.as_deref(),
expires_at,
None,
)
.opened
}
fn write_token_fields(oauth: &mut OAuthToken, tok: TokenResponse) {
oauth.access_token = tok.access_token;
oauth.refresh_token = Some(tok.refresh_token);
oauth.expires_at = Some((now_ms() + tok.expires_in * 1000) as i64);
if let Some(scope) = tok.scope {
oauth.scopes = Some(scope.split_whitespace().map(String::from).collect());
}
}
pub(crate) fn apply_rotated_tokens_locked(
config: &crate::profile::ConfigHandle,
name: &str,
tok: TokenResponse,
) -> Result<()> {
#[allow(clippy::expect_used, reason = "mutex poisoning is unrecoverable")]
let mut cfg = config.lock().expect("config mutex poisoned");
#[cfg(target_os = "macos")]
let mut mirror: Option<crate::profile::ClaudeCredentials> = None;
with_state_lock(|| {
let Some(profile) = cfg.find_mut(name) else {
return Err(anyhow::anyhow!("failed to persist rotated tokens"));
};
let Some(creds) = profile.credentials.as_mut() else {
return Err(anyhow::anyhow!("failed to persist rotated tokens"));
};
let Some(oauth) = creds.claude_ai_oauth.as_mut() else {
return Err(anyhow::anyhow!("failed to persist rotated tokens"));
};
#[cfg(target_os = "macos")]
let old_access = oauth.access_token.clone();
write_token_fields(oauth, tok);
if let Some(creds) = profile.credentials.as_ref() {
let _ = stage_rotated_credentials(name, creds);
}
if save_profile(profile).is_err() {
return Err(anyhow::anyhow!("failed to persist rotated tokens"));
}
clear_staged_credentials(name);
#[cfg(target_os = "macos")]
if crate::keychain::enabled() && cfg.is_active(name) {
if live_login_is_foreign(name, &old_access) {
eprintln!(
"clauth: rotated '{name}' but the live login diverged (a re-login clauth \
doesn't own) — Keychain left untouched; resolve the divergence in the TUI"
);
} else {
mirror = cfg.find(name).and_then(|p| p.credentials.as_ref()).cloned();
}
}
Ok(())
})?;
#[cfg(target_os = "macos")]
if let Some(creds) = mirror
&& let Err(e) = crate::keychain::keychain_write(&creds)
{
eprintln!(
"clauth: rotated '{name}' but the Keychain mirror failed: {e:#} — a \
running claude signs out when its old token expires; run `clauth {name}` \
to reinstall"
);
}
Ok(())
}
pub(crate) fn try_adopt_live_rotation(
config: &crate::profile::ConfigHandle,
name: &str,
_rotation_guard: &crate::runtime::RotationGuard,
identity: &dyn Fn(&str) -> Option<String>,
) -> Option<(String, Option<String>)> {
use crate::profile_cache::{ACCOUNT_ID_CACHE_FILE, load_profile_cache, write_profile_cache};
let (stored_access, stored_expires) = {
let Ok(cfg) = config.lock() else { return None };
if !cfg.is_active(name) {
return None;
}
let p = cfg.find(name)?;
(
p.access_token().map(str::to_string),
p.access_token_expires_at(),
)
};
if !matches!(
crate::claude::classify_credentials_link(name),
Ok(crate::claude::LinkState::Diverged)
) {
return None;
}
let Ok(Some(live)) = crate::claude::read_claude_credentials() else {
return None;
};
let live_oauth = live.claude_ai_oauth.as_ref()?;
live_oauth.refresh_token.as_ref()?;
let (Some(live_expires), Some(stored_expires)) = (live_oauth.expires_at, stored_expires) else {
return None;
};
if live_expires <= stored_expires {
return None;
}
let expected: Option<String> = load_profile_cache::<String>(name, ACCOUNT_ID_CACHE_FILE)
.or_else(|| {
let alive = (now_ms() as i64) < stored_expires;
match (&stored_access, alive) {
(Some(tok), true) => identity(tok),
_ => None,
}
});
let Some(expected) = expected else {
eprintln!(
"clauth: live login for '{name}' is newer but its identity can't be proven \
(no cached account id and the stored token is dead) — not adopting; \
resolve in the TUI or re-run clauth login {name}"
);
return None;
};
let live_id = identity(&live_oauth.access_token)?;
if live_id.trim().is_empty() || expected.trim().is_empty() {
return None;
}
if live_id != expected {
eprintln!(
"clauth: live login for '{name}' belongs to a DIFFERENT account — not adopting; \
capture it via the TUI divergence flow if that was intentional"
);
return None;
}
#[allow(clippy::expect_used, reason = "mutex poisoning is unrecoverable")]
let mut cfg = config.lock().expect("config mutex poisoned");
let adopted = with_state_lock(|| {
if !cfg.is_active(name) {
return Ok(false);
}
let Some(profile) = cfg.find_mut(name) else {
return Ok(false);
};
if profile
.access_token_expires_at()
.is_none_or(|cur| live_expires <= cur)
{
return Ok(false);
}
profile.credentials = Some(live.clone());
save_profile(profile)?;
Ok::<bool, anyhow::Error>(true)
})
.unwrap_or(false);
if !adopted {
return None;
}
if cfg.set_auth_broken(name, false) {
eprintln!("clauth: '{name}' re-authenticated — auth_broken cleared");
let _ = crate::profile::save_app_state(&cfg.state);
}
write_profile_cache(name, ACCOUNT_ID_CACHE_FILE, &live_id);
eprintln!(
"clauth: adopted the live session's rotated login for '{name}' \
(the running claude refreshed first — no token spent)"
);
Some((
live_oauth.access_token.clone(),
live_oauth.refresh_token.clone(),
))
}
#[cfg(target_os = "macos")]
fn live_login_is_foreign(name: &str, old_access: &str) -> bool {
match crate::claude::classify_credentials_link(name) {
Ok(crate::claude::LinkState::LinkedTo) | Ok(crate::claude::LinkState::Missing) => false,
Ok(crate::claude::LinkState::Diverged) => {
let live = crate::claude::read_claude_credentials().ok().flatten();
let live_token = live.as_ref().and_then(|c| c.access_token());
!live_token.is_some_and(|t| !t.is_empty() && t == old_access)
}
Err(_) => true,
}
}
fn active_link_diverged(config: &AppConfig) -> bool {
config.state.active_profile.as_deref().is_some_and(|name| {
matches!(
classify_credentials_link(name).ok(),
Some(LinkState::Diverged)
)
})
}
const AUTH_GATE_GRACE_MS: i64 = 60_000;
pub(crate) enum AuthGate {
Ready,
Refreshed,
Broken,
Transient(anyhow::Error),
}
pub(crate) fn ensure_installable(
config: &crate::profile::ConfigHandle,
name: &str,
refresher: impl Fn(&str) -> std::result::Result<TokenResponse, RefreshError>,
) -> AuthGate {
let (expires_at, refresh_token, flagged) = {
let Ok(cfg) = config.lock() else {
return AuthGate::Transient(anyhow::anyhow!("config mutex poisoned"));
};
let Some(profile) = cfg.find(name) else {
return AuthGate::Ready;
};
if !profile.is_oauth() {
return AuthGate::Ready;
}
(
profile.access_token_expires_at(),
profile.refresh_token().map(str::to_string),
cfg.is_auth_broken(name),
)
};
let expiring =
flagged || expires_at.is_some_and(|exp| (now_ms() as i64) + AUTH_GATE_GRACE_MS >= exp);
if !expiring {
return AuthGate::Ready;
}
let Ok(_guard) = RotationGuard::acquire(name) else {
return AuthGate::Transient(anyhow::anyhow!(
"'{name}' rotation lock busy; retry after the in-flight refresh"
));
};
if has_live_session(name) {
return AuthGate::Ready;
}
let Some(rt) = refresh_token else {
mark_auth_broken(config, name, true);
return AuthGate::Broken;
};
match refresher(&rt) {
Ok(tok) => {
if apply_rotated_tokens_locked(config, name, tok).is_err() {
return AuthGate::Transient(anyhow::anyhow!(
"refreshed '{name}' but failed to persist the rotated tokens"
));
}
mark_auth_broken(config, name, false);
AuthGate::Refreshed
}
Err(RefreshError::Invalid(_)) => {
mark_auth_broken(config, name, true);
AuthGate::Broken
}
Err(RefreshError::Transient(e)) => AuthGate::Transient(e),
}
}
pub(crate) fn mark_auth_broken(config: &crate::profile::ConfigHandle, name: &str, broken: bool) {
if let Ok(mut cfg) = config.lock()
&& cfg.set_auth_broken(name, broken)
{
if broken {
eprintln!(
"clauth: login for '{name}' expired — refresh token dead; \
flagged auth_broken. Recover with: clauth login {name}"
);
} else {
eprintln!("clauth: '{name}' re-authenticated — auth_broken cleared");
}
let _ = crate::profile::save_app_state(&cfg.state);
}
}
#[cfg(test)]
#[path = "../tests/inline/oauth.rs"]
mod tests;