1use std::collections::BTreeMap;
4use std::path::{Path, PathBuf};
5use std::sync::Arc;
6use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
7use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
8
9use serde_json::Value;
10use tokio::sync::RwLock;
11use tracing::{debug, warn};
12
13use crate::TokenProvider;
14use crate::error::AuthError;
15use crate::helpers::{
16 AUTH_LOCK_TIMEOUT, InFlight, cached_token_on_lock_failure, duration_from_expires_in_secs,
17 expand_tilde, is_oauth_transport_error, is_token_rotation_error, jail_creds_path,
18 lead_or_follow, oauth_http_client, parse_rfc3339, post_token_with_fallback, read_oauth_body,
19 redact_secret_looking, redact_url_origin, remaining_from_system_time, resolve_creds_path,
20 try_acquire_refresh_lock, vendor_rejected_summary,
21};
22use crate::keychain_guard::keychain_disabled;
23use crate::profile::{
24 AuthScheme, CredsFormat, ExpiresUnit, Login, OauthPack, ResolvedProfile, TokenResponse,
25};
26use crate::providers::static_token::StaticToken;
27#[cfg(any(target_os = "macos", test, feature = "test-util"))]
28use crate::writeback::apply_tokens;
29use crate::writeback::{
30 TokenWrite, copilot_store_pointers, json_string, json_u64, oidc_store_pointers,
31 oidc_token_url_ptr, oidc_token_url_value, pointer_get, read_creds_string, write_tokens,
32};
33
34const DEFAULT_LIFETIME_SECS: u64 = 3600;
35const ENV_TOKEN_LIFETIME_SECS: u64 = 60 * 60 * 24 * 365 * 10;
39
40struct CachedToken {
42 access_token: String,
43 refresh_token: Option<String>,
44 acquired_at: Instant,
45 lifetime: Duration,
46 unsaved_expires_in: Option<u64>,
48}
49
50impl std::fmt::Debug for CachedToken {
51 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52 f.debug_struct("CachedToken")
53 .field("access_token", &"[REDACTED]")
54 .field(
55 "refresh_token",
56 &self.refresh_token.as_ref().map(|_| "[REDACTED]"),
57 )
58 .field("acquired_at", &self.acquired_at)
59 .field("lifetime", &self.lifetime)
60 .field("unsaved_expires_in", &self.unsaved_expires_in)
61 .finish()
62 }
63}
64
65impl CachedToken {
66 fn needs_refresh(&self) -> bool {
67 self.acquired_at.elapsed() >= self.lifetime.mul_f64(0.8)
68 }
69}
70
71#[derive(Clone, Debug)]
73enum CredSource {
74 File(PathBuf),
75 #[cfg(any(target_os = "macos", test, feature = "test-util"))]
76 Keychain {
77 service: String,
78 account: String,
79 },
80 Env,
81}
82
83enum WriteBack {
84 File(PathBuf),
85 #[cfg(any(target_os = "macos", test, feature = "test-util"))]
86 Keychain {
87 service: String,
88 account: String,
89 },
90 None,
91}
92
93impl std::fmt::Debug for WriteBack {
94 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
95 match self {
96 Self::File(path) => f.debug_tuple("File").field(path).finish(),
97 #[cfg(any(target_os = "macos", test, feature = "test-util"))]
98 Self::Keychain { service, account } => f
99 .debug_struct("Keychain")
100 .field("service", service)
101 .field("account", account)
102 .finish(),
103 Self::None => write!(f, "None"),
104 }
105 }
106}
107
108#[derive(Clone, Debug)]
110struct StoreLayout {
111 access_ptr: String,
112 refresh_ptr: Option<String>,
113 expires_ptr: Option<String>,
114 expires_unit: ExpiresUnit,
115}
116
117#[derive(Clone)]
119pub struct ProfileTokenProvider {
120 inner: Arc<Inner>,
121}
122
123struct Inner {
124 oauth: OauthPack,
125 layout: StoreLayout,
126 write_back: WriteBack,
127 http: reqwest::Client,
128 force_refresh: AtomicBool,
129 skip_http_refresh: AtomicBool,
130 lock_timeout_ms: AtomicU64,
131 state: RwLock<CachedToken>,
132 inflight: InFlight,
133}
134
135impl std::fmt::Debug for ProfileTokenProvider {
136 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
137 f.debug_struct("ProfileTokenProvider")
138 .field("token_url", &redact_url_origin(&self.inner.oauth.token_url))
139 .field(
140 "token_url_fallback",
141 &self
142 .inner
143 .oauth
144 .token_url_fallback
145 .as_deref()
146 .map(redact_url_origin),
147 )
148 .field("write_back", &self.inner.write_back)
149 .finish()
150 }
151}
152
153pub fn credential_options(profile: &ResolvedProfile) -> String {
156 let mut options = vec![
157 "[oauth] table".to_string(),
158 "access_env".to_string(),
159 "[headers] Authorization or x-api-key".to_string(),
160 ];
161 if let Some(AuthScheme::Header(name)) = &profile.http.auth_scheme
162 && !name.eq_ignore_ascii_case("authorization")
163 && !name.eq_ignore_ascii_case("x-api-key")
164 {
165 options.push(format!("[headers] {name}"));
166 }
167 if let Some(env) = profile
168 .http
169 .gcp_key_env
170 .as_deref()
171 .filter(|value| !value.is_empty())
172 {
173 options.push(format!("gcp_key_env ({env})"));
174 }
175 options.push("auth_scheme = \"none\"".to_string());
176 let last = options.pop().unwrap_or_default();
177 if options.is_empty() {
178 last
179 } else {
180 format!("{}, or {last}", options.join(", "))
181 }
182}
183
184pub fn missing_credential_field(profile: &ResolvedProfile) -> String {
186 format!(
187 "{} on profile `{}`",
188 credential_options(profile),
189 profile.id
190 )
191}
192
193pub fn provider_from_profile(
201 profile: &ResolvedProfile,
202) -> Result<crate::AnyTokenProvider, AuthError> {
203 if let Some(oauth) = profile.oauth.as_ref() {
204 return provider_from_oauth(oauth);
205 }
206 if let Some(provider) = gcp_from_profile(profile)? {
207 return Ok(provider);
208 }
209 if !profile.access_env.is_empty() {
210 return static_from_access_env(&profile.id, &profile.access_env);
211 }
212 if matches!(profile.http.auth_scheme, Some(AuthScheme::None)) {
213 return Ok(StaticToken::new("").into());
214 }
215 Err(AuthError::MissingField(missing_credential_field(profile)))
216}
217
218fn gcp_from_profile(
219 profile: &ResolvedProfile,
220) -> Result<Option<crate::AnyTokenProvider>, AuthError> {
221 let Some(env_name) = profile.http.gcp_key_env.as_deref() else {
222 return Ok(None);
223 };
224 if env_name.is_empty() {
225 return Ok(None);
226 }
227 if let Ok(path) = std::env::var(env_name)
228 && !path.trim().is_empty()
229 {
230 return Ok(Some(crate::GcpTokenProvider::from_key_file(path)?.into()));
231 }
232 let Some(path) = crate::providers::gcp::default_adc_path() else {
233 return Ok(None);
234 };
235 if !path.is_file() {
236 return Ok(None);
237 }
238 Ok(Some(crate::GcpTokenProvider::from_key_file(path)?.into()))
239}
240
241fn static_from_access_env(
242 id: &str,
243 names: &[String],
244) -> Result<crate::AnyTokenProvider, AuthError> {
245 let mut tried = Vec::new();
246 for name in names {
247 if name.is_empty() {
248 continue;
249 }
250 tried.push(name.as_str());
251 match StaticToken::from_env(name) {
252 Ok(token) => return Ok(token.into()),
253 Err(AuthError::MissingField(_)) => continue,
254 Err(other) => return Err(other),
255 }
256 }
257 let listed = if tried.is_empty() {
258 "(none)".to_string()
259 } else {
260 tried.join(", ")
261 };
262 Err(AuthError::MissingField(format!(
263 "profile `{id}` access_env ({listed})"
264 )))
265}
266
267pub fn provider_from_oauth(oauth: &OauthPack) -> Result<crate::AnyTokenProvider, AuthError> {
269 provider_from_oauth_opts(oauth, None)
270}
271
272pub fn provider_from_oauth_opts(
277 oauth: &OauthPack,
278 explicit_creds_path: Option<&Path>,
279) -> Result<crate::AnyTokenProvider, AuthError> {
280 let loaded = load_credentials(oauth, explicit_creds_path)?;
281 Ok(crate::AnyTokenProvider::Profile(
282 ProfileTokenProvider::from_loaded(oauth.clone(), loaded)?,
283 ))
284}
285
286struct Loaded {
287 access_token: String,
288 refresh_token: Option<String>,
289 lifetime: Duration,
290 source: CredSource,
291 layout: StoreLayout,
292 store_token_url: Option<String>,
293}
294
295impl ProfileTokenProvider {
296 fn from_loaded(mut oauth: OauthPack, loaded: Loaded) -> Result<Self, AuthError> {
297 adopt_oidc_client_id(&mut oauth, &loaded.layout.access_ptr);
298 if loaded.access_token.trim().is_empty() {
299 return Err(empty_access_error(&oauth));
300 }
301 if oauth.creds_format == Some(CredsFormat::OidcAuthJson)
302 && loaded
303 .refresh_token
304 .as_deref()
305 .map(str::trim)
306 .filter(|s| !s.is_empty())
307 .is_none()
308 {
309 return Err(AuthError::TokenProvider(
310 "oidc-auth-json requires a non-empty refresh_token".into(),
311 ));
312 }
313 if let Some(url) = loaded.store_token_url.filter(|s| !s.is_empty())
314 && token_url_allowed(&url)
315 {
316 oauth.token_url = url;
317 }
318 let write_back = match &loaded.source {
319 CredSource::File(path) => absolute_write_back_path(path)
320 .map(WriteBack::File)
321 .unwrap_or(WriteBack::None),
322 #[cfg(any(target_os = "macos", test, feature = "test-util"))]
323 CredSource::Keychain { service, account } => WriteBack::Keychain {
324 service: service.clone(),
325 account: account.clone(),
326 },
327 CredSource::Env => WriteBack::None,
328 };
329 Ok(Self {
330 inner: Arc::new(Inner {
331 oauth,
332 layout: loaded.layout,
333 write_back,
334 http: oauth_http_client()?,
335 force_refresh: AtomicBool::new(false),
336 skip_http_refresh: AtomicBool::new(false),
337 lock_timeout_ms: AtomicU64::new(AUTH_LOCK_TIMEOUT.as_millis() as u64),
338 state: RwLock::new(CachedToken {
339 access_token: loaded.access_token,
340 refresh_token: loaded.refresh_token.filter(|s| !s.trim().is_empty()),
341 acquired_at: Instant::now(),
342 lifetime: loaded.lifetime,
343 unsaved_expires_in: None,
344 }),
345 inflight: InFlight::new(),
346 }),
347 })
348 }
349
350 #[cfg(test)]
351 pub(crate) fn set_lock_timeout(&self, timeout: Duration) {
352 self.inner
353 .lock_timeout_ms
354 .store(timeout.as_millis() as u64, Ordering::SeqCst);
355 }
356
357 pub fn without_http_refresh(&self) {
360 self.inner.skip_http_refresh.store(true, Ordering::SeqCst);
361 }
362
363 async fn reload_from_store(
364 &self,
365 ) -> Result<Option<(String, Option<String>, Duration)>, AuthError> {
366 match &self.inner.write_back {
367 WriteBack::File(path) => {
368 let content = read_creds_string(path).await?;
369 store_tokens_from_json(&content, &self.inner.oauth, &self.inner.layout, Some(path))
370 }
371 #[cfg(any(target_os = "macos", test, feature = "test-util"))]
372 WriteBack::Keychain { service, account } => {
373 let secret = read_keychain(service, account)?;
374 store_tokens_from_json(&secret, &self.inner.oauth, &self.inner.layout, None)
375 }
376 WriteBack::None => Ok(None),
377 }
378 }
379
380 async fn do_refresh(
381 &self,
382 refresh_token: &str,
383 ) -> Result<Result<ParsedTokenResponse, (u16, String, String)>, AuthError> {
384 let body = refresh_request_body(&self.inner.oauth, refresh_token);
385 debug!(
386 "refreshing token via {}",
387 redact_url_origin(&self.inner.oauth.token_url)
388 );
389 let (resp, used_url) = post_token_with_fallback(
390 &self.inner.http,
391 &self.inner.oauth,
392 &body,
393 "token refresh request failed",
394 )
395 .await?;
396
397 if !resp.status().is_success() {
398 let status = resp.status();
399 let raw = read_oauth_body(resp).await.unwrap_or_default();
400 return Ok(Err((status.as_u16(), raw, used_url)));
401 }
402
403 let raw = read_oauth_body(resp).await?;
404 match parse_token_response(&raw, self.inner.oauth.token_response.as_ref()) {
405 Ok(parsed) => Ok(Ok(parsed)),
406 Err(AuthError::EmptyWriteRefused) => Err(AuthError::EmptyWriteRefused),
407 Err(e) => {
408 let hint = setup_hint(&self.inner.oauth);
409 Err(AuthError::TokenProvider(format!("{e}; {hint}")))
410 }
411 }
412 }
413
414 async fn write_back(
415 &self,
416 access_token: &str,
417 refresh_token: Option<&str>,
418 expires_in: u64,
419 ) -> Result<(), AuthError> {
420 if access_token.trim().is_empty() {
421 return Err(AuthError::EmptyWriteRefused);
422 }
423 match &self.inner.write_back {
424 WriteBack::File(path) => {
425 let token_url_ptr =
426 oidc_token_url_ptr(&self.inner.oauth, &self.inner.layout.access_ptr);
427 write_tokens(
428 path,
429 &TokenWrite {
430 access_ptr: &self.inner.layout.access_ptr,
431 refresh_ptr: self.inner.layout.refresh_ptr.as_deref(),
432 expires_ptr: self.inner.layout.expires_ptr.as_deref(),
433 expires_unit: self.inner.layout.expires_unit,
434 access_token,
435 refresh_token,
436 expires_in_secs: expires_in,
437 expires_rfc3339: self.inner.oauth.creds_format
438 == Some(CredsFormat::OidcAuthJson),
439 token_url_ptr: token_url_ptr.as_deref(),
440 token_url: oidc_token_url_value(&self.inner.oauth),
441 },
442 )
443 .await
444 }
445 #[cfg(any(target_os = "macos", test, feature = "test-util"))]
446 WriteBack::Keychain { service, account } => {
447 let current = read_keychain(service, account)?;
448 let mut doc: Value = serde_json::from_str(¤t)?;
449 let token_url_ptr =
450 oidc_token_url_ptr(&self.inner.oauth, &self.inner.layout.access_ptr);
451 apply_tokens(
452 &mut doc,
453 &TokenWrite {
454 access_ptr: &self.inner.layout.access_ptr,
455 refresh_ptr: self.inner.layout.refresh_ptr.as_deref(),
456 expires_ptr: self.inner.layout.expires_ptr.as_deref(),
457 expires_unit: self.inner.layout.expires_unit,
458 access_token,
459 refresh_token,
460 expires_in_secs: expires_in,
461 expires_rfc3339: self.inner.oauth.creds_format
462 == Some(CredsFormat::OidcAuthJson),
463 token_url_ptr: token_url_ptr.as_deref(),
464 token_url: oidc_token_url_value(&self.inner.oauth),
465 },
466 )?;
467 let updated = serde_json::to_string(&doc)?;
468 write_keychain(service, account, &updated)
469 }
470 WriteBack::None => Ok(()),
471 }
472 }
473
474 async fn refresh_as_leader(&self, force: bool) -> Result<String, AuthError> {
475 let lock_timeout = Duration::from_millis(self.inner.lock_timeout_ms.load(Ordering::SeqCst));
476 let _file_lock = match try_acquire_refresh_lock(
477 refresh_lock_path(&self.inner.write_back).as_deref(),
478 "token refresh",
479 lock_timeout,
480 )
481 .await
482 {
483 Ok(guard) => guard,
484 Err(e) => {
485 let state = self.inner.state.read().await;
486 if state.unsaved_expires_in.is_some() {
487 return Err(self.stale_save_error(&e));
488 }
489 return cached_token_on_lock_failure(Some(state.access_token.as_str()), force, e);
490 }
491 };
492
493 if self.inner.oauth.creds_format == Some(CredsFormat::CopilotHosts) {
494 return self.adopt_copilot_hosts().await;
495 }
496
497 let refresh_token = {
498 let mut state = self.inner.state.write().await;
499 if state.unsaved_expires_in.is_some() {
500 return Err(self.stale_save_error(&AuthError::TokenProvider(
501 "credential save still pending".into(),
502 )));
503 }
504 if !force && !state.needs_refresh() {
505 return Ok(state.access_token.clone());
506 }
507
508 if let Ok(Some((store_token, store_rt, store_lifetime))) =
509 self.reload_from_store().await
510 && let Some(adopted) =
511 adopt_from_store(&state, &store_token, store_rt, store_lifetime)
512 {
513 debug!("token changed in store, adopting");
514 let token = adopted.access_token.clone();
515 *state = adopted;
516 if !force && !state.needs_refresh() {
517 return Ok(token);
518 }
519 }
520
521 if !state.needs_refresh() && !force {
522 return Ok(state.access_token.clone());
523 }
524
525 let Some(rt) = state
526 .refresh_token
527 .as_deref()
528 .map(str::trim)
529 .filter(|s| !s.is_empty())
530 else {
531 return Err(AuthError::TokenProvider(format!(
532 "access token expired and no refresh token is available; {}",
533 setup_hint(&self.inner.oauth)
534 )));
535 };
536 rt.to_owned()
537 };
538
539 let token_resp = match self.do_refresh(&refresh_token).await {
540 Ok(Ok(resp)) => resp,
541 Err(e) if !force && is_oauth_transport_error(&e) => {
542 let cached = self.inner.state.read().await.access_token.clone();
543 if cached.trim().is_empty() {
544 return Err(e);
545 }
546 warn!("token refresh transport failed ({e}), keeping cached token");
547 return Ok(cached);
548 }
549 Err(e) => return Err(e),
550 Ok(Err((status, body, url))) => {
551 if is_token_rotation_error(&body) {
552 match self.reload_from_store().await {
553 Ok(Some((store_token, store_rt, store_lifetime))) => {
554 let mut state = self.inner.state.write().await;
555 if let Some(adopted) =
556 adopt_from_store(&state, &store_token, store_rt, store_lifetime)
557 {
558 debug!("refresh token rotated, adopting store token");
559 let token = adopted.access_token.clone();
560 *state = adopted;
561 return Ok(token);
562 }
563 }
564 Ok(None) => {
565 warn!("refresh token rotated, store reload returned nothing");
566 }
567 Err(e) => {
568 warn!("refresh token rotated, failed to reload store: {e}");
569 }
570 }
571 }
572 let hint = setup_hint(&self.inner.oauth);
573 return Err(AuthError::VendorRejected {
574 status,
575 summary: format!(
576 "{}; {hint}",
577 vendor_rejected_summary("token refresh failed", &body, &url)
578 ),
579 });
580 }
581 };
582
583 if token_resp.access_token.trim().is_empty() {
584 return Err(AuthError::EmptyWriteRefused);
585 }
586
587 let expires_in = token_resp.expires_in.unwrap_or(DEFAULT_LIFETIME_SECS);
588 let lifetime = duration_from_expires_in_secs(expires_in);
589 let new_refresh = {
590 let mut state = self.inner.state.write().await;
591 let new_refresh = token_resp
592 .refresh_token
593 .clone()
594 .or_else(|| state.refresh_token.clone());
595 *state = CachedToken {
596 access_token: token_resp.access_token.clone(),
597 refresh_token: new_refresh.clone(),
598 acquired_at: Instant::now(),
599 lifetime,
600 unsaved_expires_in: Some(expires_in),
601 };
602 new_refresh
603 };
604
605 self.write_cached(&token_resp.access_token, new_refresh.as_deref(), expires_in)
606 .await?;
607 Ok(token_resp.access_token)
608 }
609
610 async fn persist_cached_token(&self) -> Result<String, AuthError> {
612 let lock_timeout = Duration::from_millis(self.inner.lock_timeout_ms.load(Ordering::SeqCst));
613 let _file_lock = match try_acquire_refresh_lock(
614 refresh_lock_path(&self.inner.write_back).as_deref(),
615 "token save",
616 lock_timeout,
617 )
618 .await
619 {
620 Ok(guard) => guard,
621 Err(e) => return Err(self.stale_save_error(&e)),
622 };
623 let (access, refresh, expires_in) = {
624 let state = self.inner.state.read().await;
625 let Some(expires_in) = state.unsaved_expires_in else {
626 return Ok(state.access_token.clone());
627 };
628 (
629 state.access_token.clone(),
630 state.refresh_token.clone(),
631 expires_in,
632 )
633 };
634 self.write_cached(&access, refresh.as_deref(), expires_in)
635 .await?;
636 Ok(access)
637 }
638
639 async fn write_cached(
640 &self,
641 access: &str,
642 refresh: Option<&str>,
643 expires_in: u64,
644 ) -> Result<(), AuthError> {
645 if let Err(e) = self.write_back(access, refresh, expires_in).await {
646 let stale = self.stale_save_error(&e);
647 warn!("failed to write refreshed token: {stale}");
648 return Err(stale);
649 }
650 let mut state = self.inner.state.write().await;
651 if state.access_token == access && state.unsaved_expires_in == Some(expires_in) {
652 state.unsaved_expires_in = None;
653 }
654 if state.unsaved_expires_in.is_some() {
655 return Err(self.stale_save_error(&AuthError::TokenProvider(
656 "credential save still pending".into(),
657 )));
658 }
659 Ok(())
660 }
661
662 fn credential_store_path(&self) -> PathBuf {
663 match &self.inner.write_back {
664 WriteBack::File(path) => path.clone(),
665 #[cfg(any(target_os = "macos", test, feature = "test-util"))]
666 WriteBack::Keychain { .. } => PathBuf::from("keychain"),
667 WriteBack::None => PathBuf::new(),
668 }
669 }
670
671 fn stale_save_error(&self, err: &AuthError) -> AuthError {
672 let detail = match err {
673 AuthError::Io { source, .. } => format!("save failed: {source}"),
674 AuthError::Json { source, .. } => format!("save failed: {source}"),
675 AuthError::EmptyWriteRefused => "save refused empty credentials".to_string(),
676 AuthError::LockTimeout => "save failed: file lock timeout".to_string(),
677 AuthError::CredentialStoreStale { detail, .. } => detail.clone(),
678 other => format!("save failed: {other}"),
679 };
680 AuthError::credential_store_stale(self.credential_store_path(), detail)
681 }
682
683 async fn adopt_copilot_hosts(&self) -> Result<String, AuthError> {
684 match self.reload_from_store().await {
685 Ok(Some((store_token, store_rt, store_lifetime))) => {
686 let mut state = self.inner.state.write().await;
687 if let Some(adopted) =
688 adopt_from_store(&state, &store_token, store_rt, store_lifetime)
689 {
690 debug!("copilot hosts token changed, adopting");
691 let token = adopted.access_token.clone();
692 *state = adopted;
693 return Ok(token);
694 }
695 Ok(state.access_token.clone())
696 }
697 Ok(None) => {
698 let state = self.inner.state.read().await;
699 if state.access_token.trim().is_empty() {
700 return Err(empty_access_error(&self.inner.oauth));
701 }
702 warn!("copilot hosts reload returned nothing, keeping cached token");
703 Ok(state.access_token.clone())
704 }
705 Err(e) => {
706 let state = self.inner.state.read().await;
707 if state.access_token.trim().is_empty() {
708 return Err(e);
709 }
710 warn!("copilot hosts reload failed ({e}), keeping cached token");
711 Ok(state.access_token.clone())
712 }
713 }
714 }
715}
716
717impl ProfileTokenProvider {
718 pub async fn offline_block(&self) -> Option<String> {
724 let state = self.inner.state.read().await;
725 if state.access_token.trim().is_empty() {
726 return Some(empty_access_error(&self.inner.oauth).to_string());
727 }
728 let refresh = state
729 .refresh_token
730 .as_deref()
731 .map(str::trim)
732 .filter(|token| !token.is_empty());
733 if state.needs_refresh() && refresh.is_none() {
734 return Some(format!(
735 "access token expired and no refresh token is available; {}",
736 setup_hint(&self.inner.oauth)
737 ));
738 }
739 None
740 }
741}
742
743impl TokenProvider for ProfileTokenProvider {
744 fn mark_stale(&self) {
745 self.inner.force_refresh.store(true, Ordering::SeqCst);
746 }
747
748 async fn get_token(&self) -> Result<String, AuthError> {
749 {
752 let state = self.inner.state.read().await;
753 let save_pending = state.unsaved_expires_in.is_some();
754 if !save_pending && self.inner.skip_http_refresh.load(Ordering::SeqCst) {
755 if state.access_token.trim().is_empty() {
756 return Err(empty_access_error(&self.inner.oauth));
757 }
758 return Ok(state.access_token.clone());
759 }
760 if !save_pending
761 && !self.inner.force_refresh.load(Ordering::SeqCst)
762 && !state.needs_refresh()
763 && !self.inner.inflight.is_busy()
764 {
765 return Ok(state.access_token.clone());
766 }
767 }
768 lead_or_follow(&self.inner.inflight, || async {
769 if self.inner.state.read().await.unsaved_expires_in.is_some() {
770 return self.persist_cached_token().await;
771 }
772 let force = self.inner.force_refresh.swap(false, Ordering::SeqCst);
773 let result = self.refresh_as_leader(force).await;
774 if result.is_err()
777 && force
778 && !matches!(result, Err(AuthError::CredentialStoreStale { .. }))
779 {
780 self.inner.force_refresh.store(true, Ordering::SeqCst);
781 }
782 result
783 })
784 .await
785 }
786}
787
788struct ParsedTokenResponse {
789 access_token: String,
790 refresh_token: Option<String>,
791 expires_in: Option<u64>,
792}
793
794fn adopt_oidc_client_id(oauth: &mut OauthPack, access_ptr: &str) {
795 if oauth
796 .client_id
797 .as_deref()
798 .map(str::trim)
799 .is_some_and(|s| !s.is_empty())
800 {
801 return;
802 }
803 let Some(id) = client_id_from_oidc_access_ptr(access_ptr) else {
804 return;
805 };
806 oauth.client_id = Some(id);
807}
808
809fn client_id_from_oidc_access_ptr(access_ptr: &str) -> Option<String> {
810 let rest = access_ptr.strip_prefix('/')?;
811 let entry = rest.strip_suffix("/key")?;
812 let entry = entry.replace("~1", "/").replace("~0", "~");
813 let (_, client) = entry.split_once("::")?;
814 let client = client.split('@').next()?.trim();
815 (!client.is_empty()).then(|| client.to_owned())
816}
817
818fn refresh_request_body(oauth: &OauthPack, refresh_token: &str) -> BTreeMap<String, String> {
819 let mut body = oauth.refresh_body.clone();
820 if !body.contains_key("grant_type") {
821 let grant = oauth
822 .refresh_grant
823 .as_deref()
824 .filter(|s| !s.is_empty())
825 .unwrap_or("refresh_token");
826 body.insert("grant_type".into(), grant.to_owned());
827 }
828 if !body.contains_key("client_id")
829 && let Some(id) = oauth
830 .client_id
831 .as_deref()
832 .map(str::trim)
833 .filter(|s| !s.is_empty())
834 {
835 body.insert("client_id".into(), id.to_owned());
836 }
837 body.insert("refresh_token".into(), refresh_token.to_owned());
839 body
840}
841
842fn parse_token_response(
843 raw: &str,
844 spec: Option<&TokenResponse>,
845) -> Result<ParsedTokenResponse, AuthError> {
846 let doc: Value = serde_json::from_str(raw)
847 .map_err(|e| AuthError::TokenProvider(format!("token refresh: invalid JSON: {e}")))?;
848 let access_ptr = spec
849 .and_then(|s| s.access_token_ptr.as_deref())
850 .unwrap_or("/access_token");
851 let refresh_ptr = spec
852 .and_then(|s| s.refresh_token_ptr.as_deref())
853 .unwrap_or("/refresh_token");
854 let expires_ptr = spec
855 .and_then(|s| s.expires_ptr.as_deref())
856 .unwrap_or("/expires_in");
857 let access = match pointer_get(&doc, access_ptr) {
858 Some(v) => json_string(v).unwrap_or_default(),
859 None => {
860 return Err(AuthError::TokenProvider(
861 "token response has no access token".into(),
862 ));
863 }
864 };
865 if access.is_empty() {
866 return Err(AuthError::EmptyWriteRefused);
867 }
868 let refresh = pointer_get(&doc, refresh_ptr).and_then(json_string);
869 let expires_in = pointer_get(&doc, expires_ptr).and_then(json_u64);
870 Ok(ParsedTokenResponse {
871 access_token: access,
872 refresh_token: refresh,
873 expires_in,
874 })
875}
876
877fn load_credentials(oauth: &OauthPack, explicit: Option<&Path>) -> Result<Loaded, AuthError> {
878 if let Some(path) = explicit {
879 let path = jail_creds_path(path)?;
880 if path.is_file() {
881 return load_from_file(oauth, &path);
882 }
883 return load_from_env(oauth)?.ok_or_else(|| missing_creds(oauth));
884 }
885
886 if let Some(raw) = oauth.creds_path.as_deref() {
887 let path = resolve_creds_path(raw)?;
888 if path.is_file() {
889 return load_from_file(oauth, &path);
890 }
891 }
894
895 if let Some(creds) = load_from_keychain(oauth)? {
896 return Ok(creds);
897 }
898
899 load_from_env(oauth)?.ok_or_else(|| missing_creds(oauth))
900}
901
902fn load_from_file(oauth: &OauthPack, path: &Path) -> Result<Loaded, AuthError> {
903 let path = jail_creds_path(path)?;
904 let path = path.as_path();
905 let meta = std::fs::metadata(path).map_err(|e| AuthError::io(Some(path.to_path_buf()), e))?;
906 if meta.len() > crate::helpers::MAX_CREDS_BYTES {
907 return Err(AuthError::TokenProvider(format!(
908 "credentials file too large ({} bytes)",
909 meta.len()
910 )));
911 }
912 let content =
913 std::fs::read_to_string(path).map_err(|e| AuthError::io(Some(path.to_path_buf()), e))?;
914 let doc: Value = serde_json::from_str(&content).map_err(|e| {
915 AuthError::TokenProvider(format!(
916 "parse credentials {}: {e}; {}",
917 path.display(),
918 setup_hint(oauth)
919 ))
920 })?;
921 let layout = resolve_layout(oauth, Some(&doc))?;
922 let parsed = tokens_from_doc(&doc, &layout).ok_or_else(|| empty_access_error(oauth))?;
923 if parsed.0.trim().is_empty() {
924 return Err(empty_access_error(oauth));
925 }
926 Ok(Loaded {
927 access_token: parsed.0,
928 refresh_token: parsed.1,
929 lifetime: copilot_or_default_lifetime(oauth, parsed.2),
930 source: CredSource::File(
931 absolute_write_back_path(path).unwrap_or_else(|| path.to_path_buf()),
932 ),
933 layout,
934 store_token_url: stored_oidc_token_url(oauth, &doc),
935 })
936}
937
938fn load_from_env(oauth: &OauthPack) -> Result<Option<Loaded>, AuthError> {
939 let Some(name) = oauth.access_env.as_deref().filter(|s| !s.is_empty()) else {
940 return Ok(None);
941 };
942 let Ok(token) = std::env::var(name) else {
943 return Ok(None);
944 };
945 let token = token.trim();
946 if token.is_empty() {
947 return Ok(None);
948 }
949 let layout = resolve_layout(oauth, None)?;
950 Ok(Some(Loaded {
951 access_token: token.to_owned(),
952 refresh_token: None,
953 lifetime: Duration::from_secs(ENV_TOKEN_LIFETIME_SECS),
954 source: CredSource::Env,
955 layout,
956 store_token_url: None,
957 }))
958}
959
960fn process_login_account() -> Option<String> {
961 for key in ["USER", "USERNAME"] {
962 if let Ok(v) = std::env::var(key) {
963 let t = v.trim();
964 if !t.is_empty() {
965 return Some(t.to_owned());
966 }
967 }
968 }
969 None
970}
971
972fn keychain_accounts_to_try(oauth: &OauthPack) -> Vec<String> {
973 let mut accounts = Vec::new();
974 if let Some(login) = process_login_account() {
975 accounts.push(login);
976 }
977 for account in &oauth.keychain_accounts {
978 let t = account.trim();
979 if t.is_empty() || accounts.iter().any(|a| a == t) {
980 continue;
981 }
982 accounts.push(t.to_owned());
983 }
984 accounts
985}
986
987fn load_from_keychain(oauth: &OauthPack) -> Result<Option<Loaded>, AuthError> {
988 let Some(service) = oauth
989 .keychain_service
990 .as_deref()
991 .map(str::trim)
992 .filter(|s| !s.is_empty())
993 else {
994 return Ok(None);
995 };
996 for account in keychain_accounts_to_try(oauth) {
997 if account.is_empty() {
998 continue;
999 }
1000 let Ok(secret) = read_keychain(service, &account) else {
1001 continue;
1002 };
1003 if secret.trim().is_empty() {
1004 continue;
1005 }
1006 let Ok(doc) = serde_json::from_str::<Value>(&secret) else {
1007 continue;
1008 };
1009 let Ok(layout) = resolve_layout(oauth, Some(&doc)) else {
1010 continue;
1011 };
1012 let Some(parsed) = tokens_from_doc(&doc, &layout) else {
1013 continue;
1014 };
1015 if parsed.0.is_empty() {
1016 continue;
1017 }
1018 #[cfg(any(target_os = "macos", test, feature = "test-util"))]
1019 {
1020 return Ok(Some(Loaded {
1021 access_token: parsed.0,
1022 refresh_token: parsed.1,
1023 lifetime: copilot_or_default_lifetime(oauth, parsed.2),
1024 source: CredSource::Keychain {
1025 service: service.to_owned(),
1026 account: account.clone(),
1027 },
1028 layout,
1029 store_token_url: stored_oidc_token_url(oauth, &doc),
1030 }));
1031 }
1032 }
1033 Ok(None)
1034}
1035
1036fn store_tokens_from_json(
1037 secret: &str,
1038 oauth: &OauthPack,
1039 layout: &StoreLayout,
1040 path: Option<&Path>,
1041) -> Result<Option<(String, Option<String>, Duration)>, AuthError> {
1042 let doc: Value = match serde_json::from_str(secret) {
1043 Ok(doc) => doc,
1044 Err(e) => {
1045 return Err(match path {
1046 Some(path) => AuthError::json(path, e),
1047 None => AuthError::from(e),
1048 });
1049 }
1050 };
1051 let layout = resolve_layout(oauth, Some(&doc)).unwrap_or_else(|_| layout.clone());
1052 Ok(tokens_from_doc(&doc, &layout).filter(|(a, _, _)| !a.is_empty()))
1053}
1054
1055fn tokens_from_doc(
1056 doc: &Value,
1057 layout: &StoreLayout,
1058) -> Option<(String, Option<String>, Duration)> {
1059 let access = pointer_get(doc, &layout.access_ptr).and_then(json_string)?;
1060 let refresh = layout
1061 .refresh_ptr
1062 .as_deref()
1063 .and_then(|p| pointer_get(doc, p))
1064 .and_then(json_string);
1065 let lifetime = layout
1066 .expires_ptr
1067 .as_deref()
1068 .and_then(|p| pointer_get(doc, p))
1069 .map(|v| lifetime_from_store_expires(v, layout.expires_unit))
1070 .unwrap_or(Duration::from_secs(DEFAULT_LIFETIME_SECS));
1071 Some((access, refresh, lifetime))
1072}
1073
1074fn lifetime_from_store_expires(value: &Value, unit: ExpiresUnit) -> Duration {
1075 if let Some(s) = value.as_str() {
1076 return parse_rfc3339(s)
1077 .map(remaining_from_system_time)
1078 .unwrap_or(Duration::from_secs(DEFAULT_LIFETIME_SECS));
1079 }
1080 let Some(n) = json_u64(value) else {
1081 return Duration::from_secs(DEFAULT_LIFETIME_SECS);
1082 };
1083 match unit {
1085 ExpiresUnit::Ms => remaining_from_epoch_ms(n),
1086 ExpiresUnit::S => remaining_from_epoch_secs(n),
1087 }
1088}
1089
1090fn remaining_from_epoch_ms(ms: u64) -> Duration {
1091 let now_ms = SystemTime::now()
1092 .duration_since(UNIX_EPOCH)
1093 .map(|d| d.as_millis() as u64)
1094 .unwrap_or(0);
1095 if ms <= now_ms {
1096 Duration::ZERO
1097 } else {
1098 Duration::from_millis(ms - now_ms)
1099 }
1100}
1101
1102fn remaining_from_epoch_secs(secs: u64) -> Duration {
1103 let now = SystemTime::now()
1104 .duration_since(UNIX_EPOCH)
1105 .map(|d| d.as_secs())
1106 .unwrap_or(0);
1107 if secs <= now {
1108 Duration::ZERO
1109 } else {
1110 Duration::from_secs(secs - now)
1111 }
1112}
1113
1114fn resolve_layout(oauth: &OauthPack, doc: Option<&Value>) -> Result<StoreLayout, AuthError> {
1115 if oauth.creds_format == Some(CredsFormat::ClaudeCredentials) || looks_like_claude(oauth, doc) {
1116 return Ok(claude_layout(oauth, doc));
1117 }
1118 if let Some(access) = oauth.access_token_ptr.clone() {
1119 return Ok(StoreLayout {
1120 access_ptr: access,
1121 refresh_ptr: oauth.refresh_token_ptr.clone(),
1122 expires_ptr: oauth.expires_ptr.clone(),
1123 expires_unit: oauth.expires_unit.unwrap_or(ExpiresUnit::S),
1124 });
1125 }
1126 if let Some((access, refresh, expires)) = oidc_store_pointers(oauth, doc)? {
1127 return Ok(StoreLayout {
1128 access_ptr: access,
1129 refresh_ptr: Some(refresh),
1130 expires_ptr: Some(expires),
1131 expires_unit: oauth.expires_unit.unwrap_or(ExpiresUnit::S),
1132 });
1133 }
1134 if let Some((access, refresh)) = copilot_store_pointers(oauth, doc)? {
1135 return Ok(StoreLayout {
1136 access_ptr: access,
1137 refresh_ptr: refresh,
1138 expires_ptr: None,
1139 expires_unit: oauth.expires_unit.unwrap_or(ExpiresUnit::S),
1140 });
1141 }
1142 Ok(StoreLayout {
1144 access_ptr: "/access_token".into(),
1145 refresh_ptr: oauth.refresh_token_ptr.clone(),
1146 expires_ptr: oauth.expires_ptr.clone(),
1147 expires_unit: oauth.expires_unit.unwrap_or(ExpiresUnit::S),
1148 })
1149}
1150
1151fn looks_like_claude(oauth: &OauthPack, doc: Option<&Value>) -> bool {
1152 if matches!(
1153 oauth.creds_format,
1154 Some(CredsFormat::JsonPointer | CredsFormat::OidcAuthJson | CredsFormat::CopilotHosts)
1155 ) {
1156 return false;
1157 }
1158 oauth.creds_format == Some(CredsFormat::ClaudeCredentials)
1159 || oauth
1160 .access_token_ptr
1161 .as_deref()
1162 .is_some_and(|p| p.contains("claudeAiOauth"))
1163 || doc.is_some_and(|d| d.get("claudeAiOauth").is_some() || d.get("accessToken").is_some())
1164}
1165
1166fn claude_layout(oauth: &OauthPack, doc: Option<&Value>) -> StoreLayout {
1167 let nested_ok = doc.is_some_and(|d| {
1168 matches!(d.get("claudeAiOauth"), Some(Value::Object(_)))
1169 && pointer_get(d, "/claudeAiOauth/accessToken")
1170 .and_then(json_string)
1171 .is_some()
1172 });
1173 let flat_ok = doc.is_some_and(|d| {
1174 pointer_get(d, "/accessToken")
1175 .and_then(json_string)
1176 .is_some()
1177 });
1178 if nested_ok || (!flat_ok && doc.is_none()) {
1179 StoreLayout {
1180 access_ptr: oauth
1181 .access_token_ptr
1182 .clone()
1183 .unwrap_or_else(|| "/claudeAiOauth/accessToken".into()),
1184 refresh_ptr: Some(
1185 oauth
1186 .refresh_token_ptr
1187 .clone()
1188 .unwrap_or_else(|| "/claudeAiOauth/refreshToken".into()),
1189 ),
1190 expires_ptr: Some(
1191 oauth
1192 .expires_ptr
1193 .clone()
1194 .unwrap_or_else(|| "/claudeAiOauth/expiresAt".into()),
1195 ),
1196 expires_unit: oauth.expires_unit.unwrap_or(ExpiresUnit::Ms),
1197 }
1198 } else {
1199 StoreLayout {
1200 access_ptr: "/accessToken".into(),
1201 refresh_ptr: Some("/refreshToken".into()),
1202 expires_ptr: Some("/expiresAt".into()),
1203 expires_unit: oauth.expires_unit.unwrap_or(ExpiresUnit::Ms),
1204 }
1205 }
1206}
1207
1208fn copilot_or_default_lifetime(oauth: &OauthPack, lifetime: Duration) -> Duration {
1209 if oauth.creds_format == Some(CredsFormat::CopilotHosts)
1210 && lifetime == Duration::from_secs(DEFAULT_LIFETIME_SECS)
1211 {
1212 Duration::from_secs(8 * 3600)
1213 } else {
1214 lifetime
1215 }
1216}
1217
1218fn stored_oidc_token_url(oauth: &OauthPack, doc: &Value) -> Option<String> {
1219 if oauth.creds_format != Some(CredsFormat::OidcAuthJson) {
1220 return None;
1221 }
1222 let (access, _, _) = oidc_store_pointers(oauth, Some(doc)).ok().flatten()?;
1223 let entry = access.strip_suffix("/key")?;
1224 let ptr = format!("{entry}/token_url");
1225 pointer_get(doc, &ptr).and_then(json_string)
1226}
1227
1228fn token_url_allowed(url: &str) -> bool {
1229 if crate::helpers::https_token_url(url).is_some() {
1230 return true;
1231 }
1232 #[cfg(any(test, feature = "test-util"))]
1233 {
1234 crate::profile::is_loopback_http(url)
1235 }
1236 #[cfg(not(any(test, feature = "test-util")))]
1237 {
1238 false
1239 }
1240}
1241
1242fn adopt_from_store(
1243 cached: &CachedToken,
1244 store_access: &str,
1245 store_refresh: Option<String>,
1246 store_lifetime: Duration,
1247) -> Option<CachedToken> {
1248 if cached.unsaved_expires_in.is_some()
1249 || store_access.is_empty()
1250 || store_access == cached.access_token
1251 {
1252 return None;
1253 }
1254 Some(CachedToken {
1255 access_token: store_access.to_owned(),
1256 refresh_token: store_refresh,
1257 acquired_at: Instant::now(),
1258 lifetime: store_lifetime,
1259 unsaved_expires_in: None,
1260 })
1261}
1262
1263fn refresh_lock_path(write_back: &WriteBack) -> Option<PathBuf> {
1264 match write_back {
1265 WriteBack::File(path) => Some(path.clone()),
1266 #[cfg(any(target_os = "macos", test, feature = "test-util"))]
1267 WriteBack::Keychain { service, account } => {
1268 Some(keychain_refresh_lock_path(service, account))
1269 }
1270 WriteBack::None => None,
1271 }
1272}
1273
1274#[cfg(any(target_os = "macos", test, feature = "test-util"))]
1275fn keychain_refresh_lock_path(service: &str, account: &str) -> PathBuf {
1276 std::env::temp_dir().join(format!(
1277 "wiremux-auth-{}-{}",
1278 sanitize_lock_component(service),
1279 sanitize_lock_component(account)
1280 ))
1281}
1282
1283#[cfg(any(target_os = "macos", test, feature = "test-util"))]
1284fn sanitize_lock_component(s: &str) -> String {
1285 let mut out = String::with_capacity(s.len());
1286 for c in s.chars() {
1287 if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
1288 out.push(c);
1289 } else {
1290 out.push('-');
1291 }
1292 }
1293 if out.is_empty() {
1294 out.push('_');
1295 }
1296 out
1297}
1298
1299fn absolute_write_back_path(path: &Path) -> Option<PathBuf> {
1300 let path = jail_creds_path(path).ok()?;
1301 if path.as_os_str().is_empty() {
1302 return None;
1303 }
1304 let has_parent = path
1305 .components()
1306 .any(|c| matches!(c, std::path::Component::ParentDir));
1307 if path.exists()
1308 && let Ok(canon) = std::fs::canonicalize(&path)
1309 {
1310 return Some(canon);
1311 }
1312 let joined = match std::env::current_dir() {
1313 Ok(cwd) => cwd.join(&path),
1314 Err(_) if path.is_absolute() && !has_parent => return Some(path),
1315 Err(_) => return None,
1316 };
1317 if let Ok(canon) = std::fs::canonicalize(&joined) {
1318 return Some(canon);
1319 }
1320 if joined.is_absolute()
1321 && !joined.as_os_str().is_empty()
1322 && !joined
1323 .components()
1324 .any(|c| matches!(c, std::path::Component::ParentDir))
1325 {
1326 return Some(joined);
1327 }
1328 None
1329}
1330
1331fn setup_hint(oauth: &OauthPack) -> String {
1332 if matches!(oauth.login, Some(Login::None)) {
1333 return "this profile does not start vendor login (set oauth.login to pkce or device and a wiremux client id, or overlay a store)".into();
1334 }
1335 let hint = oauth
1336 .setup_token_hint
1337 .as_deref()
1338 .map(str::trim)
1339 .filter(|s| !s.is_empty())
1340 .unwrap_or("re-authenticate using the profile login flow");
1341 redact_secret_looking(hint)
1342}
1343
1344fn empty_access_error(oauth: &OauthPack) -> AuthError {
1345 AuthError::TokenProvider(format!(
1346 "credentials have an empty access token; {}",
1347 setup_hint(oauth)
1348 ))
1349}
1350
1351fn missing_creds(oauth: &OauthPack) -> AuthError {
1352 AuthError::TokenProvider(format!(
1353 "no credentials ({}); {}",
1354 attempted_cred_stores(oauth),
1355 setup_hint(oauth)
1356 ))
1357}
1358
1359fn attempted_cred_stores(oauth: &OauthPack) -> String {
1360 let mut tried = Vec::new();
1361 if let Some(raw) = oauth
1362 .creds_path
1363 .as_deref()
1364 .map(str::trim)
1365 .filter(|s| !s.is_empty())
1366 {
1367 tried.push(format!(
1368 "creds_path={}",
1369 redact_secret_looking(&expand_tilde(raw).display().to_string())
1370 ));
1371 }
1372 if let Some(service) = oauth
1373 .keychain_service
1374 .as_deref()
1375 .map(str::trim)
1376 .filter(|s| !s.is_empty())
1377 {
1378 let accounts = keychain_accounts_to_try(oauth);
1379 if accounts.is_empty() {
1380 tried.push(format!("keychain service={service}"));
1381 } else {
1382 tried.push(format!(
1383 "keychain service={service} accounts={}",
1384 accounts.join(",")
1385 ));
1386 }
1387 }
1388 if let Some(env) = oauth
1389 .access_env
1390 .as_deref()
1391 .map(str::trim)
1392 .filter(|s| !s.is_empty())
1393 {
1394 tried.push(format!("access_env={env}"));
1395 }
1396 if tried.is_empty() {
1397 "no creds_path, keychain, or access_env configured".into()
1398 } else {
1399 format!("tried {}", tried.join(", "))
1400 }
1401}
1402
1403fn read_keychain(service: &str, account: &str) -> Result<String, AuthError> {
1404 #[cfg(any(test, feature = "test-util"))]
1405 if let Some(secret) = crate::keychain_guard::test_keychain_get(service, account) {
1406 return Ok(secret);
1407 }
1408 if keychain_disabled() {
1409 return Err(AuthError::TokenProvider("keychain disabled".into()));
1410 }
1411 #[cfg(target_os = "macos")]
1412 {
1413 let entry = keyring::Entry::new(service, account)
1414 .map_err(|e| AuthError::TokenProvider(format!("keychain: {e}")))?;
1415 entry
1416 .get_password()
1417 .map_err(|e| AuthError::TokenProvider(format!("keychain read: {e}")))
1418 }
1419 #[cfg(not(target_os = "macos"))]
1420 {
1421 let _ = (service, account);
1422 Err(AuthError::TokenProvider("keychain not available".into()))
1423 }
1424}
1425
1426#[cfg(any(target_os = "macos", test, feature = "test-util"))]
1427fn write_keychain(service: &str, account: &str, secret: &str) -> Result<(), AuthError> {
1428 #[cfg(any(test, feature = "test-util"))]
1429 if crate::keychain_guard::test_keychain_active() {
1430 crate::keychain_guard::test_keychain_set(service, account, secret);
1431 return Ok(());
1432 }
1433 if keychain_disabled() {
1434 return Ok(());
1435 }
1436 #[cfg(target_os = "macos")]
1437 {
1438 let entry = keyring::Entry::new(service, account)
1439 .map_err(|e| AuthError::TokenProvider(format!("keychain: {e}")))?;
1440 entry
1441 .set_password(secret)
1442 .map_err(|e| AuthError::TokenProvider(format!("keychain write: {e}")))
1443 }
1444 #[cfg(not(target_os = "macos"))]
1445 {
1446 let _ = (service, account, secret);
1447 Err(AuthError::TokenProvider("keychain not available".into()))
1448 }
1449}
1450
1451#[cfg(test)]
1452mod tests {
1453 use super::*;
1454 use crate::isolated_home::{IsolatedHome, PlantCredentials};
1455 use crate::parse_profile_str;
1456 use crate::{AnyTokenProvider, TokenProvider};
1457 use std::io::{Read, Write};
1458 use std::net::TcpListener;
1459
1460 #[test]
1461 fn needs_refresh_at_eighty_percent() {
1462 let mut tok = CachedToken {
1463 access_token: "tok".into(),
1464 refresh_token: Some("rt".into()),
1465 acquired_at: Instant::now() - Duration::from_secs(81),
1466 lifetime: Duration::from_secs(100),
1467 unsaved_expires_in: None,
1468 };
1469 assert!(tok.needs_refresh(), "81s of 100s must refresh");
1470 tok.acquired_at = Instant::now() - Duration::from_secs(79);
1471 assert!(!tok.needs_refresh(), "79s of 100s must keep cache");
1472 }
1473
1474 #[tokio::test]
1475 async fn wake_forces_refresh_on_next_get() {
1476 let home = IsolatedHome::new();
1477 let path = home.plant_credentials(PlantCredentials::JsonPointer {
1478 relative_path: ".config/wiremux/auth.json",
1479 document: serde_json::json!({
1480 "tokens": {
1481 "access": "first",
1482 "refresh": "rt",
1483 "expiry_unix": 4_102_444_800_i64
1484 }
1485 }),
1486 });
1487 let (url, handle) = spawn_http_server(
1488 200,
1489 r#"{"access_token":"second","refresh_token":"rt2","expires_in":3600}"#,
1490 );
1491 let oauth = pack_from_toml(&pointer_toml(&url, &path));
1492 let p = provider(&oauth);
1493 assert_eq!(p.get_token().await.expect("first"), "first");
1494 p.wake();
1495 assert_eq!(p.get_token().await.expect("forced refresh"), "second");
1496 let _ = handle.join();
1497 }
1498
1499 #[tokio::test]
1500 async fn mark_stale_forces_refresh_on_next_get() {
1501 let home = IsolatedHome::new();
1502 let path = home.plant_credentials(PlantCredentials::JsonPointer {
1503 relative_path: ".config/wiremux/auth.json",
1504 document: serde_json::json!({
1505 "tokens": {
1506 "access": "first",
1507 "refresh": "rt",
1508 "expiry_unix": 4_102_444_800_i64
1509 }
1510 }),
1511 });
1512 let (url, handle) = spawn_http_server(
1513 200,
1514 r#"{"access_token":"second","refresh_token":"rt2","expires_in":3600}"#,
1515 );
1516 let oauth = pack_from_toml(&pointer_toml(&url, &path));
1517 let p = provider(&oauth);
1518 assert_eq!(p.get_token().await.expect("first"), "first");
1519 p.mark_stale();
1520 assert_eq!(p.get_token().await.expect("forced refresh"), "second");
1521 let _ = handle.join();
1522 }
1523
1524 #[tokio::test]
1525 async fn forced_refresh_http_error_restores_force_flag() {
1526 let home = IsolatedHome::new();
1527 let path = home.plant_credentials(PlantCredentials::JsonPointer {
1528 relative_path: ".config/wiremux/auth.json",
1529 document: serde_json::json!({
1530 "tokens": {
1531 "access": "first",
1532 "refresh": "rt",
1533 "expiry_unix": 4_102_444_800_i64
1534 }
1535 }),
1536 });
1537 let (url, handle) = spawn_http_script(&[
1538 (401, r#"{"error":"invalid_grant"}"#),
1539 (
1540 200,
1541 r#"{"access_token":"second","refresh_token":"rt2","expires_in":3600}"#,
1542 ),
1543 ]);
1544 let oauth = pack_from_toml(&pointer_toml(&url, &path));
1545 let p = provider(&oauth);
1546 assert_eq!(p.get_token().await.expect("cached"), "first");
1547 p.mark_stale();
1548 let err = p.get_token().await.expect_err("forced 401");
1549 let msg = err.to_string();
1550 assert!(msg.contains("401"), "{msg}");
1551 assert_eq!(
1552 msg.matches("(HTTP ").count(),
1553 1,
1554 "VendorRejected must not re-wrap HTTP: {msg}"
1555 );
1556 assert_eq!(
1557 p.get_token().await.expect("retry after failed force"),
1558 "second"
1559 );
1560 assert_eq!(handle.join().expect("join"), 2);
1561 }
1562
1563 fn keychain_toml(token_url: &str) -> String {
1564 format!(
1565 r#"
1566schema_version = 1
1567id = "kc-test"
1568[oauth]
1569token_url = "{token_url}"
1570client_id = "test-client"
1571token_request_format = "json"
1572creds_format = "claude-credentials"
1573keychain_service = "wiremux-test"
1574keychain_accounts = ["acct"]
1575login = "none"
1576[oauth.refresh_body]
1577grant_type = "refresh_token"
1578"#
1579 )
1580 }
1581
1582 #[tokio::test]
1583 async fn refresh_transport_error_returns_stored_access() {
1584 let home = IsolatedHome::new();
1585 home.plant_credentials(PlantCredentials::Claude {
1586 access: "sk-ant-oat01-expired-keep",
1587 refresh: Some("rt"),
1588 expires_at_ms: Some(1),
1589 });
1590 let closed = closed_http_url();
1591 let fallback = closed_http_url();
1592 let oauth = pack_from_toml(&claude_toml(&closed, Some(&fallback)));
1593 let p = provider(&oauth);
1594 let started = Instant::now();
1595 let token = p
1596 .get_token()
1597 .await
1598 .expect("expired access must stay usable when both token URLs refuse");
1599 assert_eq!(token, "sk-ant-oat01-expired-keep");
1600 assert!(
1601 started.elapsed() < Duration::from_secs(8),
1602 "refused token URLs must not wait the HTTP client timeout, elapsed {:?}",
1603 started.elapsed()
1604 );
1605 let _ = home;
1606 }
1607
1608 #[tokio::test]
1609 async fn refresh_garbage_json_still_fails_closed() {
1610 let home = IsolatedHome::new();
1611 home.plant_credentials(PlantCredentials::Claude {
1612 access: "sk-ant-oat01-old",
1613 refresh: Some("rt"),
1614 expires_at_ms: Some(1),
1615 });
1616 let (url, handle) = spawn_http_server(200, "not-json");
1617 let oauth = pack_from_toml(&claude_toml(&url, None));
1618 let p = provider(&oauth);
1619 let err = p.get_token().await.expect_err("garbage JSON");
1620 let _ = handle.join();
1621 assert!(
1622 !matches!(err, AuthError::EmptyWriteRefused),
1623 "parse fail is TokenProvider, got {err:?}"
1624 );
1625 let msg = err.to_string();
1626 assert!(
1627 msg.contains("invalid JSON") || msg.contains("token refresh"),
1628 "parse fail must stay an error, got {msg}"
1629 );
1630 let _ = home;
1631 }
1632
1633 #[tokio::test]
1634 async fn forced_refresh_transport_error_does_not_keep_cache() {
1635 let home = IsolatedHome::new();
1636 home.plant_credentials(PlantCredentials::Claude {
1637 access: "sk-ant-oat01-stale",
1638 refresh: Some("rt"),
1639 expires_at_ms: Some(4_000_000_000_000),
1640 });
1641 let oauth = pack_from_toml(&claude_toml(&closed_http_url(), None));
1642 let p = provider(&oauth);
1643 assert_eq!(
1644 p.get_token().await.expect("fresh cache"),
1645 "sk-ant-oat01-stale"
1646 );
1647 p.mark_stale();
1648 let err = p
1649 .get_token()
1650 .await
1651 .expect_err("forced refresh must not hide a down token URL");
1652 let msg = err.to_string();
1653 assert!(
1654 msg.contains("token refresh request failed"),
1655 "forced path must surface transport, got {msg}"
1656 );
1657 let _ = home;
1658 }
1659
1660 #[tokio::test]
1661 async fn skip_http_refresh_keeps_expired_access_without_post() {
1662 let home = IsolatedHome::new();
1663 home.plant_credentials(PlantCredentials::Claude {
1664 access: "sk-ant-oat01-cached-only",
1665 refresh: Some("rt"),
1666 expires_at_ms: Some(1),
1667 });
1668 let (url, handle) = spawn_http_server(
1669 200,
1670 r#"{"access_token":"must-not-refresh","refresh_token":"rt2","expires_in":3600}"#,
1671 );
1672 let oauth = pack_from_toml(&claude_toml(&url, None));
1673 let p = provider(&oauth);
1674 p.without_http_refresh();
1675 assert_eq!(
1676 p.get_token().await.expect("cached access"),
1677 "sk-ant-oat01-cached-only"
1678 );
1679 drop(handle);
1680 let _ = home;
1681 }
1682
1683 #[tokio::test]
1684 async fn keychain_write_back_updates_test_store() {
1685 let home = IsolatedHome::new();
1686 home.plant_keychain(
1687 "wiremux-test",
1688 "acct",
1689 &serde_json::json!({
1690 "claudeAiOauth": {
1691 "accessToken": "kc-old",
1692 "refreshToken": "rt-old",
1693 "expiresAt": 1
1694 }
1695 }),
1696 );
1697 let (url, handle) = spawn_http_server(
1698 200,
1699 r#"{"access_token":"kc-new","refresh_token":"rt-new","expires_in":3600}"#,
1700 );
1701 let oauth = pack_from_toml(&keychain_toml(&url));
1702 let p = provider(&oauth);
1703 assert_eq!(
1704 p.get_token().await.expect("refresh from keychain"),
1705 "kc-new"
1706 );
1707 let _ = handle.join();
1708 let stored = crate::keychain_guard::test_keychain_get("wiremux-test", "acct")
1709 .expect("test keychain still planted");
1710 let doc: Value = serde_json::from_str(&stored).unwrap();
1711 assert_eq!(
1712 doc["claudeAiOauth"]["accessToken"].as_str(),
1713 Some("kc-new"),
1714 "write-back must update the keychain document, got {doc}"
1715 );
1716 assert_eq!(
1717 doc["claudeAiOauth"]["refreshToken"].as_str(),
1718 Some("rt-new")
1719 );
1720 }
1721
1722 fn claude_code_keychain_toml(token_url: &str) -> String {
1723 format!(
1724 r#"
1725schema_version = 1
1726id = "kc-login"
1727[oauth]
1728token_url = "{token_url}"
1729client_id = "test-client"
1730token_request_format = "json"
1731creds_format = "claude-credentials"
1732keychain_service = "Claude Code-credentials"
1733keychain_accounts = ["Claude Code", "credentials"]
1734login = "none"
1735[oauth.refresh_body]
1736grant_type = "refresh_token"
1737"#
1738 )
1739 }
1740
1741 #[tokio::test]
1742 async fn keychain_accounts_username_fallback_loads_planted_token() {
1743 let home = IsolatedHome::with_extra_envs(&["USER", "USERNAME"]);
1744 home.set_env("USER", "");
1745 home.set_env("USERNAME", "win-login");
1746 home.plant_keychain(
1747 "Claude Code-credentials",
1748 "win-login",
1749 &serde_json::json!({
1750 "claudeAiOauth": {
1751 "accessToken": "sk-ant-oat01-win-login",
1752 "refreshToken": "rt",
1753 "expiresAt": 4_102_444_800_000_i64
1754 }
1755 }),
1756 );
1757 let oauth = pack_from_toml(&claude_code_keychain_toml(&closed_http_url()));
1758 let p = provider(&oauth);
1759 assert_eq!(
1760 p.get_token().await.expect("USERNAME keychain account"),
1761 "sk-ant-oat01-win-login"
1762 );
1763 }
1764
1765 #[test]
1766 fn keychain_accounts_to_try_does_not_duplicate_listed_login() {
1767 let home = IsolatedHome::with_extra_envs(&["USER", "USERNAME"]);
1768 home.set_env("USER", "Claude Code");
1769 let oauth = pack_from_toml(&claude_code_keychain_toml(&closed_http_url()));
1770 let tried = keychain_accounts_to_try(&oauth);
1771 assert_eq!(
1772 tried,
1773 vec!["Claude Code".to_string(), "credentials".to_string()]
1774 );
1775 assert_eq!(tried.iter().filter(|a| *a == "Claude Code").count(), 1);
1776 let listed = attempted_cred_stores(&oauth);
1777 assert!(
1778 listed.contains("accounts=Claude Code,credentials"),
1779 "{listed}"
1780 );
1781 let _ = home;
1782 }
1783
1784 fn spawn_counting_http_server(
1785 status: u16,
1786 body: &str,
1787 window: Duration,
1788 ) -> (
1789 String,
1790 std::sync::Arc<std::sync::atomic::AtomicUsize>,
1791 std::thread::JoinHandle<()>,
1792 ) {
1793 use std::sync::atomic::{AtomicUsize, Ordering};
1794 let listener = TcpListener::bind("127.0.0.1:0").expect("bind mock");
1795 listener.set_nonblocking(true).expect("nonblocking");
1796 let addr = listener.local_addr().expect("local_addr");
1797 let body = body.to_owned();
1798 let count = std::sync::Arc::new(AtomicUsize::new(0));
1799 let count_thread = count.clone();
1800 let handle = std::thread::spawn(move || {
1801 let start = Instant::now();
1802 while start.elapsed() < window {
1803 match listener.accept() {
1804 Ok((mut stream, _)) => {
1805 count_thread.fetch_add(1, Ordering::SeqCst);
1806 stream.set_nonblocking(false).ok();
1807 let mut buf = [0u8; 4096];
1808 let _ = stream.read(&mut buf);
1809 let resp = format!(
1810 "HTTP/1.1 {status} OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
1811 body.len()
1812 );
1813 let _ = stream.write_all(resp.as_bytes());
1814 }
1815 Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
1816 std::thread::sleep(Duration::from_millis(10));
1817 }
1818 Err(_) => break,
1819 }
1820 }
1821 });
1822 (format!("http://{addr}/oauth/token"), count, handle)
1823 }
1824
1825 #[tokio::test]
1826 async fn parallel_get_token_issues_one_refresh_http() {
1827 let home = IsolatedHome::new();
1828 let path = home.plant_credentials(PlantCredentials::JsonPointer {
1829 relative_path: ".config/wiremux/auth.json",
1830 document: serde_json::json!({
1831 "tokens": {
1832 "access": "stale",
1833 "refresh": "rt",
1834 "expiry_unix": 1
1835 }
1836 }),
1837 });
1838 let (url, count, handle) = spawn_counting_http_server(
1839 200,
1840 r#"{"access_token":"shared","refresh_token":"rt2","expires_in":3600}"#,
1841 Duration::from_millis(800),
1842 );
1843 let oauth = pack_from_toml(&pointer_toml(&url, &path));
1844 let p = provider(&oauth);
1845 let a = p.clone();
1846 let b = p.clone();
1847 let (left, right) = tokio::join!(a.get_token(), b.get_token());
1848 assert_eq!(left.expect("left"), "shared");
1849 assert_eq!(right.expect("right"), "shared");
1850 let _ = handle.join();
1851 assert_eq!(
1852 count.load(std::sync::atomic::Ordering::SeqCst),
1853 1,
1854 "single-flight must POST once"
1855 );
1856 }
1857
1858 fn pack_from_toml(toml: &str) -> OauthPack {
1859 parse_profile_str(toml)
1860 .expect("parse test profile")
1861 .oauth
1862 .expect("oauth table")
1863 }
1864
1865 fn claude_toml(token_url: &str, fallback: Option<&str>) -> String {
1866 let fb = match fallback {
1867 Some(u) => format!("token_url_fallback = \"{u}\"\n"),
1868 None => String::new(),
1869 };
1870 format!(
1871 r#"
1872schema_version = 1
1873id = "t"
1874[oauth]
1875token_url = "{token_url}"
1876{fb}client_id = "test-client"
1877token_request_format = "json"
1878creds_path = "~/.claude/.credentials.json"
1879creds_format = "claude-credentials"
1880access_env = "CLAUDE_CODE_OAUTH_TOKEN"
1881login = "setup-token"
1882setup_token_hint = "run `claude setup-token`"
1883[oauth.refresh_body]
1884grant_type = "refresh_token"
1885"#
1886 )
1887 }
1888
1889 fn oidc_toml(token_url: &str, creds: &std::path::Path, client_id: &str) -> String {
1890 let creds = creds.to_string_lossy().replace('\\', "/");
1891 format!(
1892 r#"
1893schema_version = 1
1894id = "openai-codex-oauth"
1895[oauth]
1896token_url = "{token_url}"
1897authorize_url = "https://auth.openai.com/oauth/authorize"
1898client_id = "{client_id}"
1899token_request_format = "form"
1900creds_format = "oidc-auth-json"
1901creds_path = "{creds}"
1902login = "none"
1903"#
1904 )
1905 }
1906
1907 fn pointer_toml(token_url: &str, creds: &std::path::Path) -> String {
1908 let creds = creds.to_string_lossy().replace('\\', "/");
1909 format!(
1910 r#"
1911schema_version = 1
1912id = "other-vendor-oauth"
1913[headers]
1914X-Api = "must-not-be-on-token-post"
1915[oauth]
1916token_url = "{token_url}"
1917client_id = "example-public-client"
1918token_request_format = "form"
1919creds_format = "json-pointer"
1920creds_path = "{creds}"
1921access_token_ptr = "/tokens/access"
1922refresh_token_ptr = "/tokens/refresh"
1923expires_ptr = "/tokens/expiry_unix"
1924expires_unit = "s"
1925login = "none"
1926[oauth.token_headers]
1927X-Client = "official-app"
1928[oauth.token_response]
1929access_token_ptr = "/access_token"
1930refresh_token_ptr = "/refresh_token"
1931expires_ptr = "/expires_in"
1932expires_unit = "s"
1933"#
1934 )
1935 }
1936
1937 fn closed_http_url() -> String {
1939 let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
1940 let addr = listener.local_addr().expect("addr");
1941 drop(listener);
1942 format!("http://{addr}/oauth/token")
1943 }
1944
1945 fn spawn_http_server(status: u16, body: &str) -> (String, std::thread::JoinHandle<String>) {
1946 spawn_http_server_on_accept(status, body, || {})
1947 }
1948
1949 fn spawn_http_script(responses: &[(u16, &str)]) -> (String, std::thread::JoinHandle<usize>) {
1950 let listener = TcpListener::bind("127.0.0.1:0").expect("bind mock");
1951 let addr = listener.local_addr().expect("local_addr");
1952 let responses: Vec<(u16, String)> = responses
1953 .iter()
1954 .map(|(status, body)| (*status, (*body).to_owned()))
1955 .collect();
1956 let handle = std::thread::spawn(move || {
1957 let mut served = 0usize;
1958 for (status, body) in responses {
1959 let (mut stream, _) = listener.accept().expect("accept");
1960 stream.set_read_timeout(Some(Duration::from_secs(5))).ok();
1961 let mut buf = Vec::new();
1962 let mut tmp = [0u8; 1024];
1963 loop {
1964 match stream.read(&mut tmp) {
1965 Ok(0) => break,
1966 Ok(n) => {
1967 buf.extend_from_slice(&tmp[..n]);
1968 if buf.windows(4).any(|w| w == b"\r\n\r\n") {
1969 break;
1970 }
1971 }
1972 Err(_) => break,
1973 }
1974 }
1975 let resp = format!(
1976 "HTTP/1.1 {status} OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
1977 body.len()
1978 );
1979 let _ = stream.write_all(resp.as_bytes());
1980 served += 1;
1981 }
1982 served
1983 });
1984 (format!("http://{addr}/oauth/token"), handle)
1985 }
1986
1987 fn spawn_http_server_on_accept(
1988 status: u16,
1989 body: &str,
1990 on_accept: impl FnOnce() + Send + 'static,
1991 ) -> (String, std::thread::JoinHandle<String>) {
1992 let listener = TcpListener::bind("127.0.0.1:0").expect("bind mock");
1993 let addr = listener.local_addr().expect("local_addr");
1994 let body = body.to_owned();
1995 let handle = std::thread::spawn(move || {
1996 let (mut stream, _) = listener.accept().expect("accept");
1997 on_accept();
1998 stream.set_read_timeout(Some(Duration::from_secs(5))).ok();
1999 let mut buf = Vec::new();
2000 let mut tmp = [0u8; 1024];
2001 loop {
2002 match stream.read(&mut tmp) {
2003 Ok(0) => break,
2004 Ok(n) => {
2005 buf.extend_from_slice(&tmp[..n]);
2006 if let Some(pos) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
2007 let header = String::from_utf8_lossy(&buf[..pos]);
2008 let content_len = header
2009 .lines()
2010 .find_map(|line| {
2011 line.split_once(':').and_then(|(k, v)| {
2012 k.eq_ignore_ascii_case("content-length")
2013 .then_some(v.trim().parse::<usize>().unwrap_or(0))
2014 })
2015 })
2016 .unwrap_or(0);
2017 let header_end = pos + 4;
2018 while buf.len() < header_end + content_len {
2019 match stream.read(&mut tmp) {
2020 Ok(0) => break,
2021 Ok(n) => buf.extend_from_slice(&tmp[..n]),
2022 Err(_) => break,
2023 }
2024 }
2025 break;
2026 }
2027 if buf.len() > 32_768 {
2028 break;
2029 }
2030 }
2031 Err(_) => break,
2032 }
2033 }
2034 let req = String::from_utf8_lossy(&buf).into_owned();
2035 let resp = format!(
2036 "HTTP/1.1 {status} OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
2037 body.len()
2038 );
2039 let _ = stream.write_all(resp.as_bytes());
2040 let _ = stream.flush();
2041 req
2042 });
2043 (format!("http://{addr}/oauth/token"), handle)
2044 }
2045
2046 fn provider(oauth: &OauthPack) -> ProfileTokenProvider {
2047 match provider_from_oauth(oauth).expect("provider") {
2048 AnyTokenProvider::Profile(p) => p,
2049 _ => panic!("expected Profile"),
2050 }
2051 }
2052
2053 #[tokio::test]
2054 async fn fresh_token_returns_cached_without_http() {
2055 let home = IsolatedHome::new();
2056 home.plant_credentials(PlantCredentials::Claude {
2057 access: "sk-ant-oat01-fresh",
2058 refresh: Some("rt"),
2059 expires_at_ms: Some(4_000_000_000_000),
2060 });
2061 let oauth = pack_from_toml(&claude_toml(&closed_http_url(), None));
2062 let p = provider(&oauth);
2063 assert_eq!(p.get_token().await.unwrap(), "sk-ant-oat01-fresh");
2064 }
2065
2066 #[tokio::test]
2067 async fn primary_http_404_falls_back_and_writes() {
2068 let home = IsolatedHome::new();
2069 let path = home.plant_credentials(PlantCredentials::Claude {
2070 access: "sk-ant-oat01-old",
2071 refresh: Some("rt-old"),
2072 expires_at_ms: Some(1),
2073 });
2074 let (primary, primary_h) = spawn_http_server(404, r#"{"error":"not_found"}"#);
2075 let (fallback, fallback_h) = spawn_http_server(
2076 200,
2077 r#"{"access_token":"sk-ant-oat01-refreshed","refresh_token":"rt-new","expires_in":3600}"#,
2078 );
2079 let oauth = pack_from_toml(&claude_toml(&primary, Some(&fallback)));
2080 let p = provider(&oauth);
2081 let token = p.get_token().await.expect("fallback refresh");
2082 assert_eq!(token, "sk-ant-oat01-refreshed");
2083 let _ = primary_h.join();
2084 let req = fallback_h.join().expect("fallback");
2085 assert!(
2086 req.contains("\"grant_type\":\"refresh_token\""),
2087 "JSON grant, got: {req}"
2088 );
2089 assert!(
2090 req.contains("\"client_id\":\"test-client\""),
2091 "client_id from pack, got: {req}"
2092 );
2093 assert!(
2094 req.contains("\"refresh_token\":\"rt-old\""),
2095 "engine injects store refresh, got: {req}"
2096 );
2097 let written = std::fs::read_to_string(&path).unwrap();
2098 let doc: Value = serde_json::from_str(&written).unwrap();
2099 assert_eq!(
2100 doc["claudeAiOauth"]["accessToken"].as_str(),
2101 Some("sk-ant-oat01-refreshed")
2102 );
2103 assert_eq!(
2104 doc["claudeAiOauth"]["refreshToken"].as_str(),
2105 Some("rt-new")
2106 );
2107 assert_eq!(doc["otherField"].as_str(), Some("keep-me"));
2108 }
2109
2110 #[tokio::test]
2111 async fn empty_access_from_vendor_refuses_write() {
2112 let home = IsolatedHome::new();
2113 let path = home.plant_credentials(PlantCredentials::Claude {
2114 access: "sk-ant-oat01-old",
2115 refresh: Some("rt-old"),
2116 expires_at_ms: Some(1),
2117 });
2118 let before = std::fs::read_to_string(&path).unwrap();
2119 let (url, handle) = spawn_http_server(
2120 200,
2121 r#"{"access_token":"","refresh_token":"rt-new","expires_in":3600}"#,
2122 );
2123 let oauth = pack_from_toml(&claude_toml(&url, None));
2124 let p = provider(&oauth);
2125 let err = p.get_token().await.expect_err("empty write refuse");
2126 let _ = handle.join();
2127 assert!(matches!(err, AuthError::EmptyWriteRefused), "got {err:?}");
2128 assert_eq!(std::fs::read_to_string(&path).unwrap(), before);
2129 }
2130
2131 #[cfg(unix)]
2132 #[tokio::test]
2133 async fn write_back_failure_stays_sticky_and_does_not_refresh_again() {
2134 use std::os::unix::fs::PermissionsExt;
2135 let home = IsolatedHome::new();
2136 let path = home.plant_credentials(PlantCredentials::Claude {
2137 access: "sk-ant-oat01-old",
2138 refresh: Some("rt-old"),
2139 expires_at_ms: Some(1),
2140 });
2141 let dir = path.parent().expect("creds dir");
2142 let lock_path = crate::helpers::lock_sibling(&path);
2145 std::fs::write(&lock_path, b"").expect("lock sibling");
2146 std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o555)).expect("chmod");
2147 struct ResetDir<'a>(&'a std::path::Path);
2148 impl Drop for ResetDir<'_> {
2149 fn drop(&mut self) {
2150 let _ = std::fs::set_permissions(self.0, std::fs::Permissions::from_mode(0o755));
2151 }
2152 }
2153 let _reset_dir = ResetDir(dir);
2154 let (url, handle) = spawn_http_script(&[(
2155 200,
2156 r#"{"access_token":"sk-ant-oat01-fresh","refresh_token":"rt-new","expires_in":3600}"#,
2157 )]);
2158 let oauth = pack_from_toml(&claude_toml(&url, None));
2159 let p = provider(&oauth);
2160 let err = p.get_token().await.expect_err("save must fail");
2161 assert!(
2162 matches!(err, AuthError::CredentialStoreStale { .. }),
2163 "write-back failure is sticky, got {err:?}"
2164 );
2165 let err = p.get_token().await.expect_err("retry stays sticky");
2166 assert!(
2167 matches!(err, AuthError::CredentialStoreStale { .. }),
2168 "second get_token must not call the token endpoint, got {err:?}"
2169 );
2170 let served = handle.join().expect("server");
2171 assert_eq!(served, 1, "one vendor refresh only");
2172 let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o755));
2173 drop(home);
2174 }
2175
2176 #[cfg(unix)]
2177 #[tokio::test]
2178 async fn write_back_sets_0600() {
2179 use std::os::unix::fs::PermissionsExt;
2180 let home = IsolatedHome::new();
2181 let path = home.plant_credentials(PlantCredentials::Claude {
2182 access: "sk-ant-oat01-old",
2183 refresh: Some("rt-old"),
2184 expires_at_ms: Some(1),
2185 });
2186 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
2187 let (url, handle) = spawn_http_server(
2188 200,
2189 r#"{"access_token":"sk-ant-oat01-new","refresh_token":"rt-new","expires_in":3600}"#,
2190 );
2191 let oauth = pack_from_toml(&claude_toml(&url, None));
2192 let p = provider(&oauth);
2193 let _ = p.get_token().await.expect("refresh");
2194 let _ = handle.join();
2195 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
2196 assert_eq!(mode, 0o600, "got {mode:o}");
2197 }
2198
2199 #[cfg(unix)]
2200 #[tokio::test]
2201 async fn persist_io_failure_fails_closed_and_keeps_store() {
2202 use std::os::unix::fs::PermissionsExt;
2203 let home = IsolatedHome::new();
2204 let path = home.plant_credentials(PlantCredentials::Claude {
2205 access: "sk-ant-oat01-old",
2206 refresh: Some("rt-old"),
2207 expires_at_ms: Some(1),
2208 });
2209 let before = std::fs::read(&path).unwrap();
2210 let parent = path.parent().expect("creds parent").to_path_buf();
2211 let (url, handle) = spawn_http_server(
2212 200,
2213 r#"{"access_token":"sk-ant-oat01-new","refresh_token":"rt-new","expires_in":3600}"#,
2214 );
2215 let oauth = pack_from_toml(&claude_toml(&url, None));
2216 let p = provider(&oauth);
2217 let lock_path = crate::helpers::lock_sibling(&path);
2221 std::fs::write(&lock_path, b"").unwrap();
2222 std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o555)).unwrap();
2223 let result = p.get_token().await;
2224 std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o755)).unwrap();
2225 let _ = handle.join();
2226 result.expect_err("persist I/O must fail closed");
2227 assert_eq!(
2228 std::fs::read(&path).unwrap(),
2229 before,
2230 "live credentials must stay intact when the sibling write cannot be created"
2231 );
2232 }
2233
2234 #[tokio::test]
2235 async fn adopt_on_rotation_before_http() {
2236 let home = IsolatedHome::new();
2237 let path = home.plant_credentials(PlantCredentials::Claude {
2238 access: "sk-ant-oat01-old",
2239 refresh: Some("rt-old"),
2240 expires_at_ms: Some(1),
2241 });
2242 let oauth = pack_from_toml(&claude_toml(&closed_http_url(), None));
2243 let p = provider(&oauth);
2244 home.plant_credentials(PlantCredentials::Claude {
2245 access: "sk-ant-oat01-from-peer",
2246 refresh: Some("rt-peer"),
2247 expires_at_ms: Some(4_000_000_000_000),
2248 });
2249 let _ = path;
2250 assert_eq!(p.get_token().await.unwrap(), "sk-ant-oat01-from-peer");
2251 }
2252
2253 #[tokio::test]
2254 async fn invalid_grant_adopts_peer_then_errors_if_still_stale() {
2255 let home = IsolatedHome::new();
2256 let path = home.plant_credentials(PlantCredentials::Claude {
2257 access: "sk-ant-oat01-old",
2258 refresh: Some("rt-old"),
2259 expires_at_ms: Some(1),
2260 });
2261 let path_for_peer = path.clone();
2262 let (url, handle) = spawn_http_server_on_accept(
2263 400,
2264 r#"{"error":"invalid_grant","error_description":"revoked","refresh_token":"rt-LEAK"}"#,
2265 move || {
2266 std::fs::write(
2267 &path_for_peer,
2268 serde_json::json!({
2269 "claudeAiOauth": {
2270 "accessToken": "sk-ant-oat01-from-peer",
2271 "refreshToken": "rt-peer",
2272 "expiresAt": 4_000_000_000_000u64
2273 },
2274 "otherField": "keep-me"
2275 })
2276 .to_string(),
2277 )
2278 .expect("peer write");
2279 },
2280 );
2281 let oauth = pack_from_toml(&claude_toml(&url, None));
2282 let p = provider(&oauth);
2283 let token = p.get_token().await.expect("adopt after invalid_grant");
2284 let _ = handle.join();
2285 assert_eq!(token, "sk-ant-oat01-from-peer");
2286 }
2287
2288 #[tokio::test]
2289 async fn invalid_grant_sanitizes_and_includes_hint() {
2290 let home = IsolatedHome::new();
2291 home.plant_credentials(PlantCredentials::Claude {
2292 access: "sk-ant-oat01-old",
2293 refresh: Some("rt-old"),
2294 expires_at_ms: Some(1),
2295 });
2296 let (url, handle) = spawn_http_server(
2297 401,
2298 r#"{"error":"invalid_grant","refresh_token":"rt-LEAK","access_token":"sk-ant-oat01-LEAK"}"#,
2299 );
2300 let oauth = pack_from_toml(&claude_toml(&url, None));
2301 let p = provider(&oauth);
2302 let err = p.get_token().await.expect_err("401");
2303 let _ = handle.join();
2304 let msg = err.to_string();
2305 assert!(
2306 !msg.contains("sk-ant-oat01-LEAK") && !msg.contains("rt-LEAK"),
2307 "{msg}"
2308 );
2309 assert!(msg.to_ascii_lowercase().contains("setup-token"), "{msg}");
2310 }
2311
2312 #[tokio::test]
2313 async fn json_pointer_store_refreshes_without_claude_alias() {
2314 let home = IsolatedHome::new();
2315 let path = home.plant_credentials(PlantCredentials::JsonPointer {
2316 relative_path: ".config/wiremux/other-vendor.json",
2317 document: serde_json::json!({
2318 "tokens": {
2319 "access": "old-access",
2320 "refresh": "old-refresh",
2321 "expiry_unix": 1
2322 },
2323 "keep": true
2324 }),
2325 });
2326 let (url, handle) = spawn_http_server(
2327 200,
2328 r#"{"access_token":"new-access","refresh_token":"new-refresh","expires_in":3600}"#,
2329 );
2330 let oauth = pack_from_toml(&pointer_toml(&url, &path));
2331 let p = provider(&oauth);
2332 let token = p.get_token().await.expect("json-pointer refresh");
2333 assert_eq!(token, "new-access");
2334 let req = handle.join().expect("server");
2335 assert!(
2336 req.contains("X-Client: official-app") || req.contains("x-client: official-app"),
2337 "token_headers must be sent, got: {req}"
2338 );
2339 assert!(
2340 !req.to_ascii_lowercase().contains("x-api"),
2341 "API headers must not be on the token POST, got: {req}"
2342 );
2343 assert!(
2344 req.contains("grant_type=refresh_token") || req.contains("grant_type\":"),
2345 "form grant, got: {req}"
2346 );
2347 let doc: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
2348 assert_eq!(doc["tokens"]["access"].as_str(), Some("new-access"));
2349 assert_eq!(doc["tokens"]["refresh"].as_str(), Some("new-refresh"));
2350 assert_eq!(doc["keep"].as_bool(), Some(true));
2351 }
2352
2353 #[tokio::test]
2354 async fn oidc_auth_json_reads_matching_issuer_client_entry() {
2355 let home = IsolatedHome::new();
2356 let path = home.plant_credentials(PlantCredentials::JsonPointer {
2357 relative_path: ".config/wiremux/auth-openai.json",
2358 document: serde_json::json!({
2359 "https://aaa.example::other": {
2360 "key": "wrong-access",
2361 "refresh_token": "wrong-rt",
2362 "expires_at": "2099-01-01T00:00:00Z"
2363 },
2364 "https://auth.openai.com::wiremux-cli": {
2365 "key": "oidc-access",
2366 "refresh_token": "oidc-rt",
2367 "expires_at": "2099-01-01T00:00:00Z"
2368 }
2369 }),
2370 });
2371 let oauth = pack_from_toml(&oidc_toml(&closed_http_url(), &path, "wiremux-cli"));
2372 let p = provider(&oauth);
2373 assert_eq!(p.get_token().await.expect("oidc entry"), "oidc-access");
2374 }
2375
2376 #[tokio::test]
2377 async fn oidc_auth_json_matches_named_suffix() {
2378 let home = IsolatedHome::new();
2379 let path = home.plant_credentials(PlantCredentials::JsonPointer {
2380 relative_path: ".config/wiremux/auth-work.json",
2381 document: serde_json::json!({
2382 "https://auth.openai.com::wiremux-cli@work": {
2383 "key": "WORK_TOKEN",
2384 "refresh_token": "work-rt",
2385 "expires_at": "2099-01-01T00:00:00Z"
2386 }
2387 }),
2388 });
2389 let oauth = pack_from_toml(&oidc_toml(&closed_http_url(), &path, "wiremux-cli"));
2390 let p = provider(&oauth);
2391 assert_eq!(p.get_token().await.expect("named suffix"), "WORK_TOKEN");
2392 }
2393
2394 #[tokio::test]
2395 async fn oidc_auth_json_refresh_preserves_sibling_entries() {
2396 let home = IsolatedHome::new();
2397 let path = home.plant_credentials(PlantCredentials::JsonPointer {
2398 relative_path: ".config/wiremux/auth-openai.json",
2399 document: serde_json::json!({
2400 "https://aaa.example::other": {
2401 "key": "keep-me",
2402 "refresh_token": "keep-rt",
2403 "expires_at": "2099-01-01T00:00:00Z"
2404 },
2405 "https://auth.openai.com::wiremux-cli": {
2406 "key": "old-access",
2407 "refresh_token": "old-rt",
2408 "expires_at": "2020-01-01T00:00:00Z"
2409 }
2410 }),
2411 });
2412 let (url, handle) = spawn_http_server(
2413 200,
2414 r#"{"access_token":"new-oidc","refresh_token":"new-rt","expires_in":3600}"#,
2415 );
2416 let oauth = pack_from_toml(&oidc_toml(&url, &path, "wiremux-cli"));
2417 let p = provider(&oauth);
2418 assert_eq!(p.get_token().await.expect("oidc refresh"), "new-oidc");
2419 let _ = handle.join();
2420 let doc: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
2421 assert_eq!(
2422 doc["https://aaa.example::other"]["key"].as_str(),
2423 Some("keep-me")
2424 );
2425 assert_eq!(
2426 doc["https://auth.openai.com::wiremux-cli"]["key"].as_str(),
2427 Some("new-oidc")
2428 );
2429 assert_eq!(
2430 doc["https://auth.openai.com::wiremux-cli"]["refresh_token"].as_str(),
2431 Some("new-rt")
2432 );
2433 let expires = doc["https://auth.openai.com::wiremux-cli"]["expires_at"]
2434 .as_str()
2435 .expect("rfc3339 expires_at");
2436 assert!(
2437 expires.contains('T'),
2438 "expires_at must stay RFC 3339: {expires}"
2439 );
2440 }
2441
2442 #[test]
2443 fn oidc_auth_json_empty_client_id_does_not_guess_other_issuer() {
2444 let home = IsolatedHome::new();
2445 let path = home.plant_credentials(PlantCredentials::JsonPointer {
2446 relative_path: ".config/wiremux/auth-openai.json",
2447 document: serde_json::json!({
2448 "https://auth.openai.com::someone": {
2449 "key": "must-not-guess",
2450 "refresh_token": "rt",
2451 "expires_at": "2099-01-01T00:00:00Z"
2452 }
2453 }),
2454 });
2455 let oauth = pack_from_toml(&format!(
2456 r#"
2457schema_version = 1
2458id = "other-issuer"
2459[oauth]
2460token_url = "{}"
2461authorize_url = "https://auth.other.example/oauth/authorize"
2462client_id = ""
2463token_request_format = "form"
2464creds_format = "oidc-auth-json"
2465creds_path = "{}"
2466login = "none"
2467"#,
2468 closed_http_url(),
2469 path.to_string_lossy().replace('\\', "/")
2470 ));
2471 let err = provider_from_oauth(&oauth).expect_err("other issuer");
2472 let msg = err.to_string();
2473 assert!(
2474 !msg.contains("must-not-guess"),
2475 "must not leak a guessed token: {msg}"
2476 );
2477 }
2478
2479 #[tokio::test]
2480 async fn oidc_auth_json_empty_client_id_matches_sole_issuer_entry() {
2481 let home = IsolatedHome::new();
2482 let path = home.plant_credentials(PlantCredentials::JsonPointer {
2483 relative_path: ".grok/auth.json",
2484 document: serde_json::json!({
2485 "https://auth.x.ai::planted-test-client": {
2486 "key": "grok-oidc-access",
2487 "refresh_token": "grok-oidc-rt",
2488 "expires_at": "2099-01-01T00:00:00Z"
2489 }
2490 }),
2491 });
2492 let oauth = pack_from_toml(&format!(
2493 r#"
2494schema_version = 1
2495id = "xai-oauth"
2496[oauth]
2497token_url = "https://auth.x.ai/oauth2/token"
2498authorize_url = "https://auth.x.ai/oauth2/authorize"
2499client_id = ""
2500token_request_format = "form"
2501creds_format = "oidc-auth-json"
2502creds_path = "{}"
2503login = "none"
2504"#,
2505 path.to_string_lossy().replace('\\', "/")
2506 ));
2507 let p = provider(&oauth);
2508 assert_eq!(
2509 p.get_token().await.expect("issuer match"),
2510 "grok-oidc-access"
2511 );
2512 let _ = home;
2513 }
2514
2515 #[test]
2516 fn oidc_auth_json_empty_client_id_ambiguous_issuer_fails_closed() {
2517 let home = IsolatedHome::new();
2518 let path = home.plant_credentials(PlantCredentials::JsonPointer {
2519 relative_path: ".grok/auth.json",
2520 document: serde_json::json!({
2521 "https://auth.x.ai::one": {
2522 "key": "first-must-not-guess",
2523 "refresh_token": "rt-one",
2524 "expires_at": "2099-01-01T00:00:00Z"
2525 },
2526 "https://auth.x.ai::two": {
2527 "key": "second-must-not-guess",
2528 "refresh_token": "rt-two",
2529 "expires_at": "2099-01-01T00:00:00Z"
2530 }
2531 }),
2532 });
2533 let oauth = pack_from_toml(&format!(
2534 r#"
2535schema_version = 1
2536id = "xai-oauth"
2537[oauth]
2538token_url = "https://auth.x.ai/oauth2/token"
2539authorize_url = "https://auth.x.ai/oauth2/authorize"
2540client_id = ""
2541token_request_format = "form"
2542creds_format = "oidc-auth-json"
2543creds_path = "{}"
2544login = "none"
2545"#,
2546 path.to_string_lossy().replace('\\', "/")
2547 ));
2548 let err = provider_from_oauth(&oauth).expect_err("ambiguous issuer");
2549 let msg = err.to_string();
2550 assert!(
2551 msg.contains("client_id") && msg.contains("2"),
2552 "ambiguous issuer must name client_id, got {msg}"
2553 );
2554 assert!(
2555 !msg.contains("must-not-guess"),
2556 "must not leak a guessed token: {msg}"
2557 );
2558 let _ = home;
2559 }
2560
2561 fn copilot_toml(creds: &std::path::Path) -> String {
2562 let creds = creds.to_string_lossy().replace('\\', "/");
2563 format!(
2564 r#"
2565schema_version = 1
2566id = "gist-copilot"
2567[oauth]
2568token_url = "https://github.com/login/oauth/access_token"
2569creds_format = "copilot-hosts"
2570creds_path = "{creds}"
2571login = "none"
2572"#
2573 )
2574 }
2575
2576 #[tokio::test]
2577 async fn copilot_hosts_reads_oauth_token() {
2578 let home = IsolatedHome::new();
2579 let path = home.plant_credentials(PlantCredentials::JsonPointer {
2580 relative_path: ".config/github-copilot/hosts.json",
2581 document: serde_json::json!({
2582 "github.com": { "oauth_token": "ghu_from_hosts" },
2583 "other": { "oauth_token": "" }
2584 }),
2585 });
2586 let oauth = pack_from_toml(&copilot_toml(&path));
2587 let p = provider(&oauth);
2588 assert_eq!(
2589 p.get_token().await.expect("copilot token"),
2590 "ghu_from_hosts"
2591 );
2592 }
2593
2594 #[tokio::test]
2595 async fn copilot_expired_path_adopts_from_file() {
2596 let home = IsolatedHome::new();
2597 let path = home.plant_credentials(PlantCredentials::JsonPointer {
2598 relative_path: ".config/github-copilot/hosts.json",
2599 document: serde_json::json!({
2600 "github.com": { "oauth_token": "ghu_old" }
2601 }),
2602 });
2603 let oauth = pack_from_toml(&copilot_toml(&path));
2604 let p = provider(&oauth);
2605 assert_eq!(p.get_token().await.expect("first"), "ghu_old");
2606 std::fs::write(
2607 &path,
2608 serde_json::to_string(&serde_json::json!({
2609 "github.com": { "oauth_token": "ghu_rotated" }
2610 }))
2611 .unwrap(),
2612 )
2613 .unwrap();
2614 p.mark_stale();
2615 assert_eq!(
2616 p.get_token().await.expect("adopt rotated hosts.json"),
2617 "ghu_rotated"
2618 );
2619 }
2620
2621 #[tokio::test]
2622 async fn copilot_stale_without_rotation_keeps_cached() {
2623 let home = IsolatedHome::new();
2624 let path = home.plant_credentials(PlantCredentials::JsonPointer {
2625 relative_path: ".config/github-copilot/hosts.json",
2626 document: serde_json::json!({
2627 "github.com": { "oauth_token": "ghu_same" }
2628 }),
2629 });
2630 let oauth = pack_from_toml(&copilot_toml(&path));
2631 let p = provider(&oauth);
2632 assert_eq!(p.get_token().await.expect("first"), "ghu_same");
2633 p.mark_stale();
2634 assert_eq!(
2635 p.get_token().await.expect("no refresh token must not fail"),
2636 "ghu_same"
2637 );
2638 }
2639
2640 #[test]
2641 fn oidc_new_rejects_empty_refresh_token() {
2642 let home = IsolatedHome::new();
2643 let path = home.plant_credentials(PlantCredentials::JsonPointer {
2644 relative_path: ".config/wiremux/auth-openai.json",
2645 document: serde_json::json!({
2646 "https://auth.openai.com::wiremux-cli": {
2647 "key": "access-only",
2648 "expires_at": "2099-01-01T00:00:00Z"
2649 }
2650 }),
2651 });
2652 let oauth = pack_from_toml(&oidc_toml(&closed_http_url(), &path, "wiremux-cli"));
2653 let err = provider_from_oauth(&oauth).expect_err("empty refresh");
2654 let msg = err.to_string();
2655 assert!(
2656 msg.contains("refresh"),
2657 "oidc-auth-json must refuse empty refresh at construction, got {msg}"
2658 );
2659 }
2660
2661 #[tokio::test]
2662 async fn oidc_refresh_persists_token_url() {
2663 let home = IsolatedHome::new();
2664 let path = home.plant_credentials(PlantCredentials::JsonPointer {
2665 relative_path: ".config/wiremux/auth-openai.json",
2666 document: serde_json::json!({
2667 "https://auth.openai.com::wiremux-cli": {
2668 "key": "old-access",
2669 "refresh_token": "old-rt",
2670 "expires_at": "2020-01-01T00:00:00Z"
2671 }
2672 }),
2673 });
2674 let (url, handle) = spawn_http_server(
2675 200,
2676 r#"{"access_token":"new-oidc","refresh_token":"new-rt","expires_in":3600}"#,
2677 );
2678 let oauth = pack_from_toml(&oidc_toml(&url, &path, "wiremux-cli"));
2679 let p = provider(&oauth);
2680 assert_eq!(p.get_token().await.expect("refresh"), "new-oidc");
2681 let _ = handle.join();
2682 let doc: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
2683 assert_eq!(
2684 doc["https://auth.openai.com::wiremux-cli"]["token_url"].as_str(),
2685 Some(url.as_str()),
2686 "refresh must persist token_url, got {doc}"
2687 );
2688 }
2689
2690 #[tokio::test]
2691 async fn persisted_token_url_wins_over_profile() {
2692 let home = IsolatedHome::new();
2693 let (url, handle) = spawn_http_server(
2694 200,
2695 r#"{"access_token":"from-store-url","refresh_token":"new-rt","expires_in":3600}"#,
2696 );
2697 let path = home.plant_credentials(PlantCredentials::JsonPointer {
2698 relative_path: ".config/wiremux/auth-openai.json",
2699 document: serde_json::json!({
2700 "https://auth.openai.com::wiremux-cli": {
2701 "key": "old-access",
2702 "refresh_token": "old-rt",
2703 "expires_at": "2020-01-01T00:00:00Z",
2704 "token_url": url
2705 }
2706 }),
2707 });
2708 let oauth = pack_from_toml(&oidc_toml(&closed_http_url(), &path, "wiremux-cli"));
2709 let p = provider(&oauth);
2710 assert_eq!(
2711 p.get_token().await.expect("refresh via stored token_url"),
2712 "from-store-url"
2713 );
2714 let _ = handle.join();
2715 }
2716
2717 #[tokio::test]
2718 async fn copilot_hosts_empty_token_fails_closed() {
2719 let home = IsolatedHome::new();
2720 let path = home.plant_credentials(PlantCredentials::JsonPointer {
2721 relative_path: ".config/github-copilot/hosts.json",
2722 document: serde_json::json!({
2723 "github.com": { "user": "x" }
2724 }),
2725 });
2726 let oauth = pack_from_toml(&copilot_toml(&path));
2727 let err = provider_from_oauth(&oauth).expect_err("missing oauth_token");
2728 assert!(
2729 err.to_string().contains("empty access token")
2730 || err.to_string().contains("credentials"),
2731 "{err}"
2732 );
2733 }
2734
2735 #[tokio::test]
2736 async fn env_only_never_writes() {
2737 let home = IsolatedHome::with_extra_envs(&["WIREMUX_TEST_ACCESS"]);
2738 home.set_env("WIREMUX_TEST_ACCESS", "env-access-token");
2739 let toml = format!(
2740 r#"
2741schema_version = 1
2742id = "env-only"
2743[oauth]
2744token_url = "{}"
2745access_env = "WIREMUX_TEST_ACCESS"
2746"#,
2747 closed_http_url()
2748 );
2749 let oauth = pack_from_toml(&toml);
2750 let p = provider(&oauth);
2751 assert_eq!(p.get_token().await.unwrap(), "env-access-token");
2752 assert!(
2753 matches!(p.inner.write_back, WriteBack::None),
2754 "env-only must not write"
2755 );
2756 }
2757
2758 #[test]
2759 fn token_endpoint_https_or_loopback_only() {
2760 let https = crate::helpers::https_token_url("https://auth.example.invalid/token")
2761 .expect("https token url");
2762 assert_eq!(https.as_str(), "https://auth.example.invalid/token");
2763 let https_upper = crate::helpers::https_token_url("HTTPS://auth.example.invalid/token")
2764 .expect("HTTPS token url");
2765 assert_eq!(https_upper.scheme(), "https");
2766 assert_eq!(https_upper.host_str(), Some("auth.example.invalid"));
2767 assert_eq!(https_upper.path(), "/token");
2768 assert!(crate::helpers::https_token_url("http://127.0.0.1:9/token").is_none());
2769 assert!(crate::profile::is_loopback_http("http://127.0.0.1:9/token"));
2770 assert!(crate::profile::is_loopback_http("http://localhost/token"));
2771 assert!(!crate::profile::is_loopback_http("http://192.0.2.1/token"));
2772 assert!(!crate::profile::is_loopback_http(
2773 "http://example.invalid/token"
2774 ));
2775 }
2776
2777 #[test]
2778 fn missing_explicit_path_does_not_use_home_file() {
2779 let home = IsolatedHome::new();
2780 home.plant_credentials(PlantCredentials::Claude {
2781 access: "sk-ant-oat01-planted-home",
2782 refresh: Some("rt-home"),
2783 expires_at_ms: None,
2784 });
2785 let oauth = pack_from_toml(&claude_toml(&closed_http_url(), None));
2786 let missing = home.path().join("no-such-creds.json");
2787 let err = provider_from_oauth_opts(&oauth, Some(&missing)).expect_err("typo path");
2788 let msg = err.to_string();
2789 assert!(
2790 msg.contains("no credentials") || msg.contains("missing"),
2791 "{msg}"
2792 );
2793 }
2794
2795 #[tokio::test]
2796 async fn lock_timeout_returns_cached_when_not_forced() {
2797 let home = IsolatedHome::new();
2798 let path = home.plant_credentials(PlantCredentials::Claude {
2799 access: "sk-ant-oat01-cached",
2800 refresh: Some("rt"),
2801 expires_at_ms: Some(1),
2802 });
2803 let oauth = pack_from_toml(&claude_toml(&closed_http_url(), None));
2804 let p = provider(&oauth);
2805 p.set_lock_timeout(Duration::from_millis(80));
2806 let lock_path = crate::helpers::lock_sibling(&path);
2807 let lock_file = std::fs::OpenOptions::new()
2808 .create(true)
2809 .read(true)
2810 .write(true)
2811 .truncate(false)
2812 .open(&lock_path)
2813 .unwrap();
2814 fs4::FileExt::try_lock(&lock_file).expect("try lock");
2815 let token = p.get_token().await.expect("cached on lock timeout");
2816 assert_eq!(token, "sk-ant-oat01-cached");
2817 p.mark_stale();
2818 let err = p.get_token().await.expect_err("forced lock fail");
2819 assert!(matches!(err, AuthError::LockTimeout), "got {err:?}");
2820 drop(lock_file);
2821 }
2822
2823 #[test]
2824 fn provider_from_profile_requires_oauth() {
2825 let profile = parse_profile_str("schema_version = 1\nid = \"no-oauth\"\n").unwrap();
2826 let err = provider_from_profile(&profile).unwrap_err();
2827 match err {
2828 AuthError::MissingField(ref field) => {
2829 assert_eq!(field, &missing_credential_field(&profile));
2830 assert!(
2831 field.contains("[headers] Authorization or x-api-key"),
2832 "headers missing from credential text"
2833 );
2834 assert!(
2835 field.contains("access_env"),
2836 "access_env missing from credential text"
2837 );
2838 assert!(
2839 field.contains("auth_scheme = \"none\""),
2840 "auth_scheme missing from credential text"
2841 );
2842 assert_eq!(err.to_string(), format!("missing {field}"));
2843 }
2844 other => panic!("expected MissingField, got {other}"),
2845 }
2846 }
2847
2848 #[test]
2849 fn missing_credential_field_names_gcp_and_custom_header() {
2850 let profile = parse_profile_str(
2851 r#"
2852schema_version = 1
2853id = "vertex"
2854wire = "gemini"
2855auth_scheme = "header:x-goog-api-key"
2856gcp_key_env = "GOOGLE_APPLICATION_CREDENTIALS"
2857"#,
2858 )
2859 .unwrap();
2860 let field = missing_credential_field(&profile);
2861 assert!(
2862 field.contains("gcp_key_env (GOOGLE_APPLICATION_CREDENTIALS)"),
2863 "gcp key env missing from credential text"
2864 );
2865 assert!(
2866 field.contains("[headers] x-goog-api-key"),
2867 "custom header missing from credential text"
2868 );
2869 assert!(
2870 field.contains("on profile `vertex`"),
2871 "profile id missing from credential text"
2872 );
2873 }
2874
2875 #[tokio::test]
2876 async fn provider_from_profile_uses_access_env() {
2877 let home = IsolatedHome::new();
2878 home.set_env("XAI_API_KEY", "xai-from-env");
2879 let profile = parse_profile_str(
2880 "schema_version = 1\nid = \"xai\"\naccess_env = [\"XAI_API_KEY\", \"GROK_API_KEY\"]\n",
2881 )
2882 .unwrap();
2883 let provider = provider_from_profile(&profile).expect("access_env");
2884 assert_eq!(provider.get_token().await.expect("token"), "xai-from-env");
2885 let _ = home;
2886 }
2887
2888 #[test]
2889 fn provider_from_profile_missing_access_env_names_tried() {
2890 let home = IsolatedHome::new();
2891 let profile = parse_profile_str(
2892 "schema_version = 1\nid = \"xai\"\naccess_env = [\"XAI_API_KEY\", \"GROK_API_KEY\"]\n",
2893 )
2894 .unwrap();
2895 let err = provider_from_profile(&profile).expect_err("missing envs");
2896 let msg = err.to_string();
2897 assert!(
2898 matches!(err, AuthError::MissingField(_)),
2899 "expected MissingField, got {err}"
2900 );
2901 assert!(
2902 msg.contains("XAI_API_KEY") && msg.contains("GROK_API_KEY"),
2903 "MissingField must name tried envs, got {msg}"
2904 );
2905 let _ = home;
2906 }
2907
2908 #[tokio::test]
2909 async fn provider_from_profile_none_auth_empty_static() {
2910 let profile =
2911 parse_profile_str("schema_version = 1\nid = \"lmstudio\"\nauth_scheme = \"none\"\n")
2912 .unwrap();
2913 let provider = provider_from_profile(&profile).expect("none auth");
2914 assert_eq!(provider.get_token().await.expect("empty"), "");
2915 }
2916
2917 #[test]
2918 fn debug_redacts_tokens() {
2919 let home = IsolatedHome::new();
2920 home.plant_credentials(PlantCredentials::Claude {
2921 access: "sk-ant-oat01-secret",
2922 refresh: Some("rt-secret"),
2923 expires_at_ms: None,
2924 });
2925 let oauth = pack_from_toml(&claude_toml(&closed_http_url(), None));
2926 let p = provider(&oauth);
2927 let debug = format!("{p:?}");
2928 assert!(!debug.contains("sk-ant-oat01-secret"));
2929 assert!(!debug.contains("rt-secret"));
2930 }
2931
2932 #[test]
2933 fn debug_redacts_token_url_userinfo_and_query() {
2934 let home = IsolatedHome::new();
2935 home.plant_credentials(PlantCredentials::Claude {
2936 access: "sk-ant-oat01-secret",
2937 refresh: Some("rt-secret"),
2938 expires_at_ms: None,
2939 });
2940 let leaky =
2941 "https://user:s3cret@auth.example.invalid/oauth/token?client_secret=supersecret";
2942 let oauth = pack_from_toml(&claude_toml(leaky, Some(leaky)));
2943 let p = provider(&oauth);
2944 let debug = format!("{p:?}");
2945 assert!(!debug.contains("s3cret"), "Debug leaked userinfo: {debug}");
2946 assert!(
2947 !debug.contains("client_secret="),
2948 "Debug leaked query: {debug}"
2949 );
2950 assert!(
2951 !debug.contains("supersecret"),
2952 "Debug leaked secret: {debug}"
2953 );
2954 assert!(!debug.contains("/oauth"), "Debug leaked path: {debug}");
2955 assert!(
2956 debug.contains("https://auth.example.invalid"),
2957 "Debug must keep redacted origin: {debug}"
2958 );
2959 }
2960
2961 #[test]
2962 fn missing_creds_login_none_does_not_say_login_flow() {
2963 let profile = parse_profile_str(
2964 r#"
2965schema_version = 1
2966id = "none-login"
2967[oauth]
2968token_url = "https://auth.example.invalid/token"
2969creds_path = "~/missing-store.json"
2970login = "none"
2971"#,
2972 )
2973 .expect("test profile");
2974 let oauth = profile.oauth.as_ref().expect("oauth");
2975 let msg = missing_creds(oauth).to_string();
2976 assert!(
2977 !msg.contains("login flow"),
2978 "login=none must not tell the user to run auth login, got {msg}"
2979 );
2980 assert!(
2981 msg.contains("does not start vendor login"),
2982 "login=none must say vendor login is off, got {msg}"
2983 );
2984 }
2985
2986 #[test]
2987 fn missing_creds_display_redacts_envsubst_token() {
2988 const LEAK: &str = "ghp_ENVSUBST_LEAK_TOKEN_51";
2989 let home = IsolatedHome::with_extra_envs(&["GITHUB_TOKEN"]);
2990 home.set_env("GITHUB_TOKEN", LEAK);
2991 let profile = parse_profile_str(
2992 r#"
2993schema_version = 1
2994id = "env-leak"
2995[oauth]
2996token_url = "https://auth.example.invalid/token"
2997creds_path = "~/stolen/{env:GITHUB_TOKEN}.json"
2998setup_token_hint = "paste {env:GITHUB_TOKEN} into the vendor CLI"
2999"#,
3000 )
3001 .expect("test profile");
3002 let oauth = profile.oauth.as_ref().expect("oauth");
3003 assert!(
3004 oauth
3005 .creds_path
3006 .as_deref()
3007 .is_some_and(|p| p.contains(LEAK)),
3008 "precondition: envsubst must expand creds_path, got {:?}",
3009 oauth.creds_path
3010 );
3011 assert!(
3012 oauth
3013 .setup_token_hint
3014 .as_deref()
3015 .is_some_and(|h| h.contains(LEAK)),
3016 "precondition: envsubst must expand setup_token_hint, got {:?}",
3017 oauth.setup_token_hint
3018 );
3019 let msg = missing_creds(oauth).to_string();
3020 assert!(
3021 !msg.contains(LEAK),
3022 "AuthError Display leaked envsubst token: {msg}"
3023 );
3024 assert!(
3025 msg.contains("[redacted]"),
3026 "redacted Display must keep a placeholder, got {msg}"
3027 );
3028 assert!(
3029 msg.contains("paste"),
3030 "redacted Display must keep the setup hint, got {msg}"
3031 );
3032 }
3033
3034 #[tokio::test]
3035 async fn get_token_error_display_redacts_envsubst_hint() {
3036 const LEAK: &str = "ghp_ENVSUBST_LEAK_TOKEN_51";
3037 let home = IsolatedHome::with_extra_envs(&["GITHUB_TOKEN"]);
3038 home.set_env("GITHUB_TOKEN", LEAK);
3039 let path = home.plant_credentials(PlantCredentials::JsonPointer {
3040 relative_path: ".config/wiremux/auth.json",
3041 document: serde_json::json!({
3042 "tokens": {
3043 "access": "expired-access",
3044 "refresh": "",
3045 "expiry_unix": 1
3046 }
3047 }),
3048 });
3049 let creds = path.to_string_lossy().replace('\\', "/");
3050 let oauth = pack_from_toml(&format!(
3051 r#"
3052schema_version = 1
3053id = "env-hint-get"
3054[oauth]
3055token_url = "https://auth.example.invalid/token"
3056creds_path = "{creds}"
3057creds_format = "json-pointer"
3058access_token_ptr = "/tokens/access"
3059refresh_token_ptr = "/tokens/refresh"
3060expires_ptr = "/tokens/expiry_unix"
3061expires_unit = "s"
3062setup_token_hint = "paste {{env:GITHUB_TOKEN}} into the vendor CLI"
3063"#
3064 ));
3065 assert!(
3066 oauth
3067 .setup_token_hint
3068 .as_deref()
3069 .is_some_and(|h| h.contains(LEAK)),
3070 "precondition: envsubst must expand setup_token_hint, got {:?}",
3071 oauth.setup_token_hint
3072 );
3073 let p = provider(&oauth);
3074 let err = p.get_token().await.expect_err("expired without refresh");
3075 let msg = err.to_string();
3076 assert!(
3077 !msg.contains(LEAK),
3078 "get_token AuthError leaked envsubst token: {msg}"
3079 );
3080 assert!(
3081 msg.contains("[redacted]"),
3082 "redacted get_token error must keep a placeholder, got {msg}"
3083 );
3084 assert!(
3085 msg.contains("paste"),
3086 "redacted get_token error must keep the setup hint, got {msg}"
3087 );
3088 }
3089}