1use 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
43const OPENROUTER_AUTH_URL: &str = "https://openrouter.ai/auth";
45const OPENROUTER_KEYS_URL: &str = "https://openrouter.ai/api/v1/auth/keys";
46
47pub const DEFAULT_CALLBACK_PORT: u16 = 8484;
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
52#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
53#[serde(default)]
54pub struct OpenRouterOAuthConfig {
55 pub use_oauth: bool,
57 pub callback_port: u16,
59 pub auto_refresh: bool,
61 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#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct OpenRouterToken {
79 pub api_key: String,
81 pub obtained_at: u64,
83 pub expires_at: Option<u64>,
85 pub label: Option<String>,
87}
88
89impl OpenRouterToken {
90 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#[derive(Debug, Serialize, Deserialize)]
106struct EncryptedToken {
107 nonce: String,
109 ciphertext: String,
111 version: u8,
113}
114
115pub 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
134pub 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 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!(
173 "Invalid code or code_verifier. The authorization code may have expired."
174 ));
175 } else if status.as_u16() == 405 {
176 return Err(anyhow!("Method not allowed. Ensure you're using POST over HTTPS."));
177 }
178 return Err(anyhow!("Token exchange failed (HTTP {status}): {body}"));
179 }
180
181 let response_json: serde_json::Value =
183 serde_json::from_str(&body).context("Failed to parse token response")?;
184
185 let api_key = response_json
186 .get("key")
187 .and_then(|v| v.as_str())
188 .ok_or_else(|| anyhow!("Response missing 'key' field"))?
189 .to_string();
190
191 Ok(api_key)
192}
193
194fn get_token_path() -> Result<PathBuf> {
196 Ok(auth_storage_dir()?.join("openrouter.json"))
197}
198
199fn derive_encryption_key() -> Result<LessSafeKey> {
201 use ring::digest::{SHA256, digest};
202
203 let mut key_material = Vec::new();
205
206 if let Ok(hostname) = hostname::get() {
208 key_material.extend_from_slice(hostname.as_encoded_bytes());
209 }
210
211 #[cfg(unix)]
213 {
214 key_material.extend_from_slice(&nix::unistd::getuid().as_raw().to_le_bytes());
215 }
216 #[cfg(not(unix))]
217 {
218 if let Ok(user) = std::env::var("USER").or_else(|_| std::env::var("USERNAME")) {
219 key_material.extend_from_slice(user.as_bytes());
220 }
221 }
222
223 key_material.extend_from_slice(b"vtcode-openrouter-oauth-v1");
225
226 let hash = digest(&SHA256, &key_material);
228 let key_bytes: &[u8; 32] = hash.as_ref()[..32].try_into().context("Hash too short")?;
229
230 let unbound_key = UnboundKey::new(&aead::AES_256_GCM, key_bytes)
231 .map_err(|_| anyhow!("Invalid key length"))?;
232
233 Ok(LessSafeKey::new(unbound_key))
234}
235
236fn encrypt_token(token: &OpenRouterToken) -> Result<EncryptedToken> {
238 let key = derive_encryption_key()?;
239 let rng = SystemRandom::new();
240
241 let mut nonce_bytes = [0u8; NONCE_LEN];
243 rng.fill(&mut nonce_bytes).map_err(|_| anyhow!("Failed to generate nonce"))?;
244
245 let plaintext = serde_json::to_vec(token).context("Failed to serialize token")?;
247
248 let mut ciphertext = plaintext;
250 let nonce = Nonce::assume_unique_for_key(nonce_bytes);
251 key.seal_in_place_append_tag(nonce, Aad::empty(), &mut ciphertext)
252 .map_err(|_| anyhow!("Encryption failed"))?;
253
254 use base64::{Engine, engine::general_purpose::STANDARD};
255
256 Ok(EncryptedToken {
257 nonce: STANDARD.encode(nonce_bytes),
258 ciphertext: STANDARD.encode(&ciphertext),
259 version: 1,
260 })
261}
262
263fn decrypt_token(encrypted: &EncryptedToken) -> Result<OpenRouterToken> {
265 if encrypted.version != 1 {
266 return Err(anyhow!("Unsupported token format version: {}", encrypted.version));
267 }
268
269 use base64::{Engine, engine::general_purpose::STANDARD};
270
271 let key = derive_encryption_key()?;
272
273 let nonce_bytes: [u8; NONCE_LEN] = STANDARD
274 .decode(&encrypted.nonce)
275 .context("Invalid nonce encoding")?
276 .try_into()
277 .map_err(|_| anyhow!("Invalid nonce length"))?;
278
279 let mut ciphertext =
280 STANDARD.decode(&encrypted.ciphertext).context("Invalid ciphertext encoding")?;
281
282 let nonce = Nonce::assume_unique_for_key(nonce_bytes);
283 let plaintext = key.open_in_place(nonce, Aad::empty(), &mut ciphertext).map_err(|_| {
284 anyhow!("Decryption failed - token may be corrupted or from different machine")
285 })?;
286
287 serde_json::from_slice(plaintext).context("Failed to deserialize token")
288}
289
290pub fn save_oauth_token_with_mode(
296 token: &OpenRouterToken,
297 mode: AuthCredentialsStoreMode,
298) -> Result<()> {
299 let effective_mode = mode.effective_mode();
300
301 match effective_mode {
302 AuthCredentialsStoreMode::Keyring => save_oauth_token_keyring(token),
303 AuthCredentialsStoreMode::File => save_oauth_token_file(token),
304 _ => unreachable!(),
305 }
306}
307
308fn save_oauth_token_keyring(token: &OpenRouterToken) -> Result<()> {
310 let entry =
311 keyring_entry("vtcode", "openrouter_oauth").context("Failed to access OS keyring")?;
312
313 let token_json =
315 serde_json::to_string(token).context("Failed to serialize token for keyring")?;
316
317 entry.set_password(&token_json).context("Failed to store token in OS keyring")?;
318
319 tracing::info!("OAuth token saved to OS keyring");
320 Ok(())
321}
322
323fn save_oauth_token_file(token: &OpenRouterToken) -> Result<()> {
325 let path = get_token_path()?;
326 let encrypted = encrypt_token(token)?;
327 let json =
328 serde_json::to_string_pretty(&encrypted).context("Failed to serialize encrypted token")?;
329 write_private_file(&path, json.as_bytes()).context("Failed to write token file")?;
330
331 tracing::info!("OAuth token saved to {}", path.display());
332 Ok(())
333}
334
335pub fn save_oauth_token(token: &OpenRouterToken) -> Result<()> {
340 save_oauth_token_with_mode(token, AuthCredentialsStoreMode::default())
341}
342
343pub fn load_oauth_token_with_mode(
347 mode: AuthCredentialsStoreMode,
348) -> Result<Option<OpenRouterToken>> {
349 let effective_mode = mode.effective_mode();
350
351 match effective_mode {
352 AuthCredentialsStoreMode::Keyring => load_oauth_token_keyring(),
353 AuthCredentialsStoreMode::File => load_oauth_token_file(),
354 _ => unreachable!(),
355 }
356}
357
358fn load_oauth_token_keyring() -> Result<Option<OpenRouterToken>> {
360 let entry = match keyring_entry("vtcode", "openrouter_oauth") {
361 Ok(e) => e,
362 Err(_) => return Ok(None),
363 };
364
365 let token_json = match entry.get_password() {
366 Ok(json) => json,
367 Err(keyring_core::Error::NoEntry) => return Ok(None),
368 Err(e) => return Err(anyhow!("Failed to read from keyring: {e}")),
369 };
370
371 let token: OpenRouterToken =
372 serde_json::from_str(&token_json).context("Failed to parse token from keyring")?;
373
374 if token.is_expired() {
376 tracing::warn!("OAuth token has expired, removing...");
377 clear_oauth_token_keyring()?;
378 return Ok(None);
379 }
380
381 Ok(Some(token))
382}
383
384fn load_oauth_token_file() -> Result<Option<OpenRouterToken>> {
386 let path = get_token_path()?;
387
388 if !path.exists() {
389 return Ok(None);
390 }
391
392 let json = fs::read_to_string(&path).context("Failed to read token file")?;
393 let encrypted: EncryptedToken =
394 serde_json::from_str(&json).context("Failed to parse token file")?;
395
396 let token = decrypt_token(&encrypted)?;
397
398 if token.is_expired() {
400 tracing::warn!("OAuth token has expired, removing...");
401 clear_oauth_token_file()?;
402 return Ok(None);
403 }
404
405 Ok(Some(token))
406}
407
408pub fn load_oauth_token() -> Result<Option<OpenRouterToken>> {
420 match load_oauth_token_keyring() {
421 Ok(Some(token)) => return Ok(Some(token)),
422 Ok(None) => {
423 tracing::debug!("No token in keyring, checking file storage");
425 }
426 Err(e) => {
427 let error_str = e.to_string().to_lowercase();
429 if error_str.contains("no entry") || error_str.contains("not found") {
430 tracing::debug!("Keyring entry not found, checking file storage");
431 } else {
432 return Err(e);
435 }
436 }
437 }
438
439 load_oauth_token_file()
441}
442
443fn clear_oauth_token_keyring() -> Result<()> {
445 let entry = match keyring_entry("vtcode", "openrouter_oauth") {
446 Ok(e) => e,
447 Err(_) => return Ok(()),
448 };
449
450 match entry.delete_credential() {
451 Ok(_) => tracing::info!("OAuth token cleared from keyring"),
452 Err(keyring_core::Error::NoEntry) => {}
453 Err(e) => return Err(anyhow!("Failed to clear keyring entry: {e}")),
454 }
455
456 Ok(())
457}
458
459fn clear_oauth_token_file() -> Result<()> {
461 let path = get_token_path()?;
462
463 if path.exists() {
464 fs::remove_file(&path).context("Failed to remove token file")?;
465 tracing::info!("OAuth token cleared from file");
466 }
467
468 Ok(())
469}
470
471pub fn clear_oauth_token_with_mode(mode: AuthCredentialsStoreMode) -> Result<()> {
473 match mode.effective_mode() {
474 AuthCredentialsStoreMode::Keyring => clear_oauth_token_keyring(),
475 AuthCredentialsStoreMode::File => clear_oauth_token_file(),
476 AuthCredentialsStoreMode::Auto => {
477 let _ = clear_oauth_token_keyring();
478 let _ = clear_oauth_token_file();
479 Ok(())
480 }
481 }
482}
483
484pub fn clear_oauth_token() -> Result<()> {
485 let _ = clear_oauth_token_keyring();
487 let _ = clear_oauth_token_file();
488
489 tracing::info!("OAuth token cleared from all storage");
490 Ok(())
491}
492
493pub fn get_auth_status_with_mode(mode: AuthCredentialsStoreMode) -> Result<AuthStatus> {
495 match load_oauth_token_with_mode(mode)? {
496 Some(token) => {
497 let now = std::time::SystemTime::now()
498 .duration_since(std::time::UNIX_EPOCH)
499 .map(|d| d.as_secs())
500 .unwrap_or(0);
501
502 let age_seconds = now.saturating_sub(token.obtained_at);
503
504 Ok(AuthStatus::Authenticated {
505 label: token.label,
506 age_seconds,
507 expires_in: token.expires_at.map(|e| e.saturating_sub(now)),
508 })
509 }
510 None => Ok(AuthStatus::NotAuthenticated),
511 }
512}
513
514pub fn get_auth_status() -> Result<AuthStatus> {
515 match load_oauth_token()? {
516 Some(token) => {
517 let now = std::time::SystemTime::now()
518 .duration_since(std::time::UNIX_EPOCH)
519 .map(|d| d.as_secs())
520 .unwrap_or(0);
521
522 let age_seconds = now.saturating_sub(token.obtained_at);
523
524 Ok(AuthStatus::Authenticated {
525 label: token.label,
526 age_seconds,
527 expires_in: token.expires_at.map(|e| e.saturating_sub(now)),
528 })
529 }
530 None => Ok(AuthStatus::NotAuthenticated),
531 }
532}
533
534#[derive(Debug, Clone)]
536pub enum AuthStatus {
537 Authenticated {
539 label: Option<String>,
541 age_seconds: u64,
543 expires_in: Option<u64>,
545 },
546 NotAuthenticated,
548}
549
550impl AuthStatus {
551 pub fn is_authenticated(&self) -> bool {
553 matches!(self, AuthStatus::Authenticated { .. })
554 }
555
556 pub fn display_string(&self) -> String {
558 match self {
559 AuthStatus::Authenticated { label, age_seconds, expires_in } => {
560 let label_str = label.as_ref().map(|l| format!(" ({l})")).unwrap_or_default();
561 let age_str = humanize_duration(*age_seconds);
562 let expiry_str = expires_in
563 .map(|e| format!(", expires in {}", humanize_duration(e)))
564 .unwrap_or_default();
565 format!("Authenticated{label_str}, obtained {age_str}{expiry_str}")
566 }
567 AuthStatus::NotAuthenticated => "Not authenticated".to_string(),
568 }
569 }
570}
571
572fn humanize_duration(seconds: u64) -> String {
574 if seconds < 60 {
575 format!("{seconds}s ago")
576 } else if seconds < 3600 {
577 format!("{}m ago", seconds / 60)
578 } else if seconds < 86400 {
579 format!("{}h ago", seconds / 3600)
580 } else {
581 format!("{}d ago", seconds / 86400)
582 }
583}
584
585#[cfg(test)]
586mod tests {
587 use super::*;
588 use assert_fs::TempDir;
589 use serial_test::serial;
590
591 struct TestAuthDirGuard {
592 temp_dir: Option<TempDir>,
593 previous: Option<PathBuf>,
594 }
595
596 impl TestAuthDirGuard {
597 fn new() -> Self {
598 let temp_dir = TempDir::new().expect("create temp auth dir");
599 let previous = crate::storage_paths::auth_storage_dir_override_for_tests()
600 .expect("read auth dir override");
601 crate::storage_paths::set_auth_storage_dir_override_for_tests(Some(
602 temp_dir.path().to_path_buf(),
603 ))
604 .expect("set temp auth dir override");
605 Self { temp_dir: Some(temp_dir), previous }
606 }
607 }
608
609 impl Drop for TestAuthDirGuard {
610 fn drop(&mut self) {
611 crate::storage_paths::set_auth_storage_dir_override_for_tests(self.previous.clone())
612 .expect("restore auth dir override");
613 if let Some(temp_dir) = self.temp_dir.take() {
614 temp_dir.close().expect("remove temp auth dir");
615 }
616 }
617 }
618
619 #[test]
620 fn test_auth_url_generation() {
621 let challenge = PkceChallenge {
622 code_verifier: "test_verifier".to_string(),
623 code_challenge: "test_challenge".to_string(),
624 code_challenge_method: "S256".to_string(),
625 };
626
627 let url = get_auth_url(&challenge, 8484);
628
629 assert!(url.starts_with("https://openrouter.ai/auth"));
630 assert!(url.contains("callback_url="));
631 assert!(url.contains("code_challenge=test_challenge"));
632 assert!(url.contains("code_challenge_method=S256"));
633 }
634
635 #[test]
636 fn test_token_expiry_check() {
637 let now = std::time::SystemTime::now()
638 .duration_since(std::time::UNIX_EPOCH)
639 .unwrap()
640 .as_secs();
641
642 let token = OpenRouterToken {
644 api_key: "test".to_string(),
645 obtained_at: now,
646 expires_at: Some(now + 3600),
647 label: None,
648 };
649 assert!(!token.is_expired());
650
651 let expired_token = OpenRouterToken {
653 api_key: "test".to_string(),
654 obtained_at: now - 7200,
655 expires_at: Some(now - 3600),
656 label: None,
657 };
658 assert!(expired_token.is_expired());
659
660 let no_expiry_token = OpenRouterToken {
662 api_key: "test".to_string(),
663 obtained_at: now,
664 expires_at: None,
665 label: None,
666 };
667 assert!(!no_expiry_token.is_expired());
668 }
669
670 #[test]
671 fn test_encryption_roundtrip() {
672 let token = OpenRouterToken {
673 api_key: "sk-test-key-12345".to_string(),
674 obtained_at: 1234567890,
675 expires_at: Some(1234567890 + 86400),
676 label: Some("Test Token".to_string()),
677 };
678
679 let encrypted = encrypt_token(&token).unwrap();
680 let decrypted = decrypt_token(&encrypted).unwrap();
681
682 assert_eq!(decrypted.api_key, token.api_key);
683 assert_eq!(decrypted.obtained_at, token.obtained_at);
684 assert_eq!(decrypted.expires_at, token.expires_at);
685 assert_eq!(decrypted.label, token.label);
686 }
687
688 #[test]
689 fn test_auth_status_display() {
690 let status = AuthStatus::Authenticated {
691 label: Some("My App".to_string()),
692 age_seconds: 3700,
693 expires_in: Some(86000),
694 };
695
696 let display = status.display_string();
697 assert!(display.contains("Authenticated"));
698 assert!(display.contains("My App"));
699 }
700
701 #[test]
702 #[serial]
703 fn file_storage_round_trips_without_plaintext() {
704 let _guard = TestAuthDirGuard::new();
705 let now = std::time::SystemTime::now()
706 .duration_since(std::time::UNIX_EPOCH)
707 .unwrap()
708 .as_secs();
709 let token = OpenRouterToken {
710 api_key: "sk-test-key-12345".to_string(),
711 obtained_at: now,
712 expires_at: Some(now + 86400),
713 label: Some("Test Token".to_string()),
714 };
715
716 save_oauth_token_with_mode(&token, AuthCredentialsStoreMode::File).expect("save token");
717 let loaded =
718 load_oauth_token_with_mode(AuthCredentialsStoreMode::File).expect("load token");
719 assert_eq!(loaded.as_ref().map(|value| &value.api_key), Some(&token.api_key));
720
721 let stored =
722 fs::read_to_string(get_token_path().expect("token path")).expect("read token file");
723 assert!(!stored.contains(&token.api_key));
724 }
725
726 #[test]
727 #[serial]
728 #[cfg(unix)]
729 fn file_storage_uses_private_permissions() {
730 use std::os::unix::fs::PermissionsExt;
731
732 let _guard = TestAuthDirGuard::new();
733 let now = std::time::SystemTime::now()
734 .duration_since(std::time::UNIX_EPOCH)
735 .unwrap()
736 .as_secs();
737 let token = OpenRouterToken {
738 api_key: "sk-test-key-12345".to_string(),
739 obtained_at: now,
740 expires_at: Some(now + 86400),
741 label: Some("Test Token".to_string()),
742 };
743
744 save_oauth_token_with_mode(&token, AuthCredentialsStoreMode::File).expect("save token");
745
746 let metadata =
747 fs::metadata(get_token_path().expect("token path")).expect("read token metadata");
748 assert_eq!(metadata.permissions().mode() & 0o777, 0o600);
749 }
750}