1use alloc::string::String;
4use alloc::vec::Vec;
5use core::future::Future;
6use core::time::Duration;
7use std::time::SystemTime;
8
9use reqwest::header::{self, HeaderValue};
10use reqwest::{Method, RequestBuilder, Response, StatusCode};
11use serde::de::DeserializeOwned;
12use url::{Host, Url};
13use zeroize::Zeroizing;
14
15use crate::error::{error_fields, scrub_serde, snippet};
16use crate::token::{GrantBody, GrantResponse};
17use crate::{
18 Error, OverageSpendLimit, PrepaidCredits, Profile, RefreshRequest, Roles, Secret, TokenSet,
19 Usage,
20};
21
22pub const DEFAULT_BASE_URL: &str = "https://api.anthropic.com";
24pub const DEFAULT_TOKEN_URL: &str = "https://platform.claude.com/v1/oauth/token";
26pub const OAUTH_BETA: &str = "oauth-2025-04-20";
28pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(20);
30pub const DEFAULT_USER_AGENT: &str = concat!("ccwire-oauth/", env!("CARGO_PKG_VERSION"));
32pub const MAX_BODY: usize = 1 << 20;
34
35#[derive(Debug, Clone)]
53pub struct Client {
54 http: reqwest::Client,
55 base_url: Url,
56 token_url: Url,
57}
58
59#[derive(Debug, Clone)]
63pub struct ClientBuilder {
64 base_url: String,
65 token_url: String,
66 user_agent: String,
67 timeout: Duration,
68 http: Option<reqwest::Client>,
69}
70
71impl Default for ClientBuilder {
72 fn default() -> Self {
73 Self {
74 base_url: DEFAULT_BASE_URL.into(),
75 token_url: DEFAULT_TOKEN_URL.into(),
76 user_agent: DEFAULT_USER_AGENT.into(),
77 timeout: DEFAULT_TIMEOUT,
78 http: None,
79 }
80 }
81}
82
83impl ClientBuilder {
84 #[must_use]
86 pub fn base_url(mut self, url: impl Into<String>) -> Self {
87 self.base_url = url.into();
88 self
89 }
90
91 #[must_use]
93 pub fn token_url(mut self, url: impl Into<String>) -> Self {
94 self.token_url = url.into();
95 self
96 }
97
98 #[must_use]
100 pub fn user_agent(mut self, user_agent: impl Into<String>) -> Self {
101 self.user_agent = user_agent.into();
102 self
103 }
104
105 #[must_use]
107 pub fn timeout(mut self, timeout: Duration) -> Self {
108 self.timeout = timeout;
109 self
110 }
111
112 #[must_use]
122 pub fn http_client(mut self, http: reqwest::Client) -> Self {
123 self.http = Some(http);
124 self
125 }
126
127 pub fn build(self) -> Result<Client, Error> {
134 let base_url = parse_url(&self.base_url, "base URL")?;
135 let token_url = parse_url(&self.token_url, "token URL")?;
136 if let Some(http) = self.http {
137 return Ok(Client {
138 http,
139 base_url,
140 token_url,
141 });
142 }
143 let user_agent = HeaderValue::from_str(&self.user_agent)
144 .map_err(|_| Error::Config("user agent is not a valid header value".into()))?;
145 let http = reqwest::Client::builder()
146 .user_agent(user_agent)
147 .timeout(self.timeout)
148 .redirect(reqwest::redirect::Policy::none())
150 .build()
151 .map_err(|e| Error::Transport(e.without_url()))?;
152 Ok(Client {
153 http,
154 base_url,
155 token_url,
156 })
157 }
158}
159
160fn parse_url(s: &str, what: &str) -> Result<Url, Error> {
161 let url = Url::parse(s).map_err(|e| Error::Config(alloc::format!("{what}: {e}")))?;
162 let loopback = match url.host() {
163 Some(Host::Ipv4(ip)) => ip.is_loopback(),
164 Some(Host::Ipv6(ip)) => ip.is_loopback(),
165 Some(Host::Domain(d)) => d == "localhost",
166 None => false,
167 };
168 match url.scheme() {
169 "https" if url.host().is_some() => Ok(url),
170 "http" if loopback => Ok(url),
171 _ => Err(Error::Config(alloc::format!(
172 "{what} must be https (or http on a loopback host)"
173 ))),
174 }
175}
176
177impl Client {
178 pub fn new() -> Result<Self, Error> {
183 Self::builder().build()
184 }
185
186 pub fn builder() -> ClientBuilder {
188 ClientBuilder::default()
189 }
190
191 pub async fn usage(&self, token: &Secret) -> Result<Usage, Error> {
198 self.get_json(token, &["api", "oauth", "usage"]).await
199 }
200
201 pub async fn profile(&self, token: &Secret) -> Result<Profile, Error> {
206 self.get_json(token, &["api", "oauth", "profile"]).await
207 }
208
209 pub async fn validate(&self, token: &Secret) -> Result<(), Error> {
220 let req = self
221 .request(Method::POST, token, &["api", "oauth", "validate"])?
222 .header(header::CONTENT_TYPE, "application/json")
223 .body("null");
224 let (status, _body) = send(req).await?;
225 debug_assert!(status.is_success());
226 Ok(())
227 }
228
229 pub async fn roles(&self, token: &Secret) -> Result<Roles, Error> {
235 self.get_json(token, &["api", "oauth", "claude_cli", "roles"])
236 .await
237 }
238
239 pub async fn prepaid_credits(
245 &self,
246 token: &Secret,
247 org: &str,
248 ) -> Result<PrepaidCredits, Error> {
249 self.get_json(
250 token,
251 &["api", "oauth", "organizations", org, "prepaid", "credits"],
252 )
253 .await
254 }
255
256 pub async fn overage_spend_limit(
263 &self,
264 token: &Secret,
265 org: &str,
266 ) -> Result<OverageSpendLimit, Error> {
267 self.get_json(
268 token,
269 &["api", "oauth", "organizations", org, "overage_spend_limit"],
270 )
271 .await
272 }
273
274 pub async fn refresh(&self, req: &RefreshRequest) -> Result<TokenSet, Error> {
303 let grant = GrantBody {
304 grant_type: "refresh_token",
305 refresh_token: req.refresh_token.expose(),
306 client_id: &req.client_id,
307 scope: req.scope_string(),
308 };
309 let mut body = Vec::with_capacity(
313 128 + grant.refresh_token.len() + grant.client_id.len() + grant.scope.len(),
314 );
315 serde_json::to_writer(&mut body, &grant).map_err(|e| Error::Config(scrub_serde(&e)))?;
316 let http = self
317 .http
318 .post(self.token_url.clone())
319 .header(header::CONTENT_TYPE, "application/json")
320 .header(header::ACCEPT, "application/json")
321 .body(body);
322 let (status, bytes) = send(http).await?;
323 let resp: GrantResponse = decode(status, &bytes)?;
324 Ok(TokenSet::from_response(
325 resp,
326 &req.refresh_token,
327 SystemTime::now(),
328 ))
329 }
330
331 pub fn refresh_and_store<F, Fut, R>(
362 &self,
363 req: RefreshRequest,
364 store: F,
365 ) -> impl Future<Output = Result<R, Error>> + Send + 'static
366 where
367 F: FnOnce(TokenSet) -> Fut + Send + 'static,
368 Fut: Future<Output = R> + Send + 'static,
369 R: Send + 'static,
370 {
371 let client = self.clone();
372 let task = tokio::spawn(async move {
373 let tokens = client.refresh(&req).await?;
374 Ok(store(tokens).await)
375 });
376 async move {
377 match task.await {
378 Ok(r) => r,
379 Err(e) if e.is_panic() => std::panic::resume_unwind(e.into_panic()),
380 Err(_) => Err(Error::Cancelled),
381 }
382 }
383 }
384
385 fn url(&self, segments: &[&str]) -> Result<Url, Error> {
386 if segments.iter().any(|s| matches!(*s, "" | "." | "..")) {
389 return Err(Error::Config(
390 "a path segment (an org id) is empty, `.` or `..`".into(),
391 ));
392 }
393 let mut url = self.base_url.clone();
394 url.path_segments_mut()
395 .map_err(|()| Error::Config("base URL can't take a path".into()))?
396 .pop_if_empty()
397 .extend(segments);
398 Ok(url)
399 }
400
401 fn request(
402 &self,
403 method: Method,
404 token: &Secret,
405 segments: &[&str],
406 ) -> Result<RequestBuilder, Error> {
407 Ok(self
408 .http
409 .request(method, self.url(segments)?)
410 .bearer_auth(token.expose())
411 .header("anthropic-beta", OAUTH_BETA)
412 .header(header::ACCEPT, "application/json"))
413 }
414
415 async fn get_json<T: DeserializeOwned>(
416 &self,
417 token: &Secret,
418 segments: &[&str],
419 ) -> Result<T, Error> {
420 let (status, bytes) = send(self.request(Method::GET, token, segments)?).await?;
421 decode(status, &bytes)
422 }
423}
424
425async fn send(req: RequestBuilder) -> Result<(StatusCode, Zeroizing<Vec<u8>>), Error> {
428 let resp = req
429 .send()
430 .await
431 .map_err(|e| Error::Transport(e.without_url()))?;
432 let status = resp.status();
433 let retry_after = retry_after(&resp);
434 let body = read_body(resp).await?;
435 if status.is_success() {
436 return Ok((status, body));
437 }
438 let (code, message) = error_fields(&body);
439 let s = status.as_u16();
440 Err(match s {
441 401 => Error::Unauthorized { status: s, code },
442 403 => Error::Forbidden { status: s, code },
443 400 if code.as_deref() == Some("invalid_grant") => Error::Unauthorized { status: s, code },
444 429 => Error::RateLimited { retry_after },
445 _ => Error::Http {
446 status: s,
447 code,
448 message,
449 },
450 })
451}
452
453async fn read_body(mut resp: Response) -> Result<Zeroizing<Vec<u8>>, Error> {
457 let declared = resp.content_length().map(usize::try_from);
458 let cap = match declared {
459 Some(Ok(n)) if n <= MAX_BODY => n,
460 Some(_) => return Err(Error::TooLarge { limit: MAX_BODY }),
461 None => 0,
462 };
463 let mut body = Zeroizing::new(Vec::with_capacity(cap));
464 while let Some(chunk) = resp
465 .chunk()
466 .await
467 .map_err(|e| Error::Transport(e.without_url()))?
468 {
469 let len = body.len() + chunk.len();
470 if len > MAX_BODY {
471 return Err(Error::TooLarge { limit: MAX_BODY });
472 }
473 if len > body.capacity() {
474 let mut bigger = Zeroizing::new(Vec::with_capacity(
475 len.max(body.capacity() * 2).min(MAX_BODY),
476 ));
477 bigger.extend_from_slice(&body);
478 body = bigger;
480 }
481 body.extend_from_slice(&chunk);
482 }
483 Ok(body)
484}
485
486fn decode<T: DeserializeOwned>(status: StatusCode, body: &[u8]) -> Result<T, Error> {
487 serde_json::from_slice(body).map_err(|e| Error::Decode {
488 status: status.as_u16(),
489 message: scrub_serde(&e),
490 snippet: snippet(body),
491 })
492}
493
494fn retry_after(resp: &Response) -> Option<Duration> {
496 let v = resp
497 .headers()
498 .get(header::RETRY_AFTER)?
499 .to_str()
500 .ok()?
501 .trim();
502 if let Ok(secs) = v.parse::<u64>() {
503 return Some(Duration::from_secs(secs));
504 }
505 let at = httpdate::parse_http_date(v).ok()?;
506 Some(
507 at.duration_since(SystemTime::now())
508 .unwrap_or(Duration::ZERO),
509 )
510}
511
512#[cfg(test)]
513mod tests {
514 use super::*;
515
516 #[test]
517 fn urls_must_be_https_or_loopback() {
518 assert!(parse_url("https://api.example.com", "u").is_ok());
519 assert!(parse_url("http://127.0.0.1:8080", "u").is_ok());
520 assert!(parse_url("http://localhost:1", "u").is_ok());
521 assert!(parse_url("http://[::1]:1", "u").is_ok());
522 assert!(parse_url("http://api.example.com", "u").is_err());
523 assert!(parse_url("ftp://127.0.0.1", "u").is_err());
524 assert!(parse_url("not a url", "u").is_err());
525 }
526
527 #[test]
528 fn paths_are_joined_and_escaped() {
529 let c = Client::builder()
530 .base_url("https://api.example.com/prefix/")
531 .build()
532 .unwrap();
533 assert_eq!(
534 c.url(&["api", "oauth", "organizations", "a/b?c", "prepaid"])
535 .unwrap()
536 .as_str(),
537 "https://api.example.com/prefix/api/oauth/organizations/a%2Fb%3Fc/prepaid"
538 );
539 }
540
541 #[test]
542 fn dot_segments_are_refused() {
543 let c = Client::new().unwrap();
544 for org in ["", ".", ".."] {
545 let e = c
546 .url(&["api", "oauth", "organizations", org, "prepaid"])
547 .unwrap_err();
548 assert!(matches!(e, Error::Config(_)), "{org}: {e:?}");
549 }
550 assert!(c.url(&["api", "...", "a.b"]).is_ok());
551 }
552
553 #[test]
554 fn default_user_agent() {
555 assert!(DEFAULT_USER_AGENT.starts_with("ccwire-oauth/"));
556 assert!(Client::builder().user_agent("bad\nua").build().is_err());
557 }
558}