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