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 use the shared credential storage boundary: OS keyring when selected
9//! and AES-256-GCM encrypted files as the fallback backend.
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//! Existing `openrouter.json` files are decrypted and migrated when loaded.
18
19use anyhow::{Context, Result, anyhow};
20use serde::{Deserialize, Serialize};
21use std::fmt;
22
23pub use super::credentials::AuthCredentialsStoreMode;
24use super::pkce::PkceChallenge;
25use crate::openrouter_token_storage::OpenRouterTokenStorage;
26#[cfg(test)]
27use crate::openrouter_token_storage::{
28    decrypt_legacy_token as decrypt_token, encrypt_legacy_token as encrypt_token, legacy_token_path as get_token_path,
29};
30
31/// OpenRouter API endpoints
32const OPENROUTER_AUTH_URL: &str = "https://openrouter.ai/auth";
33const OPENROUTER_KEYS_URL: &str = "https://openrouter.ai/api/v1/auth/keys";
34
35/// Default callback port for localhost OAuth server
36const DEFAULT_CALLBACK_PORT: u16 = 8484;
37
38/// Configuration for OpenRouter OAuth authentication.
39#[derive(Debug, Clone, Serialize, Deserialize)]
40#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
41#[serde(default)]
42pub struct OpenRouterOAuthConfig {
43    /// Whether to use OAuth instead of API key
44    use_oauth: bool,
45    /// Port for the local callback server
46    pub callback_port: u16,
47    /// Whether to automatically refresh tokens
48    auto_refresh: bool,
49    /// Timeout in seconds for completing the OAuth browser flow.
50    pub flow_timeout_secs: u64,
51}
52
53impl Default for OpenRouterOAuthConfig {
54    fn default() -> Self {
55        Self {
56            use_oauth: false,
57            callback_port: DEFAULT_CALLBACK_PORT,
58            auto_refresh: true,
59            flow_timeout_secs: 300,
60        }
61    }
62}
63
64/// Stored OAuth token with metadata.
65#[derive(Clone, Serialize, Deserialize)]
66pub struct OpenRouterToken {
67    /// The API key obtained via OAuth
68    pub api_key: String,
69    /// When the token was obtained (Unix timestamp)
70    pub obtained_at: u64,
71    /// Optional expiry time (Unix timestamp)
72    pub expires_at: Option<u64>,
73    /// User-friendly label for the token
74    pub label: Option<String>,
75}
76
77impl fmt::Debug for OpenRouterToken {
78    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
79        f.debug_struct("OpenRouterToken")
80            .field("api_key", &"<redacted>")
81            .field("obtained_at", &self.obtained_at)
82            .field("expires_at", &self.expires_at)
83            .field("label", &self.label)
84            .finish()
85    }
86}
87
88impl OpenRouterToken {
89    /// Check if the token has expired.
90    fn is_expired(&self) -> bool {
91        if let Some(expires_at) = self.expires_at {
92            let now = std::time::SystemTime::now()
93                .duration_since(std::time::UNIX_EPOCH)
94                .map(|d| d.as_secs())
95                .unwrap_or(0);
96            now >= expires_at
97        } else {
98            false
99        }
100    }
101}
102
103/// Generate the OAuth authorization URL.
104///
105/// # Arguments
106/// * `challenge` - PKCE challenge containing the code_challenge
107/// * `callback_port` - Port for the localhost callback server
108///
109/// # Returns
110/// The full authorization URL to redirect the user to.
111pub fn get_auth_url(challenge: &PkceChallenge, callback_port: u16) -> String {
112    let callback_url = format!("http://localhost:{callback_port}/callback");
113    format!(
114        "{}?callback_url={}&code_challenge={}&code_challenge_method={}",
115        OPENROUTER_AUTH_URL,
116        urlencoding::encode(&callback_url),
117        urlencoding::encode(&challenge.code_challenge),
118        challenge.code_challenge_method
119    )
120}
121
122/// Exchange an authorization code for an API key.
123///
124/// This makes a POST request to OpenRouter's token endpoint with the
125/// authorization code and PKCE verifier.
126///
127/// # Arguments
128/// * `code` - The authorization code from the callback URL
129/// * `challenge` - The PKCE challenge used during authorization
130///
131/// # Returns
132/// The obtained API key on success.
133pub async fn exchange_code_for_token(code: &str, challenge: &PkceChallenge) -> Result<String> {
134    let client = vtcode_commons::http::create_default_client_without_redirects()
135        .context("failed to build openrouter token client")?;
136
137    let payload = serde_json::json!({
138        "code": code,
139        "code_verifier": challenge.code_verifier,
140        "code_challenge_method": challenge.code_challenge_method
141    });
142
143    let response = client
144        .post(OPENROUTER_KEYS_URL)
145        .header("Content-Type", "application/json")
146        .json(&payload)
147        .send()
148        .await
149        .context("Failed to send token exchange request")?;
150
151    let status = response.status();
152    if !status.is_success() {
153        // Never expose the raw response body: OAuth providers may echo codes,
154        // tokens, or other sensitive diagnostics in an error payload.
155        return Err(token_exchange_error(status));
156    }
157
158    // Parse the response to extract the key
159    let body = response.text().await.context("Failed to read response body")?;
160    let response_json: serde_json::Value = serde_json::from_str(&body).context("Failed to parse token response")?;
161
162    let api_key = response_json
163        .get("key")
164        .and_then(|v| v.as_str())
165        .ok_or_else(|| anyhow!("Response missing 'key' field"))?
166        .to_string();
167
168    Ok(api_key)
169}
170
171fn token_exchange_error(status: reqwest::StatusCode) -> anyhow::Error {
172    match status.as_u16() {
173        400 => anyhow!("Invalid code_challenge_method. Ensure you're using the same method (S256) in both steps."),
174        403 => anyhow!("Invalid code or code_verifier. The authorization code may have expired."),
175        405 => anyhow!("Method not allowed. Ensure you're using POST over HTTPS."),
176        _ => anyhow!("Token exchange failed (HTTP {status})"),
177    }
178}
179
180/// Save an OAuth token to encrypted storage with specified mode.
181///
182/// # Arguments
183/// * `token` - The OAuth token to save
184/// * `mode` - The storage mode to use
185pub fn save_oauth_token_with_mode(token: &OpenRouterToken, mode: AuthCredentialsStoreMode) -> Result<()> {
186    OpenRouterTokenStorage::new().save(token, mode)
187}
188
189/// Save an OAuth token to encrypted storage using the default mode.
190///
191/// Uses the configured default credential storage mode.
192pub fn save_oauth_token(token: &OpenRouterToken) -> Result<()> {
193    save_oauth_token_with_mode(token, AuthCredentialsStoreMode::default())
194}
195
196/// Load an OAuth token from storage with specified mode.
197///
198/// Returns `None` if no token exists or the token has expired.
199pub fn load_oauth_token_with_mode(mode: AuthCredentialsStoreMode) -> Result<Option<OpenRouterToken>> {
200    let storage = OpenRouterTokenStorage::new();
201    let Some(token) = storage.load(mode)? else {
202        return Ok(None);
203    };
204
205    if token.is_expired() {
206        tracing::warn!("OpenRouter OAuth token has expired, removing it");
207        storage.clear(mode)?;
208        return Ok(None);
209    }
210
211    Ok(Some(token))
212}
213
214/// Load an OAuth token from storage using the default mode.
215///
216/// This function checks the selected secure backend and migrates the legacy
217/// encrypted file format when necessary.
218pub fn load_oauth_token() -> Result<Option<OpenRouterToken>> {
219    let storage = OpenRouterTokenStorage::new();
220    for mode in [AuthCredentialsStoreMode::Keyring, AuthCredentialsStoreMode::File] {
221        let Some(token) = storage.load(mode)? else {
222            continue;
223        };
224
225        if token.is_expired() {
226            tracing::warn!("OpenRouter OAuth token has expired, removing it");
227            storage.clear(mode)?;
228            continue;
229        }
230
231        return Ok(Some(token));
232    }
233
234    Ok(None)
235}
236
237/// Clear the stored OAuth token using the selected storage mode.
238pub fn clear_oauth_token_with_mode(mode: AuthCredentialsStoreMode) -> Result<()> {
239    OpenRouterTokenStorage::new().clear(mode)
240}
241
242/// Clear the token from both shared backends and the legacy file format.
243pub fn clear_oauth_token() -> Result<()> {
244    OpenRouterTokenStorage::new().clear_all()
245}
246
247/// Get the current OAuth authentication status.
248pub fn get_auth_status_with_mode(mode: AuthCredentialsStoreMode) -> Result<AuthStatus> {
249    match load_oauth_token_with_mode(mode)? {
250        Some(token) => {
251            let now = std::time::SystemTime::now()
252                .duration_since(std::time::UNIX_EPOCH)
253                .map(|d| d.as_secs())
254                .unwrap_or(0);
255
256            let age_seconds = now.saturating_sub(token.obtained_at);
257
258            Ok(AuthStatus::Authenticated {
259                label: token.label,
260                age_seconds,
261                expires_in: token.expires_at.map(|e| e.saturating_sub(now)),
262            })
263        }
264        None => Ok(AuthStatus::NotAuthenticated),
265    }
266}
267
268pub fn get_auth_status() -> Result<AuthStatus> {
269    match load_oauth_token()? {
270        Some(token) => {
271            let now = std::time::SystemTime::now()
272                .duration_since(std::time::UNIX_EPOCH)
273                .map(|d| d.as_secs())
274                .unwrap_or(0);
275
276            let age_seconds = now.saturating_sub(token.obtained_at);
277
278            Ok(AuthStatus::Authenticated {
279                label: token.label,
280                age_seconds,
281                expires_in: token.expires_at.map(|e| e.saturating_sub(now)),
282            })
283        }
284        None => Ok(AuthStatus::NotAuthenticated),
285    }
286}
287
288/// OAuth authentication status.
289#[derive(Debug, Clone)]
290pub enum AuthStatus {
291    /// User is authenticated with OAuth
292    Authenticated {
293        /// Optional label for the token
294        label: Option<String>,
295        /// How long ago the token was obtained (seconds)
296        age_seconds: u64,
297        /// Time until expiry (seconds), if known
298        expires_in: Option<u64>,
299    },
300    /// User is not authenticated via OAuth
301    NotAuthenticated,
302}
303
304impl AuthStatus {
305    /// Check if the user is authenticated.
306    pub fn is_authenticated(&self) -> bool {
307        matches!(self, AuthStatus::Authenticated { .. })
308    }
309
310    /// Get a human-readable status string.
311    fn display_string(&self) -> String {
312        match self {
313            AuthStatus::Authenticated { label, age_seconds, expires_in } => {
314                let label_str = label.as_ref().map(|l| format!(" ({l})")).unwrap_or_default();
315                let age_str = format!("{} ago", vtcode_commons::formatting::humanize_duration_compact(*age_seconds));
316                let expiry_str = expires_in
317                    .map(|e| format!(", expires in {}", vtcode_commons::formatting::humanize_duration_compact(e)))
318                    .unwrap_or_default();
319                format!("Authenticated{label_str}, obtained {age_str}{expiry_str}")
320            }
321            AuthStatus::NotAuthenticated => "Not authenticated".to_string(),
322        }
323    }
324}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329    use assert_fs::TempDir;
330    use serial_test::serial;
331    use std::fs;
332    use std::path::PathBuf;
333
334    struct TestAuthDirGuard {
335        temp_dir: Option<TempDir>,
336        previous: Option<PathBuf>,
337    }
338
339    impl TestAuthDirGuard {
340        fn new() -> Self {
341            let temp_dir = TempDir::new().expect("create temp auth dir");
342            let previous = crate::storage_paths::auth_storage_dir_override_for_tests().expect("read auth dir override");
343            crate::storage_paths::set_auth_storage_dir_override_for_tests(Some(temp_dir.path().to_path_buf()))
344                .expect("set temp auth dir override");
345            Self { temp_dir: Some(temp_dir), previous }
346        }
347    }
348
349    impl Drop for TestAuthDirGuard {
350        fn drop(&mut self) {
351            crate::storage_paths::set_auth_storage_dir_override_for_tests(self.previous.clone())
352                .expect("restore auth dir override");
353            if let Some(temp_dir) = self.temp_dir.take() {
354                temp_dir.close().expect("remove temp auth dir");
355            }
356        }
357    }
358
359    #[test]
360    fn test_auth_url_generation() {
361        let challenge = PkceChallenge {
362            code_verifier: "test_verifier".to_string(),
363            code_challenge: "test_challenge".to_string(),
364            code_challenge_method: "S256".to_string(),
365        };
366
367        let url = get_auth_url(&challenge, 8484);
368
369        assert!(url.starts_with("https://openrouter.ai/auth"));
370        assert!(url.contains("callback_url="));
371        assert!(url.contains("code_challenge=test_challenge"));
372        assert!(url.contains("code_challenge_method=S256"));
373    }
374
375    #[test]
376    fn debug_impl_redacts_api_key() {
377        let token = OpenRouterToken {
378            api_key: "sk-openrouter-secret".to_string(),
379            obtained_at: 123,
380            expires_at: Some(456),
381            label: Some("test token".to_string()),
382        };
383
384        let debug = format!("{token:?}");
385
386        assert!(!debug.contains("sk-openrouter-secret"), "api key leaked: {debug}");
387        assert!(debug.contains("<redacted>"), "api key should be redacted: {debug}");
388        assert!(debug.contains("test token"), "non-secret metadata should remain visible: {debug}");
389    }
390
391    #[test]
392    fn token_exchange_errors_do_not_include_response_bodies() {
393        let error = token_exchange_error(reqwest::StatusCode::INTERNAL_SERVER_ERROR);
394        let message = error.to_string();
395
396        assert_eq!(message, "Token exchange failed (HTTP 500 Internal Server Error)");
397        assert!(!message.contains("sk-openrouter-secret"));
398    }
399
400    #[test]
401    fn test_token_expiry_check() {
402        let now = std::time::SystemTime::now()
403            .duration_since(std::time::UNIX_EPOCH)
404            .unwrap()
405            .as_secs();
406
407        // Non-expired token
408        let token = OpenRouterToken {
409            api_key: "test".to_string(),
410            obtained_at: now,
411            expires_at: Some(now + 3600),
412            label: None,
413        };
414        assert!(!token.is_expired());
415
416        // Expired token
417        let expired_token = OpenRouterToken {
418            api_key: "test".to_string(),
419            obtained_at: now - 7200,
420            expires_at: Some(now - 3600),
421            label: None,
422        };
423        assert!(expired_token.is_expired());
424
425        // No expiry
426        let no_expiry_token = OpenRouterToken {
427            api_key: "test".to_string(),
428            obtained_at: now,
429            expires_at: None,
430            label: None,
431        };
432        assert!(!no_expiry_token.is_expired());
433    }
434
435    #[test]
436    fn test_encryption_roundtrip() {
437        let token = OpenRouterToken {
438            api_key: "sk-test-key-12345".to_string(),
439            obtained_at: 1234567890,
440            expires_at: Some(1234567890 + 86400),
441            label: Some("Test Token".to_string()),
442        };
443
444        let encrypted = encrypt_token(&token).unwrap();
445        let decrypted = decrypt_token(&encrypted).unwrap();
446
447        assert_eq!(decrypted.api_key, token.api_key);
448        assert_eq!(decrypted.obtained_at, token.obtained_at);
449        assert_eq!(decrypted.expires_at, token.expires_at);
450        assert_eq!(decrypted.label, token.label);
451    }
452
453    #[test]
454    fn test_auth_status_display() {
455        let status = AuthStatus::Authenticated {
456            label: Some("My App".to_string()),
457            age_seconds: 3700,
458            expires_in: Some(86000),
459        };
460
461        let display = status.display_string();
462        assert!(display.contains("Authenticated"));
463        assert!(display.contains("My App"));
464    }
465
466    #[test]
467    #[serial]
468    fn file_storage_round_trips_without_plaintext() {
469        let _guard = TestAuthDirGuard::new();
470        let now = std::time::SystemTime::now()
471            .duration_since(std::time::UNIX_EPOCH)
472            .unwrap()
473            .as_secs();
474        let token = OpenRouterToken {
475            api_key: "sk-test-key-12345".to_string(),
476            obtained_at: now,
477            expires_at: Some(now + 86400),
478            label: Some("Test Token".to_string()),
479        };
480
481        save_oauth_token_with_mode(&token, AuthCredentialsStoreMode::File).expect("save token");
482        let loaded = load_oauth_token_with_mode(AuthCredentialsStoreMode::File).expect("load token");
483        assert_eq!(loaded.as_ref().map(|value| &value.api_key), Some(&token.api_key));
484
485        let stored = fs::read_to_string(OpenRouterTokenStorage::new().current_file_path().expect("token path"))
486            .expect("read token file");
487        assert!(!stored.contains(&token.api_key));
488    }
489
490    #[test]
491    #[serial]
492    fn default_loader_falls_back_to_shared_file_storage() {
493        let _guard = TestAuthDirGuard::new();
494        let token = OpenRouterToken {
495            api_key: "sk-default-file-token".to_string(),
496            obtained_at: 1,
497            expires_at: None,
498            label: Some("default file fallback".to_string()),
499        };
500
501        save_oauth_token_with_mode(&token, AuthCredentialsStoreMode::File).expect("save token");
502
503        let loaded = load_oauth_token()
504            .expect("load default token")
505            .expect("token should be present");
506        assert_eq!(loaded.api_key, token.api_key);
507    }
508
509    #[test]
510    #[serial]
511    fn legacy_file_token_migrates_to_shared_storage() {
512        let _guard = TestAuthDirGuard::new();
513        let token = OpenRouterToken {
514            api_key: "sk-legacy-token".to_string(),
515            obtained_at: 1,
516            expires_at: None,
517            label: Some("legacy".to_string()),
518        };
519        let encrypted = encrypt_token(&token).expect("encrypt legacy token");
520        let legacy_path = get_token_path().expect("legacy token path");
521        fs::write(&legacy_path, serde_json::to_vec(&encrypted).expect("serialize legacy token"))
522            .expect("write legacy token");
523
524        let loaded = load_oauth_token_with_mode(AuthCredentialsStoreMode::File)
525            .expect("load migrated token")
526            .expect("token should be present");
527
528        assert_eq!(loaded.api_key, token.api_key);
529        assert!(legacy_path.exists(), "legacy token should remain as a rollback source after migration");
530        assert!(
531            OpenRouterTokenStorage::new()
532                .current_file_path()
533                .expect("shared token path")
534                .exists()
535        );
536    }
537
538    #[test]
539    #[serial]
540    #[cfg(unix)]
541    fn file_storage_uses_private_permissions() {
542        use std::os::unix::fs::PermissionsExt;
543
544        let _guard = TestAuthDirGuard::new();
545        let now = std::time::SystemTime::now()
546            .duration_since(std::time::UNIX_EPOCH)
547            .unwrap()
548            .as_secs();
549        let token = OpenRouterToken {
550            api_key: "sk-test-key-12345".to_string(),
551            obtained_at: now,
552            expires_at: Some(now + 86400),
553            label: Some("Test Token".to_string()),
554        };
555
556        save_oauth_token_with_mode(&token, AuthCredentialsStoreMode::File).expect("save token");
557
558        let metadata = fs::metadata(OpenRouterTokenStorage::new().current_file_path().expect("token path"))
559            .expect("read token metadata");
560        assert_eq!(metadata.permissions().mode() & 0o777, 0o600);
561    }
562}