1#[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
23const CLIENT_ID: &str = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
25
26pub(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
41fn 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#[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 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 #[error("the session has expired")]
89 Unauthorized,
90 #[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 #[error("Anthropic no longer accepts this login")]
101 InvalidGrant,
102}
103
104#[derive(Debug, Clone, PartialEq)]
106pub struct Renewed {
107 pub access_token: String,
108 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 pub at: Option<i64>,
124}
125
126pub(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 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#[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
172pub 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#[derive(Clone, Default)]
195pub(crate) struct Agents(Arc<OnceLock<Agent>>);
196
197impl 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
207pub(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#[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 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
289pub(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
303fn 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
347pub(crate) fn server_time(headers: &ureq::http::HeaderMap) -> Option<i64> {
355 let raw = headers.get("date")?.to_str().ok()?;
356 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 #[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 #[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}