Skip to main content

oxicode_ai/
oauth.rs

1//! OAuth authentication system for oxicode-ai
2//!
3//! Supports PKCE-based OAuth flows for:
4//! - Anthropic (authorization code + PKCE)
5//! - OpenAI Codex (authorization code + PKCE)
6//! - GitHub Copilot (device flow)
7//!
8//! Token persistence to `<product-home>/auth.json` (`$OXICODE_HOME` or `~/.oxicode`) with secure file permissions.
9
10use base64::Engine;
11use base64::engine::general_purpose::URL_SAFE_NO_PAD;
12use chrono::{DateTime, Utc};
13use rand::RngCore;
14use serde::{Deserialize, Serialize};
15use sha2::{Digest, Sha256};
16use std::collections::HashMap;
17use std::fs;
18use std::io::{self, Write};
19use std::path::PathBuf;
20
21// ---------------------------------------------------------------------------
22// Error types
23// ---------------------------------------------------------------------------
24
25/// Errors produced by the OAuth subsystem.
26#[derive(Debug, thiserror::Error)]
27#[non_exhaustive]
28pub enum OAuthError {
29    #[error("IO error: {0}")]
30    /// io variant.
31    Io(#[from] io::Error),
32
33    #[error("HTTP request failed: {0}")]
34    /// http variant.
35    Http(#[from] reqwest::Error),
36
37    #[error("JSON error: {0}")]
38    /// json variant.
39    Json(#[from] serde_json::Error),
40
41    #[error("Token expired and no refresh_token available")]
42    /// no refresh token variant.
43    NoRefreshToken,
44
45    #[error("Token refresh failed: {0}")]
46    /// refresh failed variant.
47    RefreshFailed(String),
48
49    #[error("Device flow polling timed out after {0}s")]
50    /// device flow timeout variant.
51    DeviceFlowTimeout(u64),
52
53    #[error("Device flow authorization pending")]
54    /// device flow pending variant.
55    DeviceFlowPending,
56
57    #[error("Device flow rejected by user")]
58    /// device flow rejected variant.
59    DeviceFlowRejected,
60
61    #[error("Missing environment variable: {0}")]
62    /// missing env variant.
63    MissingEnv(String),
64
65    #[error("Invalid state: {0}")]
66    /// invalid state variant.
67    InvalidState(String),
68
69    /// Authorization endpoint URL is malformed.
70    #[error("Invalid authorization endpoint URL: {0}")]
71    InvalidAuthorizationEndpoint(#[from] url::ParseError),
72}
73
74type Result<T> = std::result::Result<T, OAuthError>;
75
76// ---------------------------------------------------------------------------
77// Token data model
78// ---------------------------------------------------------------------------
79
80/// An OAuth token bundle as stored on disk.
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct TokenBundle {
83    /// The bearer access token.
84    pub access_token: String,
85    /// Optional refresh token.
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub refresh_token: Option<String>,
88    /// Token type, e.g. "Bearer".
89    #[serde(default = "default_token_type")]
90    pub token_type: String,
91    /// Time at which the token was obtained.
92    pub obtained_at: DateTime<Utc>,
93    /// Seconds until expiry (0 = unknown / never expires).
94    #[serde(default)]
95    pub expires_in: u64,
96    /// Granted scopes (space-separated).
97    #[serde(skip_serializing_if = "Option::is_none")]
98    pub scope: Option<String>,
99}
100
101fn default_token_type() -> String {
102    "Bearer".to_string()
103}
104
105impl TokenBundle {
106    /// Returns true when the token is expired (with a 60-second safety margin).
107    pub fn is_expired(&self) -> bool {
108        if self.expires_in == 0 {
109            return false; // never expires / unknown
110        }
111        let expires_at = self.obtained_at + chrono::Duration::seconds(self.expires_in as i64);
112        Utc::now() >= expires_at - chrono::Duration::seconds(60)
113    }
114}
115
116/// The on-disk auth file structure: a map from provider name to token bundle.
117#[derive(Debug, Default, Serialize, Deserialize)]
118pub struct AuthStore {
119    /// Map from provider name to token bundle.
120    #[serde(flatten)]
121    pub tokens: HashMap<String, TokenBundle>,
122}
123
124// ---------------------------------------------------------------------------
125// File-based token persistence
126// ---------------------------------------------------------------------------
127
128/// Returns the default path for the auth store.
129///
130/// Resolves to `<product-home>/auth.json`, where the product home is
131/// `$OXICODE_HOME` or `~/.oxicode` (see [`crate::product_env`]). The parent
132/// directory is created on demand.
133pub fn default_auth_path() -> Result<PathBuf> {
134    let path = crate::product_env::auth_path().ok_or_else(|| {
135        OAuthError::InvalidState(
136            "Cannot determine product home directory (neither OXICODE_HOME nor HOME is set)".into(),
137        )
138    })?;
139    if let Some(parent) = path.parent()
140        && !parent.exists()
141    {
142        fs::create_dir_all(parent)?;
143    }
144    Ok(path)
145}
146
147/// Load the auth store from disk.
148pub fn load_auth_store() -> Result<AuthStore> {
149    let path = default_auth_path()?;
150    if !path.exists() {
151        return Ok(AuthStore::default());
152    }
153    let data = fs::read_to_string(&path)?;
154    let store: AuthStore = serde_json::from_str(&data)?;
155    Ok(store)
156}
157
158/// Persist the auth store to disk with mode 0o600 where possible.
159pub fn save_auth_store(store: &AuthStore) -> Result<()> {
160    let path = default_auth_path()?;
161    let json = serde_json::to_string_pretty(store)?;
162
163    // Write atomically: temp file → rename.
164    let tmp_path = path.with_extension("json.tmp");
165    {
166        let mut file = fs::File::create(&tmp_path)?;
167        file.write_all(json.as_bytes())?;
168        file.flush()?;
169        // Set permissions on unix.
170        #[cfg(unix)]
171        {
172            use std::os::unix::fs::PermissionsExt;
173            let perms = fs::Permissions::from_mode(0o600);
174            fs::set_permissions(&tmp_path, perms)?;
175        }
176    }
177    fs::rename(&tmp_path, &path)?;
178    Ok(())
179}
180
181/// Convenience: load a token for a provider key (e.g. "anthropic").
182pub fn load_token(provider: &str) -> Result<Option<TokenBundle>> {
183    let store = load_auth_store()?;
184    Ok(store.tokens.get(provider).cloned())
185}
186
187/// Convenience: save a token for a provider key.
188pub fn save_token(provider: &str, token: &TokenBundle) -> Result<()> {
189    let mut store = load_auth_store()?;
190    store.tokens.insert(provider.to_string(), token.clone());
191    save_auth_store(&store)
192}
193
194/// Remove a stored token.
195pub fn remove_token(provider: &str) -> Result<()> {
196    let mut store = load_auth_store()?;
197    store.tokens.remove(provider);
198    save_auth_store(&store)
199}
200
201// ---------------------------------------------------------------------------
202// PKCE helpers
203// ---------------------------------------------------------------------------
204
205/// Generate a cryptographically-random code_verifier (43 chars, RFC 7636 §4.1).
206pub fn generate_code_verifier() -> String {
207    let mut bytes = [0u8; 32]; // 32 bytes → 43 base64url chars
208    rand::rng().fill_bytes(&mut bytes);
209    URL_SAFE_NO_PAD.encode(bytes)
210}
211
212/// Derive the code_challenge from a code_verifier using S256 (SHA-256 + base64url).
213pub fn derive_code_challenge(verifier: &str) -> String {
214    let mut hasher = Sha256::new();
215    hasher.update(verifier.as_bytes());
216    let hash = hasher.finalize();
217    URL_SAFE_NO_PAD.encode(hash)
218}
219
220// ---------------------------------------------------------------------------
221// Provider configuration
222// ---------------------------------------------------------------------------
223
224/// Configuration for a PKCE-based authorization-code OAuth provider.
225#[derive(Debug, Clone)]
226pub struct OAuthConfig {
227    /// OAuth authorization endpoint URL.
228    pub authorization_endpoint: String,
229    /// OAuth token endpoint URL.
230    pub token_endpoint: String,
231    /// OAuth client identifier.
232    pub client_id: String,
233    /// OAuth redirect URI.
234    pub redirect_uri: String,
235    /// Space-separated scopes.
236    pub scopes: String,
237}
238
239/// Anthropic OAuth configuration.
240pub fn anthropic_config() -> Result<OAuthConfig> {
241    let client_id = std::env::var("ANTHROPIC_OAUTH_CLIENT_ID")
242        .map_err(|_| OAuthError::MissingEnv("ANTHROPIC_OAUTH_CLIENT_ID".into()))?;
243    Ok(OAuthConfig {
244        authorization_endpoint: "https://console.anthropic.com/api/oauth".into(),
245        token_endpoint: "https://console.anthropic.com/api/oauth/token".into(),
246        client_id,
247        redirect_uri: "http://localhost:8787/callback".into(),
248        scopes: "org.api.read org.api.write".into(),
249    })
250}
251
252/// OpenAI Codex OAuth configuration.
253pub fn openai_codex_config() -> Result<OAuthConfig> {
254    let client_id = std::env::var("OPENAI_OAUTH_CLIENT_ID")
255        .map_err(|_| OAuthError::MissingEnv("OPENAI_OAUTH_CLIENT_ID".into()))?;
256    Ok(OAuthConfig {
257        authorization_endpoint: "https://auth.openai.com/authorize".into(),
258        token_endpoint: "https://auth.openai.com/oauth/token".into(),
259        client_id,
260        redirect_uri: "http://localhost:8787/callback".into(),
261        scopes: "".into(),
262    })
263}
264
265// ---------------------------------------------------------------------------
266// Authorization URL builder (PKCE)
267// ---------------------------------------------------------------------------
268
269/// PKCE state produced when starting an authorization flow.
270#[derive(Debug, Clone)]
271pub struct PkceState {
272    /// PKCE code verifier.
273    pub code_verifier: String,
274    /// PKCE code challenge (S256).
275    pub code_challenge: String,
276    /// Full authorization URL to redirect the user to.
277    pub authorization_url: String,
278    /// Opaque state parameter for CSRF protection.
279    pub state: String,
280}
281
282/// Build a PKCE authorization URL, returning [`OAuthError`] instead of
283/// panicking when the configured authorization endpoint is not a valid URL.
284///
285/// This is the non-panicking variant; new code should prefer it over
286/// [`build_authorization_url`]. The legacy function delegates here and
287/// preserves its historical panic-on-malformed-URL behavior for backward
288/// compatibility.
289pub fn build_authorization_url_result(config: &OAuthConfig) -> Result<PkceState> {
290    let code_verifier = generate_code_verifier();
291    let code_challenge = derive_code_challenge(&code_verifier);
292    let state = generate_state_token();
293
294    let mut url = url::Url::parse(&config.authorization_endpoint)?;
295    url.query_pairs_mut()
296        .append_pair("response_type", "code")
297        .append_pair("client_id", &config.client_id)
298        .append_pair("redirect_uri", &config.redirect_uri)
299        .append_pair("code_challenge", &code_challenge)
300        .append_pair("code_challenge_method", "S256")
301        .append_pair("state", &state);
302
303    if !config.scopes.is_empty() {
304        url.query_pairs_mut().append_pair("scope", &config.scopes);
305    }
306
307    Ok(PkceState {
308        code_verifier,
309        code_challenge,
310        authorization_url: url.to_string(),
311        state,
312    })
313}
314
315/// Build a PKCE authorization URL for the given provider config.
316///
317/// **Panics** if the configured authorization endpoint is not a valid URL.
318/// This is a recoverable config error; prefer [`build_authorization_url_result`].
319///
320/// Deprecated: retained for backward compatibility; delegates to
321/// [`build_authorization_url_result`] and panics on the error path to
322/// preserve the historical behavior. Will be removed in a future release.
323#[deprecated(
324    since = "0.64.0",
325    note = "use build_authorization_url_result instead; will be removed in 0.66.0"
326)]
327pub fn build_authorization_url(config: &OAuthConfig) -> PkceState {
328    // LEGACY: preserve the historical panic-on-malformed-URL behavior. The
329    // recoverable, non-panicking path lives in `build_authorization_url_result`.
330    #[allow(clippy::expect_used)]
331    build_authorization_url_result(config).expect("invalid authorization endpoint")
332}
333
334/// Generate an opaque state parameter (22 random base64url chars).
335fn generate_state_token() -> String {
336    let mut bytes = [0u8; 16];
337    rand::rng().fill_bytes(&mut bytes);
338    URL_SAFE_NO_PAD.encode(bytes)
339}
340
341// ---------------------------------------------------------------------------
342// Token exchange (authorization code → access token)
343// ---------------------------------------------------------------------------
344
345/// Exchange an authorization code for a token bundle.
346pub async fn exchange_code(
347    client: &reqwest::Client,
348    config: &OAuthConfig,
349    pkce: &PkceState,
350    code: &str,
351) -> Result<TokenBundle> {
352    #[derive(Serialize)]
353    struct TokenRequest {
354        grant_type: String,
355        code: String,
356        redirect_uri: String,
357        client_id: String,
358        code_verifier: String,
359    }
360
361    #[derive(Deserialize)]
362    struct TokenResponse {
363        access_token: String,
364        #[serde(default)]
365        refresh_token: Option<String>,
366        #[serde(default = "default_token_type")]
367        token_type: String,
368        #[serde(default)]
369        expires_in: u64,
370        #[serde(default)]
371        scope: Option<String>,
372    }
373
374    let body = TokenRequest {
375        grant_type: "authorization_code".into(),
376        code: code.into(),
377        redirect_uri: config.redirect_uri.clone(),
378        client_id: config.client_id.clone(),
379        code_verifier: pkce.code_verifier.clone(),
380    };
381
382    let resp = client
383        .post(&config.token_endpoint)
384        .header("content-type", "application/json")
385        .header("accept", "application/json")
386        .json(&body)
387        .send()
388        .await?;
389
390    let status = resp.status();
391    if !status.is_success() {
392        let text = resp.text().await.unwrap_or_default();
393        return Err(OAuthError::RefreshFailed(format!(
394            "Token exchange failed ({status}): {text}"
395        )));
396    }
397
398    let tr: TokenResponse = resp.json().await?;
399    Ok(TokenBundle {
400        access_token: tr.access_token,
401        refresh_token: tr.refresh_token,
402        token_type: tr.token_type,
403        obtained_at: Utc::now(),
404        expires_in: tr.expires_in,
405        scope: tr.scope,
406    })
407}
408
409// ---------------------------------------------------------------------------
410// Token refresh
411// ---------------------------------------------------------------------------
412
413/// Attempt to refresh an expired token.
414pub async fn refresh_token(
415    client: &reqwest::Client,
416    config: &OAuthConfig,
417    bundle: &TokenBundle,
418) -> Result<TokenBundle> {
419    let refresh = bundle
420        .refresh_token
421        .as_ref()
422        .ok_or(OAuthError::NoRefreshToken)?;
423
424    #[derive(Serialize)]
425    struct RefreshRequest {
426        grant_type: String,
427        refresh_token: String,
428        client_id: String,
429    }
430
431    #[derive(Deserialize)]
432    struct TokenResponse {
433        access_token: String,
434        #[serde(default)]
435        refresh_token: Option<String>,
436        #[serde(default = "default_token_type")]
437        token_type: String,
438        #[serde(default)]
439        expires_in: u64,
440        #[serde(default)]
441        scope: Option<String>,
442    }
443
444    let body = RefreshRequest {
445        grant_type: "refresh_token".into(),
446        refresh_token: refresh.clone(),
447        client_id: config.client_id.clone(),
448    };
449
450    let resp = client
451        .post(&config.token_endpoint)
452        .header("content-type", "application/json")
453        .header("accept", "application/json")
454        .json(&body)
455        .send()
456        .await?;
457
458    let status = resp.status();
459    if !status.is_success() {
460        let text = resp.text().await.unwrap_or_default();
461        return Err(OAuthError::RefreshFailed(format!(
462            "Refresh failed ({status}): {text}"
463        )));
464    }
465
466    let tr: TokenResponse = resp.json().await?;
467    Ok(TokenBundle {
468        access_token: tr.access_token,
469        refresh_token: tr.refresh_token.or_else(|| Some(refresh.clone())),
470        token_type: tr.token_type,
471        obtained_at: Utc::now(),
472        expires_in: tr.expires_in,
473        scope: tr.scope,
474    })
475}
476
477/// Ensure a valid token is available, refreshing if necessary.
478/// Returns a token bundle (possibly refreshed).
479pub async fn ensure_valid_token(
480    client: &reqwest::Client,
481    config: &OAuthConfig,
482    provider_key: &str,
483) -> Result<TokenBundle> {
484    let bundle = load_token(provider_key)?.ok_or(OAuthError::InvalidState(format!(
485        "No token stored for {provider_key}"
486    )))?;
487
488    if !bundle.is_expired() {
489        return Ok(bundle);
490    }
491
492    let refreshed = refresh_token(client, config, &bundle).await?;
493    save_token(provider_key, &refreshed)?;
494    Ok(refreshed)
495}
496
497// ---------------------------------------------------------------------------
498// GitHub device flow
499// ---------------------------------------------------------------------------
500
501/// Response from GitHub's device-code endpoint.
502#[derive(Debug, Deserialize)]
503pub struct DeviceCodeResponse {
504    /// Device verification code.
505    pub device_code: String,
506    /// User-visible code to enter on the verification page.
507    pub user_code: String,
508    /// URL the user should visit to authorize.
509    pub verification_uri: String,
510    /// Optional complete verification URL (includes user code).
511    #[serde(default)]
512    pub verification_uri_complete: Option<String>,
513    /// Polling interval in seconds.
514    pub interval: u64,
515    /// Seconds until the device code expires.
516    pub expires_in: u64,
517}
518
519/// Result of the device-flow token polling.
520#[derive(Debug)]
521pub enum DeviceFlowResult {
522    /// success variant.
523    Success(TokenBundle),
524    /// pending variant.
525    Pending,
526    /// rejected variant.
527    Rejected,
528    /// timeout variant.
529    Timeout(u64),
530}
531
532/// Step 1: Request a device code from GitHub.
533pub async fn github_request_device_code(
534    client: &reqwest::Client,
535    client_id: &str,
536    scope: &str,
537) -> Result<DeviceCodeResponse> {
538    #[derive(Serialize)]
539    struct Body {
540        client_id: String,
541        scope: String,
542    }
543
544    let resp = client
545        .post("https://github.com/login/device/code")
546        .header("accept", "application/json")
547        .json(&Body {
548            client_id: client_id.into(),
549            scope: scope.into(),
550        })
551        .send()
552        .await?;
553
554    let status = resp.status();
555    if !status.is_success() {
556        let text = resp.text().await.unwrap_or_default();
557        return Err(OAuthError::RefreshFailed(format!(
558            "Device code request failed ({status}): {text}"
559        )));
560    }
561
562    Ok(resp.json().await?)
563}
564
565/// Step 2: Poll GitHub for the access token.
566///
567/// `timeout_secs` caps total polling duration. Returns `DeviceFlowResult::Success`
568/// once the user authorizes, `Pending` if still waiting, or `Rejected` on error.
569pub async fn github_poll_for_token(
570    client: &reqwest::Client,
571    client_id: &str,
572    device_code: &str,
573    timeout_secs: u64,
574) -> Result<DeviceFlowResult> {
575    #[derive(Serialize)]
576    struct Body {
577        client_id: String,
578        device_code: String,
579        grant_type: String,
580    }
581
582    #[derive(Deserialize)]
583    struct TokenResponse {
584        #[serde(default)]
585        access_token: Option<String>,
586        #[serde(default)]
587        error: Option<String>,
588        #[serde(default)]
589        token_type: Option<String>,
590        #[serde(default)]
591        scope: Option<String>,
592    }
593
594    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
595
596    loop {
597        if std::time::Instant::now() > deadline {
598            return Ok(DeviceFlowResult::Timeout(timeout_secs));
599        }
600
601        let resp = client
602            .post("https://github.com/login/oauth/access_token")
603            .header("accept", "application/json")
604            .json(&Body {
605                client_id: client_id.into(),
606                device_code: device_code.into(),
607                grant_type: "urn:ietf:params:oauth:grant-type:device_code".into(),
608            })
609            .send()
610            .await?;
611
612        let tr: TokenResponse = resp.json().await?;
613
614        if let Some(token) = tr.access_token {
615            return Ok(DeviceFlowResult::Success(TokenBundle {
616                access_token: token,
617                refresh_token: None,
618                token_type: tr.token_type.unwrap_or_else(|| "Bearer".into()),
619                obtained_at: Utc::now(),
620                expires_in: 0, // GitHub tokens don't expire by default
621                scope: tr.scope,
622            }));
623        }
624
625        match tr.error.as_deref() {
626            Some("authorization_pending") => {
627                tokio::time::sleep(std::time::Duration::from_secs(5)).await;
628                continue;
629            }
630            Some("slow_down") => {
631                tokio::time::sleep(std::time::Duration::from_secs(10)).await;
632                continue;
633            }
634            Some("expired_token") => return Ok(DeviceFlowResult::Rejected),
635            Some("access_denied") => return Ok(DeviceFlowResult::Rejected),
636            Some(other) => {
637                return Err(OAuthError::RefreshFailed(format!(
638                    "Device flow error: {other}"
639                )));
640            }
641            None => {
642                // No token and no error — keep polling briefly then bail.
643                tokio::time::sleep(std::time::Duration::from_secs(5)).await;
644                continue;
645            }
646        }
647    }
648}
649
650/// High-level convenience: run the full GitHub device flow and persist the token.
651pub async fn github_device_flow(
652    client: &reqwest::Client,
653    client_id: &str,
654    scope: &str,
655    timeout_secs: u64,
656) -> Result<TokenBundle> {
657    let dc = github_request_device_code(client, client_id, scope).await?;
658
659    println!();
660    println!("=== GitHub Device Authorization ===");
661    println!("  1. Open: {}", dc.verification_uri);
662    println!("  2. Enter code: {}", dc.user_code);
663    if let Some(ref url) = dc.verification_uri_complete {
664        println!("  Or visit: {url}");
665    }
666    println!();
667
668    let result = github_poll_for_token(client, client_id, &dc.device_code, timeout_secs).await?;
669
670    match result {
671        DeviceFlowResult::Success(token) => {
672            save_token("github", &token)?;
673            println!("✓ GitHub authentication successful.");
674            Ok(token)
675        }
676        DeviceFlowResult::Pending => Err(OAuthError::DeviceFlowPending),
677        DeviceFlowResult::Rejected => Err(OAuthError::DeviceFlowRejected),
678        DeviceFlowResult::Timeout(s) => Err(OAuthError::DeviceFlowTimeout(s)),
679    }
680}
681
682// ---------------------------------------------------------------------------
683// Tests
684// ---------------------------------------------------------------------------
685
686#[cfg(test)]
687mod tests {
688    use super::*;
689    use tempfile::TempDir;
690
691    // ---- PKCE tests ----
692
693    #[test]
694    fn test_code_verifier_length() {
695        let v = generate_code_verifier();
696        assert!((43..=128).contains(&v.len()), "verifier length {}", v.len());
697    }
698
699    #[test]
700    fn test_code_verifier_is_base64url() {
701        let v = generate_code_verifier();
702        // base64url chars: A-Z a-z 0-9 - _
703        assert!(
704            v.chars()
705                .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
706        );
707    }
708
709    #[test]
710    fn test_code_verifier_uniqueness() {
711        let a = generate_code_verifier();
712        let b = generate_code_verifier();
713        assert_ne!(a, b, "two verifiers should differ");
714    }
715
716    #[test]
717    fn test_code_challenge_deterministic() {
718        let v = generate_code_verifier();
719        let c1 = derive_code_challenge(&v);
720        let c2 = derive_code_challenge(&v);
721        assert_eq!(c1, c2);
722    }
723
724    #[test]
725    fn test_code_challenge_differs_from_verifier() {
726        let v = generate_code_verifier();
727        let c = derive_code_challenge(&v);
728        assert_ne!(v, c);
729    }
730
731    #[test]
732    fn test_code_challenge_is_base64url() {
733        let v = generate_code_verifier();
734        let c = derive_code_challenge(&v);
735        assert!(
736            c.chars()
737                .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
738        );
739    }
740
741    #[test]
742    fn test_known_pkce_vector() {
743        // RFC 7636 Appendix B reference vector
744        let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
745        let challenge = derive_code_challenge(verifier);
746        assert_eq!(challenge, "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM");
747    }
748
749    // ---- TokenBundle / AuthStore tests ----
750
751    #[test]
752    fn test_token_bundle_not_expired_when_no_expiry() {
753        let bundle = TokenBundle {
754            access_token: "abc".into(),
755            refresh_token: None,
756            token_type: "Bearer".into(),
757            obtained_at: Utc::now(),
758            expires_in: 0,
759            scope: None,
760        };
761        assert!(!bundle.is_expired());
762    }
763
764    #[test]
765    fn test_token_bundle_expired() {
766        let bundle = TokenBundle {
767            access_token: "abc".into(),
768            refresh_token: None,
769            token_type: "Bearer".into(),
770            obtained_at: Utc::now() - chrono::Duration::seconds(3600),
771            expires_in: 1800, // expired 30 min ago
772            scope: None,
773        };
774        assert!(bundle.is_expired());
775    }
776
777    #[test]
778    fn test_token_bundle_not_yet_expired() {
779        let bundle = TokenBundle {
780            access_token: "abc".into(),
781            refresh_token: None,
782            token_type: "Bearer".into(),
783            obtained_at: Utc::now(),
784            expires_in: 3600, // expires in 1 hour
785            scope: None,
786        };
787        assert!(!bundle.is_expired());
788    }
789
790    // ---- Auth store round-trip tests ----
791
792    fn setup_temp_store() -> TempDir {
793        tempfile::tempdir().expect("tempdir")
794    }
795
796    fn with_temp_auth_store<F>(f: F)
797    where
798        F: FnOnce(&PathBuf),
799    {
800        let dir = setup_temp_store();
801        let path = dir.path().join("auth.json");
802        // Monkey-patch default_auth_path by using save/load directly with a known path.
803        // For tests we directly exercise the serde round-trip.
804        let mut store = AuthStore::default();
805        store.tokens.insert(
806            "test-provider".into(),
807            TokenBundle {
808                access_token: "tok_abc123".into(),
809                refresh_token: Some("ref_xyz".into()),
810                token_type: "Bearer".into(),
811                obtained_at: Utc::now(),
812                expires_in: 3600,
813                scope: Some("read write".into()),
814            },
815        );
816        let json = serde_json::to_string_pretty(&store).unwrap();
817        fs::write(&path, &json).unwrap();
818
819        f(&path);
820
821        // Verify round-trip
822        let loaded: AuthStore = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
823        assert_eq!(loaded.tokens["test-provider"].access_token, "tok_abc123");
824        assert_eq!(
825            loaded.tokens["test-provider"].refresh_token.as_deref(),
826            Some("ref_xyz")
827        );
828    }
829
830    #[test]
831    fn test_auth_store_round_trip() {
832        with_temp_auth_store(|_| {});
833    }
834
835    #[test]
836    fn test_auth_store_missing_file() {
837        let dir = tempfile::tempdir().unwrap();
838        let path = dir.path().join("nonexistent.json");
839        assert!(!path.exists());
840        // Loading from a missing file should yield an empty store via serde
841        let result = fs::read_to_string(&path);
842        assert!(result.is_err());
843    }
844
845    // ---- Authorization URL tests ----
846
847    #[test]
848    fn test_build_authorization_url_contains_pkce_params() {
849        let config = OAuthConfig {
850            authorization_endpoint: "https://example.com/authorize".into(),
851            token_endpoint: "https://example.com/token".into(),
852            client_id: "my-client".into(),
853            redirect_uri: "http://localhost:8787/callback".into(),
854            scopes: "read write".into(),
855        };
856        let pkce = build_authorization_url_result(&config).expect("valid config should parse");
857
858        assert!(pkce.authorization_url.contains("code_challenge="));
859        assert!(
860            pkce.authorization_url
861                .contains("code_challenge_method=S256")
862        );
863        assert!(pkce.authorization_url.contains("client_id=my-client"));
864        assert!(pkce.authorization_url.contains("response_type=code"));
865        assert!(pkce.authorization_url.contains("state="));
866        assert!(pkce.authorization_url.contains("scope="));
867        assert_eq!(pkce.code_verifier.len(), 43);
868    }
869
870    #[test]
871    fn test_build_authorization_url_result_rejects_malformed_endpoint() {
872        let config = OAuthConfig {
873            authorization_endpoint: "http://[".into(),
874            token_endpoint: "https://example.com/token".into(),
875            client_id: "my-client".into(),
876            redirect_uri: "http://localhost:8787/callback".into(),
877            scopes: "".into(),
878        };
879        let err = build_authorization_url_result(&config)
880            .expect_err("malformed endpoint must not produce a URL");
881        assert!(
882            matches!(err, OAuthError::InvalidAuthorizationEndpoint(_)),
883            "expected InvalidAuthorizationEndpoint, got {err:?}"
884        );
885    }
886
887    #[test]
888    fn test_state_token_length() {
889        let state = generate_state_token();
890        assert!(state.len() >= 16, "state token should be at least 16 chars");
891    }
892
893    // ---- Serialization tests ----
894
895    #[test]
896    fn test_token_bundle_serialize_deserialize() {
897        let bundle = TokenBundle {
898            access_token: "at_123".into(),
899            refresh_token: None,
900            token_type: "Bearer".into(),
901            obtained_at: "2025-01-01T00:00:00Z".parse().unwrap(),
902            expires_in: 3600,
903            scope: Some("org.api.read".into()),
904        };
905        let json = serde_json::to_string(&bundle).unwrap();
906        let back: TokenBundle = serde_json::from_str(&json).unwrap();
907        assert_eq!(back.access_token, "at_123");
908        assert!(back.refresh_token.is_none());
909        assert_eq!(back.expires_in, 3600);
910    }
911
912    #[test]
913    fn test_auth_store_multiple_providers() {
914        let mut store = AuthStore::default();
915        for name in &["anthropic", "openai", "github"] {
916            store.tokens.insert(
917                (*name).into(),
918                TokenBundle {
919                    access_token: format!("tok_{name}"),
920                    refresh_token: None,
921                    token_type: "Bearer".into(),
922                    obtained_at: Utc::now(),
923                    expires_in: 0,
924                    scope: None,
925                },
926            );
927        }
928        let json = serde_json::to_string(&store).unwrap();
929        let back: AuthStore = serde_json::from_str(&json).unwrap();
930        assert_eq!(back.tokens.len(), 3);
931        assert_eq!(back.tokens["openai"].access_token, "tok_openai");
932    }
933}