This adds an end-to-end OAuth PKCE login/logout path to the Rust CLI, persists OAuth credentials under the Claude config home, and teaches the API client to use persisted bearer credentials with refresh support when env-based API credentials are absent. Constraint: Reuse existing runtime OAuth primitives and keep browser/callback orchestration in the CLI Constraint: Preserve auth precedence as API key, then auth-token env, then persisted OAuth credentials Rejected: Put browser launch and token exchange entirely in runtime | caused boundary creep across shared crates Rejected: Duplicate credential parsing in CLI and api | increased drift and refresh inconsistency Confidence: medium Scope-risk: moderate Reversibility: clean Directive: Keep logout non-destructive to unrelated credentials.json fields and do not silently fall back to stale expired tokens Tested: cargo fmt; cargo clippy --workspace --all-targets -- -D warnings; cargo test Not-tested: Manual live Anthropic OAuth browser flow against real authorize/token endpoints
135 lines
3.9 KiB
Rust
135 lines
3.9 KiB
Rust
use std::env::VarError;
|
|
use std::fmt::{Display, Formatter};
|
|
use std::time::Duration;
|
|
|
|
#[derive(Debug)]
|
|
pub enum ApiError {
|
|
MissingApiKey,
|
|
ExpiredOAuthToken,
|
|
Auth(String),
|
|
InvalidApiKeyEnv(VarError),
|
|
Http(reqwest::Error),
|
|
Io(std::io::Error),
|
|
Json(serde_json::Error),
|
|
Api {
|
|
status: reqwest::StatusCode,
|
|
error_type: Option<String>,
|
|
message: Option<String>,
|
|
body: String,
|
|
retryable: bool,
|
|
},
|
|
RetriesExhausted {
|
|
attempts: u32,
|
|
last_error: Box<ApiError>,
|
|
},
|
|
InvalidSseFrame(&'static str),
|
|
BackoffOverflow {
|
|
attempt: u32,
|
|
base_delay: Duration,
|
|
},
|
|
}
|
|
|
|
impl ApiError {
|
|
#[must_use]
|
|
pub fn is_retryable(&self) -> bool {
|
|
match self {
|
|
Self::Http(error) => error.is_connect() || error.is_timeout() || error.is_request(),
|
|
Self::Api { retryable, .. } => *retryable,
|
|
Self::RetriesExhausted { last_error, .. } => last_error.is_retryable(),
|
|
Self::MissingApiKey
|
|
| Self::ExpiredOAuthToken
|
|
| Self::Auth(_)
|
|
| Self::InvalidApiKeyEnv(_)
|
|
| Self::Io(_)
|
|
| Self::Json(_)
|
|
| Self::InvalidSseFrame(_)
|
|
| Self::BackoffOverflow { .. } => false,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Display for ApiError {
|
|
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
Self::MissingApiKey => {
|
|
write!(
|
|
f,
|
|
"ANTHROPIC_AUTH_TOKEN or ANTHROPIC_API_KEY is not set; export one before calling the Anthropic API"
|
|
)
|
|
}
|
|
Self::ExpiredOAuthToken => {
|
|
write!(
|
|
f,
|
|
"saved OAuth token is expired and no refresh token is available"
|
|
)
|
|
}
|
|
Self::Auth(message) => write!(f, "auth error: {message}"),
|
|
Self::InvalidApiKeyEnv(error) => {
|
|
write!(
|
|
f,
|
|
"failed to read ANTHROPIC_AUTH_TOKEN / ANTHROPIC_API_KEY: {error}"
|
|
)
|
|
}
|
|
Self::Http(error) => write!(f, "http error: {error}"),
|
|
Self::Io(error) => write!(f, "io error: {error}"),
|
|
Self::Json(error) => write!(f, "json error: {error}"),
|
|
Self::Api {
|
|
status,
|
|
error_type,
|
|
message,
|
|
body,
|
|
..
|
|
} => match (error_type, message) {
|
|
(Some(error_type), Some(message)) => {
|
|
write!(
|
|
f,
|
|
"anthropic api returned {status} ({error_type}): {message}"
|
|
)
|
|
}
|
|
_ => write!(f, "anthropic api returned {status}: {body}"),
|
|
},
|
|
Self::RetriesExhausted {
|
|
attempts,
|
|
last_error,
|
|
} => write!(
|
|
f,
|
|
"anthropic api failed after {attempts} attempts: {last_error}"
|
|
),
|
|
Self::InvalidSseFrame(message) => write!(f, "invalid sse frame: {message}"),
|
|
Self::BackoffOverflow {
|
|
attempt,
|
|
base_delay,
|
|
} => write!(
|
|
f,
|
|
"retry backoff overflowed on attempt {attempt} with base delay {base_delay:?}"
|
|
),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for ApiError {}
|
|
|
|
impl From<reqwest::Error> for ApiError {
|
|
fn from(value: reqwest::Error) -> Self {
|
|
Self::Http(value)
|
|
}
|
|
}
|
|
|
|
impl From<std::io::Error> for ApiError {
|
|
fn from(value: std::io::Error) -> Self {
|
|
Self::Io(value)
|
|
}
|
|
}
|
|
|
|
impl From<serde_json::Error> for ApiError {
|
|
fn from(value: serde_json::Error) -> Self {
|
|
Self::Json(value)
|
|
}
|
|
}
|
|
|
|
impl From<VarError> for ApiError {
|
|
fn from(value: VarError) -> Self {
|
|
Self::InvalidApiKeyEnv(value)
|
|
}
|
|
}
|