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::{SecretRef, SecretStore};
18
19mod state;
20use state::{
21    ActiveCredentials, AuthStateV2, CasOutcome, ProcessAuthLock, RefreshCas, RefreshedCredentials,
22    SecretAuthStateStore, StateCoordinator,
23};
24
25pub const PARSLEE_ACCESS_TOKEN_KEY: &str = car_secrets::PARSLEE_ACCESS_TOKEN_KEY;
26pub const PARSLEE_REFRESH_TOKEN_KEY: &str = car_secrets::PARSLEE_REFRESH_TOKEN_KEY;
27pub const PARSLEE_EXPIRES_AT_KEY: &str = car_secrets::PARSLEE_EXPIRES_AT_KEY;
28pub const PARSLEE_API_BASE_KEY: &str = car_secrets::PARSLEE_API_BASE_KEY;
29pub const DEFAULT_API_BASE: &str = "https://api.parslee.ai";
30const PARSLEE_TOKEN_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
31const PARSLEE_STATUS_REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
32/// Maximum time an auth operation may wait behind another in-process
33/// coordinator operation before failing safely without starting storage work.
34///
35/// Once the coordinator guard is acquired, the daemon-owned caller retains it
36/// until the bounded blocking storage task has actually joined. Timing out a
37/// WebSocket response therefore cannot release this overlap guard while an
38/// abandoned keychain worker continues in the background.
39pub const AUTH_COORDINATOR_QUEUE_TIMEOUT: Duration = Duration::from_secs(30);
40/// The host allows up to 300 seconds for the browser callback. Keep the durable
41/// reservation valid beyond that window so a callback at the edge can still
42/// atomically claim its bounded completion worker.
43pub const LOGIN_ATTEMPT_CALLBACK_TTL: Duration = Duration::from_secs(420);
44/// Maximum time allowed for the aggregate token-exchange and completion-session
45/// network phase.
46pub const AUTH_COMPLETION_NETWORK_DEADLINE: Duration = Duration::from_secs(90);
47/// Bound used for one authoritative credential-store read or publication in
48/// the login-worker budget. The macOS keychain helper enforces this duration;
49/// the other local backends are expected to complete within the same budget.
50pub const AUTH_STATE_OPERATION_BUDGET: Duration = Duration::from_secs(15);
51/// Maximum time allowed to acquire the per-user cross-process auth-state lock.
52pub const AUTH_PROCESS_LOCK_TIMEOUT: Duration = Duration::from_secs(30);
53/// Explicit scheduler/runtime headroom after every bounded serial phase.
54pub const LOGIN_ATTEMPT_WORKER_SCHEDULING_MARGIN: Duration = Duration::from_secs(30);
55/// Worst-case serial work between calculating the redeeming lease expiry and
56/// the strict expiry check immediately before credential publication:
57///
58/// claim publication + network + coordinator queue + process lock + state read.
59pub const LOGIN_ATTEMPT_WORKER_SERIAL_BUDGET: Duration = Duration::from_secs(
60    AUTH_STATE_OPERATION_BUDGET.as_secs()
61        + AUTH_COMPLETION_NETWORK_DEADLINE.as_secs()
62        + AUTH_COORDINATOR_QUEUE_TIMEOUT.as_secs()
63        + AUTH_PROCESS_LOCK_TIMEOUT.as_secs()
64        + AUTH_STATE_OPERATION_BUDGET.as_secs(),
65);
66/// Redeeming-worker lease derived from the complete serial budget plus positive
67/// scheduling margin. Keep this below the host's 480-second reconciliation
68/// horizon when changing any component.
69pub const LOGIN_ATTEMPT_WORKER_TTL: Duration = Duration::from_secs(
70    LOGIN_ATTEMPT_WORKER_SERIAL_BUDGET.as_secs() + LOGIN_ATTEMPT_WORKER_SCHEDULING_MARGIN.as_secs(),
71);
72
73/// Classified failure for coordinator-backed auth operations.
74///
75/// A coordination deadline is known to occur before the requested state
76/// operation starts. Callers may distinguish it from terminal state,
77/// credential-store, or worker failures without parsing human-readable text.
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub enum AuthOperationError {
80    CoordinationDeadline(String),
81    Terminal(String),
82}
83
84impl AuthOperationError {
85    /// Return whether this failure occurred before the requested state
86    /// operation began.
87    pub fn is_coordination_deadline(&self) -> bool {
88        matches!(self, Self::CoordinationDeadline(_))
89    }
90}
91
92impl std::fmt::Display for AuthOperationError {
93    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
94        match self {
95            Self::CoordinationDeadline(message) | Self::Terminal(message) => {
96                formatter.write_str(message)
97            }
98        }
99    }
100}
101
102impl std::error::Error for AuthOperationError {}
103
104/// `/connect/token` success body.
105#[derive(Debug, Clone, Deserialize)]
106pub struct TokenSet {
107    pub access_token: String,
108    pub refresh_token: String,
109    pub expires_in: u64,
110    pub token_type: String,
111}
112
113/// A local, non-mutating view of the persisted Parslee login state.
114///
115/// Unlike [`fetch_status`], this never refreshes a token and never calls the
116/// Parslee API. It is the safe pre-browser baseline for a login attempt.
117#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
118pub struct LocalAuthSnapshot {
119    pub authenticated: bool,
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub active_account_id: Option<String>,
122}
123
124/// The latest causally-bound browser completion. Only one completion can remain
125/// current because every identity-changing credential mutation advances
126/// `generation`; a later mutation therefore supersedes this proof.
127#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
128pub struct AuthCompletionRecord {
129    pub attempt_id: String,
130    pub generation: u64,
131    #[serde(default)]
132    pub account_id: Option<String>,
133    #[serde(default)]
134    pub session: Option<String>,
135}
136
137/// Durable phase of an incomplete browser login attempt.
138#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
139#[serde(rename_all = "snake_case")]
140pub enum AuthAttemptPhase {
141    AwaitingCallback,
142    Redeeming,
143}
144
145/// Typed result returned by the local-only completion-status read.
146#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
147#[serde(rename_all = "snake_case")]
148pub enum AuthCompletionState {
149    Pending,
150    Complete,
151    Failed,
152    Stale,
153}
154
155/// Safe terminal failure metadata persisted for one exact attempt.
156#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
157pub struct AuthAttemptFailure {
158    pub error_code: String,
159    pub message: String,
160    pub retryable: bool,
161}
162
163impl AuthAttemptFailure {
164    pub fn completion_failed() -> Self {
165        Self {
166            error_code: "completion_failed".into(),
167            message:
168                "Sign-in could not be completed. Start a new sign-in attempt; do not reuse this authorization code."
169                    .into(),
170            retryable: true,
171        }
172    }
173
174    fn attempt_expired() -> Self {
175        Self {
176            error_code: "attempt_expired".into(),
177            message:
178                "This sign-in attempt expired. Start a new sign-in attempt; do not reuse this authorization code."
179                    .into(),
180            retryable: true,
181        }
182    }
183
184    fn daemon_restarted() -> Self {
185        Self {
186            error_code: "daemon_restarted".into(),
187            message:
188                "CAR restarted while finishing sign-in. Start a new sign-in attempt; do not reuse this authorization code."
189                    .into(),
190            retryable: true,
191        }
192    }
193}
194
195/// One authoritative, coordinator-locked view of completion, generation, and
196/// attempt lifecycle. Optional fields are populated only for their matching
197/// state.
198#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
199pub struct AuthCompletionStatus {
200    pub state: AuthCompletionState,
201    pub attempt_id: String,
202    pub generation: u64,
203    #[serde(default, skip_serializing_if = "Option::is_none")]
204    pub phase: Option<AuthAttemptPhase>,
205    #[serde(default, skip_serializing_if = "Option::is_none")]
206    pub expires_at_unix_ms: Option<u64>,
207    #[serde(default, skip_serializing_if = "Option::is_none")]
208    pub account_id: Option<String>,
209    #[serde(default, skip_serializing_if = "Option::is_none")]
210    pub session: Option<String>,
211    #[serde(default, skip_serializing_if = "Option::is_none")]
212    pub error_code: Option<String>,
213    #[serde(default, skip_serializing_if = "Option::is_none")]
214    pub message: Option<String>,
215    #[serde(default, skip_serializing_if = "Option::is_none")]
216    pub retryable: Option<bool>,
217}
218
219impl AuthCompletionStatus {
220    fn stale(attempt_id: &str, generation: u64) -> Self {
221        Self {
222            state: AuthCompletionState::Stale,
223            attempt_id: attempt_id.to_string(),
224            generation,
225            phase: None,
226            expires_at_unix_ms: None,
227            account_id: None,
228            session: None,
229            error_code: None,
230            message: None,
231            retryable: None,
232        }
233    }
234}
235
236/// Persisted reservation and worker fence for one browser login attempt.
237/// `auth.start` publishes the awaiting-callback form. `auth.complete` may
238/// atomically populate the worker fields exactly once before network I/O; only
239/// a completion carrying that exact claimed lease may replace credentials.
240#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
241pub struct LoginAttemptLease {
242    pub attempt_id: String,
243    pub revision: u64,
244    pub generation: u64,
245    #[serde(default)]
246    pub attempt_expires_at_unix_ms: u64,
247    #[serde(default, skip_serializing_if = "Option::is_none")]
248    pub worker_owner_id: Option<String>,
249    #[serde(default, skip_serializing_if = "Option::is_none")]
250    pub worker_id: Option<String>,
251    #[serde(default, skip_serializing_if = "Option::is_none")]
252    pub worker_expires_at_unix_ms: Option<u64>,
253}
254
255fn epoch_seconds() -> u64 {
256    std::time::SystemTime::now()
257        .duration_since(std::time::UNIX_EPOCH)
258        .map(|d| d.as_secs())
259        .unwrap_or(0)
260}
261
262fn epoch_millis() -> u64 {
263    std::time::SystemTime::now()
264        .duration_since(std::time::UNIX_EPOCH)
265        .map(|duration| u64::try_from(duration.as_millis()).unwrap_or(u64::MAX))
266        .unwrap_or(0)
267}
268
269/// PKCE code verifier (URL-safe, no padding).
270pub fn pkce_verifier() -> String {
271    let raw = format!(
272        "{}{}",
273        uuid::Uuid::new_v4().simple(),
274        uuid::Uuid::new_v4().simple()
275    );
276    base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(raw.as_bytes())
277}
278
279/// Opaque OAuth `state` value (CSRF guard).
280pub fn new_state() -> String {
281    uuid::Uuid::new_v4().simple().to_string()
282}
283
284/// PKCE S256 challenge for a verifier.
285pub fn pkce_challenge(verifier: &str) -> String {
286    let digest = Sha256::digest(verifier.as_bytes());
287    base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest)
288}
289
290/// Build the `/connect/authorize` URL the user opens in a browser.
291pub fn authorize_url(
292    api_base: &str,
293    client_id: &str,
294    redirect_uri: &str,
295    state: &str,
296    challenge: &str,
297    provider: Option<&str>,
298    prompt: Option<&str>,
299) -> Result<String, String> {
300    let mut url = reqwest::Url::parse(&format!(
301        "{}/connect/authorize",
302        api_base.trim_end_matches('/')
303    ))
304    .map_err(|e| format!("build authorize URL: {e}"))?;
305    url.query_pairs_mut()
306        .append_pair("client_id", client_id)
307        .append_pair("redirect_uri", redirect_uri)
308        .append_pair("response_type", "code")
309        .append_pair("scope", "openid profile email")
310        .append_pair("state", state)
311        .append_pair("code_challenge", challenge)
312        .append_pair("code_challenge_method", "S256");
313    if let Some(provider) = provider {
314        url.query_pairs_mut().append_pair("provider", provider);
315    }
316    // `prompt=select_account` forces a fresh account chooser (add-account),
317    // bypassing the existing SSO cookie so a second login can be added.
318    if let Some(prompt) = prompt {
319        url.query_pairs_mut().append_pair("prompt", prompt);
320    }
321    Ok(url.to_string())
322}
323
324fn form_body(pairs: &[(&str, &str)]) -> String {
325    let mut s = String::new();
326    for (i, (k, v)) in pairs.iter().enumerate() {
327        if i > 0 {
328            s.push('&');
329        }
330        s.push_str(&urlencode(k));
331        s.push('=');
332        s.push_str(&urlencode(v));
333    }
334    s
335}
336
337fn urlencode(s: &str) -> String {
338    let mut out = String::with_capacity(s.len());
339    for b in s.bytes() {
340        match b {
341            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
342                out.push(b as char)
343            }
344            _ => out.push_str(&format!("%{b:02X}")),
345        }
346    }
347    out
348}
349
350/// Exchange an authorization code + PKCE verifier for tokens.
351pub async fn exchange_code(
352    api_base: &str,
353    client_id: &str,
354    redirect_uri: &str,
355    code: &str,
356    verifier: &str,
357) -> Result<TokenSet, String> {
358    exchange_code_with_timeout(
359        api_base,
360        client_id,
361        redirect_uri,
362        code,
363        verifier,
364        PARSLEE_TOKEN_REQUEST_TIMEOUT,
365    )
366    .await
367}
368
369async fn post_token_form_with_timeout(
370    token_url: String,
371    body: String,
372    action: &'static str,
373    request_timeout: Duration,
374) -> Result<(reqwest::StatusCode, String), String> {
375    let client = reqwest::Client::builder()
376        .timeout(request_timeout)
377        .build()
378        .map_err(|error| format!("build Parslee token client: {error}"))?;
379    let response = client
380        .post(token_url)
381        .header("content-type", "application/x-www-form-urlencoded")
382        .body(body)
383        .send()
384        .await
385        .map_err(|error| {
386            if error.is_timeout() {
387                format!("{action} timed out after {}ms", request_timeout.as_millis())
388            } else {
389                format!("{action}: {error}")
390            }
391        })?;
392    let status = response.status();
393    let text = response.text().await.map_err(|error| {
394        if error.is_timeout() {
395            format!("{action} timed out after {}ms", request_timeout.as_millis())
396        } else {
397            format!("read Parslee token response: {error}")
398        }
399    })?;
400    Ok((status, text))
401}
402
403async fn exchange_code_with_timeout(
404    api_base: &str,
405    client_id: &str,
406    redirect_uri: &str,
407    code: &str,
408    verifier: &str,
409    request_timeout: Duration,
410) -> Result<TokenSet, String> {
411    let body = form_body(&[
412        ("grant_type", "authorization_code"),
413        ("client_id", client_id),
414        ("redirect_uri", redirect_uri),
415        ("code", code),
416        ("code_verifier", verifier),
417    ]);
418    let token_url = format!("{}/connect/token", api_base.trim_end_matches('/'));
419    let (status, text) = post_token_form_with_timeout(
420        token_url,
421        body,
422        "exchange Parslee authorization code",
423        request_timeout,
424    )
425    .await?;
426    if !status.is_success() {
427        return Err(format!(
428            "Parslee token exchange failed: HTTP {status}: {text}"
429        ));
430    }
431    let token: TokenSet =
432        serde_json::from_str(&text).map_err(|e| format!("parse token response: {e}"))?;
433    if !token.token_type.eq_ignore_ascii_case("bearer") {
434        return Err(format!(
435            "unexpected Parslee token_type `{}`",
436            token.token_type
437        ));
438    }
439    Ok(token)
440}
441
442static AUTH_STATE_MUTEX: std::sync::OnceLock<tokio::sync::Mutex<()>> = std::sync::OnceLock::new();
443
444async fn lock_auth_state_queue<'a>(
445    mutex: &'a tokio::sync::Mutex<()>,
446    timeout: Duration,
447) -> Result<tokio::sync::MutexGuard<'a, ()>, AuthOperationError> {
448    tokio::time::timeout(timeout, mutex.lock())
449        .await
450        .map_err(|_| {
451            AuthOperationError::CoordinationDeadline(format!(
452                "timed out waiting for the in-process Parslee credential coordinator after {}ms",
453                timeout.as_millis()
454            ))
455        })
456}
457
458async fn with_locked_state_classified<T, F>(operation: F) -> Result<T, AuthOperationError>
459where
460    T: Send + 'static,
461    F: FnOnce(StateCoordinator<SecretAuthStateStore>) -> Result<T, state::AuthStateError>
462        + Send
463        + 'static,
464{
465    let _process_guard = lock_auth_state_queue(
466        AUTH_STATE_MUTEX.get_or_init(|| tokio::sync::Mutex::new(())),
467        AUTH_COORDINATOR_QUEUE_TIMEOUT,
468    )
469    .await?;
470    tokio::task::spawn_blocking(move || {
471        let _file_guard = ProcessAuthLock::acquire()?;
472        operation(StateCoordinator::new(SecretAuthStateStore))
473    })
474    .await
475    .map_err(|error| {
476        AuthOperationError::Terminal(format!("Parslee credential worker failed: {error}"))
477    })?
478    .map_err(|error| match error {
479        state::AuthStateError::CoordinationDeadline(message) => {
480            AuthOperationError::CoordinationDeadline(message)
481        }
482        other => AuthOperationError::Terminal(other.to_string()),
483    })
484}
485
486async fn with_locked_state<T, F>(operation: F) -> Result<T, String>
487where
488    T: Send + 'static,
489    F: FnOnce(StateCoordinator<SecretAuthStateStore>) -> Result<T, state::AuthStateError>
490        + Send
491        + 'static,
492{
493    with_locked_state_classified(operation)
494        .await
495        .map_err(|error| error.to_string())
496}
497
498fn read_published_state_without_migration() -> Result<Option<AuthStateV2>, String> {
499    StateCoordinator::new(SecretAuthStateStore)
500        .read_published_snapshot()
501        .map_err(|error| error.to_string())
502}
503
504/// How long a resolved access token may be served from process memory before
505/// the credential store is consulted again.
506///
507/// Remote inference resolves the bearer on **every request**
508/// (`car-inference::remote`), and each resolution took the cross-process auth
509/// file lock and read the OS keychain. On macOS a keychain read can prompt, and
510/// the ACL is keyed to the caller's code signature — so an unsigned or freshly
511/// rebuilt binary re-prompts *per request*. A single 4-task coder-A/B run made
512/// 88 inference calls and therefore 88 keychain reads. Caching the resolved
513/// token collapses that to roughly one read per TTL.
514///
515/// 30s rather than the token's own lifetime (~1h) is deliberate. The cache is
516/// per-process, so a `car auth login`, `logout`, or org switch performed by a
517/// *different* process is invisible to it; a short TTL bounds that staleness
518/// to something a human won't notice, while still removing ~99% of the reads.
519/// Same-process mutations don't wait for the TTL — they call
520/// [`invalidate_access_token_cache`] directly.
521const TOKEN_CACHE_TTL: Duration = Duration::from_secs(30);
522
523struct CachedAccessToken {
524    access_token: String,
525    /// The token's own expiry (epoch seconds); 0 when the record carries none.
526    expires_at: u64,
527    cached_at: Instant,
528}
529
530static ACCESS_TOKEN_CACHE: OnceLock<Mutex<Option<CachedAccessToken>>> = OnceLock::new();
531
532fn access_token_cache() -> &'static Mutex<Option<CachedAccessToken>> {
533    ACCESS_TOKEN_CACHE.get_or_init(|| Mutex::new(None))
534}
535
536/// Drop any process-cached access token.
537///
538/// Called by every operation in this module that changes which credential is
539/// active — login, logout, refresh, org switch, account switch/removal — so a
540/// caller never has to wait out [`TOKEN_CACHE_TTL`] to see its own change.
541pub fn invalidate_access_token_cache() {
542    if let Ok(mut slot) = access_token_cache().lock() {
543        *slot = None;
544    }
545}
546
547/// A cached token, if one is still both fresh enough and far enough from its
548/// own expiry that the refresh path would not have replaced it anyway.
549fn cached_access_token() -> Option<String> {
550    let slot = access_token_cache().lock().ok()?;
551    let entry = slot.as_ref()?;
552    if entry.cached_at.elapsed() >= TOKEN_CACHE_TTL {
553        return None;
554    }
555    // Never serve something `access_token_refreshing` would consider expiring —
556    // otherwise the cache would suppress the refresh that keeps a long run alive.
557    if entry.expires_at > 0 && epoch_seconds() + REFRESH_SKEW_SECS >= entry.expires_at {
558        return None;
559    }
560    Some(entry.access_token.clone())
561}
562
563/// The last token this process resolved, **ignoring** [`TOKEN_CACHE_TTL`].
564///
565/// Only for the case where the credential store could not be READ. The TTL
566/// exists so a credential change made by another process becomes visible
567/// quickly; that reasoning does not apply when the store is unreadable, because
568/// then there is no fresher answer to defer to. Expiry is deliberately not
569/// checked either — a stale token yields an actionable 401, whereas returning
570/// `None` fabricates a sign-out the user never performed.
571fn last_known_access_token() -> Option<String> {
572    let slot = access_token_cache().lock().ok()?;
573    slot.as_ref().map(|entry| entry.access_token.clone())
574}
575
576fn store_access_token(access_token: &str, expires_at: u64) {
577    if let Ok(mut slot) = access_token_cache().lock() {
578        *slot = Some(CachedAccessToken {
579            access_token: access_token.to_string(),
580            expires_at,
581            cached_at: Instant::now(),
582        });
583    }
584}
585
586/// Current access token (env override first, then authoritative V2 record).
587///
588/// This synchronous path never reads the import-only legacy token slot. Async
589/// callers that need migration or refresh use [`access_token_refreshing`].
590///
591/// **Deliberately uncached.** [`TOKEN_CACHE_TTL`] exists for the per-request
592/// inference path; this reader is the one whose callers depend on a published
593/// tombstone or an invalid record taking effect *immediately* (fail-closed),
594/// and it is not hot enough to be worth trading that for. Keep it reading the
595/// authoritative record every call.
596pub fn access_token() -> Option<String> {
597    if let Ok(token) = std::env::var(PARSLEE_ACCESS_TOKEN_KEY) {
598        if !token.is_empty() {
599            return Some(token);
600        }
601    }
602    read_published_state_without_migration()
603        .ok()
604        .flatten()
605        .and_then(|state| state.active.map(|active| active.access_token))
606}
607
608/// Whether Parslee inference may enter request-time credential reconciliation.
609///
610/// Environment injection wins. A published V2 record is authoritative,
611/// including a signed-out tombstone. Only when V2 has never been published may
612/// an old fixed-slot token keep managed aliases routable; the request-time
613/// async reader will then migrate attributable legacy state under the auth
614/// coordinator lock. This existence-only probe never returns the bearer.
615pub fn access_token_is_available() -> bool {
616    if std::env::var(PARSLEE_ACCESS_TOKEN_KEY).is_ok_and(|token| !token.is_empty()) {
617        return true;
618    }
619    match read_published_state_without_migration() {
620        Ok(Some(state)) => state.active.is_some(),
621        Ok(None) => {
622            let legacy_available = car_secrets::SecretStore::new()
623                .status(&car_secrets::SecretRef::with_default_service(
624                    PARSLEE_ACCESS_TOKEN_KEY,
625                ))
626                .is_ok_and(|status| status.exists);
627            // Finish with the authoritative read. If logout publishes its
628            // tombstone while the legacy probe is in flight, this later read
629            // observes it instead of reviving the stale fixed slot.
630            match read_published_state_without_migration() {
631                Ok(Some(state)) => state.active.is_some(),
632                Ok(None) => legacy_available,
633                Err(_) => false,
634            }
635        }
636        Err(_) => false,
637    }
638}
639
640/// Current durable generation of the active Parslee credential identity.
641///
642/// Callers reconciling a browser attempt must use [`auth_completion_status`]
643/// instead, which reads generation and lifecycle from one locked snapshot.
644pub async fn auth_generation() -> Result<u64, String> {
645    with_locked_state(|coordinator| Ok(coordinator.read_snapshot()?.generation)).await
646}
647
648/// Read the latest browser completion without refreshing or mutating tokens.
649///
650/// Callers reconciling a browser attempt must use [`auth_completion_status`]
651/// instead, which cannot race this read against a separate generation read.
652pub async fn auth_completion() -> Result<Option<AuthCompletionRecord>, String> {
653    with_locked_state(|coordinator| Ok(coordinator.read_snapshot()?.completion)).await
654}
655
656/// Atomically reserve one browser login attempt during `auth.start`.
657/// Publishing a newer reservation advances the credential generation and
658/// permanently fences every older completion before any code can be redeemed.
659pub async fn reserve_login_attempt(attempt_id: &str) -> Result<LoginAttemptLease, String> {
660    reserve_login_attempt_classified(attempt_id)
661        .await
662        .map_err(|error| error.to_string())
663}
664
665/// Reserve a login attempt while preserving a typed pre-operation deadline.
666pub async fn reserve_login_attempt_classified(
667    attempt_id: &str,
668) -> Result<LoginAttemptLease, AuthOperationError> {
669    let attempt_id = attempt_id.to_string();
670    with_locked_state_classified(move |coordinator| {
671        let expires_at =
672            epoch_millis().saturating_add(LOGIN_ATTEMPT_CALLBACK_TTL.as_millis() as u64);
673        coordinator.reserve_login_attempt(&attempt_id, expires_at)
674    })
675    .await
676}
677
678/// Atomically claim one exact awaiting-callback attempt for a single daemon
679/// worker. Duplicate, missing, stale, or expired attempts fail before OAuth
680/// token exchange.
681pub async fn claim_login_attempt(
682    attempt_id: &str,
683    daemon_owner_id: &str,
684) -> Result<LoginAttemptLease, String> {
685    claim_login_attempt_classified(attempt_id, daemon_owner_id)
686        .await
687        .map_err(|error| error.to_string())
688}
689
690/// Claim a login attempt while preserving a typed pre-operation deadline.
691pub async fn claim_login_attempt_classified(
692    attempt_id: &str,
693    daemon_owner_id: &str,
694) -> Result<LoginAttemptLease, AuthOperationError> {
695    let attempt_id = attempt_id.to_string();
696    let daemon_owner_id = daemon_owner_id.to_string();
697    with_locked_state_classified(move |coordinator| {
698        coordinator.claim_login_attempt_now(&attempt_id, &daemon_owner_id)
699    })
700    .await
701}
702
703/// Persist a terminal result only while the worker still owns its exact fence.
704pub async fn fail_login_attempt(
705    lease: &LoginAttemptLease,
706    failure: AuthAttemptFailure,
707) -> Result<bool, String> {
708    let lease = lease.clone();
709    with_locked_state(move |coordinator| {
710        Ok(matches!(
711            coordinator.fail_login_attempt(&lease, failure)?,
712            CasOutcome::Committed
713        ))
714    })
715    .await
716}
717
718/// One local-only, coordinator-locked completion/lifecycle snapshot.
719///
720/// This never refreshes or calls the network. Matching expired or old-daemon
721/// redeeming attempts are atomically closed before the typed status returns.
722pub async fn auth_completion_status(
723    attempt_id: &str,
724    daemon_owner_id: &str,
725) -> Result<AuthCompletionStatus, String> {
726    auth_completion_status_classified(attempt_id, daemon_owner_id)
727        .await
728        .map_err(|error| error.to_string())
729}
730
731/// Read completion proof while preserving a typed pre-operation deadline.
732pub async fn auth_completion_status_classified(
733    attempt_id: &str,
734    daemon_owner_id: &str,
735) -> Result<AuthCompletionStatus, AuthOperationError> {
736    let attempt_id = attempt_id.to_string();
737    let daemon_owner_id = daemon_owner_id.to_string();
738    with_locked_state_classified(move |coordinator| {
739        coordinator.completion_status_from_published_now(&attempt_id, &daemon_owner_id)
740    })
741    .await
742}
743
744/// Atomically publish a newly-authorized login and its attempt-bound completion.
745///
746/// The final critical section performs no network I/O and refuses a lease
747/// invalidated by a newer attempt or identity mutation. `None` remains the
748/// in-process CLI compatibility path and itself invalidates any outstanding
749/// browser lease.
750pub async fn commit_login(
751    api_base: &str,
752    token: &TokenSet,
753    session: &str,
754    lease: Option<LoginAttemptLease>,
755) -> Result<AuthCompletionRecord, String> {
756    let identity = session_identity(session)?;
757    let credentials = ActiveCredentials {
758        account_id: identity.id.clone(),
759        email: identity.email,
760        name: identity.name,
761        access_token: token.access_token.clone(),
762        refresh_token: Some(token.refresh_token.clone()),
763        expires_at: epoch_seconds().saturating_add(token.expires_in),
764        api_base: api_base.trim_end_matches('/').to_string(),
765    };
766    let full_session = session.to_string();
767    let completion_session = lease.as_ref().map(|_| full_session.clone());
768    let state = with_locked_state(move |coordinator| {
769        coordinator.commit_login_now(credentials, completion_session, lease)
770    })
771    .await;
772    // Before `?`: a login that failed mid-commit must not leave a previous
773    // account's bearer cached either.
774    invalidate_access_token_cache();
775    let state = state?;
776    Ok(AuthCompletionRecord {
777        attempt_id: state
778            .completion
779            .as_ref()
780            .map(|record| record.attempt_id.clone())
781            .unwrap_or_default(),
782        generation: state.generation,
783        account_id: state.active.map(|active| active.account_id),
784        session: Some(full_session),
785    })
786}
787
788/// Publish a signed-out tombstone before best-effort legacy cleanup.
789pub async fn logout() -> Result<(), String> {
790    let result = with_locked_state(|coordinator| coordinator.logout().map(|_| ())).await;
791    // Unconditional, including on error: a partially-applied logout must not
792    // leave this process serving the bearer it just tried to revoke.
793    invalidate_access_token_cache();
794    result
795}
796
797/// Seconds before the stored expiry at which [`access_token_refreshing`]
798/// proactively refreshes — absorbs clock skew plus a slow request. Public so
799/// the daemon's `load_or_refresh` shares the same threshold (#320).
800pub const REFRESH_SKEW_SECS: u64 = 120;
801
802/// Result of a [`refresh_grant`]. The gateway may omit a rotated refresh
803/// token (reuse the prior one) and/or an expiry, so both are optional.
804#[derive(Debug, Clone)]
805pub struct RefreshedTokens {
806    pub access_token: String,
807    pub refresh_token: Option<String>,
808    pub expires_in: Option<u64>,
809}
810
811/// `refresh_token` grant against `/connect/token`. Network-only — the
812/// caller persists. Mirrors the Parslee gateway contract used by the
813/// daemon's own refresh path (`car-server-core::parslee_auth`): the
814/// gateway treats this as a public-client grant, so no `client_id` is
815/// sent. This lives in `car-auth` (not `car-server-core`) so the
816/// request-time inference path — which cannot depend on `car-server-core`
817/// — shares one definition of "mint a fresh Parslee bearer" (#313).
818pub async fn refresh_grant(api_base: &str, refresh_token: &str) -> Result<RefreshedTokens, String> {
819    refresh_grant_with_timeout(api_base, refresh_token, PARSLEE_TOKEN_REQUEST_TIMEOUT).await
820}
821
822async fn refresh_grant_with_timeout(
823    api_base: &str,
824    refresh_token: &str,
825    request_timeout: Duration,
826) -> Result<RefreshedTokens, String> {
827    #[derive(Deserialize)]
828    struct Resp {
829        access_token: String,
830        #[serde(default)]
831        refresh_token: Option<String>,
832        #[serde(default)]
833        expires_in: Option<u64>,
834    }
835    let body = form_body(&[
836        ("grant_type", "refresh_token"),
837        ("refresh_token", refresh_token),
838    ]);
839    let token_url = format!("{}/connect/token", api_base.trim_end_matches('/'));
840    let (status, text) =
841        post_token_form_with_timeout(token_url, body, "refresh Parslee token", request_timeout)
842            .await?;
843    if !status.is_success() {
844        return Err(format!("refresh Parslee token: HTTP {status}: {text}"));
845    }
846    let r: Resp =
847        serde_json::from_str(&text).map_err(|e| format!("parse Parslee token response: {e}"))?;
848    Ok(RefreshedTokens {
849        access_token: r.access_token,
850        refresh_token: r.refresh_token,
851        expires_in: r.expires_in,
852    })
853}
854
855async fn active_state_for_network() -> Result<Option<ActiveCredentials>, String> {
856    with_locked_state(|coordinator| Ok(coordinator.read_snapshot()?.active)).await
857}
858
859/// The compare half of the refresh CAS, taken from the credential the caller
860/// read before going to the network.
861fn refresh_cas(current: &ActiveCredentials) -> RefreshCas {
862    RefreshCas {
863        account_id: current.account_id.clone(),
864        access_token: current.access_token.clone(),
865        refresh_token: current.refresh_token.clone(),
866    }
867}
868
869async fn commit_refreshed_credentials(
870    expected: RefreshCas,
871    api_base: String,
872    tokens: RefreshedTokens,
873    generation_change: bool,
874) -> Result<CasOutcome, String> {
875    let refreshed = RefreshedCredentials {
876        access_token: tokens.access_token,
877        refresh_token: tokens.refresh_token,
878        expires_at: tokens
879            .expires_in
880            .map(|seconds| epoch_seconds().saturating_add(seconds)),
881        api_base,
882        generation_change,
883    };
884    let outcome =
885        with_locked_state(move |coordinator| coordinator.commit_refresh(&expected, refreshed))
886            .await;
887    // The active credential just changed (or lost a CAS race to someone who
888    // changed it); either way the cached bearer is stale.
889    invalidate_access_token_cache();
890    outcome
891}
892
893/// Current access token, **proactively refreshed** when the stored token
894/// is within [`REFRESH_SKEW_SECS`] of expiry (or already expired) and a
895/// refresh token is available. The `PARSLEE_ACCESS_TOKEN` env override
896/// always wins and is never refreshed — it's a deliberate injection for
897/// tests/CI. Returns `None` only when no token is available at all.
898///
899/// Request-time consumers (notably `car-inference`) should call this
900/// instead of [`access_token`]: it's the difference between a lapsed
901/// token producing a 401 burst that poisons 30-day model health and a
902/// transparent refresh-and-proceed (#313).
903/// Why there is no usable Parslee access token — for ERROR MESSAGES, not for
904/// control flow.
905///
906/// [`access_token_refreshing`] returns a bare `Option`, so every failure renders
907/// as "no credential … run `car auth login`". That reads as *never
908/// authenticated*, and the three states below need different remedies: a token
909/// that aged out mid-run is not the same problem as a signed-out account, and
910/// neither is a keychain that momentarily could not be read. A long job dying
911/// on the first with the message for the second is Parslee-ai/car#797.
912///
913/// Consulted only on the failure path, so the extra store read costs nothing in
914/// the hot path.
915#[derive(Debug, Clone, PartialEq, Eq)]
916pub enum CredentialState {
917    /// Credentials exist and the access token is not past expiry.
918    Active,
919    /// Credentials exist but the access token is expired (or within the refresh
920    /// skew) and refresh did not yield a new one — commonly because the refresh
921    /// token itself is spent, or the network refused.
922    Expired { expires_at: u64 },
923    /// A published tombstone: no account is active. This is the only state that
924    /// genuinely means "log in".
925    SignedOut,
926    /// The credential store could not be read at all (locked keychain, helper
927    /// timeout). Says nothing about whether credentials exist.
928    Unreadable(String),
929}
930
931/// Seconds of life left in the active access token, for callers that want to
932/// warn *before* a long operation dies rather than diagnose it afterwards.
933///
934/// `None` means there is nothing to warn about, for any of three different
935/// reasons deliberately collapsed here: no active session, no stored expiry, or
936/// a `PARSLEE_ACCESS_TOKEN` override (which CAR never refreshes and whose
937/// lifetime it does not know). Callers wanting to distinguish those want
938/// [`credential_state`] instead — this answers only "how long have I got".
939///
940/// `Some(0)` means already past expiry. Note that a token inside
941/// [`REFRESH_SKEW_SECS`] is normally refreshed transparently on use, so a small
942/// number here is not by itself a failure — it is a reason to expect a refresh,
943/// and a reason to care whether that refresh can succeed. What killed the
944/// multi-hour sweep in Parslee-ai/car#797 was the refresh failing, with the job
945/// already hours in and no earlier signal that the deadline was coming.
946pub async fn access_token_lifetime_remaining() -> Option<u64> {
947    if std::env::var(PARSLEE_ACCESS_TOKEN_KEY).is_ok_and(|tok| !tok.is_empty()) {
948        return None;
949    }
950    let current = active_state_for_network().await.ok()??;
951    if current.expires_at == 0 {
952        return None;
953    }
954    Some(current.expires_at.saturating_sub(epoch_seconds()))
955}
956
957/// Classify the current credential state. See [`CredentialState`].
958pub async fn credential_state() -> CredentialState {
959    if let Ok(tok) = std::env::var(PARSLEE_ACCESS_TOKEN_KEY) {
960        if !tok.is_empty() {
961            return CredentialState::Active;
962        }
963    }
964    match active_state_for_network().await {
965        Ok(Some(current)) => {
966            let expiring =
967                current.expires_at > 0 && epoch_seconds() + REFRESH_SKEW_SECS >= current.expires_at;
968            if expiring {
969                CredentialState::Expired {
970                    expires_at: current.expires_at,
971                }
972            } else {
973                CredentialState::Active
974            }
975        }
976        Ok(None) => CredentialState::SignedOut,
977        Err(e) => CredentialState::Unreadable(e),
978    }
979}
980
981pub async fn access_token_refreshing() -> Option<String> {
982    // Env override wins and is never refreshed.
983    if let Ok(tok) = std::env::var(PARSLEE_ACCESS_TOKEN_KEY) {
984        if !tok.is_empty() {
985            return Some(tok);
986        }
987    }
988    // The hot path: remote inference resolves the bearer per request, and the
989    // read below takes the cross-process auth lock AND the OS keychain. Serve a
990    // recently-resolved, not-yet-expiring token from memory instead. See
991    // TOKEN_CACHE_TTL for why this is bounded at 30s rather than token lifetime.
992    if let Some(token) = cached_access_token() {
993        return Some(token);
994    }
995    let current = match active_state_for_network().await {
996        Ok(Some(value)) => value,
997        Ok(None) => return None,
998        Err(error) => {
999            // A read ERROR is not evidence of sign-out. `Ok(None)` is — that is
1000            // a published tombstone, a positive statement that no account is
1001            // active. An `Err` means the store was momentarily unreadable (a
1002            // keychain helper that timed out, lock contention), which says
1003            // nothing about whether credentials exist.
1004            //
1005            // Treating the two alike turned a single transient hiccup into
1006            // `no inference backend is available`, and the caller abandons the
1007            // session: one timeout in 59 requests killed a 29-minute agent run
1008            // whose credentials were present and valid the whole time.
1009            //
1010            // So fall back to the last token this process resolved, ignoring the
1011            // TTL — it may be stale, in which case the server rejects it and the
1012            // caller sees a real 401 it can act on, which is strictly better
1013            // information than a fabricated "signed out". With nothing cached we
1014            // still report None, because then we genuinely do not know.
1015            if let Some(token) = last_known_access_token() {
1016                eprintln!(
1017                    "car-auth: credential store unreadable ({error}); using the last token this \
1018                     process resolved. If requests start failing with 401, re-run `car auth login`."
1019                );
1020                return Some(token);
1021            }
1022            eprintln!("car-auth: cannot read Parslee credentials ({error})");
1023            return None;
1024        }
1025    };
1026    let current_access = current.access_token.clone();
1027    let expiring =
1028        current.expires_at > 0 && epoch_seconds() + REFRESH_SKEW_SECS >= current.expires_at;
1029    if !expiring {
1030        store_access_token(&current_access, current.expires_at);
1031        return Some(current_access);
1032    }
1033    let Some(refresh) = current.refresh_token.clone() else {
1034        return Some(current_access);
1035    };
1036    let base = current.api_base.clone();
1037    let expected = refresh_cas(&current);
1038    match refresh_grant(&base, &refresh).await {
1039        Ok(tokens) => {
1040            let access = tokens.access_token.clone();
1041            match commit_refreshed_credentials(expected, base, tokens, false).await {
1042                Ok(CasOutcome::Committed) => Some(access),
1043                Ok(CasOutcome::Conflict) => active_state_for_network()
1044                    .await
1045                    .ok()
1046                    .flatten()
1047                    .map(|active| active.access_token),
1048                Err(error) => {
1049                    eprintln!(
1050                        "car-auth: refreshed Parslee token could not be committed; using current token ({error})"
1051                    );
1052                    Some(current_access)
1053                }
1054            }
1055        }
1056        // Refresh failed (expired refresh token / offline): fall back to the
1057        // stored access token and let the server decide. No worse than today
1058        // — a still-valid access token keeps working — but WARN so a lapsed
1059        // session that then 401s downstream is diagnosable, rather than the
1060        // refresh failing silently ("why didn't it refresh?").
1061        Err(e) => {
1062            eprintln!("car-auth: proactive Parslee token refresh failed; using stored token (it may 401 — re-run `car auth login`) ({e})");
1063            Some(current_access)
1064        }
1065    }
1066}
1067
1068/// Unconditionally refresh the Parslee bearer, for the **reactive 401**
1069/// path. [`access_token_refreshing`] only refreshes inside a proactive
1070/// window keyed on the stored expiry — but a token can be revoked or
1071/// invalidated server-side *before* its advertised expiry, and a token
1072/// stored without an expiry never enters that window at all. When a live
1073/// request is rejected with 401/403, the caller invokes this to mint a
1074/// fresh bearer and retry once, instead of letting the failure poison
1075/// 30-day model health (#313).
1076///
1077/// Returns the new access token, or `None` when there is no refresh token
1078/// to use or the refresh itself fails. The `PARSLEE_ACCESS_TOKEN` env
1079/// override is authoritative and never refreshed (returns `None` so the
1080/// caller keeps using the injected token).
1081pub async fn force_refresh() -> Option<String> {
1082    if let Ok(tok) = std::env::var(PARSLEE_ACCESS_TOKEN_KEY) {
1083        if !tok.is_empty() {
1084            return None;
1085        }
1086    }
1087    let current = match active_state_for_network().await {
1088        Ok(Some(value)) => value,
1089        Ok(None) => return None,
1090        Err(error) => {
1091            eprintln!("car-auth: reactive Parslee refresh cannot read credentials ({error})");
1092            return None;
1093        }
1094    };
1095    let Some(refresh) = current.refresh_token.clone() else {
1096        eprintln!(
1097            "car-auth: reactive Parslee refresh: no refresh token stored — run `car auth login`"
1098        );
1099        return None;
1100    };
1101    let base = current.api_base.clone();
1102    let expected = refresh_cas(&current);
1103    match refresh_grant(&base, &refresh).await {
1104        Ok(tokens) => {
1105            let access = tokens.access_token.clone();
1106            match commit_refreshed_credentials(expected, base, tokens, false).await {
1107                Ok(CasOutcome::Committed) => Some(access),
1108                Ok(CasOutcome::Conflict) => active_state_for_network()
1109                    .await
1110                    .ok()
1111                    .flatten()
1112                    .map(|active| active.access_token),
1113                Err(error) => {
1114                    eprintln!(
1115                        "car-auth: reactive Parslee refresh commit failed (401 will surface) ({error})"
1116                    );
1117                    None
1118                }
1119            }
1120        }
1121        Err(e) => {
1122            eprintln!("car-auth: reactive Parslee token refresh failed (401 will surface) — re-run `car auth login` ({e})");
1123            None
1124        }
1125    }
1126}
1127
1128/// Resolve the API base: explicit override → process environment →
1129/// authoritative V2 record → default.
1130///
1131/// The legacy keychain slot is import-only and is never consulted here.
1132pub fn api_base(override_: Option<&str>) -> String {
1133    override_
1134        .map(str::to_string)
1135        .or_else(|| {
1136            std::env::var(PARSLEE_API_BASE_KEY)
1137                .ok()
1138                .filter(|value| !value.trim().is_empty())
1139        })
1140        .or_else(|| {
1141            read_published_state_without_migration()
1142                .ok()
1143                .flatten()
1144                .and_then(|state| state.active.map(|active| active.api_base))
1145        })
1146        .unwrap_or_else(|| DEFAULT_API_BASE.to_string())
1147        .trim_end_matches('/')
1148        .to_string()
1149}
1150
1151/// Fetch the Parslee session JSON for the stored token. Returns the
1152/// raw response body (the caller renders it). `Ok(None)` = not signed in.
1153pub async fn fetch_status(api_base_override: Option<&str>) -> Result<Option<String>, String> {
1154    // Access tokens are short-lived (~15 min). Reading the stored one raw made
1155    // `car auth status` report a stale "not authenticated" / HTTP 401 for a
1156    // login that is perfectly healthy and one refresh away — the CLI said
1157    // signed-out while the daemon, which does refresh, showed an active org.
1158    // Status is a QUESTION about the session, so it should answer with the
1159    // session's real state rather than whatever happened to be cached.
1160    let Some(access) = access_token_refreshing().await else {
1161        return Ok(None);
1162    };
1163    let base = api_base(api_base_override);
1164    let url = format!("{}/connect/session", base.trim_end_matches('/'));
1165    let client = reqwest::Client::builder()
1166        .timeout(PARSLEE_STATUS_REQUEST_TIMEOUT)
1167        .build()
1168        .map_err(|error| format!("build Parslee session client: {error}"))?;
1169
1170    let mut response = client
1171        .get(&url)
1172        .bearer_auth(&access)
1173        .send()
1174        .await
1175        .map_err(|e| format!("fetch Parslee session: {e}"))?;
1176
1177    // The proactive refresh above goes on expiry math; a token can still be
1178    // rejected (revoked, rotated, clock skew). One reactive refresh + retry,
1179    // matching the inference and Studio paths.
1180    if response.status() == reqwest::StatusCode::UNAUTHORIZED {
1181        if let Some(fresh) = force_refresh().await {
1182            response = client
1183                .get(&url)
1184                .bearer_auth(&fresh)
1185                .send()
1186                .await
1187                .map_err(|e| format!("fetch Parslee session: {e}"))?;
1188        }
1189    }
1190
1191    let status = response.status();
1192    let text = response
1193        .text()
1194        .await
1195        .map_err(|e| format!("read Parslee session response: {e}"))?;
1196    if !status.is_success() {
1197        return Err(format!(
1198            "Parslee session check failed: HTTP {status}: {text}"
1199        ));
1200    }
1201    Ok(Some(text))
1202}
1203
1204/// Fetch `/connect/session` with an explicitly supplied access token.
1205///
1206/// This is intentionally non-refreshing and non-persisting. Browser completion
1207/// uses it before touching the active credential slots so the account identity
1208/// is known before the mutation begins.
1209pub async fn fetch_status_with_access(
1210    api_base: &str,
1211    access_token: &str,
1212) -> Result<String, String> {
1213    fetch_status_with_access_timeout(api_base, access_token, PARSLEE_STATUS_REQUEST_TIMEOUT).await
1214}
1215
1216async fn fetch_status_with_access_timeout(
1217    api_base: &str,
1218    access_token: &str,
1219    request_timeout: Duration,
1220) -> Result<String, String> {
1221    let url = format!("{}/connect/session", api_base.trim_end_matches('/'));
1222    let client = reqwest::Client::builder()
1223        .timeout(request_timeout)
1224        .build()
1225        .map_err(|e| format!("build Parslee session client: {e}"))?;
1226    let response = client
1227        .get(url)
1228        .bearer_auth(access_token)
1229        .send()
1230        .await
1231        .map_err(|e| {
1232            if e.is_timeout() {
1233                format!(
1234                    "fetch Parslee session timed out after {}ms",
1235                    request_timeout.as_millis()
1236                )
1237            } else {
1238                format!("fetch Parslee session: {e}")
1239            }
1240        })?;
1241    let status = response.status();
1242    let text = response.text().await.map_err(|e| {
1243        if e.is_timeout() {
1244            format!(
1245                "read Parslee session response timed out after {}ms",
1246                request_timeout.as_millis()
1247            )
1248        } else {
1249            format!("read Parslee session response: {e}")
1250        }
1251    })?;
1252    if !status.is_success() {
1253        return Err(format!(
1254            "Parslee session check failed: HTTP {status}: {text}"
1255        ));
1256    }
1257    Ok(text)
1258}
1259
1260/// Set the account's active organization (bearer `PUT /accounts/me/active-org`).
1261///
1262/// This changes the account-level `active_org_id` PREFERENCE server-side and
1263/// validates membership. It does NOT re-scope the currently-stored access
1264/// token — the token's `active_org` claim (what inference reads) is fixed at
1265/// mint time, so a caller who wants the switch to take effect for inference
1266/// must re-authorize afterward to mint a token bound to the new org. Returns
1267/// the raw `AccountResponse` body on success.
1268pub async fn set_active_org(
1269    api_base_override: Option<&str>,
1270    organization_id: &str,
1271) -> Result<String, String> {
1272    let Some(access) = access_token_refreshing().await else {
1273        return Err("not signed in".to_string());
1274    };
1275    let base = api_base(api_base_override);
1276    // reqwest is built without the `json` feature, so serialize by hand.
1277    set_active_org_with_access(&base, &access, organization_id).await
1278}
1279
1280async fn set_active_org_with_access(
1281    base: &str,
1282    access_token: &str,
1283    organization_id: &str,
1284) -> Result<String, String> {
1285    let body = serde_json::json!({ "organizationId": organization_id }).to_string();
1286    let response = reqwest::Client::builder()
1287        .timeout(PARSLEE_STATUS_REQUEST_TIMEOUT)
1288        .build()
1289        .map_err(|error| format!("build set-active-org client: {error}"))?
1290        .put(format!(
1291            "{}/api/v1/accounts/me/active-org",
1292            base.trim_end_matches('/')
1293        ))
1294        .bearer_auth(access_token)
1295        .header("content-type", "application/json")
1296        .body(body)
1297        .send()
1298        .await
1299        .map_err(|e| format!("set active org: {e}"))?;
1300    let status = response.status();
1301    let text = response
1302        .text()
1303        .await
1304        .map_err(|e| format!("read set-active-org response: {e}"))?;
1305    if !status.is_success() {
1306        return Err(format!("set active org failed: HTTP {status}: {text}"));
1307    }
1308    Ok(text)
1309}
1310
1311/// Switch the active organization **silently** by minting a fresh token
1312/// scoped to `org_id` via the refresh grant's `organization_id` override
1313/// (`/connect/token`, `grant_type=refresh_token`). The backend validates
1314/// membership and stamps `active_org=org_id` on the new access token — which
1315/// is what inference reads — so the switch takes effect without a browser
1316/// re-authorization. Rotated tokens are persisted to the keychain. Also
1317/// best-effort updates the account's default org so a future fresh sign-in
1318/// lands in the same place.
1319pub async fn switch_org(api_base_override: Option<&str>, org_id: &str) -> Result<(), String> {
1320    #[derive(Deserialize)]
1321    struct Resp {
1322        access_token: String,
1323        #[serde(default)]
1324        refresh_token: Option<String>,
1325        #[serde(default)]
1326        expires_in: Option<u64>,
1327    }
1328    let current = active_state_for_network()
1329        .await?
1330        .ok_or_else(|| "not signed in".to_string())?;
1331    let Some(refresh) = current.refresh_token.clone() else {
1332        return Err("not signed in".to_string());
1333    };
1334    let expected = refresh_cas(&current);
1335    let base = api_base_override
1336        .map(|value| value.trim_end_matches('/').to_string())
1337        .unwrap_or_else(|| current.api_base.clone());
1338    let body = form_body(&[
1339        ("grant_type", "refresh_token"),
1340        ("refresh_token", &refresh),
1341        ("organization_id", org_id),
1342    ]);
1343    let (status, text) = post_token_form_with_timeout(
1344        format!("{}/connect/token", base.trim_end_matches('/')),
1345        body,
1346        "switch Parslee organization token",
1347        PARSLEE_TOKEN_REQUEST_TIMEOUT,
1348    )
1349    .await?;
1350    if !status.is_success() {
1351        return Err(format!("switch org failed: HTTP {status}: {text}"));
1352    }
1353    let r: Resp =
1354        serde_json::from_str(&text).map_err(|e| format!("parse switch-org response: {e}"))?;
1355    let access_token = r.access_token.clone();
1356    let outcome = commit_refreshed_credentials(
1357        expected,
1358        base.clone(),
1359        RefreshedTokens {
1360            access_token: r.access_token,
1361            refresh_token: r.refresh_token,
1362            expires_in: r.expires_in,
1363        },
1364        true,
1365    )
1366    .await?;
1367    if outcome == CasOutcome::Conflict {
1368        return Err(
1369            "Parslee credentials changed while switching organizations; retry the switch".into(),
1370        );
1371    }
1372    // Keep the account's default org in sync (best-effort; the token is
1373    // already switched regardless of this call's outcome).
1374    let _ = set_active_org_with_access(&base, &access_token, org_id).await;
1375    Ok(())
1376}
1377
1378/// Non-secret metadata for one signed-in Parslee login.
1379#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1380pub struct AccountMeta {
1381    pub id: String,
1382    #[serde(default)]
1383    pub email: Option<String>,
1384    #[serde(default)]
1385    pub name: Option<String>,
1386    /// True for the login whose tokens are currently in the active slots.
1387    #[serde(default)]
1388    pub active: bool,
1389}
1390
1391struct SessionIdentity {
1392    id: String,
1393    email: Option<String>,
1394    name: Option<String>,
1395}
1396
1397fn session_identity(session: &str) -> Result<SessionIdentity, String> {
1398    let value: serde_json::Value =
1399        serde_json::from_str(session).map_err(|error| format!("parse session: {error}"))?;
1400    let account = value
1401        .get("Account")
1402        .or_else(|| value.get("account"))
1403        .ok_or_else(|| "session has no account".to_string())?;
1404    let field = |pascal: &str, camel: &str| {
1405        account
1406            .get(pascal)
1407            .or_else(|| account.get(camel))
1408            .and_then(serde_json::Value::as_str)
1409            .map(str::trim)
1410            .filter(|value| !value.is_empty())
1411            .map(str::to_string)
1412    };
1413    Ok(SessionIdentity {
1414        id: field("Id", "id").ok_or_else(|| "session has no account id".to_string())?,
1415        email: field("Email", "email"),
1416        name: field("Name", "name").or_else(|| field("DisplayName", "displayName")),
1417    })
1418}
1419
1420/// Parse the stable account id from a `/connect/session` response.
1421pub fn account_id_from_session(session: &str) -> Result<String, String> {
1422    session_identity(session).map(|identity| identity.id)
1423}
1424
1425/// Local pre-browser auth state. No network request or refresh occurs. The first
1426/// read may import an attributable legacy session into the authoritative V2
1427/// record; an ambiguous legacy marker reports signed-out and its orphan token is
1428/// discarded, so the caller can sign in again.
1429pub async fn local_auth_snapshot() -> Result<LocalAuthSnapshot, String> {
1430    let env_override_active = std::env::var(PARSLEE_ACCESS_TOKEN_KEY)
1431        .map(|value| !value.is_empty())
1432        .unwrap_or(false);
1433    if env_override_active {
1434        return Ok(LocalAuthSnapshot {
1435            authenticated: true,
1436            active_account_id: None,
1437        });
1438    }
1439    with_locked_state(|coordinator| {
1440        let state = coordinator.read_snapshot()?;
1441        Ok(LocalAuthSnapshot {
1442            authenticated: state.active.is_some(),
1443            active_account_id: state.active.map(|active| active.account_id),
1444        })
1445    })
1446    .await
1447}
1448
1449/// List every known login (`active` marks the current one). Migrates a
1450/// pre-multi-login session (tokens in the fixed slots, no registry entry) in.
1451pub async fn list_accounts(_api_base_override: Option<&str>) -> Result<Vec<AccountMeta>, String> {
1452    with_locked_state(|coordinator| Ok(coordinator.read_snapshot()?.account_meta())).await
1453}
1454
1455/// Switch the active login by publishing the selected account credential as
1456/// part of the same V2 record.
1457pub async fn switch_account(account_id: &str) -> Result<(), String> {
1458    let account_id = account_id.to_string();
1459    let result =
1460        with_locked_state(move |coordinator| coordinator.switch_account(&account_id).map(|_| ()))
1461            .await;
1462    invalidate_access_token_cache();
1463    result
1464}
1465
1466/// Remove a login (deletes its stashed tokens). If it was active, switch to
1467/// another remaining login, or clear the session when none remain.
1468pub async fn remove_account(account_id: &str) -> Result<Vec<AccountMeta>, String> {
1469    let account_id = account_id.to_string();
1470    let result = with_locked_state(move |coordinator| {
1471        Ok(coordinator.remove_account(&account_id)?.account_meta())
1472    })
1473    .await;
1474    invalidate_access_token_cache();
1475    result
1476}
1477
1478// First-login onboarding is intentionally NOT here. Brand-new users
1479// are routed through Parslee's existing hosted web consent/org page
1480// during the `/connect/authorize` browser hand-off (see m365dotnet
1481// `specs/draft/car-inference-gateway-auth.md` B6), so the token CAR
1482// redeems already carries `active_org`. CAR is a pure OAuth client and
1483// never touches consent — there is no `ensure_org`, by design.
1484
1485#[cfg(test)]
1486mod tests {
1487    use super::*;
1488    use std::ffi::OsString;
1489
1490    static AUTH_ENV_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
1491
1492    struct RestoredEnv {
1493        values: Vec<(&'static str, Option<OsString>)>,
1494    }
1495
1496    impl RestoredEnv {
1497        fn capture(keys: &[&'static str]) -> Self {
1498            Self {
1499                values: keys
1500                    .iter()
1501                    .map(|key| (*key, std::env::var_os(key)))
1502                    .collect(),
1503            }
1504        }
1505    }
1506
1507    impl Drop for RestoredEnv {
1508        fn drop(&mut self) {
1509            for (key, value) in self.values.drain(..) {
1510                match value {
1511                    Some(value) => std::env::set_var(key, value),
1512                    None => std::env::remove_var(key),
1513                }
1514            }
1515        }
1516    }
1517
1518    #[tokio::test]
1519    async fn auth_env_lock_survives_result_receiver_drop_until_owner_finishes() {
1520        let (holder_acquired_tx, holder_acquired_rx) = tokio::sync::oneshot::channel();
1521        let (release_tx, release_rx) = tokio::sync::oneshot::channel::<()>();
1522        let (owner_result_tx, owner_result_rx) = tokio::sync::oneshot::channel();
1523        let holder = tokio::spawn(async move {
1524            let _guard = AUTH_ENV_LOCK.lock().await;
1525            let _ = holder_acquired_tx.send(());
1526            let _ = release_rx.await;
1527            let _ = owner_result_tx.send(());
1528        });
1529        holder_acquired_rx.await.unwrap();
1530        drop(owner_result_rx);
1531
1532        let (contender_started_tx, contender_started_rx) = tokio::sync::oneshot::channel();
1533        let (contender_acquired_tx, mut contender_acquired_rx) = tokio::sync::oneshot::channel();
1534        let contender = tokio::spawn(async move {
1535            let _ = contender_started_tx.send(());
1536            let _guard = AUTH_ENV_LOCK.lock().await;
1537            let _ = contender_acquired_tx.send(());
1538        });
1539        contender_started_rx.await.unwrap();
1540
1541        assert!(
1542            tokio::time::timeout(
1543                std::time::Duration::from_millis(50),
1544                &mut contender_acquired_rx,
1545            )
1546            .await
1547            .is_err(),
1548            "a contender must not enter while the first future owns the environment lock"
1549        );
1550
1551        drop(release_tx);
1552        holder.await.unwrap();
1553        contender_acquired_rx.await.unwrap();
1554        contender.await.unwrap();
1555    }
1556
1557    #[test]
1558    fn local_auth_snapshot_omits_an_unattributable_active_account() {
1559        let snapshot = LocalAuthSnapshot {
1560            authenticated: true,
1561            active_account_id: None,
1562        };
1563
1564        assert_eq!(
1565            serde_json::to_value(snapshot).unwrap(),
1566            serde_json::json!({ "authenticated": true })
1567        );
1568    }
1569
1570    #[tokio::test]
1571    async fn coordinator_queue_wait_has_an_enforced_deadline() {
1572        let mutex = tokio::sync::Mutex::new(());
1573        let _held = mutex.lock().await;
1574        let timeout = Duration::from_millis(10);
1575        let error = lock_auth_state_queue(&mutex, timeout)
1576            .await
1577            .expect_err("a contended coordinator queue must fail at its own bound");
1578        assert!(
1579            matches!(error, AuthOperationError::CoordinationDeadline(_)),
1580            "bounded contention must stay typed as retryable: {error:?}"
1581        );
1582        let message = error.to_string();
1583        assert!(
1584            message.contains("in-process Parslee credential coordinator")
1585                && message.contains("10ms"),
1586            "{message}"
1587        );
1588    }
1589
1590    #[test]
1591    fn worker_lease_exceeds_the_serial_redemption_budget() {
1592        let composed_serial_budget = AUTH_STATE_OPERATION_BUDGET
1593            + AUTH_COMPLETION_NETWORK_DEADLINE
1594            + AUTH_COORDINATOR_QUEUE_TIMEOUT
1595            + AUTH_PROCESS_LOCK_TIMEOUT
1596            + AUTH_STATE_OPERATION_BUDGET;
1597
1598        assert_eq!(
1599            LOGIN_ATTEMPT_WORKER_SERIAL_BUDGET, composed_serial_budget,
1600            "serial redemption budget must compose every bounded phase exactly once"
1601        );
1602        assert!(
1603            LOGIN_ATTEMPT_WORKER_SCHEDULING_MARGIN > Duration::ZERO,
1604            "worker lease requires explicit positive scheduling margin"
1605        );
1606        assert_eq!(
1607            LOGIN_ATTEMPT_WORKER_TTL,
1608            LOGIN_ATTEMPT_WORKER_SERIAL_BUDGET + LOGIN_ATTEMPT_WORKER_SCHEDULING_MARGIN,
1609            "worker lease must be derived from the complete serial budget plus margin"
1610        );
1611    }
1612
1613    #[test]
1614    fn local_auth_snapshot_serializes_an_attributable_active_account() {
1615        let snapshot = LocalAuthSnapshot {
1616            authenticated: true,
1617            active_account_id: Some("account-1".to_string()),
1618        };
1619
1620        assert_eq!(
1621            serde_json::to_value(snapshot).unwrap(),
1622            serde_json::json!({
1623                "authenticated": true,
1624                "active_account_id": "account-1",
1625            })
1626        );
1627    }
1628
1629    #[test]
1630    fn pkce_challenge_is_s256_urlsafe_nopad() {
1631        let v = pkce_verifier();
1632        let c = pkce_challenge(&v);
1633        assert!(!c.contains('=') && !c.contains('+') && !c.contains('/'));
1634        assert_eq!(c, pkce_challenge(&v)); // deterministic
1635    }
1636
1637    #[test]
1638    fn authorize_url_has_pkce_and_provider() {
1639        let u = authorize_url(
1640            "https://api.parslee.ai/",
1641            "parslee-car",
1642            "http://localhost:8765/auth/callback",
1643            "st8",
1644            "chal",
1645            Some("microsoft"),
1646            Some("select_account"),
1647        )
1648        .unwrap();
1649        assert!(u.starts_with("https://api.parslee.ai/connect/authorize?"));
1650        assert!(u.contains("code_challenge=chal"));
1651        assert!(u.contains("code_challenge_method=S256"));
1652        assert!(u.contains("client_id=parslee-car"));
1653        assert!(u.contains("provider=microsoft"));
1654        assert!(u.contains("prompt=select_account"));
1655    }
1656
1657    #[test]
1658    fn api_base_precedence() {
1659        assert_eq!(api_base(Some("https://x.test/")), "https://x.test");
1660    }
1661
1662    #[test]
1663    fn api_base_environment_override_beats_persisted_state() {
1664        let _env_lock = AUTH_ENV_LOCK.blocking_lock();
1665        let _restore = RestoredEnv::capture(&["CAR_SECRETS_FILE_DIR", PARSLEE_API_BASE_KEY]);
1666        let directory = tempfile::tempdir().unwrap();
1667        std::env::set_var("CAR_SECRETS_FILE_DIR", directory.path());
1668        std::env::set_var(PARSLEE_API_BASE_KEY, "https://env.example/");
1669        SecretStore::new()
1670            .publish(
1671                &SecretRef::with_default_service(car_secrets::PARSLEE_AUTH_STATE_V2_KEY),
1672                &serde_json::json!({
1673                    "schema": 2,
1674                    "revision": 7,
1675                    "generation": 3,
1676                    "active": {
1677                        "account_id": "account-v2",
1678                        "access_token": "v2-access",
1679                        "expires_at": 9_999_999_999_u64,
1680                        "api_base": "https://persisted.example"
1681                    },
1682                    "accounts": [{
1683                        "account_id": "account-v2",
1684                        "access_token": "v2-access",
1685                        "expires_at": 9_999_999_999_u64,
1686                        "api_base": "https://persisted.example"
1687                    }]
1688                })
1689                .to_string(),
1690            )
1691            .unwrap();
1692
1693        assert_eq!(api_base(None), "https://env.example");
1694    }
1695
1696    /// The cache must never suppress the proactive refresh. A token inside
1697    /// `REFRESH_SKEW_SECS` of expiry has to fall through to the refresh path,
1698    /// or a long run keeps presenting a bearer the server is about to reject —
1699    /// which is how a mid-run token expiry fabricates losses (fix #6 in
1700    /// docs/coder-ab-results.md).
1701    #[test]
1702    fn cache_does_not_serve_a_token_that_is_due_for_refresh() {
1703        invalidate_access_token_cache();
1704        let nearly_expired = epoch_seconds() + REFRESH_SKEW_SECS / 2;
1705        store_access_token("about-to-expire", nearly_expired);
1706        assert_eq!(
1707            cached_access_token(),
1708            None,
1709            "a token inside the refresh skew must not be served from cache"
1710        );
1711
1712        invalidate_access_token_cache();
1713        store_access_token("good-for-hours", epoch_seconds() + 3_600);
1714        assert_eq!(cached_access_token().as_deref(), Some("good-for-hours"));
1715    }
1716
1717    /// A record with no stored expiry (`expires_at == 0`) is still cacheable —
1718    /// the refresh path treats 0 as "not expiring", so the cache must agree
1719    /// rather than falling through on every call and defeating itself.
1720    #[test]
1721    fn cache_serves_a_token_with_no_recorded_expiry() {
1722        invalidate_access_token_cache();
1723        store_access_token("no-expiry", 0);
1724        assert_eq!(cached_access_token().as_deref(), Some("no-expiry"));
1725    }
1726
1727    /// An unreadable store must not be mistaken for a sign-out. A published
1728    /// tombstone is a positive statement; an I/O error is absence of
1729    /// information, and conflating them let one keychain timeout in 59 requests
1730    /// kill a 29-minute agent run whose credentials were valid throughout.
1731    #[test]
1732    fn last_known_token_survives_the_ttl_for_the_unreadable_store_path() {
1733        invalidate_access_token_cache();
1734        assert_eq!(
1735            last_known_access_token(),
1736            None,
1737            "with nothing cached we genuinely do not know — report None"
1738        );
1739
1740        // Expired AND past any TTL: still the best available answer when the
1741        // store cannot be read, because a stale bearer yields an actionable 401
1742        // rather than a fabricated "signed out".
1743        store_access_token("stale-but-real", 1);
1744        assert_eq!(
1745            cached_access_token(),
1746            None,
1747            "the normal path must still refuse an expiring token"
1748        );
1749        assert_eq!(
1750            last_known_access_token().as_deref(),
1751            Some("stale-but-real"),
1752            "the unreadable-store path deliberately ignores TTL and expiry"
1753        );
1754
1755        // A real sign-out still clears it — this fallback must not resurrect a
1756        // credential the user revoked.
1757        invalidate_access_token_cache();
1758        assert_eq!(last_known_access_token(), None);
1759    }
1760
1761    /// Signing out must drop the cached bearer immediately rather than leaving
1762    /// this process to serve it until the TTL lapses.
1763    #[test]
1764    fn invalidate_clears_a_cached_token() {
1765        invalidate_access_token_cache();
1766        store_access_token("live", epoch_seconds() + 3_600);
1767        assert!(cached_access_token().is_some());
1768        invalidate_access_token_cache();
1769        assert_eq!(
1770            cached_access_token(),
1771            None,
1772            "logout / switch / refresh must not leave a stale bearer readable"
1773        );
1774    }
1775
1776    #[test]
1777    fn normal_readers_never_fall_back_to_conflicting_legacy_slots() {
1778        let _env_lock = AUTH_ENV_LOCK.blocking_lock();
1779        let _restore = RestoredEnv::capture(&[
1780            "CAR_SECRETS_FILE_DIR",
1781            PARSLEE_ACCESS_TOKEN_KEY,
1782            PARSLEE_API_BASE_KEY,
1783        ]);
1784        let directory = tempfile::tempdir().unwrap();
1785        std::env::set_var("CAR_SECRETS_FILE_DIR", directory.path());
1786        std::env::remove_var(PARSLEE_ACCESS_TOKEN_KEY);
1787        std::env::remove_var(PARSLEE_API_BASE_KEY);
1788
1789        let store = SecretStore::new();
1790        store
1791            .put(
1792                &SecretRef::with_default_service(PARSLEE_ACCESS_TOKEN_KEY),
1793                "legacy-access",
1794            )
1795            .unwrap();
1796        store
1797            .put(
1798                &SecretRef::with_default_service(PARSLEE_API_BASE_KEY),
1799                "https://legacy.example",
1800            )
1801            .unwrap();
1802        let state_ref = SecretRef::with_default_service(car_secrets::PARSLEE_AUTH_STATE_V2_KEY);
1803        assert!(
1804            access_token_is_available(),
1805            "a legacy token may enter the locked request-time migration path only before V2 exists"
1806        );
1807
1808        store
1809            .publish(
1810                &state_ref,
1811                &serde_json::json!({
1812                    "schema": 2,
1813                    "revision": 7,
1814                    "generation": 3,
1815                    "active": {
1816                        "account_id": "account-v2",
1817                        "access_token": "v2-access",
1818                        "refresh_token": "v2-refresh",
1819                        "expires_at": 9_999_999_999_u64,
1820                        "api_base": "https://v2.example"
1821                    },
1822                    "accounts": [{
1823                        "account_id": "account-v2",
1824                        "access_token": "v2-access",
1825                        "refresh_token": "v2-refresh",
1826                        "expires_at": 9_999_999_999_u64,
1827                        "api_base": "https://v2.example"
1828                    }],
1829                    "tombstone": false
1830                })
1831                .to_string(),
1832            )
1833            .unwrap();
1834        assert_eq!(access_token().as_deref(), Some("v2-access"));
1835        assert!(access_token_is_available());
1836        assert_eq!(api_base(None), "https://v2.example");
1837
1838        store
1839            .publish(
1840                &state_ref,
1841                r#"{"schema":2,"revision":8,"generation":4,"accounts":[],"tombstone":true}"#,
1842            )
1843            .unwrap();
1844        assert_eq!(access_token(), None);
1845        assert!(
1846            !access_token_is_available(),
1847            "a published tombstone must remain authoritative over the stale legacy token"
1848        );
1849        assert_eq!(api_base(None), DEFAULT_API_BASE);
1850
1851        store.publish(&state_ref, "{not-json").unwrap();
1852        assert_eq!(access_token(), None, "invalid V2 must fail closed");
1853        assert!(
1854            !access_token_is_available(),
1855            "an invalid V2 record must fail closed instead of reviving legacy"
1856        );
1857        assert_eq!(
1858            api_base(None),
1859            DEFAULT_API_BASE,
1860            "invalid V2 must not resurrect the legacy API base"
1861        );
1862    }
1863
1864    /// Hand-rolled loopback HTTP mock — no extra prod dep, no feature
1865    /// flags. Serves exactly `expected` one-shot requests, records
1866    /// what came in, and replies with whatever `respond` returns.
1867    /// Lets the networked auth fns be exercised end-to-end in CI
1868    /// without the real Parslee backend (or the OS keychain — the
1869    /// token is injected via the `PARSLEE_ACCESS_TOKEN` env override).
1870    mod mock {
1871        use std::io::{Read, Write};
1872        use std::net::TcpListener;
1873        use std::sync::{Arc, Mutex};
1874        use std::thread;
1875
1876        pub struct Recorded {
1877            pub method: String,
1878            pub path: String,
1879            pub authorization: Option<String>,
1880            #[allow(dead_code)] // captured for completeness; not asserted on in tests
1881            pub content_type: Option<String>,
1882            pub body: String,
1883        }
1884
1885        pub struct Mock {
1886            pub base: String,
1887            pub recorded: Arc<Mutex<Vec<Recorded>>>,
1888            handle: Option<thread::JoinHandle<()>>,
1889        }
1890
1891        impl Drop for Mock {
1892            fn drop(&mut self) {
1893                if let Some(h) = self.handle.take() {
1894                    let _ = h.join();
1895                }
1896            }
1897        }
1898
1899        fn find(hay: &[u8], needle: &[u8]) -> Option<usize> {
1900            hay.windows(needle.len()).position(|w| w == needle)
1901        }
1902
1903        pub fn start(
1904            expected: usize,
1905            respond: impl Fn(&Recorded) -> (u16, String) + Send + 'static,
1906        ) -> Mock {
1907            let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1908            let port = listener.local_addr().unwrap().port();
1909            let recorded = Arc::new(Mutex::new(Vec::new()));
1910            let rec = recorded.clone();
1911            // Bounded accept. `accept()` blocks forever when the expected
1912            // request never arrives — and `Drop` joins this thread, so the
1913            // whole test hangs rather than failing. That is reachable whenever
1914            // a client future is cancelled mid-connect, which is exactly what
1915            // an over-tight outer timeout used to do (car#727). A deadline
1916            // makes the thread always terminate, so `Drop` always returns.
1917            let handle = thread::spawn(move || {
1918                listener
1919                    .set_nonblocking(true)
1920                    .expect("mock listener nonblocking");
1921                for _ in 0..expected {
1922                    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
1923                    let mut stream = loop {
1924                        match listener.accept() {
1925                            Ok((stream, _)) => break stream,
1926                            Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
1927                                if std::time::Instant::now() >= deadline {
1928                                    // No client is coming. Leave quietly: the
1929                                    // test's own assertions decide pass/fail,
1930                                    // and panicking here would only surface as
1931                                    // an unhelpful join failure.
1932                                    return;
1933                                }
1934                                thread::sleep(std::time::Duration::from_millis(5));
1935                            }
1936                            Err(e) => panic!("mock accept failed: {e}"),
1937                        }
1938                    };
1939                    // Back to blocking for the request itself, with a read
1940                    // timeout so a half-open connection cannot wedge us either.
1941                    stream.set_nonblocking(false).expect("mock stream blocking");
1942                    stream
1943                        .set_read_timeout(Some(std::time::Duration::from_secs(30)))
1944                        .expect("mock stream read timeout");
1945                    let mut buf = Vec::new();
1946                    let mut tmp = [0u8; 1024];
1947                    loop {
1948                        let n = stream.read(&mut tmp).unwrap();
1949                        if n == 0 {
1950                            break;
1951                        }
1952                        buf.extend_from_slice(&tmp[..n]);
1953                        let Some(hdr_end) = find(&buf, b"\r\n\r\n") else {
1954                            continue;
1955                        };
1956                        let headers = String::from_utf8_lossy(&buf[..hdr_end]).into_owned();
1957                        let content_length = headers
1958                            .lines()
1959                            .find_map(|l| {
1960                                let (k, v) = l.split_once(':')?;
1961                                if k.eq_ignore_ascii_case("content-length") {
1962                                    v.trim().parse::<usize>().ok()
1963                                } else {
1964                                    None
1965                                }
1966                            })
1967                            .unwrap_or(0);
1968                        let body_start = hdr_end + 4;
1969                        while buf.len() < body_start + content_length {
1970                            let n = stream.read(&mut tmp).unwrap();
1971                            if n == 0 {
1972                                break;
1973                            }
1974                            buf.extend_from_slice(&tmp[..n]);
1975                        }
1976                        let mut header_lines = headers.lines();
1977                        let req_line = header_lines.next().unwrap_or("");
1978                        let mut rl = req_line.split_whitespace();
1979                        let method = rl.next().unwrap_or("").to_string();
1980                        let path = rl.next().unwrap_or("").to_string();
1981                        let mut authorization = None;
1982                        let mut content_type = None;
1983                        for l in header_lines {
1984                            if let Some((k, v)) = l.split_once(':') {
1985                                if k.eq_ignore_ascii_case("authorization") {
1986                                    authorization = Some(v.trim().to_string());
1987                                } else if k.eq_ignore_ascii_case("content-type") {
1988                                    content_type = Some(v.trim().to_string());
1989                                }
1990                            }
1991                        }
1992                        let body = String::from_utf8_lossy(
1993                            &buf[body_start..(body_start + content_length).min(buf.len())],
1994                        )
1995                        .into_owned();
1996                        let r = Recorded {
1997                            method,
1998                            path,
1999                            authorization,
2000                            content_type,
2001                            body,
2002                        };
2003                        let (code, resp_body) = respond(&r);
2004                        rec.lock().unwrap().push(r);
2005                        let resp = format!(
2006                            "HTTP/1.1 {code} OK\r\ncontent-type: application/json\r\n\
2007                             content-length: {}\r\nconnection: close\r\n\r\n{}",
2008                            resp_body.len(),
2009                            resp_body
2010                        );
2011                        stream.write_all(resp.as_bytes()).unwrap();
2012                        let _ = stream.flush();
2013                        break;
2014                    }
2015                }
2016            });
2017            Mock {
2018                base: format!("http://127.0.0.1:{port}"),
2019                recorded,
2020                handle: Some(handle),
2021            }
2022        }
2023    }
2024
2025    #[tokio::test]
2026    async fn exchange_code_round_trips_token() {
2027        let mock = mock::start(1, |_r| {
2028            (
2029                200,
2030                r#"{"access_token":"a","refresh_token":"r","expires_in":3600,"token_type":"Bearer"}"#
2031                    .to_string(),
2032            )
2033        });
2034        let token = exchange_code(
2035            &mock.base,
2036            "parslee-car",
2037            "http://localhost:1/cb",
2038            "thecode",
2039            "theverifier",
2040        )
2041        .await
2042        .unwrap();
2043        assert_eq!(token.access_token, "a");
2044        assert_eq!(token.refresh_token, "r");
2045        assert_eq!(token.expires_in, 3600);
2046
2047        let reqs = mock.recorded.lock().unwrap();
2048        assert_eq!(reqs.len(), 1);
2049        assert_eq!(reqs[0].method, "POST");
2050        assert_eq!(reqs[0].path, "/connect/token");
2051        assert!(reqs[0].body.contains("grant_type=authorization_code"));
2052        assert!(reqs[0].body.contains("code=thecode"));
2053        assert!(reqs[0].body.contains("code_verifier=theverifier"));
2054    }
2055
2056    /// The outer bound is a **liveness guard, not a timing assertion**.
2057    ///
2058    /// It exists only so a broken inner timeout fails the run instead of
2059    /// hanging it forever. It was 200ms against a 50ms inner timeout — a 4x
2060    /// margin — and under a loaded `cargo test --workspace` the scheduler
2061    /// routinely takes longer than that to wake the inner timer, so the outer
2062    /// bound won the race and the test failed (or wedged) on a machine-load
2063    /// property rather than a code property. Seen three times.
2064    ///
2065    /// A generous bound keeps the guard without the race: if the inner timeout
2066    /// never fires, the mock answers after 250ms, the call returns `Ok`, and
2067    /// `unwrap_err()` panics immediately — so the real failure path is still
2068    /// fast. The outer timeout only ever trips on a genuinely stuck future.
2069    const STUCK_FUTURE_GUARD: Duration = Duration::from_secs(30);
2070
2071    #[tokio::test]
2072    async fn exchange_code_stall_is_bounded_by_the_explicit_request_timeout() {
2073        let mock = mock::start(1, |_r| {
2074            std::thread::sleep(Duration::from_millis(250));
2075            (
2076                200,
2077                r#"{"access_token":"a","refresh_token":"r","expires_in":3600,"token_type":"Bearer"}"#
2078                    .to_string(),
2079            )
2080        });
2081
2082        let error = tokio::time::timeout(
2083            STUCK_FUTURE_GUARD,
2084            exchange_code_with_timeout(
2085                &mock.base,
2086                "parslee-car",
2087                "http://localhost:1/cb",
2088                "thecode",
2089                "theverifier",
2090                Duration::from_millis(50),
2091            ),
2092        )
2093        .await
2094        .expect("the explicit token request timeout must bound the stalled endpoint")
2095        .unwrap_err();
2096
2097        assert_eq!(
2098            error,
2099            "exchange Parslee authorization code timed out after 50ms"
2100        );
2101    }
2102
2103    #[tokio::test]
2104    async fn refresh_grant_round_trips_token() {
2105        // Gateway reuses the refresh token (omits it from the response) — the
2106        // `Option` fields must tolerate that.
2107        let mock = mock::start(1, |_r| {
2108            (
2109                200,
2110                r#"{"access_token":"a2","expires_in":3600,"token_type":"Bearer"}"#.to_string(),
2111            )
2112        });
2113        let tokens = refresh_grant(&mock.base, "the-refresh-token")
2114            .await
2115            .unwrap();
2116        assert_eq!(tokens.access_token, "a2");
2117        assert_eq!(tokens.refresh_token, None);
2118        assert_eq!(tokens.expires_in, Some(3600));
2119
2120        let reqs = mock.recorded.lock().unwrap();
2121        assert_eq!(reqs.len(), 1);
2122        assert_eq!(reqs[0].method, "POST");
2123        assert_eq!(reqs[0].path, "/connect/token");
2124        assert!(reqs[0].body.contains("grant_type=refresh_token"));
2125        assert!(reqs[0].body.contains("refresh_token=the-refresh-token"));
2126        // Public-client refresh: no client_id is sent (matches the daemon).
2127        assert!(!reqs[0].body.contains("client_id"));
2128    }
2129
2130    #[tokio::test]
2131    async fn fetch_status_sends_bearer() {
2132        let _env_lock = AUTH_ENV_LOCK.lock().await;
2133        let _restore = RestoredEnv::capture(&[PARSLEE_ACCESS_TOKEN_KEY]);
2134        // Inject the token via the env override so the keychain is
2135        // never touched. No other car-auth test reads this var.
2136        std::env::set_var(PARSLEE_ACCESS_TOKEN_KEY, "test-token");
2137
2138        let mock = mock::start(1, |_r| (200, r#"{"authenticated":true}"#.to_string()));
2139
2140        let session = fetch_status(Some(&mock.base)).await.unwrap();
2141        assert_eq!(session.as_deref(), Some(r#"{"authenticated":true}"#));
2142
2143        let reqs = mock.recorded.lock().unwrap();
2144        assert_eq!(reqs.len(), 1);
2145        let sess = &reqs[0];
2146        assert_eq!(sess.method, "GET");
2147        assert_eq!(sess.path, "/connect/session");
2148        assert_eq!(sess.authorization.as_deref(), Some("Bearer test-token"));
2149    }
2150
2151    #[tokio::test]
2152    async fn fetch_status_with_access_has_a_total_request_timeout() {
2153        let mock = mock::start(1, |_r| {
2154            std::thread::sleep(Duration::from_millis(250));
2155            (200, r#"{"authenticated":true}"#.to_string())
2156        });
2157
2158        let error = tokio::time::timeout(
2159            STUCK_FUTURE_GUARD,
2160            fetch_status_with_access_timeout(
2161                &mock.base,
2162                "test-access-token",
2163                Duration::from_millis(50),
2164            ),
2165        )
2166        .await
2167        .expect("the explicit request timeout must bound the stalled double")
2168        .unwrap_err();
2169
2170        assert_eq!(error, "fetch Parslee session timed out after 50ms");
2171    }
2172}