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