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;
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
47const 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 use_oauth: bool,
57 pub callback_port: u16,
59 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 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
226 .as_ref()
227 .get(..32)
228 .context("Hash too short")?
229 .try_into()
230 .context("Hash had an invalid length")?;
231
232 let unbound_key = UnboundKey::new(&aead::AES_256_GCM, key_bytes).map_err(|_| anyhow!("Invalid key length"))?;
233
234 Ok(LessSafeKey::new(unbound_key))
235}
236
237fn encrypt_token(token: &OpenRouterToken) -> Result<EncryptedToken> {
239 let key = derive_encryption_key()?;
240 let rng = SystemRandom::new();
241
242 let mut nonce_bytes = [0u8; NONCE_LEN];
244 rng.fill(&mut nonce_bytes).map_err(|_| anyhow!("Failed to generate nonce"))?;
245
246 let plaintext = serde_json::to_vec(token).context("Failed to serialize token")?;
248
249 let mut ciphertext = plaintext;
251 let nonce = Nonce::assume_unique_for_key(nonce_bytes);
252 key.seal_in_place_append_tag(nonce, Aad::empty(), &mut ciphertext)
253 .map_err(|_| anyhow!("Encryption failed"))?;
254
255 use base64::{Engine, engine::general_purpose::STANDARD};
256
257 Ok(EncryptedToken {
258 nonce: STANDARD.encode(nonce_bytes),
259 ciphertext: STANDARD.encode(&ciphertext),
260 version: 1,
261 })
262}
263
264fn decrypt_token(encrypted: &EncryptedToken) -> Result<OpenRouterToken> {
266 if encrypted.version != 1 {
267 return Err(anyhow!("Unsupported token format version: {}", encrypted.version));
268 }
269
270 use base64::{Engine, engine::general_purpose::STANDARD};
271
272 let key = derive_encryption_key()?;
273
274 let nonce_bytes: [u8; NONCE_LEN] = STANDARD
275 .decode(&encrypted.nonce)
276 .context("Invalid nonce encoding")?
277 .try_into()
278 .map_err(|_| anyhow!("Invalid nonce length"))?;
279
280 let mut ciphertext = STANDARD.decode(&encrypted.ciphertext).context("Invalid ciphertext encoding")?;
281
282 let nonce = Nonce::assume_unique_for_key(nonce_bytes);
283 let plaintext = key
284 .open_in_place(nonce, Aad::empty(), &mut ciphertext)
285 .map_err(|_| anyhow!("Decryption failed - token may be corrupted or from different machine"))?;
286
287 serde_json::from_slice(plaintext).context("Failed to deserialize token")
288}
289
290pub fn save_oauth_token_with_mode(token: &OpenRouterToken, mode: AuthCredentialsStoreMode) -> Result<()> {
296 let effective_mode = mode.effective_mode();
297
298 match effective_mode {
299 AuthCredentialsStoreMode::Keyring => save_oauth_token_keyring(token),
300 AuthCredentialsStoreMode::File => save_oauth_token_file(token),
301 _ => unreachable!(),
302 }
303}
304
305fn save_oauth_token_keyring(token: &OpenRouterToken) -> Result<()> {
307 let entry = keyring::entry("vtcode", "openrouter_oauth").context("Failed to access OS keyring")?;
308
309 let token_json = serde_json::to_string(token).context("Failed to serialize token for keyring")?;
311
312 entry.set_password(&token_json).context("Failed to store token in OS keyring")?;
313
314 tracing::info!("OAuth token saved to OS keyring");
315 Ok(())
316}
317
318fn save_oauth_token_file(token: &OpenRouterToken) -> Result<()> {
320 let path = get_token_path()?;
321 let encrypted = encrypt_token(token)?;
322 let json = serde_json::to_string_pretty(&encrypted).context("Failed to serialize encrypted token")?;
323 write_private_file(&path, json.as_bytes()).context("Failed to write token file")?;
324
325 tracing::info!("OAuth token saved to {}", path.display());
326 Ok(())
327}
328
329pub fn save_oauth_token(token: &OpenRouterToken) -> Result<()> {
334 save_oauth_token_with_mode(token, AuthCredentialsStoreMode::default())
335}
336
337pub fn load_oauth_token_with_mode(mode: AuthCredentialsStoreMode) -> Result<Option<OpenRouterToken>> {
341 let effective_mode = mode.effective_mode();
342
343 match effective_mode {
344 AuthCredentialsStoreMode::Keyring => load_oauth_token_keyring(),
345 AuthCredentialsStoreMode::File => load_oauth_token_file(),
346 _ => unreachable!(),
347 }
348}
349
350fn load_oauth_token_keyring() -> Result<Option<OpenRouterToken>> {
352 let entry = match keyring::entry("vtcode", "openrouter_oauth") {
353 Ok(e) => e,
354 Err(_) => return Ok(None),
355 };
356
357 let token_json = match entry.get_password() {
358 Ok(json) => json,
359 Err(keyring_core::Error::NoEntry) => return Ok(None),
360 Err(e) => return Err(anyhow!("Failed to read from keyring: {e}")),
361 };
362
363 let token: OpenRouterToken = serde_json::from_str(&token_json).context("Failed to parse token from keyring")?;
364
365 if token.is_expired() {
367 tracing::warn!("OAuth token has expired, removing...");
368 clear_oauth_token_keyring()?;
369 return Ok(None);
370 }
371
372 Ok(Some(token))
373}
374
375fn load_oauth_token_file() -> Result<Option<OpenRouterToken>> {
377 let path = get_token_path()?;
378
379 if !path.exists() {
380 return Ok(None);
381 }
382
383 let json = fs::read_to_string(&path).context("Failed to read token file")?;
384 let encrypted: EncryptedToken = serde_json::from_str(&json).context("Failed to parse token file")?;
385
386 let token = decrypt_token(&encrypted)?;
387
388 if token.is_expired() {
390 tracing::warn!("OAuth token has expired, removing...");
391 clear_oauth_token_file()?;
392 return Ok(None);
393 }
394
395 Ok(Some(token))
396}
397
398pub fn load_oauth_token() -> Result<Option<OpenRouterToken>> {
410 match load_oauth_token_keyring() {
411 Ok(Some(token)) => return Ok(Some(token)),
412 Ok(None) => {
413 tracing::debug!("No token in keyring, checking file storage");
415 }
416 Err(e) => {
417 let error_str = e.to_string().to_lowercase();
419 if error_str.contains("no entry") || error_str.contains("not found") {
420 tracing::debug!("Keyring entry not found, checking file storage");
421 } else {
422 return Err(e);
425 }
426 }
427 }
428
429 load_oauth_token_file()
431}
432
433fn clear_oauth_token_keyring() -> Result<()> {
435 let entry = match keyring::entry("vtcode", "openrouter_oauth") {
436 Ok(e) => e,
437 Err(_) => return Ok(()),
438 };
439
440 match entry.delete_credential() {
441 Ok(_) => tracing::info!("OAuth token cleared from keyring"),
442 Err(keyring_core::Error::NoEntry) => {}
443 Err(e) => return Err(anyhow!("Failed to clear keyring entry: {e}")),
444 }
445
446 Ok(())
447}
448
449fn clear_oauth_token_file() -> Result<()> {
451 let path = get_token_path()?;
452
453 if path.exists() {
454 fs::remove_file(&path).context("Failed to remove token file")?;
455 tracing::info!("OAuth token cleared from file");
456 }
457
458 Ok(())
459}
460
461pub fn clear_oauth_token_with_mode(mode: AuthCredentialsStoreMode) -> Result<()> {
463 match mode.effective_mode() {
464 AuthCredentialsStoreMode::Keyring => clear_oauth_token_keyring(),
465 AuthCredentialsStoreMode::File => clear_oauth_token_file(),
466 AuthCredentialsStoreMode::Auto => {
467 drop(clear_oauth_token_keyring());
468 drop(clear_oauth_token_file());
469 Ok(())
470 }
471 }
472}
473
474pub fn clear_oauth_token() -> Result<()> {
475 drop(clear_oauth_token_keyring());
477 drop(clear_oauth_token_file());
478
479 tracing::info!("OAuth token cleared from all storage");
480 Ok(())
481}
482
483pub fn get_auth_status_with_mode(mode: AuthCredentialsStoreMode) -> Result<AuthStatus> {
485 match load_oauth_token_with_mode(mode)? {
486 Some(token) => {
487 let now = std::time::SystemTime::now()
488 .duration_since(std::time::UNIX_EPOCH)
489 .map(|d| d.as_secs())
490 .unwrap_or(0);
491
492 let age_seconds = now.saturating_sub(token.obtained_at);
493
494 Ok(AuthStatus::Authenticated {
495 label: token.label,
496 age_seconds,
497 expires_in: token.expires_at.map(|e| e.saturating_sub(now)),
498 })
499 }
500 None => Ok(AuthStatus::NotAuthenticated),
501 }
502}
503
504pub fn get_auth_status() -> Result<AuthStatus> {
505 match load_oauth_token()? {
506 Some(token) => {
507 let now = std::time::SystemTime::now()
508 .duration_since(std::time::UNIX_EPOCH)
509 .map(|d| d.as_secs())
510 .unwrap_or(0);
511
512 let age_seconds = now.saturating_sub(token.obtained_at);
513
514 Ok(AuthStatus::Authenticated {
515 label: token.label,
516 age_seconds,
517 expires_in: token.expires_at.map(|e| e.saturating_sub(now)),
518 })
519 }
520 None => Ok(AuthStatus::NotAuthenticated),
521 }
522}
523
524#[derive(Debug, Clone)]
526pub enum AuthStatus {
527 Authenticated {
529 label: Option<String>,
531 age_seconds: u64,
533 expires_in: Option<u64>,
535 },
536 NotAuthenticated,
538}
539
540impl AuthStatus {
541 pub fn is_authenticated(&self) -> bool {
543 matches!(self, AuthStatus::Authenticated { .. })
544 }
545
546 fn display_string(&self) -> String {
548 match self {
549 AuthStatus::Authenticated { label, age_seconds, expires_in } => {
550 let label_str = label.as_ref().map(|l| format!(" ({l})")).unwrap_or_default();
551 let age_str = humanize_duration(*age_seconds);
552 let expiry_str = expires_in
553 .map(|e| format!(", expires in {}", humanize_duration(e)))
554 .unwrap_or_default();
555 format!("Authenticated{label_str}, obtained {age_str}{expiry_str}")
556 }
557 AuthStatus::NotAuthenticated => "Not authenticated".to_string(),
558 }
559 }
560}
561
562fn humanize_duration(seconds: u64) -> String {
564 if seconds < 60 {
565 format!("{seconds}s ago")
566 } else if seconds < 3600 {
567 format!("{}m ago", seconds / 60)
568 } else if seconds < 86400 {
569 format!("{}h ago", seconds / 3600)
570 } else {
571 format!("{}d ago", seconds / 86400)
572 }
573}
574
575#[cfg(test)]
576mod tests {
577 use super::*;
578 use assert_fs::TempDir;
579 use serial_test::serial;
580
581 struct TestAuthDirGuard {
582 temp_dir: Option<TempDir>,
583 previous: Option<PathBuf>,
584 }
585
586 impl TestAuthDirGuard {
587 fn new() -> Self {
588 let temp_dir = TempDir::new().expect("create temp auth dir");
589 let previous = crate::storage_paths::auth_storage_dir_override_for_tests().expect("read auth dir override");
590 crate::storage_paths::set_auth_storage_dir_override_for_tests(Some(temp_dir.path().to_path_buf()))
591 .expect("set temp auth dir override");
592 Self { temp_dir: Some(temp_dir), previous }
593 }
594 }
595
596 impl Drop for TestAuthDirGuard {
597 fn drop(&mut self) {
598 crate::storage_paths::set_auth_storage_dir_override_for_tests(self.previous.clone())
599 .expect("restore auth dir override");
600 if let Some(temp_dir) = self.temp_dir.take() {
601 temp_dir.close().expect("remove temp auth dir");
602 }
603 }
604 }
605
606 #[test]
607 fn test_auth_url_generation() {
608 let challenge = PkceChallenge {
609 code_verifier: "test_verifier".to_string(),
610 code_challenge: "test_challenge".to_string(),
611 code_challenge_method: "S256".to_string(),
612 };
613
614 let url = get_auth_url(&challenge, 8484);
615
616 assert!(url.starts_with("https://openrouter.ai/auth"));
617 assert!(url.contains("callback_url="));
618 assert!(url.contains("code_challenge=test_challenge"));
619 assert!(url.contains("code_challenge_method=S256"));
620 }
621
622 #[test]
623 fn test_token_expiry_check() {
624 let now = std::time::SystemTime::now()
625 .duration_since(std::time::UNIX_EPOCH)
626 .unwrap()
627 .as_secs();
628
629 let token = OpenRouterToken {
631 api_key: "test".to_string(),
632 obtained_at: now,
633 expires_at: Some(now + 3600),
634 label: None,
635 };
636 assert!(!token.is_expired());
637
638 let expired_token = OpenRouterToken {
640 api_key: "test".to_string(),
641 obtained_at: now - 7200,
642 expires_at: Some(now - 3600),
643 label: None,
644 };
645 assert!(expired_token.is_expired());
646
647 let no_expiry_token = OpenRouterToken {
649 api_key: "test".to_string(),
650 obtained_at: now,
651 expires_at: None,
652 label: None,
653 };
654 assert!(!no_expiry_token.is_expired());
655 }
656
657 #[test]
658 fn test_encryption_roundtrip() {
659 let token = OpenRouterToken {
660 api_key: "sk-test-key-12345".to_string(),
661 obtained_at: 1234567890,
662 expires_at: Some(1234567890 + 86400),
663 label: Some("Test Token".to_string()),
664 };
665
666 let encrypted = encrypt_token(&token).unwrap();
667 let decrypted = decrypt_token(&encrypted).unwrap();
668
669 assert_eq!(decrypted.api_key, token.api_key);
670 assert_eq!(decrypted.obtained_at, token.obtained_at);
671 assert_eq!(decrypted.expires_at, token.expires_at);
672 assert_eq!(decrypted.label, token.label);
673 }
674
675 #[test]
676 fn test_auth_status_display() {
677 let status = AuthStatus::Authenticated {
678 label: Some("My App".to_string()),
679 age_seconds: 3700,
680 expires_in: Some(86000),
681 };
682
683 let display = status.display_string();
684 assert!(display.contains("Authenticated"));
685 assert!(display.contains("My App"));
686 }
687
688 #[test]
689 #[serial]
690 fn file_storage_round_trips_without_plaintext() {
691 let _guard = TestAuthDirGuard::new();
692 let now = std::time::SystemTime::now()
693 .duration_since(std::time::UNIX_EPOCH)
694 .unwrap()
695 .as_secs();
696 let token = OpenRouterToken {
697 api_key: "sk-test-key-12345".to_string(),
698 obtained_at: now,
699 expires_at: Some(now + 86400),
700 label: Some("Test Token".to_string()),
701 };
702
703 save_oauth_token_with_mode(&token, AuthCredentialsStoreMode::File).expect("save token");
704 let loaded = load_oauth_token_with_mode(AuthCredentialsStoreMode::File).expect("load token");
705 assert_eq!(loaded.as_ref().map(|value| &value.api_key), Some(&token.api_key));
706
707 let stored = fs::read_to_string(get_token_path().expect("token path")).expect("read token file");
708 assert!(!stored.contains(&token.api_key));
709 }
710
711 #[test]
712 #[serial]
713 #[cfg(unix)]
714 fn file_storage_uses_private_permissions() {
715 use std::os::unix::fs::PermissionsExt;
716
717 let _guard = TestAuthDirGuard::new();
718 let now = std::time::SystemTime::now()
719 .duration_since(std::time::UNIX_EPOCH)
720 .unwrap()
721 .as_secs();
722 let token = OpenRouterToken {
723 api_key: "sk-test-key-12345".to_string(),
724 obtained_at: now,
725 expires_at: Some(now + 86400),
726 label: Some("Test Token".to_string()),
727 };
728
729 save_oauth_token_with_mode(&token, AuthCredentialsStoreMode::File).expect("save token");
730
731 let metadata = fs::metadata(get_token_path().expect("token path")).expect("read token metadata");
732 assert_eq!(metadata.permissions().mode() & 0o777, 0o600);
733 }
734}