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