1use 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
19const CLIENT_ID: &str = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
21
22fn 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
37fn 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#[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 #[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 #[error("Anthropic no longer accepts this login")]
78 InvalidGrant,
79}
80
81#[derive(Debug, PartialEq)]
83pub struct Renewed {
84 pub access_token: String,
85 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 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
134pub fn renew(
140 ctx: &Context,
141 refresh_token: &str,
142 scopes: &[String],
143 client_id: Option<&str>,
144) -> Result<Renewed, ApiError> {
145 let body = serde_json::json!({
146 "grant_type": "refresh_token",
147 "refresh_token": refresh_token,
148 "client_id": client_id.unwrap_or(CLIENT_ID),
149 "scope": scopes.join(" "),
150 });
151 let mut response = agent()
152 .post(format!("{}/v1/oauth/token", auth_base(ctx)))
153 .header("Content-Type", "application/json")
154 .send(body.to_string())
155 .map_err(|e| ApiError::Network(e.to_string()))?;
156 let status = response.status().as_u16();
157 let text = response
158 .body_mut()
159 .read_to_string()
160 .map_err(|e| ApiError::Network(e.to_string()))?;
161 match status {
162 200 => {
163 let body: Value =
164 serde_json::from_str(&text).map_err(|e| ApiError::Malformed(e.to_string()))?;
165 parse_renewed(&body)
166 }
167 429 => Err(ApiError::RateLimited),
168 400..=499 if text.contains("invalid_grant") => Err(ApiError::InvalidGrant),
169 status => Err(ApiError::Unexpected { status }),
170 }
171}
172
173fn parse_renewed(body: &Value) -> Result<Renewed, ApiError> {
174 let text = |key: &str| body.get(key).and_then(Value::as_str).map(str::to_owned);
175 Ok(Renewed {
176 access_token: text("access_token")
177 .ok_or_else(|| ApiError::Malformed("no access_token".into()))?,
178 refresh_token: text("refresh_token"),
179 expires_in: body
180 .get("expires_in")
181 .and_then(Value::as_i64)
182 .ok_or_else(|| ApiError::Malformed("no expires_in".into()))?,
183 refresh_token_expires_in: body.get("refresh_token_expires_in").and_then(Value::as_i64),
184 scopes: text("scope").map(|s| s.split_whitespace().map(str::to_owned).collect()),
185 })
186}
187
188pub fn owner(ctx: &Context, access_token: &str) -> Result<Owner, ApiError> {
189 let body = get(ctx, "/api/oauth/profile", access_token)?;
190 parse_owner(&body)
191}
192
193pub fn usage(ctx: &Context, access_token: &str) -> Result<Snapshot, ApiError> {
194 let body = get(ctx, "/api/oauth/usage", access_token)?;
195 Ok(usage::from_usage_object(&body, crate::time::now()))
196}
197
198fn parse_owner(body: &Value) -> Result<Owner, ApiError> {
199 let text = |path: &[&str]| -> Result<String, ApiError> {
200 path.iter()
201 .try_fold(body, |v, key| v.get(key))
202 .and_then(Value::as_str)
203 .map(str::to_owned)
204 .ok_or_else(|| ApiError::Malformed(format!("no {}", path.join("."))))
205 };
206 Ok(Owner {
207 account_uuid: text(&["account", "uuid"])?,
208 email: text(&["account", "email"])?,
209 organization_uuid: text(&["organization", "uuid"])?,
210 })
211}
212
213#[cfg(test)]
214mod tests {
215 use super::*;
216 use serde_json::json;
217
218 #[test]
219 fn a_renewal_answer_is_read_as_claude_code_reads_it() {
220 let full = json!({
221 "access_token": "a2", "refresh_token": "r2", "expires_in": 28_800,
222 "refresh_token_expires_in": 2_592_000, "scope": "user:inference user:profile",
223 "token_type": "Bearer"
224 });
225 assert_eq!(
226 parse_renewed(&full).unwrap(),
227 Renewed {
228 access_token: "a2".into(),
229 refresh_token: Some("r2".into()),
230 expires_in: 28_800,
231 refresh_token_expires_in: Some(2_592_000),
232 scopes: Some(vec!["user:inference".into(), "user:profile".into()]),
233 }
234 );
235 let kept = parse_renewed(&json!({"access_token": "a2", "expires_in": 60})).unwrap();
236 assert_eq!(
237 kept.refresh_token, None,
238 "no refresh_token means the old one stays"
239 );
240 assert!(parse_renewed(&json!({"expires_in": 60})).is_err());
241 }
242
243 #[test]
245 fn reads_the_owner_from_the_shape_the_profile_endpoint_returns() {
246 let body = json!({
247 "account": {
248 "uuid": "acc", "email": "a@b.c", "display_name": "A", "full_name": "A",
249 "created_at": "2025-10-15T02:36:35Z", "has_claude_max": true, "has_claude_pro": false
250 },
251 "organization": {
252 "uuid": "org", "name": "Org", "organization_type": "claude_max",
253 "rate_limit_tier": "default_claude_max_20x", "billing_type": "stripe_subscription"
254 },
255 "application": {}, "enabled_plugins": []
256 });
257 assert_eq!(
258 parse_owner(&body).unwrap(),
259 Owner {
260 account_uuid: "acc".into(),
261 email: "a@b.c".into(),
262 organization_uuid: "org".into(),
263 }
264 );
265 }
266
267 #[test]
268 fn only_a_loopback_address_can_redirect_requests() {
269 for allowed in ["http://127.0.0.1:8080", "http://[::1]:9"] {
270 assert!(is_loopback(allowed), "{allowed}");
271 }
272 for refused in [
273 "https://evil.example.com",
274 "http://127.0.0.1.evil.example.com:80",
275 "http://localhost.evil.example.com:80",
276 "http://127.0.0.1:@evil.example.com/",
277 "http://127.0.0.1:8080@evil.example.com/",
278 "http://localhost:1",
279 "https://127.0.0.1:443",
280 "http://10.0.0.1:80",
281 "",
282 ] {
283 assert!(
284 !is_loopback(refused),
285 "{refused} must not be able to answer identity"
286 );
287 }
288 }
289
290 #[test]
291 fn a_profile_missing_the_account_is_malformed_rather_than_guessed() {
292 assert!(matches!(
293 parse_owner(&json!({"organization": {"uuid": "org"}})),
294 Err(ApiError::Malformed(_))
295 ));
296 }
297}