Skip to main content

ccwire_oauth/
client.rs

1//! [`Client`] and [`ClientBuilder`].
2
3use 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
22/// The API's base URL.
23pub const DEFAULT_BASE_URL: &str = "https://api.anthropic.com";
24/// The OAuth token endpoint.
25pub const DEFAULT_TOKEN_URL: &str = "https://platform.claude.com/v1/oauth/token";
26/// The `anthropic-beta` value the OAuth endpoints require.
27pub const OAUTH_BETA: &str = "oauth-2025-04-20";
28/// The default request timeout.
29pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(20);
30/// The default `User-Agent`: `ccwire-oauth/<version>`.
31pub const DEFAULT_USER_AGENT: &str = concat!("ccwire-oauth/", env!("CARGO_PKG_VERSION"));
32/// The largest response body read; a larger one is [`Error::TooLarge`].
33pub const MAX_BODY: usize = 1 << 20;
34
35/// A client for the account endpoints.
36///
37/// Every call takes the token it acts with; the client holds none. It is
38/// cheap to clone (the connection pool is shared).
39///
40/// ```no_run
41/// # async fn run() -> Result<(), ccwire_oauth::Error> {
42/// use ccwire_oauth::{Client, Secret};
43///
44/// let client = Client::new()?;
45/// let token = Secret::new(std::env::var("ACCESS_TOKEN").unwrap_or_default());
46/// let usage = client.usage(&token).await?;
47/// if let Some(w) = usage.five_hour {
48///     println!("5h: {:?}% (resets {:?})", w.utilization, w.resets_at);
49/// }
50/// # Ok(()) }
51/// ```
52#[derive(Debug, Clone)]
53pub struct Client {
54    http: reqwest::Client,
55    base_url: Url,
56    token_url: Url,
57}
58
59/// Builds a [`Client`].
60///
61/// URLs must be `https`, except on a loopback host (a local mock server).
62#[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    /// The API's base URL. Default [`DEFAULT_BASE_URL`].
85    #[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    /// The token endpoint's URL. Default [`DEFAULT_TOKEN_URL`].
92    #[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    /// The `User-Agent`. Default [`DEFAULT_USER_AGENT`].
99    #[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    /// The whole-request timeout. Default [`DEFAULT_TIMEOUT`].
106    #[must_use]
107    pub fn timeout(mut self, timeout: Duration) -> Self {
108        self.timeout = timeout;
109        self
110    }
111
112    /// Sends with your own `reqwest::Client` (to share its connection pool,
113    /// proxy or TLS settings) instead of building one.
114    ///
115    /// [`ClientBuilder::user_agent`] and [`ClientBuilder::timeout`] are then
116    /// not applied: set them on your client. Build it with
117    /// `.redirect(reqwest::redirect::Policy::none())`: a client that follows
118    /// redirects would carry the bearer token (and the refresh token's
119    /// request) to wherever the redirect points. This crate can't check
120    /// that for you.
121    #[must_use]
122    pub fn http_client(mut self, http: reqwest::Client) -> Self {
123        self.http = Some(http);
124        self
125    }
126
127    /// The client.
128    ///
129    /// # Errors
130    /// [`Error::Config`]: a URL that doesn't parse or isn't `https` (or
131    /// loopback), or a `User-Agent` that isn't a valid header value.
132    /// [`Error::Transport`]: the TLS backend couldn't start.
133    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            // Never follow a redirect with a bearer token on it.
149            .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    /// A client with the defaults.
179    ///
180    /// # Errors
181    /// See [`ClientBuilder::build`].
182    pub fn new() -> Result<Self, Error> {
183        Self::builder().build()
184    }
185
186    /// A [`ClientBuilder`] with the defaults.
187    pub fn builder() -> ClientBuilder {
188        ClientBuilder::default()
189    }
190
191    /// `GET /api/oauth/usage`: the 5-hour and 7-day windows and extra
192    /// usage.
193    ///
194    /// # Errors
195    /// [`Error`]; a token without the profile scope is
196    /// [`Error::Forbidden`].
197    pub async fn usage(&self, token: &Secret) -> Result<Usage, Error> {
198        self.get_json(token, &["api", "oauth", "usage"]).await
199    }
200
201    /// `GET /api/oauth/profile`: the account and its organization.
202    ///
203    /// # Errors
204    /// [`Error`]; an inference-only token is [`Error::Forbidden`].
205    pub async fn profile(&self, token: &Secret) -> Result<Profile, Error> {
206        self.get_json(token, &["api", "oauth", "profile"]).await
207    }
208
209    /// `POST /api/oauth/validate`: whether the token is good, without
210    /// reading anything. `Ok(())` if it is.
211    ///
212    /// The endpoint accepts tokens with `user:profile` (a subscription
213    /// login) and refuses inference-only ones.
214    ///
215    /// # Errors
216    /// [`Error::Unauthorized`] for a bad, expired or revoked token,
217    /// [`Error::Forbidden`] for one without an accepted scope; [`Error`]
218    /// otherwise.
219    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    /// `GET /api/oauth/claude_cli/roles`: the user's roles in the token's
230    /// organization.
231    ///
232    /// # Errors
233    /// [`Error`].
234    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    /// `GET /api/oauth/organizations/{org}/prepaid/credits`: the prepaid
240    /// credit balance. `org` is [`Organization::uuid`](crate::Organization::uuid).
241    ///
242    /// # Errors
243    /// [`Error::Config`] for an empty, `.` or `..` `org`; [`Error`].
244    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    /// `GET /api/oauth/organizations/{org}/overage_spend_limit`: the extra
257    /// usage limit and what's spent against it. Read-only: this crate never
258    /// changes it.
259    ///
260    /// # Errors
261    /// [`Error::Config`] for an empty, `.` or `..` `org`; [`Error`].
262    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    /// Posts a refresh-token grant to the token endpoint and returns the
275    /// new tokens.
276    ///
277    /// A plain call: it stores nothing and takes no lock. The posted
278    /// refresh token is spent once the server answers, so the caller stores
279    /// the result with a compare-and-swap (see [`TokenSet`]).
280    ///
281    /// The response is read leniently: as long as it carries the tokens,
282    /// they come back, whatever the other fields hold (a missing or odd
283    /// `expires_in` is `None`).
284    ///
285    /// # Don't cancel it
286    ///
287    /// Once the request is sent, the server may already have spent the
288    /// posted refresh token. Dropping this future then (a `select!` that
289    /// picks another branch, a `tokio::time::timeout` shorter than the
290    /// client's own, an aborted task) throws away the new tokens, and the
291    /// login is lost: the old refresh token is dead and the new one was
292    /// never stored. Await it to the end; the client's own timeout
293    /// ([`ClientBuilder::timeout`]) bounds it. If the caller itself may be
294    /// cancelled, use [`Client::refresh_and_store`], which runs the refresh
295    /// and the store in a task of their own.
296    ///
297    /// # Errors
298    /// [`Error::Unauthorized`] with code `invalid_grant` for a revoked or
299    /// already-rotated refresh token (a new login is needed);
300    /// [`Error::Decode`] only for a body without the tokens; [`Error`]
301    /// otherwise.
302    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        // Sized up front so it isn't normally reallocated (which would
310        // leave a copy of the token behind in the freed buffer). The buffer
311        // itself then goes to reqwest, which doesn't zero it.
312        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    /// [`Client::refresh`], then `store` with the new tokens, both in a
332    /// task of their own (`tokio::spawn`): once this is called they run to
333    /// the end even if the returned future is dropped, so a cancelled
334    /// caller can't lose a rotated login.
335    ///
336    /// `store` runs only on success, and gets the [`TokenSet`]; do the
337    /// compare-and-swap there. The returned future gives `store`'s output.
338    /// The task starts when this is called, not when the future is first
339    /// polled.
340    ///
341    /// ```no_run
342    /// # async fn run(client: ccwire_oauth::Client) -> Result<(), ccwire_oauth::Error> {
343    /// use ccwire_oauth::{RefreshRequest, Secret};
344    ///
345    /// let req = RefreshRequest::new(Secret::new("stored-refresh-token"));
346    /// let access = client
347    ///     .refresh_and_store(req, |tokens| async move {
348    ///         // Compare-and-swap tokens.refresh_token into the store here.
349    ///         tokens.access_token
350    ///     })
351    ///     .await?;
352    /// # Ok(()) }
353    /// ```
354    ///
355    /// # Errors
356    /// As [`Client::refresh`]; [`Error::Cancelled`] if the task was
357    /// cancelled (its runtime shut down).
358    ///
359    /// # Panics
360    /// Outside a tokio runtime. A panic in `store` resumes in the caller.
361    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        // `url` drops `.` and `..` segments (and an empty one would
387        // double the slash), so a bad org id could reach another endpoint.
388        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
425/// Sends, reads the body (capped), and turns a non-success status into its
426/// [`Error`].
427async 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
453/// The body, capped at [`MAX_BODY`], in a buffer zeroed when it is dropped
454/// (it may hold tokens). It grows by hand so no unzeroed copy is left
455/// behind; reqwest's own chunks are not zeroed.
456async 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            // The old buffer is zeroed as it drops.
479            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
494/// `Retry-After`: delta-seconds or an HTTP date.
495fn 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}