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;
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
48const 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    use_oauth: bool,
57    /// Port for the local callback server
58    pub callback_port: u16,
59    /// Whether to automatically refresh tokens
60    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    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!("Invalid code or code_verifier. The authorization code may have expired."));
173        } else if status.as_u16() == 405 {
174            return Err(anyhow!("Method not allowed. Ensure you're using POST over HTTPS."));
175        }
176        return Err(anyhow!("Token exchange failed (HTTP {status}): {body}"));
177    }
178
179    // Parse the response to extract the key
180    let response_json: serde_json::Value = serde_json::from_str(&body).context("Failed to parse token response")?;
181
182    let api_key = response_json
183        .get("key")
184        .and_then(|v| v.as_str())
185        .ok_or_else(|| anyhow!("Response missing 'key' field"))?
186        .to_string();
187
188    Ok(api_key)
189}
190
191/// Get the path to the token storage file.
192fn get_token_path() -> Result<PathBuf> {
193    Ok(auth_storage_dir()?.join("openrouter.json"))
194}
195
196/// Derive encryption key from machine-specific data.
197fn derive_encryption_key() -> Result<LessSafeKey> {
198    use ring::digest::{SHA256, digest};
199
200    // Collect machine-specific entropy
201    let mut key_material = Vec::new();
202
203    // Hostname
204    if let Ok(hostname) = hostname::get() {
205        key_material.extend_from_slice(hostname.as_encoded_bytes());
206    }
207
208    // User ID (Unix) or username (cross-platform fallback)
209    #[cfg(unix)]
210    {
211        key_material.extend_from_slice(&nix::unistd::getuid().as_raw().to_le_bytes());
212    }
213    #[cfg(not(unix))]
214    {
215        if let Ok(user) = std::env::var("USER").or_else(|_| std::env::var("USERNAME")) {
216            key_material.extend_from_slice(user.as_bytes());
217        }
218    }
219
220    // Static salt (not secret, just ensures consistent key derivation)
221    key_material.extend_from_slice(b"vtcode-openrouter-oauth-v1");
222
223    // Hash to get 32-byte key
224    let hash = digest(&SHA256, &key_material);
225    let key_bytes: &[u8; 32] = hash
226        .as_ref()
227        .get(..32)
228        .context("Hash too short")?
229        .try_into()
230        .context("Hash had an invalid length")?;
231
232    let unbound_key = UnboundKey::new(&aead::AES_256_GCM, key_bytes).map_err(|_| anyhow!("Invalid key length"))?;
233
234    Ok(LessSafeKey::new(unbound_key))
235}
236
237/// Encrypt token data for storage.
238fn encrypt_token(token: &OpenRouterToken) -> Result<EncryptedToken> {
239    let key = derive_encryption_key()?;
240    let rng = SystemRandom::new();
241
242    // Generate random nonce
243    let mut nonce_bytes = [0u8; NONCE_LEN];
244    rng.fill(&mut nonce_bytes).map_err(|_| anyhow!("Failed to generate nonce"))?;
245
246    // Serialize token to JSON
247    let plaintext = serde_json::to_vec(token).context("Failed to serialize token")?;
248
249    // Encrypt (includes authentication tag)
250    let mut ciphertext = plaintext;
251    let nonce = Nonce::assume_unique_for_key(nonce_bytes);
252    key.seal_in_place_append_tag(nonce, Aad::empty(), &mut ciphertext)
253        .map_err(|_| anyhow!("Encryption failed"))?;
254
255    use base64::{Engine, engine::general_purpose::STANDARD};
256
257    Ok(EncryptedToken {
258        nonce: STANDARD.encode(nonce_bytes),
259        ciphertext: STANDARD.encode(&ciphertext),
260        version: 1,
261    })
262}
263
264/// Decrypt stored token data.
265fn decrypt_token(encrypted: &EncryptedToken) -> Result<OpenRouterToken> {
266    if encrypted.version != 1 {
267        return Err(anyhow!("Unsupported token format version: {}", encrypted.version));
268    }
269
270    use base64::{Engine, engine::general_purpose::STANDARD};
271
272    let key = derive_encryption_key()?;
273
274    let nonce_bytes: [u8; NONCE_LEN] = STANDARD
275        .decode(&encrypted.nonce)
276        .context("Invalid nonce encoding")?
277        .try_into()
278        .map_err(|_| anyhow!("Invalid nonce length"))?;
279
280    let mut ciphertext = STANDARD.decode(&encrypted.ciphertext).context("Invalid ciphertext encoding")?;
281
282    let nonce = Nonce::assume_unique_for_key(nonce_bytes);
283    let plaintext = key
284        .open_in_place(nonce, Aad::empty(), &mut ciphertext)
285        .map_err(|_| anyhow!("Decryption failed - token may be corrupted or from different machine"))?;
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(token: &OpenRouterToken, mode: AuthCredentialsStoreMode) -> Result<()> {
296    let effective_mode = mode.effective_mode();
297
298    match effective_mode {
299        AuthCredentialsStoreMode::Keyring => save_oauth_token_keyring(token),
300        AuthCredentialsStoreMode::File => save_oauth_token_file(token),
301        _ => unreachable!(),
302    }
303}
304
305/// Save token to OS keyring.
306fn save_oauth_token_keyring(token: &OpenRouterToken) -> Result<()> {
307    let entry = keyring::entry("vtcode", "openrouter_oauth").context("Failed to access OS keyring")?;
308
309    // Serialize the entire token to JSON for storage
310    let token_json = serde_json::to_string(token).context("Failed to serialize token for keyring")?;
311
312    entry.set_password(&token_json).context("Failed to store token in OS keyring")?;
313
314    tracing::info!("OAuth token saved to OS keyring");
315    Ok(())
316}
317
318/// Save token to encrypted file.
319fn save_oauth_token_file(token: &OpenRouterToken) -> Result<()> {
320    let path = get_token_path()?;
321    let encrypted = encrypt_token(token)?;
322    let json = serde_json::to_string_pretty(&encrypted).context("Failed to serialize encrypted token")?;
323    write_private_file(&path, json.as_bytes()).context("Failed to write token file")?;
324
325    tracing::info!("OAuth token saved to {}", path.display());
326    Ok(())
327}
328
329/// Save an OAuth token to encrypted storage using the default mode.
330///
331/// Defaults to Keyring on macOS, falls back to file-based storage on other platforms
332/// or when keyring is unavailable.
333pub fn save_oauth_token(token: &OpenRouterToken) -> Result<()> {
334    save_oauth_token_with_mode(token, AuthCredentialsStoreMode::default())
335}
336
337/// Load an OAuth token from storage with specified mode.
338///
339/// Returns `None` if no token exists or the token has expired.
340pub fn load_oauth_token_with_mode(mode: AuthCredentialsStoreMode) -> Result<Option<OpenRouterToken>> {
341    let effective_mode = mode.effective_mode();
342
343    match effective_mode {
344        AuthCredentialsStoreMode::Keyring => load_oauth_token_keyring(),
345        AuthCredentialsStoreMode::File => load_oauth_token_file(),
346        _ => unreachable!(),
347    }
348}
349
350/// Load token from OS keyring.
351fn load_oauth_token_keyring() -> Result<Option<OpenRouterToken>> {
352    let entry = match keyring::entry("vtcode", "openrouter_oauth") {
353        Ok(e) => e,
354        Err(_) => return Ok(None),
355    };
356
357    let token_json = match entry.get_password() {
358        Ok(json) => json,
359        Err(keyring_core::Error::NoEntry) => return Ok(None),
360        Err(e) => return Err(anyhow!("Failed to read from keyring: {e}")),
361    };
362
363    let token: OpenRouterToken = serde_json::from_str(&token_json).context("Failed to parse token from keyring")?;
364
365    // Check expiry
366    if token.is_expired() {
367        tracing::warn!("OAuth token has expired, removing...");
368        clear_oauth_token_keyring()?;
369        return Ok(None);
370    }
371
372    Ok(Some(token))
373}
374
375/// Load token from encrypted file.
376fn load_oauth_token_file() -> Result<Option<OpenRouterToken>> {
377    let path = get_token_path()?;
378
379    if !path.exists() {
380        return Ok(None);
381    }
382
383    let json = fs::read_to_string(&path).context("Failed to read token file")?;
384    let encrypted: EncryptedToken = serde_json::from_str(&json).context("Failed to parse token file")?;
385
386    let token = decrypt_token(&encrypted)?;
387
388    // Check expiry
389    if token.is_expired() {
390        tracing::warn!("OAuth token has expired, removing...");
391        clear_oauth_token_file()?;
392        return Ok(None);
393    }
394
395    Ok(Some(token))
396}
397
398/// Load an OAuth token from storage using the default mode.
399///
400/// This function attempts to load from the OS keyring first (the default).
401/// If no entry exists in the keyring, it falls back to file-based storage
402/// for backward compatibility. This allows seamless migration from file
403/// to keyring storage.
404///
405/// # Errors
406/// Returns an error if:
407/// - Keyring access fails with an error other than "no entry found"
408/// - File access fails (and keyring had no entry)
409pub fn load_oauth_token() -> Result<Option<OpenRouterToken>> {
410    match load_oauth_token_keyring() {
411        Ok(Some(token)) => return Ok(Some(token)),
412        Ok(None) => {
413            // No entry in keyring, try file for backward compatibility
414            tracing::debug!("No token in keyring, checking file storage");
415        }
416        Err(e) => {
417            // Keyring error - only fall back to file for "no entry" errors
418            let error_str = e.to_string().to_lowercase();
419            if error_str.contains("no entry") || error_str.contains("not found") {
420                tracing::debug!("Keyring entry not found, checking file storage");
421            } else {
422                // Actual keyring error - propagate it unless we're in Auto mode
423                // where we can try file as fallback
424                return Err(e);
425            }
426        }
427    }
428
429    // Fall back to file-based storage
430    load_oauth_token_file()
431}
432
433/// Clear token from OS keyring.
434fn clear_oauth_token_keyring() -> Result<()> {
435    let entry = match keyring::entry("vtcode", "openrouter_oauth") {
436        Ok(e) => e,
437        Err(_) => return Ok(()),
438    };
439
440    match entry.delete_credential() {
441        Ok(_) => tracing::info!("OAuth token cleared from keyring"),
442        Err(keyring_core::Error::NoEntry) => {}
443        Err(e) => return Err(anyhow!("Failed to clear keyring entry: {e}")),
444    }
445
446    Ok(())
447}
448
449/// Clear token from file.
450fn clear_oauth_token_file() -> Result<()> {
451    let path = get_token_path()?;
452
453    if path.exists() {
454        fs::remove_file(&path).context("Failed to remove token file")?;
455        tracing::info!("OAuth token cleared from file");
456    }
457
458    Ok(())
459}
460
461/// Clear the stored OAuth token from all storage locations.
462pub fn clear_oauth_token_with_mode(mode: AuthCredentialsStoreMode) -> Result<()> {
463    match mode.effective_mode() {
464        AuthCredentialsStoreMode::Keyring => clear_oauth_token_keyring(),
465        AuthCredentialsStoreMode::File => clear_oauth_token_file(),
466        AuthCredentialsStoreMode::Auto => {
467            drop(clear_oauth_token_keyring());
468            drop(clear_oauth_token_file());
469            Ok(())
470        }
471    }
472}
473
474pub fn clear_oauth_token() -> Result<()> {
475    // Clear from both keyring and file to ensure complete removal
476    drop(clear_oauth_token_keyring());
477    drop(clear_oauth_token_file());
478
479    tracing::info!("OAuth token cleared from all storage");
480    Ok(())
481}
482
483/// Get the current OAuth authentication status.
484pub fn get_auth_status_with_mode(mode: AuthCredentialsStoreMode) -> Result<AuthStatus> {
485    match load_oauth_token_with_mode(mode)? {
486        Some(token) => {
487            let now = std::time::SystemTime::now()
488                .duration_since(std::time::UNIX_EPOCH)
489                .map(|d| d.as_secs())
490                .unwrap_or(0);
491
492            let age_seconds = now.saturating_sub(token.obtained_at);
493
494            Ok(AuthStatus::Authenticated {
495                label: token.label,
496                age_seconds,
497                expires_in: token.expires_at.map(|e| e.saturating_sub(now)),
498            })
499        }
500        None => Ok(AuthStatus::NotAuthenticated),
501    }
502}
503
504pub fn get_auth_status() -> Result<AuthStatus> {
505    match load_oauth_token()? {
506        Some(token) => {
507            let now = std::time::SystemTime::now()
508                .duration_since(std::time::UNIX_EPOCH)
509                .map(|d| d.as_secs())
510                .unwrap_or(0);
511
512            let age_seconds = now.saturating_sub(token.obtained_at);
513
514            Ok(AuthStatus::Authenticated {
515                label: token.label,
516                age_seconds,
517                expires_in: token.expires_at.map(|e| e.saturating_sub(now)),
518            })
519        }
520        None => Ok(AuthStatus::NotAuthenticated),
521    }
522}
523
524/// OAuth authentication status.
525#[derive(Debug, Clone)]
526pub enum AuthStatus {
527    /// User is authenticated with OAuth
528    Authenticated {
529        /// Optional label for the token
530        label: Option<String>,
531        /// How long ago the token was obtained (seconds)
532        age_seconds: u64,
533        /// Time until expiry (seconds), if known
534        expires_in: Option<u64>,
535    },
536    /// User is not authenticated via OAuth
537    NotAuthenticated,
538}
539
540impl AuthStatus {
541    /// Check if the user is authenticated.
542    pub fn is_authenticated(&self) -> bool {
543        matches!(self, AuthStatus::Authenticated { .. })
544    }
545
546    /// Get a human-readable status string.
547    fn display_string(&self) -> String {
548        match self {
549            AuthStatus::Authenticated { label, age_seconds, expires_in } => {
550                let label_str = label.as_ref().map(|l| format!(" ({l})")).unwrap_or_default();
551                let age_str = humanize_duration(*age_seconds);
552                let expiry_str = expires_in
553                    .map(|e| format!(", expires in {}", humanize_duration(e)))
554                    .unwrap_or_default();
555                format!("Authenticated{label_str}, obtained {age_str}{expiry_str}")
556            }
557            AuthStatus::NotAuthenticated => "Not authenticated".to_string(),
558        }
559    }
560}
561
562/// Convert seconds to human-readable duration.
563fn humanize_duration(seconds: u64) -> String {
564    if seconds < 60 {
565        format!("{seconds}s ago")
566    } else if seconds < 3600 {
567        format!("{}m ago", seconds / 60)
568    } else if seconds < 86400 {
569        format!("{}h ago", seconds / 3600)
570    } else {
571        format!("{}d ago", seconds / 86400)
572    }
573}
574
575#[cfg(test)]
576mod tests {
577    use super::*;
578    use assert_fs::TempDir;
579    use serial_test::serial;
580
581    struct TestAuthDirGuard {
582        temp_dir: Option<TempDir>,
583        previous: Option<PathBuf>,
584    }
585
586    impl TestAuthDirGuard {
587        fn new() -> Self {
588            let temp_dir = TempDir::new().expect("create temp auth dir");
589            let previous = crate::storage_paths::auth_storage_dir_override_for_tests().expect("read auth dir override");
590            crate::storage_paths::set_auth_storage_dir_override_for_tests(Some(temp_dir.path().to_path_buf()))
591                .expect("set temp auth dir override");
592            Self { temp_dir: Some(temp_dir), previous }
593        }
594    }
595
596    impl Drop for TestAuthDirGuard {
597        fn drop(&mut self) {
598            crate::storage_paths::set_auth_storage_dir_override_for_tests(self.previous.clone())
599                .expect("restore auth dir override");
600            if let Some(temp_dir) = self.temp_dir.take() {
601                temp_dir.close().expect("remove temp auth dir");
602            }
603        }
604    }
605
606    #[test]
607    fn test_auth_url_generation() {
608        let challenge = PkceChallenge {
609            code_verifier: "test_verifier".to_string(),
610            code_challenge: "test_challenge".to_string(),
611            code_challenge_method: "S256".to_string(),
612        };
613
614        let url = get_auth_url(&challenge, 8484);
615
616        assert!(url.starts_with("https://openrouter.ai/auth"));
617        assert!(url.contains("callback_url="));
618        assert!(url.contains("code_challenge=test_challenge"));
619        assert!(url.contains("code_challenge_method=S256"));
620    }
621
622    #[test]
623    fn test_token_expiry_check() {
624        let now = std::time::SystemTime::now()
625            .duration_since(std::time::UNIX_EPOCH)
626            .unwrap()
627            .as_secs();
628
629        // Non-expired token
630        let token = OpenRouterToken {
631            api_key: "test".to_string(),
632            obtained_at: now,
633            expires_at: Some(now + 3600),
634            label: None,
635        };
636        assert!(!token.is_expired());
637
638        // Expired token
639        let expired_token = OpenRouterToken {
640            api_key: "test".to_string(),
641            obtained_at: now - 7200,
642            expires_at: Some(now - 3600),
643            label: None,
644        };
645        assert!(expired_token.is_expired());
646
647        // No expiry
648        let no_expiry_token = OpenRouterToken {
649            api_key: "test".to_string(),
650            obtained_at: now,
651            expires_at: None,
652            label: None,
653        };
654        assert!(!no_expiry_token.is_expired());
655    }
656
657    #[test]
658    fn test_encryption_roundtrip() {
659        let token = OpenRouterToken {
660            api_key: "sk-test-key-12345".to_string(),
661            obtained_at: 1234567890,
662            expires_at: Some(1234567890 + 86400),
663            label: Some("Test Token".to_string()),
664        };
665
666        let encrypted = encrypt_token(&token).unwrap();
667        let decrypted = decrypt_token(&encrypted).unwrap();
668
669        assert_eq!(decrypted.api_key, token.api_key);
670        assert_eq!(decrypted.obtained_at, token.obtained_at);
671        assert_eq!(decrypted.expires_at, token.expires_at);
672        assert_eq!(decrypted.label, token.label);
673    }
674
675    #[test]
676    fn test_auth_status_display() {
677        let status = AuthStatus::Authenticated {
678            label: Some("My App".to_string()),
679            age_seconds: 3700,
680            expires_in: Some(86000),
681        };
682
683        let display = status.display_string();
684        assert!(display.contains("Authenticated"));
685        assert!(display.contains("My App"));
686    }
687
688    #[test]
689    #[serial]
690    fn file_storage_round_trips_without_plaintext() {
691        let _guard = TestAuthDirGuard::new();
692        let now = std::time::SystemTime::now()
693            .duration_since(std::time::UNIX_EPOCH)
694            .unwrap()
695            .as_secs();
696        let token = OpenRouterToken {
697            api_key: "sk-test-key-12345".to_string(),
698            obtained_at: now,
699            expires_at: Some(now + 86400),
700            label: Some("Test Token".to_string()),
701        };
702
703        save_oauth_token_with_mode(&token, AuthCredentialsStoreMode::File).expect("save token");
704        let loaded = load_oauth_token_with_mode(AuthCredentialsStoreMode::File).expect("load token");
705        assert_eq!(loaded.as_ref().map(|value| &value.api_key), Some(&token.api_key));
706
707        let stored = fs::read_to_string(get_token_path().expect("token path")).expect("read token file");
708        assert!(!stored.contains(&token.api_key));
709    }
710
711    #[test]
712    #[serial]
713    #[cfg(unix)]
714    fn file_storage_uses_private_permissions() {
715        use std::os::unix::fs::PermissionsExt;
716
717        let _guard = TestAuthDirGuard::new();
718        let now = std::time::SystemTime::now()
719            .duration_since(std::time::UNIX_EPOCH)
720            .unwrap()
721            .as_secs();
722        let token = OpenRouterToken {
723            api_key: "sk-test-key-12345".to_string(),
724            obtained_at: now,
725            expires_at: Some(now + 86400),
726            label: Some("Test Token".to_string()),
727        };
728
729        save_oauth_token_with_mode(&token, AuthCredentialsStoreMode::File).expect("save token");
730
731        let metadata = fs::metadata(get_token_path().expect("token path")).expect("read token metadata");
732        assert_eq!(metadata.permissions().mode() & 0o777, 0o600);
733    }
734}