//! Claude OAuth implementation //! //! Loads OAuth credentials from Claude CLI and fetches usage from the API. use chrono::{DateTime, Utc}; use reqwest::Client; use reqwest::header::{HeaderValue, RETRY_AFTER}; use serde::Deserialize; use std::collections::HashMap; use std::sync::{LazyLock, Mutex}; use std::time::{Duration, Instant}; use crate::core::{ CostSnapshot, NamedRateWindow, ProviderDisplayDetail, ProviderError, ProviderFetchResult, RateWindow, UsageSnapshot, }; mod credentials_store; mod profile; mod refresh; mod usage_gate; use super::spend_window; pub(super) fn clear_account_cache(credential_path: &std::path::Path) { credentials_store::clear_cache(); clear_refresh_backoff(&credentials_store::CredentialSource::File( credential_path.to_path_buf(), )); } /// OAuth credentials from Claude CLI #[derive(Debug, Clone)] pub struct ClaudeOAuthCredentials { pub access_token: String, pub refresh_token: Option, pub expires_at: Option>, pub scopes: Vec, pub rate_limit_tier: Option, } impl ClaudeOAuthCredentials { /// Check if the token is expired pub fn is_expired(&self) -> bool { if let Some(expires_at) = self.expires_at { // Consider expired if within 5 minutes of expiry expires_at <= Utc::now() + chrono::Duration::minutes(5) } else { // No expiry info = don't assume expired, try it false } } /// Check if the credentials have a specific scope pub fn has_scope(&self, scope: &str) -> bool { self.scopes.iter().any(|s| s == scope) } } /// Return a non-secret identity for the credential that would authorize a /// Claude CLI session. JWT subjects survive token rotation; opaque tokens use /// a one-way fingerprint and therefore fail closed if the credential changes. pub(super) fn credential_identity(credentials: &ClaudeOAuthCredentials) -> Option { let token = credentials.access_token.trim(); if token.is_empty() { return None; } if let Some(subject) = crate::codex_accounts::api::jwt_payload(token).and_then(|payload| { ["sub", "account_id", "user_id"] .into_iter() .find_map(|key| { payload .get(key) .and_then(serde_json::Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_owned) }) }) { return Some(format!("claude-account:{subject}")); } Some(format!( "claude-credential:{}", crate::core::sha256_hex(token.as_bytes()) )) } /// Load the identity used to authorize Claude Code. Reading Claude Code's /// credential stores remains subject to the user's explicit consent setting. pub(super) fn auto_resume_identity() -> Option { let (credentials, _) = credentials_store::load_credentials().ok()?; credential_identity(&credentials) } /// OAuth usage response from Claude API #[derive(Debug, Deserialize)] pub struct OAuthUsageResponse { #[serde(rename = "fiveHour", alias = "five_hour")] pub five_hour: Option, #[serde(rename = "sevenDay", alias = "seven_day")] pub seven_day: Option, #[serde(rename = "sevenDaySonnet", alias = "seven_day_sonnet")] pub seven_day_sonnet: Option, #[serde(rename = "sevenDayOpus", alias = "seven_day_opus")] pub seven_day_opus: Option, #[serde( rename = "sevenDayDesign", alias = "seven_day_design", alias = "seven_day_oauth_apps" )] pub seven_day_design: Option, #[serde( rename = "sevenDayRoutines", alias = "seven_day_routines", alias = "seven_day_omelette" )] pub seven_day_routines: Option, #[serde(rename = "extraUsage", alias = "extra_usage")] pub extra_usage: Option, /// Promotional cloud-credits block. Display-only: read by `cloud_credits`, /// never by the quota windows. #[serde(default)] pub iguana_necktie: Option, /// Saved limit-reset block, present when the request opted in. Decoded /// leniently by `reset_credits` so a malformed block never hides usage. #[serde(default)] cedar_ember: Option, /// Read entry by entry (upstream #4614): one malformed limit keeps its siblings. #[serde(default, deserialize_with = "super::scoped_weekly::deserialize_limits")] limits: Vec, } /// Display-only limit-reset inventory, when the block is present and valid. fn reset_credits_item( response: &OAuthUsageResponse, now: DateTime, ) -> Option { response .cedar_ember .as_ref() .and_then(|block| super::reset_credits::inventory_from_block(block, now)) } /// Upstream retries without the reset opt-in on 400, and on 403 unless the /// rejection names the missing `user:profile` scope. fn rejects_reset_opt_in(status: reqwest::StatusCode, body: &str) -> bool { status == reqwest::StatusCode::BAD_REQUEST || (status == reqwest::StatusCode::FORBIDDEN && !body.contains("user:profile")) } /// Status, `Retry-After` and body of one usage response. struct UsageReply { status: reqwest::StatusCode, retry_after: Option, body: String, } /// Display row for the promotional cloud-credits block, when present and valid. fn cloud_credits_row( response: &OAuthUsageResponse, now: DateTime, ) -> Option { response .iguana_necktie .as_ref() .and_then(|block| super::cloud_credits::detail_from_block(block, now)) } /// A usage window from the OAuth API #[derive(Debug, Deserialize)] pub struct UsageWindow { pub utilization: Option, #[serde(rename = "resetsAt", alias = "resets_at")] pub resets_at: Option, } /// Extra usage (credits) info #[derive(Debug, Deserialize)] pub struct ExtraUsage { #[serde(rename = "isEnabled", alias = "is_enabled")] pub is_enabled: Option, #[serde(rename = "usedCredits", alias = "used_credits")] pub used_credits: Option, #[serde(rename = "monthlyLimit", alias = "monthly_limit")] pub monthly_limit: Option, pub currency: Option, } /// Start of the error raised while the OAuth usage endpoint refuses requests /// with 429. The credentials stay valid. const RATE_LIMITED_PREFIX: &str = "Claude OAuth usage endpoint is rate limited."; /// Whether `error` is the OAuth usage endpoint's rate-limit refusal. pub(super) fn is_rate_limited_error(error: &ProviderError) -> bool { matches!( error, ProviderError::OAuthTransient(message) if message.starts_with(RATE_LIMITED_PREFIX) ) } /// Claude OAuth fetcher pub struct ClaudeOAuthFetcher { client: Client, api_base: String, usage_gate: &'static Mutex, version_source: ClaudeVersionSource, } /// Where the Claude CLI version in the usage User-Agent comes from. #[derive(Clone, Copy)] enum ClaudeVersionSource { /// Run `claude --version`, cached for `CLAUDE_VERSION_CACHE_TTL`. Detect, /// A fixed answer for tests. `None` exercises the fallback version. #[cfg(test)] Fixed(Option<&'static str>), } /// Upstream `ClaudeOAuthUsageFetcher.fallbackClaudeCodeVersion`, used when the /// CLI version cannot be detected. const FALLBACK_CLAUDE_CODE_VERSION: &str = "2.1.0"; /// Upstream caches the detected CLI version for 30 minutes. const CLAUDE_VERSION_CACHE_TTL: Duration = Duration::from_secs(30 * 60); /// Last successful `claude --version` detection, as (when, version). static CLAUDE_VERSION_CACHE: Mutex> = Mutex::new(None); #[cfg(test)] const TEST_CLAUDE_CODE_VERSION: &str = "2.3.4"; /// Version used in the User-Agent: the detected CLI version, or the fallback /// when detection failed or returned nothing usable. fn claude_agent_version(detected: Option<&str>) -> &str { detected .map(str::trim) .filter(|version| !version.is_empty()) .unwrap_or(FALLBACK_CLAUDE_CODE_VERSION) } /// Legacy agent string (`claude-code/`). The fallback retry sends it. fn claude_code_user_agent(detected: Option<&str>) -> String { format!("claude-code/{}", claude_agent_version(detected)) } /// Opt-in agent string (`claude-cli/ (external, cli)`). Upstream /// derives it from the legacy string by renaming the product. fn claude_cli_user_agent(detected: Option<&str>) -> String { format!( "claude-cli/{} (external, cli)", claude_agent_version(detected) ) } /// The Claude CLI version from `claude --version`, cached for the TTL. /// Failed detections are not cached, so a later poll can still succeed. async fn detected_claude_version() -> Option { { let cache = CLAUDE_VERSION_CACHE .lock() .unwrap_or_else(|e| e.into_inner()); if let Some((cached_at, version)) = cache.as_ref() && cached_at.elapsed() < CLAUDE_VERSION_CACHE_TTL { return Some(version.clone()); } } // The detector blocks on a child process, so keep it off the async runtime. let version = tokio::task::spawn_blocking(super::cli_binary::detect_claude_version) .await .ok() .flatten()?; *CLAUDE_VERSION_CACHE .lock() .unwrap_or_else(|e| e.into_inner()) = Some((Instant::now(), version.clone())); Some(version) } /// Gate shared with other CodexBar processes through a small file (#775). static USAGE_GATE: LazyLock> = LazyLock::new(|| Mutex::new(usage_gate::UsageGate::new(usage_gate::default_path()))); // ── Refresh-token backoff (upstream 0.48.0 #2650) ──────────────────────────── // // On Windows the Claude Code credential file is readable, so the macOS // "touch completes but the refreshed credential is unreadable" state has no // equivalent; the matching *provably-unrecoverable-by-retry* state here is the // refresh endpoint itself rejecting the stored refresh token with // `invalid_grant`. Retrying the identical grant can never succeed → the // terminal gate stays blocked *indefinitely* and only clears when the // credential file changes (the CLI re-auth rotates the refresh token) or a // refresh succeeds. Transient failures (network, 5xx, 403, non-grant 4xx) // use a flat 5-minute cooldown: a retry can still heal those. const TRANSIENT_REFRESH_BACKOFF: Duration = Duration::from_secs(5 * 60); struct RefreshBackoffEntry { /// When transient cooldown expires. Terminal entries never expire on a /// timer; this is `None` for terminal gates. until: Option, kind: refresh::RefreshFailureKind, /// The refresh token observed at failure time. A subsequent poll that /// sees a different refresh token (CLI re-auth) clears a terminal gate. fingerprint: Option, } static REFRESH_BACKOFF: LazyLock< Mutex>, > = LazyLock::new(|| Mutex::new(HashMap::new())); /// Returns the active backoff kind for `source`, or `None` if it has expired /// or been cleared by a credential change. `current_refresh_token` is the /// token the caller is about to retry with; a terminal gate whose stored /// fingerprint differs from it is cleared (the grant changed → retry allowed). fn active_refresh_backoff( source: &credentials_store::CredentialSource, now: Instant, current_refresh_token: Option<&str>, ) -> Option { let mut guard = REFRESH_BACKOFF.lock().ok()?; let entry = guard.get(source)?; match entry.kind { refresh::RefreshFailureKind::Terminal => { // Indefinite gate: only a credential change (different refresh // token) or an explicit success clears it. if let Some(fp) = &entry.fingerprint && current_refresh_token != Some(fp.as_str()) { guard.remove(source); return None; } Some(entry.kind) } refresh::RefreshFailureKind::Transient => { if entry.until.is_some_and(|until| until <= now) { guard.remove(source); return None; } Some(entry.kind) } } } fn record_refresh_backoff( source: &credentials_store::CredentialSource, kind: refresh::RefreshFailureKind, now: Instant, current_refresh_token: Option<&str>, ) { let (until, fingerprint) = match kind { refresh::RefreshFailureKind::Terminal => ( // Terminal gates do not expire on a timer. None, current_refresh_token.map(str::to_string), ), refresh::RefreshFailureKind::Transient => (Some(now + TRANSIENT_REFRESH_BACKOFF), None), }; if let Ok(mut guard) = REFRESH_BACKOFF.lock() { guard.insert( source.clone(), RefreshBackoffEntry { until, kind, fingerprint, }, ); } } fn clear_refresh_backoff(source: &credentials_store::CredentialSource) { if let Ok(mut guard) = REFRESH_BACKOFF.lock() { guard.remove(source); } } /// User-facing message when refresh outcome is *terminal*: the stored refresh /// token was rejected, so no amount of retrying refreshes the session. No /// "then retry" tail — upstream dropped the same advice because refreshing /// Claude Code's own credential store cannot heal this state. fn terminal_refresh_message() -> String { "Claude OAuth session expired and its stored refresh token was rejected by the \ server. Run `claude login` to re-authenticate." .to_string() } /// User-facing message while a transient refresh failure is cooling down. fn refresh_cooldown_message() -> String { "Claude OAuth token expired and token refresh is cooling down after a failed \ attempt. Please retry shortly, or run `claude login`." .to_string() } // The shared renewal path returns OAuth only for a terminal grant rejection; // its other outcome is OAuthTransient. Preserve that distinction for saved // account rows without changing the ambient provider's fallback behavior. fn account_refresh_error(error: ProviderError) -> ProviderError { match error { ProviderError::OAuth(message) => ProviderError::OAuthRevoked(message), other => other, } } /// Whether the standalone saved account may stand in for Claude Code's login. /// An explicit OAuth token from the environment always wins, so it is checked /// first; the standalone account never replaces it or runs ahead of it. fn standalone_fallback_applies() -> bool { !credentials_store::environment_token_present() && !super::claude_code_consent() } /// Standalone results carry the saved account's own labels and never the /// identity a token may hold, because the token says nothing about which /// login Claude CLI uses. fn apply_standalone_summary( fetched: &mut ProviderFetchResult, summary: Option, ) { fetched.account_identity = None; let Some(summary) = summary else { return; }; let usage = &mut fetched.usage; usage.account_email = Some(summary.email); if summary.organization.is_some() { usage.account_organization = summary.organization; } if let Some(label) = summary.plan.as_deref().and_then(super::claude_plan_label) { usage.login_method = Some(label); } } impl ClaudeOAuthFetcher { const API_BASE: &'static str = "https://api.anthropic.com"; const USAGE_PATH: &'static str = "/api/oauth/usage"; const DEFAULT_RATE_LIMIT_BACKOFF: Duration = Duration::from_secs(5 * 60); pub fn new() -> Self { Self { client: Client::new(), api_base: Self::API_BASE.to_string(), usage_gate: &USAGE_GATE, version_source: ClaudeVersionSource::Detect, } } /// A fetcher against a local server with its own in-memory usage gate, so /// tests never touch the shared gate file. The CLI version is pinned so /// the User-Agent is deterministic. #[cfg(test)] fn for_test_server(api_base: impl Into) -> Self { Self { client: Client::new(), api_base: api_base.into(), usage_gate: Box::leak(Box::new(Mutex::new(usage_gate::UsageGate::new(None)))), version_source: ClaudeVersionSource::Fixed(Some(TEST_CLAUDE_CODE_VERSION)), } } async fn claude_code_version(&self) -> Option { match self.version_source { ClaudeVersionSource::Detect => detected_claude_version().await, #[cfg(test)] ClaudeVersionSource::Fixed(version) => version.map(str::to_string), } } /// Load credentials and fetch usage, transparently refreshing an expired /// OAuth token first (like the Claude CLI does) so the panel stays green /// without the user having to re-run `claude`. pub async fn fetch(&self) -> Result { let _account_operation = super::accounts::CREDENTIAL_OPERATION.lock().await; let account_manager = super::accounts::AccountManager::new().ok(); // Without consent Claude Code's credentials stay closed. A saved // account that was never installed into Claude Code is CodexBar's own // and can still supply usage, so a stale or absent Claude Code login // does not blank the panel. An environment token is checked first. if standalone_fallback_applies() && let Some(manager) = &account_manager && let Some(result) = self.fetch_standalone_saved_account(manager).await { return result; } let saved_account_id = account_manager .as_ref() .and_then(|manager| manager.current_account_id().ok().flatten()); let (credentials, source) = credentials_store::load_credentials()?; let (credentials, refresh_outcome) = self .ensure_fresh_credentials( credentials, source, account_manager.as_ref(), saved_account_id.as_deref(), true, ) .await; // Still-expired credentials with a terminal/gated refresh state get the // honest message instead of a generic "expired" error (or another // doomed API call). if credentials.is_expired() && let Some(error) = refresh_outcome { return Err(error); } let gate_key = usage_gate::account_key(saved_account_id.as_deref(), &credentials.access_token); self.fetch_with_credentials(credentials, &gate_key).await } /// Fetch a saved account directly; never switch Claude Code to obtain usage. pub async fn fetch_account(&self, id: &str) -> Result { if !super::claude_code_consent() { return Err(ProviderError::OAuth( "Enable Allow reading Claude Code's credentials in Settings to check saved accounts.".into(), )); } let _operation = super::accounts::CREDENTIAL_OPERATION.lock().await; let manager = super::accounts::AccountManager::new() .map_err(|e| ProviderError::Other(e.to_string()))?; let (oauth, active) = manager .usage_credentials(id) .map_err(|e| ProviderError::Other(e.to_string()))?; self.fetch_saved_account(&manager, id, &oauth, active).await } /// Usage for the one saved account CodexBar may use without Claude Code's /// credentials. `None` when there is no such account, so the caller keeps /// its normal path (and its own error) for that case. async fn fetch_standalone_saved_account( &self, manager: &super::accounts::AccountManager, ) -> Option> { let (id, oauth) = match manager.standalone_usage_credentials() { Ok(Some(account)) => account, Ok(None) => return None, Err(error) => { tracing::warn!(%error, "Could not read CodexBar's saved Claude accounts"); return None; } }; tracing::debug!("Claude Code credentials are off; using the standalone saved account"); let mut result = self.fetch_saved_account(manager, &id, &oauth, false).await; match &mut result { Ok(fetched) => apply_standalone_summary(fetched, manager.saved_summary(&id)), Err(error) => { tracing::warn!(%error, "Claude usage from the standalone saved account failed"); } } Some(result) } /// Resolve, refresh when needed, and query one saved account. `active` /// means Claude Code holds the same login, so a refresh also updates it. async fn fetch_saved_account( &self, manager: &super::accounts::AccountManager, id: &str, oauth: &serde_json::Value, active: bool, ) -> Result { let oauth = oauth.to_string(); let source = credentials_store::CredentialSource::saved_account( manager.usage_source_path(), id, &oauth, ); let credentials = credentials_store::parse_credentials_json(&oauth)?; let (credentials, outcome) = self .ensure_fresh_credentials(credentials, source.clone(), Some(manager), Some(id), active) .await; if credentials.is_expired() && let Some(error) = outcome { return Err(account_refresh_error(error)); } // Keyed by the account, so its own 429 block survives a token renewal. let gate_key = usage_gate::account_key(Some(id), &credentials.access_token); let result = self .fetch_with_credentials(credentials.clone(), &gate_key) .await; // Unknown expiry metadata and early server expiry still get one renewal. if matches!(&result, Err(ProviderError::OAuthExpired(_))) && credentials.refresh_token.is_some() { let mut expired = credentials; expired.expires_at = Some(Utc::now()); credentials_store::store_refreshed(&source, &expired); let (refreshed, outcome) = self .ensure_fresh_credentials(expired, source, Some(manager), Some(id), active) .await; if let Some(error) = outcome { return Err(account_refresh_error(error)); } return self.fetch_with_credentials(refreshed, &gate_key).await; } result } /// Fetch usage with an explicit OAuth access token. pub async fn fetch_with_access_token( &self, access_token: &str, ) -> Result { let access_token = access_token.trim(); if access_token.is_empty() { return Err(ProviderError::OAuth( "Claude OAuth access token is empty.".to_string(), )); } let credentials = ClaudeOAuthCredentials { access_token: access_token.to_string(), refresh_token: None, expires_at: None, scopes: vec!["user:profile".to_string()], rate_limit_tier: None, }; let gate_key = usage_gate::account_key(None, &credentials.access_token); self.fetch_with_credentials(credentials, &gate_key).await } async fn fetch_with_credentials( &self, credentials: ClaudeOAuthCredentials, gate_key: &str, ) -> Result { let fingerprint = usage_gate::token_fingerprint(&credentials.access_token); // Upstream validates the token before its profile lookup, so an expired // or scope-less token sends no /profile request. Self::check_usage_credentials(&credentials)?; // Started before the usage request so a quick profile answer can still // label this refresh; the usage request never waits for it. let _ = profile::display_email(&self.client, &credentials.access_token, &fingerprint); let usage_response = self.fetch_usage(&credentials, gate_key).await?; let mut usage = self.build_usage_snapshot(&usage_response, &credentials); let spend_cost = apply_spend_only_extra_usage(&mut usage, &usage_response); // Header email only; a missing or failed profile leaves usage intact. if usage.account_email.is_none() && let Some(email) = profile::display_email(&self.client, &credentials.access_token, &fingerprint) { usage = usage.with_email(email); } let mut result = ProviderFetchResult::new(usage, "oauth") .with_display_detail(cloud_credits_row(&usage_response, Utc::now())); if let Some(item) = reset_credits_item(&usage_response, Utc::now()) { result = result.with_inventory_item(item); } if let Some(cost) = spend_cost { result = result.with_cost(cost); } if let Some(identity) = credential_identity(&credentials) { result = result.with_account_identity(identity); } Ok(result) } /// If the token is expired (or about to expire), refresh it using the /// refresh token and persist the new token back to `.credentials.json`. /// Best-effort: on any failure the original credentials are returned so the /// caller falls back to the existing "expired" handling. The second return /// value carries a user-facing message when the refresh outcome is gated /// (cooldown) or terminal (#2650) and the credentials remain expired. async fn ensure_fresh_credentials( &self, mut credentials: ClaudeOAuthCredentials, source: credentials_store::CredentialSource, account_manager: Option<&super::accounts::AccountManager>, saved_account_id: Option<&str>, write_ambient: bool, ) -> (ClaudeOAuthCredentials, Option) { // Prefer an in-memory refreshed token if it is fresher than what we just // read from disk (covers a prior persist that failed to write). Scoped // to this credential's own source so a refresh cached for one source // (e.g. the credentials file) never shadows another (e.g. an // environment-provided token). let recovered_from_cache = if let Some(cached) = credentials_store::cached_refreshed_if_fresher(&source, &credentials) { credentials = cached; true } else { false }; if !credentials.is_expired() { if recovered_from_cache { self.persist_refreshed_state( &credentials, account_manager, saved_account_id, write_ambient, ); } return (credentials, None); } let Some(refresh_token) = credentials.refresh_token.clone() else { // Environment-provided tokens have no refresh token; nothing to do. return (credentials, None); }; // Skip a poll-cadence retry that is still cooling down (#2650): a // terminal rejection would replay the identical rejected grant, and a // transient failure should not hammer the endpoint every poll. let now = Instant::now(); if let Some(kind) = active_refresh_backoff(&source, now, Some(refresh_token.as_str())) { let error = match kind { refresh::RefreshFailureKind::Terminal => { ProviderError::OAuth(terminal_refresh_message()) } refresh::RefreshFailureKind::Transient => { ProviderError::OAuthTransient(refresh_cooldown_message()) } }; return (credentials, Some(error)); } match refresh::refresh_access_token(&self.client, &refresh_token, &credentials).await { Ok(refreshed) => { clear_refresh_backoff(&source); credentials_store::store_refreshed(&source, &refreshed); self.persist_refreshed_state( &refreshed, account_manager, saved_account_id, write_ambient, ); tracing::debug!("Refreshed expired Claude OAuth token"); (refreshed, None) } Err(failure) => { tracing::debug!("Claude OAuth token refresh failed: {}", failure.message); let error = match failure.kind { refresh::RefreshFailureKind::Terminal => { ProviderError::OAuth(terminal_refresh_message()) } refresh::RefreshFailureKind::Transient => { ProviderError::OAuthTransient(refresh_cooldown_message()) } }; record_refresh_backoff(&source, failure.kind, now, Some(refresh_token.as_str())); (credentials, Some(error)) } } } /// Reconcile the saved account before the live credentials file. If either /// write fails, the in-memory refreshed value remains newer than disk and /// the next poll retries this same path without rotating the token again. pub(super) fn persist_refreshed_state( &self, refreshed: &ClaudeOAuthCredentials, account_manager: Option<&super::accounts::AccountManager>, saved_account_id: Option<&str>, write_ambient: bool, ) { if let (Some(manager), Some(account_id)) = (account_manager, saved_account_id) { let refreshed_oauth = Self::refreshed_oauth_value(refreshed); if let Err(err) = manager.update_saved_oauth(account_id, &refreshed_oauth) { tracing::debug!( "Claude OAuth token refreshed but saved account store was not updated: {err}" ); return; } } let still_active = saved_account_id.is_none_or(|id| { account_manager.is_some_and(|manager| { manager.current_account_id().ok().flatten().as_deref() == Some(id) }) }); if write_ambient && still_active && let Err(err) = credentials_store::persist_refreshed_credentials(refreshed) { tracing::debug!("Claude OAuth token refreshed but could not persist: {err}"); } } /// Expiry and `user:profile` scope checks that run before any request for /// the token, including the profile lookup. fn check_usage_credentials(credentials: &ClaudeOAuthCredentials) -> Result<(), ProviderError> { if credentials.is_expired() { return Err(ProviderError::OAuthExpired( "OAuth token expired. Run `claude` to refresh.".to_string(), )); } // Check for required scope if !credentials.scopes.is_empty() && !credentials.has_scope("user:profile") { return Err(ProviderError::OAuth(format!( "OAuth token missing 'user:profile' scope (has: {}). {}", credentials.scopes.join(", "), Self::scope_recovery_message() ))); } Ok(()) } /// Fetch usage data using OAuth credentials pub async fn fetch_usage( &self, credentials: &ClaudeOAuthCredentials, gate_key: &str, ) -> Result { Self::check_usage_credentials(credentials)?; if let Some(result) = Self::gate_precheck(self.usage_gate, usage_gate::now_ms(), gate_key) { return result; } // Opt in to the saved limit-reset block (upstream 0.72.0). Rejecting // the optional query must not hide quota or spend, so one plain // request follows; its outcome gets the normal handling below. // Upstream 0.77.0: the opt-in request identifies as the Claude CLI, // and the fallback keeps the legacy `claude-code/` string. let usage_url = format!("{}{}", self.api_base, Self::USAGE_PATH); let claude_version = self.claude_code_version().await; let mut reply = self .request_usage( &format!("{usage_url}?{}", super::reset_credits::RESET_OPT_IN_QUERY), &credentials.access_token, &claude_cli_user_agent(claude_version.as_deref()), ) .await?; if rejects_reset_opt_in(reply.status, &reply.body) { reply = self .request_usage( &usage_url, &credentials.access_token, &claude_code_user_agent(claude_version.as_deref()), ) .await?; } let UsageReply { status, retry_after, body, } = reply; if !status.is_success() { let retry_after = Self::retry_after_duration(retry_after.as_ref()); // Upstream 0.50.1 #2516: distinguish revoked tokens (keyring ACL // revocation, token rotation) from merely expired/invalid ones. // A revoked token exists but the API rejects it with a // revocation indicator — the CLI fallback should still work. if status.as_u16() == 401 || status.as_u16() == 403 { let lower = body.to_ascii_lowercase(); if lower.contains("revoked") || lower.contains("invalid_grant") || lower.contains("token_revoked") { return Err(ProviderError::OAuthRevoked( "OAuth token was revoked. The CLI fallback will be used.".to_string(), )); } } if status.as_u16() == 401 { return Err(ProviderError::OAuthExpired( "OAuth token invalid or expired. Run `claude` to re-authenticate.".to_string(), )); } if status.as_u16() == 403 && body.contains("user:profile") { return Err(ProviderError::OAuth(format!( "OAuth token does not meet scope requirement 'user:profile'. {}", Self::scope_recovery_message() ))); } if status.as_u16() == 429 { let backoff = self .usage_gate .lock() .unwrap_or_else(|e| e.into_inner()) .record_rate_limit(usage_gate::now_ms(), gate_key, retry_after); return Err(Self::rate_limited_error(backoff)); } return Err(ProviderError::OAuth(format!( "API error {}: {}", status, body.chars().take(200).collect::() ))); } let usage = Self::parse_usage(&body)?; self.usage_gate .lock() .unwrap_or_else(|e| e.into_inner()) .record_success( usage_gate::now_ms(), gate_key, super::reset_credits::redact_usage_body(&body), ); Ok(usage) } async fn request_usage( &self, url: &str, access_token: &str, user_agent: &str, ) -> Result { let response = self .client .get(url) .header("Authorization", format!("Bearer {access_token}")) .header("Accept", "application/json") .header("anthropic-beta", "oauth-2025-04-20") .header("User-Agent", user_agent) .timeout(std::time::Duration::from_secs(10)) .send() .await?; let status = response.status(); let retry_after = response.headers().get(RETRY_AFTER).cloned(); let body = match response.text().await { Ok(body) => body, Err(e) if status.is_success() => { return Err(ProviderError::Parse(format!( "Failed to read OAuth response: {}", e ))); } Err(_) => String::new(), }; Ok(UsageReply { status, retry_after, body, }) } fn parse_usage(body: &str) -> Result { serde_json::from_str(body) .map_err(|e| ProviderError::Parse(format!("Failed to parse OAuth response: {}", e))) } /// Gate decision before a request: `Some` answers without a request (rate /// limited, or a recent response served again), `None` lets it proceed. fn gate_precheck( gate: &Mutex, now_ms: i64, fingerprint: &str, ) -> Option> { let decision = gate .lock() .unwrap_or_else(|e| e.into_inner()) .check(now_ms, fingerprint); match decision { usage_gate::Decision::Blocked(remaining) => { Some(Err(Self::rate_limited_error(remaining))) } usage_gate::Decision::Cached(body) => Self::parse_usage(&body).ok().map(Ok), usage_gate::Decision::Proceed => None, } } fn retry_after_duration(value: Option<&HeaderValue>) -> Duration { let Some(value) = value.and_then(|value| value.to_str().ok()) else { return Self::DEFAULT_RATE_LIMIT_BACKOFF; }; if let Ok(seconds) = value.trim().parse::() { return Duration::from_secs(seconds); } if let Ok(date) = DateTime::parse_from_rfc2822(value.trim()) { let now = Utc::now(); let date = date.with_timezone(&Utc); if date > now { return (date - now) .to_std() .unwrap_or(Self::DEFAULT_RATE_LIMIT_BACKOFF); } } Self::DEFAULT_RATE_LIMIT_BACKOFF } pub(super) fn rate_limited_error(duration: Duration) -> ProviderError { ProviderError::OAuthTransient(format!( "{RATE_LIMITED_PREFIX} Retrying in about {}s; credentials were preserved.", duration.as_secs().max(1) )) } /// Upstream `OAuthExecutor.scopeRecoveryMessage` (#3672): recommend a /// Claude Code sign-in token, and explain removing an OAuth override /// before switching sources. Never mentions `claude setup-token`, which /// produces a model-request token, not a usage-scope recovery. fn scope_recovery_message() -> &'static str { "Use a Claude Code sign-in token that includes 'user:profile'. To use Web/CLI instead, remove any configured OAuth token override and switch Claude Source." } fn refreshed_oauth_value(credentials: &ClaudeOAuthCredentials) -> serde_json::Value { serde_json::json!({ "accessToken": credentials.access_token, "refreshToken": credentials.refresh_token, "expiresAt": credentials.expires_at.map(|expires_at| expires_at.timestamp_millis()), "scopes": credentials.scopes, "rateLimitTier": credentials.rate_limit_tier, }) } /// Build UsageSnapshot from OAuth response fn build_usage_snapshot( &self, response: &OAuthUsageResponse, credentials: &ClaudeOAuthCredentials, ) -> UsageSnapshot { // Primary: prefer limits[] session over legacy five_hour (mirrors the // weekly lane preferring weekly_all over seven_day). A stale // five_hour.utilization can transiently report 1.0 (100%) right after // a window rollover while the limits[] entry already reflects the // fresh value (#279, same bug class as #210). let primary = super::scoped_weekly::session_window(&response.limits) .or_else(|| { response .five_hour .as_ref() .and_then(|w| Self::to_rate_window(w, Some(300))) }) .unwrap_or_else(RateWindow::no_active_session); let mut usage = UsageSnapshot::new(primary); // Secondary: prefer limits[] weekly_all over legacy seven_day, which // Anthropic can leave stale. if let Some(weekly) = super::scoped_weekly::weekly_all_window(&response.limits).or_else(|| { response .seven_day .as_ref() .and_then(|w| Self::to_rate_window(w, Some(10080))) }) { usage = usage.with_secondary(weekly); } // Model-specific: Opus or Sonnet if let Some(opus) = response .seven_day_opus .as_ref() .and_then(|w| Self::to_rate_window(w, Some(10080))) { usage = usage.with_model_specific(opus); } else if let Some(sonnet) = response .seven_day_sonnet .as_ref() .and_then(|w| Self::to_rate_window(w, Some(10080))) { usage = usage.with_model_specific(sonnet); } // Model-scoped weekly limits first; Daily Routines last (upstream order). usage .extra_rate_windows .extend(super::scoped_weekly::scoped_weekly_windows( &response.limits, )); if let Some(window) = response .seven_day_routines .as_ref() .and_then(|w| Self::to_rate_window(w, Some(10080))) { usage.extra_rate_windows.push(NamedRateWindow::new( "claude-routines", "Daily Routines", window, )); } // Login method from rate limit tier or default if let Some(label) = credentials .rate_limit_tier .as_deref() .and_then(super::claude_plan_label) { usage = usage.with_login_method(label); } else { usage = usage.with_login_method("Claude (OAuth)"); } usage } /// Convert OAuth usage window to RateWindow fn to_rate_window(window: &UsageWindow, window_minutes: Option) -> Option { // `utilization` is already expressed in percent units: `1.0` means 1%, // not 100%. Treating values <= 1 as fractions reported a 1% session as a // fully consumed quota. `scoped_weekly::weekly_all_window` has always // read the sibling `limits[].percent` field this way. let utilization = window.utilization?; let resets_at = window .resets_at .as_ref() .and_then(|s| parse_iso8601_date(s)); let reset_description = resets_at.map(format_reset_date); Some(RateWindow::with_details( utilization, window_minutes, resets_at, reset_description, )) } } impl Default for ClaudeOAuthFetcher { fn default() -> Self { Self::new() } } /// Upstream #4480: a spend-only OAuth account's monthly cap becomes the /// primary meter. Returns the cost to attach, or `None` when not projected. fn apply_spend_only_extra_usage( usage: &mut UsageSnapshot, response: &OAuthUsageResponse, ) -> Option { let extra = response.extra_usage.as_ref()?; if !extra.is_enabled.unwrap_or(false) { return None; } // Extra usage amounts are reported in cents. let currency = extra.currency.clone().unwrap_or_else(|| "USD".to_string()); let mut cost = CostSnapshot::new( extra.used_credits.unwrap_or(0.0) / 100.0, currency, "Monthly", ); if let Some(limit) = extra.monthly_limit { cost = cost.with_limit(limit / 100.0); } spend_window::project_spend_only(usage, &mut cost).then_some(cost) } /// Parse an ISO8601 date string fn parse_iso8601_date(s: &str) -> Option> { // Try parsing with various formats DateTime::parse_from_rfc3339(s) .ok() .map(|dt| dt.with_timezone(&Utc)) .or_else(|| { // Try without timezone chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S%.f") .ok() .map(|ndt| ndt.and_utc()) }) } /// Format a reset date for display fn format_reset_date(date: DateTime) -> String { date.format("%b %-d at %-I:%M%p").to_string() } #[cfg(test)] #[path = "tests.rs"] mod tests; #[cfg(test)] mod reset_opt_in_tests;