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}