1use serde::{Deserialize, Serialize};
12use tokio::sync::oneshot;
13
14mod pkce;
15mod callback;
16mod token;
17mod storage;
18mod browser;
19mod openai_codex;
20mod credential_source;
21
22pub use pkce::{generate_code_verifier, generate_code_challenge, generate_state, build_auth_url};
25pub use callback::{CallbackServerHandle, start_callback_server};
26pub use token::{exchange_code_for_tokens, refresh_token, ensure_fresh_token, ensure_fresh_provider_token};
27pub use storage::{auth_file_path, load_auth, load_provider_auth, save_auth, save_provider_auth};
28pub use browser::open_browser;
29pub use openai_codex::{extract_account_id as extract_codex_account_id, login as login_openai_codex};
30pub use credential_source::{CredentialSource, BrokerToken, BrokerClient, TokenCache, TokenFetcher, resolve_remote, resolve_remote_token, resolve_access_token, is_expired_with_margin, DEFAULT_MARGIN_MS};
31
32pub(super) const CLIENT_ID: &str = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
35pub(super) const AUTHORIZE_URL: &str = "https://claude.ai/oauth/authorize";
36pub(super) const TOKEN_URL: &str = "https://platform.claude.com/v1/oauth/token";
37pub(super) const CALLBACK_HOST: &str = "127.0.0.1";
38pub(super) const CALLBACK_PORT: u16 = 53692;
39pub(super) const SCOPES: &str = "org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload";
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct OAuthCredentials {
45 #[serde(rename = "type")]
46 pub auth_type: String,
47 pub refresh: String,
48 pub access: String,
49 pub expires: u64,
50 #[serde(rename = "accountId", skip_serializing_if = "Option::is_none")]
51 pub account_id: Option<String>,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct AuthFile {
56 pub anthropic: OAuthCredentials,
57 #[serde(rename = "openai-codex", default, skip_serializing_if = "Option::is_none")]
58 pub openai_codex: Option<OAuthCredentials>,
59}
60
61#[derive(Debug, Deserialize)]
62pub(crate) struct TokenResponse {
63 pub(crate) access_token: String,
64 pub(crate) refresh_token: String,
65 pub(crate) expires_in: u64,
66}
67
68#[derive(Debug, Clone)]
70pub struct CallbackResult {
71 pub code: String,
72 pub state: String,
73}
74
75pub fn is_token_expired(creds: &OAuthCredentials) -> bool {
82 now_millis() >= creds.expires
83}
84
85pub(crate) fn now_millis() -> u64 {
88 crate::epoch_millis()
89}
90
91fn parse_manual_input(input: &str) -> (Option<String>, Option<String>) {
101 let trimmed = input.trim();
102
103 if let Ok(url) = url::Url::parse(trimmed) {
105 let code = url.query_pairs().find(|(k, _)| k == "code").map(|(_, v)| v.to_string());
106 let state = url.query_pairs().find(|(k, _)| k == "state").map(|(_, v)| v.to_string());
107 if code.is_some() {
108 return (code, state);
109 }
110 }
111
112 if trimmed.contains('#') {
114 let parts: Vec<&str> = trimmed.splitn(2, '#').collect();
115 if parts.len() == 2 && !parts[0].is_empty() && !parts[1].is_empty() {
116 return (Some(parts[0].to_string()), Some(parts[1].to_string()));
117 }
118 }
119
120 (None, None)
124}
125
126fn manual_paste_to_callback(input: &str) -> Option<CallbackResult> {
136 let (code, state) = parse_manual_input(input);
137 Some(CallbackResult { code: code?, state: state? })
138}
139
140pub async fn login() -> std::result::Result<OAuthCredentials, String> {
144 let port = CALLBACK_PORT;
145
146 let verifier = generate_code_verifier();
148 let challenge = generate_code_challenge(&verifier);
149 let state = generate_state();
150
151 let (rx, server_handle) = start_callback_server(state.clone(), port).await?;
153
154 let auth_url = build_auth_url(&challenge, &state, port);
156
157 eprintln!("\n\x1b[1mOpening browser to sign in...\x1b[0m\n");
158
159 if let Err(e) = open_browser(&auth_url) {
160 eprintln!("Could not open browser automatically: {}", e);
161 }
162
163 eprintln!("\x1b[2mIf the browser didn't open, visit this URL:\x1b[0m");
164 eprintln!("\x1b[36m{}\x1b[0m\n", auth_url);
165
166 let (manual_tx, manual_rx) = oneshot::channel::<CallbackResult>();
168 let stdin_task = tokio::spawn(async move {
169 eprintln!("\x1b[2mOr paste the redirect URL here (must include `state`):\x1b[0m");
170
171 let mut line = String::new();
172 let result = tokio::task::spawn_blocking(move || {
173 std::io::stdin().read_line(&mut line).ok();
174 line.trim().to_string()
175 })
176 .await;
177
178 if let Ok(input) = result {
179 match manual_paste_to_callback(&input) {
180 Some(callback) => {
181 let _ = manual_tx.send(callback);
182 }
183 None if !input.is_empty() => {
184 eprintln!(
185 "\x1b[31m✗ Pasted input did not contain both `code` and `state`.\x1b[0m"
186 );
187 eprintln!(
188 "\x1b[2m Paste the full redirect URL (e.g. http://localhost:53692/callback?code=…&state=…).\x1b[0m"
189 );
190 }
191 None => {}
192 }
193 }
194 });
195
196 let result = tokio::select! {
198 callback = rx => {
199 match callback {
200 Ok(result) => result,
201 Err(_) => return Err("Callback channel closed".to_string()),
202 }
203 }
204 manual = manual_rx => {
205 match manual {
206 Ok(result) => result,
207 Err(_) => return Err("Manual input channel closed".to_string()),
208 }
209 }
210 };
211
212 stdin_task.abort();
213
214 if result.state != state {
216 server_handle.shutdown().await;
217 return Err("OAuth state mismatch — possible CSRF attack".to_string());
218 }
219
220 eprintln!("\n\x1b[1mExchanging code for tokens...\x1b[0m");
221
222 let creds = exchange_code_for_tokens(&result.code, &result.state, &verifier, port).await?;
224
225 server_handle.shutdown().await;
227
228 save_auth(&creds)?;
230
231 Ok(creds)
232}
233
234#[cfg(test)]
240mod tests {
241 use super::*;
242 use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
243
244 #[test]
245 fn test_generate_code_verifier() {
246 let verifier = generate_code_verifier();
247 assert!(!verifier.is_empty(), "Code verifier should not be empty");
248 assert!(verifier.len() > 20, "Code verifier should be longer than 20 characters");
249 let verifier2 = generate_code_verifier();
250 assert_ne!(verifier, verifier2, "Two calls should produce different verifiers");
251 }
252
253 #[test]
254 fn test_generate_code_challenge() {
255 let verifier = "test_verifier_123";
256 let challenge = generate_code_challenge(verifier);
257 assert!(!challenge.is_empty(), "Code challenge should not be empty");
258 let challenge2 = generate_code_challenge(verifier);
259 assert_eq!(challenge, challenge2, "Same verifier should produce same challenge");
260 let different_challenge = generate_code_challenge("different_verifier_456");
261 assert_ne!(challenge, different_challenge, "Different verifiers should produce different challenges");
262 }
263
264 #[test]
265 fn test_generate_state() {
266 let state = generate_state();
267 assert!(!state.is_empty(), "State should not be empty");
268 let state2 = generate_state();
269 assert_ne!(state, state2, "Two calls should produce different states");
270 }
271
272 #[test]
273 fn test_build_auth_url() {
274 let challenge = "test_challenge";
275 let state = "test_state";
276 let port = 8080;
277 let url = build_auth_url(challenge, state, port);
278 assert!(url.contains("claude.ai/oauth/authorize"));
279 assert!(url.contains("client_id=9d1c250a-e61b-44d9-88ed-5944d1962f5e"));
280 assert!(url.contains(&format!("code_challenge={}", challenge)));
281 assert!(url.contains(&format!("state={}", state)));
282 assert!(url.contains("localhost"));
283 assert!(url.contains(&port.to_string()));
284 assert!(url.contains("redirect_uri="));
285 }
286
287 #[test]
288 fn test_is_token_expired() {
289 let expired_creds = OAuthCredentials {
290 auth_type: "oauth".to_string(),
291 refresh: "test_refresh".to_string(),
292 access: "test_access".to_string(),
293 expires: 0,
294 account_id: None,
295 };
296 assert!(is_token_expired(&expired_creds));
297
298 let future_time = now_millis() + 3600000;
299 let fresh_creds = OAuthCredentials {
300 auth_type: "oauth".to_string(),
301 refresh: "test_refresh".to_string(),
302 access: "test_access".to_string(),
303 expires: future_time,
304 account_id: None,
305 };
306 assert!(!is_token_expired(&fresh_creds));
307 assert_eq!(fresh_creds.auth_type, "oauth");
308 }
309
310 #[test]
311 fn test_pkce_challenge_sha256() {
312 let verifier = "test_verifier_string";
313 let challenge = generate_code_challenge(verifier);
314
315 use sha2::{Sha256, Digest};
316 let mut hasher = Sha256::new();
317 hasher.update(verifier.as_bytes());
318 let hash = hasher.finalize();
319 let expected = URL_SAFE_NO_PAD.encode(hash);
320
321 assert_eq!(challenge, expected);
322 }
323
324 #[test]
325 fn test_code_verifier_length() {
326 let verifier = generate_code_verifier();
327 assert_eq!(verifier.len(), 43);
328 }
329
330 #[test]
331 fn test_state_length() {
332 let state = generate_state();
333 assert_eq!(state.len(), 43);
334 }
335
336 #[test]
337 fn test_build_auth_url_required_params() {
338 let url = build_auth_url("test_challenge", "test_state", 8080);
339 assert!(url.contains("response_type=code"));
340 assert!(url.contains("code_challenge_method=S256"));
341 assert!(url.contains("scope="));
342 assert!(url.contains("redirect_uri="));
343 assert!(url.contains("8080"));
344 }
345
346 #[test]
347 fn test_is_token_expired_edge_cases() {
348 let current_time = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_millis() as u64;
349
350 let exactly_now_creds = OAuthCredentials {
351 auth_type: "oauth".to_string(),
352 refresh: "test_refresh".to_string(),
353 access: "test_access".to_string(),
354 expires: current_time,
355 account_id: None,
356 };
357 assert!(is_token_expired(&exactly_now_creds));
358
359 let one_ms_future_creds = OAuthCredentials {
360 auth_type: "oauth".to_string(),
361 refresh: "test_refresh".to_string(),
362 access: "test_access".to_string(),
363 expires: current_time + 1,
364 account_id: None,
365 };
366 assert!(!is_token_expired(&one_ms_future_creds));
367 }
368
369 #[test]
370 fn test_auth_file_path() {
371 let path = auth_file_path();
372 let path_str = path.to_string_lossy();
373 assert!(path_str.ends_with("auth.json"));
374 }
375
376 #[test]
377 fn test_oauth_credentials_serialization_roundtrip() {
378 let original_creds = OAuthCredentials {
379 auth_type: "oauth".to_string(),
380 refresh: "test_refresh_token".to_string(),
381 access: "test_access_token".to_string(),
382 expires: 1234567890,
383 account_id: None,
384 };
385
386 let json = serde_json::to_string(&original_creds).expect("Should serialize");
387 let deserialized_creds: OAuthCredentials = serde_json::from_str(&json).expect("Should deserialize");
388
389 assert_eq!(original_creds.auth_type, deserialized_creds.auth_type);
390 assert_eq!(original_creds.refresh, deserialized_creds.refresh);
391 assert_eq!(original_creds.access, deserialized_creds.access);
392 assert_eq!(original_creds.expires, deserialized_creds.expires);
393 }
394
395 #[test]
405 fn anthropic_manual_paste_accepts_full_redirect_url() {
406 let result = manual_paste_to_callback(
407 "http://localhost:53692/callback?code=abc&state=xyz",
408 )
409 .expect("URL with code+state must be accepted");
410 assert_eq!(result.code, "abc");
411 assert_eq!(result.state, "xyz");
412 }
413
414 #[test]
415 fn anthropic_manual_paste_rejects_bare_code() {
416 assert!(
419 manual_paste_to_callback("abc123_bare_code").is_none(),
420 "bare code with no state must be rejected — was the root of the CSRF bypass"
421 );
422 }
423
424 #[test]
425 fn anthropic_manual_paste_rejects_url_without_state() {
426 assert!(
427 manual_paste_to_callback("http://localhost:53692/callback?code=abc").is_none(),
428 "URL missing `state` must be rejected — would bypass CSRF check"
429 );
430 }
431
432 #[test]
433 fn anthropic_manual_paste_accepts_code_hash_state() {
434 let result = manual_paste_to_callback("mycode#mystate")
436 .expect("code#state shorthand must be accepted");
437 assert_eq!(result.code, "mycode");
438 assert_eq!(result.state, "mystate");
439 }
440
441 #[test]
442 fn anthropic_manual_paste_rejects_empty_input() {
443 assert!(manual_paste_to_callback("").is_none());
444 assert!(manual_paste_to_callback(" ").is_none());
445 }
446
447 #[test]
448 fn anthropic_manual_paste_rejects_url_with_only_state() {
449 assert!(
450 manual_paste_to_callback("http://localhost:53692/callback?state=xyz").is_none(),
451 "URL missing `code` must be rejected"
452 );
453 }
454}