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 = 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 return Err(token_exchange_error(status));
155 }
156
157 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
179pub fn save_oauth_token_with_mode(token: &OpenRouterToken, mode: AuthCredentialsStoreMode) -> Result<()> {
185 OpenRouterTokenStorage::new().save(token, mode)
186}
187
188pub fn save_oauth_token(token: &OpenRouterToken) -> Result<()> {
192 save_oauth_token_with_mode(token, AuthCredentialsStoreMode::default())
193}
194
195pub 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
213pub 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
236pub fn clear_oauth_token_with_mode(mode: AuthCredentialsStoreMode) -> Result<()> {
238 OpenRouterTokenStorage::new().clear(mode)
239}
240
241pub fn clear_oauth_token() -> Result<()> {
243 OpenRouterTokenStorage::new().clear_all()
244}
245
246pub 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#[derive(Debug, Clone)]
289pub enum AuthStatus {
290 Authenticated {
292 label: Option<String>,
294 age_seconds: u64,
296 expires_in: Option<u64>,
298 },
299 NotAuthenticated,
301}
302
303impl AuthStatus {
304 pub fn is_authenticated(&self) -> bool {
306 matches!(self, AuthStatus::Authenticated { .. })
307 }
308
309 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 = format!("{} ago", vtcode_commons::formatting::humanize_duration_compact(*age_seconds));
315 let expiry_str = expires_in
316 .map(|e| format!(", expires in {}", vtcode_commons::formatting::humanize_duration_compact(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#[cfg(test)]
326mod tests {
327 use super::*;
328 use assert_fs::TempDir;
329 use serial_test::serial;
330 use std::fs;
331 use std::path::PathBuf;
332
333 struct TestAuthDirGuard {
334 temp_dir: Option<TempDir>,
335 previous: Option<PathBuf>,
336 }
337
338 impl TestAuthDirGuard {
339 fn new() -> Self {
340 let temp_dir = TempDir::new().expect("create temp auth dir");
341 let previous = crate::storage_paths::auth_storage_dir_override_for_tests().expect("read auth dir override");
342 crate::storage_paths::set_auth_storage_dir_override_for_tests(Some(temp_dir.path().to_path_buf()))
343 .expect("set temp auth dir override");
344 Self { temp_dir: Some(temp_dir), previous }
345 }
346 }
347
348 impl Drop for TestAuthDirGuard {
349 fn drop(&mut self) {
350 crate::storage_paths::set_auth_storage_dir_override_for_tests(self.previous.clone())
351 .expect("restore auth dir override");
352 if let Some(temp_dir) = self.temp_dir.take() {
353 temp_dir.close().expect("remove temp auth dir");
354 }
355 }
356 }
357
358 #[test]
359 fn test_auth_url_generation() {
360 let challenge = PkceChallenge {
361 code_verifier: "test_verifier".to_string(),
362 code_challenge: "test_challenge".to_string(),
363 code_challenge_method: "S256".to_string(),
364 };
365
366 let url = get_auth_url(&challenge, 8484);
367
368 assert!(url.starts_with("https://openrouter.ai/auth"));
369 assert!(url.contains("callback_url="));
370 assert!(url.contains("code_challenge=test_challenge"));
371 assert!(url.contains("code_challenge_method=S256"));
372 }
373
374 #[test]
375 fn debug_impl_redacts_api_key() {
376 let token = OpenRouterToken {
377 api_key: "sk-openrouter-secret".to_string(),
378 obtained_at: 123,
379 expires_at: Some(456),
380 label: Some("test token".to_string()),
381 };
382
383 let debug = format!("{token:?}");
384
385 assert!(!debug.contains("sk-openrouter-secret"), "api key leaked: {debug}");
386 assert!(debug.contains("<redacted>"), "api key should be redacted: {debug}");
387 assert!(debug.contains("test token"), "non-secret metadata should remain visible: {debug}");
388 }
389
390 #[test]
391 fn token_exchange_errors_do_not_include_response_bodies() {
392 let error = token_exchange_error(reqwest::StatusCode::INTERNAL_SERVER_ERROR);
393 let message = error.to_string();
394
395 assert_eq!(message, "Token exchange failed (HTTP 500 Internal Server Error)");
396 assert!(!message.contains("sk-openrouter-secret"));
397 }
398
399 #[test]
400 fn test_token_expiry_check() {
401 let now = std::time::SystemTime::now()
402 .duration_since(std::time::UNIX_EPOCH)
403 .unwrap()
404 .as_secs();
405
406 let token = OpenRouterToken {
408 api_key: "test".to_string(),
409 obtained_at: now,
410 expires_at: Some(now + 3600),
411 label: None,
412 };
413 assert!(!token.is_expired());
414
415 let expired_token = OpenRouterToken {
417 api_key: "test".to_string(),
418 obtained_at: now - 7200,
419 expires_at: Some(now - 3600),
420 label: None,
421 };
422 assert!(expired_token.is_expired());
423
424 let no_expiry_token = OpenRouterToken {
426 api_key: "test".to_string(),
427 obtained_at: now,
428 expires_at: None,
429 label: None,
430 };
431 assert!(!no_expiry_token.is_expired());
432 }
433
434 #[test]
435 fn test_encryption_roundtrip() {
436 let token = OpenRouterToken {
437 api_key: "sk-test-key-12345".to_string(),
438 obtained_at: 1234567890,
439 expires_at: Some(1234567890 + 86400),
440 label: Some("Test Token".to_string()),
441 };
442
443 let encrypted = encrypt_token(&token).unwrap();
444 let decrypted = decrypt_token(&encrypted).unwrap();
445
446 assert_eq!(decrypted.api_key, token.api_key);
447 assert_eq!(decrypted.obtained_at, token.obtained_at);
448 assert_eq!(decrypted.expires_at, token.expires_at);
449 assert_eq!(decrypted.label, token.label);
450 }
451
452 #[test]
453 fn test_auth_status_display() {
454 let status = AuthStatus::Authenticated {
455 label: Some("My App".to_string()),
456 age_seconds: 3700,
457 expires_in: Some(86000),
458 };
459
460 let display = status.display_string();
461 assert!(display.contains("Authenticated"));
462 assert!(display.contains("My App"));
463 }
464
465 #[test]
466 #[serial]
467 fn file_storage_round_trips_without_plaintext() {
468 let _guard = TestAuthDirGuard::new();
469 let now = std::time::SystemTime::now()
470 .duration_since(std::time::UNIX_EPOCH)
471 .unwrap()
472 .as_secs();
473 let token = OpenRouterToken {
474 api_key: "sk-test-key-12345".to_string(),
475 obtained_at: now,
476 expires_at: Some(now + 86400),
477 label: Some("Test Token".to_string()),
478 };
479
480 save_oauth_token_with_mode(&token, AuthCredentialsStoreMode::File).expect("save token");
481 let loaded = load_oauth_token_with_mode(AuthCredentialsStoreMode::File).expect("load token");
482 assert_eq!(loaded.as_ref().map(|value| &value.api_key), Some(&token.api_key));
483
484 let stored = fs::read_to_string(OpenRouterTokenStorage::new().current_file_path().expect("token path"))
485 .expect("read token file");
486 assert!(!stored.contains(&token.api_key));
487 }
488
489 #[test]
490 #[serial]
491 fn default_loader_falls_back_to_shared_file_storage() {
492 let _guard = TestAuthDirGuard::new();
493 let token = OpenRouterToken {
494 api_key: "sk-default-file-token".to_string(),
495 obtained_at: 1,
496 expires_at: None,
497 label: Some("default file fallback".to_string()),
498 };
499
500 save_oauth_token_with_mode(&token, AuthCredentialsStoreMode::File).expect("save token");
501
502 let loaded = load_oauth_token()
503 .expect("load default token")
504 .expect("token should be present");
505 assert_eq!(loaded.api_key, token.api_key);
506 }
507
508 #[test]
509 #[serial]
510 fn legacy_file_token_migrates_to_shared_storage() {
511 let _guard = TestAuthDirGuard::new();
512 let token = OpenRouterToken {
513 api_key: "sk-legacy-token".to_string(),
514 obtained_at: 1,
515 expires_at: None,
516 label: Some("legacy".to_string()),
517 };
518 let encrypted = encrypt_token(&token).expect("encrypt legacy token");
519 let legacy_path = get_token_path().expect("legacy token path");
520 fs::write(&legacy_path, serde_json::to_vec(&encrypted).expect("serialize legacy token"))
521 .expect("write legacy token");
522
523 let loaded = load_oauth_token_with_mode(AuthCredentialsStoreMode::File)
524 .expect("load migrated token")
525 .expect("token should be present");
526
527 assert_eq!(loaded.api_key, token.api_key);
528 assert!(legacy_path.exists(), "legacy token should remain as a rollback source after migration");
529 assert!(
530 OpenRouterTokenStorage::new()
531 .current_file_path()
532 .expect("shared token path")
533 .exists()
534 );
535 }
536
537 #[test]
538 #[serial]
539 #[cfg(unix)]
540 fn file_storage_uses_private_permissions() {
541 use std::os::unix::fs::PermissionsExt;
542
543 let _guard = TestAuthDirGuard::new();
544 let now = std::time::SystemTime::now()
545 .duration_since(std::time::UNIX_EPOCH)
546 .unwrap()
547 .as_secs();
548 let token = OpenRouterToken {
549 api_key: "sk-test-key-12345".to_string(),
550 obtained_at: now,
551 expires_at: Some(now + 86400),
552 label: Some("Test Token".to_string()),
553 };
554
555 save_oauth_token_with_mode(&token, AuthCredentialsStoreMode::File).expect("save token");
556
557 let metadata = fs::metadata(OpenRouterTokenStorage::new().current_file_path().expect("token path"))
558 .expect("read token metadata");
559 assert_eq!(metadata.permissions().mode() & 0o777, 0o600);
560 }
561}