Skip to main content

yapi_ai/auth/
anthropic.rs

1//! Anthropic (Claude Pro/Max) sign-in: Proof Key for Code Exchange (PKCE)
2//! with a loopback callback or a copied code. Port of
3//! `oauth/anthropic.ts`.
4
5use 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
17/// Public client id, base64 in pi's source.
18const 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/// The Anthropic sign-in and its endpoints.
26#[derive(Clone, Debug)]
27pub struct AnthropicOAuth {
28    /// Authorization page.
29    pub authorize_url: String,
30    /// Token endpoint.
31    pub token_url: String,
32    /// Loopback callback port.
33    pub callback_port: u16,
34    /// Redirect URI for the browser flow, which must reach the callback port.
35    pub redirect_uri: String,
36    /// Redirect URI for the copy-code flow.
37    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    /// POSTs JSON and returns the body, or pi's `HTTP request failed` message.
100    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}