Skip to main content

vtcode_auth/
openrouter_oauth.rs

1//! OpenRouter OAuth PKCE authentication flow.
2//!
3//! This module implements the OAuth PKCE flow for OpenRouter, allowing users
4//! to authenticate with their OpenRouter account securely.
5//!
6//! ## Security Model
7//!
8//! Tokens are stored using OS-specific secure storage (keyring) by default,
9//! with fallback to AES-256-GCM encrypted files if the keyring is unavailable.
10//!
11//! ### Keyring Storage (Default)
12//! Uses the platform-native credential store:
13//! - **macOS**: Keychain (accessible only to the user)
14//! - **Windows**: Credential Manager (encrypted with user's credentials)
15//! - **Linux**: Secret Service API / libsecret (requires a keyring daemon)
16//!
17//! ### File Storage (Fallback)
18//! When keyring is unavailable, tokens are stored in:
19//! `~/.vtcode/auth/openrouter.json`
20//!
21//! The file is encrypted with AES-256-GCM using a machine-derived key:
22//! - Machine hostname
23//! - User ID (where available)
24//! - A static salt
25//!
26//! ### Migration
27//! When loading tokens, the system checks the keyring first, then falls back
28//! to file storage for backward compatibility. This allows seamless migration
29//! from file-based to keyring-based storage.
30
31use anyhow::{Context, Result, anyhow};
32use ring::aead::{self, Aad, LessSafeKey, NONCE_LEN, Nonce, UnboundKey};
33use ring::rand::{SecureRandom, SystemRandom};
34use serde::{Deserialize, Serialize};
35use std::fs;
36use std::path::PathBuf;
37
38pub use super::credentials::AuthCredentialsStoreMode;
39use super::credentials::keyring_entry;
40use super::pkce::PkceChallenge;
41use crate::storage_paths::{auth_storage_dir, write_private_file};
42
43/// OpenRouter API endpoints
44const OPENROUTER_AUTH_URL: &str = "https://openrouter.ai/auth";
45const OPENROUTER_KEYS_URL: &str = "https://openrouter.ai/api/v1/auth/keys";
46
47/// Default callback port for localhost OAuth server
48pub const DEFAULT_CALLBACK_PORT: u16 = 8484;
49
50/// Configuration for OpenRouter OAuth authentication.
51#[derive(Debug, Clone, Serialize, Deserialize)]
52#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
53#[serde(default)]
54pub struct OpenRouterOAuthConfig {
55    /// Whether to use OAuth instead of API key
56    pub use_oauth: bool,
57    /// Port for the local callback server
58    pub callback_port: u16,
59    /// Whether to automatically refresh tokens
60    pub auto_refresh: bool,
61    /// Timeout in seconds for completing the OAuth browser flow.
62    pub flow_timeout_secs: u64,
63}
64
65impl Default for OpenRouterOAuthConfig {
66    fn default() -> Self {
67        Self {
68            use_oauth: false,
69            callback_port: DEFAULT_CALLBACK_PORT,
70            auto_refresh: true,
71            flow_timeout_secs: 300,
72        }
73    }
74}
75
76/// Stored OAuth token with metadata.
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct OpenRouterToken {
79    /// The API key obtained via OAuth
80    pub api_key: String,
81    /// When the token was obtained (Unix timestamp)
82    pub obtained_at: u64,
83    /// Optional expiry time (Unix timestamp)
84    pub expires_at: Option<u64>,
85    /// User-friendly label for the token
86    pub label: Option<String>,
87}
88
89impl OpenRouterToken {
90    /// Check if the token has expired.
91    pub fn is_expired(&self) -> bool {
92        if let Some(expires_at) = self.expires_at {
93            let now = std::time::SystemTime::now()
94                .duration_since(std::time::UNIX_EPOCH)
95                .map(|d| d.as_secs())
96                .unwrap_or(0);
97            now >= expires_at
98        } else {
99            false
100        }
101    }
102}
103
104/// Encrypted token wrapper for storage.
105#[derive(Debug, Serialize, Deserialize)]
106struct EncryptedToken {
107    /// Base64-encoded nonce
108    nonce: String,
109    /// Base64-encoded ciphertext (includes auth tag)
110    ciphertext: String,
111    /// Version for future format changes
112    version: u8,
113}
114
115/// Generate the OAuth authorization URL.
116///
117/// # Arguments
118/// * `challenge` - PKCE challenge containing the code_challenge
119/// * `callback_port` - Port for the localhost callback server
120///
121/// # Returns
122/// The full authorization URL to redirect the user to.
123pub fn get_auth_url(challenge: &PkceChallenge, callback_port: u16) -> String {
124    let callback_url = format!("http://localhost:{callback_port}/callback");
125    format!(
126        "{}?callback_url={}&code_challenge={}&code_challenge_method={}",
127        OPENROUTER_AUTH_URL,
128        urlencoding::encode(&callback_url),
129        urlencoding::encode(&challenge.code_challenge),
130        challenge.code_challenge_method
131    )
132}
133
134/// Exchange an authorization code for an API key.
135///
136/// This makes a POST request to OpenRouter's token endpoint with the
137/// authorization code and PKCE verifier.
138///
139/// # Arguments
140/// * `code` - The authorization code from the callback URL
141/// * `challenge` - The PKCE challenge used during authorization
142///
143/// # Returns
144/// The obtained API key on success.
145pub async fn exchange_code_for_token(code: &str, challenge: &PkceChallenge) -> Result<String> {
146    let client = reqwest::Client::new();
147
148    let payload = serde_json::json!({
149        "code": code,
150        "code_verifier": challenge.code_verifier,
151        "code_challenge_method": challenge.code_challenge_method
152    });
153
154    let response = client
155        .post(OPENROUTER_KEYS_URL)
156        .header("Content-Type", "application/json")
157        .json(&payload)
158        .send()
159        .await
160        .context("Failed to send token exchange request")?;
161
162    let status = response.status();
163    let body = response.text().await.context("Failed to read response body")?;
164
165    if !status.is_success() {
166        // Parse error response for better messages
167        if status.as_u16() == 400 {
168            return Err(anyhow!(
169                "Invalid code_challenge_method. Ensure you're using the same method (S256) in both steps."
170            ));
171        } else if status.as_u16() == 403 {
172            return Err(anyhow!(
173                "Invalid code or code_verifier. The authorization code may have expired."
174            ));
175        } else if status.as_u16() == 405 {
176            return Err(anyhow!("Method not allowed. Ensure you're using POST over HTTPS."));
177        }
178        return Err(anyhow!("Token exchange failed (HTTP {status}): {body}"));
179    }
180
181    // Parse the response to extract the key
182    let response_json: serde_json::Value =
183        serde_json::from_str(&body).context("Failed to parse token response")?;
184
185    let api_key = response_json
186        .get("key")
187        .and_then(|v| v.as_str())
188        .ok_or_else(|| anyhow!("Response missing 'key' field"))?
189        .to_string();
190
191    Ok(api_key)
192}
193
194/// Get the path to the token storage file.
195fn get_token_path() -> Result<PathBuf> {
196    Ok(auth_storage_dir()?.join("openrouter.json"))
197}
198
199/// Derive encryption key from machine-specific data.
200fn derive_encryption_key() -> Result<LessSafeKey> {
201    use ring::digest::{SHA256, digest};
202
203    // Collect machine-specific entropy
204    let mut key_material = Vec::new();
205
206    // Hostname
207    if let Ok(hostname) = hostname::get() {
208        key_material.extend_from_slice(hostname.as_encoded_bytes());
209    }
210
211    // User ID (Unix) or username (cross-platform fallback)
212    #[cfg(unix)]
213    {
214        key_material.extend_from_slice(&nix::unistd::getuid().as_raw().to_le_bytes());
215    }
216    #[cfg(not(unix))]
217    {
218        if let Ok(user) = std::env::var("USER").or_else(|_| std::env::var("USERNAME")) {
219            key_material.extend_from_slice(user.as_bytes());
220        }
221    }
222
223    // Static salt (not secret, just ensures consistent key derivation)
224    key_material.extend_from_slice(b"vtcode-openrouter-oauth-v1");
225
226    // Hash to get 32-byte key
227    let hash = digest(&SHA256, &key_material);
228    let key_bytes: &[u8; 32] = hash.as_ref()[..32].try_into().context("Hash too short")?;
229
230    let unbound_key = UnboundKey::new(&aead::AES_256_GCM, key_bytes)
231        .map_err(|_| anyhow!("Invalid key length"))?;
232
233    Ok(LessSafeKey::new(unbound_key))
234}
235
236/// Encrypt token data for storage.
237fn encrypt_token(token: &OpenRouterToken) -> Result<EncryptedToken> {
238    let key = derive_encryption_key()?;
239    let rng = SystemRandom::new();
240
241    // Generate random nonce
242    let mut nonce_bytes = [0u8; NONCE_LEN];
243    rng.fill(&mut nonce_bytes).map_err(|_| anyhow!("Failed to generate nonce"))?;
244
245    // Serialize token to JSON
246    let plaintext = serde_json::to_vec(token).context("Failed to serialize token")?;
247
248    // Encrypt (includes authentication tag)
249    let mut ciphertext = plaintext;
250    let nonce = Nonce::assume_unique_for_key(nonce_bytes);
251    key.seal_in_place_append_tag(nonce, Aad::empty(), &mut ciphertext)
252        .map_err(|_| anyhow!("Encryption failed"))?;
253
254    use base64::{Engine, engine::general_purpose::STANDARD};
255
256    Ok(EncryptedToken {
257        nonce: STANDARD.encode(nonce_bytes),
258        ciphertext: STANDARD.encode(&ciphertext),
259        version: 1,
260    })
261}
262
263/// Decrypt stored token data.
264fn decrypt_token(encrypted: &EncryptedToken) -> Result<OpenRouterToken> {
265    if encrypted.version != 1 {
266        return Err(anyhow!("Unsupported token format version: {}", encrypted.version));
267    }
268
269    use base64::{Engine, engine::general_purpose::STANDARD};
270
271    let key = derive_encryption_key()?;
272
273    let nonce_bytes: [u8; NONCE_LEN] = STANDARD
274        .decode(&encrypted.nonce)
275        .context("Invalid nonce encoding")?
276        .try_into()
277        .map_err(|_| anyhow!("Invalid nonce length"))?;
278
279    let mut ciphertext =
280        STANDARD.decode(&encrypted.ciphertext).context("Invalid ciphertext encoding")?;
281
282    let nonce = Nonce::assume_unique_for_key(nonce_bytes);
283    let plaintext = key.open_in_place(nonce, Aad::empty(), &mut ciphertext).map_err(|_| {
284        anyhow!("Decryption failed - token may be corrupted or from different machine")
285    })?;
286
287    serde_json::from_slice(plaintext).context("Failed to deserialize token")
288}
289
290/// Save an OAuth token to encrypted storage with specified mode.
291///
292/// # Arguments
293/// * `token` - The OAuth token to save
294/// * `mode` - The storage mode to use (defaults to Keyring on macOS)
295pub fn save_oauth_token_with_mode(
296    token: &OpenRouterToken,
297    mode: AuthCredentialsStoreMode,
298) -> Result<()> {
299    let effective_mode = mode.effective_mode();
300
301    match effective_mode {
302        AuthCredentialsStoreMode::Keyring => save_oauth_token_keyring(token),
303        AuthCredentialsStoreMode::File => save_oauth_token_file(token),
304        _ => unreachable!(),
305    }
306}
307
308/// Save token to OS keyring.
309fn save_oauth_token_keyring(token: &OpenRouterToken) -> Result<()> {
310    let entry =
311        keyring_entry("vtcode", "openrouter_oauth").context("Failed to access OS keyring")?;
312
313    // Serialize the entire token to JSON for storage
314    let token_json =
315        serde_json::to_string(token).context("Failed to serialize token for keyring")?;
316
317    entry.set_password(&token_json).context("Failed to store token in OS keyring")?;
318
319    tracing::info!("OAuth token saved to OS keyring");
320    Ok(())
321}
322
323/// Save token to encrypted file.
324fn save_oauth_token_file(token: &OpenRouterToken) -> Result<()> {
325    let path = get_token_path()?;
326    let encrypted = encrypt_token(token)?;
327    let json =
328        serde_json::to_string_pretty(&encrypted).context("Failed to serialize encrypted token")?;
329    write_private_file(&path, json.as_bytes()).context("Failed to write token file")?;
330
331    tracing::info!("OAuth token saved to {}", path.display());
332    Ok(())
333}
334
335/// Save an OAuth token to encrypted storage using the default mode.
336///
337/// Defaults to Keyring on macOS, falls back to file-based storage on other platforms
338/// or when keyring is unavailable.
339pub fn save_oauth_token(token: &OpenRouterToken) -> Result<()> {
340    save_oauth_token_with_mode(token, AuthCredentialsStoreMode::default())
341}
342
343/// Load an OAuth token from storage with specified mode.
344///
345/// Returns `None` if no token exists or the token has expired.
346pub fn load_oauth_token_with_mode(
347    mode: AuthCredentialsStoreMode,
348) -> Result<Option<OpenRouterToken>> {
349    let effective_mode = mode.effective_mode();
350
351    match effective_mode {
352        AuthCredentialsStoreMode::Keyring => load_oauth_token_keyring(),
353        AuthCredentialsStoreMode::File => load_oauth_token_file(),
354        _ => unreachable!(),
355    }
356}
357
358/// Load token from OS keyring.
359fn load_oauth_token_keyring() -> Result<Option<OpenRouterToken>> {
360    let entry = match keyring_entry("vtcode", "openrouter_oauth") {
361        Ok(e) => e,
362        Err(_) => return Ok(None),
363    };
364
365    let token_json = match entry.get_password() {
366        Ok(json) => json,
367        Err(keyring_core::Error::NoEntry) => return Ok(None),
368        Err(e) => return Err(anyhow!("Failed to read from keyring: {e}")),
369    };
370
371    let token: OpenRouterToken =
372        serde_json::from_str(&token_json).context("Failed to parse token from keyring")?;
373
374    // Check expiry
375    if token.is_expired() {
376        tracing::warn!("OAuth token has expired, removing...");
377        clear_oauth_token_keyring()?;
378        return Ok(None);
379    }
380
381    Ok(Some(token))
382}
383
384/// Load token from encrypted file.
385fn load_oauth_token_file() -> Result<Option<OpenRouterToken>> {
386    let path = get_token_path()?;
387
388    if !path.exists() {
389        return Ok(None);
390    }
391
392    let json = fs::read_to_string(&path).context("Failed to read token file")?;
393    let encrypted: EncryptedToken =
394        serde_json::from_str(&json).context("Failed to parse token file")?;
395
396    let token = decrypt_token(&encrypted)?;
397
398    // Check expiry
399    if token.is_expired() {
400        tracing::warn!("OAuth token has expired, removing...");
401        clear_oauth_token_file()?;
402        return Ok(None);
403    }
404
405    Ok(Some(token))
406}
407
408/// Load an OAuth token from storage using the default mode.
409///
410/// This function attempts to load from the OS keyring first (the default).
411/// If no entry exists in the keyring, it falls back to file-based storage
412/// for backward compatibility. This allows seamless migration from file
413/// to keyring storage.
414///
415/// # Errors
416/// Returns an error if:
417/// - Keyring access fails with an error other than "no entry found"
418/// - File access fails (and keyring had no entry)
419pub fn load_oauth_token() -> Result<Option<OpenRouterToken>> {
420    match load_oauth_token_keyring() {
421        Ok(Some(token)) => return Ok(Some(token)),
422        Ok(None) => {
423            // No entry in keyring, try file for backward compatibility
424            tracing::debug!("No token in keyring, checking file storage");
425        }
426        Err(e) => {
427            // Keyring error - only fall back to file for "no entry" errors
428            let error_str = e.to_string().to_lowercase();
429            if error_str.contains("no entry") || error_str.contains("not found") {
430                tracing::debug!("Keyring entry not found, checking file storage");
431            } else {
432                // Actual keyring error - propagate it unless we're in Auto mode
433                // where we can try file as fallback
434                return Err(e);
435            }
436        }
437    }
438
439    // Fall back to file-based storage
440    load_oauth_token_file()
441}
442
443/// Clear token from OS keyring.
444fn clear_oauth_token_keyring() -> Result<()> {
445    let entry = match keyring_entry("vtcode", "openrouter_oauth") {
446        Ok(e) => e,
447        Err(_) => return Ok(()),
448    };
449
450    match entry.delete_credential() {
451        Ok(_) => tracing::info!("OAuth token cleared from keyring"),
452        Err(keyring_core::Error::NoEntry) => {}
453        Err(e) => return Err(anyhow!("Failed to clear keyring entry: {e}")),
454    }
455
456    Ok(())
457}
458
459/// Clear token from file.
460fn clear_oauth_token_file() -> Result<()> {
461    let path = get_token_path()?;
462
463    if path.exists() {
464        fs::remove_file(&path).context("Failed to remove token file")?;
465        tracing::info!("OAuth token cleared from file");
466    }
467
468    Ok(())
469}
470
471/// Clear the stored OAuth token from all storage locations.
472pub fn clear_oauth_token_with_mode(mode: AuthCredentialsStoreMode) -> Result<()> {
473    match mode.effective_mode() {
474        AuthCredentialsStoreMode::Keyring => clear_oauth_token_keyring(),
475        AuthCredentialsStoreMode::File => clear_oauth_token_file(),
476        AuthCredentialsStoreMode::Auto => {
477            let _ = clear_oauth_token_keyring();
478            let _ = clear_oauth_token_file();
479            Ok(())
480        }
481    }
482}
483
484pub fn clear_oauth_token() -> Result<()> {
485    // Clear from both keyring and file to ensure complete removal
486    let _ = clear_oauth_token_keyring();
487    let _ = clear_oauth_token_file();
488
489    tracing::info!("OAuth token cleared from all storage");
490    Ok(())
491}
492
493/// Get the current OAuth authentication status.
494pub fn get_auth_status_with_mode(mode: AuthCredentialsStoreMode) -> Result<AuthStatus> {
495    match load_oauth_token_with_mode(mode)? {
496        Some(token) => {
497            let now = std::time::SystemTime::now()
498                .duration_since(std::time::UNIX_EPOCH)
499                .map(|d| d.as_secs())
500                .unwrap_or(0);
501
502            let age_seconds = now.saturating_sub(token.obtained_at);
503
504            Ok(AuthStatus::Authenticated {
505                label: token.label,
506                age_seconds,
507                expires_in: token.expires_at.map(|e| e.saturating_sub(now)),
508            })
509        }
510        None => Ok(AuthStatus::NotAuthenticated),
511    }
512}
513
514pub fn get_auth_status() -> Result<AuthStatus> {
515    match load_oauth_token()? {
516        Some(token) => {
517            let now = std::time::SystemTime::now()
518                .duration_since(std::time::UNIX_EPOCH)
519                .map(|d| d.as_secs())
520                .unwrap_or(0);
521
522            let age_seconds = now.saturating_sub(token.obtained_at);
523
524            Ok(AuthStatus::Authenticated {
525                label: token.label,
526                age_seconds,
527                expires_in: token.expires_at.map(|e| e.saturating_sub(now)),
528            })
529        }
530        None => Ok(AuthStatus::NotAuthenticated),
531    }
532}
533
534/// OAuth authentication status.
535#[derive(Debug, Clone)]
536pub enum AuthStatus {
537    /// User is authenticated with OAuth
538    Authenticated {
539        /// Optional label for the token
540        label: Option<String>,
541        /// How long ago the token was obtained (seconds)
542        age_seconds: u64,
543        /// Time until expiry (seconds), if known
544        expires_in: Option<u64>,
545    },
546    /// User is not authenticated via OAuth
547    NotAuthenticated,
548}
549
550impl AuthStatus {
551    /// Check if the user is authenticated.
552    pub fn is_authenticated(&self) -> bool {
553        matches!(self, AuthStatus::Authenticated { .. })
554    }
555
556    /// Get a human-readable status string.
557    pub fn display_string(&self) -> String {
558        match self {
559            AuthStatus::Authenticated { label, age_seconds, expires_in } => {
560                let label_str = label.as_ref().map(|l| format!(" ({l})")).unwrap_or_default();
561                let age_str = humanize_duration(*age_seconds);
562                let expiry_str = expires_in
563                    .map(|e| format!(", expires in {}", humanize_duration(e)))
564                    .unwrap_or_default();
565                format!("Authenticated{label_str}, obtained {age_str}{expiry_str}")
566            }
567            AuthStatus::NotAuthenticated => "Not authenticated".to_string(),
568        }
569    }
570}
571
572/// Convert seconds to human-readable duration.
573fn humanize_duration(seconds: u64) -> String {
574    if seconds < 60 {
575        format!("{seconds}s ago")
576    } else if seconds < 3600 {
577        format!("{}m ago", seconds / 60)
578    } else if seconds < 86400 {
579        format!("{}h ago", seconds / 3600)
580    } else {
581        format!("{}d ago", seconds / 86400)
582    }
583}
584
585#[cfg(test)]
586mod tests {
587    use super::*;
588    use assert_fs::TempDir;
589    use serial_test::serial;
590
591    struct TestAuthDirGuard {
592        temp_dir: Option<TempDir>,
593        previous: Option<PathBuf>,
594    }
595
596    impl TestAuthDirGuard {
597        fn new() -> Self {
598            let temp_dir = TempDir::new().expect("create temp auth dir");
599            let previous = crate::storage_paths::auth_storage_dir_override_for_tests()
600                .expect("read auth dir override");
601            crate::storage_paths::set_auth_storage_dir_override_for_tests(Some(
602                temp_dir.path().to_path_buf(),
603            ))
604            .expect("set temp auth dir override");
605            Self { temp_dir: Some(temp_dir), previous }
606        }
607    }
608
609    impl Drop for TestAuthDirGuard {
610        fn drop(&mut self) {
611            crate::storage_paths::set_auth_storage_dir_override_for_tests(self.previous.clone())
612                .expect("restore auth dir override");
613            if let Some(temp_dir) = self.temp_dir.take() {
614                temp_dir.close().expect("remove temp auth dir");
615            }
616        }
617    }
618
619    #[test]
620    fn test_auth_url_generation() {
621        let challenge = PkceChallenge {
622            code_verifier: "test_verifier".to_string(),
623            code_challenge: "test_challenge".to_string(),
624            code_challenge_method: "S256".to_string(),
625        };
626
627        let url = get_auth_url(&challenge, 8484);
628
629        assert!(url.starts_with("https://openrouter.ai/auth"));
630        assert!(url.contains("callback_url="));
631        assert!(url.contains("code_challenge=test_challenge"));
632        assert!(url.contains("code_challenge_method=S256"));
633    }
634
635    #[test]
636    fn test_token_expiry_check() {
637        let now = std::time::SystemTime::now()
638            .duration_since(std::time::UNIX_EPOCH)
639            .unwrap()
640            .as_secs();
641
642        // Non-expired token
643        let token = OpenRouterToken {
644            api_key: "test".to_string(),
645            obtained_at: now,
646            expires_at: Some(now + 3600),
647            label: None,
648        };
649        assert!(!token.is_expired());
650
651        // Expired token
652        let expired_token = OpenRouterToken {
653            api_key: "test".to_string(),
654            obtained_at: now - 7200,
655            expires_at: Some(now - 3600),
656            label: None,
657        };
658        assert!(expired_token.is_expired());
659
660        // No expiry
661        let no_expiry_token = OpenRouterToken {
662            api_key: "test".to_string(),
663            obtained_at: now,
664            expires_at: None,
665            label: None,
666        };
667        assert!(!no_expiry_token.is_expired());
668    }
669
670    #[test]
671    fn test_encryption_roundtrip() {
672        let token = OpenRouterToken {
673            api_key: "sk-test-key-12345".to_string(),
674            obtained_at: 1234567890,
675            expires_at: Some(1234567890 + 86400),
676            label: Some("Test Token".to_string()),
677        };
678
679        let encrypted = encrypt_token(&token).unwrap();
680        let decrypted = decrypt_token(&encrypted).unwrap();
681
682        assert_eq!(decrypted.api_key, token.api_key);
683        assert_eq!(decrypted.obtained_at, token.obtained_at);
684        assert_eq!(decrypted.expires_at, token.expires_at);
685        assert_eq!(decrypted.label, token.label);
686    }
687
688    #[test]
689    fn test_auth_status_display() {
690        let status = AuthStatus::Authenticated {
691            label: Some("My App".to_string()),
692            age_seconds: 3700,
693            expires_in: Some(86000),
694        };
695
696        let display = status.display_string();
697        assert!(display.contains("Authenticated"));
698        assert!(display.contains("My App"));
699    }
700
701    #[test]
702    #[serial]
703    fn file_storage_round_trips_without_plaintext() {
704        let _guard = TestAuthDirGuard::new();
705        let now = std::time::SystemTime::now()
706            .duration_since(std::time::UNIX_EPOCH)
707            .unwrap()
708            .as_secs();
709        let token = OpenRouterToken {
710            api_key: "sk-test-key-12345".to_string(),
711            obtained_at: now,
712            expires_at: Some(now + 86400),
713            label: Some("Test Token".to_string()),
714        };
715
716        save_oauth_token_with_mode(&token, AuthCredentialsStoreMode::File).expect("save token");
717        let loaded =
718            load_oauth_token_with_mode(AuthCredentialsStoreMode::File).expect("load token");
719        assert_eq!(loaded.as_ref().map(|value| &value.api_key), Some(&token.api_key));
720
721        let stored =
722            fs::read_to_string(get_token_path().expect("token path")).expect("read token file");
723        assert!(!stored.contains(&token.api_key));
724    }
725
726    #[test]
727    #[serial]
728    #[cfg(unix)]
729    fn file_storage_uses_private_permissions() {
730        use std::os::unix::fs::PermissionsExt;
731
732        let _guard = TestAuthDirGuard::new();
733        let now = std::time::SystemTime::now()
734            .duration_since(std::time::UNIX_EPOCH)
735            .unwrap()
736            .as_secs();
737        let token = OpenRouterToken {
738            api_key: "sk-test-key-12345".to_string(),
739            obtained_at: now,
740            expires_at: Some(now + 86400),
741            label: Some("Test Token".to_string()),
742        };
743
744        save_oauth_token_with_mode(&token, AuthCredentialsStoreMode::File).expect("save token");
745
746        let metadata =
747            fs::metadata(get_token_path().expect("token path")).expect("read token metadata");
748        assert_eq!(metadata.permissions().mode() & 0o777, 0o600);
749    }
750}