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 = client
144 .post(OPENROUTER_KEYS_URL)
145 .header("Content-Type", "application/json")
146 .json(&payload)
147 .send()
148 .await
149 .context("Failed to send token exchange request")?;
150
151 let status = response.status();
152 if !status.is_success() {
153 return Err(token_exchange_error(status));
156 }
157
158 let body = response.text().await.context("Failed to read response body")?;
160 let response_json: serde_json::Value = serde_json::from_str(&body).context("Failed to parse token response")?;
161
162 let api_key = response_json
163 .get("key")
164 .and_then(|v| v.as_str())
165 .ok_or_else(|| anyhow!("Response missing 'key' field"))?
166 .to_string();
167
168 Ok(api_key)
169}
170
171fn token_exchange_error(status: reqwest::StatusCode) -> anyhow::Error {
172 match status.as_u16() {
173 400 => anyhow!("Invalid code_challenge_method. Ensure you're using the same method (S256) in both steps."),
174 403 => anyhow!("Invalid code or code_verifier. The authorization code may have expired."),
175 405 => anyhow!("Method not allowed. Ensure you're using POST over HTTPS."),
176 _ => anyhow!("Token exchange failed (HTTP {status})"),
177 }
178}
179
180pub fn save_oauth_token_with_mode(token: &OpenRouterToken, mode: AuthCredentialsStoreMode) -> Result<()> {
186 OpenRouterTokenStorage::new().save(token, mode)
187}
188
189pub fn save_oauth_token(token: &OpenRouterToken) -> Result<()> {
193 save_oauth_token_with_mode(token, AuthCredentialsStoreMode::default())
194}
195
196pub fn load_oauth_token_with_mode(mode: AuthCredentialsStoreMode) -> Result<Option<OpenRouterToken>> {
200 let storage = OpenRouterTokenStorage::new();
201 let Some(token) = storage.load(mode)? else {
202 return Ok(None);
203 };
204
205 if token.is_expired() {
206 tracing::warn!("OpenRouter OAuth token has expired, removing it");
207 storage.clear(mode)?;
208 return Ok(None);
209 }
210
211 Ok(Some(token))
212}
213
214pub fn load_oauth_token() -> Result<Option<OpenRouterToken>> {
219 let storage = OpenRouterTokenStorage::new();
220 for mode in [AuthCredentialsStoreMode::Keyring, AuthCredentialsStoreMode::File] {
221 let Some(token) = storage.load(mode)? else {
222 continue;
223 };
224
225 if token.is_expired() {
226 tracing::warn!("OpenRouter OAuth token has expired, removing it");
227 storage.clear(mode)?;
228 continue;
229 }
230
231 return Ok(Some(token));
232 }
233
234 Ok(None)
235}
236
237pub fn clear_oauth_token_with_mode(mode: AuthCredentialsStoreMode) -> Result<()> {
239 OpenRouterTokenStorage::new().clear(mode)
240}
241
242pub fn clear_oauth_token() -> Result<()> {
244 OpenRouterTokenStorage::new().clear_all()
245}
246
247pub fn get_auth_status_with_mode(mode: AuthCredentialsStoreMode) -> Result<AuthStatus> {
249 match load_oauth_token_with_mode(mode)? {
250 Some(token) => {
251 let now = std::time::SystemTime::now()
252 .duration_since(std::time::UNIX_EPOCH)
253 .map(|d| d.as_secs())
254 .unwrap_or(0);
255
256 let age_seconds = now.saturating_sub(token.obtained_at);
257
258 Ok(AuthStatus::Authenticated {
259 label: token.label,
260 age_seconds,
261 expires_in: token.expires_at.map(|e| e.saturating_sub(now)),
262 })
263 }
264 None => Ok(AuthStatus::NotAuthenticated),
265 }
266}
267
268pub fn get_auth_status() -> Result<AuthStatus> {
269 match load_oauth_token()? {
270 Some(token) => {
271 let now = std::time::SystemTime::now()
272 .duration_since(std::time::UNIX_EPOCH)
273 .map(|d| d.as_secs())
274 .unwrap_or(0);
275
276 let age_seconds = now.saturating_sub(token.obtained_at);
277
278 Ok(AuthStatus::Authenticated {
279 label: token.label,
280 age_seconds,
281 expires_in: token.expires_at.map(|e| e.saturating_sub(now)),
282 })
283 }
284 None => Ok(AuthStatus::NotAuthenticated),
285 }
286}
287
288#[derive(Debug, Clone)]
290pub enum AuthStatus {
291 Authenticated {
293 label: Option<String>,
295 age_seconds: u64,
297 expires_in: Option<u64>,
299 },
300 NotAuthenticated,
302}
303
304impl AuthStatus {
305 pub fn is_authenticated(&self) -> bool {
307 matches!(self, AuthStatus::Authenticated { .. })
308 }
309
310 fn display_string(&self) -> String {
312 match self {
313 AuthStatus::Authenticated { label, age_seconds, expires_in } => {
314 let label_str = label.as_ref().map(|l| format!(" ({l})")).unwrap_or_default();
315 let age_str = format!("{} ago", vtcode_commons::formatting::humanize_duration_compact(*age_seconds));
316 let expiry_str = expires_in
317 .map(|e| format!(", expires in {}", vtcode_commons::formatting::humanize_duration_compact(e)))
318 .unwrap_or_default();
319 format!("Authenticated{label_str}, obtained {age_str}{expiry_str}")
320 }
321 AuthStatus::NotAuthenticated => "Not authenticated".to_string(),
322 }
323 }
324}
325
326#[cfg(test)]
327mod tests {
328 use super::*;
329 use assert_fs::TempDir;
330 use serial_test::serial;
331 use std::fs;
332 use std::path::PathBuf;
333
334 struct TestAuthDirGuard {
335 temp_dir: Option<TempDir>,
336 previous: Option<PathBuf>,
337 }
338
339 impl TestAuthDirGuard {
340 fn new() -> Self {
341 let temp_dir = TempDir::new().expect("create temp auth dir");
342 let previous = crate::storage_paths::auth_storage_dir_override_for_tests().expect("read auth dir override");
343 crate::storage_paths::set_auth_storage_dir_override_for_tests(Some(temp_dir.path().to_path_buf()))
344 .expect("set temp auth dir override");
345 Self { temp_dir: Some(temp_dir), previous }
346 }
347 }
348
349 impl Drop for TestAuthDirGuard {
350 fn drop(&mut self) {
351 crate::storage_paths::set_auth_storage_dir_override_for_tests(self.previous.clone())
352 .expect("restore auth dir override");
353 if let Some(temp_dir) = self.temp_dir.take() {
354 temp_dir.close().expect("remove temp auth dir");
355 }
356 }
357 }
358
359 #[test]
360 fn test_auth_url_generation() {
361 let challenge = PkceChallenge {
362 code_verifier: "test_verifier".to_string(),
363 code_challenge: "test_challenge".to_string(),
364 code_challenge_method: "S256".to_string(),
365 };
366
367 let url = get_auth_url(&challenge, 8484);
368
369 assert!(url.starts_with("https://openrouter.ai/auth"));
370 assert!(url.contains("callback_url="));
371 assert!(url.contains("code_challenge=test_challenge"));
372 assert!(url.contains("code_challenge_method=S256"));
373 }
374
375 #[test]
376 fn debug_impl_redacts_api_key() {
377 let token = OpenRouterToken {
378 api_key: "sk-openrouter-secret".to_string(),
379 obtained_at: 123,
380 expires_at: Some(456),
381 label: Some("test token".to_string()),
382 };
383
384 let debug = format!("{token:?}");
385
386 assert!(!debug.contains("sk-openrouter-secret"), "api key leaked: {debug}");
387 assert!(debug.contains("<redacted>"), "api key should be redacted: {debug}");
388 assert!(debug.contains("test token"), "non-secret metadata should remain visible: {debug}");
389 }
390
391 #[test]
392 fn token_exchange_errors_do_not_include_response_bodies() {
393 let error = token_exchange_error(reqwest::StatusCode::INTERNAL_SERVER_ERROR);
394 let message = error.to_string();
395
396 assert_eq!(message, "Token exchange failed (HTTP 500 Internal Server Error)");
397 assert!(!message.contains("sk-openrouter-secret"));
398 }
399
400 #[test]
401 fn test_token_expiry_check() {
402 let now = std::time::SystemTime::now()
403 .duration_since(std::time::UNIX_EPOCH)
404 .unwrap()
405 .as_secs();
406
407 let token = OpenRouterToken {
409 api_key: "test".to_string(),
410 obtained_at: now,
411 expires_at: Some(now + 3600),
412 label: None,
413 };
414 assert!(!token.is_expired());
415
416 let expired_token = OpenRouterToken {
418 api_key: "test".to_string(),
419 obtained_at: now - 7200,
420 expires_at: Some(now - 3600),
421 label: None,
422 };
423 assert!(expired_token.is_expired());
424
425 let no_expiry_token = OpenRouterToken {
427 api_key: "test".to_string(),
428 obtained_at: now,
429 expires_at: None,
430 label: None,
431 };
432 assert!(!no_expiry_token.is_expired());
433 }
434
435 #[test]
436 fn test_encryption_roundtrip() {
437 let token = OpenRouterToken {
438 api_key: "sk-test-key-12345".to_string(),
439 obtained_at: 1234567890,
440 expires_at: Some(1234567890 + 86400),
441 label: Some("Test Token".to_string()),
442 };
443
444 let encrypted = encrypt_token(&token).unwrap();
445 let decrypted = decrypt_token(&encrypted).unwrap();
446
447 assert_eq!(decrypted.api_key, token.api_key);
448 assert_eq!(decrypted.obtained_at, token.obtained_at);
449 assert_eq!(decrypted.expires_at, token.expires_at);
450 assert_eq!(decrypted.label, token.label);
451 }
452
453 #[test]
454 fn test_auth_status_display() {
455 let status = AuthStatus::Authenticated {
456 label: Some("My App".to_string()),
457 age_seconds: 3700,
458 expires_in: Some(86000),
459 };
460
461 let display = status.display_string();
462 assert!(display.contains("Authenticated"));
463 assert!(display.contains("My App"));
464 }
465
466 #[test]
467 #[serial]
468 fn file_storage_round_trips_without_plaintext() {
469 let _guard = TestAuthDirGuard::new();
470 let now = std::time::SystemTime::now()
471 .duration_since(std::time::UNIX_EPOCH)
472 .unwrap()
473 .as_secs();
474 let token = OpenRouterToken {
475 api_key: "sk-test-key-12345".to_string(),
476 obtained_at: now,
477 expires_at: Some(now + 86400),
478 label: Some("Test Token".to_string()),
479 };
480
481 save_oauth_token_with_mode(&token, AuthCredentialsStoreMode::File).expect("save token");
482 let loaded = load_oauth_token_with_mode(AuthCredentialsStoreMode::File).expect("load token");
483 assert_eq!(loaded.as_ref().map(|value| &value.api_key), Some(&token.api_key));
484
485 let stored = fs::read_to_string(OpenRouterTokenStorage::new().current_file_path().expect("token path"))
486 .expect("read token file");
487 assert!(!stored.contains(&token.api_key));
488 }
489
490 #[test]
491 #[serial]
492 fn default_loader_falls_back_to_shared_file_storage() {
493 let _guard = TestAuthDirGuard::new();
494 let token = OpenRouterToken {
495 api_key: "sk-default-file-token".to_string(),
496 obtained_at: 1,
497 expires_at: None,
498 label: Some("default file fallback".to_string()),
499 };
500
501 save_oauth_token_with_mode(&token, AuthCredentialsStoreMode::File).expect("save token");
502
503 let loaded = load_oauth_token()
504 .expect("load default token")
505 .expect("token should be present");
506 assert_eq!(loaded.api_key, token.api_key);
507 }
508
509 #[test]
510 #[serial]
511 fn legacy_file_token_migrates_to_shared_storage() {
512 let _guard = TestAuthDirGuard::new();
513 let token = OpenRouterToken {
514 api_key: "sk-legacy-token".to_string(),
515 obtained_at: 1,
516 expires_at: None,
517 label: Some("legacy".to_string()),
518 };
519 let encrypted = encrypt_token(&token).expect("encrypt legacy token");
520 let legacy_path = get_token_path().expect("legacy token path");
521 fs::write(&legacy_path, serde_json::to_vec(&encrypted).expect("serialize legacy token"))
522 .expect("write legacy token");
523
524 let loaded = load_oauth_token_with_mode(AuthCredentialsStoreMode::File)
525 .expect("load migrated token")
526 .expect("token should be present");
527
528 assert_eq!(loaded.api_key, token.api_key);
529 assert!(legacy_path.exists(), "legacy token should remain as a rollback source after migration");
530 assert!(
531 OpenRouterTokenStorage::new()
532 .current_file_path()
533 .expect("shared token path")
534 .exists()
535 );
536 }
537
538 #[test]
539 #[serial]
540 #[cfg(unix)]
541 fn file_storage_uses_private_permissions() {
542 use std::os::unix::fs::PermissionsExt;
543
544 let _guard = TestAuthDirGuard::new();
545 let now = std::time::SystemTime::now()
546 .duration_since(std::time::UNIX_EPOCH)
547 .unwrap()
548 .as_secs();
549 let token = OpenRouterToken {
550 api_key: "sk-test-key-12345".to_string(),
551 obtained_at: now,
552 expires_at: Some(now + 86400),
553 label: Some("Test Token".to_string()),
554 };
555
556 save_oauth_token_with_mode(&token, AuthCredentialsStoreMode::File).expect("save token");
557
558 let metadata = fs::metadata(OpenRouterTokenStorage::new().current_file_path().expect("token path"))
559 .expect("read token metadata");
560 assert_eq!(metadata.permissions().mode() & 0o777, 0o600);
561 }
562}