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
8use crate::context::Context;
9use crate::usage::{self, Snapshot};
10use serde_json::Value;
11use std::sync::OnceLock;
12use std::time::Duration;
13use ureq::Agent;
14use ureq::tls::{RootCerts, TlsConfig, TlsProvider};
15
16const BASE: &str = "https://api.anthropic.com";
17const AUTH_BASE: &str = "https://platform.claude.com";
18
19/// Claude Code's own OAuth client, which every login it stores was issued to.
20const CLIENT_ID: &str = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
21
22/// Where requests go instead, for tests. Nothing else may redirect them, because an address
23/// that answers "this token belongs to account X" decides which account a credential is filed
24/// under. Only loopback is accepted, so a token or an answer never leaves this machine.
25fn test_base(ctx: &Context) -> Option<String> {
26    ctx.api_base.clone().filter(|url| is_loopback(url))
27}
28
29fn base(ctx: &Context) -> String {
30    test_base(ctx).unwrap_or_else(|| BASE.to_string())
31}
32
33fn auth_base(ctx: &Context) -> String {
34    test_base(ctx).unwrap_or_else(|| AUTH_BASE.to_string())
35}
36
37/// Judged on the parsed host, never on a prefix: `http://127.0.0.1:@elsewhere/` begins like
38/// loopback and is not. A name is refused too, since the hosts file decides where it points.
39fn is_loopback(url: &str) -> bool {
40    let Ok(uri) = url.parse::<ureq::http::Uri>() else {
41        return false;
42    };
43    uri.scheme_str() == Some("http")
44        && uri.host().is_some_and(|host| {
45            host.trim_start_matches('[')
46                .trim_end_matches(']')
47                .parse::<std::net::IpAddr>()
48                .is_ok_and(|ip| ip.is_loopback())
49        })
50}
51const TIMEOUT: Duration = Duration::from_secs(5);
52
53/// Who a token belongs to, as the server sees it. Unlike a refresh-token fingerprint, this
54/// does not change when Claude Code rotates the token, and unlike Claude Code's config, it
55/// cannot lag behind the credential.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct Owner {
58    pub account_uuid: String,
59    pub email: String,
60    pub organization_uuid: String,
61}
62
63#[derive(Debug, thiserror::Error)]
64pub enum ApiError {
65    /// The access token has expired or been revoked.
66    #[error("the session has expired")]
67    Unauthorized,
68    #[error("Anthropic is rate limiting this request")]
69    RateLimited,
70    #[error("could not reach Anthropic: {0}")]
71    Network(String),
72    #[error("Anthropic answered {status}")]
73    Unexpected { status: u16 },
74    #[error("Anthropic's answer was not understood: {0}")]
75    Malformed(String),
76    /// The refresh token was refused for good: revoked, or already used elsewhere.
77    #[error("Anthropic no longer accepts this login")]
78    InvalidGrant,
79}
80
81/// Fresh tokens for a parked login, as the token endpoint returns them.
82#[derive(Debug, PartialEq)]
83pub struct Renewed {
84    pub access_token: String,
85    /// `None` when the server keeps the refresh token it was given.
86    pub refresh_token: Option<String>,
87    pub expires_in: i64,
88    pub refresh_token_expires_in: Option<i64>,
89    pub scopes: Option<Vec<String>>,
90}
91
92fn agent() -> &'static Agent {
93    static AGENT: OnceLock<Agent> = OnceLock::new();
94    AGENT.get_or_init(|| {
95        // rustls's documented way to choose a crypto provider. Returns Err only when one is
96        // already installed, which is exactly the state wanted.
97        let _ = rustls::crypto::ring::default_provider().install_default();
98        Agent::config_builder()
99            .tls_config(
100                TlsConfig::builder()
101                    .provider(TlsProvider::Rustls)
102                    .root_certs(RootCerts::PlatformVerifier)
103                    .build(),
104            )
105            .timeout_global(Some(TIMEOUT))
106            .http_status_as_error(false)
107            .user_agent(concat!("pitboard/", env!("CARGO_PKG_VERSION")))
108            .build()
109            .new_agent()
110    })
111}
112
113fn get(ctx: &Context, path: &str, access_token: &str) -> Result<Value, ApiError> {
114    let mut response = agent()
115        .get(format!("{}{path}", base(ctx)))
116        .header("Authorization", format!("Bearer {access_token}"))
117        .header("anthropic-beta", "oauth-2025-04-20")
118        .call()
119        .map_err(|e| ApiError::Network(e.to_string()))?;
120    match response.status().as_u16() {
121        200 => {
122            let body = response
123                .body_mut()
124                .read_to_string()
125                .map_err(|e| ApiError::Network(e.to_string()))?;
126            serde_json::from_str(&body).map_err(|e| ApiError::Malformed(e.to_string()))
127        }
128        401 | 403 => Err(ApiError::Unauthorized),
129        429 => Err(ApiError::RateLimited),
130        status => Err(ApiError::Unexpected { status }),
131    }
132}
133
134/// The request Claude Code makes to renew its own login, for a parked one. The scopes asked
135/// for are the ones the login already has, so the answer can never be `invalid_scope`.
136pub fn renew(ctx: &Context, refresh_token: &str, scopes: &[String]) -> Result<Renewed, ApiError> {
137    let body = serde_json::json!({
138        "grant_type": "refresh_token",
139        "refresh_token": refresh_token,
140        "client_id": CLIENT_ID,
141        "scope": scopes.join(" "),
142    });
143    let mut response = agent()
144        .post(format!("{}/v1/oauth/token", auth_base(ctx)))
145        .header("Content-Type", "application/json")
146        .send(body.to_string())
147        .map_err(|e| ApiError::Network(e.to_string()))?;
148    let status = response.status().as_u16();
149    let text = response
150        .body_mut()
151        .read_to_string()
152        .map_err(|e| ApiError::Network(e.to_string()))?;
153    match status {
154        200 => {
155            let body: Value =
156                serde_json::from_str(&text).map_err(|e| ApiError::Malformed(e.to_string()))?;
157            parse_renewed(&body)
158        }
159        429 => Err(ApiError::RateLimited),
160        400..=499 if text.contains("invalid_grant") => Err(ApiError::InvalidGrant),
161        status => Err(ApiError::Unexpected { status }),
162    }
163}
164
165fn parse_renewed(body: &Value) -> Result<Renewed, ApiError> {
166    let text = |key: &str| body.get(key).and_then(Value::as_str).map(str::to_owned);
167    Ok(Renewed {
168        access_token: text("access_token")
169            .ok_or_else(|| ApiError::Malformed("no access_token".into()))?,
170        refresh_token: text("refresh_token"),
171        expires_in: body
172            .get("expires_in")
173            .and_then(Value::as_i64)
174            .ok_or_else(|| ApiError::Malformed("no expires_in".into()))?,
175        refresh_token_expires_in: body.get("refresh_token_expires_in").and_then(Value::as_i64),
176        scopes: text("scope").map(|s| s.split_whitespace().map(str::to_owned).collect()),
177    })
178}
179
180pub fn owner(ctx: &Context, access_token: &str) -> Result<Owner, ApiError> {
181    let body = get(ctx, "/api/oauth/profile", access_token)?;
182    parse_owner(&body)
183}
184
185pub fn usage(ctx: &Context, access_token: &str) -> Result<Snapshot, ApiError> {
186    let body = get(ctx, "/api/oauth/usage", access_token)?;
187    Ok(usage::from_usage_object(&body, crate::time::now()))
188}
189
190fn parse_owner(body: &Value) -> Result<Owner, ApiError> {
191    let text = |path: &[&str]| -> Result<String, ApiError> {
192        path.iter()
193            .try_fold(body, |v, key| v.get(key))
194            .and_then(Value::as_str)
195            .map(str::to_owned)
196            .ok_or_else(|| ApiError::Malformed(format!("no {}", path.join("."))))
197    };
198    Ok(Owner {
199        account_uuid: text(&["account", "uuid"])?,
200        email: text(&["account", "email"])?,
201        organization_uuid: text(&["organization", "uuid"])?,
202    })
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208    use serde_json::json;
209
210    #[test]
211    fn a_renewal_answer_is_read_as_claude_code_reads_it() {
212        let full = json!({
213            "access_token": "a2", "refresh_token": "r2", "expires_in": 28_800,
214            "refresh_token_expires_in": 2_592_000, "scope": "user:inference user:profile",
215            "token_type": "Bearer"
216        });
217        assert_eq!(
218            parse_renewed(&full).unwrap(),
219            Renewed {
220                access_token: "a2".into(),
221                refresh_token: Some("r2".into()),
222                expires_in: 28_800,
223                refresh_token_expires_in: Some(2_592_000),
224                scopes: Some(vec!["user:inference".into(), "user:profile".into()]),
225            }
226        );
227        let kept = parse_renewed(&json!({"access_token": "a2", "expires_in": 60})).unwrap();
228        assert_eq!(
229            kept.refresh_token, None,
230            "no refresh_token means the old one stays"
231        );
232        assert!(parse_renewed(&json!({"expires_in": 60})).is_err());
233    }
234
235    /// Field names copied from a live response on 2026-09-21.
236    #[test]
237    fn reads_the_owner_from_the_shape_the_profile_endpoint_returns() {
238        let body = json!({
239            "account": {
240                "uuid": "acc", "email": "a@b.c", "display_name": "A", "full_name": "A",
241                "created_at": "2025-10-15T02:36:35Z", "has_claude_max": true, "has_claude_pro": false
242            },
243            "organization": {
244                "uuid": "org", "name": "Org", "organization_type": "claude_max",
245                "rate_limit_tier": "default_claude_max_20x", "billing_type": "stripe_subscription"
246            },
247            "application": {}, "enabled_plugins": []
248        });
249        assert_eq!(
250            parse_owner(&body).unwrap(),
251            Owner {
252                account_uuid: "acc".into(),
253                email: "a@b.c".into(),
254                organization_uuid: "org".into(),
255            }
256        );
257    }
258
259    #[test]
260    fn only_a_loopback_address_can_redirect_requests() {
261        for allowed in ["http://127.0.0.1:8080", "http://[::1]:9"] {
262            assert!(is_loopback(allowed), "{allowed}");
263        }
264        for refused in [
265            "https://evil.example.com",
266            "http://127.0.0.1.evil.example.com:80",
267            "http://localhost.evil.example.com:80",
268            "http://127.0.0.1:@evil.example.com/",
269            "http://127.0.0.1:8080@evil.example.com/",
270            "http://localhost:1",
271            "https://127.0.0.1:443",
272            "http://10.0.0.1:80",
273            "",
274        ] {
275            assert!(
276                !is_loopback(refused),
277                "{refused} must not be able to answer identity"
278            );
279        }
280    }
281
282    #[test]
283    fn a_profile_missing_the_account_is_malformed_rather_than_guessed() {
284        assert!(matches!(
285            parse_owner(&json!({"organization": {"uuid": "org"}})),
286            Err(ApiError::Malformed(_))
287        ));
288    }
289}