1use std::time::Duration;
6
7use serde_json::{Map, Value};
8use tokio_util::sync::CancellationToken;
9use yapi_types::auth::OAuthCredential;
10
11use super::callback::{Received, callback_or_manual, start_code_server};
12use super::{
13 AuthError, AuthEvent, AuthPrompt, BoxFuture, Interaction, OAuthProvider, callback_host, expiry,
14 parse_authorization_input, pkce,
15};
16
17const CLIENT_ID: &str = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
19const SCOPES: &str = "org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload";
20const BROWSER: &str = "browser";
21const COPY_CODE: &str = "copy_code";
22const EXCHANGING: &str = "Exchanging authorization code for tokens...";
23const EXPIRY_MARGIN_MS: f64 = 5.0 * 60.0 * 1000.0;
24
25#[derive(Clone, Debug)]
27pub struct AnthropicOAuth {
28 pub authorize_url: String,
30 pub token_url: String,
32 pub callback_port: u16,
34 pub redirect_uri: String,
36 pub copy_code_redirect_uri: String,
38}
39
40impl Default for AnthropicOAuth {
41 fn default() -> AnthropicOAuth {
42 AnthropicOAuth {
43 authorize_url: "https://claude.ai/oauth/authorize".into(),
44 token_url: "https://platform.claude.com/v1/oauth/token".into(),
45 callback_port: 53692,
46 redirect_uri: "http://localhost:53692/callback".into(),
47 copy_code_redirect_uri: "https://platform.claude.com/oauth/code/callback".into(),
48 }
49 }
50}
51
52struct Token {
53 access: String,
54 refresh: String,
55 expires_in: f64,
56}
57
58fn credential(token: Token) -> OAuthCredential {
59 OAuthCredential {
60 access: token.access,
61 refresh: token.refresh,
62 expires: expiry(token.expires_in, EXPIRY_MARGIN_MS) as f64,
63 extra: Map::new(),
64 }
65}
66
67fn parse_token(body: &str) -> Result<Token, String> {
68 let value: Value = serde_json::from_str(body).map_err(|err| format!("SyntaxError: {err}"))?;
69 let field = |name: &str| value.get(name).and_then(Value::as_str).map(str::to_owned);
70 match (
71 field("access_token"),
72 field("refresh_token"),
73 value.get("expires_in").and_then(Value::as_f64),
74 ) {
75 (Some(access), Some(refresh), Some(expires_in)) => Ok(Token {
76 access,
77 refresh,
78 expires_in,
79 }),
80 _ => Err("Error: token response is missing fields".into()),
81 }
82}
83
84impl AnthropicOAuth {
85 fn authorize_url(&self, challenge: &str, verifier: &str, redirect_uri: &str) -> String {
86 let query = super::form(&[
87 ("code", "true"),
88 ("client_id", CLIENT_ID),
89 ("response_type", "code"),
90 ("redirect_uri", redirect_uri),
91 ("scope", SCOPES),
92 ("code_challenge", challenge),
93 ("code_challenge_method", "S256"),
94 ("state", verifier),
95 ]);
96 format!("{}?{query}", self.authorize_url)
97 }
98
99 async fn post_json(
101 &self,
102 body: &Value,
103 cancel: &CancellationToken,
104 ) -> Result<String, AuthError> {
105 let body = yapi_types::json::stringify(body);
106 let request = crate::http::client()
107 .post(&self.token_url)
108 .header("Content-Type", "application/json")
109 .header("Accept", "application/json")
110 .timeout(Duration::from_secs(30))
111 .body(body);
112 let response = super::send(request, cancel).await?;
113 let status = response.status().as_u16();
114 let text = response.text().await.unwrap_or_default();
115 if !(200..300).contains(&status) {
116 return Err(AuthError::Failed(format!(
117 "HTTP request failed. status={status}; url={}; body={text}",
118 self.token_url
119 )));
120 }
121 Ok(text)
122 }
123
124 async fn exchange(
125 &self,
126 code: &str,
127 state: &str,
128 verifier: &str,
129 redirect_uri: &str,
130 cancel: &CancellationToken,
131 ) -> Result<OAuthCredential, AuthError> {
132 let body = super::object(&[
133 ("grant_type", "authorization_code"),
134 ("client_id", CLIENT_ID),
135 ("code", code),
136 ("state", state),
137 ("redirect_uri", redirect_uri),
138 ("code_verifier", verifier),
139 ]);
140 let text = self.post_json(&body, cancel).await.map_err(|err| match err {
141 AuthError::Cancelled => AuthError::Cancelled,
142 AuthError::Failed(message) => AuthError::Failed(format!(
143 "Token exchange request failed. url={}; redirect_uri={redirect_uri}; response_type=authorization_code; details=Error: {message}",
144 self.token_url
145 )),
146 })?;
147 parse_token(&text).map(credential).map_err(|details| {
148 AuthError::Failed(format!(
149 "Token exchange returned invalid JSON. url={}; body={text}; details={details}",
150 self.token_url
151 ))
152 })
153 }
154
155 async fn login_browser(&self, interaction: &Interaction) -> Result<OAuthCredential, AuthError> {
156 let pkce = pkce::generate()?;
157 let mut server = start_code_server(
158 "Anthropic",
159 &callback_host(),
160 self.callback_port,
161 "/callback",
162 Some(pkce.verifier.clone()),
163 false,
164 interaction.cancel(),
165 )
166 .await
167 .ok();
168 interaction.notify(AuthEvent::AuthUrl {
169 url: self.authorize_url(&pkce.challenge, &pkce.verifier, &self.redirect_uri),
170 instructions: Some("Complete login in your browser. If the browser is on another machine, paste the final redirect URL here.".into()),
171 });
172 let received = callback_or_manual(
173 interaction,
174 server.as_mut(),
175 "Complete login in your browser, or paste the authorization code / redirect URL here:",
176 &self.redirect_uri,
177 )
178 .await?;
179 let (code, state) = match received {
180 Received::Callback((code, _)) => (Some(code), pkce.verifier.clone()),
181 Received::Manual(input) => {
182 let (code, state) = parse_authorization_input(&input);
183 if state.as_ref().is_some_and(|state| *state != pkce.verifier) {
184 return Err(AuthError::failed("OAuth state mismatch"));
185 }
186 (code, state.unwrap_or_else(|| pkce.verifier.clone()))
187 }
188 };
189 let code = code.ok_or_else(|| AuthError::failed("Missing authorization code"))?;
190 interaction.notify(AuthEvent::Progress {
191 message: EXCHANGING.into(),
192 });
193 self.exchange(
194 &code,
195 &state,
196 &pkce.verifier,
197 &self.redirect_uri,
198 interaction.cancel(),
199 )
200 .await
201 }
202
203 async fn login_copy_code(
204 &self,
205 interaction: &Interaction,
206 ) -> Result<OAuthCredential, AuthError> {
207 let pkce = pkce::generate()?;
208 interaction.notify(AuthEvent::AuthUrl {
209 url: self.authorize_url(&pkce.challenge, &pkce.verifier, &self.copy_code_redirect_uri),
210 instructions: Some(
211 "Complete login in your browser, then copy the code Anthropic shows and paste it here."
212 .into(),
213 ),
214 });
215 let input = interaction
216 .prompt(AuthPrompt::ManualCode {
217 message: "Paste the code Anthropic shows after you sign in:".into(),
218 placeholder: Some("code#state".into()),
219 })
220 .await?;
221 let (code, state) = parse_authorization_input(&input);
222 if state.as_ref().is_some_and(|state| *state != pkce.verifier) {
223 return Err(AuthError::failed("OAuth state mismatch"));
224 }
225 let code = code.ok_or_else(|| AuthError::failed("Missing authorization code"))?;
226 interaction.notify(AuthEvent::Progress {
227 message: EXCHANGING.into(),
228 });
229 let state = state.unwrap_or_else(|| pkce.verifier.clone());
230 self.exchange(
231 &code,
232 &state,
233 &pkce.verifier,
234 &self.copy_code_redirect_uri,
235 interaction.cancel(),
236 )
237 .await
238 }
239
240 async fn refresh_token(
241 &self,
242 refresh: &str,
243 cancel: &CancellationToken,
244 ) -> Result<OAuthCredential, AuthError> {
245 let body = super::object(&[
246 ("grant_type", "refresh_token"),
247 ("client_id", CLIENT_ID),
248 ("refresh_token", refresh),
249 ]);
250 let text = self
251 .post_json(&body, cancel)
252 .await
253 .map_err(|err| match err {
254 AuthError::Cancelled => AuthError::Cancelled,
255 AuthError::Failed(message) => AuthError::Failed(format!(
256 "Anthropic token refresh request failed. url={}; details=Error: {message}",
257 self.token_url
258 )),
259 })?;
260 parse_token(&text).map(credential).map_err(|details| {
261 AuthError::Failed(format!(
262 "Anthropic token refresh returned invalid JSON. url={}; body={text}; details={details}",
263 self.token_url
264 ))
265 })
266 }
267}
268
269impl OAuthProvider for AnthropicOAuth {
270 fn name(&self) -> &str {
271 "Anthropic (Claude Pro/Max)"
272 }
273
274 fn login<'a>(
275 &'a self,
276 interaction: &'a Interaction,
277 _options: &'a super::LoginOptions,
278 ) -> BoxFuture<'a, Result<OAuthCredential, AuthError>> {
279 Box::pin(async move {
280 let method = interaction
281 .prompt(AuthPrompt::select(
282 "Select Anthropic login method:",
283 &[
284 (BROWSER, "Browser login (default)"),
285 (COPY_CODE, "Copy code login (headless)"),
286 ],
287 ))
288 .await?;
289 match method.as_str() {
290 COPY_CODE => self.login_copy_code(interaction).await,
291 BROWSER => self.login_browser(interaction).await,
292 other => Err(AuthError::Failed(format!(
293 "Unknown Anthropic login method: {other}"
294 ))),
295 }
296 })
297 }
298
299 fn refresh<'a>(
300 &'a self,
301 credential: &'a OAuthCredential,
302 cancel: &'a CancellationToken,
303 ) -> BoxFuture<'a, Result<OAuthCredential, AuthError>> {
304 Box::pin(self.refresh_token(&credential.refresh, cancel))
305 }
306}