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