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