1#[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
22const CLIENT_ID: &str = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
24
25pub(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
40fn 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#[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 #[error("the session has expired")]
71 Unauthorized,
72 #[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 #[error("Anthropic no longer accepts this login")]
83 InvalidGrant,
84}
85
86#[derive(Debug, Clone, PartialEq)]
88pub struct Renewed {
89 pub access_token: String,
90 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 pub at: Option<i64>,
106}
107
108pub(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#[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
150pub 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 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
212pub(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
226fn 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
269pub(crate) fn server_time(headers: &ureq::http::HeaderMap) -> Option<i64> {
277 let raw = headers.get("date")?.to_str().ok()?;
278 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 #[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 #[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}