Skip to main content

pitboard_core/
api.rs

1//! What pitboard asks of Anthropic: who a login belongs to and how much it has left, both
2//! read with an access token, and fresh tokens for a parked login.
3//!
4//! Renewing is only ever done for a parked login, which pitboard alone holds. The login
5//! signed in is Claude Code's to renew: two holders renewing one refresh chain would break
6//! it for both.
7
8#[cfg(any(test, feature = "test-support"))]
9pub mod scripted;
10
11use crate::context::Context;
12use crate::usage::{self, Snapshot};
13use serde_json::Value;
14use std::sync::OnceLock;
15use std::time::Duration;
16use ureq::Agent;
17use ureq::tls::{RootCerts, TlsConfig, TlsProvider};
18
19const BASE: &str = "https://api.anthropic.com";
20const AUTH_BASE: &str = "https://platform.claude.com";
21
22/// Claude Code's own OAuth client, which every login it stores was issued to.
23const CLIENT_ID: &str = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
24
25/// Where requests go instead, for tests. Nothing else may redirect them, because an address
26/// that answers "this token belongs to account X" decides which account a credential is filed
27/// under. Only loopback is accepted, so a token or an answer never leaves this machine.
28pub(crate) fn test_base(ctx: &Context) -> Option<String> {
29    ctx.api_base.clone().filter(|url| is_loopback(url))
30}
31
32fn base(ctx: &Context) -> String {
33    test_base(ctx).unwrap_or_else(|| BASE.to_string())
34}
35
36fn auth_base(ctx: &Context) -> String {
37    test_base(ctx).unwrap_or_else(|| AUTH_BASE.to_string())
38}
39
40/// Judged on the parsed host, never on a prefix: `http://127.0.0.1:@elsewhere/` begins like
41/// loopback and is not. A name is refused too, since the hosts file decides where it points.
42fn is_loopback(url: &str) -> bool {
43    let Ok(uri) = url.parse::<ureq::http::Uri>() else {
44        return false;
45    };
46    uri.scheme_str() == Some("http")
47        && uri.host().is_some_and(|host| {
48            host.trim_start_matches('[')
49                .trim_end_matches(']')
50                .parse::<std::net::IpAddr>()
51                .is_ok_and(|ip| ip.is_loopback())
52        })
53}
54const TIMEOUT: Duration = Duration::from_secs(5);
55
56/// Who a token belongs to, as the server sees it. Unlike a refresh-token fingerprint, this
57/// does not change when Claude Code rotates the token, and unlike Claude Code's config, it
58/// cannot lag behind the credential.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct Owner {
61    pub account_uuid: String,
62    pub email: String,
63    pub organization_uuid: String,
64}
65
66#[derive(Debug, thiserror::Error)]
67#[non_exhaustive]
68pub enum ApiError {
69    /// The access token has expired or been revoked.
70    #[error("the session has expired")]
71    Unauthorized,
72    /// `retry_after` is what Anthropic said to wait, in seconds, where it said anything.
73    #[error("Anthropic is rate limiting this request")]
74    RateLimited { retry_after: Option<i64> },
75    #[error("could not reach Anthropic: {0}")]
76    Network(String),
77    #[error("Anthropic answered {status}")]
78    Unexpected { status: u16 },
79    #[error("Anthropic's answer was not understood: {0}")]
80    Malformed(String),
81    /// The refresh token was refused for good: revoked, or already used elsewhere.
82    #[error("Anthropic no longer accepts this login")]
83    InvalidGrant,
84}
85
86/// Fresh tokens for a parked login, as the token endpoint returns them.
87#[derive(Debug, Clone, PartialEq)]
88pub struct Renewed {
89    pub access_token: String,
90    /// `None` when the server keeps the refresh token it was given.
91    pub refresh_token: Option<String>,
92    pub expires_in: i64,
93    pub refresh_token_expires_in: Option<i64>,
94    pub scopes: Option<Vec<String>>,
95    /// When the server said it was, from the response's `Date` header, in epoch seconds.
96    ///
97    /// The lifetimes above are relative, so whatever they are added to decides when the
98    /// login expires. Adding them to this machine's clock makes a wrong clock a wrong
99    /// expiry: running ahead, a freshly renewed park reads as lapsed and every status
100    /// renews it again, rotating the refresh chain on a loop; running behind, a lapsed
101    /// park looks restorable and a switch installs a login that cannot work. Anchored to
102    /// the answer's own clock, neither happens.
103    ///
104    /// `None` where the server sent no `Date`, and then the local clock is all there is.
105    pub at: Option<i64>,
106}
107
108/// What pitboard asks Anthropic, as a seam.
109///
110/// The loopback server the integration tests run answers requests, which proves the
111/// parsing and the wiring. What it cannot produce on demand is the half that decides
112/// behaviour: a request that times out, a 429, a refresh token Anthropic has stopped
113/// accepting. Those are the answers the engine has to be right about.
114pub(crate) trait Api: Send + Sync + std::fmt::Debug {
115    fn owner(&self, ctx: &Context, access_token: &str) -> Result<Owner, ApiError>;
116    fn usage(&self, ctx: &Context, access_token: &str) -> Result<Snapshot, ApiError>;
117    fn renew(
118        &self,
119        ctx: &Context,
120        refresh_token: &str,
121        scopes: &[String],
122        client_id: Option<&str>,
123    ) -> Result<Renewed, ApiError>;
124}
125
126/// Anthropic, over the network. What every real context uses.
127#[derive(Debug, Clone, Copy)]
128pub(crate) struct Anthropic;
129
130impl Api for Anthropic {
131    fn owner(&self, ctx: &Context, access_token: &str) -> Result<Owner, ApiError> {
132        ask_owner(ctx, access_token)
133    }
134
135    fn usage(&self, ctx: &Context, access_token: &str) -> Result<Snapshot, ApiError> {
136        ask_usage(ctx, access_token)
137    }
138
139    fn renew(
140        &self,
141        ctx: &Context,
142        refresh_token: &str,
143        scopes: &[String],
144        client_id: Option<&str>,
145    ) -> Result<Renewed, ApiError> {
146        ask_renew(ctx, refresh_token, scopes, client_id)
147    }
148}
149
150/// Ask Anthropic through this context.
151pub fn owner(ctx: &Context, access_token: &str) -> Result<Owner, ApiError> {
152    ctx.api().owner(ctx, access_token)
153}
154
155pub fn usage(ctx: &Context, access_token: &str) -> Result<Snapshot, ApiError> {
156    ctx.api().usage(ctx, access_token)
157}
158
159pub fn renew(
160    ctx: &Context,
161    refresh_token: &str,
162    scopes: &[String],
163    client_id: Option<&str>,
164) -> Result<Renewed, ApiError> {
165    ctx.api().renew(ctx, refresh_token, scopes, client_id)
166}
167
168pub(crate) fn agent() -> &'static Agent {
169    static AGENT: OnceLock<Agent> = OnceLock::new();
170    AGENT.get_or_init(|| {
171        // rustls's documented way to choose a crypto provider. Returns Err only when one is
172        // already installed, which is exactly the state wanted.
173        let _ = rustls::crypto::ring::default_provider().install_default();
174        Agent::config_builder()
175            .tls_config(
176                TlsConfig::builder()
177                    .provider(TlsProvider::Rustls)
178                    .root_certs(RootCerts::PlatformVerifier)
179                    .build(),
180            )
181            .timeout_global(Some(TIMEOUT))
182            .http_status_as_error(false)
183            .user_agent(concat!("pitboard/", env!("CARGO_PKG_VERSION")))
184            .build()
185            .new_agent()
186    })
187}
188
189fn get(ctx: &Context, path: &str, access_token: &str) -> Result<Value, ApiError> {
190    let mut response = agent()
191        .get(format!("{}{path}", base(ctx)))
192        .header("Authorization", format!("Bearer {access_token}"))
193        .header("anthropic-beta", "oauth-2025-04-20")
194        .call()
195        .map_err(|e| ApiError::Network(e.to_string()))?;
196    match response.status().as_u16() {
197        200 => {
198            let body = response
199                .body_mut()
200                .read_to_string()
201                .map_err(|e| ApiError::Network(e.to_string()))?;
202            serde_json::from_str(&body).map_err(|e| ApiError::Malformed(e.to_string()))
203        }
204        401 | 403 => Err(ApiError::Unauthorized),
205        429 => Err(ApiError::RateLimited {
206            retry_after: retry_after(response.headers()),
207        }),
208        status => Err(ApiError::Unexpected { status }),
209    }
210}
211
212/// How long Anthropic asked us to wait, from `Retry-After`. Only the seconds form is read:
213/// the date form is allowed by the standard and has not been seen from this endpoint, and
214/// misreading one would be worse than not reading it.
215pub(crate) fn retry_after(headers: &ureq::http::HeaderMap) -> Option<i64> {
216    headers
217        .get("retry-after")?
218        .to_str()
219        .ok()?
220        .trim()
221        .parse::<i64>()
222        .ok()
223        .filter(|seconds| *seconds > 0)
224}
225
226/// The request Claude Code makes to renew its own login, for a parked one. The scopes asked
227/// for are the ones the login already has, so the answer can never be `invalid_scope`.
228///
229/// A login issued to another client carries its own `clientId`, and Claude Code renews it
230/// as that client. Renewing it as the first-party one would be a different login.
231fn ask_renew(
232    ctx: &Context,
233    refresh_token: &str,
234    scopes: &[String],
235    client_id: Option<&str>,
236) -> Result<Renewed, ApiError> {
237    let body = serde_json::json!({
238        "grant_type": "refresh_token",
239        "refresh_token": refresh_token,
240        "client_id": client_id.unwrap_or(CLIENT_ID),
241        "scope": scopes.join(" "),
242    });
243    let mut response = agent()
244        .post(format!("{}/v1/oauth/token", auth_base(ctx)))
245        .header("Content-Type", "application/json")
246        .send(body.to_string())
247        .map_err(|e| ApiError::Network(e.to_string()))?;
248    let status = response.status().as_u16();
249    let at = server_time(response.headers());
250    let rate_limit_wait = retry_after(response.headers());
251    let text = response
252        .body_mut()
253        .read_to_string()
254        .map_err(|e| ApiError::Network(e.to_string()))?;
255    match status {
256        200 => {
257            let body: Value =
258                serde_json::from_str(&text).map_err(|e| ApiError::Malformed(e.to_string()))?;
259            parse_renewed(&body, at)
260        }
261        429 => Err(ApiError::RateLimited {
262            retry_after: rate_limit_wait,
263        }),
264        400..=499 if text.contains("invalid_grant") => Err(ApiError::InvalidGrant),
265        status => Err(ApiError::Unexpected { status }),
266    }
267}
268
269/// The instant a response says it was sent, from its `Date` header.
270///
271/// Measured against api.anthropic.com over eight requests: this machine sat within 0.75
272/// seconds of the server, and `Date` has a granularity of one second, so the whole spread
273/// was inside the noise. That is why there is no skew estimate here and no median of
274/// several observations: on a machine whose clock works there is nothing to correct, and
275/// on a machine whose clock does not, reading the time off the answer is the correction.
276pub(crate) fn server_time(headers: &ureq::http::HeaderMap) -> Option<i64> {
277    let raw = headers.get("date")?.to_str().ok()?;
278    // RFC 9110's preferred form, which is what every answer measured used:
279    // `Mon, 22 Sep 2026 12:34:56 GMT`.
280    jiff::civil::DateTime::strptime("%a, %d %b %Y %H:%M:%S GMT", raw)
281        .ok()?
282        .to_zoned(jiff::tz::TimeZone::UTC)
283        .ok()
284        .map(|z| z.timestamp().as_second())
285}
286
287fn parse_renewed(body: &Value, at: Option<i64>) -> Result<Renewed, ApiError> {
288    let text = |key: &str| body.get(key).and_then(Value::as_str).map(str::to_owned);
289    Ok(Renewed {
290        access_token: text("access_token")
291            .ok_or_else(|| ApiError::Malformed("no access_token".into()))?,
292        refresh_token: text("refresh_token"),
293        expires_in: body
294            .get("expires_in")
295            .and_then(Value::as_i64)
296            .ok_or_else(|| ApiError::Malformed("no expires_in".into()))?,
297        refresh_token_expires_in: body.get("refresh_token_expires_in").and_then(Value::as_i64),
298        scopes: text("scope").map(|s| s.split_whitespace().map(str::to_owned).collect()),
299        at,
300    })
301}
302
303fn ask_owner(ctx: &Context, access_token: &str) -> Result<Owner, ApiError> {
304    let body = get(ctx, "/api/oauth/profile", access_token)?;
305    parse_owner(&body)
306}
307
308fn ask_usage(ctx: &Context, access_token: &str) -> Result<Snapshot, ApiError> {
309    let body = get(ctx, "/api/oauth/usage", access_token)?;
310    Ok(usage::from_usage_object(&body, ctx.now()))
311}
312
313fn parse_owner(body: &Value) -> Result<Owner, ApiError> {
314    let text = |path: &[&str]| -> Result<String, ApiError> {
315        path.iter()
316            .try_fold(body, |v, key| v.get(key))
317            .and_then(Value::as_str)
318            .map(str::to_owned)
319            .ok_or_else(|| ApiError::Malformed(format!("no {}", path.join("."))))
320    };
321    Ok(Owner {
322        account_uuid: text(&["account", "uuid"])?,
323        email: text(&["account", "email"])?,
324        organization_uuid: text(&["organization", "uuid"])?,
325    })
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331    use serde_json::json;
332
333    #[test]
334    fn a_renewal_answer_is_read_as_claude_code_reads_it() {
335        let full = json!({
336            "access_token": "a2", "refresh_token": "r2", "expires_in": 28_800,
337            "refresh_token_expires_in": 2_592_000, "scope": "user:inference user:profile",
338            "token_type": "Bearer"
339        });
340        assert_eq!(
341            parse_renewed(&full, Some(1_790_000_000)).unwrap(),
342            Renewed {
343                access_token: "a2".into(),
344                refresh_token: Some("r2".into()),
345                expires_in: 28_800,
346                refresh_token_expires_in: Some(2_592_000),
347                scopes: Some(vec!["user:inference".into(), "user:profile".into()]),
348                at: Some(1_790_000_000),
349            }
350        );
351        let kept = parse_renewed(&json!({"access_token": "a2", "expires_in": 60}), None).unwrap();
352        assert_eq!(
353            kept.refresh_token, None,
354            "no refresh_token means the old one stays"
355        );
356        assert_eq!(kept.at, None, "no Date header leaves the local clock to it");
357        assert!(parse_renewed(&json!({"expires_in": 60}), None).is_err());
358    }
359
360    /// The header every answer measured carried, in RFC 9110's preferred form.
361    #[test]
362    fn the_instant_an_answer_says_it_was_sent_is_read_off_it() {
363        let mut headers = ureq::http::HeaderMap::new();
364        headers.insert(
365            "date",
366            "Tue, 22 Sep 2026 12:34:56 GMT".parse().expect("valid"),
367        );
368        assert_eq!(server_time(&headers), Some(1_790_080_496));
369
370        headers.insert("date", "whenever".parse().expect("valid"));
371        assert_eq!(
372            server_time(&headers),
373            None,
374            "an answer pitboard cannot read the time off is not a reason to guess one"
375        );
376        assert_eq!(server_time(&ureq::http::HeaderMap::new()), None);
377    }
378
379    /// Field names copied from a live response on 2026-09-21.
380    #[test]
381    fn reads_the_owner_from_the_shape_the_profile_endpoint_returns() {
382        let body = json!({
383            "account": {
384                "uuid": "acc", "email": "a@b.c", "display_name": "A", "full_name": "A",
385                "created_at": "2025-10-15T02:36:35Z", "has_claude_max": true, "has_claude_pro": false
386            },
387            "organization": {
388                "uuid": "org", "name": "Org", "organization_type": "claude_max",
389                "rate_limit_tier": "default_claude_max_20x", "billing_type": "stripe_subscription"
390            },
391            "application": {}, "enabled_plugins": []
392        });
393        assert_eq!(
394            parse_owner(&body).unwrap(),
395            Owner {
396                account_uuid: "acc".into(),
397                email: "a@b.c".into(),
398                organization_uuid: "org".into(),
399            }
400        );
401    }
402
403    #[test]
404    fn only_a_loopback_address_can_redirect_requests() {
405        for allowed in ["http://127.0.0.1:8080", "http://[::1]:9"] {
406            assert!(is_loopback(allowed), "{allowed}");
407        }
408        for refused in [
409            "https://evil.example.com",
410            "http://127.0.0.1.evil.example.com:80",
411            "http://localhost.evil.example.com:80",
412            "http://127.0.0.1:@evil.example.com/",
413            "http://127.0.0.1:8080@evil.example.com/",
414            "http://localhost:1",
415            "https://127.0.0.1:443",
416            "http://10.0.0.1:80",
417            "",
418        ] {
419            assert!(
420                !is_loopback(refused),
421                "{refused} must not be able to answer identity"
422            );
423        }
424    }
425
426    #[test]
427    fn a_profile_missing_the_account_is_malformed_rather_than_guessed() {
428        assert!(matches!(
429            parse_owner(&json!({"organization": {"uuid": "org"}})),
430            Err(ApiError::Malformed(_))
431        ));
432    }
433}