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