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!("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 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
191fn get_token_path() -> Result<PathBuf> {
193 Ok(auth_storage_dir()?.join("openrouter.json"))
194}
195
196fn derive_encryption_key() -> Result<LessSafeKey> {
198 use ring::digest::{SHA256, digest};
199
200 let mut key_material = Vec::new();
202
203 if let Ok(hostname) = hostname::get() {
205 key_material.extend_from_slice(hostname.as_encoded_bytes());
206 }
207
208 #[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 key_material.extend_from_slice(b"vtcode-openrouter-oauth-v1");
222
223 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
232fn encrypt_token(token: &OpenRouterToken) -> Result<EncryptedToken> {
234 let key = derive_encryption_key()?;
235 let rng = SystemRandom::new();
236
237 let mut nonce_bytes = [0u8; NONCE_LEN];
239 rng.fill(&mut nonce_bytes).map_err(|_| anyhow!("Failed to generate nonce"))?;
240
241 let plaintext = serde_json::to_vec(token).context("Failed to serialize token")?;
243
244 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
259fn 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
285pub 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
300fn save_oauth_token_keyring(token: &OpenRouterToken) -> Result<()> {
302 let entry = keyring_entry("vtcode", "openrouter_oauth").context("Failed to access OS keyring")?;
303
304 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
313fn 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
324pub fn save_oauth_token(token: &OpenRouterToken) -> Result<()> {
329 save_oauth_token_with_mode(token, AuthCredentialsStoreMode::default())
330}
331
332pub 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
345fn 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 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
370fn 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 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
393pub 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 tracing::debug!("No token in keyring, checking file storage");
410 }
411 Err(e) => {
412 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 return Err(e);
420 }
421 }
422 }
423
424 load_oauth_token_file()
426}
427
428fn 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
444fn 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
456pub 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 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
478pub 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#[derive(Debug, Clone)]
521pub enum AuthStatus {
522 Authenticated {
524 label: Option<String>,
526 age_seconds: u64,
528 expires_in: Option<u64>,
530 },
531 NotAuthenticated,
533}
534
535impl AuthStatus {
536 pub fn is_authenticated(&self) -> bool {
538 matches!(self, AuthStatus::Authenticated { .. })
539 }
540
541 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
557fn 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 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 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 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}