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 = vtcode_commons::http::send_with_retry_transport_only(&crate::oauth_token_retry_policy(), || {
144        client
145            .post(OPENROUTER_KEYS_URL)
146            .header("Content-Type", "application/json")
147            .json(&payload)
148            .send()
149    })
150    .await
151    .context("Failed to send token exchange request")?;
152
153    let status = response.status();
154    if !status.is_success() {
155        // Never expose the raw response body: OAuth providers may echo codes,
156        // tokens, or other sensitive diagnostics in an error payload.
157        return Err(token_exchange_error(status));
158    }
159
160    // Parse the response to extract the key
161    let body = response.text().await.context("Failed to read response body")?;
162    let response_json: serde_json::Value = serde_json::from_str(&body).context("Failed to parse token response")?;
163
164    let api_key = response_json
165        .get("key")
166        .and_then(|v| v.as_str())
167        .ok_or_else(|| anyhow!("Response missing 'key' field"))?
168        .to_string();
169
170    Ok(api_key)
171}
172
173fn token_exchange_error(status: reqwest::StatusCode) -> anyhow::Error {
174    match status.as_u16() {
175        400 => anyhow!("Invalid code_challenge_method. Ensure you're using the same method (S256) in both steps."),
176        403 => anyhow!("Invalid code or code_verifier. The authorization code may have expired."),
177        405 => anyhow!("Method not allowed. Ensure you're using POST over HTTPS."),
178        _ => anyhow!("Token exchange failed (HTTP {status})"),
179    }
180}
181
182/// Save an OAuth token to encrypted storage with specified mode.
183///
184/// # Arguments
185/// * `token` - The OAuth token to save
186/// * `mode` - The storage mode to use
187pub fn save_oauth_token_with_mode(token: &OpenRouterToken, mode: AuthCredentialsStoreMode) -> Result<()> {
188    OpenRouterTokenStorage::new().save(token, mode)
189}
190
191/// Save an OAuth token to encrypted storage using the default mode.
192///
193/// Uses the configured default credential storage mode.
194pub fn save_oauth_token(token: &OpenRouterToken) -> Result<()> {
195    save_oauth_token_with_mode(token, AuthCredentialsStoreMode::default())
196}
197
198/// Load an OAuth token from storage with specified mode.
199///
200/// Returns `None` if no token exists or the token has expired.
201pub fn load_oauth_token_with_mode(mode: AuthCredentialsStoreMode) -> Result<Option<OpenRouterToken>> {
202    let storage = OpenRouterTokenStorage::new();
203    let Some(token) = storage.load(mode)? else {
204        return Ok(None);
205    };
206
207    if token.is_expired() {
208        tracing::warn!("OpenRouter OAuth token has expired, removing it");
209        storage.clear(mode)?;
210        return Ok(None);
211    }
212
213    Ok(Some(token))
214}
215
216/// Load an OAuth token from storage using the default mode.
217///
218/// This function checks the selected secure backend and migrates the legacy
219/// encrypted file format when necessary.
220pub fn load_oauth_token() -> Result<Option<OpenRouterToken>> {
221    let storage = OpenRouterTokenStorage::new();
222    for mode in [AuthCredentialsStoreMode::Keyring, AuthCredentialsStoreMode::File] {
223        let Some(token) = storage.load(mode)? else {
224            continue;
225        };
226
227        if token.is_expired() {
228            tracing::warn!("OpenRouter OAuth token has expired, removing it");
229            storage.clear(mode)?;
230            continue;
231        }
232
233        return Ok(Some(token));
234    }
235
236    Ok(None)
237}
238
239/// Clear the stored OAuth token using the selected storage mode.
240pub fn clear_oauth_token_with_mode(mode: AuthCredentialsStoreMode) -> Result<()> {
241    OpenRouterTokenStorage::new().clear(mode)
242}
243
244/// Clear the token from both shared backends and the legacy file format.
245pub fn clear_oauth_token() -> Result<()> {
246    OpenRouterTokenStorage::new().clear_all()
247}
248
249/// Get the current OAuth authentication status.
250pub fn get_auth_status_with_mode(mode: AuthCredentialsStoreMode) -> Result<AuthStatus> {
251    match load_oauth_token_with_mode(mode)? {
252        Some(token) => {
253            let now = std::time::SystemTime::now()
254                .duration_since(std::time::UNIX_EPOCH)
255                .map(|d| d.as_secs())
256                .unwrap_or(0);
257
258            let age_seconds = now.saturating_sub(token.obtained_at);
259
260            Ok(AuthStatus::Authenticated {
261                label: token.label,
262                age_seconds,
263                expires_in: token.expires_at.map(|e| e.saturating_sub(now)),
264            })
265        }
266        None => Ok(AuthStatus::NotAuthenticated),
267    }
268}
269
270pub fn get_auth_status() -> Result<AuthStatus> {
271    match load_oauth_token()? {
272        Some(token) => {
273            let now = std::time::SystemTime::now()
274                .duration_since(std::time::UNIX_EPOCH)
275                .map(|d| d.as_secs())
276                .unwrap_or(0);
277
278            let age_seconds = now.saturating_sub(token.obtained_at);
279
280            Ok(AuthStatus::Authenticated {
281                label: token.label,
282                age_seconds,
283                expires_in: token.expires_at.map(|e| e.saturating_sub(now)),
284            })
285        }
286        None => Ok(AuthStatus::NotAuthenticated),
287    }
288}
289
290/// OAuth authentication status.
291#[derive(Debug, Clone)]
292pub enum AuthStatus {
293    /// User is authenticated with OAuth
294    Authenticated {
295        /// Optional label for the token
296        label: Option<String>,
297        /// How long ago the token was obtained (seconds)
298        age_seconds: u64,
299        /// Time until expiry (seconds), if known
300        expires_in: Option<u64>,
301    },
302    /// User is not authenticated via OAuth
303    NotAuthenticated,
304}
305
306impl AuthStatus {
307    /// Check if the user is authenticated.
308    pub fn is_authenticated(&self) -> bool {
309        matches!(self, AuthStatus::Authenticated { .. })
310    }
311
312    /// Get a human-readable status string.
313    fn display_string(&self) -> String {
314        match self {
315            AuthStatus::Authenticated { label, age_seconds, expires_in } => {
316                let label_str = label.as_ref().map(|l| format!(" ({l})")).unwrap_or_default();
317                let age_str = format!("{} ago", vtcode_commons::formatting::humanize_duration_compact(*age_seconds));
318                let expiry_str = expires_in
319                    .map(|e| format!(", expires in {}", vtcode_commons::formatting::humanize_duration_compact(e)))
320                    .unwrap_or_default();
321                format!("Authenticated{label_str}, obtained {age_str}{expiry_str}")
322            }
323            AuthStatus::NotAuthenticated => "Not authenticated".to_string(),
324        }
325    }
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331    use assert_fs::TempDir;
332    use serial_test::serial;
333    use std::fs;
334    use std::path::PathBuf;
335
336    struct TestAuthDirGuard {
337        temp_dir: Option<TempDir>,
338        previous: Option<PathBuf>,
339    }
340
341    impl TestAuthDirGuard {
342        fn new() -> Self {
343            let temp_dir = TempDir::new().expect("create temp auth dir");
344            let previous = crate::storage_paths::auth_storage_dir_override_for_tests().expect("read auth dir override");
345            crate::storage_paths::set_auth_storage_dir_override_for_tests(Some(temp_dir.path().to_path_buf()))
346                .expect("set temp auth dir override");
347            Self { temp_dir: Some(temp_dir), previous }
348        }
349    }
350
351    impl Drop for TestAuthDirGuard {
352        fn drop(&mut self) {
353            crate::storage_paths::set_auth_storage_dir_override_for_tests(self.previous.clone())
354                .expect("restore auth dir override");
355            if let Some(temp_dir) = self.temp_dir.take() {
356                temp_dir.close().expect("remove temp auth dir");
357            }
358        }
359    }
360
361    #[test]
362    fn test_auth_url_generation() {
363        let challenge = PkceChallenge {
364            code_verifier: "test_verifier".to_string(),
365            code_challenge: "test_challenge".to_string(),
366            code_challenge_method: "S256".to_string(),
367        };
368
369        let url = get_auth_url(&challenge, 8484);
370
371        assert!(url.starts_with("https://openrouter.ai/auth"));
372        assert!(url.contains("callback_url="));
373        assert!(url.contains("code_challenge=test_challenge"));
374        assert!(url.contains("code_challenge_method=S256"));
375    }
376
377    #[test]
378    fn debug_impl_redacts_api_key() {
379        let token = OpenRouterToken {
380            api_key: "sk-openrouter-secret".to_string(),
381            obtained_at: 123,
382            expires_at: Some(456),
383            label: Some("test token".to_string()),
384        };
385
386        let debug = format!("{token:?}");
387
388        assert!(!debug.contains("sk-openrouter-secret"), "api key leaked: {debug}");
389        assert!(debug.contains("<redacted>"), "api key should be redacted: {debug}");
390        assert!(debug.contains("test token"), "non-secret metadata should remain visible: {debug}");
391    }
392
393    #[test]
394    fn token_exchange_errors_do_not_include_response_bodies() {
395        let error = token_exchange_error(reqwest::StatusCode::INTERNAL_SERVER_ERROR);
396        let message = error.to_string();
397
398        assert_eq!(message, "Token exchange failed (HTTP 500 Internal Server Error)");
399        assert!(!message.contains("sk-openrouter-secret"));
400    }
401
402    #[test]
403    fn test_token_expiry_check() {
404        let now = std::time::SystemTime::now()
405            .duration_since(std::time::UNIX_EPOCH)
406            .unwrap()
407            .as_secs();
408
409        // Non-expired token
410        let token = OpenRouterToken {
411            api_key: "test".to_string(),
412            obtained_at: now,
413            expires_at: Some(now + 3600),
414            label: None,
415        };
416        assert!(!token.is_expired());
417
418        // Expired token
419        let expired_token = OpenRouterToken {
420            api_key: "test".to_string(),
421            obtained_at: now - 7200,
422            expires_at: Some(now - 3600),
423            label: None,
424        };
425        assert!(expired_token.is_expired());
426
427        // No expiry
428        let no_expiry_token = OpenRouterToken {
429            api_key: "test".to_string(),
430            obtained_at: now,
431            expires_at: None,
432            label: None,
433        };
434        assert!(!no_expiry_token.is_expired());
435    }
436
437    #[test]
438    fn test_encryption_roundtrip() {
439        let token = OpenRouterToken {
440            api_key: "sk-test-key-12345".to_string(),
441            obtained_at: 1234567890,
442            expires_at: Some(1234567890 + 86400),
443            label: Some("Test Token".to_string()),
444        };
445
446        let encrypted = encrypt_token(&token).unwrap();
447        let decrypted = decrypt_token(&encrypted).unwrap();
448
449        assert_eq!(decrypted.api_key, token.api_key);
450        assert_eq!(decrypted.obtained_at, token.obtained_at);
451        assert_eq!(decrypted.expires_at, token.expires_at);
452        assert_eq!(decrypted.label, token.label);
453    }
454
455    #[test]
456    fn test_auth_status_display() {
457        let status = AuthStatus::Authenticated {
458            label: Some("My App".to_string()),
459            age_seconds: 3700,
460            expires_in: Some(86000),
461        };
462
463        let display = status.display_string();
464        assert!(display.contains("Authenticated"));
465        assert!(display.contains("My App"));
466    }
467
468    #[test]
469    #[serial]
470    fn file_storage_round_trips_without_plaintext() {
471        let _guard = TestAuthDirGuard::new();
472        let now = std::time::SystemTime::now()
473            .duration_since(std::time::UNIX_EPOCH)
474            .unwrap()
475            .as_secs();
476        let token = OpenRouterToken {
477            api_key: "sk-test-key-12345".to_string(),
478            obtained_at: now,
479            expires_at: Some(now + 86400),
480            label: Some("Test Token".to_string()),
481        };
482
483        save_oauth_token_with_mode(&token, AuthCredentialsStoreMode::File).expect("save token");
484        let loaded = load_oauth_token_with_mode(AuthCredentialsStoreMode::File).expect("load token");
485        assert_eq!(loaded.as_ref().map(|value| &value.api_key), Some(&token.api_key));
486
487        let stored = fs::read_to_string(OpenRouterTokenStorage::new().current_file_path().expect("token path"))
488            .expect("read token file");
489        assert!(!stored.contains(&token.api_key));
490    }
491
492    #[test]
493    #[serial]
494    fn default_loader_falls_back_to_shared_file_storage() {
495        let _guard = TestAuthDirGuard::new();
496        let token = OpenRouterToken {
497            api_key: "sk-default-file-token".to_string(),
498            obtained_at: 1,
499            expires_at: None,
500            label: Some("default file fallback".to_string()),
501        };
502
503        save_oauth_token_with_mode(&token, AuthCredentialsStoreMode::File).expect("save token");
504
505        let loaded = load_oauth_token()
506            .expect("load default token")
507            .expect("token should be present");
508        assert_eq!(loaded.api_key, token.api_key);
509    }
510
511    #[test]
512    #[serial]
513    fn legacy_file_token_migrates_to_shared_storage() {
514        let _guard = TestAuthDirGuard::new();
515        let token = OpenRouterToken {
516            api_key: "sk-legacy-token".to_string(),
517            obtained_at: 1,
518            expires_at: None,
519            label: Some("legacy".to_string()),
520        };
521        let encrypted = encrypt_token(&token).expect("encrypt legacy token");
522        let legacy_path = get_token_path().expect("legacy token path");
523        fs::write(&legacy_path, serde_json::to_vec(&encrypted).expect("serialize legacy token"))
524            .expect("write legacy token");
525
526        let loaded = load_oauth_token_with_mode(AuthCredentialsStoreMode::File)
527            .expect("load migrated token")
528            .expect("token should be present");
529
530        assert_eq!(loaded.api_key, token.api_key);
531        assert!(legacy_path.exists(), "legacy token should remain as a rollback source after migration");
532        assert!(
533            OpenRouterTokenStorage::new()
534                .current_file_path()
535                .expect("shared token path")
536                .exists()
537        );
538    }
539
540    #[test]
541    #[serial]
542    #[cfg(unix)]
543    fn file_storage_uses_private_permissions() {
544        use std::os::unix::fs::PermissionsExt;
545
546        let _guard = TestAuthDirGuard::new();
547        let now = std::time::SystemTime::now()
548            .duration_since(std::time::UNIX_EPOCH)
549            .unwrap()
550            .as_secs();
551        let token = OpenRouterToken {
552            api_key: "sk-test-key-12345".to_string(),
553            obtained_at: now,
554            expires_at: Some(now + 86400),
555            label: Some("Test Token".to_string()),
556        };
557
558        save_oauth_token_with_mode(&token, AuthCredentialsStoreMode::File).expect("save token");
559
560        let metadata = fs::metadata(OpenRouterTokenStorage::new().current_file_path().expect("token path"))
561            .expect("read token metadata");
562        assert_eq!(metadata.permissions().mode() & 0o777, 0o600);
563    }
564}