1use base64::Engine;
11use serde::{Deserialize, Serialize};
12use sha2::{Digest, Sha256};
13use std::sync::{Mutex, OnceLock};
14use std::time::{Duration, Instant};
15
16#[cfg(test)]
17use car_secrets::SecretStore;
18use car_secrets::{SecretError, SecretRef};
19
20mod authority_hint;
21mod credential_read;
22mod state;
23pub use authority_hint::{
24 credential_authority_hint, CredentialAuthorityHint, CredentialAuthorityState,
25};
26use credential_read::CredentialReadPurpose;
27pub use credential_read::{
28 refresh_credential, resolve_credential, subscribe_credential_read_event_handoff,
29 subscribe_credential_read_events, subscribe_credential_read_updates, CredentialReadError,
30 CredentialReadEventCloseReason, CredentialReadEventHandoff, CredentialReadEventSubscription,
31 CredentialReadFailureKind, CredentialReadMode, CredentialReadStatus, CredentialReadStatusState,
32 ResolvedParsleeCredential,
33};
34use state::{
35 ActiveCredentials, AuthStateError, AuthStateStore, AuthStateV2, CasOutcome, ProcessAuthLock,
36 RefreshCas, RefreshedCredentials, SecretAuthStateStore, StateCoordinator,
37};
38
39pub const PARSLEE_ACCESS_TOKEN_KEY: &str = car_secrets::PARSLEE_ACCESS_TOKEN_KEY;
40pub const PARSLEE_REFRESH_TOKEN_KEY: &str = car_secrets::PARSLEE_REFRESH_TOKEN_KEY;
41pub const PARSLEE_EXPIRES_AT_KEY: &str = car_secrets::PARSLEE_EXPIRES_AT_KEY;
42pub const PARSLEE_API_BASE_KEY: &str = car_secrets::PARSLEE_API_BASE_KEY;
43pub const DEFAULT_API_BASE: &str = "https://api.parslee.ai";
44const PARSLEE_TOKEN_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
45const PARSLEE_STATUS_REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
46pub const AUTH_COORDINATOR_QUEUE_TIMEOUT: Duration = Duration::from_secs(30);
54pub const LOGIN_ATTEMPT_CALLBACK_TTL: Duration = Duration::from_secs(420);
58pub const AUTH_COMPLETION_NETWORK_DEADLINE: Duration = Duration::from_secs(90);
61pub const AUTH_STATE_OPERATION_BUDGET: Duration = Duration::from_secs(15);
65pub const AUTH_PROCESS_LOCK_TIMEOUT: Duration = Duration::from_secs(30);
67pub const LOGIN_ATTEMPT_WORKER_SCHEDULING_MARGIN: Duration = Duration::from_secs(30);
69pub const LOGIN_ATTEMPT_WORKER_SERIAL_BUDGET: Duration = Duration::from_secs(
74 AUTH_STATE_OPERATION_BUDGET.as_secs()
75 + AUTH_COMPLETION_NETWORK_DEADLINE.as_secs()
76 + AUTH_COORDINATOR_QUEUE_TIMEOUT.as_secs()
77 + AUTH_PROCESS_LOCK_TIMEOUT.as_secs()
78 + AUTH_STATE_OPERATION_BUDGET.as_secs(),
79);
80pub const LOGIN_ATTEMPT_WORKER_TTL: Duration = Duration::from_secs(
84 LOGIN_ATTEMPT_WORKER_SERIAL_BUDGET.as_secs() + LOGIN_ATTEMPT_WORKER_SCHEDULING_MARGIN.as_secs(),
85);
86
87#[derive(Debug, Clone, PartialEq, Eq)]
93pub enum AuthOperationError {
94 CoordinationDeadline(String),
95 Terminal(String),
96}
97
98impl AuthOperationError {
99 pub fn is_coordination_deadline(&self) -> bool {
102 matches!(self, Self::CoordinationDeadline(_))
103 }
104}
105
106impl std::fmt::Display for AuthOperationError {
107 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108 match self {
109 Self::CoordinationDeadline(message) | Self::Terminal(message) => {
110 formatter.write_str(message)
111 }
112 }
113 }
114}
115
116impl std::error::Error for AuthOperationError {}
117
118#[derive(Debug, Clone, Deserialize)]
120pub struct TokenSet {
121 pub access_token: String,
122 pub refresh_token: String,
123 pub expires_in: u64,
124 pub token_type: String,
125}
126
127#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
132pub struct LocalAuthSnapshot {
133 pub authenticated: bool,
134 #[serde(skip_serializing_if = "Option::is_none")]
135 pub active_account_id: Option<String>,
136}
137
138#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
142pub struct AuthCompletionRecord {
143 pub attempt_id: String,
144 pub generation: u64,
145 #[serde(default)]
146 pub account_id: Option<String>,
147 #[serde(default)]
148 pub session: Option<String>,
149}
150
151#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
153#[serde(rename_all = "snake_case")]
154pub enum AuthAttemptPhase {
155 AwaitingCallback,
156 Redeeming,
157}
158
159#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
161#[serde(rename_all = "snake_case")]
162pub enum AuthCompletionState {
163 Pending,
164 Complete,
165 Failed,
166 Stale,
167}
168
169#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
171pub struct AuthAttemptFailure {
172 pub error_code: String,
173 pub message: String,
174 pub retryable: bool,
175}
176
177impl AuthAttemptFailure {
178 pub fn completion_failed() -> Self {
179 Self {
180 error_code: "completion_failed".into(),
181 message:
182 "Sign-in could not be completed. Start a new sign-in attempt; do not reuse this authorization code."
183 .into(),
184 retryable: true,
185 }
186 }
187
188 fn attempt_expired() -> Self {
189 Self {
190 error_code: "attempt_expired".into(),
191 message:
192 "This sign-in attempt expired. Start a new sign-in attempt; do not reuse this authorization code."
193 .into(),
194 retryable: true,
195 }
196 }
197
198 fn daemon_restarted() -> Self {
199 Self {
200 error_code: "daemon_restarted".into(),
201 message:
202 "CAR restarted while finishing sign-in. Start a new sign-in attempt; do not reuse this authorization code."
203 .into(),
204 retryable: true,
205 }
206 }
207}
208
209#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
213pub struct AuthCompletionStatus {
214 pub state: AuthCompletionState,
215 pub attempt_id: String,
216 pub generation: u64,
217 #[serde(default, skip_serializing_if = "Option::is_none")]
218 pub phase: Option<AuthAttemptPhase>,
219 #[serde(default, skip_serializing_if = "Option::is_none")]
220 pub expires_at_unix_ms: Option<u64>,
221 #[serde(default, skip_serializing_if = "Option::is_none")]
222 pub account_id: Option<String>,
223 #[serde(default, skip_serializing_if = "Option::is_none")]
224 pub session: Option<String>,
225 #[serde(default, skip_serializing_if = "Option::is_none")]
226 pub error_code: Option<String>,
227 #[serde(default, skip_serializing_if = "Option::is_none")]
228 pub message: Option<String>,
229 #[serde(default, skip_serializing_if = "Option::is_none")]
230 pub retryable: Option<bool>,
231}
232
233impl AuthCompletionStatus {
234 fn stale(attempt_id: &str, generation: u64) -> Self {
235 Self {
236 state: AuthCompletionState::Stale,
237 attempt_id: attempt_id.to_string(),
238 generation,
239 phase: None,
240 expires_at_unix_ms: None,
241 account_id: None,
242 session: None,
243 error_code: None,
244 message: None,
245 retryable: None,
246 }
247 }
248}
249
250#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
255pub struct LoginAttemptLease {
256 pub attempt_id: String,
257 pub revision: u64,
258 pub generation: u64,
259 #[serde(default)]
260 pub attempt_expires_at_unix_ms: u64,
261 #[serde(default, skip_serializing_if = "Option::is_none")]
262 pub worker_owner_id: Option<String>,
263 #[serde(default, skip_serializing_if = "Option::is_none")]
264 pub worker_id: Option<String>,
265 #[serde(default, skip_serializing_if = "Option::is_none")]
266 pub worker_expires_at_unix_ms: Option<u64>,
267}
268
269fn epoch_seconds() -> u64 {
270 std::time::SystemTime::now()
271 .duration_since(std::time::UNIX_EPOCH)
272 .map(|d| d.as_secs())
273 .unwrap_or(0)
274}
275
276fn epoch_millis() -> u64 {
277 std::time::SystemTime::now()
278 .duration_since(std::time::UNIX_EPOCH)
279 .map(|duration| u64::try_from(duration.as_millis()).unwrap_or(u64::MAX))
280 .unwrap_or(0)
281}
282
283pub fn pkce_verifier() -> String {
285 let raw = format!(
286 "{}{}",
287 uuid::Uuid::new_v4().simple(),
288 uuid::Uuid::new_v4().simple()
289 );
290 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(raw.as_bytes())
291}
292
293pub fn new_state() -> String {
295 uuid::Uuid::new_v4().simple().to_string()
296}
297
298pub fn pkce_challenge(verifier: &str) -> String {
300 let digest = Sha256::digest(verifier.as_bytes());
301 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest)
302}
303
304pub fn authorize_url(
306 api_base: &str,
307 client_id: &str,
308 redirect_uri: &str,
309 state: &str,
310 challenge: &str,
311 provider: Option<&str>,
312 prompt: Option<&str>,
313) -> Result<String, String> {
314 let mut url = reqwest::Url::parse(&format!(
315 "{}/connect/authorize",
316 api_base.trim_end_matches('/')
317 ))
318 .map_err(|e| format!("build authorize URL: {e}"))?;
319 url.query_pairs_mut()
320 .append_pair("client_id", client_id)
321 .append_pair("redirect_uri", redirect_uri)
322 .append_pair("response_type", "code")
323 .append_pair("scope", "openid profile email")
324 .append_pair("state", state)
325 .append_pair("code_challenge", challenge)
326 .append_pair("code_challenge_method", "S256");
327 if let Some(provider) = provider {
328 url.query_pairs_mut().append_pair("provider", provider);
329 }
330 if let Some(prompt) = prompt {
333 url.query_pairs_mut().append_pair("prompt", prompt);
334 }
335 Ok(url.to_string())
336}
337
338fn form_body(pairs: &[(&str, &str)]) -> String {
339 let mut s = String::new();
340 for (i, (k, v)) in pairs.iter().enumerate() {
341 if i > 0 {
342 s.push('&');
343 }
344 s.push_str(&urlencode(k));
345 s.push('=');
346 s.push_str(&urlencode(v));
347 }
348 s
349}
350
351fn urlencode(s: &str) -> String {
352 let mut out = String::with_capacity(s.len());
353 for b in s.bytes() {
354 match b {
355 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
356 out.push(b as char)
357 }
358 _ => out.push_str(&format!("%{b:02X}")),
359 }
360 }
361 out
362}
363
364pub async fn exchange_code(
366 api_base: &str,
367 client_id: &str,
368 redirect_uri: &str,
369 code: &str,
370 verifier: &str,
371) -> Result<TokenSet, String> {
372 exchange_code_with_timeout(
373 api_base,
374 client_id,
375 redirect_uri,
376 code,
377 verifier,
378 PARSLEE_TOKEN_REQUEST_TIMEOUT,
379 )
380 .await
381}
382
383async fn post_token_form_with_timeout(
384 token_url: String,
385 body: String,
386 action: &'static str,
387 request_timeout: Duration,
388) -> Result<(reqwest::StatusCode, String), String> {
389 let client = reqwest::Client::builder()
390 .timeout(request_timeout)
391 .build()
392 .map_err(|error| format!("build Parslee token client: {error}"))?;
393 let response = client
394 .post(token_url)
395 .header("content-type", "application/x-www-form-urlencoded")
396 .body(body)
397 .send()
398 .await
399 .map_err(|error| {
400 if error.is_timeout() {
401 format!("{action} timed out after {}ms", request_timeout.as_millis())
402 } else {
403 format!("{action}: {error}")
404 }
405 })?;
406 let status = response.status();
407 let text = response.text().await.map_err(|error| {
408 if error.is_timeout() {
409 format!("{action} timed out after {}ms", request_timeout.as_millis())
410 } else {
411 format!("read Parslee token response: {error}")
412 }
413 })?;
414 Ok((status, text))
415}
416
417async fn exchange_code_with_timeout(
418 api_base: &str,
419 client_id: &str,
420 redirect_uri: &str,
421 code: &str,
422 verifier: &str,
423 request_timeout: Duration,
424) -> Result<TokenSet, String> {
425 let body = form_body(&[
426 ("grant_type", "authorization_code"),
427 ("client_id", client_id),
428 ("redirect_uri", redirect_uri),
429 ("code", code),
430 ("code_verifier", verifier),
431 ]);
432 let token_url = format!("{}/connect/token", api_base.trim_end_matches('/'));
433 let (status, text) = post_token_form_with_timeout(
434 token_url,
435 body,
436 "exchange Parslee authorization code",
437 request_timeout,
438 )
439 .await?;
440 if !status.is_success() {
441 return Err(format!(
442 "Parslee token exchange failed: HTTP {status}: {text}"
443 ));
444 }
445 let token: TokenSet =
446 serde_json::from_str(&text).map_err(|e| format!("parse token response: {e}"))?;
447 if !token.token_type.eq_ignore_ascii_case("bearer") {
448 return Err(format!(
449 "unexpected Parslee token_type `{}`",
450 token.token_type
451 ));
452 }
453 Ok(token)
454}
455
456static AUTH_STATE_MUTEX: std::sync::OnceLock<tokio::sync::Mutex<()>> = std::sync::OnceLock::new();
457
458async fn lock_auth_state_queue<'a>(
459 mutex: &'a tokio::sync::Mutex<()>,
460 timeout: Duration,
461) -> Result<tokio::sync::MutexGuard<'a, ()>, AuthOperationError> {
462 tokio::time::timeout(timeout, mutex.lock())
463 .await
464 .map_err(|_| {
465 AuthOperationError::CoordinationDeadline(format!(
466 "timed out waiting for the in-process Parslee credential coordinator after {}ms",
467 timeout.as_millis()
468 ))
469 })
470}
471
472async fn with_locked_state_classified<T, F>(operation: F) -> Result<T, AuthOperationError>
473where
474 T: Send + 'static,
475 F: FnOnce(StateCoordinator<SecretAuthStateStore>) -> Result<T, state::AuthStateError>
476 + Send
477 + 'static,
478{
479 let _process_guard = lock_auth_state_queue(
480 AUTH_STATE_MUTEX.get_or_init(|| tokio::sync::Mutex::new(())),
481 AUTH_COORDINATOR_QUEUE_TIMEOUT,
482 )
483 .await?;
484 tokio::task::spawn_blocking(move || {
485 let _file_guard = ProcessAuthLock::acquire()?;
486 operation(StateCoordinator::new(SecretAuthStateStore))
487 })
488 .await
489 .map_err(|error| {
490 AuthOperationError::Terminal(format!("Parslee credential worker failed: {error}"))
491 })?
492 .map_err(|error| match error {
493 state::AuthStateError::CoordinationDeadline(message) => {
494 AuthOperationError::CoordinationDeadline(message)
495 }
496 other => AuthOperationError::Terminal(other.to_string()),
497 })
498}
499
500async fn with_locked_state<T, F>(operation: F) -> Result<T, String>
501where
502 T: Send + 'static,
503 F: FnOnce(StateCoordinator<SecretAuthStateStore>) -> Result<T, state::AuthStateError>
504 + Send
505 + 'static,
506{
507 with_locked_state_classified(operation)
508 .await
509 .map_err(|error| error.to_string())
510}
511
512fn read_published_state_without_migration() -> Result<Option<AuthStateV2>, String> {
513 StateCoordinator::new(SecretAuthStateStore)
514 .read_published_snapshot()
515 .map_err(|error| error.to_string())
516}
517
518const TOKEN_CACHE_TTL: Duration = Duration::from_secs(30);
536
537struct CachedParsleeCredential {
538 access_token: String,
539 api_base: String,
540 expires_at: u64,
542 cached_at: Instant,
543}
544
545static ACCESS_TOKEN_CACHE: OnceLock<Mutex<Option<CachedParsleeCredential>>> = OnceLock::new();
546
547fn access_token_cache() -> &'static Mutex<Option<CachedParsleeCredential>> {
548 ACCESS_TOKEN_CACHE.get_or_init(|| Mutex::new(None))
549}
550
551const PROACTIVE_REFRESH_FAILURE_MIN_INTERVAL: Duration = Duration::from_secs(30);
558
559type ProactiveRefreshCredentialKey = [u8; 32];
560type RejectedBearerKey = [u8; 32];
561
562struct ProactiveRefreshFailure {
563 credential: ProactiveRefreshCredentialKey,
564 failed_at: Instant,
565}
566
567static PROACTIVE_REFRESH_FAILURE: OnceLock<Mutex<Option<ProactiveRefreshFailure>>> =
571 OnceLock::new();
572
573fn proactive_refresh_failure() -> &'static Mutex<Option<ProactiveRefreshFailure>> {
574 PROACTIVE_REFRESH_FAILURE.get_or_init(|| Mutex::new(None))
575}
576
577fn rejected_bearer_key(access_token: &str) -> RejectedBearerKey {
579 Sha256::digest(access_token.as_bytes()).into()
580}
581
582fn proactive_refresh_credential_key(
587 credential: &ActiveCredentials,
588) -> ProactiveRefreshCredentialKey {
589 let mut digest = Sha256::new();
590 for field in [
591 credential.account_id.as_bytes(),
592 credential.access_token.as_bytes(),
593 credential
594 .refresh_token
595 .as_deref()
596 .unwrap_or_default()
597 .as_bytes(),
598 credential.api_base.as_bytes(),
599 ] {
600 digest.update((field.len() as u64).to_le_bytes());
601 digest.update(field);
602 }
603 digest.update(credential.expires_at.to_le_bytes());
604 digest.finalize().into()
605}
606
607fn proactive_refresh_is_suppressed(credential: &ActiveCredentials, now: Instant) -> bool {
608 let key = proactive_refresh_credential_key(credential);
609 let Ok(mut slot) = proactive_refresh_failure().lock() else {
610 return false;
611 };
612 let Some(failure) = slot.as_ref() else {
613 return false;
614 };
615 if now.saturating_duration_since(failure.failed_at) >= PROACTIVE_REFRESH_FAILURE_MIN_INTERVAL {
616 *slot = None;
617 return false;
618 }
619 failure.credential == key
620}
621
622fn record_proactive_refresh_failure(credential: &ActiveCredentials, now: Instant) {
623 if let Ok(mut slot) = proactive_refresh_failure().lock() {
624 *slot = Some(ProactiveRefreshFailure {
625 credential: proactive_refresh_credential_key(credential),
626 failed_at: now,
627 });
628 }
629}
630
631fn clear_proactive_refresh_failure(credential: &ActiveCredentials) {
632 if let Ok(mut slot) = proactive_refresh_failure().lock() {
633 if slot.as_ref().is_some_and(|failure| {
634 failure.credential == proactive_refresh_credential_key(credential)
635 }) {
636 *slot = None;
637 }
638 }
639}
640
641fn invalidate_proactive_refresh_failure() {
642 if let Ok(mut slot) = proactive_refresh_failure().lock() {
643 *slot = None;
644 }
645}
646
647pub fn invalidate_access_token_cache() {
653 if let Ok(mut slot) = access_token_cache().lock() {
654 *slot = None;
655 }
656}
657
658fn cached_credential() -> Option<ResolvedParsleeCredential> {
661 let slot = access_token_cache().lock().ok()?;
662 let entry = slot.as_ref()?;
663 if entry.cached_at.elapsed() >= TOKEN_CACHE_TTL {
664 return None;
665 }
666 if entry.expires_at > 0 && epoch_seconds() + REFRESH_SKEW_SECS >= entry.expires_at {
669 return None;
670 }
671 Some(ResolvedParsleeCredential {
672 access_token: entry.access_token.clone(),
673 api_base: entry.api_base.clone(),
674 expires_at: entry.expires_at,
675 })
676}
677
678fn store_resolved_credential(credential: &ResolvedParsleeCredential) {
679 if let Ok(mut slot) = access_token_cache().lock() {
680 *slot = Some(CachedParsleeCredential {
681 access_token: credential.access_token.clone(),
682 api_base: credential.api_base.clone(),
683 expires_at: credential.expires_at,
684 cached_at: Instant::now(),
685 });
686 }
687}
688
689pub fn access_token() -> Option<String> {
700 if let Ok(token) = std::env::var(PARSLEE_ACCESS_TOKEN_KEY) {
701 if !token.is_empty() {
702 return Some(token);
703 }
704 }
705 read_published_state_without_migration()
706 .ok()
707 .flatten()
708 .and_then(|state| state.active.map(|active| active.access_token))
709}
710
711pub fn access_token_is_available() -> bool {
719 if std::env::var(PARSLEE_ACCESS_TOKEN_KEY).is_ok_and(|token| !token.is_empty()) {
720 return true;
721 }
722 match read_published_state_without_migration() {
723 Ok(Some(state)) => state.active.is_some(),
724 Ok(None) => {
725 let legacy_available = car_secrets::status_via_operator_broker_or_keychain(
726 &car_secrets::SecretRef::with_default_service(PARSLEE_ACCESS_TOKEN_KEY),
727 )
728 .is_ok_and(|status| status.exists);
729 match read_published_state_without_migration() {
733 Ok(Some(state)) => state.active.is_some(),
734 Ok(None) => legacy_available,
735 Err(_) => false,
736 }
737 }
738 Err(_) => false,
739 }
740}
741
742pub async fn auth_generation() -> Result<u64, String> {
747 with_locked_state(|coordinator| Ok(coordinator.read_snapshot()?.generation)).await
748}
749
750pub async fn auth_completion() -> Result<Option<AuthCompletionRecord>, String> {
755 with_locked_state(|coordinator| Ok(coordinator.read_snapshot()?.completion)).await
756}
757
758pub async fn reserve_login_attempt(attempt_id: &str) -> Result<LoginAttemptLease, String> {
762 reserve_login_attempt_classified(attempt_id)
763 .await
764 .map_err(|error| error.to_string())
765}
766
767pub async fn reserve_login_attempt_classified(
769 attempt_id: &str,
770) -> Result<LoginAttemptLease, AuthOperationError> {
771 let attempt_id = attempt_id.to_string();
772 with_locked_state_classified(move |coordinator| {
773 let expires_at =
774 epoch_millis().saturating_add(LOGIN_ATTEMPT_CALLBACK_TTL.as_millis() as u64);
775 coordinator.reserve_login_attempt(&attempt_id, expires_at)
776 })
777 .await
778}
779
780pub async fn claim_login_attempt(
784 attempt_id: &str,
785 daemon_owner_id: &str,
786) -> Result<LoginAttemptLease, String> {
787 claim_login_attempt_classified(attempt_id, daemon_owner_id)
788 .await
789 .map_err(|error| error.to_string())
790}
791
792pub async fn claim_login_attempt_classified(
794 attempt_id: &str,
795 daemon_owner_id: &str,
796) -> Result<LoginAttemptLease, AuthOperationError> {
797 let attempt_id = attempt_id.to_string();
798 let daemon_owner_id = daemon_owner_id.to_string();
799 with_locked_state_classified(move |coordinator| {
800 coordinator.claim_login_attempt_now(&attempt_id, &daemon_owner_id)
801 })
802 .await
803}
804
805pub async fn fail_login_attempt(
807 lease: &LoginAttemptLease,
808 failure: AuthAttemptFailure,
809) -> Result<bool, String> {
810 let lease = lease.clone();
811 with_locked_state(move |coordinator| {
812 Ok(matches!(
813 coordinator.fail_login_attempt(&lease, failure)?,
814 CasOutcome::Committed
815 ))
816 })
817 .await
818}
819
820pub async fn auth_completion_status(
825 attempt_id: &str,
826 daemon_owner_id: &str,
827) -> Result<AuthCompletionStatus, String> {
828 auth_completion_status_classified(attempt_id, daemon_owner_id)
829 .await
830 .map_err(|error| error.to_string())
831}
832
833pub async fn auth_completion_status_classified(
835 attempt_id: &str,
836 daemon_owner_id: &str,
837) -> Result<AuthCompletionStatus, AuthOperationError> {
838 let attempt_id = attempt_id.to_string();
839 let daemon_owner_id = daemon_owner_id.to_string();
840 with_locked_state_classified(move |coordinator| {
841 coordinator.completion_status_from_published_now(&attempt_id, &daemon_owner_id)
842 })
843 .await
844}
845
846pub async fn commit_login(
853 api_base: &str,
854 token: &TokenSet,
855 session: &str,
856 lease: Option<LoginAttemptLease>,
857) -> Result<AuthCompletionRecord, String> {
858 let identity = session_identity(session)?;
859 let credentials = ActiveCredentials {
860 account_id: identity.id.clone(),
861 email: identity.email,
862 name: identity.name,
863 access_token: token.access_token.clone(),
864 refresh_token: Some(token.refresh_token.clone()),
865 expires_at: epoch_seconds().saturating_add(token.expires_in),
866 api_base: api_base.trim_end_matches('/').to_string(),
867 };
868 let full_session = session.to_string();
869 let completion_session = lease.as_ref().map(|_| full_session.clone());
870 let state = with_locked_state(move |coordinator| {
871 coordinator.commit_login_now(credentials, completion_session, lease)
872 })
873 .await;
874 invalidate_access_token_cache();
877 invalidate_proactive_refresh_failure();
878 let state = state?;
879 Ok(AuthCompletionRecord {
880 attempt_id: state
881 .completion
882 .as_ref()
883 .map(|record| record.attempt_id.clone())
884 .unwrap_or_default(),
885 generation: state.generation,
886 account_id: state.active.map(|active| active.account_id),
887 session: Some(full_session),
888 })
889}
890
891pub async fn logout() -> Result<(), String> {
893 let result = with_locked_state(|coordinator| coordinator.logout().map(|_| ())).await;
894 invalidate_access_token_cache();
897 invalidate_proactive_refresh_failure();
898 result
899}
900
901pub const REFRESH_SKEW_SECS: u64 = 120;
905
906#[derive(Debug, Clone)]
909pub struct RefreshedTokens {
910 pub access_token: String,
911 pub refresh_token: Option<String>,
912 pub expires_in: Option<u64>,
913}
914
915pub async fn refresh_grant(api_base: &str, refresh_token: &str) -> Result<RefreshedTokens, String> {
923 refresh_grant_with_timeout(api_base, refresh_token, PARSLEE_TOKEN_REQUEST_TIMEOUT).await
924}
925
926async fn refresh_grant_with_timeout(
927 api_base: &str,
928 refresh_token: &str,
929 request_timeout: Duration,
930) -> Result<RefreshedTokens, String> {
931 #[derive(Deserialize)]
932 struct Resp {
933 access_token: String,
934 #[serde(default)]
935 refresh_token: Option<String>,
936 #[serde(default)]
937 expires_in: Option<u64>,
938 }
939 let body = form_body(&[
940 ("grant_type", "refresh_token"),
941 ("refresh_token", refresh_token),
942 ]);
943 let token_url = format!("{}/connect/token", api_base.trim_end_matches('/'));
944 let (status, text) =
945 post_token_form_with_timeout(token_url, body, "refresh Parslee token", request_timeout)
946 .await?;
947 if !status.is_success() {
948 return Err(format!("refresh Parslee token: HTTP {status}: {text}"));
949 }
950 let r: Resp =
951 serde_json::from_str(&text).map_err(|e| format!("parse Parslee token response: {e}"))?;
952 Ok(RefreshedTokens {
953 access_token: r.access_token,
954 refresh_token: r.refresh_token,
955 expires_in: r.expires_in,
956 })
957}
958
959async fn active_state_for_network() -> Result<Option<ActiveCredentials>, String> {
960 with_locked_state(|coordinator| Ok(coordinator.read_snapshot()?.active)).await
961}
962
963fn credential_read_error(
964 kind: CredentialReadFailureKind,
965 message: impl Into<String>,
966) -> CredentialReadError {
967 CredentialReadError {
968 kind,
969 message: message.into(),
970 }
971}
972
973fn credential_read_error_from_state(error: state::AuthStateError) -> CredentialReadError {
974 let kind = match error {
975 state::AuthStateError::CoordinationDeadline(_) => CredentialReadFailureKind::TimedOut,
976 state::AuthStateError::Conflict(_)
977 | state::AuthStateError::Store(_)
978 | state::AuthStateError::Invalid(_) => CredentialReadFailureKind::Unreadable,
979 };
980 credential_read_error(kind, error.to_string())
981}
982
983#[derive(Clone)]
987struct ReadOnceAuthStateStore(String);
988
989impl AuthStateStore for ReadOnceAuthStateStore {
990 fn read(&self, key: &str) -> Result<Option<String>, AuthStateError> {
991 if key != state::AUTH_STATE_V2_KEY {
992 return Err(AuthStateError::Store(format!(
993 "read-once credential snapshot cannot read {key}"
994 )));
995 }
996 Ok(Some(self.0.clone()))
997 }
998
999 fn publish(&self, _key: &str, _value: &str) -> Result<(), AuthStateError> {
1000 Err(AuthStateError::Store(
1001 "read-once credential snapshot cannot publish".into(),
1002 ))
1003 }
1004
1005 fn publish_recreating(&self, _key: &str, _value: &str) -> Result<(), AuthStateError> {
1006 Err(AuthStateError::Store(
1007 "read-once credential snapshot cannot recreate".into(),
1008 ))
1009 }
1010
1011 fn delete(&self, _key: &str) -> Result<(), AuthStateError> {
1012 Err(AuthStateError::Store(
1013 "read-once credential snapshot cannot delete".into(),
1014 ))
1015 }
1016}
1017
1018fn refresh_authority_hint_after_read(state: &AuthStateV2) {
1019 if let Err(error) = authority_hint::publish_for_state(state) {
1020 eprintln!(
1021 "car-auth: authoritative credential read succeeded but its passive hint could not be refreshed ({error})"
1022 );
1023 if let Err(degrade_error) = authority_hint::degrade_to_unknown() {
1024 eprintln!(
1025 "car-auth: credential authority hint could not be degraded after read ({degrade_error})"
1026 );
1027 }
1028 }
1029}
1030
1031async fn active_state_for_credential_resolution(
1036) -> Result<Option<ActiveCredentials>, CredentialReadError> {
1037 let _process_guard = lock_auth_state_queue(
1038 AUTH_STATE_MUTEX.get_or_init(|| tokio::sync::Mutex::new(())),
1039 AUTH_COORDINATOR_QUEUE_TIMEOUT,
1040 )
1041 .await
1042 .map_err(|error| {
1043 credential_read_error(CredentialReadFailureKind::TimedOut, error.to_string())
1044 })?;
1045
1046 tokio::task::spawn_blocking(move || {
1047 let _file_guard = ProcessAuthLock::acquire().map_err(credential_read_error_from_state)?;
1048 let reference = SecretRef::with_default_service(state::AUTH_STATE_V2_KEY);
1049 let state = match car_secrets::read_via_operator_broker_or_keychain(&reference) {
1050 Ok(raw) => StateCoordinator::new(ReadOnceAuthStateStore(raw))
1051 .read_published_snapshot()
1052 .map_err(credential_read_error_from_state)?
1053 .expect("the read-once store always contains its V2 payload"),
1054 Err(SecretError::NotFound { .. }) => StateCoordinator::new(SecretAuthStateStore)
1055 .read_snapshot()
1056 .map_err(credential_read_error_from_state)?,
1057 Err(error) => return Err(CredentialReadError::from(error)),
1058 };
1059 refresh_authority_hint_after_read(&state);
1060 Ok(state.active)
1061 })
1062 .await
1063 .map_err(|error| {
1064 credential_read_error(
1065 CredentialReadFailureKind::Unreadable,
1066 format!("Parslee credential worker failed: {error}"),
1067 )
1068 })?
1069}
1070
1071fn resolved_from_active(active: &ActiveCredentials) -> ResolvedParsleeCredential {
1072 ResolvedParsleeCredential {
1073 access_token: active.access_token.clone(),
1074 api_base: active.api_base.trim_end_matches('/').to_string(),
1075 expires_at: active.expires_at,
1076 }
1077}
1078
1079async fn resolve_credential_once(
1081 purpose: CredentialReadPurpose,
1082) -> Result<Option<ResolvedParsleeCredential>, CredentialReadError> {
1083 resolve_credential_once_with_clock(purpose, Instant::now).await
1084}
1085
1086async fn resolve_credential_once_with_clock<N>(
1089 purpose: CredentialReadPurpose,
1090 now: N,
1091) -> Result<Option<ResolvedParsleeCredential>, CredentialReadError>
1092where
1093 N: Fn() -> Instant + Send + Sync,
1094{
1095 if let Ok(access_token) = std::env::var(PARSLEE_ACCESS_TOKEN_KEY) {
1099 if !access_token.is_empty() {
1100 if purpose == CredentialReadPurpose::ForceRefresh
1101 || matches!(
1102 purpose,
1103 CredentialReadPurpose::AutomaticRefresh(rejected)
1104 if rejected == rejected_bearer_key(&access_token)
1105 )
1106 {
1107 return Ok(None);
1108 }
1109 let api_base = std::env::var(PARSLEE_API_BASE_KEY)
1110 .ok()
1111 .filter(|value| !value.trim().is_empty())
1112 .unwrap_or_else(|| DEFAULT_API_BASE.to_string())
1113 .trim_end_matches('/')
1114 .to_string();
1115 return Ok(Some(ResolvedParsleeCredential {
1116 access_token,
1117 api_base,
1118 expires_at: 0,
1119 }));
1120 }
1121 }
1122
1123 if purpose == CredentialReadPurpose::Resolve {
1124 if let Some(credential) = cached_credential() {
1125 return Ok(Some(credential));
1126 }
1127 }
1128
1129 let Some(current) = active_state_for_credential_resolution().await? else {
1130 return Ok(None);
1131 };
1132 let current_credential = resolved_from_active(¤t);
1133 let automatic_refresh = match purpose {
1134 CredentialReadPurpose::AutomaticRefresh(rejected) => {
1135 if rejected != rejected_bearer_key(¤t.access_token) {
1136 store_resolved_credential(¤t_credential);
1137 return Ok(Some(current_credential));
1138 }
1139 true
1140 }
1141 _ => false,
1142 };
1143 let expiring =
1144 current.expires_at > 0 && epoch_seconds() + REFRESH_SKEW_SECS >= current.expires_at;
1145 if purpose != CredentialReadPurpose::ForceRefresh && !automatic_refresh && !expiring {
1146 store_resolved_credential(¤t_credential);
1147 return Ok(Some(current_credential));
1148 }
1149
1150 let Some(refresh) = current.refresh_token.clone() else {
1151 if purpose == CredentialReadPurpose::ForceRefresh || automatic_refresh {
1152 eprintln!(
1153 "car-auth: reactive Parslee refresh: no refresh token stored — run `car auth login`"
1154 );
1155 return Ok(None);
1156 }
1157 return Ok(Some(current_credential));
1158 };
1159 if (purpose == CredentialReadPurpose::Resolve || automatic_refresh)
1160 && proactive_refresh_is_suppressed(¤t, now())
1161 {
1162 return if automatic_refresh {
1163 Ok(None)
1164 } else {
1165 Ok(Some(current_credential))
1166 };
1167 }
1168
1169 let base = current.api_base.clone();
1170 let expected = refresh_cas(¤t);
1171 match refresh_grant(&base, &refresh).await {
1172 Ok(tokens) => {
1173 let refreshed = ResolvedParsleeCredential {
1174 access_token: tokens.access_token.clone(),
1175 api_base: base.trim_end_matches('/').to_string(),
1176 expires_at: tokens
1177 .expires_in
1178 .map(|seconds| epoch_seconds().saturating_add(seconds))
1179 .unwrap_or(0),
1180 };
1181 match commit_refreshed_credentials(expected, base, tokens, false).await {
1182 Ok(CasOutcome::Committed) => {
1183 clear_proactive_refresh_failure(¤t);
1184 store_resolved_credential(&refreshed);
1185 Ok(Some(refreshed))
1186 }
1187 Ok(CasOutcome::Conflict) => {
1188 clear_proactive_refresh_failure(¤t);
1189 let active = active_state_for_credential_resolution().await?;
1190 let credential = active.as_ref().map(resolved_from_active);
1191 if let Some(credential) = &credential {
1192 store_resolved_credential(credential);
1193 }
1194 Ok(credential)
1195 }
1196 Err(error) => {
1197 if purpose == CredentialReadPurpose::ForceRefresh || automatic_refresh {
1198 if automatic_refresh {
1199 record_proactive_refresh_failure(¤t, now());
1200 }
1201 Err(credential_read_error(
1202 CredentialReadFailureKind::Unreadable,
1203 format!("reactive Parslee refresh commit failed: {error}"),
1204 ))
1205 } else {
1206 record_proactive_refresh_failure(¤t, now());
1207 eprintln!(
1208 "car-auth: refreshed Parslee token could not be committed; using current token ({error})"
1209 );
1210 Ok(Some(current_credential))
1211 }
1212 }
1213 }
1214 }
1215 Err(error) => {
1216 if purpose == CredentialReadPurpose::ForceRefresh || automatic_refresh {
1217 if automatic_refresh {
1218 record_proactive_refresh_failure(¤t, now());
1219 }
1220 eprintln!(
1221 "car-auth: reactive Parslee token refresh failed (401 will surface) — re-run `car auth login` ({error})"
1222 );
1223 Ok(None)
1224 } else {
1225 record_proactive_refresh_failure(¤t, now());
1226 eprintln!(
1227 "car-auth: proactive Parslee token refresh failed; using stored token (it may 401 — re-run `car auth login`) ({error})"
1228 );
1229 Ok(Some(current_credential))
1230 }
1231 }
1232 }
1233}
1234
1235pub fn refresh_credential_after_rejection(
1244 rejected_bearer: &str,
1245) -> impl std::future::Future<Output = Result<Option<ResolvedParsleeCredential>, CredentialReadError>>
1246 + Send
1247 + 'static {
1248 credential_read::refresh_credential_after_rejection(rejected_bearer_key(rejected_bearer))
1249}
1250
1251#[cfg(test)]
1252async fn refresh_credential_after_rejection_with_clock<N>(
1253 rejected_bearer: &str,
1254 now: N,
1255) -> Result<Option<ResolvedParsleeCredential>, CredentialReadError>
1256where
1257 N: Fn() -> Instant + Send + Sync,
1258{
1259 resolve_credential_once_with_clock(
1260 CredentialReadPurpose::AutomaticRefresh(rejected_bearer_key(rejected_bearer)),
1261 now,
1262 )
1263 .await
1264}
1265
1266fn refresh_cas(current: &ActiveCredentials) -> RefreshCas {
1269 RefreshCas {
1270 account_id: current.account_id.clone(),
1271 access_token: current.access_token.clone(),
1272 refresh_token: current.refresh_token.clone(),
1273 }
1274}
1275
1276async fn commit_refreshed_credentials(
1277 expected: RefreshCas,
1278 api_base: String,
1279 tokens: RefreshedTokens,
1280 generation_change: bool,
1281) -> Result<CasOutcome, String> {
1282 let refreshed = RefreshedCredentials {
1283 access_token: tokens.access_token,
1284 refresh_token: tokens.refresh_token,
1285 expires_at: tokens
1286 .expires_in
1287 .map(|seconds| epoch_seconds().saturating_add(seconds)),
1288 api_base,
1289 generation_change,
1290 };
1291 let outcome =
1292 with_locked_state(move |coordinator| coordinator.commit_refresh(&expected, refreshed))
1293 .await;
1294 invalidate_access_token_cache();
1297 outcome
1298}
1299
1300#[derive(Debug, Clone, PartialEq, Eq)]
1313pub enum CredentialState {
1314 Active,
1316 Expired { expires_at: u64 },
1320 SignedOut,
1323 Unreadable(String),
1326}
1327
1328pub async fn access_token_lifetime_remaining() -> Option<u64> {
1344 if std::env::var(PARSLEE_ACCESS_TOKEN_KEY).is_ok_and(|tok| !tok.is_empty()) {
1345 return None;
1346 }
1347 let current = active_state_for_network().await.ok()??;
1348 if current.expires_at == 0 {
1349 return None;
1350 }
1351 Some(current.expires_at.saturating_sub(epoch_seconds()))
1352}
1353
1354pub async fn credential_state() -> CredentialState {
1356 if let Ok(tok) = std::env::var(PARSLEE_ACCESS_TOKEN_KEY) {
1357 if !tok.is_empty() {
1358 return CredentialState::Active;
1359 }
1360 }
1361 match active_state_for_network().await {
1362 Ok(Some(current)) => {
1363 let expiring =
1364 current.expires_at > 0 && epoch_seconds() + REFRESH_SKEW_SECS >= current.expires_at;
1365 if expiring {
1366 CredentialState::Expired {
1367 expires_at: current.expires_at,
1368 }
1369 } else {
1370 CredentialState::Active
1371 }
1372 }
1373 Ok(None) => CredentialState::SignedOut,
1374 Err(e) => CredentialState::Unreadable(e),
1375 }
1376}
1377
1378pub async fn access_token_refreshing() -> Option<String> {
1386 resolve_credential(CredentialReadMode::Use)
1387 .await
1388 .ok()
1389 .flatten()
1390 .map(|credential| credential.access_token)
1391}
1392
1393pub async fn force_refresh() -> Option<String> {
1405 refresh_credential()
1406 .await
1407 .ok()
1408 .flatten()
1409 .map(|credential| credential.access_token)
1410}
1411
1412pub fn api_base(override_: Option<&str>) -> String {
1417 override_
1418 .map(str::to_string)
1419 .or_else(|| {
1420 std::env::var(PARSLEE_API_BASE_KEY)
1421 .ok()
1422 .filter(|value| !value.trim().is_empty())
1423 })
1424 .or_else(|| {
1425 read_published_state_without_migration()
1426 .ok()
1427 .flatten()
1428 .and_then(|state| state.active.map(|active| active.api_base))
1429 })
1430 .unwrap_or_else(|| DEFAULT_API_BASE.to_string())
1431 .trim_end_matches('/')
1432 .to_string()
1433}
1434
1435pub async fn fetch_status(api_base_override: Option<&str>) -> Result<Option<String>, String> {
1438 let Some(access) = access_token_refreshing().await else {
1445 return Ok(None);
1446 };
1447 let base = api_base(api_base_override);
1448 let url = format!("{}/connect/session", base.trim_end_matches('/'));
1449 let client = reqwest::Client::builder()
1450 .timeout(PARSLEE_STATUS_REQUEST_TIMEOUT)
1451 .build()
1452 .map_err(|error| format!("build Parslee session client: {error}"))?;
1453
1454 let mut response = client
1455 .get(&url)
1456 .bearer_auth(&access)
1457 .send()
1458 .await
1459 .map_err(|e| format!("fetch Parslee session: {e}"))?;
1460
1461 if response.status() == reqwest::StatusCode::UNAUTHORIZED {
1465 if let Some(fresh) = force_refresh().await {
1466 response = client
1467 .get(&url)
1468 .bearer_auth(&fresh)
1469 .send()
1470 .await
1471 .map_err(|e| format!("fetch Parslee session: {e}"))?;
1472 }
1473 }
1474
1475 let status = response.status();
1476 let text = response
1477 .text()
1478 .await
1479 .map_err(|e| format!("read Parslee session response: {e}"))?;
1480 if !status.is_success() {
1481 return Err(format!(
1482 "Parslee session check failed: HTTP {status}: {text}"
1483 ));
1484 }
1485 Ok(Some(text))
1486}
1487
1488pub async fn fetch_status_with_access(
1494 api_base: &str,
1495 access_token: &str,
1496) -> Result<String, String> {
1497 fetch_status_with_access_timeout(api_base, access_token, PARSLEE_STATUS_REQUEST_TIMEOUT).await
1498}
1499
1500async fn fetch_status_with_access_timeout(
1501 api_base: &str,
1502 access_token: &str,
1503 request_timeout: Duration,
1504) -> Result<String, String> {
1505 let url = format!("{}/connect/session", api_base.trim_end_matches('/'));
1506 let client = reqwest::Client::builder()
1507 .timeout(request_timeout)
1508 .build()
1509 .map_err(|e| format!("build Parslee session client: {e}"))?;
1510 let response = client
1511 .get(url)
1512 .bearer_auth(access_token)
1513 .send()
1514 .await
1515 .map_err(|e| {
1516 if e.is_timeout() {
1517 format!(
1518 "fetch Parslee session timed out after {}ms",
1519 request_timeout.as_millis()
1520 )
1521 } else {
1522 format!("fetch Parslee session: {e}")
1523 }
1524 })?;
1525 let status = response.status();
1526 let text = response.text().await.map_err(|e| {
1527 if e.is_timeout() {
1528 format!(
1529 "read Parslee session response timed out after {}ms",
1530 request_timeout.as_millis()
1531 )
1532 } else {
1533 format!("read Parslee session response: {e}")
1534 }
1535 })?;
1536 if !status.is_success() {
1537 return Err(format!(
1538 "Parslee session check failed: HTTP {status}: {text}"
1539 ));
1540 }
1541 Ok(text)
1542}
1543
1544pub async fn set_active_org(
1553 api_base_override: Option<&str>,
1554 organization_id: &str,
1555) -> Result<String, String> {
1556 let Some(access) = access_token_refreshing().await else {
1557 return Err("not signed in".to_string());
1558 };
1559 let base = api_base(api_base_override);
1560 set_active_org_with_access(&base, &access, organization_id).await
1562}
1563
1564async fn set_active_org_with_access(
1565 base: &str,
1566 access_token: &str,
1567 organization_id: &str,
1568) -> Result<String, String> {
1569 let body = serde_json::json!({ "organizationId": organization_id }).to_string();
1570 let response = reqwest::Client::builder()
1571 .timeout(PARSLEE_STATUS_REQUEST_TIMEOUT)
1572 .build()
1573 .map_err(|error| format!("build set-active-org client: {error}"))?
1574 .put(format!(
1575 "{}/api/v1/accounts/me/active-org",
1576 base.trim_end_matches('/')
1577 ))
1578 .bearer_auth(access_token)
1579 .header("content-type", "application/json")
1580 .body(body)
1581 .send()
1582 .await
1583 .map_err(|e| format!("set active org: {e}"))?;
1584 let status = response.status();
1585 let text = response
1586 .text()
1587 .await
1588 .map_err(|e| format!("read set-active-org response: {e}"))?;
1589 if !status.is_success() {
1590 return Err(format!("set active org failed: HTTP {status}: {text}"));
1591 }
1592 Ok(text)
1593}
1594
1595pub async fn switch_org(api_base_override: Option<&str>, org_id: &str) -> Result<(), String> {
1604 #[derive(Deserialize)]
1605 struct Resp {
1606 access_token: String,
1607 #[serde(default)]
1608 refresh_token: Option<String>,
1609 #[serde(default)]
1610 expires_in: Option<u64>,
1611 }
1612 let current = active_state_for_network()
1613 .await?
1614 .ok_or_else(|| "not signed in".to_string())?;
1615 let Some(refresh) = current.refresh_token.clone() else {
1616 return Err("not signed in".to_string());
1617 };
1618 let expected = refresh_cas(¤t);
1619 let base = api_base_override
1620 .map(|value| value.trim_end_matches('/').to_string())
1621 .unwrap_or_else(|| current.api_base.clone());
1622 let body = form_body(&[
1623 ("grant_type", "refresh_token"),
1624 ("refresh_token", &refresh),
1625 ("organization_id", org_id),
1626 ]);
1627 let (status, text) = post_token_form_with_timeout(
1628 format!("{}/connect/token", base.trim_end_matches('/')),
1629 body,
1630 "switch Parslee organization token",
1631 PARSLEE_TOKEN_REQUEST_TIMEOUT,
1632 )
1633 .await?;
1634 if !status.is_success() {
1635 return Err(format!("switch org failed: HTTP {status}: {text}"));
1636 }
1637 let r: Resp =
1638 serde_json::from_str(&text).map_err(|e| format!("parse switch-org response: {e}"))?;
1639 let access_token = r.access_token.clone();
1640 let outcome = commit_refreshed_credentials(
1641 expected,
1642 base.clone(),
1643 RefreshedTokens {
1644 access_token: r.access_token,
1645 refresh_token: r.refresh_token,
1646 expires_in: r.expires_in,
1647 },
1648 true,
1649 )
1650 .await?;
1651 if outcome == CasOutcome::Conflict {
1652 return Err(
1653 "Parslee credentials changed while switching organizations; retry the switch".into(),
1654 );
1655 }
1656 let _ = set_active_org_with_access(&base, &access_token, org_id).await;
1659 Ok(())
1660}
1661
1662#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1664pub struct AccountMeta {
1665 pub id: String,
1666 #[serde(default)]
1667 pub email: Option<String>,
1668 #[serde(default)]
1669 pub name: Option<String>,
1670 #[serde(default)]
1672 pub active: bool,
1673}
1674
1675struct SessionIdentity {
1676 id: String,
1677 email: Option<String>,
1678 name: Option<String>,
1679}
1680
1681fn session_identity(session: &str) -> Result<SessionIdentity, String> {
1682 let value: serde_json::Value =
1683 serde_json::from_str(session).map_err(|error| format!("parse session: {error}"))?;
1684 let account = value
1685 .get("Account")
1686 .or_else(|| value.get("account"))
1687 .ok_or_else(|| "session has no account".to_string())?;
1688 let field = |pascal: &str, camel: &str| {
1689 account
1690 .get(pascal)
1691 .or_else(|| account.get(camel))
1692 .and_then(serde_json::Value::as_str)
1693 .map(str::trim)
1694 .filter(|value| !value.is_empty())
1695 .map(str::to_string)
1696 };
1697 Ok(SessionIdentity {
1698 id: field("Id", "id").ok_or_else(|| "session has no account id".to_string())?,
1699 email: field("Email", "email"),
1700 name: field("Name", "name").or_else(|| field("DisplayName", "displayName")),
1701 })
1702}
1703
1704pub fn account_id_from_session(session: &str) -> Result<String, String> {
1706 session_identity(session).map(|identity| identity.id)
1707}
1708
1709pub async fn local_auth_snapshot() -> Result<LocalAuthSnapshot, String> {
1714 let env_override_active = std::env::var(PARSLEE_ACCESS_TOKEN_KEY)
1715 .map(|value| !value.is_empty())
1716 .unwrap_or(false);
1717 if env_override_active {
1718 return Ok(LocalAuthSnapshot {
1719 authenticated: true,
1720 active_account_id: None,
1721 });
1722 }
1723 with_locked_state(|coordinator| {
1724 let state = coordinator.read_snapshot()?;
1725 Ok(LocalAuthSnapshot {
1726 authenticated: state.active.is_some(),
1727 active_account_id: state.active.map(|active| active.account_id),
1728 })
1729 })
1730 .await
1731}
1732
1733pub async fn list_accounts(_api_base_override: Option<&str>) -> Result<Vec<AccountMeta>, String> {
1736 with_locked_state(|coordinator| Ok(coordinator.read_snapshot()?.account_meta())).await
1737}
1738
1739pub async fn switch_account(account_id: &str) -> Result<(), String> {
1742 let account_id = account_id.to_string();
1743 let result =
1744 with_locked_state(move |coordinator| coordinator.switch_account(&account_id).map(|_| ()))
1745 .await;
1746 invalidate_access_token_cache();
1747 invalidate_proactive_refresh_failure();
1748 result
1749}
1750
1751pub async fn remove_account(account_id: &str) -> Result<Vec<AccountMeta>, String> {
1754 let account_id = account_id.to_string();
1755 let result = with_locked_state(move |coordinator| {
1756 Ok(coordinator.remove_account(&account_id)?.account_meta())
1757 })
1758 .await;
1759 invalidate_access_token_cache();
1760 invalidate_proactive_refresh_failure();
1761 result
1762}
1763
1764#[cfg(test)]
1772mod tests {
1773 use super::*;
1774 use std::ffi::OsString;
1775
1776 static AUTH_ENV_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
1777
1778 struct RestoredEnv {
1779 values: Vec<(&'static str, Option<OsString>)>,
1780 }
1781
1782 impl RestoredEnv {
1783 fn capture(keys: &[&'static str]) -> Self {
1784 Self {
1785 values: keys
1786 .iter()
1787 .map(|key| (*key, std::env::var_os(key)))
1788 .collect(),
1789 }
1790 }
1791 }
1792
1793 impl Drop for RestoredEnv {
1794 fn drop(&mut self) {
1795 for (key, value) in self.values.drain(..) {
1796 match value {
1797 Some(value) => std::env::set_var(key, value),
1798 None => std::env::remove_var(key),
1799 }
1800 }
1801 }
1802 }
1803
1804 #[tokio::test]
1805 async fn auth_env_lock_survives_result_receiver_drop_until_owner_finishes() {
1806 let (holder_acquired_tx, holder_acquired_rx) = tokio::sync::oneshot::channel();
1807 let (release_tx, release_rx) = tokio::sync::oneshot::channel::<()>();
1808 let (owner_result_tx, owner_result_rx) = tokio::sync::oneshot::channel();
1809 let holder = tokio::spawn(async move {
1810 let _guard = AUTH_ENV_LOCK.lock().await;
1811 let _ = holder_acquired_tx.send(());
1812 let _ = release_rx.await;
1813 let _ = owner_result_tx.send(());
1814 });
1815 holder_acquired_rx.await.unwrap();
1816 drop(owner_result_rx);
1817
1818 let (contender_started_tx, contender_started_rx) = tokio::sync::oneshot::channel();
1819 let (contender_acquired_tx, mut contender_acquired_rx) = tokio::sync::oneshot::channel();
1820 let contender = tokio::spawn(async move {
1821 let _ = contender_started_tx.send(());
1822 let _guard = AUTH_ENV_LOCK.lock().await;
1823 let _ = contender_acquired_tx.send(());
1824 });
1825 contender_started_rx.await.unwrap();
1826
1827 assert!(
1828 tokio::time::timeout(
1829 std::time::Duration::from_millis(50),
1830 &mut contender_acquired_rx,
1831 )
1832 .await
1833 .is_err(),
1834 "a contender must not enter while the first future owns the environment lock"
1835 );
1836
1837 drop(release_tx);
1838 holder.await.unwrap();
1839 contender_acquired_rx.await.unwrap();
1840 contender.await.unwrap();
1841 }
1842
1843 #[test]
1844 fn local_auth_snapshot_omits_an_unattributable_active_account() {
1845 let snapshot = LocalAuthSnapshot {
1846 authenticated: true,
1847 active_account_id: None,
1848 };
1849
1850 assert_eq!(
1851 serde_json::to_value(snapshot).unwrap(),
1852 serde_json::json!({ "authenticated": true })
1853 );
1854 }
1855
1856 #[tokio::test]
1857 async fn coordinator_queue_wait_has_an_enforced_deadline() {
1858 let mutex = tokio::sync::Mutex::new(());
1859 let _held = mutex.lock().await;
1860 let timeout = Duration::from_millis(10);
1861 let error = lock_auth_state_queue(&mutex, timeout)
1862 .await
1863 .expect_err("a contended coordinator queue must fail at its own bound");
1864 assert!(
1865 matches!(error, AuthOperationError::CoordinationDeadline(_)),
1866 "bounded contention must stay typed as retryable: {error:?}"
1867 );
1868 let message = error.to_string();
1869 assert!(
1870 message.contains("in-process Parslee credential coordinator")
1871 && message.contains("10ms"),
1872 "{message}"
1873 );
1874 }
1875
1876 #[test]
1877 fn worker_lease_exceeds_the_serial_redemption_budget() {
1878 let composed_serial_budget = AUTH_STATE_OPERATION_BUDGET
1879 + AUTH_COMPLETION_NETWORK_DEADLINE
1880 + AUTH_COORDINATOR_QUEUE_TIMEOUT
1881 + AUTH_PROCESS_LOCK_TIMEOUT
1882 + AUTH_STATE_OPERATION_BUDGET;
1883
1884 assert_eq!(
1885 LOGIN_ATTEMPT_WORKER_SERIAL_BUDGET, composed_serial_budget,
1886 "serial redemption budget must compose every bounded phase exactly once"
1887 );
1888 assert!(
1889 LOGIN_ATTEMPT_WORKER_SCHEDULING_MARGIN > Duration::ZERO,
1890 "worker lease requires explicit positive scheduling margin"
1891 );
1892 assert_eq!(
1893 LOGIN_ATTEMPT_WORKER_TTL,
1894 LOGIN_ATTEMPT_WORKER_SERIAL_BUDGET + LOGIN_ATTEMPT_WORKER_SCHEDULING_MARGIN,
1895 "worker lease must be derived from the complete serial budget plus margin"
1896 );
1897 }
1898
1899 #[test]
1900 fn local_auth_snapshot_serializes_an_attributable_active_account() {
1901 let snapshot = LocalAuthSnapshot {
1902 authenticated: true,
1903 active_account_id: Some("account-1".to_string()),
1904 };
1905
1906 assert_eq!(
1907 serde_json::to_value(snapshot).unwrap(),
1908 serde_json::json!({
1909 "authenticated": true,
1910 "active_account_id": "account-1",
1911 })
1912 );
1913 }
1914
1915 #[test]
1916 fn pkce_challenge_is_s256_urlsafe_nopad() {
1917 let v = pkce_verifier();
1918 let c = pkce_challenge(&v);
1919 assert!(!c.contains('=') && !c.contains('+') && !c.contains('/'));
1920 assert_eq!(c, pkce_challenge(&v)); }
1922
1923 #[test]
1924 fn authorize_url_has_pkce_and_provider() {
1925 let u = authorize_url(
1926 "https://api.parslee.ai/",
1927 "parslee-car",
1928 "http://localhost:8765/auth/callback",
1929 "st8",
1930 "chal",
1931 Some("microsoft"),
1932 Some("select_account"),
1933 )
1934 .unwrap();
1935 assert!(u.starts_with("https://api.parslee.ai/connect/authorize?"));
1936 assert!(u.contains("code_challenge=chal"));
1937 assert!(u.contains("code_challenge_method=S256"));
1938 assert!(u.contains("client_id=parslee-car"));
1939 assert!(u.contains("provider=microsoft"));
1940 assert!(u.contains("prompt=select_account"));
1941 }
1942
1943 #[test]
1944 fn api_base_precedence() {
1945 assert_eq!(api_base(Some("https://x.test/")), "https://x.test");
1946 }
1947
1948 #[test]
1949 fn api_base_environment_override_beats_persisted_state() {
1950 let _env_lock = AUTH_ENV_LOCK.blocking_lock();
1951 let _restore = RestoredEnv::capture(&["CAR_SECRETS_FILE_DIR", PARSLEE_API_BASE_KEY]);
1952 let directory = tempfile::tempdir().unwrap();
1953 std::env::set_var("CAR_SECRETS_FILE_DIR", directory.path());
1954 std::env::set_var(PARSLEE_API_BASE_KEY, "https://env.example/");
1955 SecretStore::new()
1956 .publish(
1957 &SecretRef::with_default_service(car_secrets::PARSLEE_AUTH_STATE_V2_KEY),
1958 &serde_json::json!({
1959 "schema": 2,
1960 "revision": 7,
1961 "generation": 3,
1962 "active": {
1963 "account_id": "account-v2",
1964 "access_token": "v2-access",
1965 "expires_at": 9_999_999_999_u64,
1966 "api_base": "https://persisted.example"
1967 },
1968 "accounts": [{
1969 "account_id": "account-v2",
1970 "access_token": "v2-access",
1971 "expires_at": 9_999_999_999_u64,
1972 "api_base": "https://persisted.example"
1973 }]
1974 })
1975 .to_string(),
1976 )
1977 .unwrap();
1978
1979 assert_eq!(api_base(None), "https://env.example");
1980 }
1981
1982 #[test]
1988 fn cache_does_not_serve_a_token_that_is_due_for_refresh() {
1989 invalidate_access_token_cache();
1990 let nearly_expired = epoch_seconds() + REFRESH_SKEW_SECS / 2;
1991 store_resolved_credential(&ResolvedParsleeCredential {
1992 access_token: "about-to-expire".into(),
1993 api_base: DEFAULT_API_BASE.into(),
1994 expires_at: nearly_expired,
1995 });
1996 assert_eq!(
1997 cached_credential(),
1998 None,
1999 "a token inside the refresh skew must not be served from cache"
2000 );
2001
2002 invalidate_access_token_cache();
2003 let expected = ResolvedParsleeCredential {
2004 access_token: "good-for-hours".into(),
2005 api_base: "https://staging-api.parslee.test".into(),
2006 expires_at: epoch_seconds() + 3_600,
2007 };
2008 store_resolved_credential(&expected);
2009 assert_eq!(cached_credential(), Some(expected));
2010 }
2011
2012 #[test]
2016 fn cache_serves_a_token_with_no_recorded_expiry() {
2017 invalidate_access_token_cache();
2018 let expected = ResolvedParsleeCredential {
2019 access_token: "no-expiry".into(),
2020 api_base: DEFAULT_API_BASE.into(),
2021 expires_at: 0,
2022 };
2023 store_resolved_credential(&expected);
2024 assert_eq!(cached_credential(), Some(expected));
2025 }
2026
2027 #[test]
2030 fn invalidate_clears_a_cached_token() {
2031 invalidate_access_token_cache();
2032 store_resolved_credential(&ResolvedParsleeCredential {
2033 access_token: "live".into(),
2034 api_base: DEFAULT_API_BASE.into(),
2035 expires_at: epoch_seconds() + 3_600,
2036 });
2037 assert!(cached_credential().is_some());
2038 invalidate_access_token_cache();
2039 assert_eq!(
2040 cached_credential(),
2041 None,
2042 "logout / switch / refresh must not leave a stale bearer readable"
2043 );
2044 }
2045
2046 #[test]
2047 fn normal_readers_never_fall_back_to_conflicting_legacy_slots() {
2048 let _env_lock = AUTH_ENV_LOCK.blocking_lock();
2049 let _restore = RestoredEnv::capture(&[
2050 "CAR_SECRETS_FILE_DIR",
2051 PARSLEE_ACCESS_TOKEN_KEY,
2052 PARSLEE_API_BASE_KEY,
2053 ]);
2054 let directory = tempfile::tempdir().unwrap();
2055 std::env::set_var("CAR_SECRETS_FILE_DIR", directory.path());
2056 std::env::remove_var(PARSLEE_ACCESS_TOKEN_KEY);
2057 std::env::remove_var(PARSLEE_API_BASE_KEY);
2058
2059 let store = SecretStore::new();
2060 store
2061 .put(
2062 &SecretRef::with_default_service(PARSLEE_ACCESS_TOKEN_KEY),
2063 "legacy-access",
2064 )
2065 .unwrap();
2066 store
2067 .put(
2068 &SecretRef::with_default_service(PARSLEE_API_BASE_KEY),
2069 "https://legacy.example",
2070 )
2071 .unwrap();
2072 let state_ref = SecretRef::with_default_service(car_secrets::PARSLEE_AUTH_STATE_V2_KEY);
2073 assert!(
2074 access_token_is_available(),
2075 "a legacy token may enter the locked request-time migration path only before V2 exists"
2076 );
2077
2078 store
2079 .publish(
2080 &state_ref,
2081 &serde_json::json!({
2082 "schema": 2,
2083 "revision": 7,
2084 "generation": 3,
2085 "active": {
2086 "account_id": "account-v2",
2087 "access_token": "v2-access",
2088 "refresh_token": "v2-refresh",
2089 "expires_at": 9_999_999_999_u64,
2090 "api_base": "https://v2.example"
2091 },
2092 "accounts": [{
2093 "account_id": "account-v2",
2094 "access_token": "v2-access",
2095 "refresh_token": "v2-refresh",
2096 "expires_at": 9_999_999_999_u64,
2097 "api_base": "https://v2.example"
2098 }],
2099 "tombstone": false
2100 })
2101 .to_string(),
2102 )
2103 .unwrap();
2104 assert_eq!(access_token().as_deref(), Some("v2-access"));
2105 assert!(access_token_is_available());
2106 assert_eq!(api_base(None), "https://v2.example");
2107
2108 store
2109 .publish(
2110 &state_ref,
2111 r#"{"schema":2,"revision":8,"generation":4,"accounts":[],"tombstone":true}"#,
2112 )
2113 .unwrap();
2114 assert_eq!(access_token(), None);
2115 assert!(
2116 !access_token_is_available(),
2117 "a published tombstone must remain authoritative over the stale legacy token"
2118 );
2119 assert_eq!(api_base(None), DEFAULT_API_BASE);
2120
2121 store.publish(&state_ref, "{not-json").unwrap();
2122 assert_eq!(access_token(), None, "invalid V2 must fail closed");
2123 assert!(
2124 !access_token_is_available(),
2125 "an invalid V2 record must fail closed instead of reviving legacy"
2126 );
2127 assert_eq!(
2128 api_base(None),
2129 DEFAULT_API_BASE,
2130 "invalid V2 must not resurrect the legacy API base"
2131 );
2132 }
2133
2134 mod mock {
2141 use std::io::{Read, Write};
2142 use std::net::TcpListener;
2143 use std::sync::{Arc, Mutex};
2144 use std::thread;
2145
2146 pub struct Recorded {
2147 pub method: String,
2148 pub path: String,
2149 pub authorization: Option<String>,
2150 #[allow(dead_code)] pub content_type: Option<String>,
2152 pub body: String,
2153 }
2154
2155 pub struct Mock {
2156 pub base: String,
2157 pub recorded: Arc<Mutex<Vec<Recorded>>>,
2158 handle: Option<thread::JoinHandle<()>>,
2159 }
2160
2161 impl Drop for Mock {
2162 fn drop(&mut self) {
2163 if let Some(h) = self.handle.take() {
2164 let _ = h.join();
2165 }
2166 }
2167 }
2168
2169 fn find(hay: &[u8], needle: &[u8]) -> Option<usize> {
2170 hay.windows(needle.len()).position(|w| w == needle)
2171 }
2172
2173 pub fn start(
2174 expected: usize,
2175 respond: impl Fn(&Recorded) -> (u16, String) + Send + 'static,
2176 ) -> Mock {
2177 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
2178 let port = listener.local_addr().unwrap().port();
2179 let recorded = Arc::new(Mutex::new(Vec::new()));
2180 let rec = recorded.clone();
2181 let handle = thread::spawn(move || {
2188 listener
2189 .set_nonblocking(true)
2190 .expect("mock listener nonblocking");
2191 for _ in 0..expected {
2192 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
2193 let mut stream = loop {
2194 match listener.accept() {
2195 Ok((stream, _)) => break stream,
2196 Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
2197 if std::time::Instant::now() >= deadline {
2198 return;
2203 }
2204 thread::sleep(std::time::Duration::from_millis(5));
2205 }
2206 Err(e) => panic!("mock accept failed: {e}"),
2207 }
2208 };
2209 stream.set_nonblocking(false).expect("mock stream blocking");
2212 stream
2213 .set_read_timeout(Some(std::time::Duration::from_secs(30)))
2214 .expect("mock stream read timeout");
2215 let mut buf = Vec::new();
2216 let mut tmp = [0u8; 1024];
2217 loop {
2218 let n = stream.read(&mut tmp).unwrap();
2219 if n == 0 {
2220 break;
2221 }
2222 buf.extend_from_slice(&tmp[..n]);
2223 let Some(hdr_end) = find(&buf, b"\r\n\r\n") else {
2224 continue;
2225 };
2226 let headers = String::from_utf8_lossy(&buf[..hdr_end]).into_owned();
2227 let content_length = headers
2228 .lines()
2229 .find_map(|l| {
2230 let (k, v) = l.split_once(':')?;
2231 if k.eq_ignore_ascii_case("content-length") {
2232 v.trim().parse::<usize>().ok()
2233 } else {
2234 None
2235 }
2236 })
2237 .unwrap_or(0);
2238 let body_start = hdr_end + 4;
2239 while buf.len() < body_start + content_length {
2240 let n = stream.read(&mut tmp).unwrap();
2241 if n == 0 {
2242 break;
2243 }
2244 buf.extend_from_slice(&tmp[..n]);
2245 }
2246 let mut header_lines = headers.lines();
2247 let req_line = header_lines.next().unwrap_or("");
2248 let mut rl = req_line.split_whitespace();
2249 let method = rl.next().unwrap_or("").to_string();
2250 let path = rl.next().unwrap_or("").to_string();
2251 let mut authorization = None;
2252 let mut content_type = None;
2253 for l in header_lines {
2254 if let Some((k, v)) = l.split_once(':') {
2255 if k.eq_ignore_ascii_case("authorization") {
2256 authorization = Some(v.trim().to_string());
2257 } else if k.eq_ignore_ascii_case("content-type") {
2258 content_type = Some(v.trim().to_string());
2259 }
2260 }
2261 }
2262 let body = String::from_utf8_lossy(
2263 &buf[body_start..(body_start + content_length).min(buf.len())],
2264 )
2265 .into_owned();
2266 let r = Recorded {
2267 method,
2268 path,
2269 authorization,
2270 content_type,
2271 body,
2272 };
2273 let (code, resp_body) = respond(&r);
2274 rec.lock().unwrap().push(r);
2275 let resp = format!(
2276 "HTTP/1.1 {code} OK\r\ncontent-type: application/json\r\n\
2277 content-length: {}\r\nconnection: close\r\n\r\n{}",
2278 resp_body.len(),
2279 resp_body
2280 );
2281 stream.write_all(resp.as_bytes()).unwrap();
2282 let _ = stream.flush();
2283 break;
2284 }
2285 }
2286 });
2287 Mock {
2288 base: format!("http://127.0.0.1:{port}"),
2289 recorded,
2290 handle: Some(handle),
2291 }
2292 }
2293 }
2294
2295 #[tokio::test]
2296 async fn exchange_code_round_trips_token() {
2297 let mock = mock::start(1, |_r| {
2298 (
2299 200,
2300 r#"{"access_token":"a","refresh_token":"r","expires_in":3600,"token_type":"Bearer"}"#
2301 .to_string(),
2302 )
2303 });
2304 let token = exchange_code(
2305 &mock.base,
2306 "parslee-car",
2307 "http://localhost:1/cb",
2308 "thecode",
2309 "theverifier",
2310 )
2311 .await
2312 .unwrap();
2313 assert_eq!(token.access_token, "a");
2314 assert_eq!(token.refresh_token, "r");
2315 assert_eq!(token.expires_in, 3600);
2316
2317 let reqs = mock.recorded.lock().unwrap();
2318 assert_eq!(reqs.len(), 1);
2319 assert_eq!(reqs[0].method, "POST");
2320 assert_eq!(reqs[0].path, "/connect/token");
2321 assert!(reqs[0].body.contains("grant_type=authorization_code"));
2322 assert!(reqs[0].body.contains("code=thecode"));
2323 assert!(reqs[0].body.contains("code_verifier=theverifier"));
2324 }
2325
2326 const STUCK_FUTURE_GUARD: Duration = Duration::from_secs(30);
2340
2341 #[tokio::test]
2342 async fn exchange_code_stall_is_bounded_by_the_explicit_request_timeout() {
2343 let mock = mock::start(1, |_r| {
2344 std::thread::sleep(Duration::from_millis(250));
2345 (
2346 200,
2347 r#"{"access_token":"a","refresh_token":"r","expires_in":3600,"token_type":"Bearer"}"#
2348 .to_string(),
2349 )
2350 });
2351
2352 let error = tokio::time::timeout(
2353 STUCK_FUTURE_GUARD,
2354 exchange_code_with_timeout(
2355 &mock.base,
2356 "parslee-car",
2357 "http://localhost:1/cb",
2358 "thecode",
2359 "theverifier",
2360 Duration::from_millis(50),
2361 ),
2362 )
2363 .await
2364 .expect("the explicit token request timeout must bound the stalled endpoint")
2365 .unwrap_err();
2366
2367 assert_eq!(
2368 error,
2369 "exchange Parslee authorization code timed out after 50ms"
2370 );
2371 }
2372
2373 #[tokio::test]
2374 async fn refresh_grant_round_trips_token() {
2375 let mock = mock::start(1, |_r| {
2378 (
2379 200,
2380 r#"{"access_token":"a2","expires_in":3600,"token_type":"Bearer"}"#.to_string(),
2381 )
2382 });
2383 let tokens = refresh_grant(&mock.base, "the-refresh-token")
2384 .await
2385 .unwrap();
2386 assert_eq!(tokens.access_token, "a2");
2387 assert_eq!(tokens.refresh_token, None);
2388 assert_eq!(tokens.expires_in, Some(3600));
2389
2390 let reqs = mock.recorded.lock().unwrap();
2391 assert_eq!(reqs.len(), 1);
2392 assert_eq!(reqs[0].method, "POST");
2393 assert_eq!(reqs[0].path, "/connect/token");
2394 assert!(reqs[0].body.contains("grant_type=refresh_token"));
2395 assert!(reqs[0].body.contains("refresh_token=the-refresh-token"));
2396 assert!(!reqs[0].body.contains("client_id"));
2398 }
2399
2400 #[tokio::test]
2401 async fn repeated_proactive_refresh_failures_back_off() {
2402 let _env_lock = AUTH_ENV_LOCK.lock().await;
2403 let _restore = RestoredEnv::capture(&[
2404 car_home::ENV_VAR,
2405 "CAR_SECRETS_FILE_DIR",
2406 PARSLEE_ACCESS_TOKEN_KEY,
2407 PARSLEE_API_BASE_KEY,
2408 ]);
2409 let directory = tempfile::tempdir().unwrap();
2410 std::env::set_var(car_home::ENV_VAR, directory.path().join("car-home"));
2411 std::env::set_var("CAR_SECRETS_FILE_DIR", directory.path());
2412 std::env::remove_var(PARSLEE_ACCESS_TOKEN_KEY);
2413 std::env::remove_var(PARSLEE_API_BASE_KEY);
2414 invalidate_access_token_cache();
2415 invalidate_proactive_refresh_failure();
2416
2417 let response_index = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
2418 let next_response = std::sync::Arc::clone(&response_index);
2419 let mock = mock::start(5, move |_request| {
2420 if next_response.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 3 {
2421 (
2422 200,
2423 r#"{"access_token":"expired-access","expires_in":0}"#.to_string(),
2424 )
2425 } else {
2426 (401, r#"{"error":"invalid_grant"}"#.to_string())
2427 }
2428 });
2429 let active = ActiveCredentials {
2430 account_id: "expired-account".into(),
2431 email: None,
2432 name: None,
2433 access_token: "expired-access".into(),
2434 refresh_token: Some("invalid-refresh".into()),
2435 expires_at: 1,
2436 api_base: mock.base.clone(),
2437 };
2438 SecretStore::new()
2439 .publish(
2440 &SecretRef::with_default_service(car_secrets::PARSLEE_AUTH_STATE_V2_KEY),
2441 &serde_json::json!({
2442 "schema": 2,
2443 "revision": 1,
2444 "generation": 1,
2445 "active": active.clone(),
2446 "accounts": [active.clone()]
2447 })
2448 .to_string(),
2449 )
2450 .unwrap();
2451
2452 let started = Instant::now();
2453 for _ in 0..10 {
2454 let resolved =
2455 resolve_credential_once_with_clock(CredentialReadPurpose::Resolve, || started)
2456 .await
2457 .unwrap()
2458 .unwrap();
2459 assert_eq!(resolved.access_token, "expired-access");
2460 }
2461 assert_eq!(
2462 mock.recorded.lock().unwrap().len(),
2463 1,
2464 "back-to-back resolves inside the interval must make one attempt"
2465 );
2466
2467 let mut newly_logged_in = active.clone();
2468 newly_logged_in.access_token = "new-login-access".into();
2469 assert!(
2470 !proactive_refresh_is_suppressed(&newly_logged_in, started + Duration::from_secs(1)),
2471 "a different credential must not inherit the failed interval"
2472 );
2473 let mut externally_refreshed = active.clone();
2474 externally_refreshed.expires_at = active.expires_at + 1;
2475 assert!(
2476 !proactive_refresh_is_suppressed(
2477 &externally_refreshed,
2478 started + Duration::from_secs(1)
2479 ),
2480 "the same token tuple with a new authoritative expiry must not inherit backoff"
2481 );
2482
2483 let interval_end = started + PROACTIVE_REFRESH_FAILURE_MIN_INTERVAL;
2484 resolve_credential_once_with_clock(CredentialReadPurpose::Resolve, || interval_end)
2485 .await
2486 .unwrap();
2487 assert_eq!(
2488 mock.recorded.lock().unwrap().len(),
2489 2,
2490 "the interval boundary must permit a second proactive attempt"
2491 );
2492
2493 let inside_second_interval = interval_end + Duration::from_secs(1);
2494 let forced =
2495 resolve_credential_once_with_clock(CredentialReadPurpose::ForceRefresh, || {
2496 inside_second_interval
2497 })
2498 .await
2499 .unwrap();
2500 assert_eq!(forced, None);
2501 assert_eq!(
2502 mock.recorded.lock().unwrap().len(),
2503 3,
2504 "ForceRefresh must bypass the proactive interval"
2505 );
2506 resolve_credential_once_with_clock(CredentialReadPurpose::Resolve, || {
2507 inside_second_interval
2508 })
2509 .await
2510 .unwrap();
2511 assert_eq!(
2512 mock.recorded.lock().unwrap().len(),
2513 3,
2514 "a failed ForceRefresh must not clear proactive backoff"
2515 );
2516
2517 let second_interval_end = interval_end + PROACTIVE_REFRESH_FAILURE_MIN_INTERVAL;
2518 let refreshed = resolve_credential_once_with_clock(CredentialReadPurpose::Resolve, || {
2519 second_interval_end
2520 })
2521 .await
2522 .unwrap()
2523 .unwrap();
2524 assert_eq!(refreshed.access_token, "expired-access");
2525 assert_eq!(mock.recorded.lock().unwrap().len(), 4);
2526
2527 resolve_credential_once_with_clock(CredentialReadPurpose::Resolve, || second_interval_end)
2528 .await
2529 .unwrap();
2530 assert_eq!(
2531 mock.recorded.lock().unwrap().len(),
2532 5,
2533 "a successful proactive refresh must clear the failed interval"
2534 );
2535 invalidate_access_token_cache();
2536 invalidate_proactive_refresh_failure();
2537 }
2538
2539 #[tokio::test]
2540 async fn login_logout_and_account_switch_clear_refresh_backoff() {
2541 let _env_lock = AUTH_ENV_LOCK.lock().await;
2542 let _restore = RestoredEnv::capture(&[
2543 car_home::ENV_VAR,
2544 "CAR_SECRETS_FILE_DIR",
2545 PARSLEE_ACCESS_TOKEN_KEY,
2546 PARSLEE_API_BASE_KEY,
2547 ]);
2548 let directory = tempfile::tempdir().unwrap();
2549 std::env::set_var(car_home::ENV_VAR, directory.path().join("car-home"));
2550 std::env::set_var("CAR_SECRETS_FILE_DIR", directory.path());
2551 std::env::remove_var(PARSLEE_ACCESS_TOKEN_KEY);
2552 std::env::remove_var(PARSLEE_API_BASE_KEY);
2553 invalidate_proactive_refresh_failure();
2554
2555 let old = ActiveCredentials {
2556 account_id: "old-account".into(),
2557 email: None,
2558 name: None,
2559 access_token: "same-access".into(),
2560 refresh_token: Some("same-refresh".into()),
2561 expires_at: 1,
2562 api_base: DEFAULT_API_BASE.into(),
2563 };
2564 record_proactive_refresh_failure(&old, Instant::now());
2565 commit_login(
2566 DEFAULT_API_BASE,
2567 &TokenSet {
2568 access_token: "new-access".into(),
2569 refresh_token: "new-refresh".into(),
2570 expires_in: 3_600,
2571 token_type: "Bearer".into(),
2572 },
2573 r#"{"Account":{"Id":"new-account"}}"#,
2574 None,
2575 )
2576 .await
2577 .unwrap();
2578 assert!(
2579 proactive_refresh_failure().lock().unwrap().is_none(),
2580 "same-process login replacement must clear refresh backoff"
2581 );
2582
2583 record_proactive_refresh_failure(&old, Instant::now());
2584 logout().await.unwrap();
2585 assert!(
2586 proactive_refresh_failure().lock().unwrap().is_none(),
2587 "same-process logout must clear refresh backoff"
2588 );
2589
2590 let second = ActiveCredentials {
2591 account_id: "second-account".into(),
2592 access_token: "second-access".into(),
2593 refresh_token: Some("second-refresh".into()),
2594 ..old.clone()
2595 };
2596 SecretStore::new()
2597 .publish(
2598 &SecretRef::with_default_service(car_secrets::PARSLEE_AUTH_STATE_V2_KEY),
2599 &serde_json::json!({
2600 "schema": 2,
2601 "revision": 7,
2602 "generation": 4,
2603 "active": old.clone(),
2604 "accounts": [old.clone(), second]
2605 })
2606 .to_string(),
2607 )
2608 .unwrap();
2609 record_proactive_refresh_failure(&old, Instant::now());
2610 switch_account("second-account").await.unwrap();
2611 assert!(
2612 proactive_refresh_failure().lock().unwrap().is_none(),
2613 "same-process account switch must clear refresh backoff"
2614 );
2615 }
2616
2617 #[tokio::test]
2618 async fn proactive_refresh_commit_failure_arms_backoff() {
2619 let _env_lock = AUTH_ENV_LOCK.lock().await;
2620 let _restore = RestoredEnv::capture(&[
2621 car_home::ENV_VAR,
2622 "CAR_SECRETS_FILE_DIR",
2623 PARSLEE_ACCESS_TOKEN_KEY,
2624 PARSLEE_API_BASE_KEY,
2625 ]);
2626 let directory = tempfile::tempdir().unwrap();
2627 std::env::set_var(car_home::ENV_VAR, directory.path().join("car-home"));
2628 std::env::set_var("CAR_SECRETS_FILE_DIR", directory.path());
2629 std::env::remove_var(PARSLEE_ACCESS_TOKEN_KEY);
2630 std::env::remove_var(PARSLEE_API_BASE_KEY);
2631 invalidate_access_token_cache();
2632 invalidate_proactive_refresh_failure();
2633
2634 let mock = mock::start(1, |_request| {
2635 SecretStore::new()
2636 .publish(
2637 &SecretRef::with_default_service(car_secrets::PARSLEE_AUTH_STATE_V2_KEY),
2638 "{malformed",
2639 )
2640 .unwrap();
2641 (
2642 200,
2643 r#"{"access_token":"fresh-access","expires_in":3600}"#.to_string(),
2644 )
2645 });
2646 let active = ActiveCredentials {
2647 account_id: "commit-failure-account".into(),
2648 email: None,
2649 name: None,
2650 access_token: "expired-access".into(),
2651 refresh_token: Some("refresh".into()),
2652 expires_at: 1,
2653 api_base: mock.base.clone(),
2654 };
2655 SecretStore::new()
2656 .publish(
2657 &SecretRef::with_default_service(car_secrets::PARSLEE_AUTH_STATE_V2_KEY),
2658 &serde_json::json!({
2659 "schema": 2,
2660 "revision": 1,
2661 "generation": 1,
2662 "active": active.clone(),
2663 "accounts": [active.clone()]
2664 })
2665 .to_string(),
2666 )
2667 .unwrap();
2668
2669 let failed_at = Instant::now();
2670 let resolved =
2671 resolve_credential_once_with_clock(CredentialReadPurpose::Resolve, || failed_at)
2672 .await
2673 .unwrap()
2674 .unwrap();
2675 assert_eq!(resolved.access_token, "expired-access");
2676 assert!(
2677 proactive_refresh_is_suppressed(&active, failed_at + Duration::from_secs(1)),
2678 "a proactive grant that cannot be committed must arm backoff"
2679 );
2680 assert_eq!(mock.recorded.lock().unwrap().len(), 1);
2681 invalidate_proactive_refresh_failure();
2682 }
2683
2684 #[tokio::test]
2685 async fn rejected_bearer_loop_limits_total_refresh_endpoint_attempts() {
2686 let _env_lock = AUTH_ENV_LOCK.lock().await;
2687 let _restore = RestoredEnv::capture(&[
2688 car_home::ENV_VAR,
2689 "CAR_SECRETS_FILE_DIR",
2690 PARSLEE_ACCESS_TOKEN_KEY,
2691 PARSLEE_API_BASE_KEY,
2692 ]);
2693 let directory = tempfile::tempdir().unwrap();
2694 std::env::set_var(car_home::ENV_VAR, directory.path().join("car-home"));
2695 std::env::set_var("CAR_SECRETS_FILE_DIR", directory.path());
2696 std::env::remove_var(PARSLEE_ACCESS_TOKEN_KEY);
2697 std::env::remove_var(PARSLEE_API_BASE_KEY);
2698 invalidate_access_token_cache();
2699 invalidate_proactive_refresh_failure();
2700
2701 let mock = mock::start(4, |_request| {
2702 (401, r#"{"error":"invalid_grant"}"#.to_string())
2703 });
2704 let active = ActiveCredentials {
2705 account_id: "rejected-account".into(),
2706 email: None,
2707 name: None,
2708 access_token: "rejected-access".into(),
2709 refresh_token: Some("invalid-refresh".into()),
2710 expires_at: 1,
2711 api_base: mock.base.clone(),
2712 };
2713 SecretStore::new()
2714 .publish(
2715 &SecretRef::with_default_service(car_secrets::PARSLEE_AUTH_STATE_V2_KEY),
2716 &serde_json::json!({
2717 "schema": 2,
2718 "revision": 1,
2719 "generation": 1,
2720 "active": active.clone(),
2721 "accounts": [active.clone()]
2722 })
2723 .to_string(),
2724 )
2725 .unwrap();
2726
2727 let mut first_failure_at = None;
2728 for _ in 0..10 {
2729 let credential = resolve_credential(CredentialReadMode::Use)
2730 .await
2731 .unwrap()
2732 .unwrap();
2733 assert_eq!(credential.access_token, "rejected-access");
2734 let failed_at = *first_failure_at.get_or_insert_with(|| {
2735 proactive_refresh_failure()
2736 .lock()
2737 .unwrap()
2738 .as_ref()
2739 .expect("the first proactive failure must arm backoff")
2740 .failed_at
2741 });
2742 assert_eq!(
2743 refresh_credential_after_rejection_with_clock("rejected-access", || failed_at)
2744 .await
2745 .unwrap(),
2746 None
2747 );
2748 }
2749
2750 assert_eq!(
2751 mock.recorded.lock().unwrap().len(),
2752 1,
2753 "ten rejected-bearer calls inside the interval must make one token-endpoint attempt"
2754 );
2755
2756 let interval_end = first_failure_at.unwrap() + PROACTIVE_REFRESH_FAILURE_MIN_INTERVAL;
2757 assert_eq!(
2758 refresh_credential_after_rejection_with_clock("rejected-access", || interval_end)
2759 .await
2760 .unwrap(),
2761 None
2762 );
2763 assert_eq!(
2764 mock.recorded.lock().unwrap().len(),
2765 2,
2766 "the automatic retry at the interval boundary is the second attempt within one minute"
2767 );
2768
2769 assert_eq!(
2770 refresh_credential().await.unwrap(),
2771 None,
2772 "an explicit forced refresh remains unconditional"
2773 );
2774 assert_eq!(mock.recorded.lock().unwrap().len(), 3);
2775
2776 assert_eq!(
2777 resolve_credential(CredentialReadMode::Retry).await.unwrap(),
2778 Some(resolved_from_active(&active)),
2779 "an explicit Retry-mode resolve remains unconditional"
2780 );
2781 assert_eq!(mock.recorded.lock().unwrap().len(), 4);
2782
2783 invalidate_access_token_cache();
2784 invalidate_proactive_refresh_failure();
2785 }
2786
2787 #[tokio::test]
2788 async fn automatic_refresh_is_attributed_across_account_switch_race() {
2789 let _env_lock = AUTH_ENV_LOCK.lock().await;
2790 let _restore = RestoredEnv::capture(&[
2791 car_home::ENV_VAR,
2792 "CAR_SECRETS_FILE_DIR",
2793 PARSLEE_ACCESS_TOKEN_KEY,
2794 PARSLEE_API_BASE_KEY,
2795 ]);
2796 let directory = tempfile::tempdir().unwrap();
2797 std::env::set_var(car_home::ENV_VAR, directory.path().join("car-home"));
2798 std::env::set_var("CAR_SECRETS_FILE_DIR", directory.path());
2799 std::env::remove_var(PARSLEE_ACCESS_TOKEN_KEY);
2800 std::env::remove_var(PARSLEE_API_BASE_KEY);
2801 invalidate_access_token_cache();
2802 invalidate_proactive_refresh_failure();
2803
2804 let (a_started_tx, a_started_rx) = tokio::sync::oneshot::channel();
2805 let a_started_tx = std::sync::Mutex::new(Some(a_started_tx));
2806 let (release_a_tx, release_a_rx) = std::sync::mpsc::channel();
2807 let attempt = std::sync::atomic::AtomicUsize::new(0);
2808 let mock = mock::start(2, move |request| {
2809 match attempt.fetch_add(1, std::sync::atomic::Ordering::SeqCst) {
2810 0 => {
2811 assert!(request.body.contains("refresh_token=a-refresh"));
2812 a_started_tx
2813 .lock()
2814 .unwrap()
2815 .take()
2816 .unwrap()
2817 .send(())
2818 .unwrap();
2819 release_a_rx.recv().unwrap();
2820 (401, r#"{"error":"invalid_grant"}"#.to_string())
2821 }
2822 1 => {
2823 assert!(request.body.contains("refresh_token=b-refresh"));
2824 (
2825 200,
2826 r#"{"access_token":"b-fresh","expires_in":3600}"#.to_string(),
2827 )
2828 }
2829 other => panic!("unexpected refresh attempt {other}"),
2830 }
2831 });
2832 let account_a = ActiveCredentials {
2833 account_id: "account-a".into(),
2834 email: None,
2835 name: None,
2836 access_token: "a-access".into(),
2837 refresh_token: Some("a-refresh".into()),
2838 expires_at: 9_999_999_999,
2839 api_base: mock.base.clone(),
2840 };
2841 let account_b = ActiveCredentials {
2842 account_id: "account-b".into(),
2843 access_token: "b-access".into(),
2844 refresh_token: Some("b-refresh".into()),
2845 ..account_a.clone()
2846 };
2847 SecretStore::new()
2848 .publish(
2849 &SecretRef::with_default_service(car_secrets::PARSLEE_AUTH_STATE_V2_KEY),
2850 &serde_json::json!({
2851 "schema": 2,
2852 "revision": 1,
2853 "generation": 1,
2854 "active": account_a.clone(),
2855 "accounts": [account_a, account_b]
2856 })
2857 .to_string(),
2858 )
2859 .unwrap();
2860
2861 let a_refresh = tokio::spawn(refresh_credential_after_rejection("a-access"));
2862 a_started_rx.await.unwrap();
2863 switch_account("account-b").await.unwrap();
2864
2865 let b_refresh = refresh_credential_after_rejection("b-access");
2868 release_a_tx.send(()).unwrap();
2869 let b_refresh = tokio::spawn(b_refresh);
2870
2871 assert_eq!(a_refresh.await.unwrap().unwrap(), None);
2872 let refreshed_b = b_refresh.await.unwrap().unwrap().unwrap();
2873 assert_eq!(refreshed_b.access_token, "b-fresh");
2874 assert_eq!(
2875 mock.recorded.lock().unwrap().len(),
2876 2,
2877 "A's failed flight must not suppress B's automatic refresh"
2878 );
2879
2880 let failure_before_stale_call = proactive_refresh_failure()
2881 .lock()
2882 .unwrap()
2883 .as_ref()
2884 .map(|failure| (failure.credential, failure.failed_at));
2885 let current = refresh_credential_after_rejection("a-access")
2886 .await
2887 .unwrap()
2888 .unwrap();
2889 assert_eq!(current.access_token, "b-fresh");
2890 assert_eq!(
2891 mock.recorded.lock().unwrap().len(),
2892 2,
2893 "a stale rejected bearer must return the current credential without refreshing"
2894 );
2895 assert_eq!(
2896 proactive_refresh_failure()
2897 .lock()
2898 .unwrap()
2899 .as_ref()
2900 .map(|failure| (failure.credential, failure.failed_at)),
2901 failure_before_stale_call,
2902 "a stale rejected bearer must not record refresh failure state"
2903 );
2904 invalidate_access_token_cache();
2905 invalidate_proactive_refresh_failure();
2906 }
2907
2908 #[tokio::test]
2909 async fn forced_refresh_cas_conflict_returns_complete_winning_credential() {
2910 let _env_lock = AUTH_ENV_LOCK.lock().await;
2911 let _restore = RestoredEnv::capture(&[
2912 "CAR_SECRETS_FILE_DIR",
2913 PARSLEE_ACCESS_TOKEN_KEY,
2914 PARSLEE_API_BASE_KEY,
2915 ]);
2916 let directory = tempfile::tempdir().unwrap();
2917 std::env::set_var("CAR_SECRETS_FILE_DIR", directory.path());
2918 std::env::remove_var(PARSLEE_ACCESS_TOKEN_KEY);
2919 std::env::remove_var(PARSLEE_API_BASE_KEY);
2920 invalidate_access_token_cache();
2921
2922 let winning_state = serde_json::json!({
2923 "schema": 2,
2924 "revision": 9,
2925 "generation": 5,
2926 "active": {
2927 "account_id": "winning-account",
2928 "access_token": "winning-access",
2929 "refresh_token": "winning-refresh",
2930 "expires_at": 9_999_999_999_u64,
2931 "api_base": "https://winning-api.example"
2932 },
2933 "accounts": [{
2934 "account_id": "winning-account",
2935 "access_token": "winning-access",
2936 "refresh_token": "winning-refresh",
2937 "expires_at": 9_999_999_999_u64,
2938 "api_base": "https://winning-api.example"
2939 }]
2940 })
2941 .to_string();
2942 let mock = mock::start(1, move |_request| {
2943 SecretStore::new()
2944 .publish(
2945 &SecretRef::with_default_service(car_secrets::PARSLEE_AUTH_STATE_V2_KEY),
2946 &winning_state,
2947 )
2948 .unwrap();
2949 (
2950 200,
2951 r#"{"access_token":"losing-refresh-access","expires_in":3600}"#.to_string(),
2952 )
2953 });
2954 SecretStore::new()
2955 .publish(
2956 &SecretRef::with_default_service(car_secrets::PARSLEE_AUTH_STATE_V2_KEY),
2957 &serde_json::json!({
2958 "schema": 2,
2959 "revision": 8,
2960 "generation": 4,
2961 "active": {
2962 "account_id": "original-account",
2963 "access_token": "rejected-access",
2964 "refresh_token": "original-refresh",
2965 "expires_at": 9_999_999_999_u64,
2966 "api_base": mock.base.clone()
2967 },
2968 "accounts": [{
2969 "account_id": "original-account",
2970 "access_token": "rejected-access",
2971 "refresh_token": "original-refresh",
2972 "expires_at": 9_999_999_999_u64,
2973 "api_base": mock.base.clone()
2974 }]
2975 })
2976 .to_string(),
2977 )
2978 .unwrap();
2979
2980 let resolved = resolve_credential_once(CredentialReadPurpose::ForceRefresh)
2981 .await
2982 .unwrap()
2983 .unwrap();
2984
2985 assert_eq!(resolved.access_token, "winning-access");
2986 assert_eq!(resolved.api_base, "https://winning-api.example");
2987 assert_eq!(resolved.expires_at, 9_999_999_999);
2988 }
2989
2990 #[tokio::test]
2991 async fn fetch_status_sends_bearer() {
2992 let _env_lock = AUTH_ENV_LOCK.lock().await;
2993 let _restore = RestoredEnv::capture(&[PARSLEE_ACCESS_TOKEN_KEY]);
2994 std::env::set_var(PARSLEE_ACCESS_TOKEN_KEY, "test-token");
2997
2998 let mock = mock::start(1, |_r| (200, r#"{"authenticated":true}"#.to_string()));
2999
3000 let session = fetch_status(Some(&mock.base)).await.unwrap();
3001 assert_eq!(session.as_deref(), Some(r#"{"authenticated":true}"#));
3002
3003 let reqs = mock.recorded.lock().unwrap();
3004 assert_eq!(reqs.len(), 1);
3005 let sess = &reqs[0];
3006 assert_eq!(sess.method, "GET");
3007 assert_eq!(sess.path, "/connect/session");
3008 assert_eq!(sess.authorization.as_deref(), Some("Bearer test-token"));
3009 }
3010
3011 #[tokio::test]
3012 async fn fetch_status_with_access_has_a_total_request_timeout() {
3013 let mock = mock::start(1, |_r| {
3014 std::thread::sleep(Duration::from_millis(250));
3015 (200, r#"{"authenticated":true}"#.to_string())
3016 });
3017
3018 let error = tokio::time::timeout(
3019 STUCK_FUTURE_GUARD,
3020 fetch_status_with_access_timeout(
3021 &mock.base,
3022 "test-access-token",
3023 Duration::from_millis(50),
3024 ),
3025 )
3026 .await
3027 .expect("the explicit request timeout must bound the stalled double")
3028 .unwrap_err();
3029
3030 assert_eq!(error, "fetch Parslee session timed out after 50ms");
3031 }
3032}