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