Skip to main content

wiremux_auth/providers/
oauth.rs

1//! Profile-driven TokenProvider. `[oauth]`, else top-level `access_env` or `auth_scheme = none`.
2
3use 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;
35/// Env tokens have no expiry and no refresh token. A one-hour default
36/// makes `get_token` fail offline later even though the variable did not
37/// change. Ten years stays inside `Duration` math for the 80% refresh check.
38const ENV_TOKEN_LIFETIME_SECS: u64 = 60 * 60 * 24 * 365 * 10;
39
40/// Cached access token plus refresh material.
41struct CachedToken {
42    access_token: String,
43    refresh_token: Option<String>,
44    acquired_at: Instant,
45    lifetime: Duration,
46    /// Vendor `expires_in` while this refresh is cached but not saved.
47    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/// Where credentials were loaded from.
72#[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/// Resolved JSON pointers into the credential store.
109#[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/// Profile-driven OAuth refresh.
118#[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
153/// Ways a profile can authenticate, for error text shared by the proxy
154/// and `auth login`.
155pub 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
184/// Field text for [`AuthError::MissingField`]. Display adds `missing `.
185pub fn missing_credential_field(profile: &ResolvedProfile) -> String {
186    format!(
187        "{} on profile `{}`",
188        credential_options(profile),
189        profile.id
190    )
191}
192
193/// Build a provider from a resolved profile.
194///
195/// `[oauth]` uses the refresh pack. Otherwise a non-empty top-level
196/// `access_env` list reads the first non-empty env. GCP uses
197/// `gcp_key_env` or application default credentials. `auth_scheme = none`
198/// is an empty static token. A `[headers]` credential is not a provider;
199/// the proxy reads that header itself.
200pub 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
267/// Build a provider from an `[oauth]` pack (no fingerprint, no API headers).
268pub fn provider_from_oauth(oauth: &OauthPack) -> Result<crate::AnyTokenProvider, AuthError> {
269    provider_from_oauth_opts(oauth, None)
270}
271
272/// Same as [`provider_from_oauth`], with a host-supplied credentials path.
273///
274/// A missing explicit path fails closed to `access_env` only (a typo must
275/// not silently pick keychain or the profile default file).
276pub 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    /// Skip the token-URL POST on later [`TokenProvider::get_token`] calls.
358    /// Hosts that want a cheap "cached oat only" probe use this.
359    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(&current)?;
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    /// Retry a save that already updated memory. Does not call the token URL.
611    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    /// A local reason `get_token` would fail without contacting the token URL.
719    ///
720    /// Status uses this so an expired access token with no refresh token is
721    /// not reported as available. A token that can still be refreshed is not
722    /// a block: refresh needs the network, and status does not call it.
723    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        // load() not swap: a concurrent get_token must not see a cleared force
750        // flag before the InFlight claim.
751        {
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            // A failed save must not schedule another vendor refresh.
775            // VendorRejected still re-arms.
776            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    // Reserved key wins: a gist cannot omit or hardcode the refresh token.
838    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        // Profile path set but missing: still try keychain, then env.
892        // An *explicit* missing path (host override) never reaches here.
893    }
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    // Store expiry is an absolute timestamp. `expiresAt: 1` is 1970, not 1ms TTL.
1084    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    // Env-only / no store: pointers unused until write-back (which is None).
1143    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    /// Bind-and-close so a mistaken refresh fails with ECONNREFUSED, not a hang.
1938    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        // Lock sibling must exist before 0555. Otherwise lock create fails
2143        // first, write-back never runs, and the mock accept hangs.
2144        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        // Pre-create the lock sibling so 0555 still lets us lock, but not
2218        // create `{creds}.tmp`. Otherwise lock-file create fails first and
2219        // we never reach write-back (and the mock accept hangs).
2220        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}