1use 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
31const OPENROUTER_AUTH_URL: &str = "https://openrouter.ai/auth";
33const OPENROUTER_KEYS_URL: &str = "https://openrouter.ai/api/v1/auth/keys";
34
35const DEFAULT_CALLBACK_PORT: u16 = 8484;
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
40#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
41#[serde(default)]
42pub struct OpenRouterOAuthConfig {
43 use_oauth: bool,
45 pub callback_port: u16,
47 auto_refresh: bool,
49 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#[derive(Clone, Serialize, Deserialize)]
66pub struct OpenRouterToken {
67 pub api_key: String,
69 pub obtained_at: u64,
71 pub expires_at: Option<u64>,
73 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 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
103pub 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
122pub 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 return Err(token_exchange_error(status));
158 }
159
160 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
182pub fn save_oauth_token_with_mode(token: &OpenRouterToken, mode: AuthCredentialsStoreMode) -> Result<()> {
188 OpenRouterTokenStorage::new().save(token, mode)
189}
190
191pub fn save_oauth_token(token: &OpenRouterToken) -> Result<()> {
195 save_oauth_token_with_mode(token, AuthCredentialsStoreMode::default())
196}
197
198pub 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
216pub 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
239pub fn clear_oauth_token_with_mode(mode: AuthCredentialsStoreMode) -> Result<()> {
241 OpenRouterTokenStorage::new().clear(mode)
242}
243
244pub fn clear_oauth_token() -> Result<()> {
246 OpenRouterTokenStorage::new().clear_all()
247}
248
249pub 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#[derive(Debug, Clone)]
292pub enum AuthStatus {
293 Authenticated {
295 label: Option<String>,
297 age_seconds: u64,
299 expires_in: Option<u64>,
301 },
302 NotAuthenticated,
304}
305
306impl AuthStatus {
307 pub fn is_authenticated(&self) -> bool {
309 matches!(self, AuthStatus::Authenticated { .. })
310 }
311
312 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 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 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 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}