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