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