1use serde::{Deserialize, Serialize};
12use tokio::sync::oneshot;
13
14pub mod aws_bedrock;
15pub mod azure_openai;
16pub mod broker;
17mod browser;
18mod callback;
19pub mod cloud;
20pub mod cloud_login;
21mod credential_source;
22pub mod github_copilot;
23pub mod google_gemini;
24pub mod google_vertex;
25mod openai_codex;
26mod pkce;
27pub mod provider;
28pub mod providers;
29pub mod static_providers;
30mod storage;
31mod token;
32mod xai;
33
34pub use broker::{
37 broker_from_source, global_broker, preflight_cloud_capability, set_global_broker, AccessToken,
38 BrokerError, CredentialBroker, CredentialKind, LocalBroker, ProviderStatus, ProxyByteStream,
39 ProxyMethod, ProxyRequest, ProxyResponse, RemoteBroker, StaticKeyStatus,
40 MAX_PROXY_REQUEST_BYTES, MAX_PROXY_RESPONSE_BYTES, MAX_UPSTREAM_ERROR_BYTES,
41 PROXY_REQUEST_TIMEOUT,
42};
43pub use browser::open_browser;
44pub use callback::{
45 start_callback_server, start_callback_server_at, CallbackOutcome, CallbackServerHandle,
46};
47pub use cloud::{
48 AuthIdentity, AwsBedrockConfig, AzureOpenAiConfig, BrokerMessage, BrokerOperation, BrokerTool,
49 CloudProviderId, GoogleVertexConfig, InvokeOptions, InvokeRequest, MessageRole, ProviderId,
50};
51pub use credential_source::{
52 is_expired_with_margin, resolve_access_token, resolve_remote, resolve_remote_token,
53 BrokerClient, BrokerToken, CredentialSource, TokenCache, TokenFetcher, DEFAULT_MARGIN_MS,
54};
55pub use github_copilot::login as login_github_copilot;
56pub use openai_codex::{
57 extract_account_id as extract_codex_account_id, login as login_openai_codex,
58};
59pub use pkce::{build_auth_url, generate_code_challenge, generate_code_verifier, generate_state};
60pub use provider::{
61 BrokerCredentialStrategy, OAuthProviderDescriptor, OAuthProviderId, OAuthProviderRegistry,
62};
63pub use static_providers::{static_provider, StaticProviderSpec, LOCAL_PROVIDER_KEY};
64pub use storage::{
65 auth_file_path, load_auth, load_cloud_state, load_provider_auth, load_static_key, save_auth,
66 save_cloud_state, save_provider_auth, save_static_key,
67};
68pub use token::{
69 ensure_fresh_provider_token, ensure_fresh_token, exchange_code_for_tokens, refresh_token,
70};
71pub use xai::login as login_xai;
72
73pub(super) const CLIENT_ID: &str = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
76pub(super) const AUTHORIZE_URL: &str = "https://claude.ai/oauth/authorize";
77pub(super) const TOKEN_URL: &str = "https://platform.claude.com/v1/oauth/token";
78pub(super) const CALLBACK_HOST: &str = "127.0.0.1";
79pub(super) const CALLBACK_PORT: u16 = 53692;
80pub(super) const SCOPES: &str = "org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload";
81
82#[derive(Clone, Serialize, Deserialize)]
85pub struct OAuthCredentials {
86 #[serde(rename = "type")]
87 pub auth_type: String,
88 pub refresh: String,
89 pub access: String,
90 pub expires: u64,
91 #[serde(rename = "accountId", skip_serializing_if = "Option::is_none")]
92 pub account_id: Option<String>,
93}
94
95impl std::fmt::Debug for OAuthCredentials {
97 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
98 f.debug_struct("OAuthCredentials")
99 .field("auth_type", &self.auth_type)
100 .field("refresh", &"[REDACTED]")
101 .field("access", &"[REDACTED]")
102 .field("expires", &self.expires)
103 .field("account_id", &self.account_id)
104 .finish()
105 }
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct AuthFile {
110 pub anthropic: OAuthCredentials,
111 #[serde(
112 rename = "openai-codex",
113 default,
114 skip_serializing_if = "Option::is_none"
115 )]
116 pub openai_codex: Option<OAuthCredentials>,
117}
118
119#[derive(Debug, Deserialize)]
120pub(crate) struct TokenResponse {
121 pub(crate) access_token: String,
122 pub(crate) refresh_token: String,
123 pub(crate) expires_in: u64,
124}
125
126#[derive(Debug, Clone, PartialEq, Eq)]
128pub struct CallbackResult {
129 pub code: String,
130 pub state: String,
131}
132
133pub fn is_token_expired(creds: &OAuthCredentials) -> bool {
140 now_millis() >= creds.expires
141}
142
143pub(crate) fn now_millis() -> u64 {
146 crate::epoch_millis()
147}
148
149fn parse_manual_input(input: &str) -> (Option<String>, Option<String>) {
159 let trimmed = input.trim();
160
161 if let Ok(url) = url::Url::parse(trimmed) {
163 let code = url
164 .query_pairs()
165 .find(|(k, _)| k == "code")
166 .map(|(_, v)| v.to_string());
167 let state = url
168 .query_pairs()
169 .find(|(k, _)| k == "state")
170 .map(|(_, v)| v.to_string());
171 if code.is_some() {
172 return (code, state);
173 }
174 }
175
176 if trimmed.contains('#') {
178 let parts: Vec<&str> = trimmed.splitn(2, '#').collect();
179 if parts.len() == 2 && !parts[0].is_empty() && !parts[1].is_empty() {
180 return (Some(parts[0].to_string()), Some(parts[1].to_string()));
181 }
182 }
183
184 (None, None)
188}
189
190fn manual_paste_to_callback(input: &str) -> Option<CallbackResult> {
200 let (code, state) = parse_manual_input(input);
201 Some(CallbackResult {
202 code: code?,
203 state: state?,
204 })
205}
206
207pub async fn login() -> std::result::Result<OAuthCredentials, String> {
211 let port = CALLBACK_PORT;
212
213 let verifier = generate_code_verifier();
215 let challenge = generate_code_challenge(&verifier);
216 let state = generate_state();
217
218 let (rx, server_handle) = start_callback_server(state.clone(), port).await?;
220
221 let auth_url = build_auth_url(&challenge, &state, port);
223
224 eprintln!("\n\x1b[1mOpening browser to sign in...\x1b[0m\n");
225
226 if let Err(e) = open_browser(&auth_url) {
227 eprintln!("Could not open browser automatically: {}", e);
228 }
229
230 eprintln!("\x1b[2mIf the browser didn't open, visit this URL:\x1b[0m");
231 eprintln!("\x1b[36m{}\x1b[0m\n", auth_url);
232
233 let (manual_tx, manual_rx) = oneshot::channel::<CallbackResult>();
235 let stdin_task = tokio::spawn(async move {
236 eprintln!("\x1b[2mOr paste the redirect URL here (must include `state`):\x1b[0m");
237
238 let mut line = String::new();
239 let result = tokio::task::spawn_blocking(move || {
240 std::io::stdin().read_line(&mut line).ok();
241 line.trim().to_string()
242 })
243 .await;
244
245 if let Ok(input) = result {
246 match manual_paste_to_callback(&input) {
247 Some(callback) => {
248 let _ = manual_tx.send(callback);
249 }
250 None if !input.is_empty() => {
251 eprintln!(
252 "\x1b[31m✗ Pasted input did not contain both `code` and `state`.\x1b[0m"
253 );
254 eprintln!(
255 "\x1b[2m Paste the full redirect URL (e.g. http://localhost:53692/callback?code=…&state=…).\x1b[0m"
256 );
257 }
258 None => {}
259 }
260 }
261 });
262
263 let result = tokio::select! {
265 callback = rx => {
266 match callback {
267 Ok(CallbackOutcome::Authorized(result)) => result,
268 Ok(CallbackOutcome::Denied { error, description }) => return Err(format!("OAuth denied: {}{}", error, description.map(|d| format!(": {d}")).unwrap_or_default())),
269 Ok(CallbackOutcome::Invalid) => return Err("Invalid OAuth callback".to_string()),
270 Err(_) => return Err("Callback channel closed".to_string()),
271 }
272 }
273 manual = manual_rx => {
274 match manual {
275 Ok(result) => result,
276 Err(_) => return Err("Manual input channel closed".to_string()),
277 }
278 }
279 };
280
281 stdin_task.abort();
282
283 if result.state != state {
285 server_handle.shutdown().await;
286 return Err("OAuth state mismatch — possible CSRF attack".to_string());
287 }
288
289 eprintln!("\n\x1b[1mExchanging code for tokens...\x1b[0m");
290
291 let creds = exchange_code_for_tokens(&result.code, &result.state, &verifier, port).await?;
293
294 server_handle.shutdown().await;
296
297 save_auth(&creds)?;
299
300 Ok(creds)
301}
302
303pub(crate) fn github_copilot_models_base_url() -> &'static str {
310 "https://api.githubcopilot.com"
311}
312
313pub(crate) fn github_copilot_models_request_headers() -> &'static [(&'static str, &'static str)] {
315 &[
316 ("User-Agent", "SynapsCLI/0.6.0"),
317 ("Accept", "application/json"),
318 ("Editor-Version", "vscode/1.107.0"),
319 ("Editor-Plugin-Version", "copilot-chat/0.35.0"),
320 ("Copilot-Integration-Id", "vscode-chat"),
321 ("X-Github-Api-Version", "2025-10-01"),
322 ]
323}
324
325#[cfg(test)]
326mod tests {
327 use super::*;
328 use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
329
330 #[test]
331 fn test_generate_code_verifier() {
332 let verifier = generate_code_verifier();
333 assert!(!verifier.is_empty(), "Code verifier should not be empty");
334 assert!(
335 verifier.len() > 20,
336 "Code verifier should be longer than 20 characters"
337 );
338 let verifier2 = generate_code_verifier();
339 assert_ne!(
340 verifier, verifier2,
341 "Two calls should produce different verifiers"
342 );
343 }
344
345 #[test]
346 fn test_generate_code_challenge() {
347 let verifier = "test_verifier_123";
348 let challenge = generate_code_challenge(verifier);
349 assert!(!challenge.is_empty(), "Code challenge should not be empty");
350 let challenge2 = generate_code_challenge(verifier);
351 assert_eq!(
352 challenge, challenge2,
353 "Same verifier should produce same challenge"
354 );
355 let different_challenge = generate_code_challenge("different_verifier_456");
356 assert_ne!(
357 challenge, different_challenge,
358 "Different verifiers should produce different challenges"
359 );
360 }
361
362 #[test]
363 fn test_generate_state() {
364 let state = generate_state();
365 assert!(!state.is_empty(), "State should not be empty");
366 let state2 = generate_state();
367 assert_ne!(state, state2, "Two calls should produce different states");
368 }
369
370 #[test]
371 fn test_build_auth_url() {
372 let challenge = "test_challenge";
373 let state = "test_state";
374 let port = 8080;
375 let url = build_auth_url(challenge, state, port);
376 assert!(url.contains("claude.ai/oauth/authorize"));
377 assert!(url.contains("client_id=9d1c250a-e61b-44d9-88ed-5944d1962f5e"));
378 assert!(url.contains(&format!("code_challenge={}", challenge)));
379 assert!(url.contains(&format!("state={}", state)));
380 assert!(url.contains("localhost"));
381 assert!(url.contains(&port.to_string()));
382 assert!(url.contains("redirect_uri="));
383 }
384
385 #[test]
386 fn test_is_token_expired() {
387 let expired_creds = OAuthCredentials {
388 auth_type: "oauth".to_string(),
389 refresh: "test_refresh".to_string(),
390 access: "test_access".to_string(),
391 expires: 0,
392 account_id: None,
393 };
394 assert!(is_token_expired(&expired_creds));
395
396 let future_time = now_millis() + 3600000;
397 let fresh_creds = OAuthCredentials {
398 auth_type: "oauth".to_string(),
399 refresh: "test_refresh".to_string(),
400 access: "test_access".to_string(),
401 expires: future_time,
402 account_id: None,
403 };
404 assert!(!is_token_expired(&fresh_creds));
405 assert_eq!(fresh_creds.auth_type, "oauth");
406 }
407
408 #[test]
409 fn test_pkce_challenge_sha256() {
410 let verifier = "test_verifier_string";
411 let challenge = generate_code_challenge(verifier);
412
413 use sha2::{Digest, Sha256};
414 let mut hasher = Sha256::new();
415 hasher.update(verifier.as_bytes());
416 let hash = hasher.finalize();
417 let expected = URL_SAFE_NO_PAD.encode(hash);
418
419 assert_eq!(challenge, expected);
420 }
421
422 #[test]
423 fn test_code_verifier_length() {
424 let verifier = generate_code_verifier();
425 assert_eq!(verifier.len(), 43);
426 }
427
428 #[test]
429 fn test_state_length() {
430 let state = generate_state();
431 assert_eq!(state.len(), 43);
432 }
433
434 #[test]
435 fn test_build_auth_url_required_params() {
436 let url = build_auth_url("test_challenge", "test_state", 8080);
437 assert!(url.contains("response_type=code"));
438 assert!(url.contains("code_challenge_method=S256"));
439 assert!(url.contains("scope="));
440 assert!(url.contains("redirect_uri="));
441 assert!(url.contains("8080"));
442 }
443
444 #[test]
445 fn test_is_token_expired_edge_cases() {
446 let current_time = std::time::SystemTime::now()
447 .duration_since(std::time::UNIX_EPOCH)
448 .unwrap()
449 .as_millis() as u64;
450
451 let exactly_now_creds = OAuthCredentials {
452 auth_type: "oauth".to_string(),
453 refresh: "test_refresh".to_string(),
454 access: "test_access".to_string(),
455 expires: current_time,
456 account_id: None,
457 };
458 assert!(is_token_expired(&exactly_now_creds));
459
460 let one_ms_future_creds = OAuthCredentials {
461 auth_type: "oauth".to_string(),
462 refresh: "test_refresh".to_string(),
463 access: "test_access".to_string(),
464 expires: current_time + 1,
465 account_id: None,
466 };
467 assert!(!is_token_expired(&one_ms_future_creds));
468 }
469
470 #[test]
471 fn test_auth_file_path() {
472 let path = auth_file_path();
473 let path_str = path.to_string_lossy();
474 assert!(path_str.ends_with("auth.json"));
475 }
476
477 #[test]
478 fn test_oauth_credentials_serialization_roundtrip() {
479 let original_creds = OAuthCredentials {
480 auth_type: "oauth".to_string(),
481 refresh: "test_refresh_token".to_string(),
482 access: "test_access_token".to_string(),
483 expires: 1234567890,
484 account_id: None,
485 };
486
487 let json = serde_json::to_string(&original_creds).expect("Should serialize");
488 let deserialized_creds: OAuthCredentials =
489 serde_json::from_str(&json).expect("Should deserialize");
490
491 assert_eq!(original_creds.auth_type, deserialized_creds.auth_type);
492 assert_eq!(original_creds.refresh, deserialized_creds.refresh);
493 assert_eq!(original_creds.access, deserialized_creds.access);
494 assert_eq!(original_creds.expires, deserialized_creds.expires);
495 }
496
497 #[test]
507 fn anthropic_manual_paste_accepts_full_redirect_url() {
508 let result = manual_paste_to_callback("http://localhost:53692/callback?code=abc&state=xyz")
509 .expect("URL with code+state must be accepted");
510 assert_eq!(result.code, "abc");
511 assert_eq!(result.state, "xyz");
512 }
513
514 #[test]
515 fn anthropic_manual_paste_rejects_bare_code() {
516 assert!(
519 manual_paste_to_callback("abc123_bare_code").is_none(),
520 "bare code with no state must be rejected — was the root of the CSRF bypass"
521 );
522 }
523
524 #[test]
525 fn anthropic_manual_paste_rejects_url_without_state() {
526 assert!(
527 manual_paste_to_callback("http://localhost:53692/callback?code=abc").is_none(),
528 "URL missing `state` must be rejected — would bypass CSRF check"
529 );
530 }
531
532 #[test]
533 fn anthropic_manual_paste_accepts_code_hash_state() {
534 let result = manual_paste_to_callback("mycode#mystate")
536 .expect("code#state shorthand must be accepted");
537 assert_eq!(result.code, "mycode");
538 assert_eq!(result.state, "mystate");
539 }
540
541 #[test]
542 fn anthropic_manual_paste_rejects_empty_input() {
543 assert!(manual_paste_to_callback("").is_none());
544 assert!(manual_paste_to_callback(" ").is_none());
545 }
546
547 #[test]
548 fn anthropic_manual_paste_rejects_url_with_only_state() {
549 assert!(
550 manual_paste_to_callback("http://localhost:53692/callback?state=xyz").is_none(),
551 "URL missing `code` must be rejected"
552 );
553 }
554}