Skip to main content

agent_core/core/auth/
mod.rs

1//! OAuth 2.0 Authorization Code + PKCE flow for Anthropic (Claude Pro/Max).
2//!
3//! Implements the same flow as Claude Code and Pi coding agent:
4//! 1. Generate PKCE verifier + challenge
5//! 2. Start localhost callback server
6//! 3. Open browser to claude.ai/oauth/authorize
7//! 4. Capture redirect with auth code
8//! 5. Exchange code for access + refresh tokens
9//! 6. Save to ~/.pi/agent/auth.json (shared with Pi)
10
11use 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
35// ── Re-exports ──────────────────────────────────────────────────────────────────
36
37pub 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
76// ── Constants (match Claude Code / Pi) ──────────────────────────────────────
77
78pub(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// ── Types ───────────────────────────────────────────────────────────────────
86
87#[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
98/// Redacting Debug — never print refresh/access tokens (review finding H1).
99impl 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/// Result from the OAuth callback.
130#[derive(Debug, Clone, PartialEq, Eq)]
131pub struct CallbackResult {
132    pub code: String,
133    pub state: String,
134}
135
136/// Check if the current token is expired.
137///
138/// Note the effective ~5-minute safety margin lives in the stored value, not
139/// here: `refresh_token`/`exchange_code_for_tokens` bake a 5-minute buffer into
140/// `expires` (`now + expires_in*1000 - 5min`). So a bare `now >= expires` check
141/// already fires ~5 minutes before the credential actually dies at the provider.
142pub fn is_token_expired(creds: &OAuthCredentials) -> bool {
143    now_millis() >= creds.expires
144}
145
146// ── Helpers ─────────────────────────────────────────────────────────────────
147
148pub(crate) fn now_millis() -> u64 {
149    crate::epoch_millis()
150}
151
152/// Try to parse manual input as a redirect URL or code#state shorthand.
153///
154/// Returns `(Some(code), Some(state))` only when both components are present
155/// and unambiguous. The `None` state path has been intentionally removed:
156/// previously the caller defaulted a missing state to the expected value
157/// (`parsed_state.unwrap_or(manual_state)`), which made the downstream
158/// `result.state != state` check compare a value against itself — CSRF
159/// protection silently nullified. By never returning `Some(code)` without
160/// `Some(state)`, the CSRF guard is guaranteed to do real work.
161fn parse_manual_input(input: &str) -> (Option<String>, Option<String>) {
162    let trimmed = input.trim();
163
164    // Try as full URL (e.g. the redirect from the browser)
165    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    // Try as "code#state" format (Claude Code manual flow)
180    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    // Bare code with no state: REJECTED. Accepting it and defaulting the state
188    // to `manual_state` would cause the CSRF check to compare a value to itself.
189    // The caller (`manual_paste_to_callback`) will return `None` for this input.
190    (None, None)
191}
192
193/// Validate user-pasted OAuth authorization input for the Anthropic manual
194/// fallback flow.
195///
196/// Returns `Some(CallbackResult)` only if the input contains BOTH a `code`
197/// and a `state`. Previously the caller defaulted a missing `state` to the
198/// original CSRF token (`parsed_state.unwrap_or(manual_state)`), which
199/// silently bypassed the CSRF check — the state comparison became
200/// `manual_state == state` (always true). By requiring an explicit state
201/// here, a bare code paste or a URL without `state` is rejected outright.
202fn 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
210// ── High-level login flow ───────────────────────────────────────────────────
211
212/// Run the full OAuth login flow. Returns saved credentials.
213pub async fn login() -> std::result::Result<OAuthCredentials, String> {
214    let port = CALLBACK_PORT;
215
216    // 1. Generate PKCE
217    let verifier = generate_code_verifier();
218    let challenge = generate_code_challenge(&verifier);
219    let state = generate_state();
220
221    // 2. Start callback server
222    let (rx, server_handle) = start_callback_server(state.clone(), port).await?;
223
224    // 3. Build URL and open browser
225    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    // Also provide manual paste option
237    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    // 4. Wait for either callback or manual input
267    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    // 5. Verify state
287    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    // 6. Exchange code for tokens
295    let creds = exchange_code_for_tokens(&result.code, &result.state, &verifier, port).await?;
296
297    // 7. Shut down callback server
298    server_handle.shutdown().await;
299
300    // 8. Save to auth.json
301    save_auth(&creds)?;
302
303    Ok(creds)
304}
305
306// ── Anthropic-specific CSRF regression tests ─────────────────────────────────
307//
308// See also: openai_codex::tests for the parallel codex test suite.
309// These mirror the codex tests — same invariants, same naming pattern.
310
311/// Pinned experimental Copilot models base URL for broker catalog proxy.
312pub(crate) fn github_copilot_models_base_url() -> &'static str {
313    "https://api.githubcopilot.com"
314}
315
316/// Headers for the experimental Copilot models GET (no secrets).
317pub(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    // ── manual_paste_to_callback: CSRF guard on the Anthropic manual paste path ──
501    //
502    // Mirrors the regression suite in openai_codex::tests (manual_paste_to_callback).
503    // Pre-fix: parse_manual_input returned (Some(code), None) for bare codes,
504    // and the caller did `parsed_state.unwrap_or(manual_state)`, making the
505    // CSRF check `manual_state == state` — always true — bypass.
506    // Fix: removed bare-code fallback from parse_manual_input + extracted
507    // manual_paste_to_callback that requires both code AND state.
508
509    #[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        // CSRF regression: a bare code with no state was previously defaulted
520        // to `manual_state`, making the CSRF check trivially true.
521        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        // "code#state" is the Claude Code shorthand; both components explicit.
538        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}