Skip to main content

car_auth/
lib.rs

1//! Shared Parslee OAuth2 PKCE + token/keychain logic.
2//!
3//! Used by `car-cli` (`car auth login parslee`, loopback flow) and by
4//! `car-server` (the `auth.*` JSON-RPC surface that CAR Host.app's
5//! signup GUI drives). The keychain keys + default service exactly
6//! match what `car-inference` reads at request time. The serialized
7//! `PARSLEE_AUTH_STATE_V2` record under the default `"car"` service is the
8//! durable authority; the old fixed slots are read only by locked migration.
9
10use base64::Engine;
11use serde::{Deserialize, Serialize};
12use sha2::{Digest, Sha256};
13use std::sync::{Mutex, OnceLock};
14use std::time::{Duration, Instant};
15
16#[cfg(test)]
17use car_secrets::SecretStore;
18use car_secrets::{SecretError, SecretRef};
19
20mod authority_hint;
21mod credential_read;
22mod state;
23pub use authority_hint::{
24    credential_authority_hint, CredentialAuthorityHint, CredentialAuthorityState,
25};
26use credential_read::CredentialReadPurpose;
27pub use credential_read::{
28    refresh_credential, resolve_credential, subscribe_credential_read_event_handoff,
29    subscribe_credential_read_events, subscribe_credential_read_updates, CredentialReadError,
30    CredentialReadEventCloseReason, CredentialReadEventHandoff, CredentialReadEventSubscription,
31    CredentialReadFailureKind, CredentialReadMode, CredentialReadStatus, CredentialReadStatusState,
32    ResolvedParsleeCredential,
33};
34use state::{
35    ActiveCredentials, AuthStateError, AuthStateStore, AuthStateV2, CasOutcome, ProcessAuthLock,
36    RefreshCas, RefreshedCredentials, SecretAuthStateStore, StateCoordinator,
37};
38
39pub const PARSLEE_ACCESS_TOKEN_KEY: &str = car_secrets::PARSLEE_ACCESS_TOKEN_KEY;
40pub const PARSLEE_REFRESH_TOKEN_KEY: &str = car_secrets::PARSLEE_REFRESH_TOKEN_KEY;
41pub const PARSLEE_EXPIRES_AT_KEY: &str = car_secrets::PARSLEE_EXPIRES_AT_KEY;
42pub const PARSLEE_API_BASE_KEY: &str = car_secrets::PARSLEE_API_BASE_KEY;
43pub const DEFAULT_API_BASE: &str = "https://api.parslee.ai";
44const PARSLEE_TOKEN_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
45const PARSLEE_STATUS_REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
46/// Maximum time an auth operation may wait behind another in-process
47/// coordinator operation before failing safely without starting storage work.
48///
49/// Once the coordinator guard is acquired, the daemon-owned caller retains it
50/// until the bounded blocking storage task has actually joined. Timing out a
51/// WebSocket response therefore cannot release this overlap guard while an
52/// abandoned keychain worker continues in the background.
53pub const AUTH_COORDINATOR_QUEUE_TIMEOUT: Duration = Duration::from_secs(30);
54/// The host allows up to 300 seconds for the browser callback. Keep the durable
55/// reservation valid beyond that window so a callback at the edge can still
56/// atomically claim its bounded completion worker.
57pub const LOGIN_ATTEMPT_CALLBACK_TTL: Duration = Duration::from_secs(420);
58/// Maximum time allowed for the aggregate token-exchange and completion-session
59/// network phase.
60pub const AUTH_COMPLETION_NETWORK_DEADLINE: Duration = Duration::from_secs(90);
61/// Bound used for one authoritative credential-store read or publication in
62/// the login-worker budget. The macOS keychain helper enforces this duration;
63/// the other local backends are expected to complete within the same budget.
64pub const AUTH_STATE_OPERATION_BUDGET: Duration = Duration::from_secs(15);
65/// Maximum time allowed to acquire the per-user cross-process auth-state lock.
66pub const AUTH_PROCESS_LOCK_TIMEOUT: Duration = Duration::from_secs(30);
67/// Explicit scheduler/runtime headroom after every bounded serial phase.
68pub const LOGIN_ATTEMPT_WORKER_SCHEDULING_MARGIN: Duration = Duration::from_secs(30);
69/// Worst-case serial work between calculating the redeeming lease expiry and
70/// the strict expiry check immediately before credential publication:
71///
72/// claim publication + network + coordinator queue + process lock + state read.
73pub const LOGIN_ATTEMPT_WORKER_SERIAL_BUDGET: Duration = Duration::from_secs(
74    AUTH_STATE_OPERATION_BUDGET.as_secs()
75        + AUTH_COMPLETION_NETWORK_DEADLINE.as_secs()
76        + AUTH_COORDINATOR_QUEUE_TIMEOUT.as_secs()
77        + AUTH_PROCESS_LOCK_TIMEOUT.as_secs()
78        + AUTH_STATE_OPERATION_BUDGET.as_secs(),
79);
80/// Redeeming-worker lease derived from the complete serial budget plus positive
81/// scheduling margin. Keep this below the host's 480-second reconciliation
82/// horizon when changing any component.
83pub const LOGIN_ATTEMPT_WORKER_TTL: Duration = Duration::from_secs(
84    LOGIN_ATTEMPT_WORKER_SERIAL_BUDGET.as_secs() + LOGIN_ATTEMPT_WORKER_SCHEDULING_MARGIN.as_secs(),
85);
86
87/// Classified failure for coordinator-backed auth operations.
88///
89/// A coordination deadline is known to occur before the requested state
90/// operation starts. Callers may distinguish it from terminal state,
91/// credential-store, or worker failures without parsing human-readable text.
92#[derive(Debug, Clone, PartialEq, Eq)]
93pub enum AuthOperationError {
94    CoordinationDeadline(String),
95    Terminal(String),
96}
97
98impl AuthOperationError {
99    /// Return whether this failure occurred before the requested state
100    /// operation began.
101    pub fn is_coordination_deadline(&self) -> bool {
102        matches!(self, Self::CoordinationDeadline(_))
103    }
104}
105
106impl std::fmt::Display for AuthOperationError {
107    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108        match self {
109            Self::CoordinationDeadline(message) | Self::Terminal(message) => {
110                formatter.write_str(message)
111            }
112        }
113    }
114}
115
116impl std::error::Error for AuthOperationError {}
117
118/// `/connect/token` success body.
119#[derive(Debug, Clone, Deserialize)]
120pub struct TokenSet {
121    pub access_token: String,
122    pub refresh_token: String,
123    pub expires_in: u64,
124    pub token_type: String,
125}
126
127/// A local, non-mutating view of the persisted Parslee login state.
128///
129/// Unlike [`fetch_status`], this never refreshes a token and never calls the
130/// Parslee API. It is the safe pre-browser baseline for a login attempt.
131#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
132pub struct LocalAuthSnapshot {
133    pub authenticated: bool,
134    #[serde(skip_serializing_if = "Option::is_none")]
135    pub active_account_id: Option<String>,
136}
137
138/// The latest causally-bound browser completion. Only one completion can remain
139/// current because every identity-changing credential mutation advances
140/// `generation`; a later mutation therefore supersedes this proof.
141#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
142pub struct AuthCompletionRecord {
143    pub attempt_id: String,
144    pub generation: u64,
145    #[serde(default)]
146    pub account_id: Option<String>,
147    #[serde(default)]
148    pub session: Option<String>,
149}
150
151/// Durable phase of an incomplete browser login attempt.
152#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
153#[serde(rename_all = "snake_case")]
154pub enum AuthAttemptPhase {
155    AwaitingCallback,
156    Redeeming,
157}
158
159/// Typed result returned by the local-only completion-status read.
160#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
161#[serde(rename_all = "snake_case")]
162pub enum AuthCompletionState {
163    Pending,
164    Complete,
165    Failed,
166    Stale,
167}
168
169/// Safe terminal failure metadata persisted for one exact attempt.
170#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
171pub struct AuthAttemptFailure {
172    pub error_code: String,
173    pub message: String,
174    pub retryable: bool,
175}
176
177impl AuthAttemptFailure {
178    pub fn completion_failed() -> Self {
179        Self {
180            error_code: "completion_failed".into(),
181            message:
182                "Sign-in could not be completed. Start a new sign-in attempt; do not reuse this authorization code."
183                    .into(),
184            retryable: true,
185        }
186    }
187
188    fn attempt_expired() -> Self {
189        Self {
190            error_code: "attempt_expired".into(),
191            message:
192                "This sign-in attempt expired. Start a new sign-in attempt; do not reuse this authorization code."
193                    .into(),
194            retryable: true,
195        }
196    }
197
198    fn daemon_restarted() -> Self {
199        Self {
200            error_code: "daemon_restarted".into(),
201            message:
202                "CAR restarted while finishing sign-in. Start a new sign-in attempt; do not reuse this authorization code."
203                    .into(),
204            retryable: true,
205        }
206    }
207}
208
209/// One authoritative, coordinator-locked view of completion, generation, and
210/// attempt lifecycle. Optional fields are populated only for their matching
211/// state.
212#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
213pub struct AuthCompletionStatus {
214    pub state: AuthCompletionState,
215    pub attempt_id: String,
216    pub generation: u64,
217    #[serde(default, skip_serializing_if = "Option::is_none")]
218    pub phase: Option<AuthAttemptPhase>,
219    #[serde(default, skip_serializing_if = "Option::is_none")]
220    pub expires_at_unix_ms: Option<u64>,
221    #[serde(default, skip_serializing_if = "Option::is_none")]
222    pub account_id: Option<String>,
223    #[serde(default, skip_serializing_if = "Option::is_none")]
224    pub session: Option<String>,
225    #[serde(default, skip_serializing_if = "Option::is_none")]
226    pub error_code: Option<String>,
227    #[serde(default, skip_serializing_if = "Option::is_none")]
228    pub message: Option<String>,
229    #[serde(default, skip_serializing_if = "Option::is_none")]
230    pub retryable: Option<bool>,
231}
232
233impl AuthCompletionStatus {
234    fn stale(attempt_id: &str, generation: u64) -> Self {
235        Self {
236            state: AuthCompletionState::Stale,
237            attempt_id: attempt_id.to_string(),
238            generation,
239            phase: None,
240            expires_at_unix_ms: None,
241            account_id: None,
242            session: None,
243            error_code: None,
244            message: None,
245            retryable: None,
246        }
247    }
248}
249
250/// Persisted reservation and worker fence for one browser login attempt.
251/// `auth.start` publishes the awaiting-callback form. `auth.complete` may
252/// atomically populate the worker fields exactly once before network I/O; only
253/// a completion carrying that exact claimed lease may replace credentials.
254#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
255pub struct LoginAttemptLease {
256    pub attempt_id: String,
257    pub revision: u64,
258    pub generation: u64,
259    #[serde(default)]
260    pub attempt_expires_at_unix_ms: u64,
261    #[serde(default, skip_serializing_if = "Option::is_none")]
262    pub worker_owner_id: Option<String>,
263    #[serde(default, skip_serializing_if = "Option::is_none")]
264    pub worker_id: Option<String>,
265    #[serde(default, skip_serializing_if = "Option::is_none")]
266    pub worker_expires_at_unix_ms: Option<u64>,
267}
268
269fn epoch_seconds() -> u64 {
270    std::time::SystemTime::now()
271        .duration_since(std::time::UNIX_EPOCH)
272        .map(|d| d.as_secs())
273        .unwrap_or(0)
274}
275
276fn epoch_millis() -> u64 {
277    std::time::SystemTime::now()
278        .duration_since(std::time::UNIX_EPOCH)
279        .map(|duration| u64::try_from(duration.as_millis()).unwrap_or(u64::MAX))
280        .unwrap_or(0)
281}
282
283/// PKCE code verifier (URL-safe, no padding).
284pub fn pkce_verifier() -> String {
285    let raw = format!(
286        "{}{}",
287        uuid::Uuid::new_v4().simple(),
288        uuid::Uuid::new_v4().simple()
289    );
290    base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(raw.as_bytes())
291}
292
293/// Opaque OAuth `state` value (CSRF guard).
294pub fn new_state() -> String {
295    uuid::Uuid::new_v4().simple().to_string()
296}
297
298/// PKCE S256 challenge for a verifier.
299pub fn pkce_challenge(verifier: &str) -> String {
300    let digest = Sha256::digest(verifier.as_bytes());
301    base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest)
302}
303
304/// Build the `/connect/authorize` URL the user opens in a browser.
305pub fn authorize_url(
306    api_base: &str,
307    client_id: &str,
308    redirect_uri: &str,
309    state: &str,
310    challenge: &str,
311    provider: Option<&str>,
312    prompt: Option<&str>,
313) -> Result<String, String> {
314    let mut url = reqwest::Url::parse(&format!(
315        "{}/connect/authorize",
316        api_base.trim_end_matches('/')
317    ))
318    .map_err(|e| format!("build authorize URL: {e}"))?;
319    url.query_pairs_mut()
320        .append_pair("client_id", client_id)
321        .append_pair("redirect_uri", redirect_uri)
322        .append_pair("response_type", "code")
323        .append_pair("scope", "openid profile email")
324        .append_pair("state", state)
325        .append_pair("code_challenge", challenge)
326        .append_pair("code_challenge_method", "S256");
327    if let Some(provider) = provider {
328        url.query_pairs_mut().append_pair("provider", provider);
329    }
330    // `prompt=select_account` forces a fresh account chooser (add-account),
331    // bypassing the existing SSO cookie so a second login can be added.
332    if let Some(prompt) = prompt {
333        url.query_pairs_mut().append_pair("prompt", prompt);
334    }
335    Ok(url.to_string())
336}
337
338fn form_body(pairs: &[(&str, &str)]) -> String {
339    let mut s = String::new();
340    for (i, (k, v)) in pairs.iter().enumerate() {
341        if i > 0 {
342            s.push('&');
343        }
344        s.push_str(&urlencode(k));
345        s.push('=');
346        s.push_str(&urlencode(v));
347    }
348    s
349}
350
351fn urlencode(s: &str) -> String {
352    let mut out = String::with_capacity(s.len());
353    for b in s.bytes() {
354        match b {
355            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
356                out.push(b as char)
357            }
358            _ => out.push_str(&format!("%{b:02X}")),
359        }
360    }
361    out
362}
363
364/// Exchange an authorization code + PKCE verifier for tokens.
365pub async fn exchange_code(
366    api_base: &str,
367    client_id: &str,
368    redirect_uri: &str,
369    code: &str,
370    verifier: &str,
371) -> Result<TokenSet, String> {
372    exchange_code_with_timeout(
373        api_base,
374        client_id,
375        redirect_uri,
376        code,
377        verifier,
378        PARSLEE_TOKEN_REQUEST_TIMEOUT,
379    )
380    .await
381}
382
383async fn post_token_form_with_timeout(
384    token_url: String,
385    body: String,
386    action: &'static str,
387    request_timeout: Duration,
388) -> Result<(reqwest::StatusCode, String), String> {
389    let client = reqwest::Client::builder()
390        .timeout(request_timeout)
391        .build()
392        .map_err(|error| format!("build Parslee token client: {error}"))?;
393    let response = client
394        .post(token_url)
395        .header("content-type", "application/x-www-form-urlencoded")
396        .body(body)
397        .send()
398        .await
399        .map_err(|error| {
400            if error.is_timeout() {
401                format!("{action} timed out after {}ms", request_timeout.as_millis())
402            } else {
403                format!("{action}: {error}")
404            }
405        })?;
406    let status = response.status();
407    let text = response.text().await.map_err(|error| {
408        if error.is_timeout() {
409            format!("{action} timed out after {}ms", request_timeout.as_millis())
410        } else {
411            format!("read Parslee token response: {error}")
412        }
413    })?;
414    Ok((status, text))
415}
416
417async fn exchange_code_with_timeout(
418    api_base: &str,
419    client_id: &str,
420    redirect_uri: &str,
421    code: &str,
422    verifier: &str,
423    request_timeout: Duration,
424) -> Result<TokenSet, String> {
425    let body = form_body(&[
426        ("grant_type", "authorization_code"),
427        ("client_id", client_id),
428        ("redirect_uri", redirect_uri),
429        ("code", code),
430        ("code_verifier", verifier),
431    ]);
432    let token_url = format!("{}/connect/token", api_base.trim_end_matches('/'));
433    let (status, text) = post_token_form_with_timeout(
434        token_url,
435        body,
436        "exchange Parslee authorization code",
437        request_timeout,
438    )
439    .await?;
440    if !status.is_success() {
441        return Err(format!(
442            "Parslee token exchange failed: HTTP {status}: {text}"
443        ));
444    }
445    let token: TokenSet =
446        serde_json::from_str(&text).map_err(|e| format!("parse token response: {e}"))?;
447    if !token.token_type.eq_ignore_ascii_case("bearer") {
448        return Err(format!(
449            "unexpected Parslee token_type `{}`",
450            token.token_type
451        ));
452    }
453    Ok(token)
454}
455
456static AUTH_STATE_MUTEX: std::sync::OnceLock<tokio::sync::Mutex<()>> = std::sync::OnceLock::new();
457
458async fn lock_auth_state_queue<'a>(
459    mutex: &'a tokio::sync::Mutex<()>,
460    timeout: Duration,
461) -> Result<tokio::sync::MutexGuard<'a, ()>, AuthOperationError> {
462    tokio::time::timeout(timeout, mutex.lock())
463        .await
464        .map_err(|_| {
465            AuthOperationError::CoordinationDeadline(format!(
466                "timed out waiting for the in-process Parslee credential coordinator after {}ms",
467                timeout.as_millis()
468            ))
469        })
470}
471
472async fn with_locked_state_classified<T, F>(operation: F) -> Result<T, AuthOperationError>
473where
474    T: Send + 'static,
475    F: FnOnce(StateCoordinator<SecretAuthStateStore>) -> Result<T, state::AuthStateError>
476        + Send
477        + 'static,
478{
479    let _process_guard = lock_auth_state_queue(
480        AUTH_STATE_MUTEX.get_or_init(|| tokio::sync::Mutex::new(())),
481        AUTH_COORDINATOR_QUEUE_TIMEOUT,
482    )
483    .await?;
484    tokio::task::spawn_blocking(move || {
485        let _file_guard = ProcessAuthLock::acquire()?;
486        operation(StateCoordinator::new(SecretAuthStateStore))
487    })
488    .await
489    .map_err(|error| {
490        AuthOperationError::Terminal(format!("Parslee credential worker failed: {error}"))
491    })?
492    .map_err(|error| match error {
493        state::AuthStateError::CoordinationDeadline(message) => {
494            AuthOperationError::CoordinationDeadline(message)
495        }
496        other => AuthOperationError::Terminal(other.to_string()),
497    })
498}
499
500async fn with_locked_state<T, F>(operation: F) -> Result<T, String>
501where
502    T: Send + 'static,
503    F: FnOnce(StateCoordinator<SecretAuthStateStore>) -> Result<T, state::AuthStateError>
504        + Send
505        + 'static,
506{
507    with_locked_state_classified(operation)
508        .await
509        .map_err(|error| error.to_string())
510}
511
512fn read_published_state_without_migration() -> Result<Option<AuthStateV2>, String> {
513    StateCoordinator::new(SecretAuthStateStore)
514        .read_published_snapshot()
515        .map_err(|error| error.to_string())
516}
517
518/// How long a resolved access token may be served from process memory before
519/// the credential store is consulted again.
520///
521/// Remote inference resolves the bearer on **every request**
522/// (`car-inference::remote`), and each resolution took the cross-process auth
523/// file lock and read the OS keychain. On macOS a keychain read can prompt, and
524/// the ACL is keyed to the caller's code signature — so an unsigned or freshly
525/// rebuilt binary re-prompts *per request*. A single 4-task coder-A/B run made
526/// 88 inference calls and therefore 88 keychain reads. Caching the resolved
527/// token collapses that to roughly one read per TTL.
528///
529/// 30s rather than the token's own lifetime (~1h) is deliberate. The cache is
530/// per-process, so a `car auth login`, `logout`, or org switch performed by a
531/// *different* process is invisible to it; a short TTL bounds that staleness
532/// to something a human won't notice, while still removing ~99% of the reads.
533/// Same-process mutations don't wait for the TTL — they call
534/// [`invalidate_access_token_cache`] directly.
535const TOKEN_CACHE_TTL: Duration = Duration::from_secs(30);
536
537struct CachedParsleeCredential {
538    access_token: String,
539    api_base: String,
540    /// The token's own expiry (epoch seconds); 0 when the record carries none.
541    expires_at: u64,
542    cached_at: Instant,
543}
544
545static ACCESS_TOKEN_CACHE: OnceLock<Mutex<Option<CachedParsleeCredential>>> = OnceLock::new();
546
547fn access_token_cache() -> &'static Mutex<Option<CachedParsleeCredential>> {
548    ACCESS_TOKEN_CACHE.get_or_init(|| Mutex::new(None))
549}
550
551/// Minimum delay between failed proactive refresh attempts for one credential.
552///
553/// This is independent of [`TOKEN_CACHE_TTL`]: an expiring bearer still never
554/// comes from the cache, but a bad refresh token cannot hit Parslee's token
555/// endpoint once per model turn. Automatic post-401 retries share this delay;
556/// only explicit/caller-initiated forced refreshes bypass it.
557const PROACTIVE_REFRESH_FAILURE_MIN_INTERVAL: Duration = Duration::from_secs(30);
558
559type ProactiveRefreshCredentialKey = [u8; 32];
560type RejectedBearerKey = [u8; 32];
561
562struct ProactiveRefreshFailure {
563    credential: ProactiveRefreshCredentialKey,
564    failed_at: Instant,
565}
566
567/// One active-credential slot is enough: CAR has one active Parslee login, and
568/// bounding this by cardinality prevents rapid credential churn from growing a
569/// process-lifetime map even within the short interval.
570static PROACTIVE_REFRESH_FAILURE: OnceLock<Mutex<Option<ProactiveRefreshFailure>>> =
571    OnceLock::new();
572
573fn proactive_refresh_failure() -> &'static Mutex<Option<ProactiveRefreshFailure>> {
574    PROACTIVE_REFRESH_FAILURE.get_or_init(|| Mutex::new(None))
575}
576
577/// Derive a secret-free coordinator identity for the bearer a server rejected.
578fn rejected_bearer_key(access_token: &str) -> RejectedBearerKey {
579    Sha256::digest(access_token.as_bytes()).into()
580}
581
582/// Derive a secret-free in-memory lookup key from the active credential
583/// snapshot. Expiry is authoritative state too: another process can refresh or
584/// re-login with the same token tuple but a new expiry, and that replacement
585/// must not inherit this process's stale failure interval.
586fn proactive_refresh_credential_key(
587    credential: &ActiveCredentials,
588) -> ProactiveRefreshCredentialKey {
589    let mut digest = Sha256::new();
590    for field in [
591        credential.account_id.as_bytes(),
592        credential.access_token.as_bytes(),
593        credential
594            .refresh_token
595            .as_deref()
596            .unwrap_or_default()
597            .as_bytes(),
598        credential.api_base.as_bytes(),
599    ] {
600        digest.update((field.len() as u64).to_le_bytes());
601        digest.update(field);
602    }
603    digest.update(credential.expires_at.to_le_bytes());
604    digest.finalize().into()
605}
606
607fn proactive_refresh_is_suppressed(credential: &ActiveCredentials, now: Instant) -> bool {
608    let key = proactive_refresh_credential_key(credential);
609    let Ok(mut slot) = proactive_refresh_failure().lock() else {
610        return false;
611    };
612    let Some(failure) = slot.as_ref() else {
613        return false;
614    };
615    if now.saturating_duration_since(failure.failed_at) >= PROACTIVE_REFRESH_FAILURE_MIN_INTERVAL {
616        *slot = None;
617        return false;
618    }
619    failure.credential == key
620}
621
622fn record_proactive_refresh_failure(credential: &ActiveCredentials, now: Instant) {
623    if let Ok(mut slot) = proactive_refresh_failure().lock() {
624        *slot = Some(ProactiveRefreshFailure {
625            credential: proactive_refresh_credential_key(credential),
626            failed_at: now,
627        });
628    }
629}
630
631fn clear_proactive_refresh_failure(credential: &ActiveCredentials) {
632    if let Ok(mut slot) = proactive_refresh_failure().lock() {
633        if slot.as_ref().is_some_and(|failure| {
634            failure.credential == proactive_refresh_credential_key(credential)
635        }) {
636            *slot = None;
637        }
638    }
639}
640
641fn invalidate_proactive_refresh_failure() {
642    if let Ok(mut slot) = proactive_refresh_failure().lock() {
643        *slot = None;
644    }
645}
646
647/// Drop any process-cached access token.
648///
649/// Called by every operation in this module that changes which credential is
650/// active — login, logout, refresh, org switch, account switch/removal — so a
651/// caller never has to wait out [`TOKEN_CACHE_TTL`] to see its own change.
652pub fn invalidate_access_token_cache() {
653    if let Ok(mut slot) = access_token_cache().lock() {
654        *slot = None;
655    }
656}
657
658/// A cached token, if one is still both fresh enough and far enough from its
659/// own expiry that the refresh path would not have replaced it anyway.
660fn cached_credential() -> Option<ResolvedParsleeCredential> {
661    let slot = access_token_cache().lock().ok()?;
662    let entry = slot.as_ref()?;
663    if entry.cached_at.elapsed() >= TOKEN_CACHE_TTL {
664        return None;
665    }
666    // Never serve something `access_token_refreshing` would consider expiring —
667    // otherwise the cache would suppress the refresh that keeps a long run alive.
668    if entry.expires_at > 0 && epoch_seconds() + REFRESH_SKEW_SECS >= entry.expires_at {
669        return None;
670    }
671    Some(ResolvedParsleeCredential {
672        access_token: entry.access_token.clone(),
673        api_base: entry.api_base.clone(),
674        expires_at: entry.expires_at,
675    })
676}
677
678fn store_resolved_credential(credential: &ResolvedParsleeCredential) {
679    if let Ok(mut slot) = access_token_cache().lock() {
680        *slot = Some(CachedParsleeCredential {
681            access_token: credential.access_token.clone(),
682            api_base: credential.api_base.clone(),
683            expires_at: credential.expires_at,
684            cached_at: Instant::now(),
685        });
686    }
687}
688
689/// Current access token (env override first, then authoritative V2 record).
690///
691/// This synchronous path never reads the import-only legacy token slot. Async
692/// callers that need migration or refresh use [`access_token_refreshing`].
693///
694/// **Deliberately uncached.** [`TOKEN_CACHE_TTL`] exists for the per-request
695/// inference path; this reader is the one whose callers depend on a published
696/// tombstone or an invalid record taking effect *immediately* (fail-closed),
697/// and it is not hot enough to be worth trading that for. Keep it reading the
698/// authoritative record every call.
699pub fn access_token() -> Option<String> {
700    if let Ok(token) = std::env::var(PARSLEE_ACCESS_TOKEN_KEY) {
701        if !token.is_empty() {
702            return Some(token);
703        }
704    }
705    read_published_state_without_migration()
706        .ok()
707        .flatten()
708        .and_then(|state| state.active.map(|active| active.access_token))
709}
710
711/// Whether Parslee inference may enter request-time credential reconciliation.
712///
713/// Environment injection wins. A published V2 record is authoritative,
714/// including a signed-out tombstone. Only when V2 has never been published may
715/// an old fixed-slot token keep managed aliases routable; the request-time
716/// async reader will then migrate attributable legacy state under the auth
717/// coordinator lock. This existence-only probe never returns the bearer.
718pub fn access_token_is_available() -> bool {
719    if std::env::var(PARSLEE_ACCESS_TOKEN_KEY).is_ok_and(|token| !token.is_empty()) {
720        return true;
721    }
722    match read_published_state_without_migration() {
723        Ok(Some(state)) => state.active.is_some(),
724        Ok(None) => {
725            let legacy_available = car_secrets::status_via_operator_broker_or_keychain(
726                &car_secrets::SecretRef::with_default_service(PARSLEE_ACCESS_TOKEN_KEY),
727            )
728            .is_ok_and(|status| status.exists);
729            // Finish with the authoritative read. If logout publishes its
730            // tombstone while the legacy probe is in flight, this later read
731            // observes it instead of reviving the stale fixed slot.
732            match read_published_state_without_migration() {
733                Ok(Some(state)) => state.active.is_some(),
734                Ok(None) => legacy_available,
735                Err(_) => false,
736            }
737        }
738        Err(_) => false,
739    }
740}
741
742/// Current durable generation of the active Parslee credential identity.
743///
744/// Callers reconciling a browser attempt must use [`auth_completion_status`]
745/// instead, which reads generation and lifecycle from one locked snapshot.
746pub async fn auth_generation() -> Result<u64, String> {
747    with_locked_state(|coordinator| Ok(coordinator.read_snapshot()?.generation)).await
748}
749
750/// Read the latest browser completion without refreshing or mutating tokens.
751///
752/// Callers reconciling a browser attempt must use [`auth_completion_status`]
753/// instead, which cannot race this read against a separate generation read.
754pub async fn auth_completion() -> Result<Option<AuthCompletionRecord>, String> {
755    with_locked_state(|coordinator| Ok(coordinator.read_snapshot()?.completion)).await
756}
757
758/// Atomically reserve one browser login attempt during `auth.start`.
759/// Publishing a newer reservation advances the credential generation and
760/// permanently fences every older completion before any code can be redeemed.
761pub async fn reserve_login_attempt(attempt_id: &str) -> Result<LoginAttemptLease, String> {
762    reserve_login_attempt_classified(attempt_id)
763        .await
764        .map_err(|error| error.to_string())
765}
766
767/// Reserve a login attempt while preserving a typed pre-operation deadline.
768pub async fn reserve_login_attempt_classified(
769    attempt_id: &str,
770) -> Result<LoginAttemptLease, AuthOperationError> {
771    let attempt_id = attempt_id.to_string();
772    with_locked_state_classified(move |coordinator| {
773        let expires_at =
774            epoch_millis().saturating_add(LOGIN_ATTEMPT_CALLBACK_TTL.as_millis() as u64);
775        coordinator.reserve_login_attempt(&attempt_id, expires_at)
776    })
777    .await
778}
779
780/// Atomically claim one exact awaiting-callback attempt for a single daemon
781/// worker. Duplicate, missing, stale, or expired attempts fail before OAuth
782/// token exchange.
783pub async fn claim_login_attempt(
784    attempt_id: &str,
785    daemon_owner_id: &str,
786) -> Result<LoginAttemptLease, String> {
787    claim_login_attempt_classified(attempt_id, daemon_owner_id)
788        .await
789        .map_err(|error| error.to_string())
790}
791
792/// Claim a login attempt while preserving a typed pre-operation deadline.
793pub async fn claim_login_attempt_classified(
794    attempt_id: &str,
795    daemon_owner_id: &str,
796) -> Result<LoginAttemptLease, AuthOperationError> {
797    let attempt_id = attempt_id.to_string();
798    let daemon_owner_id = daemon_owner_id.to_string();
799    with_locked_state_classified(move |coordinator| {
800        coordinator.claim_login_attempt_now(&attempt_id, &daemon_owner_id)
801    })
802    .await
803}
804
805/// Persist a terminal result only while the worker still owns its exact fence.
806pub async fn fail_login_attempt(
807    lease: &LoginAttemptLease,
808    failure: AuthAttemptFailure,
809) -> Result<bool, String> {
810    let lease = lease.clone();
811    with_locked_state(move |coordinator| {
812        Ok(matches!(
813            coordinator.fail_login_attempt(&lease, failure)?,
814            CasOutcome::Committed
815        ))
816    })
817    .await
818}
819
820/// One local-only, coordinator-locked completion/lifecycle snapshot.
821///
822/// This never refreshes or calls the network. Matching expired or old-daemon
823/// redeeming attempts are atomically closed before the typed status returns.
824pub async fn auth_completion_status(
825    attempt_id: &str,
826    daemon_owner_id: &str,
827) -> Result<AuthCompletionStatus, String> {
828    auth_completion_status_classified(attempt_id, daemon_owner_id)
829        .await
830        .map_err(|error| error.to_string())
831}
832
833/// Read completion proof while preserving a typed pre-operation deadline.
834pub async fn auth_completion_status_classified(
835    attempt_id: &str,
836    daemon_owner_id: &str,
837) -> Result<AuthCompletionStatus, AuthOperationError> {
838    let attempt_id = attempt_id.to_string();
839    let daemon_owner_id = daemon_owner_id.to_string();
840    with_locked_state_classified(move |coordinator| {
841        coordinator.completion_status_from_published_now(&attempt_id, &daemon_owner_id)
842    })
843    .await
844}
845
846/// Atomically publish a newly-authorized login and its attempt-bound completion.
847///
848/// The final critical section performs no network I/O and refuses a lease
849/// invalidated by a newer attempt or identity mutation. `None` remains the
850/// in-process CLI compatibility path and itself invalidates any outstanding
851/// browser lease.
852pub async fn commit_login(
853    api_base: &str,
854    token: &TokenSet,
855    session: &str,
856    lease: Option<LoginAttemptLease>,
857) -> Result<AuthCompletionRecord, String> {
858    let identity = session_identity(session)?;
859    let credentials = ActiveCredentials {
860        account_id: identity.id.clone(),
861        email: identity.email,
862        name: identity.name,
863        access_token: token.access_token.clone(),
864        refresh_token: Some(token.refresh_token.clone()),
865        expires_at: epoch_seconds().saturating_add(token.expires_in),
866        api_base: api_base.trim_end_matches('/').to_string(),
867    };
868    let full_session = session.to_string();
869    let completion_session = lease.as_ref().map(|_| full_session.clone());
870    let state = with_locked_state(move |coordinator| {
871        coordinator.commit_login_now(credentials, completion_session, lease)
872    })
873    .await;
874    // Before `?`: a login that failed mid-commit must not leave a previous
875    // account's bearer or failed-refresh interval cached either.
876    invalidate_access_token_cache();
877    invalidate_proactive_refresh_failure();
878    let state = state?;
879    Ok(AuthCompletionRecord {
880        attempt_id: state
881            .completion
882            .as_ref()
883            .map(|record| record.attempt_id.clone())
884            .unwrap_or_default(),
885        generation: state.generation,
886        account_id: state.active.map(|active| active.account_id),
887        session: Some(full_session),
888    })
889}
890
891/// Publish a signed-out tombstone before best-effort legacy cleanup.
892pub async fn logout() -> Result<(), String> {
893    let result = with_locked_state(|coordinator| coordinator.logout().map(|_| ())).await;
894    // Unconditional, including on error: a partially-applied logout must not
895    // leave this process serving the bearer it just tried to revoke.
896    invalidate_access_token_cache();
897    invalidate_proactive_refresh_failure();
898    result
899}
900
901/// Seconds before the stored expiry at which [`access_token_refreshing`]
902/// proactively refreshes — absorbs clock skew plus a slow request. Public so
903/// the daemon's `load_or_refresh` shares the same threshold (#320).
904pub const REFRESH_SKEW_SECS: u64 = 120;
905
906/// Result of a [`refresh_grant`]. The gateway may omit a rotated refresh
907/// token (reuse the prior one) and/or an expiry, so both are optional.
908#[derive(Debug, Clone)]
909pub struct RefreshedTokens {
910    pub access_token: String,
911    pub refresh_token: Option<String>,
912    pub expires_in: Option<u64>,
913}
914
915/// `refresh_token` grant against `/connect/token`. Network-only — the
916/// caller persists. Mirrors the Parslee gateway contract used by the
917/// daemon's own refresh path (`car-server-core::parslee_auth`): the
918/// gateway treats this as a public-client grant, so no `client_id` is
919/// sent. This lives in `car-auth` (not `car-server-core`) so the
920/// request-time inference path — which cannot depend on `car-server-core`
921/// — shares one definition of "mint a fresh Parslee bearer" (#313).
922pub async fn refresh_grant(api_base: &str, refresh_token: &str) -> Result<RefreshedTokens, String> {
923    refresh_grant_with_timeout(api_base, refresh_token, PARSLEE_TOKEN_REQUEST_TIMEOUT).await
924}
925
926async fn refresh_grant_with_timeout(
927    api_base: &str,
928    refresh_token: &str,
929    request_timeout: Duration,
930) -> Result<RefreshedTokens, String> {
931    #[derive(Deserialize)]
932    struct Resp {
933        access_token: String,
934        #[serde(default)]
935        refresh_token: Option<String>,
936        #[serde(default)]
937        expires_in: Option<u64>,
938    }
939    let body = form_body(&[
940        ("grant_type", "refresh_token"),
941        ("refresh_token", refresh_token),
942    ]);
943    let token_url = format!("{}/connect/token", api_base.trim_end_matches('/'));
944    let (status, text) =
945        post_token_form_with_timeout(token_url, body, "refresh Parslee token", request_timeout)
946            .await?;
947    if !status.is_success() {
948        return Err(format!("refresh Parslee token: HTTP {status}: {text}"));
949    }
950    let r: Resp =
951        serde_json::from_str(&text).map_err(|e| format!("parse Parslee token response: {e}"))?;
952    Ok(RefreshedTokens {
953        access_token: r.access_token,
954        refresh_token: r.refresh_token,
955        expires_in: r.expires_in,
956    })
957}
958
959async fn active_state_for_network() -> Result<Option<ActiveCredentials>, String> {
960    with_locked_state(|coordinator| Ok(coordinator.read_snapshot()?.active)).await
961}
962
963fn credential_read_error(
964    kind: CredentialReadFailureKind,
965    message: impl Into<String>,
966) -> CredentialReadError {
967    CredentialReadError {
968        kind,
969        message: message.into(),
970    }
971}
972
973fn credential_read_error_from_state(error: state::AuthStateError) -> CredentialReadError {
974    let kind = match error {
975        state::AuthStateError::CoordinationDeadline(_) => CredentialReadFailureKind::TimedOut,
976        state::AuthStateError::Conflict(_)
977        | state::AuthStateError::Store(_)
978        | state::AuthStateError::Invalid(_) => CredentialReadFailureKind::Unreadable,
979    };
980    credential_read_error(kind, error.to_string())
981}
982
983/// An already-read V2 payload presented through the existing state decoder.
984/// This preserves every durable-state validation invariant without asking the
985/// physical secret store for the same record a second time.
986#[derive(Clone)]
987struct ReadOnceAuthStateStore(String);
988
989impl AuthStateStore for ReadOnceAuthStateStore {
990    fn read(&self, key: &str) -> Result<Option<String>, AuthStateError> {
991        if key != state::AUTH_STATE_V2_KEY {
992            return Err(AuthStateError::Store(format!(
993                "read-once credential snapshot cannot read {key}"
994            )));
995        }
996        Ok(Some(self.0.clone()))
997    }
998
999    fn publish(&self, _key: &str, _value: &str) -> Result<(), AuthStateError> {
1000        Err(AuthStateError::Store(
1001            "read-once credential snapshot cannot publish".into(),
1002        ))
1003    }
1004
1005    fn publish_recreating(&self, _key: &str, _value: &str) -> Result<(), AuthStateError> {
1006        Err(AuthStateError::Store(
1007            "read-once credential snapshot cannot recreate".into(),
1008        ))
1009    }
1010
1011    fn delete(&self, _key: &str) -> Result<(), AuthStateError> {
1012        Err(AuthStateError::Store(
1013            "read-once credential snapshot cannot delete".into(),
1014        ))
1015    }
1016}
1017
1018fn refresh_authority_hint_after_read(state: &AuthStateV2) {
1019    if let Err(error) = authority_hint::publish_for_state(state) {
1020        eprintln!(
1021            "car-auth: authoritative credential read succeeded but its passive hint could not be refreshed ({error})"
1022        );
1023        if let Err(degrade_error) = authority_hint::degrade_to_unknown() {
1024            eprintln!(
1025                "car-auth: credential authority hint could not be degraded after read ({degrade_error})"
1026            );
1027        }
1028    }
1029}
1030
1031/// Read one authoritative state snapshot while retaining Task 1's typed secret
1032/// failures. The common V2 path performs exactly one store read. A missing V2
1033/// record enters the existing locked legacy migration path so upgrades retain
1034/// their durability semantics.
1035async fn active_state_for_credential_resolution(
1036) -> Result<Option<ActiveCredentials>, CredentialReadError> {
1037    let _process_guard = lock_auth_state_queue(
1038        AUTH_STATE_MUTEX.get_or_init(|| tokio::sync::Mutex::new(())),
1039        AUTH_COORDINATOR_QUEUE_TIMEOUT,
1040    )
1041    .await
1042    .map_err(|error| {
1043        credential_read_error(CredentialReadFailureKind::TimedOut, error.to_string())
1044    })?;
1045
1046    tokio::task::spawn_blocking(move || {
1047        let _file_guard = ProcessAuthLock::acquire().map_err(credential_read_error_from_state)?;
1048        let reference = SecretRef::with_default_service(state::AUTH_STATE_V2_KEY);
1049        let state = match car_secrets::read_via_operator_broker_or_keychain(&reference) {
1050            Ok(raw) => StateCoordinator::new(ReadOnceAuthStateStore(raw))
1051                .read_published_snapshot()
1052                .map_err(credential_read_error_from_state)?
1053                .expect("the read-once store always contains its V2 payload"),
1054            Err(SecretError::NotFound { .. }) => StateCoordinator::new(SecretAuthStateStore)
1055                .read_snapshot()
1056                .map_err(credential_read_error_from_state)?,
1057            Err(error) => return Err(CredentialReadError::from(error)),
1058        };
1059        refresh_authority_hint_after_read(&state);
1060        Ok(state.active)
1061    })
1062    .await
1063    .map_err(|error| {
1064        credential_read_error(
1065            CredentialReadFailureKind::Unreadable,
1066            format!("Parslee credential worker failed: {error}"),
1067        )
1068    })?
1069}
1070
1071fn resolved_from_active(active: &ActiveCredentials) -> ResolvedParsleeCredential {
1072    ResolvedParsleeCredential {
1073        access_token: active.access_token.clone(),
1074        api_base: active.api_base.trim_end_matches('/').to_string(),
1075        expires_at: active.expires_at,
1076    }
1077}
1078
1079/// The independently owned flight's one complete resolution operation.
1080async fn resolve_credential_once(
1081    purpose: CredentialReadPurpose,
1082) -> Result<Option<ResolvedParsleeCredential>, CredentialReadError> {
1083    resolve_credential_once_with_clock(purpose, Instant::now).await
1084}
1085
1086/// Clock-injected refresh resolution keeps interval tests deterministic without
1087/// sleeping while production timestamps failures after the network call ends.
1088async fn resolve_credential_once_with_clock<N>(
1089    purpose: CredentialReadPurpose,
1090    now: N,
1091) -> Result<Option<ResolvedParsleeCredential>, CredentialReadError>
1092where
1093    N: Fn() -> Instant + Send + Sync,
1094{
1095    // A deliberate process injection is self-contained and never consults the
1096    // OS store. Pair it with an optional base override; otherwise use the
1097    // public default rather than prompting for unrelated persisted metadata.
1098    if let Ok(access_token) = std::env::var(PARSLEE_ACCESS_TOKEN_KEY) {
1099        if !access_token.is_empty() {
1100            if purpose == CredentialReadPurpose::ForceRefresh
1101                || matches!(
1102                    purpose,
1103                    CredentialReadPurpose::AutomaticRefresh(rejected)
1104                        if rejected == rejected_bearer_key(&access_token)
1105                )
1106            {
1107                return Ok(None);
1108            }
1109            let api_base = std::env::var(PARSLEE_API_BASE_KEY)
1110                .ok()
1111                .filter(|value| !value.trim().is_empty())
1112                .unwrap_or_else(|| DEFAULT_API_BASE.to_string())
1113                .trim_end_matches('/')
1114                .to_string();
1115            return Ok(Some(ResolvedParsleeCredential {
1116                access_token,
1117                api_base,
1118                expires_at: 0,
1119            }));
1120        }
1121    }
1122
1123    if purpose == CredentialReadPurpose::Resolve {
1124        if let Some(credential) = cached_credential() {
1125            return Ok(Some(credential));
1126        }
1127    }
1128
1129    let Some(current) = active_state_for_credential_resolution().await? else {
1130        return Ok(None);
1131    };
1132    let current_credential = resolved_from_active(&current);
1133    let automatic_refresh = match purpose {
1134        CredentialReadPurpose::AutomaticRefresh(rejected) => {
1135            if rejected != rejected_bearer_key(&current.access_token) {
1136                store_resolved_credential(&current_credential);
1137                return Ok(Some(current_credential));
1138            }
1139            true
1140        }
1141        _ => false,
1142    };
1143    let expiring =
1144        current.expires_at > 0 && epoch_seconds() + REFRESH_SKEW_SECS >= current.expires_at;
1145    if purpose != CredentialReadPurpose::ForceRefresh && !automatic_refresh && !expiring {
1146        store_resolved_credential(&current_credential);
1147        return Ok(Some(current_credential));
1148    }
1149
1150    let Some(refresh) = current.refresh_token.clone() else {
1151        if purpose == CredentialReadPurpose::ForceRefresh || automatic_refresh {
1152            eprintln!(
1153                "car-auth: reactive Parslee refresh: no refresh token stored — run `car auth login`"
1154            );
1155            return Ok(None);
1156        }
1157        return Ok(Some(current_credential));
1158    };
1159    if (purpose == CredentialReadPurpose::Resolve || automatic_refresh)
1160        && proactive_refresh_is_suppressed(&current, now())
1161    {
1162        return if automatic_refresh {
1163            Ok(None)
1164        } else {
1165            Ok(Some(current_credential))
1166        };
1167    }
1168
1169    let base = current.api_base.clone();
1170    let expected = refresh_cas(&current);
1171    match refresh_grant(&base, &refresh).await {
1172        Ok(tokens) => {
1173            let refreshed = ResolvedParsleeCredential {
1174                access_token: tokens.access_token.clone(),
1175                api_base: base.trim_end_matches('/').to_string(),
1176                expires_at: tokens
1177                    .expires_in
1178                    .map(|seconds| epoch_seconds().saturating_add(seconds))
1179                    .unwrap_or(0),
1180            };
1181            match commit_refreshed_credentials(expected, base, tokens, false).await {
1182                Ok(CasOutcome::Committed) => {
1183                    clear_proactive_refresh_failure(&current);
1184                    store_resolved_credential(&refreshed);
1185                    Ok(Some(refreshed))
1186                }
1187                Ok(CasOutcome::Conflict) => {
1188                    clear_proactive_refresh_failure(&current);
1189                    let active = active_state_for_credential_resolution().await?;
1190                    let credential = active.as_ref().map(resolved_from_active);
1191                    if let Some(credential) = &credential {
1192                        store_resolved_credential(credential);
1193                    }
1194                    Ok(credential)
1195                }
1196                Err(error) => {
1197                    if purpose == CredentialReadPurpose::ForceRefresh || automatic_refresh {
1198                        if automatic_refresh {
1199                            record_proactive_refresh_failure(&current, now());
1200                        }
1201                        Err(credential_read_error(
1202                            CredentialReadFailureKind::Unreadable,
1203                            format!("reactive Parslee refresh commit failed: {error}"),
1204                        ))
1205                    } else {
1206                        record_proactive_refresh_failure(&current, now());
1207                        eprintln!(
1208                            "car-auth: refreshed Parslee token could not be committed; using current token ({error})"
1209                        );
1210                        Ok(Some(current_credential))
1211                    }
1212                }
1213            }
1214        }
1215        Err(error) => {
1216            if purpose == CredentialReadPurpose::ForceRefresh || automatic_refresh {
1217                if automatic_refresh {
1218                    record_proactive_refresh_failure(&current, now());
1219                }
1220                eprintln!(
1221                    "car-auth: reactive Parslee token refresh failed (401 will surface) — re-run `car auth login` ({error})"
1222                );
1223                Ok(None)
1224            } else {
1225                record_proactive_refresh_failure(&current, now());
1226                eprintln!(
1227                    "car-auth: proactive Parslee token refresh failed; using stored token (it may 401 — re-run `car auth login`) ({error})"
1228                );
1229                Ok(Some(current_credential))
1230            }
1231        }
1232    }
1233}
1234
1235/// Automatically refresh after Parslee rejects one exact bearer.
1236///
1237/// Unlike [`refresh_credential`] (the unconditional primitive retained for
1238/// explicit user- or caller-initiated refreshes), this automatic retry honors
1239/// and arms the same per-credential interval as proactive refresh. The rejected
1240/// bearer is part of the coordinator flight identity: a call arriving after an
1241/// account switch returns the current credential without refreshing, and a
1242/// waiter cannot accept or attribute another credential's failed flight.
1243pub fn refresh_credential_after_rejection(
1244    rejected_bearer: &str,
1245) -> impl std::future::Future<Output = Result<Option<ResolvedParsleeCredential>, CredentialReadError>>
1246       + Send
1247       + 'static {
1248    credential_read::refresh_credential_after_rejection(rejected_bearer_key(rejected_bearer))
1249}
1250
1251#[cfg(test)]
1252async fn refresh_credential_after_rejection_with_clock<N>(
1253    rejected_bearer: &str,
1254    now: N,
1255) -> Result<Option<ResolvedParsleeCredential>, CredentialReadError>
1256where
1257    N: Fn() -> Instant + Send + Sync,
1258{
1259    resolve_credential_once_with_clock(
1260        CredentialReadPurpose::AutomaticRefresh(rejected_bearer_key(rejected_bearer)),
1261        now,
1262    )
1263    .await
1264}
1265
1266/// The compare half of the refresh CAS, taken from the credential the caller
1267/// read before going to the network.
1268fn refresh_cas(current: &ActiveCredentials) -> RefreshCas {
1269    RefreshCas {
1270        account_id: current.account_id.clone(),
1271        access_token: current.access_token.clone(),
1272        refresh_token: current.refresh_token.clone(),
1273    }
1274}
1275
1276async fn commit_refreshed_credentials(
1277    expected: RefreshCas,
1278    api_base: String,
1279    tokens: RefreshedTokens,
1280    generation_change: bool,
1281) -> Result<CasOutcome, String> {
1282    let refreshed = RefreshedCredentials {
1283        access_token: tokens.access_token,
1284        refresh_token: tokens.refresh_token,
1285        expires_at: tokens
1286            .expires_in
1287            .map(|seconds| epoch_seconds().saturating_add(seconds)),
1288        api_base,
1289        generation_change,
1290    };
1291    let outcome =
1292        with_locked_state(move |coordinator| coordinator.commit_refresh(&expected, refreshed))
1293            .await;
1294    // The active credential just changed (or lost a CAS race to someone who
1295    // changed it); either way the cached bearer is stale.
1296    invalidate_access_token_cache();
1297    outcome
1298}
1299
1300/// Why there is no usable Parslee access token — for ERROR MESSAGES, not for
1301/// control flow.
1302///
1303/// [`access_token_refreshing`] returns a bare `Option`, so every failure renders
1304/// as "no credential … run `car auth login`". That reads as *never
1305/// authenticated*, and the three states below need different remedies: a token
1306/// that aged out mid-run is not the same problem as a signed-out account, and
1307/// neither is a keychain that momentarily could not be read. A long job dying
1308/// on the first with the message for the second is Parslee-ai/car#797.
1309///
1310/// Consulted only on the failure path, so the extra store read costs nothing in
1311/// the hot path.
1312#[derive(Debug, Clone, PartialEq, Eq)]
1313pub enum CredentialState {
1314    /// Credentials exist and the access token is not past expiry.
1315    Active,
1316    /// Credentials exist but the access token is expired (or within the refresh
1317    /// skew) and refresh did not yield a new one — commonly because the refresh
1318    /// token itself is spent, or the network refused.
1319    Expired { expires_at: u64 },
1320    /// A published tombstone: no account is active. This is the only state that
1321    /// genuinely means "log in".
1322    SignedOut,
1323    /// The credential store could not be read at all (locked keychain, helper
1324    /// timeout). Says nothing about whether credentials exist.
1325    Unreadable(String),
1326}
1327
1328/// Seconds of life left in the active access token, for callers that want to
1329/// warn *before* a long operation dies rather than diagnose it afterwards.
1330///
1331/// `None` means there is nothing to warn about, for any of three different
1332/// reasons deliberately collapsed here: no active session, no stored expiry, or
1333/// a `PARSLEE_ACCESS_TOKEN` override (which CAR never refreshes and whose
1334/// lifetime it does not know). Callers wanting to distinguish those want
1335/// [`credential_state`] instead — this answers only "how long have I got".
1336///
1337/// `Some(0)` means already past expiry. Note that a token inside
1338/// [`REFRESH_SKEW_SECS`] is normally refreshed transparently on use, so a small
1339/// number here is not by itself a failure — it is a reason to expect a refresh,
1340/// and a reason to care whether that refresh can succeed. What killed the
1341/// multi-hour sweep in Parslee-ai/car#797 was the refresh failing, with the job
1342/// already hours in and no earlier signal that the deadline was coming.
1343pub async fn access_token_lifetime_remaining() -> Option<u64> {
1344    if std::env::var(PARSLEE_ACCESS_TOKEN_KEY).is_ok_and(|tok| !tok.is_empty()) {
1345        return None;
1346    }
1347    let current = active_state_for_network().await.ok()??;
1348    if current.expires_at == 0 {
1349        return None;
1350    }
1351    Some(current.expires_at.saturating_sub(epoch_seconds()))
1352}
1353
1354/// Classify the current credential state. See [`CredentialState`].
1355pub async fn credential_state() -> CredentialState {
1356    if let Ok(tok) = std::env::var(PARSLEE_ACCESS_TOKEN_KEY) {
1357        if !tok.is_empty() {
1358            return CredentialState::Active;
1359        }
1360    }
1361    match active_state_for_network().await {
1362        Ok(Some(current)) => {
1363            let expiring =
1364                current.expires_at > 0 && epoch_seconds() + REFRESH_SKEW_SECS >= current.expires_at;
1365            if expiring {
1366                CredentialState::Expired {
1367                    expires_at: current.expires_at,
1368                }
1369            } else {
1370                CredentialState::Active
1371            }
1372        }
1373        Ok(None) => CredentialState::SignedOut,
1374        Err(e) => CredentialState::Unreadable(e),
1375    }
1376}
1377
1378/// Compatibility token-only view of [`resolve_credential`].
1379///
1380/// The underlying resolution proactively refreshes inside
1381/// [`REFRESH_SKEW_SECS`], coalesces concurrent callers into one physical
1382/// credential read, and honors the `PARSLEE_ACCESS_TOKEN` process override.
1383/// New request-time consumers that also need the API base or expiry should use
1384/// [`resolve_credential`] so all authority fields come from one snapshot.
1385pub async fn access_token_refreshing() -> Option<String> {
1386    resolve_credential(CredentialReadMode::Use)
1387        .await
1388        .ok()
1389        .flatten()
1390        .map(|credential| credential.access_token)
1391}
1392
1393/// Unconditionally refresh the Parslee bearer for an explicit caller request.
1394/// [`access_token_refreshing`] only refreshes inside a proactive window keyed
1395/// on the stored expiry, while automatic 401/403 recovery uses
1396/// [`refresh_credential_after_rejection`] so repeated failures are gated and a
1397/// stale request cannot refresh a newly selected account. User-requested
1398/// session checks retain this unconditional primitive (#313).
1399///
1400/// Returns the new access token, or `None` when there is no refresh token
1401/// to use or the refresh itself fails. The `PARSLEE_ACCESS_TOKEN` env
1402/// override is authoritative and never refreshed (returns `None` so the
1403/// caller keeps using the injected token).
1404pub async fn force_refresh() -> Option<String> {
1405    refresh_credential()
1406        .await
1407        .ok()
1408        .flatten()
1409        .map(|credential| credential.access_token)
1410}
1411
1412/// Resolve the API base: explicit override → process environment →
1413/// authoritative V2 record → default.
1414///
1415/// The legacy keychain slot is import-only and is never consulted here.
1416pub fn api_base(override_: Option<&str>) -> String {
1417    override_
1418        .map(str::to_string)
1419        .or_else(|| {
1420            std::env::var(PARSLEE_API_BASE_KEY)
1421                .ok()
1422                .filter(|value| !value.trim().is_empty())
1423        })
1424        .or_else(|| {
1425            read_published_state_without_migration()
1426                .ok()
1427                .flatten()
1428                .and_then(|state| state.active.map(|active| active.api_base))
1429        })
1430        .unwrap_or_else(|| DEFAULT_API_BASE.to_string())
1431        .trim_end_matches('/')
1432        .to_string()
1433}
1434
1435/// Fetch the Parslee session JSON for the stored token. Returns the
1436/// raw response body (the caller renders it). `Ok(None)` = not signed in.
1437pub async fn fetch_status(api_base_override: Option<&str>) -> Result<Option<String>, String> {
1438    // Access tokens are short-lived (~15 min). Reading the stored one raw made
1439    // `car auth status` report a stale "not authenticated" / HTTP 401 for a
1440    // login that is perfectly healthy and one refresh away — the CLI said
1441    // signed-out while the daemon, which does refresh, showed an active org.
1442    // Status is a QUESTION about the session, so it should answer with the
1443    // session's real state rather than whatever happened to be cached.
1444    let Some(access) = access_token_refreshing().await else {
1445        return Ok(None);
1446    };
1447    let base = api_base(api_base_override);
1448    let url = format!("{}/connect/session", base.trim_end_matches('/'));
1449    let client = reqwest::Client::builder()
1450        .timeout(PARSLEE_STATUS_REQUEST_TIMEOUT)
1451        .build()
1452        .map_err(|error| format!("build Parslee session client: {error}"))?;
1453
1454    let mut response = client
1455        .get(&url)
1456        .bearer_auth(&access)
1457        .send()
1458        .await
1459        .map_err(|e| format!("fetch Parslee session: {e}"))?;
1460
1461    // The proactive refresh above goes on expiry math; a token can still be
1462    // rejected (revoked, rotated, clock skew). One reactive refresh + retry,
1463    // matching the inference and Studio paths.
1464    if response.status() == reqwest::StatusCode::UNAUTHORIZED {
1465        if let Some(fresh) = force_refresh().await {
1466            response = client
1467                .get(&url)
1468                .bearer_auth(&fresh)
1469                .send()
1470                .await
1471                .map_err(|e| format!("fetch Parslee session: {e}"))?;
1472        }
1473    }
1474
1475    let status = response.status();
1476    let text = response
1477        .text()
1478        .await
1479        .map_err(|e| format!("read Parslee session response: {e}"))?;
1480    if !status.is_success() {
1481        return Err(format!(
1482            "Parslee session check failed: HTTP {status}: {text}"
1483        ));
1484    }
1485    Ok(Some(text))
1486}
1487
1488/// Fetch `/connect/session` with an explicitly supplied access token.
1489///
1490/// This is intentionally non-refreshing and non-persisting. Browser completion
1491/// uses it before touching the active credential slots so the account identity
1492/// is known before the mutation begins.
1493pub async fn fetch_status_with_access(
1494    api_base: &str,
1495    access_token: &str,
1496) -> Result<String, String> {
1497    fetch_status_with_access_timeout(api_base, access_token, PARSLEE_STATUS_REQUEST_TIMEOUT).await
1498}
1499
1500async fn fetch_status_with_access_timeout(
1501    api_base: &str,
1502    access_token: &str,
1503    request_timeout: Duration,
1504) -> Result<String, String> {
1505    let url = format!("{}/connect/session", api_base.trim_end_matches('/'));
1506    let client = reqwest::Client::builder()
1507        .timeout(request_timeout)
1508        .build()
1509        .map_err(|e| format!("build Parslee session client: {e}"))?;
1510    let response = client
1511        .get(url)
1512        .bearer_auth(access_token)
1513        .send()
1514        .await
1515        .map_err(|e| {
1516            if e.is_timeout() {
1517                format!(
1518                    "fetch Parslee session timed out after {}ms",
1519                    request_timeout.as_millis()
1520                )
1521            } else {
1522                format!("fetch Parslee session: {e}")
1523            }
1524        })?;
1525    let status = response.status();
1526    let text = response.text().await.map_err(|e| {
1527        if e.is_timeout() {
1528            format!(
1529                "read Parslee session response timed out after {}ms",
1530                request_timeout.as_millis()
1531            )
1532        } else {
1533            format!("read Parslee session response: {e}")
1534        }
1535    })?;
1536    if !status.is_success() {
1537        return Err(format!(
1538            "Parslee session check failed: HTTP {status}: {text}"
1539        ));
1540    }
1541    Ok(text)
1542}
1543
1544/// Set the account's active organization (bearer `PUT /accounts/me/active-org`).
1545///
1546/// This changes the account-level `active_org_id` PREFERENCE server-side and
1547/// validates membership. It does NOT re-scope the currently-stored access
1548/// token — the token's `active_org` claim (what inference reads) is fixed at
1549/// mint time, so a caller who wants the switch to take effect for inference
1550/// must re-authorize afterward to mint a token bound to the new org. Returns
1551/// the raw `AccountResponse` body on success.
1552pub async fn set_active_org(
1553    api_base_override: Option<&str>,
1554    organization_id: &str,
1555) -> Result<String, String> {
1556    let Some(access) = access_token_refreshing().await else {
1557        return Err("not signed in".to_string());
1558    };
1559    let base = api_base(api_base_override);
1560    // reqwest is built without the `json` feature, so serialize by hand.
1561    set_active_org_with_access(&base, &access, organization_id).await
1562}
1563
1564async fn set_active_org_with_access(
1565    base: &str,
1566    access_token: &str,
1567    organization_id: &str,
1568) -> Result<String, String> {
1569    let body = serde_json::json!({ "organizationId": organization_id }).to_string();
1570    let response = reqwest::Client::builder()
1571        .timeout(PARSLEE_STATUS_REQUEST_TIMEOUT)
1572        .build()
1573        .map_err(|error| format!("build set-active-org client: {error}"))?
1574        .put(format!(
1575            "{}/api/v1/accounts/me/active-org",
1576            base.trim_end_matches('/')
1577        ))
1578        .bearer_auth(access_token)
1579        .header("content-type", "application/json")
1580        .body(body)
1581        .send()
1582        .await
1583        .map_err(|e| format!("set active org: {e}"))?;
1584    let status = response.status();
1585    let text = response
1586        .text()
1587        .await
1588        .map_err(|e| format!("read set-active-org response: {e}"))?;
1589    if !status.is_success() {
1590        return Err(format!("set active org failed: HTTP {status}: {text}"));
1591    }
1592    Ok(text)
1593}
1594
1595/// Switch the active organization **silently** by minting a fresh token
1596/// scoped to `org_id` via the refresh grant's `organization_id` override
1597/// (`/connect/token`, `grant_type=refresh_token`). The backend validates
1598/// membership and stamps `active_org=org_id` on the new access token — which
1599/// is what inference reads — so the switch takes effect without a browser
1600/// re-authorization. Rotated tokens are persisted to the keychain. Also
1601/// best-effort updates the account's default org so a future fresh sign-in
1602/// lands in the same place.
1603pub async fn switch_org(api_base_override: Option<&str>, org_id: &str) -> Result<(), String> {
1604    #[derive(Deserialize)]
1605    struct Resp {
1606        access_token: String,
1607        #[serde(default)]
1608        refresh_token: Option<String>,
1609        #[serde(default)]
1610        expires_in: Option<u64>,
1611    }
1612    let current = active_state_for_network()
1613        .await?
1614        .ok_or_else(|| "not signed in".to_string())?;
1615    let Some(refresh) = current.refresh_token.clone() else {
1616        return Err("not signed in".to_string());
1617    };
1618    let expected = refresh_cas(&current);
1619    let base = api_base_override
1620        .map(|value| value.trim_end_matches('/').to_string())
1621        .unwrap_or_else(|| current.api_base.clone());
1622    let body = form_body(&[
1623        ("grant_type", "refresh_token"),
1624        ("refresh_token", &refresh),
1625        ("organization_id", org_id),
1626    ]);
1627    let (status, text) = post_token_form_with_timeout(
1628        format!("{}/connect/token", base.trim_end_matches('/')),
1629        body,
1630        "switch Parslee organization token",
1631        PARSLEE_TOKEN_REQUEST_TIMEOUT,
1632    )
1633    .await?;
1634    if !status.is_success() {
1635        return Err(format!("switch org failed: HTTP {status}: {text}"));
1636    }
1637    let r: Resp =
1638        serde_json::from_str(&text).map_err(|e| format!("parse switch-org response: {e}"))?;
1639    let access_token = r.access_token.clone();
1640    let outcome = commit_refreshed_credentials(
1641        expected,
1642        base.clone(),
1643        RefreshedTokens {
1644            access_token: r.access_token,
1645            refresh_token: r.refresh_token,
1646            expires_in: r.expires_in,
1647        },
1648        true,
1649    )
1650    .await?;
1651    if outcome == CasOutcome::Conflict {
1652        return Err(
1653            "Parslee credentials changed while switching organizations; retry the switch".into(),
1654        );
1655    }
1656    // Keep the account's default org in sync (best-effort; the token is
1657    // already switched regardless of this call's outcome).
1658    let _ = set_active_org_with_access(&base, &access_token, org_id).await;
1659    Ok(())
1660}
1661
1662/// Non-secret metadata for one signed-in Parslee login.
1663#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1664pub struct AccountMeta {
1665    pub id: String,
1666    #[serde(default)]
1667    pub email: Option<String>,
1668    #[serde(default)]
1669    pub name: Option<String>,
1670    /// True for the login whose tokens are currently in the active slots.
1671    #[serde(default)]
1672    pub active: bool,
1673}
1674
1675struct SessionIdentity {
1676    id: String,
1677    email: Option<String>,
1678    name: Option<String>,
1679}
1680
1681fn session_identity(session: &str) -> Result<SessionIdentity, String> {
1682    let value: serde_json::Value =
1683        serde_json::from_str(session).map_err(|error| format!("parse session: {error}"))?;
1684    let account = value
1685        .get("Account")
1686        .or_else(|| value.get("account"))
1687        .ok_or_else(|| "session has no account".to_string())?;
1688    let field = |pascal: &str, camel: &str| {
1689        account
1690            .get(pascal)
1691            .or_else(|| account.get(camel))
1692            .and_then(serde_json::Value::as_str)
1693            .map(str::trim)
1694            .filter(|value| !value.is_empty())
1695            .map(str::to_string)
1696    };
1697    Ok(SessionIdentity {
1698        id: field("Id", "id").ok_or_else(|| "session has no account id".to_string())?,
1699        email: field("Email", "email"),
1700        name: field("Name", "name").or_else(|| field("DisplayName", "displayName")),
1701    })
1702}
1703
1704/// Parse the stable account id from a `/connect/session` response.
1705pub fn account_id_from_session(session: &str) -> Result<String, String> {
1706    session_identity(session).map(|identity| identity.id)
1707}
1708
1709/// Local pre-browser auth state. No network request or refresh occurs. The first
1710/// read may import an attributable legacy session into the authoritative V2
1711/// record; an ambiguous legacy marker reports signed-out and its orphan token is
1712/// discarded, so the caller can sign in again.
1713pub async fn local_auth_snapshot() -> Result<LocalAuthSnapshot, String> {
1714    let env_override_active = std::env::var(PARSLEE_ACCESS_TOKEN_KEY)
1715        .map(|value| !value.is_empty())
1716        .unwrap_or(false);
1717    if env_override_active {
1718        return Ok(LocalAuthSnapshot {
1719            authenticated: true,
1720            active_account_id: None,
1721        });
1722    }
1723    with_locked_state(|coordinator| {
1724        let state = coordinator.read_snapshot()?;
1725        Ok(LocalAuthSnapshot {
1726            authenticated: state.active.is_some(),
1727            active_account_id: state.active.map(|active| active.account_id),
1728        })
1729    })
1730    .await
1731}
1732
1733/// List every known login (`active` marks the current one). Migrates a
1734/// pre-multi-login session (tokens in the fixed slots, no registry entry) in.
1735pub async fn list_accounts(_api_base_override: Option<&str>) -> Result<Vec<AccountMeta>, String> {
1736    with_locked_state(|coordinator| Ok(coordinator.read_snapshot()?.account_meta())).await
1737}
1738
1739/// Switch the active login by publishing the selected account credential as
1740/// part of the same V2 record.
1741pub async fn switch_account(account_id: &str) -> Result<(), String> {
1742    let account_id = account_id.to_string();
1743    let result =
1744        with_locked_state(move |coordinator| coordinator.switch_account(&account_id).map(|_| ()))
1745            .await;
1746    invalidate_access_token_cache();
1747    invalidate_proactive_refresh_failure();
1748    result
1749}
1750
1751/// Remove a login (deletes its stashed tokens). If it was active, switch to
1752/// another remaining login, or clear the session when none remain.
1753pub async fn remove_account(account_id: &str) -> Result<Vec<AccountMeta>, String> {
1754    let account_id = account_id.to_string();
1755    let result = with_locked_state(move |coordinator| {
1756        Ok(coordinator.remove_account(&account_id)?.account_meta())
1757    })
1758    .await;
1759    invalidate_access_token_cache();
1760    invalidate_proactive_refresh_failure();
1761    result
1762}
1763
1764// First-login onboarding is intentionally NOT here. Brand-new users
1765// are routed through Parslee's existing hosted web consent/org page
1766// during the `/connect/authorize` browser hand-off (see m365dotnet
1767// `specs/draft/car-inference-gateway-auth.md` B6), so the token CAR
1768// redeems already carries `active_org`. CAR is a pure OAuth client and
1769// never touches consent — there is no `ensure_org`, by design.
1770
1771#[cfg(test)]
1772mod tests {
1773    use super::*;
1774    use std::ffi::OsString;
1775
1776    static AUTH_ENV_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
1777
1778    struct RestoredEnv {
1779        values: Vec<(&'static str, Option<OsString>)>,
1780    }
1781
1782    impl RestoredEnv {
1783        fn capture(keys: &[&'static str]) -> Self {
1784            Self {
1785                values: keys
1786                    .iter()
1787                    .map(|key| (*key, std::env::var_os(key)))
1788                    .collect(),
1789            }
1790        }
1791    }
1792
1793    impl Drop for RestoredEnv {
1794        fn drop(&mut self) {
1795            for (key, value) in self.values.drain(..) {
1796                match value {
1797                    Some(value) => std::env::set_var(key, value),
1798                    None => std::env::remove_var(key),
1799                }
1800            }
1801        }
1802    }
1803
1804    #[tokio::test]
1805    async fn auth_env_lock_survives_result_receiver_drop_until_owner_finishes() {
1806        let (holder_acquired_tx, holder_acquired_rx) = tokio::sync::oneshot::channel();
1807        let (release_tx, release_rx) = tokio::sync::oneshot::channel::<()>();
1808        let (owner_result_tx, owner_result_rx) = tokio::sync::oneshot::channel();
1809        let holder = tokio::spawn(async move {
1810            let _guard = AUTH_ENV_LOCK.lock().await;
1811            let _ = holder_acquired_tx.send(());
1812            let _ = release_rx.await;
1813            let _ = owner_result_tx.send(());
1814        });
1815        holder_acquired_rx.await.unwrap();
1816        drop(owner_result_rx);
1817
1818        let (contender_started_tx, contender_started_rx) = tokio::sync::oneshot::channel();
1819        let (contender_acquired_tx, mut contender_acquired_rx) = tokio::sync::oneshot::channel();
1820        let contender = tokio::spawn(async move {
1821            let _ = contender_started_tx.send(());
1822            let _guard = AUTH_ENV_LOCK.lock().await;
1823            let _ = contender_acquired_tx.send(());
1824        });
1825        contender_started_rx.await.unwrap();
1826
1827        assert!(
1828            tokio::time::timeout(
1829                std::time::Duration::from_millis(50),
1830                &mut contender_acquired_rx,
1831            )
1832            .await
1833            .is_err(),
1834            "a contender must not enter while the first future owns the environment lock"
1835        );
1836
1837        drop(release_tx);
1838        holder.await.unwrap();
1839        contender_acquired_rx.await.unwrap();
1840        contender.await.unwrap();
1841    }
1842
1843    #[test]
1844    fn local_auth_snapshot_omits_an_unattributable_active_account() {
1845        let snapshot = LocalAuthSnapshot {
1846            authenticated: true,
1847            active_account_id: None,
1848        };
1849
1850        assert_eq!(
1851            serde_json::to_value(snapshot).unwrap(),
1852            serde_json::json!({ "authenticated": true })
1853        );
1854    }
1855
1856    #[tokio::test]
1857    async fn coordinator_queue_wait_has_an_enforced_deadline() {
1858        let mutex = tokio::sync::Mutex::new(());
1859        let _held = mutex.lock().await;
1860        let timeout = Duration::from_millis(10);
1861        let error = lock_auth_state_queue(&mutex, timeout)
1862            .await
1863            .expect_err("a contended coordinator queue must fail at its own bound");
1864        assert!(
1865            matches!(error, AuthOperationError::CoordinationDeadline(_)),
1866            "bounded contention must stay typed as retryable: {error:?}"
1867        );
1868        let message = error.to_string();
1869        assert!(
1870            message.contains("in-process Parslee credential coordinator")
1871                && message.contains("10ms"),
1872            "{message}"
1873        );
1874    }
1875
1876    #[test]
1877    fn worker_lease_exceeds_the_serial_redemption_budget() {
1878        let composed_serial_budget = AUTH_STATE_OPERATION_BUDGET
1879            + AUTH_COMPLETION_NETWORK_DEADLINE
1880            + AUTH_COORDINATOR_QUEUE_TIMEOUT
1881            + AUTH_PROCESS_LOCK_TIMEOUT
1882            + AUTH_STATE_OPERATION_BUDGET;
1883
1884        assert_eq!(
1885            LOGIN_ATTEMPT_WORKER_SERIAL_BUDGET, composed_serial_budget,
1886            "serial redemption budget must compose every bounded phase exactly once"
1887        );
1888        assert!(
1889            LOGIN_ATTEMPT_WORKER_SCHEDULING_MARGIN > Duration::ZERO,
1890            "worker lease requires explicit positive scheduling margin"
1891        );
1892        assert_eq!(
1893            LOGIN_ATTEMPT_WORKER_TTL,
1894            LOGIN_ATTEMPT_WORKER_SERIAL_BUDGET + LOGIN_ATTEMPT_WORKER_SCHEDULING_MARGIN,
1895            "worker lease must be derived from the complete serial budget plus margin"
1896        );
1897    }
1898
1899    #[test]
1900    fn local_auth_snapshot_serializes_an_attributable_active_account() {
1901        let snapshot = LocalAuthSnapshot {
1902            authenticated: true,
1903            active_account_id: Some("account-1".to_string()),
1904        };
1905
1906        assert_eq!(
1907            serde_json::to_value(snapshot).unwrap(),
1908            serde_json::json!({
1909                "authenticated": true,
1910                "active_account_id": "account-1",
1911            })
1912        );
1913    }
1914
1915    #[test]
1916    fn pkce_challenge_is_s256_urlsafe_nopad() {
1917        let v = pkce_verifier();
1918        let c = pkce_challenge(&v);
1919        assert!(!c.contains('=') && !c.contains('+') && !c.contains('/'));
1920        assert_eq!(c, pkce_challenge(&v)); // deterministic
1921    }
1922
1923    #[test]
1924    fn authorize_url_has_pkce_and_provider() {
1925        let u = authorize_url(
1926            "https://api.parslee.ai/",
1927            "parslee-car",
1928            "http://localhost:8765/auth/callback",
1929            "st8",
1930            "chal",
1931            Some("microsoft"),
1932            Some("select_account"),
1933        )
1934        .unwrap();
1935        assert!(u.starts_with("https://api.parslee.ai/connect/authorize?"));
1936        assert!(u.contains("code_challenge=chal"));
1937        assert!(u.contains("code_challenge_method=S256"));
1938        assert!(u.contains("client_id=parslee-car"));
1939        assert!(u.contains("provider=microsoft"));
1940        assert!(u.contains("prompt=select_account"));
1941    }
1942
1943    #[test]
1944    fn api_base_precedence() {
1945        assert_eq!(api_base(Some("https://x.test/")), "https://x.test");
1946    }
1947
1948    #[test]
1949    fn api_base_environment_override_beats_persisted_state() {
1950        let _env_lock = AUTH_ENV_LOCK.blocking_lock();
1951        let _restore = RestoredEnv::capture(&["CAR_SECRETS_FILE_DIR", PARSLEE_API_BASE_KEY]);
1952        let directory = tempfile::tempdir().unwrap();
1953        std::env::set_var("CAR_SECRETS_FILE_DIR", directory.path());
1954        std::env::set_var(PARSLEE_API_BASE_KEY, "https://env.example/");
1955        SecretStore::new()
1956            .publish(
1957                &SecretRef::with_default_service(car_secrets::PARSLEE_AUTH_STATE_V2_KEY),
1958                &serde_json::json!({
1959                    "schema": 2,
1960                    "revision": 7,
1961                    "generation": 3,
1962                    "active": {
1963                        "account_id": "account-v2",
1964                        "access_token": "v2-access",
1965                        "expires_at": 9_999_999_999_u64,
1966                        "api_base": "https://persisted.example"
1967                    },
1968                    "accounts": [{
1969                        "account_id": "account-v2",
1970                        "access_token": "v2-access",
1971                        "expires_at": 9_999_999_999_u64,
1972                        "api_base": "https://persisted.example"
1973                    }]
1974                })
1975                .to_string(),
1976            )
1977            .unwrap();
1978
1979        assert_eq!(api_base(None), "https://env.example");
1980    }
1981
1982    /// The cache must never suppress the proactive refresh. A token inside
1983    /// `REFRESH_SKEW_SECS` of expiry has to fall through to the refresh path,
1984    /// or a long run keeps presenting a bearer the server is about to reject —
1985    /// which is how a mid-run token expiry fabricates losses (fix #6 in
1986    /// docs/coder-ab-results.md).
1987    #[test]
1988    fn cache_does_not_serve_a_token_that_is_due_for_refresh() {
1989        invalidate_access_token_cache();
1990        let nearly_expired = epoch_seconds() + REFRESH_SKEW_SECS / 2;
1991        store_resolved_credential(&ResolvedParsleeCredential {
1992            access_token: "about-to-expire".into(),
1993            api_base: DEFAULT_API_BASE.into(),
1994            expires_at: nearly_expired,
1995        });
1996        assert_eq!(
1997            cached_credential(),
1998            None,
1999            "a token inside the refresh skew must not be served from cache"
2000        );
2001
2002        invalidate_access_token_cache();
2003        let expected = ResolvedParsleeCredential {
2004            access_token: "good-for-hours".into(),
2005            api_base: "https://staging-api.parslee.test".into(),
2006            expires_at: epoch_seconds() + 3_600,
2007        };
2008        store_resolved_credential(&expected);
2009        assert_eq!(cached_credential(), Some(expected));
2010    }
2011
2012    /// A record with no stored expiry (`expires_at == 0`) is still cacheable —
2013    /// the refresh path treats 0 as "not expiring", so the cache must agree
2014    /// rather than falling through on every call and defeating itself.
2015    #[test]
2016    fn cache_serves_a_token_with_no_recorded_expiry() {
2017        invalidate_access_token_cache();
2018        let expected = ResolvedParsleeCredential {
2019            access_token: "no-expiry".into(),
2020            api_base: DEFAULT_API_BASE.into(),
2021            expires_at: 0,
2022        };
2023        store_resolved_credential(&expected);
2024        assert_eq!(cached_credential(), Some(expected));
2025    }
2026
2027    /// Signing out must drop the cached bearer immediately rather than leaving
2028    /// this process to serve it until the TTL lapses.
2029    #[test]
2030    fn invalidate_clears_a_cached_token() {
2031        invalidate_access_token_cache();
2032        store_resolved_credential(&ResolvedParsleeCredential {
2033            access_token: "live".into(),
2034            api_base: DEFAULT_API_BASE.into(),
2035            expires_at: epoch_seconds() + 3_600,
2036        });
2037        assert!(cached_credential().is_some());
2038        invalidate_access_token_cache();
2039        assert_eq!(
2040            cached_credential(),
2041            None,
2042            "logout / switch / refresh must not leave a stale bearer readable"
2043        );
2044    }
2045
2046    #[test]
2047    fn normal_readers_never_fall_back_to_conflicting_legacy_slots() {
2048        let _env_lock = AUTH_ENV_LOCK.blocking_lock();
2049        let _restore = RestoredEnv::capture(&[
2050            "CAR_SECRETS_FILE_DIR",
2051            PARSLEE_ACCESS_TOKEN_KEY,
2052            PARSLEE_API_BASE_KEY,
2053        ]);
2054        let directory = tempfile::tempdir().unwrap();
2055        std::env::set_var("CAR_SECRETS_FILE_DIR", directory.path());
2056        std::env::remove_var(PARSLEE_ACCESS_TOKEN_KEY);
2057        std::env::remove_var(PARSLEE_API_BASE_KEY);
2058
2059        let store = SecretStore::new();
2060        store
2061            .put(
2062                &SecretRef::with_default_service(PARSLEE_ACCESS_TOKEN_KEY),
2063                "legacy-access",
2064            )
2065            .unwrap();
2066        store
2067            .put(
2068                &SecretRef::with_default_service(PARSLEE_API_BASE_KEY),
2069                "https://legacy.example",
2070            )
2071            .unwrap();
2072        let state_ref = SecretRef::with_default_service(car_secrets::PARSLEE_AUTH_STATE_V2_KEY);
2073        assert!(
2074            access_token_is_available(),
2075            "a legacy token may enter the locked request-time migration path only before V2 exists"
2076        );
2077
2078        store
2079            .publish(
2080                &state_ref,
2081                &serde_json::json!({
2082                    "schema": 2,
2083                    "revision": 7,
2084                    "generation": 3,
2085                    "active": {
2086                        "account_id": "account-v2",
2087                        "access_token": "v2-access",
2088                        "refresh_token": "v2-refresh",
2089                        "expires_at": 9_999_999_999_u64,
2090                        "api_base": "https://v2.example"
2091                    },
2092                    "accounts": [{
2093                        "account_id": "account-v2",
2094                        "access_token": "v2-access",
2095                        "refresh_token": "v2-refresh",
2096                        "expires_at": 9_999_999_999_u64,
2097                        "api_base": "https://v2.example"
2098                    }],
2099                    "tombstone": false
2100                })
2101                .to_string(),
2102            )
2103            .unwrap();
2104        assert_eq!(access_token().as_deref(), Some("v2-access"));
2105        assert!(access_token_is_available());
2106        assert_eq!(api_base(None), "https://v2.example");
2107
2108        store
2109            .publish(
2110                &state_ref,
2111                r#"{"schema":2,"revision":8,"generation":4,"accounts":[],"tombstone":true}"#,
2112            )
2113            .unwrap();
2114        assert_eq!(access_token(), None);
2115        assert!(
2116            !access_token_is_available(),
2117            "a published tombstone must remain authoritative over the stale legacy token"
2118        );
2119        assert_eq!(api_base(None), DEFAULT_API_BASE);
2120
2121        store.publish(&state_ref, "{not-json").unwrap();
2122        assert_eq!(access_token(), None, "invalid V2 must fail closed");
2123        assert!(
2124            !access_token_is_available(),
2125            "an invalid V2 record must fail closed instead of reviving legacy"
2126        );
2127        assert_eq!(
2128            api_base(None),
2129            DEFAULT_API_BASE,
2130            "invalid V2 must not resurrect the legacy API base"
2131        );
2132    }
2133
2134    /// Hand-rolled loopback HTTP mock — no extra prod dep, no feature
2135    /// flags. Serves exactly `expected` one-shot requests, records
2136    /// what came in, and replies with whatever `respond` returns.
2137    /// Lets the networked auth fns be exercised end-to-end in CI
2138    /// without the real Parslee backend (or the OS keychain — the
2139    /// token is injected via the `PARSLEE_ACCESS_TOKEN` env override).
2140    mod mock {
2141        use std::io::{Read, Write};
2142        use std::net::TcpListener;
2143        use std::sync::{Arc, Mutex};
2144        use std::thread;
2145
2146        pub struct Recorded {
2147            pub method: String,
2148            pub path: String,
2149            pub authorization: Option<String>,
2150            #[allow(dead_code)] // captured for completeness; not asserted on in tests
2151            pub content_type: Option<String>,
2152            pub body: String,
2153        }
2154
2155        pub struct Mock {
2156            pub base: String,
2157            pub recorded: Arc<Mutex<Vec<Recorded>>>,
2158            handle: Option<thread::JoinHandle<()>>,
2159        }
2160
2161        impl Drop for Mock {
2162            fn drop(&mut self) {
2163                if let Some(h) = self.handle.take() {
2164                    let _ = h.join();
2165                }
2166            }
2167        }
2168
2169        fn find(hay: &[u8], needle: &[u8]) -> Option<usize> {
2170            hay.windows(needle.len()).position(|w| w == needle)
2171        }
2172
2173        pub fn start(
2174            expected: usize,
2175            respond: impl Fn(&Recorded) -> (u16, String) + Send + 'static,
2176        ) -> Mock {
2177            let listener = TcpListener::bind("127.0.0.1:0").unwrap();
2178            let port = listener.local_addr().unwrap().port();
2179            let recorded = Arc::new(Mutex::new(Vec::new()));
2180            let rec = recorded.clone();
2181            // Bounded accept. `accept()` blocks forever when the expected
2182            // request never arrives — and `Drop` joins this thread, so the
2183            // whole test hangs rather than failing. That is reachable whenever
2184            // a client future is cancelled mid-connect, which is exactly what
2185            // an over-tight outer timeout used to do (car#727). A deadline
2186            // makes the thread always terminate, so `Drop` always returns.
2187            let handle = thread::spawn(move || {
2188                listener
2189                    .set_nonblocking(true)
2190                    .expect("mock listener nonblocking");
2191                for _ in 0..expected {
2192                    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
2193                    let mut stream = loop {
2194                        match listener.accept() {
2195                            Ok((stream, _)) => break stream,
2196                            Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
2197                                if std::time::Instant::now() >= deadline {
2198                                    // No client is coming. Leave quietly: the
2199                                    // test's own assertions decide pass/fail,
2200                                    // and panicking here would only surface as
2201                                    // an unhelpful join failure.
2202                                    return;
2203                                }
2204                                thread::sleep(std::time::Duration::from_millis(5));
2205                            }
2206                            Err(e) => panic!("mock accept failed: {e}"),
2207                        }
2208                    };
2209                    // Back to blocking for the request itself, with a read
2210                    // timeout so a half-open connection cannot wedge us either.
2211                    stream.set_nonblocking(false).expect("mock stream blocking");
2212                    stream
2213                        .set_read_timeout(Some(std::time::Duration::from_secs(30)))
2214                        .expect("mock stream read timeout");
2215                    let mut buf = Vec::new();
2216                    let mut tmp = [0u8; 1024];
2217                    loop {
2218                        let n = stream.read(&mut tmp).unwrap();
2219                        if n == 0 {
2220                            break;
2221                        }
2222                        buf.extend_from_slice(&tmp[..n]);
2223                        let Some(hdr_end) = find(&buf, b"\r\n\r\n") else {
2224                            continue;
2225                        };
2226                        let headers = String::from_utf8_lossy(&buf[..hdr_end]).into_owned();
2227                        let content_length = headers
2228                            .lines()
2229                            .find_map(|l| {
2230                                let (k, v) = l.split_once(':')?;
2231                                if k.eq_ignore_ascii_case("content-length") {
2232                                    v.trim().parse::<usize>().ok()
2233                                } else {
2234                                    None
2235                                }
2236                            })
2237                            .unwrap_or(0);
2238                        let body_start = hdr_end + 4;
2239                        while buf.len() < body_start + content_length {
2240                            let n = stream.read(&mut tmp).unwrap();
2241                            if n == 0 {
2242                                break;
2243                            }
2244                            buf.extend_from_slice(&tmp[..n]);
2245                        }
2246                        let mut header_lines = headers.lines();
2247                        let req_line = header_lines.next().unwrap_or("");
2248                        let mut rl = req_line.split_whitespace();
2249                        let method = rl.next().unwrap_or("").to_string();
2250                        let path = rl.next().unwrap_or("").to_string();
2251                        let mut authorization = None;
2252                        let mut content_type = None;
2253                        for l in header_lines {
2254                            if let Some((k, v)) = l.split_once(':') {
2255                                if k.eq_ignore_ascii_case("authorization") {
2256                                    authorization = Some(v.trim().to_string());
2257                                } else if k.eq_ignore_ascii_case("content-type") {
2258                                    content_type = Some(v.trim().to_string());
2259                                }
2260                            }
2261                        }
2262                        let body = String::from_utf8_lossy(
2263                            &buf[body_start..(body_start + content_length).min(buf.len())],
2264                        )
2265                        .into_owned();
2266                        let r = Recorded {
2267                            method,
2268                            path,
2269                            authorization,
2270                            content_type,
2271                            body,
2272                        };
2273                        let (code, resp_body) = respond(&r);
2274                        rec.lock().unwrap().push(r);
2275                        let resp = format!(
2276                            "HTTP/1.1 {code} OK\r\ncontent-type: application/json\r\n\
2277                             content-length: {}\r\nconnection: close\r\n\r\n{}",
2278                            resp_body.len(),
2279                            resp_body
2280                        );
2281                        stream.write_all(resp.as_bytes()).unwrap();
2282                        let _ = stream.flush();
2283                        break;
2284                    }
2285                }
2286            });
2287            Mock {
2288                base: format!("http://127.0.0.1:{port}"),
2289                recorded,
2290                handle: Some(handle),
2291            }
2292        }
2293    }
2294
2295    #[tokio::test]
2296    async fn exchange_code_round_trips_token() {
2297        let mock = mock::start(1, |_r| {
2298            (
2299                200,
2300                r#"{"access_token":"a","refresh_token":"r","expires_in":3600,"token_type":"Bearer"}"#
2301                    .to_string(),
2302            )
2303        });
2304        let token = exchange_code(
2305            &mock.base,
2306            "parslee-car",
2307            "http://localhost:1/cb",
2308            "thecode",
2309            "theverifier",
2310        )
2311        .await
2312        .unwrap();
2313        assert_eq!(token.access_token, "a");
2314        assert_eq!(token.refresh_token, "r");
2315        assert_eq!(token.expires_in, 3600);
2316
2317        let reqs = mock.recorded.lock().unwrap();
2318        assert_eq!(reqs.len(), 1);
2319        assert_eq!(reqs[0].method, "POST");
2320        assert_eq!(reqs[0].path, "/connect/token");
2321        assert!(reqs[0].body.contains("grant_type=authorization_code"));
2322        assert!(reqs[0].body.contains("code=thecode"));
2323        assert!(reqs[0].body.contains("code_verifier=theverifier"));
2324    }
2325
2326    /// The outer bound is a **liveness guard, not a timing assertion**.
2327    ///
2328    /// It exists only so a broken inner timeout fails the run instead of
2329    /// hanging it forever. It was 200ms against a 50ms inner timeout — a 4x
2330    /// margin — and under a loaded `cargo test --workspace` the scheduler
2331    /// routinely takes longer than that to wake the inner timer, so the outer
2332    /// bound won the race and the test failed (or wedged) on a machine-load
2333    /// property rather than a code property. Seen three times.
2334    ///
2335    /// A generous bound keeps the guard without the race: if the inner timeout
2336    /// never fires, the mock answers after 250ms, the call returns `Ok`, and
2337    /// `unwrap_err()` panics immediately — so the real failure path is still
2338    /// fast. The outer timeout only ever trips on a genuinely stuck future.
2339    const STUCK_FUTURE_GUARD: Duration = Duration::from_secs(30);
2340
2341    #[tokio::test]
2342    async fn exchange_code_stall_is_bounded_by_the_explicit_request_timeout() {
2343        let mock = mock::start(1, |_r| {
2344            std::thread::sleep(Duration::from_millis(250));
2345            (
2346                200,
2347                r#"{"access_token":"a","refresh_token":"r","expires_in":3600,"token_type":"Bearer"}"#
2348                    .to_string(),
2349            )
2350        });
2351
2352        let error = tokio::time::timeout(
2353            STUCK_FUTURE_GUARD,
2354            exchange_code_with_timeout(
2355                &mock.base,
2356                "parslee-car",
2357                "http://localhost:1/cb",
2358                "thecode",
2359                "theverifier",
2360                Duration::from_millis(50),
2361            ),
2362        )
2363        .await
2364        .expect("the explicit token request timeout must bound the stalled endpoint")
2365        .unwrap_err();
2366
2367        assert_eq!(
2368            error,
2369            "exchange Parslee authorization code timed out after 50ms"
2370        );
2371    }
2372
2373    #[tokio::test]
2374    async fn refresh_grant_round_trips_token() {
2375        // Gateway reuses the refresh token (omits it from the response) — the
2376        // `Option` fields must tolerate that.
2377        let mock = mock::start(1, |_r| {
2378            (
2379                200,
2380                r#"{"access_token":"a2","expires_in":3600,"token_type":"Bearer"}"#.to_string(),
2381            )
2382        });
2383        let tokens = refresh_grant(&mock.base, "the-refresh-token")
2384            .await
2385            .unwrap();
2386        assert_eq!(tokens.access_token, "a2");
2387        assert_eq!(tokens.refresh_token, None);
2388        assert_eq!(tokens.expires_in, Some(3600));
2389
2390        let reqs = mock.recorded.lock().unwrap();
2391        assert_eq!(reqs.len(), 1);
2392        assert_eq!(reqs[0].method, "POST");
2393        assert_eq!(reqs[0].path, "/connect/token");
2394        assert!(reqs[0].body.contains("grant_type=refresh_token"));
2395        assert!(reqs[0].body.contains("refresh_token=the-refresh-token"));
2396        // Public-client refresh: no client_id is sent (matches the daemon).
2397        assert!(!reqs[0].body.contains("client_id"));
2398    }
2399
2400    #[tokio::test]
2401    async fn repeated_proactive_refresh_failures_back_off() {
2402        let _env_lock = AUTH_ENV_LOCK.lock().await;
2403        let _restore = RestoredEnv::capture(&[
2404            car_home::ENV_VAR,
2405            "CAR_SECRETS_FILE_DIR",
2406            PARSLEE_ACCESS_TOKEN_KEY,
2407            PARSLEE_API_BASE_KEY,
2408        ]);
2409        let directory = tempfile::tempdir().unwrap();
2410        std::env::set_var(car_home::ENV_VAR, directory.path().join("car-home"));
2411        std::env::set_var("CAR_SECRETS_FILE_DIR", directory.path());
2412        std::env::remove_var(PARSLEE_ACCESS_TOKEN_KEY);
2413        std::env::remove_var(PARSLEE_API_BASE_KEY);
2414        invalidate_access_token_cache();
2415        invalidate_proactive_refresh_failure();
2416
2417        let response_index = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
2418        let next_response = std::sync::Arc::clone(&response_index);
2419        let mock = mock::start(5, move |_request| {
2420            if next_response.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 3 {
2421                (
2422                    200,
2423                    r#"{"access_token":"expired-access","expires_in":0}"#.to_string(),
2424                )
2425            } else {
2426                (401, r#"{"error":"invalid_grant"}"#.to_string())
2427            }
2428        });
2429        let active = ActiveCredentials {
2430            account_id: "expired-account".into(),
2431            email: None,
2432            name: None,
2433            access_token: "expired-access".into(),
2434            refresh_token: Some("invalid-refresh".into()),
2435            expires_at: 1,
2436            api_base: mock.base.clone(),
2437        };
2438        SecretStore::new()
2439            .publish(
2440                &SecretRef::with_default_service(car_secrets::PARSLEE_AUTH_STATE_V2_KEY),
2441                &serde_json::json!({
2442                    "schema": 2,
2443                    "revision": 1,
2444                    "generation": 1,
2445                    "active": active.clone(),
2446                    "accounts": [active.clone()]
2447                })
2448                .to_string(),
2449            )
2450            .unwrap();
2451
2452        let started = Instant::now();
2453        for _ in 0..10 {
2454            let resolved =
2455                resolve_credential_once_with_clock(CredentialReadPurpose::Resolve, || started)
2456                    .await
2457                    .unwrap()
2458                    .unwrap();
2459            assert_eq!(resolved.access_token, "expired-access");
2460        }
2461        assert_eq!(
2462            mock.recorded.lock().unwrap().len(),
2463            1,
2464            "back-to-back resolves inside the interval must make one attempt"
2465        );
2466
2467        let mut newly_logged_in = active.clone();
2468        newly_logged_in.access_token = "new-login-access".into();
2469        assert!(
2470            !proactive_refresh_is_suppressed(&newly_logged_in, started + Duration::from_secs(1)),
2471            "a different credential must not inherit the failed interval"
2472        );
2473        let mut externally_refreshed = active.clone();
2474        externally_refreshed.expires_at = active.expires_at + 1;
2475        assert!(
2476            !proactive_refresh_is_suppressed(
2477                &externally_refreshed,
2478                started + Duration::from_secs(1)
2479            ),
2480            "the same token tuple with a new authoritative expiry must not inherit backoff"
2481        );
2482
2483        let interval_end = started + PROACTIVE_REFRESH_FAILURE_MIN_INTERVAL;
2484        resolve_credential_once_with_clock(CredentialReadPurpose::Resolve, || interval_end)
2485            .await
2486            .unwrap();
2487        assert_eq!(
2488            mock.recorded.lock().unwrap().len(),
2489            2,
2490            "the interval boundary must permit a second proactive attempt"
2491        );
2492
2493        let inside_second_interval = interval_end + Duration::from_secs(1);
2494        let forced =
2495            resolve_credential_once_with_clock(CredentialReadPurpose::ForceRefresh, || {
2496                inside_second_interval
2497            })
2498            .await
2499            .unwrap();
2500        assert_eq!(forced, None);
2501        assert_eq!(
2502            mock.recorded.lock().unwrap().len(),
2503            3,
2504            "ForceRefresh must bypass the proactive interval"
2505        );
2506        resolve_credential_once_with_clock(CredentialReadPurpose::Resolve, || {
2507            inside_second_interval
2508        })
2509        .await
2510        .unwrap();
2511        assert_eq!(
2512            mock.recorded.lock().unwrap().len(),
2513            3,
2514            "a failed ForceRefresh must not clear proactive backoff"
2515        );
2516
2517        let second_interval_end = interval_end + PROACTIVE_REFRESH_FAILURE_MIN_INTERVAL;
2518        let refreshed = resolve_credential_once_with_clock(CredentialReadPurpose::Resolve, || {
2519            second_interval_end
2520        })
2521        .await
2522        .unwrap()
2523        .unwrap();
2524        assert_eq!(refreshed.access_token, "expired-access");
2525        assert_eq!(mock.recorded.lock().unwrap().len(), 4);
2526
2527        resolve_credential_once_with_clock(CredentialReadPurpose::Resolve, || second_interval_end)
2528            .await
2529            .unwrap();
2530        assert_eq!(
2531            mock.recorded.lock().unwrap().len(),
2532            5,
2533            "a successful proactive refresh must clear the failed interval"
2534        );
2535        invalidate_access_token_cache();
2536        invalidate_proactive_refresh_failure();
2537    }
2538
2539    #[tokio::test]
2540    async fn login_logout_and_account_switch_clear_refresh_backoff() {
2541        let _env_lock = AUTH_ENV_LOCK.lock().await;
2542        let _restore = RestoredEnv::capture(&[
2543            car_home::ENV_VAR,
2544            "CAR_SECRETS_FILE_DIR",
2545            PARSLEE_ACCESS_TOKEN_KEY,
2546            PARSLEE_API_BASE_KEY,
2547        ]);
2548        let directory = tempfile::tempdir().unwrap();
2549        std::env::set_var(car_home::ENV_VAR, directory.path().join("car-home"));
2550        std::env::set_var("CAR_SECRETS_FILE_DIR", directory.path());
2551        std::env::remove_var(PARSLEE_ACCESS_TOKEN_KEY);
2552        std::env::remove_var(PARSLEE_API_BASE_KEY);
2553        invalidate_proactive_refresh_failure();
2554
2555        let old = ActiveCredentials {
2556            account_id: "old-account".into(),
2557            email: None,
2558            name: None,
2559            access_token: "same-access".into(),
2560            refresh_token: Some("same-refresh".into()),
2561            expires_at: 1,
2562            api_base: DEFAULT_API_BASE.into(),
2563        };
2564        record_proactive_refresh_failure(&old, Instant::now());
2565        commit_login(
2566            DEFAULT_API_BASE,
2567            &TokenSet {
2568                access_token: "new-access".into(),
2569                refresh_token: "new-refresh".into(),
2570                expires_in: 3_600,
2571                token_type: "Bearer".into(),
2572            },
2573            r#"{"Account":{"Id":"new-account"}}"#,
2574            None,
2575        )
2576        .await
2577        .unwrap();
2578        assert!(
2579            proactive_refresh_failure().lock().unwrap().is_none(),
2580            "same-process login replacement must clear refresh backoff"
2581        );
2582
2583        record_proactive_refresh_failure(&old, Instant::now());
2584        logout().await.unwrap();
2585        assert!(
2586            proactive_refresh_failure().lock().unwrap().is_none(),
2587            "same-process logout must clear refresh backoff"
2588        );
2589
2590        let second = ActiveCredentials {
2591            account_id: "second-account".into(),
2592            access_token: "second-access".into(),
2593            refresh_token: Some("second-refresh".into()),
2594            ..old.clone()
2595        };
2596        SecretStore::new()
2597            .publish(
2598                &SecretRef::with_default_service(car_secrets::PARSLEE_AUTH_STATE_V2_KEY),
2599                &serde_json::json!({
2600                    "schema": 2,
2601                    "revision": 7,
2602                    "generation": 4,
2603                    "active": old.clone(),
2604                    "accounts": [old.clone(), second]
2605                })
2606                .to_string(),
2607            )
2608            .unwrap();
2609        record_proactive_refresh_failure(&old, Instant::now());
2610        switch_account("second-account").await.unwrap();
2611        assert!(
2612            proactive_refresh_failure().lock().unwrap().is_none(),
2613            "same-process account switch must clear refresh backoff"
2614        );
2615    }
2616
2617    #[tokio::test]
2618    async fn proactive_refresh_commit_failure_arms_backoff() {
2619        let _env_lock = AUTH_ENV_LOCK.lock().await;
2620        let _restore = RestoredEnv::capture(&[
2621            car_home::ENV_VAR,
2622            "CAR_SECRETS_FILE_DIR",
2623            PARSLEE_ACCESS_TOKEN_KEY,
2624            PARSLEE_API_BASE_KEY,
2625        ]);
2626        let directory = tempfile::tempdir().unwrap();
2627        std::env::set_var(car_home::ENV_VAR, directory.path().join("car-home"));
2628        std::env::set_var("CAR_SECRETS_FILE_DIR", directory.path());
2629        std::env::remove_var(PARSLEE_ACCESS_TOKEN_KEY);
2630        std::env::remove_var(PARSLEE_API_BASE_KEY);
2631        invalidate_access_token_cache();
2632        invalidate_proactive_refresh_failure();
2633
2634        let mock = mock::start(1, |_request| {
2635            SecretStore::new()
2636                .publish(
2637                    &SecretRef::with_default_service(car_secrets::PARSLEE_AUTH_STATE_V2_KEY),
2638                    "{malformed",
2639                )
2640                .unwrap();
2641            (
2642                200,
2643                r#"{"access_token":"fresh-access","expires_in":3600}"#.to_string(),
2644            )
2645        });
2646        let active = ActiveCredentials {
2647            account_id: "commit-failure-account".into(),
2648            email: None,
2649            name: None,
2650            access_token: "expired-access".into(),
2651            refresh_token: Some("refresh".into()),
2652            expires_at: 1,
2653            api_base: mock.base.clone(),
2654        };
2655        SecretStore::new()
2656            .publish(
2657                &SecretRef::with_default_service(car_secrets::PARSLEE_AUTH_STATE_V2_KEY),
2658                &serde_json::json!({
2659                    "schema": 2,
2660                    "revision": 1,
2661                    "generation": 1,
2662                    "active": active.clone(),
2663                    "accounts": [active.clone()]
2664                })
2665                .to_string(),
2666            )
2667            .unwrap();
2668
2669        let failed_at = Instant::now();
2670        let resolved =
2671            resolve_credential_once_with_clock(CredentialReadPurpose::Resolve, || failed_at)
2672                .await
2673                .unwrap()
2674                .unwrap();
2675        assert_eq!(resolved.access_token, "expired-access");
2676        assert!(
2677            proactive_refresh_is_suppressed(&active, failed_at + Duration::from_secs(1)),
2678            "a proactive grant that cannot be committed must arm backoff"
2679        );
2680        assert_eq!(mock.recorded.lock().unwrap().len(), 1);
2681        invalidate_proactive_refresh_failure();
2682    }
2683
2684    #[tokio::test]
2685    async fn rejected_bearer_loop_limits_total_refresh_endpoint_attempts() {
2686        let _env_lock = AUTH_ENV_LOCK.lock().await;
2687        let _restore = RestoredEnv::capture(&[
2688            car_home::ENV_VAR,
2689            "CAR_SECRETS_FILE_DIR",
2690            PARSLEE_ACCESS_TOKEN_KEY,
2691            PARSLEE_API_BASE_KEY,
2692        ]);
2693        let directory = tempfile::tempdir().unwrap();
2694        std::env::set_var(car_home::ENV_VAR, directory.path().join("car-home"));
2695        std::env::set_var("CAR_SECRETS_FILE_DIR", directory.path());
2696        std::env::remove_var(PARSLEE_ACCESS_TOKEN_KEY);
2697        std::env::remove_var(PARSLEE_API_BASE_KEY);
2698        invalidate_access_token_cache();
2699        invalidate_proactive_refresh_failure();
2700
2701        let mock = mock::start(4, |_request| {
2702            (401, r#"{"error":"invalid_grant"}"#.to_string())
2703        });
2704        let active = ActiveCredentials {
2705            account_id: "rejected-account".into(),
2706            email: None,
2707            name: None,
2708            access_token: "rejected-access".into(),
2709            refresh_token: Some("invalid-refresh".into()),
2710            expires_at: 1,
2711            api_base: mock.base.clone(),
2712        };
2713        SecretStore::new()
2714            .publish(
2715                &SecretRef::with_default_service(car_secrets::PARSLEE_AUTH_STATE_V2_KEY),
2716                &serde_json::json!({
2717                    "schema": 2,
2718                    "revision": 1,
2719                    "generation": 1,
2720                    "active": active.clone(),
2721                    "accounts": [active.clone()]
2722                })
2723                .to_string(),
2724            )
2725            .unwrap();
2726
2727        let mut first_failure_at = None;
2728        for _ in 0..10 {
2729            let credential = resolve_credential(CredentialReadMode::Use)
2730                .await
2731                .unwrap()
2732                .unwrap();
2733            assert_eq!(credential.access_token, "rejected-access");
2734            let failed_at = *first_failure_at.get_or_insert_with(|| {
2735                proactive_refresh_failure()
2736                    .lock()
2737                    .unwrap()
2738                    .as_ref()
2739                    .expect("the first proactive failure must arm backoff")
2740                    .failed_at
2741            });
2742            assert_eq!(
2743                refresh_credential_after_rejection_with_clock("rejected-access", || failed_at)
2744                    .await
2745                    .unwrap(),
2746                None
2747            );
2748        }
2749
2750        assert_eq!(
2751            mock.recorded.lock().unwrap().len(),
2752            1,
2753            "ten rejected-bearer calls inside the interval must make one token-endpoint attempt"
2754        );
2755
2756        let interval_end = first_failure_at.unwrap() + PROACTIVE_REFRESH_FAILURE_MIN_INTERVAL;
2757        assert_eq!(
2758            refresh_credential_after_rejection_with_clock("rejected-access", || interval_end)
2759                .await
2760                .unwrap(),
2761            None
2762        );
2763        assert_eq!(
2764            mock.recorded.lock().unwrap().len(),
2765            2,
2766            "the automatic retry at the interval boundary is the second attempt within one minute"
2767        );
2768
2769        assert_eq!(
2770            refresh_credential().await.unwrap(),
2771            None,
2772            "an explicit forced refresh remains unconditional"
2773        );
2774        assert_eq!(mock.recorded.lock().unwrap().len(), 3);
2775
2776        assert_eq!(
2777            resolve_credential(CredentialReadMode::Retry).await.unwrap(),
2778            Some(resolved_from_active(&active)),
2779            "an explicit Retry-mode resolve remains unconditional"
2780        );
2781        assert_eq!(mock.recorded.lock().unwrap().len(), 4);
2782
2783        invalidate_access_token_cache();
2784        invalidate_proactive_refresh_failure();
2785    }
2786
2787    #[tokio::test]
2788    async fn automatic_refresh_is_attributed_across_account_switch_race() {
2789        let _env_lock = AUTH_ENV_LOCK.lock().await;
2790        let _restore = RestoredEnv::capture(&[
2791            car_home::ENV_VAR,
2792            "CAR_SECRETS_FILE_DIR",
2793            PARSLEE_ACCESS_TOKEN_KEY,
2794            PARSLEE_API_BASE_KEY,
2795        ]);
2796        let directory = tempfile::tempdir().unwrap();
2797        std::env::set_var(car_home::ENV_VAR, directory.path().join("car-home"));
2798        std::env::set_var("CAR_SECRETS_FILE_DIR", directory.path());
2799        std::env::remove_var(PARSLEE_ACCESS_TOKEN_KEY);
2800        std::env::remove_var(PARSLEE_API_BASE_KEY);
2801        invalidate_access_token_cache();
2802        invalidate_proactive_refresh_failure();
2803
2804        let (a_started_tx, a_started_rx) = tokio::sync::oneshot::channel();
2805        let a_started_tx = std::sync::Mutex::new(Some(a_started_tx));
2806        let (release_a_tx, release_a_rx) = std::sync::mpsc::channel();
2807        let attempt = std::sync::atomic::AtomicUsize::new(0);
2808        let mock = mock::start(2, move |request| {
2809            match attempt.fetch_add(1, std::sync::atomic::Ordering::SeqCst) {
2810                0 => {
2811                    assert!(request.body.contains("refresh_token=a-refresh"));
2812                    a_started_tx
2813                        .lock()
2814                        .unwrap()
2815                        .take()
2816                        .unwrap()
2817                        .send(())
2818                        .unwrap();
2819                    release_a_rx.recv().unwrap();
2820                    (401, r#"{"error":"invalid_grant"}"#.to_string())
2821                }
2822                1 => {
2823                    assert!(request.body.contains("refresh_token=b-refresh"));
2824                    (
2825                        200,
2826                        r#"{"access_token":"b-fresh","expires_in":3600}"#.to_string(),
2827                    )
2828                }
2829                other => panic!("unexpected refresh attempt {other}"),
2830            }
2831        });
2832        let account_a = ActiveCredentials {
2833            account_id: "account-a".into(),
2834            email: None,
2835            name: None,
2836            access_token: "a-access".into(),
2837            refresh_token: Some("a-refresh".into()),
2838            expires_at: 9_999_999_999,
2839            api_base: mock.base.clone(),
2840        };
2841        let account_b = ActiveCredentials {
2842            account_id: "account-b".into(),
2843            access_token: "b-access".into(),
2844            refresh_token: Some("b-refresh".into()),
2845            ..account_a.clone()
2846        };
2847        SecretStore::new()
2848            .publish(
2849                &SecretRef::with_default_service(car_secrets::PARSLEE_AUTH_STATE_V2_KEY),
2850                &serde_json::json!({
2851                    "schema": 2,
2852                    "revision": 1,
2853                    "generation": 1,
2854                    "active": account_a.clone(),
2855                    "accounts": [account_a, account_b]
2856                })
2857                .to_string(),
2858            )
2859            .unwrap();
2860
2861        let a_refresh = tokio::spawn(refresh_credential_after_rejection("a-access"));
2862        a_started_rx.await.unwrap();
2863        switch_account("account-b").await.unwrap();
2864
2865        // Constructing this future synchronously registers B's intent before
2866        // A can publish its failure, even though B has not been polled yet.
2867        let b_refresh = refresh_credential_after_rejection("b-access");
2868        release_a_tx.send(()).unwrap();
2869        let b_refresh = tokio::spawn(b_refresh);
2870
2871        assert_eq!(a_refresh.await.unwrap().unwrap(), None);
2872        let refreshed_b = b_refresh.await.unwrap().unwrap().unwrap();
2873        assert_eq!(refreshed_b.access_token, "b-fresh");
2874        assert_eq!(
2875            mock.recorded.lock().unwrap().len(),
2876            2,
2877            "A's failed flight must not suppress B's automatic refresh"
2878        );
2879
2880        let failure_before_stale_call = proactive_refresh_failure()
2881            .lock()
2882            .unwrap()
2883            .as_ref()
2884            .map(|failure| (failure.credential, failure.failed_at));
2885        let current = refresh_credential_after_rejection("a-access")
2886            .await
2887            .unwrap()
2888            .unwrap();
2889        assert_eq!(current.access_token, "b-fresh");
2890        assert_eq!(
2891            mock.recorded.lock().unwrap().len(),
2892            2,
2893            "a stale rejected bearer must return the current credential without refreshing"
2894        );
2895        assert_eq!(
2896            proactive_refresh_failure()
2897                .lock()
2898                .unwrap()
2899                .as_ref()
2900                .map(|failure| (failure.credential, failure.failed_at)),
2901            failure_before_stale_call,
2902            "a stale rejected bearer must not record refresh failure state"
2903        );
2904        invalidate_access_token_cache();
2905        invalidate_proactive_refresh_failure();
2906    }
2907
2908    #[tokio::test]
2909    async fn forced_refresh_cas_conflict_returns_complete_winning_credential() {
2910        let _env_lock = AUTH_ENV_LOCK.lock().await;
2911        let _restore = RestoredEnv::capture(&[
2912            "CAR_SECRETS_FILE_DIR",
2913            PARSLEE_ACCESS_TOKEN_KEY,
2914            PARSLEE_API_BASE_KEY,
2915        ]);
2916        let directory = tempfile::tempdir().unwrap();
2917        std::env::set_var("CAR_SECRETS_FILE_DIR", directory.path());
2918        std::env::remove_var(PARSLEE_ACCESS_TOKEN_KEY);
2919        std::env::remove_var(PARSLEE_API_BASE_KEY);
2920        invalidate_access_token_cache();
2921
2922        let winning_state = serde_json::json!({
2923            "schema": 2,
2924            "revision": 9,
2925            "generation": 5,
2926            "active": {
2927                "account_id": "winning-account",
2928                "access_token": "winning-access",
2929                "refresh_token": "winning-refresh",
2930                "expires_at": 9_999_999_999_u64,
2931                "api_base": "https://winning-api.example"
2932            },
2933            "accounts": [{
2934                "account_id": "winning-account",
2935                "access_token": "winning-access",
2936                "refresh_token": "winning-refresh",
2937                "expires_at": 9_999_999_999_u64,
2938                "api_base": "https://winning-api.example"
2939            }]
2940        })
2941        .to_string();
2942        let mock = mock::start(1, move |_request| {
2943            SecretStore::new()
2944                .publish(
2945                    &SecretRef::with_default_service(car_secrets::PARSLEE_AUTH_STATE_V2_KEY),
2946                    &winning_state,
2947                )
2948                .unwrap();
2949            (
2950                200,
2951                r#"{"access_token":"losing-refresh-access","expires_in":3600}"#.to_string(),
2952            )
2953        });
2954        SecretStore::new()
2955            .publish(
2956                &SecretRef::with_default_service(car_secrets::PARSLEE_AUTH_STATE_V2_KEY),
2957                &serde_json::json!({
2958                    "schema": 2,
2959                    "revision": 8,
2960                    "generation": 4,
2961                    "active": {
2962                        "account_id": "original-account",
2963                        "access_token": "rejected-access",
2964                        "refresh_token": "original-refresh",
2965                        "expires_at": 9_999_999_999_u64,
2966                        "api_base": mock.base.clone()
2967                    },
2968                    "accounts": [{
2969                        "account_id": "original-account",
2970                        "access_token": "rejected-access",
2971                        "refresh_token": "original-refresh",
2972                        "expires_at": 9_999_999_999_u64,
2973                        "api_base": mock.base.clone()
2974                    }]
2975                })
2976                .to_string(),
2977            )
2978            .unwrap();
2979
2980        let resolved = resolve_credential_once(CredentialReadPurpose::ForceRefresh)
2981            .await
2982            .unwrap()
2983            .unwrap();
2984
2985        assert_eq!(resolved.access_token, "winning-access");
2986        assert_eq!(resolved.api_base, "https://winning-api.example");
2987        assert_eq!(resolved.expires_at, 9_999_999_999);
2988    }
2989
2990    #[tokio::test]
2991    async fn fetch_status_sends_bearer() {
2992        let _env_lock = AUTH_ENV_LOCK.lock().await;
2993        let _restore = RestoredEnv::capture(&[PARSLEE_ACCESS_TOKEN_KEY]);
2994        // Inject the token via the env override so the keychain is
2995        // never touched. No other car-auth test reads this var.
2996        std::env::set_var(PARSLEE_ACCESS_TOKEN_KEY, "test-token");
2997
2998        let mock = mock::start(1, |_r| (200, r#"{"authenticated":true}"#.to_string()));
2999
3000        let session = fetch_status(Some(&mock.base)).await.unwrap();
3001        assert_eq!(session.as_deref(), Some(r#"{"authenticated":true}"#));
3002
3003        let reqs = mock.recorded.lock().unwrap();
3004        assert_eq!(reqs.len(), 1);
3005        let sess = &reqs[0];
3006        assert_eq!(sess.method, "GET");
3007        assert_eq!(sess.path, "/connect/session");
3008        assert_eq!(sess.authorization.as_deref(), Some("Bearer test-token"));
3009    }
3010
3011    #[tokio::test]
3012    async fn fetch_status_with_access_has_a_total_request_timeout() {
3013        let mock = mock::start(1, |_r| {
3014            std::thread::sleep(Duration::from_millis(250));
3015            (200, r#"{"authenticated":true}"#.to_string())
3016        });
3017
3018        let error = tokio::time::timeout(
3019            STUCK_FUTURE_GUARD,
3020            fetch_status_with_access_timeout(
3021                &mock.base,
3022                "test-access-token",
3023                Duration::from_millis(50),
3024            ),
3025        )
3026        .await
3027        .expect("the explicit request timeout must bound the stalled double")
3028        .unwrap_err();
3029
3030        assert_eq!(error, "fetch Parslee session timed out after 50ms");
3031    }
3032}