1use 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);
44pub const AUTH_COORDINATOR_QUEUE_TIMEOUT: Duration = Duration::from_secs(30);
52pub const LOGIN_ATTEMPT_CALLBACK_TTL: Duration = Duration::from_secs(420);
56pub const AUTH_COMPLETION_NETWORK_DEADLINE: Duration = Duration::from_secs(90);
59pub const AUTH_STATE_OPERATION_BUDGET: Duration = Duration::from_secs(15);
63pub const AUTH_PROCESS_LOCK_TIMEOUT: Duration = Duration::from_secs(30);
65pub const LOGIN_ATTEMPT_WORKER_SCHEDULING_MARGIN: Duration = Duration::from_secs(30);
67pub 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);
78pub 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#[derive(Debug, Clone, PartialEq, Eq)]
91pub enum AuthOperationError {
92 CoordinationDeadline(String),
93 Terminal(String),
94}
95
96impl AuthOperationError {
97 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#[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#[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#[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#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
151#[serde(rename_all = "snake_case")]
152pub enum AuthAttemptPhase {
153 AwaitingCallback,
154 Redeeming,
155}
156
157#[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#[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#[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#[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
281pub 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
291pub fn new_state() -> String {
293 uuid::Uuid::new_v4().simple().to_string()
294}
295
296pub 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
302pub 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 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
362pub 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
516const TOKEN_CACHE_TTL: Duration = Duration::from_secs(30);
534
535struct CachedParsleeCredential {
536 access_token: String,
537 api_base: String,
538 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
549pub fn invalidate_access_token_cache() {
555 if let Ok(mut slot) = access_token_cache().lock() {
556 *slot = None;
557 }
558}
559
560fn 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 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
591pub 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
613pub 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 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
645pub async fn auth_generation() -> Result<u64, String> {
650 with_locked_state(|coordinator| Ok(coordinator.read_snapshot()?.generation)).await
651}
652
653pub async fn auth_completion() -> Result<Option<AuthCompletionRecord>, String> {
658 with_locked_state(|coordinator| Ok(coordinator.read_snapshot()?.completion)).await
659}
660
661pub 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
670pub 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
683pub 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
695pub 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
708pub 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
723pub 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
736pub 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
749pub 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 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
793pub async fn logout() -> Result<(), String> {
795 let result = with_locked_state(|coordinator| coordinator.logout().map(|_| ())).await;
796 invalidate_access_token_cache();
799 result
800}
801
802pub const REFRESH_SKEW_SECS: u64 = 120;
806
807#[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
816pub 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#[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
932async 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
980async fn resolve_credential_once(
982 purpose: CredentialReadPurpose,
983) -> Result<Option<ResolvedParsleeCredential>, CredentialReadError> {
984 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(¤t);
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(¤t_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(¤t);
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
1088fn 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 invalidate_access_token_cache();
1119 outcome
1120}
1121
1122#[derive(Debug, Clone, PartialEq, Eq)]
1135pub enum CredentialState {
1136 Active,
1138 Expired { expires_at: u64 },
1142 SignedOut,
1145 Unreadable(String),
1148}
1149
1150pub 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
1176pub 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
1200pub 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
1215pub async fn force_refresh() -> Option<String> {
1229 refresh_credential()
1230 .await
1231 .ok()
1232 .flatten()
1233 .map(|credential| credential.access_token)
1234}
1235
1236pub 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
1259pub async fn fetch_status(api_base_override: Option<&str>) -> Result<Option<String>, String> {
1262 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 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
1312pub 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
1368pub 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 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
1419pub 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(¤t);
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 let _ = set_active_org_with_access(&base, &access_token, org_id).await;
1483 Ok(())
1484}
1485
1486#[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 #[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
1528pub fn account_id_from_session(session: &str) -> Result<String, String> {
1530 session_identity(session).map(|identity| identity.id)
1531}
1532
1533pub 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
1557pub 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
1563pub 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
1574pub 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#[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)); }
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 #[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 #[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 #[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 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)] 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 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 return;
2025 }
2026 thread::sleep(std::time::Duration::from_millis(5));
2027 }
2028 Err(e) => panic!("mock accept failed: {e}"),
2029 }
2030 };
2031 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 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 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 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 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}