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