anthropic_auth/client/
blocking.rs

1use oauth2::PkceCodeChallenge;
2use rand::Rng;
3use url::Url;
4
5use super::shared::*;
6use crate::types::{ApiKeyResponse, TokenResponse};
7use crate::{OAuthConfig, OAuthFlow, OAuthMode, Result, TokenSet};
8
9/// Synchronous Anthropic OAuth client for authentication
10///
11/// This client handles the OAuth 2.0 flow with PKCE for Anthropic/Claude authentication
12/// using blocking I/O. No async runtime required.
13///
14/// # Example
15///
16/// ```no_run
17/// use anthropic_auth::{OAuthClient, OAuthConfig, OAuthMode};
18///
19/// fn main() -> Result<(), Box<dyn std::error::Error>> {
20///     let client = OAuthClient::new(OAuthConfig::default())?;
21///     let flow = client.start_flow(OAuthMode::Max)?;
22///     
23///     println!("Visit: {}", flow.authorization_url);
24///     // User authorizes and you get the code and state...
25///     
26///     let tokens = client.exchange_code("code_value", "state_value", &flow.verifier)?;
27///     println!("Got tokens!");
28///     Ok(())
29/// }
30/// ```
31pub struct OAuthClient {
32    config: OAuthConfig,
33}
34
35impl OAuthClient {
36    /// Create a new OAuth client with the given configuration
37    ///
38    /// # Arguments
39    ///
40    /// * `config` - OAuth configuration (client ID, redirect URI)
41    ///
42    /// # Errors
43    ///
44    /// Returns an error if the configuration is invalid
45    pub fn new(config: OAuthConfig) -> Result<Self> {
46        Ok(Self { config })
47    }
48
49    /// Start the OAuth authorization flow
50    ///
51    /// This generates a PKCE challenge and state token, then creates the authorization URL
52    /// that the user should visit to authorize the application.
53    ///
54    /// # Arguments
55    ///
56    /// * `mode` - The OAuth mode (Max for subscription, Console for API key creation)
57    ///
58    /// # Returns
59    ///
60    /// An `OAuthFlow` containing the authorization URL, PKCE verifier, state token, and mode
61    ///
62    /// # Example
63    ///
64    /// ```no_run
65    /// # use anthropic_auth::{OAuthClient, OAuthConfig, OAuthMode};
66    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
67    /// let client = OAuthClient::new(OAuthConfig::default())?;
68    /// let flow = client.start_flow(OAuthMode::Max)?;
69    /// println!("Visit: {}", flow.authorization_url);
70    /// # Ok(())
71    /// # }
72    /// ```
73    pub fn start_flow(&self, mode: OAuthMode) -> Result<OAuthFlow> {
74        // Generate PKCE challenge and verifier
75        let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
76        let verifier = pkce_verifier.secret().to_string();
77
78        // Generate a separate random state for CSRF protection (more secure than using verifier)
79        let state = generate_random_state();
80
81        // Determine base domain based on mode
82        let base_domain = match mode {
83            OAuthMode::Max => "claude.ai",
84            OAuthMode::Console => "console.anthropic.com",
85        };
86
87        // Build authorization URL
88        let auth_url = format!("https://{}/oauth/authorize", base_domain);
89        let mut url = Url::parse(&auth_url)?;
90
91        url.query_pairs_mut()
92            .append_pair("code", "true")
93            .append_pair("client_id", &self.config.client_id)
94            .append_pair("response_type", "code")
95            .append_pair("redirect_uri", REDIRECT_URI)
96            .append_pair("scope", SCOPE)
97            .append_pair("code_challenge", pkce_challenge.as_str())
98            .append_pair("code_challenge_method", "S256")
99            .append_pair("state", &state);
100
101        Ok(OAuthFlow {
102            authorization_url: url.to_string(),
103            verifier,
104            state,
105            mode,
106        })
107    }
108
109    /// Exchange an authorization code for access and refresh tokens (blocking)
110    ///
111    /// After the user authorizes the application, Anthropic returns a combined string
112    /// in the format `code#state`. This method parses that format, validates the state
113    /// for CSRF protection, and exchanges the code for tokens.
114    ///
115    /// # Arguments
116    ///
117    /// * `code_with_state` - The combined authorization response (format: "code#state")
118    ///   or just the code if already separated
119    /// * `expected_state` - The state token from the original flow (for CSRF validation)
120    /// * `verifier` - The PKCE verifier from the original flow
121    ///
122    /// # Returns
123    ///
124    /// A `TokenSet` containing access token, refresh token, and expiration time
125    ///
126    /// # Errors
127    ///
128    /// Returns an error if:
129    /// - The code, state, or verifier is invalid or empty
130    /// - The state doesn't match the expected state (CSRF protection)
131    /// - The token exchange fails (invalid code, network error, etc.)
132    /// - The response contains invalid token data
133    ///
134    /// # Example
135    ///
136    /// ```no_run
137    /// # use anthropic_auth::{OAuthClient, OAuthConfig, OAuthMode};
138    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
139    /// # let client = OAuthClient::new(OAuthConfig::default())?;
140    /// # let flow = client.start_flow(OAuthMode::Max)?;
141    /// // User pastes the combined response from Anthropic
142    /// let response = "code123#state456";
143    /// let tokens = client.exchange_code(response, &flow.state, &flow.verifier)?;
144    /// println!("Access token expires in: {:?}", tokens.expires_in());
145    /// # Ok(())
146    /// # }
147    /// ```
148    pub fn exchange_code(
149        &self,
150        code_with_state: &str,
151        expected_state: &str,
152        verifier: &str,
153    ) -> Result<TokenSet> {
154        // Parse code and state from the input
155        let (code, state) = parse_code_and_state(code_with_state, expected_state)?;
156
157        // Validate inputs
158        validate_code(&code)?;
159        validate_state(&state)?;
160        validate_verifier(verifier)?;
161
162        let client = reqwest::blocking::Client::new();
163        let request_body = build_token_request(&code, &state, verifier, &self.config.client_id);
164
165        let response = client.post(TOKEN_URL).json(&request_body).send()?;
166
167        if !response.status().is_success() {
168            let status = response.status().as_u16();
169            let body = response.text().unwrap_or_default();
170            return Err(create_http_error(status, &body));
171        }
172
173        let token_response: TokenResponse = response.json()?;
174        let tokens = TokenSet::from(token_response);
175
176        // Validate the token structure
177        tokens.validate().map_err(|e| {
178            crate::AnthropicAuthError::OAuth(format!("Invalid token response: {}", e))
179        })?;
180
181        Ok(tokens)
182    }
183
184    /// Refresh an expired access token (blocking)
185    ///
186    /// When an access token expires, use the refresh token to obtain a new
187    /// access token without requiring the user to re-authorize.
188    ///
189    /// # Arguments
190    ///
191    /// * `refresh_token` - The refresh token from a previous token exchange
192    ///
193    /// # Returns
194    ///
195    /// A new `TokenSet` with fresh access token
196    ///
197    /// # Errors
198    ///
199    /// Returns an error if the refresh fails (invalid refresh token, network error, etc.)
200    ///
201    /// # Example
202    ///
203    /// ```no_run
204    /// # use anthropic_auth::{OAuthClient, OAuthConfig};
205    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
206    /// # let client = OAuthClient::new(OAuthConfig::default())?;
207    /// # let old_tokens = client.exchange_code("code", "state", "verifier")?;
208    /// let new_tokens = client.refresh_token(&old_tokens.refresh_token)?;
209    /// # Ok(())
210    /// # }
211    /// ```
212    pub fn refresh_token(&self, refresh_token: &str) -> Result<TokenSet> {
213        if refresh_token.is_empty() {
214            return Err(crate::AnthropicAuthError::OAuth(
215                "Refresh token is empty".to_string(),
216            ));
217        }
218
219        let client = reqwest::blocking::Client::new();
220        let request_body = build_refresh_request(refresh_token, &self.config.client_id);
221
222        let response = client.post(TOKEN_URL).json(&request_body).send()?;
223
224        if !response.status().is_success() {
225            let status = response.status().as_u16();
226            let body = response.text().unwrap_or_default();
227            return Err(create_http_error(status, &body));
228        }
229
230        let token_response: TokenResponse = response.json()?;
231        let tokens = TokenSet::from(token_response);
232
233        // Validate the token structure
234        tokens.validate().map_err(|e| {
235            crate::AnthropicAuthError::OAuth(format!("Invalid token response: {}", e))
236        })?;
237
238        Ok(tokens)
239    }
240
241    /// Create an API key using a Console OAuth access token (blocking)
242    ///
243    /// This method is only available when using Console mode OAuth.
244    /// It creates a new API key that can be used with Anthropic's API.
245    ///
246    /// # Arguments
247    ///
248    /// * `access_token` - The access token from Console mode OAuth
249    ///
250    /// # Returns
251    ///
252    /// The API key as a string
253    ///
254    /// # Errors
255    ///
256    /// Returns an error if API key creation fails
257    ///
258    /// # Example
259    ///
260    /// ```no_run
261    /// # use anthropic_auth::{OAuthClient, OAuthConfig, OAuthMode};
262    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
263    /// # let client = OAuthClient::new(OAuthConfig::default())?;
264    /// # let flow = client.start_flow(OAuthMode::Console)?;
265    /// # let tokens = client.exchange_code("code", "state", &flow.verifier)?;
266    /// let api_key = client.create_api_key(&tokens.access_token)?;
267    /// println!("API Key: {}", api_key);
268    /// # Ok(())
269    /// # }
270    /// ```
271    pub fn create_api_key(&self, access_token: &str) -> Result<String> {
272        validate_access_token(access_token)?;
273
274        let client = reqwest::blocking::Client::new();
275        let request_body = build_api_key_request();
276
277        let response = client
278            .post(API_KEY_URL)
279            .header("authorization", format!("Bearer {}", access_token))
280            .json(&request_body)
281            .send()?;
282
283        if !response.status().is_success() {
284            let status = response.status().as_u16();
285            let body = response.text().unwrap_or_default();
286            return Err(create_http_error(status, &body));
287        }
288
289        let key_response: ApiKeyResponse = response.json()?;
290
291        // Validate API key is not empty
292        if key_response.raw_key.is_empty() {
293            return Err(crate::AnthropicAuthError::OAuth(
294                "Received empty API key from server".to_string(),
295            ));
296        }
297
298        Ok(key_response.raw_key)
299    }
300}
301
302/// Generate a cryptographically random state token for CSRF protection
303fn generate_random_state() -> String {
304    let mut rng = rand::thread_rng();
305    let random_bytes: Vec<u8> = (0..32).map(|_| rng.gen()).collect();
306    base64::Engine::encode(
307        &base64::engine::general_purpose::URL_SAFE_NO_PAD,
308        &random_bytes,
309    )
310}
311
312/// Parse code and state from the authorization response
313///
314/// Anthropic returns the authorization response in the format "code#state".
315/// This function parses that format and validates the state against the expected value.
316///
317/// # Arguments
318///
319/// * `code_with_state` - The authorization response (may contain "#state" or just the code)
320/// * `expected_state` - The state token from the original flow for validation
321///
322/// # Returns
323///
324/// A tuple of (code, state) where state has been validated against expected_state
325///
326/// # Errors
327///
328/// Returns an error if the state doesn't match the expected state (CSRF protection)
329fn parse_code_and_state(code_with_state: &str, expected_state: &str) -> Result<(String, String)> {
330    if let Some(hash_pos) = code_with_state.find('#') {
331        // Parse "code#state" format
332        let code = &code_with_state[..hash_pos];
333        let returned_state = &code_with_state[hash_pos + 1..];
334
335        // Validate state for CSRF protection
336        if returned_state != expected_state {
337            return Err(crate::AnthropicAuthError::OAuth(format!(
338                "State mismatch - possible CSRF attack. Expected: {}, Got: {}",
339                expected_state, returned_state
340            )));
341        }
342
343        Ok((code.to_string(), returned_state.to_string()))
344    } else {
345        // No "#" found, assume just the code was provided
346        // Use the expected_state directly
347        Ok((code_with_state.to_string(), expected_state.to_string()))
348    }
349}