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::{SecretRef, SecretStore};
18
19mod state;
20use state::{
21 ActiveCredentials, AuthStateV2, CasOutcome, ProcessAuthLock, RefreshCas, RefreshedCredentials,
22 SecretAuthStateStore, StateCoordinator,
23};
24
25pub const PARSLEE_ACCESS_TOKEN_KEY: &str = car_secrets::PARSLEE_ACCESS_TOKEN_KEY;
26pub const PARSLEE_REFRESH_TOKEN_KEY: &str = car_secrets::PARSLEE_REFRESH_TOKEN_KEY;
27pub const PARSLEE_EXPIRES_AT_KEY: &str = car_secrets::PARSLEE_EXPIRES_AT_KEY;
28pub const PARSLEE_API_BASE_KEY: &str = car_secrets::PARSLEE_API_BASE_KEY;
29pub const DEFAULT_API_BASE: &str = "https://api.parslee.ai";
30const PARSLEE_TOKEN_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
31const PARSLEE_STATUS_REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
32pub const AUTH_COORDINATOR_QUEUE_TIMEOUT: Duration = Duration::from_secs(30);
40pub const LOGIN_ATTEMPT_CALLBACK_TTL: Duration = Duration::from_secs(420);
44pub const AUTH_COMPLETION_NETWORK_DEADLINE: Duration = Duration::from_secs(90);
47pub const AUTH_STATE_OPERATION_BUDGET: Duration = Duration::from_secs(15);
51pub const AUTH_PROCESS_LOCK_TIMEOUT: Duration = Duration::from_secs(30);
53pub const LOGIN_ATTEMPT_WORKER_SCHEDULING_MARGIN: Duration = Duration::from_secs(30);
55pub const LOGIN_ATTEMPT_WORKER_SERIAL_BUDGET: Duration = Duration::from_secs(
60 AUTH_STATE_OPERATION_BUDGET.as_secs()
61 + AUTH_COMPLETION_NETWORK_DEADLINE.as_secs()
62 + AUTH_COORDINATOR_QUEUE_TIMEOUT.as_secs()
63 + AUTH_PROCESS_LOCK_TIMEOUT.as_secs()
64 + AUTH_STATE_OPERATION_BUDGET.as_secs(),
65);
66pub const LOGIN_ATTEMPT_WORKER_TTL: Duration = Duration::from_secs(
70 LOGIN_ATTEMPT_WORKER_SERIAL_BUDGET.as_secs() + LOGIN_ATTEMPT_WORKER_SCHEDULING_MARGIN.as_secs(),
71);
72
73#[derive(Debug, Clone, PartialEq, Eq)]
79pub enum AuthOperationError {
80 CoordinationDeadline(String),
81 Terminal(String),
82}
83
84impl AuthOperationError {
85 pub fn is_coordination_deadline(&self) -> bool {
88 matches!(self, Self::CoordinationDeadline(_))
89 }
90}
91
92impl std::fmt::Display for AuthOperationError {
93 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
94 match self {
95 Self::CoordinationDeadline(message) | Self::Terminal(message) => {
96 formatter.write_str(message)
97 }
98 }
99 }
100}
101
102impl std::error::Error for AuthOperationError {}
103
104#[derive(Debug, Clone, Deserialize)]
106pub struct TokenSet {
107 pub access_token: String,
108 pub refresh_token: String,
109 pub expires_in: u64,
110 pub token_type: String,
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
118pub struct LocalAuthSnapshot {
119 pub authenticated: bool,
120 #[serde(skip_serializing_if = "Option::is_none")]
121 pub active_account_id: Option<String>,
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
128pub struct AuthCompletionRecord {
129 pub attempt_id: String,
130 pub generation: u64,
131 #[serde(default)]
132 pub account_id: Option<String>,
133 #[serde(default)]
134 pub session: Option<String>,
135}
136
137#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
139#[serde(rename_all = "snake_case")]
140pub enum AuthAttemptPhase {
141 AwaitingCallback,
142 Redeeming,
143}
144
145#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
147#[serde(rename_all = "snake_case")]
148pub enum AuthCompletionState {
149 Pending,
150 Complete,
151 Failed,
152 Stale,
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
157pub struct AuthAttemptFailure {
158 pub error_code: String,
159 pub message: String,
160 pub retryable: bool,
161}
162
163impl AuthAttemptFailure {
164 pub fn completion_failed() -> Self {
165 Self {
166 error_code: "completion_failed".into(),
167 message:
168 "Sign-in could not be completed. Start a new sign-in attempt; do not reuse this authorization code."
169 .into(),
170 retryable: true,
171 }
172 }
173
174 fn attempt_expired() -> Self {
175 Self {
176 error_code: "attempt_expired".into(),
177 message:
178 "This sign-in attempt expired. Start a new sign-in attempt; do not reuse this authorization code."
179 .into(),
180 retryable: true,
181 }
182 }
183
184 fn daemon_restarted() -> Self {
185 Self {
186 error_code: "daemon_restarted".into(),
187 message:
188 "CAR restarted while finishing sign-in. Start a new sign-in attempt; do not reuse this authorization code."
189 .into(),
190 retryable: true,
191 }
192 }
193}
194
195#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
199pub struct AuthCompletionStatus {
200 pub state: AuthCompletionState,
201 pub attempt_id: String,
202 pub generation: u64,
203 #[serde(default, skip_serializing_if = "Option::is_none")]
204 pub phase: Option<AuthAttemptPhase>,
205 #[serde(default, skip_serializing_if = "Option::is_none")]
206 pub expires_at_unix_ms: Option<u64>,
207 #[serde(default, skip_serializing_if = "Option::is_none")]
208 pub account_id: Option<String>,
209 #[serde(default, skip_serializing_if = "Option::is_none")]
210 pub session: Option<String>,
211 #[serde(default, skip_serializing_if = "Option::is_none")]
212 pub error_code: Option<String>,
213 #[serde(default, skip_serializing_if = "Option::is_none")]
214 pub message: Option<String>,
215 #[serde(default, skip_serializing_if = "Option::is_none")]
216 pub retryable: Option<bool>,
217}
218
219impl AuthCompletionStatus {
220 fn stale(attempt_id: &str, generation: u64) -> Self {
221 Self {
222 state: AuthCompletionState::Stale,
223 attempt_id: attempt_id.to_string(),
224 generation,
225 phase: None,
226 expires_at_unix_ms: None,
227 account_id: None,
228 session: None,
229 error_code: None,
230 message: None,
231 retryable: None,
232 }
233 }
234}
235
236#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
241pub struct LoginAttemptLease {
242 pub attempt_id: String,
243 pub revision: u64,
244 pub generation: u64,
245 #[serde(default)]
246 pub attempt_expires_at_unix_ms: u64,
247 #[serde(default, skip_serializing_if = "Option::is_none")]
248 pub worker_owner_id: Option<String>,
249 #[serde(default, skip_serializing_if = "Option::is_none")]
250 pub worker_id: Option<String>,
251 #[serde(default, skip_serializing_if = "Option::is_none")]
252 pub worker_expires_at_unix_ms: Option<u64>,
253}
254
255fn epoch_seconds() -> u64 {
256 std::time::SystemTime::now()
257 .duration_since(std::time::UNIX_EPOCH)
258 .map(|d| d.as_secs())
259 .unwrap_or(0)
260}
261
262fn epoch_millis() -> u64 {
263 std::time::SystemTime::now()
264 .duration_since(std::time::UNIX_EPOCH)
265 .map(|duration| u64::try_from(duration.as_millis()).unwrap_or(u64::MAX))
266 .unwrap_or(0)
267}
268
269pub fn pkce_verifier() -> String {
271 let raw = format!(
272 "{}{}",
273 uuid::Uuid::new_v4().simple(),
274 uuid::Uuid::new_v4().simple()
275 );
276 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(raw.as_bytes())
277}
278
279pub fn new_state() -> String {
281 uuid::Uuid::new_v4().simple().to_string()
282}
283
284pub fn pkce_challenge(verifier: &str) -> String {
286 let digest = Sha256::digest(verifier.as_bytes());
287 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest)
288}
289
290pub fn authorize_url(
292 api_base: &str,
293 client_id: &str,
294 redirect_uri: &str,
295 state: &str,
296 challenge: &str,
297 provider: Option<&str>,
298 prompt: Option<&str>,
299) -> Result<String, String> {
300 let mut url = reqwest::Url::parse(&format!(
301 "{}/connect/authorize",
302 api_base.trim_end_matches('/')
303 ))
304 .map_err(|e| format!("build authorize URL: {e}"))?;
305 url.query_pairs_mut()
306 .append_pair("client_id", client_id)
307 .append_pair("redirect_uri", redirect_uri)
308 .append_pair("response_type", "code")
309 .append_pair("scope", "openid profile email")
310 .append_pair("state", state)
311 .append_pair("code_challenge", challenge)
312 .append_pair("code_challenge_method", "S256");
313 if let Some(provider) = provider {
314 url.query_pairs_mut().append_pair("provider", provider);
315 }
316 if let Some(prompt) = prompt {
319 url.query_pairs_mut().append_pair("prompt", prompt);
320 }
321 Ok(url.to_string())
322}
323
324fn form_body(pairs: &[(&str, &str)]) -> String {
325 let mut s = String::new();
326 for (i, (k, v)) in pairs.iter().enumerate() {
327 if i > 0 {
328 s.push('&');
329 }
330 s.push_str(&urlencode(k));
331 s.push('=');
332 s.push_str(&urlencode(v));
333 }
334 s
335}
336
337fn urlencode(s: &str) -> String {
338 let mut out = String::with_capacity(s.len());
339 for b in s.bytes() {
340 match b {
341 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
342 out.push(b as char)
343 }
344 _ => out.push_str(&format!("%{b:02X}")),
345 }
346 }
347 out
348}
349
350pub async fn exchange_code(
352 api_base: &str,
353 client_id: &str,
354 redirect_uri: &str,
355 code: &str,
356 verifier: &str,
357) -> Result<TokenSet, String> {
358 exchange_code_with_timeout(
359 api_base,
360 client_id,
361 redirect_uri,
362 code,
363 verifier,
364 PARSLEE_TOKEN_REQUEST_TIMEOUT,
365 )
366 .await
367}
368
369async fn post_token_form_with_timeout(
370 token_url: String,
371 body: String,
372 action: &'static str,
373 request_timeout: Duration,
374) -> Result<(reqwest::StatusCode, String), String> {
375 let client = reqwest::Client::builder()
376 .timeout(request_timeout)
377 .build()
378 .map_err(|error| format!("build Parslee token client: {error}"))?;
379 let response = client
380 .post(token_url)
381 .header("content-type", "application/x-www-form-urlencoded")
382 .body(body)
383 .send()
384 .await
385 .map_err(|error| {
386 if error.is_timeout() {
387 format!("{action} timed out after {}ms", request_timeout.as_millis())
388 } else {
389 format!("{action}: {error}")
390 }
391 })?;
392 let status = response.status();
393 let text = response.text().await.map_err(|error| {
394 if error.is_timeout() {
395 format!("{action} timed out after {}ms", request_timeout.as_millis())
396 } else {
397 format!("read Parslee token response: {error}")
398 }
399 })?;
400 Ok((status, text))
401}
402
403async fn exchange_code_with_timeout(
404 api_base: &str,
405 client_id: &str,
406 redirect_uri: &str,
407 code: &str,
408 verifier: &str,
409 request_timeout: Duration,
410) -> Result<TokenSet, String> {
411 let body = form_body(&[
412 ("grant_type", "authorization_code"),
413 ("client_id", client_id),
414 ("redirect_uri", redirect_uri),
415 ("code", code),
416 ("code_verifier", verifier),
417 ]);
418 let token_url = format!("{}/connect/token", api_base.trim_end_matches('/'));
419 let (status, text) = post_token_form_with_timeout(
420 token_url,
421 body,
422 "exchange Parslee authorization code",
423 request_timeout,
424 )
425 .await?;
426 if !status.is_success() {
427 return Err(format!(
428 "Parslee token exchange failed: HTTP {status}: {text}"
429 ));
430 }
431 let token: TokenSet =
432 serde_json::from_str(&text).map_err(|e| format!("parse token response: {e}"))?;
433 if !token.token_type.eq_ignore_ascii_case("bearer") {
434 return Err(format!(
435 "unexpected Parslee token_type `{}`",
436 token.token_type
437 ));
438 }
439 Ok(token)
440}
441
442static AUTH_STATE_MUTEX: std::sync::OnceLock<tokio::sync::Mutex<()>> = std::sync::OnceLock::new();
443
444async fn lock_auth_state_queue<'a>(
445 mutex: &'a tokio::sync::Mutex<()>,
446 timeout: Duration,
447) -> Result<tokio::sync::MutexGuard<'a, ()>, AuthOperationError> {
448 tokio::time::timeout(timeout, mutex.lock())
449 .await
450 .map_err(|_| {
451 AuthOperationError::CoordinationDeadline(format!(
452 "timed out waiting for the in-process Parslee credential coordinator after {}ms",
453 timeout.as_millis()
454 ))
455 })
456}
457
458async fn with_locked_state_classified<T, F>(operation: F) -> Result<T, AuthOperationError>
459where
460 T: Send + 'static,
461 F: FnOnce(StateCoordinator<SecretAuthStateStore>) -> Result<T, state::AuthStateError>
462 + Send
463 + 'static,
464{
465 let _process_guard = lock_auth_state_queue(
466 AUTH_STATE_MUTEX.get_or_init(|| tokio::sync::Mutex::new(())),
467 AUTH_COORDINATOR_QUEUE_TIMEOUT,
468 )
469 .await?;
470 tokio::task::spawn_blocking(move || {
471 let _file_guard = ProcessAuthLock::acquire()?;
472 operation(StateCoordinator::new(SecretAuthStateStore))
473 })
474 .await
475 .map_err(|error| {
476 AuthOperationError::Terminal(format!("Parslee credential worker failed: {error}"))
477 })?
478 .map_err(|error| match error {
479 state::AuthStateError::CoordinationDeadline(message) => {
480 AuthOperationError::CoordinationDeadline(message)
481 }
482 other => AuthOperationError::Terminal(other.to_string()),
483 })
484}
485
486async fn with_locked_state<T, F>(operation: F) -> Result<T, String>
487where
488 T: Send + 'static,
489 F: FnOnce(StateCoordinator<SecretAuthStateStore>) -> Result<T, state::AuthStateError>
490 + Send
491 + 'static,
492{
493 with_locked_state_classified(operation)
494 .await
495 .map_err(|error| error.to_string())
496}
497
498fn read_published_state_without_migration() -> Result<Option<AuthStateV2>, String> {
499 StateCoordinator::new(SecretAuthStateStore)
500 .read_published_snapshot()
501 .map_err(|error| error.to_string())
502}
503
504const TOKEN_CACHE_TTL: Duration = Duration::from_secs(30);
522
523struct CachedAccessToken {
524 access_token: String,
525 expires_at: u64,
527 cached_at: Instant,
528}
529
530static ACCESS_TOKEN_CACHE: OnceLock<Mutex<Option<CachedAccessToken>>> = OnceLock::new();
531
532fn access_token_cache() -> &'static Mutex<Option<CachedAccessToken>> {
533 ACCESS_TOKEN_CACHE.get_or_init(|| Mutex::new(None))
534}
535
536pub fn invalidate_access_token_cache() {
542 if let Ok(mut slot) = access_token_cache().lock() {
543 *slot = None;
544 }
545}
546
547fn cached_access_token() -> Option<String> {
550 let slot = access_token_cache().lock().ok()?;
551 let entry = slot.as_ref()?;
552 if entry.cached_at.elapsed() >= TOKEN_CACHE_TTL {
553 return None;
554 }
555 if entry.expires_at > 0 && epoch_seconds() + REFRESH_SKEW_SECS >= entry.expires_at {
558 return None;
559 }
560 Some(entry.access_token.clone())
561}
562
563fn last_known_access_token() -> Option<String> {
572 let slot = access_token_cache().lock().ok()?;
573 slot.as_ref().map(|entry| entry.access_token.clone())
574}
575
576fn store_access_token(access_token: &str, expires_at: u64) {
577 if let Ok(mut slot) = access_token_cache().lock() {
578 *slot = Some(CachedAccessToken {
579 access_token: access_token.to_string(),
580 expires_at,
581 cached_at: Instant::now(),
582 });
583 }
584}
585
586pub fn access_token() -> Option<String> {
597 if let Ok(token) = std::env::var(PARSLEE_ACCESS_TOKEN_KEY) {
598 if !token.is_empty() {
599 return Some(token);
600 }
601 }
602 read_published_state_without_migration()
603 .ok()
604 .flatten()
605 .and_then(|state| state.active.map(|active| active.access_token))
606}
607
608pub fn access_token_is_available() -> bool {
616 if std::env::var(PARSLEE_ACCESS_TOKEN_KEY).is_ok_and(|token| !token.is_empty()) {
617 return true;
618 }
619 match read_published_state_without_migration() {
620 Ok(Some(state)) => state.active.is_some(),
621 Ok(None) => {
622 let legacy_available = car_secrets::SecretStore::new()
623 .status(&car_secrets::SecretRef::with_default_service(
624 PARSLEE_ACCESS_TOKEN_KEY,
625 ))
626 .is_ok_and(|status| status.exists);
627 match read_published_state_without_migration() {
631 Ok(Some(state)) => state.active.is_some(),
632 Ok(None) => legacy_available,
633 Err(_) => false,
634 }
635 }
636 Err(_) => false,
637 }
638}
639
640pub async fn auth_generation() -> Result<u64, String> {
645 with_locked_state(|coordinator| Ok(coordinator.read_snapshot()?.generation)).await
646}
647
648pub async fn auth_completion() -> Result<Option<AuthCompletionRecord>, String> {
653 with_locked_state(|coordinator| Ok(coordinator.read_snapshot()?.completion)).await
654}
655
656pub async fn reserve_login_attempt(attempt_id: &str) -> Result<LoginAttemptLease, String> {
660 reserve_login_attempt_classified(attempt_id)
661 .await
662 .map_err(|error| error.to_string())
663}
664
665pub async fn reserve_login_attempt_classified(
667 attempt_id: &str,
668) -> Result<LoginAttemptLease, AuthOperationError> {
669 let attempt_id = attempt_id.to_string();
670 with_locked_state_classified(move |coordinator| {
671 let expires_at =
672 epoch_millis().saturating_add(LOGIN_ATTEMPT_CALLBACK_TTL.as_millis() as u64);
673 coordinator.reserve_login_attempt(&attempt_id, expires_at)
674 })
675 .await
676}
677
678pub async fn claim_login_attempt(
682 attempt_id: &str,
683 daemon_owner_id: &str,
684) -> Result<LoginAttemptLease, String> {
685 claim_login_attempt_classified(attempt_id, daemon_owner_id)
686 .await
687 .map_err(|error| error.to_string())
688}
689
690pub async fn claim_login_attempt_classified(
692 attempt_id: &str,
693 daemon_owner_id: &str,
694) -> Result<LoginAttemptLease, AuthOperationError> {
695 let attempt_id = attempt_id.to_string();
696 let daemon_owner_id = daemon_owner_id.to_string();
697 with_locked_state_classified(move |coordinator| {
698 coordinator.claim_login_attempt_now(&attempt_id, &daemon_owner_id)
699 })
700 .await
701}
702
703pub async fn fail_login_attempt(
705 lease: &LoginAttemptLease,
706 failure: AuthAttemptFailure,
707) -> Result<bool, String> {
708 let lease = lease.clone();
709 with_locked_state(move |coordinator| {
710 Ok(matches!(
711 coordinator.fail_login_attempt(&lease, failure)?,
712 CasOutcome::Committed
713 ))
714 })
715 .await
716}
717
718pub async fn auth_completion_status(
723 attempt_id: &str,
724 daemon_owner_id: &str,
725) -> Result<AuthCompletionStatus, String> {
726 auth_completion_status_classified(attempt_id, daemon_owner_id)
727 .await
728 .map_err(|error| error.to_string())
729}
730
731pub async fn auth_completion_status_classified(
733 attempt_id: &str,
734 daemon_owner_id: &str,
735) -> Result<AuthCompletionStatus, AuthOperationError> {
736 let attempt_id = attempt_id.to_string();
737 let daemon_owner_id = daemon_owner_id.to_string();
738 with_locked_state_classified(move |coordinator| {
739 coordinator.completion_status_from_published_now(&attempt_id, &daemon_owner_id)
740 })
741 .await
742}
743
744pub async fn commit_login(
751 api_base: &str,
752 token: &TokenSet,
753 session: &str,
754 lease: Option<LoginAttemptLease>,
755) -> Result<AuthCompletionRecord, String> {
756 let identity = session_identity(session)?;
757 let credentials = ActiveCredentials {
758 account_id: identity.id.clone(),
759 email: identity.email,
760 name: identity.name,
761 access_token: token.access_token.clone(),
762 refresh_token: Some(token.refresh_token.clone()),
763 expires_at: epoch_seconds().saturating_add(token.expires_in),
764 api_base: api_base.trim_end_matches('/').to_string(),
765 };
766 let full_session = session.to_string();
767 let completion_session = lease.as_ref().map(|_| full_session.clone());
768 let state = with_locked_state(move |coordinator| {
769 coordinator.commit_login_now(credentials, completion_session, lease)
770 })
771 .await;
772 invalidate_access_token_cache();
775 let state = state?;
776 Ok(AuthCompletionRecord {
777 attempt_id: state
778 .completion
779 .as_ref()
780 .map(|record| record.attempt_id.clone())
781 .unwrap_or_default(),
782 generation: state.generation,
783 account_id: state.active.map(|active| active.account_id),
784 session: Some(full_session),
785 })
786}
787
788pub async fn logout() -> Result<(), String> {
790 let result = with_locked_state(|coordinator| coordinator.logout().map(|_| ())).await;
791 invalidate_access_token_cache();
794 result
795}
796
797pub const REFRESH_SKEW_SECS: u64 = 120;
801
802#[derive(Debug, Clone)]
805pub struct RefreshedTokens {
806 pub access_token: String,
807 pub refresh_token: Option<String>,
808 pub expires_in: Option<u64>,
809}
810
811pub async fn refresh_grant(api_base: &str, refresh_token: &str) -> Result<RefreshedTokens, String> {
819 refresh_grant_with_timeout(api_base, refresh_token, PARSLEE_TOKEN_REQUEST_TIMEOUT).await
820}
821
822async fn refresh_grant_with_timeout(
823 api_base: &str,
824 refresh_token: &str,
825 request_timeout: Duration,
826) -> Result<RefreshedTokens, String> {
827 #[derive(Deserialize)]
828 struct Resp {
829 access_token: String,
830 #[serde(default)]
831 refresh_token: Option<String>,
832 #[serde(default)]
833 expires_in: Option<u64>,
834 }
835 let body = form_body(&[
836 ("grant_type", "refresh_token"),
837 ("refresh_token", refresh_token),
838 ]);
839 let token_url = format!("{}/connect/token", api_base.trim_end_matches('/'));
840 let (status, text) =
841 post_token_form_with_timeout(token_url, body, "refresh Parslee token", request_timeout)
842 .await?;
843 if !status.is_success() {
844 return Err(format!("refresh Parslee token: HTTP {status}: {text}"));
845 }
846 let r: Resp =
847 serde_json::from_str(&text).map_err(|e| format!("parse Parslee token response: {e}"))?;
848 Ok(RefreshedTokens {
849 access_token: r.access_token,
850 refresh_token: r.refresh_token,
851 expires_in: r.expires_in,
852 })
853}
854
855async fn active_state_for_network() -> Result<Option<ActiveCredentials>, String> {
856 with_locked_state(|coordinator| Ok(coordinator.read_snapshot()?.active)).await
857}
858
859fn refresh_cas(current: &ActiveCredentials) -> RefreshCas {
862 RefreshCas {
863 account_id: current.account_id.clone(),
864 access_token: current.access_token.clone(),
865 refresh_token: current.refresh_token.clone(),
866 }
867}
868
869async fn commit_refreshed_credentials(
870 expected: RefreshCas,
871 api_base: String,
872 tokens: RefreshedTokens,
873 generation_change: bool,
874) -> Result<CasOutcome, String> {
875 let refreshed = RefreshedCredentials {
876 access_token: tokens.access_token,
877 refresh_token: tokens.refresh_token,
878 expires_at: tokens
879 .expires_in
880 .map(|seconds| epoch_seconds().saturating_add(seconds)),
881 api_base,
882 generation_change,
883 };
884 let outcome =
885 with_locked_state(move |coordinator| coordinator.commit_refresh(&expected, refreshed))
886 .await;
887 invalidate_access_token_cache();
890 outcome
891}
892
893#[derive(Debug, Clone, PartialEq, Eq)]
916pub enum CredentialState {
917 Active,
919 Expired { expires_at: u64 },
923 SignedOut,
926 Unreadable(String),
929}
930
931pub async fn access_token_lifetime_remaining() -> Option<u64> {
947 if std::env::var(PARSLEE_ACCESS_TOKEN_KEY).is_ok_and(|tok| !tok.is_empty()) {
948 return None;
949 }
950 let current = active_state_for_network().await.ok()??;
951 if current.expires_at == 0 {
952 return None;
953 }
954 Some(current.expires_at.saturating_sub(epoch_seconds()))
955}
956
957pub async fn credential_state() -> CredentialState {
959 if let Ok(tok) = std::env::var(PARSLEE_ACCESS_TOKEN_KEY) {
960 if !tok.is_empty() {
961 return CredentialState::Active;
962 }
963 }
964 match active_state_for_network().await {
965 Ok(Some(current)) => {
966 let expiring =
967 current.expires_at > 0 && epoch_seconds() + REFRESH_SKEW_SECS >= current.expires_at;
968 if expiring {
969 CredentialState::Expired {
970 expires_at: current.expires_at,
971 }
972 } else {
973 CredentialState::Active
974 }
975 }
976 Ok(None) => CredentialState::SignedOut,
977 Err(e) => CredentialState::Unreadable(e),
978 }
979}
980
981pub async fn access_token_refreshing() -> Option<String> {
982 if let Ok(tok) = std::env::var(PARSLEE_ACCESS_TOKEN_KEY) {
984 if !tok.is_empty() {
985 return Some(tok);
986 }
987 }
988 if let Some(token) = cached_access_token() {
993 return Some(token);
994 }
995 let current = match active_state_for_network().await {
996 Ok(Some(value)) => value,
997 Ok(None) => return None,
998 Err(error) => {
999 if let Some(token) = last_known_access_token() {
1016 eprintln!(
1017 "car-auth: credential store unreadable ({error}); using the last token this \
1018 process resolved. If requests start failing with 401, re-run `car auth login`."
1019 );
1020 return Some(token);
1021 }
1022 eprintln!("car-auth: cannot read Parslee credentials ({error})");
1023 return None;
1024 }
1025 };
1026 let current_access = current.access_token.clone();
1027 let expiring =
1028 current.expires_at > 0 && epoch_seconds() + REFRESH_SKEW_SECS >= current.expires_at;
1029 if !expiring {
1030 store_access_token(¤t_access, current.expires_at);
1031 return Some(current_access);
1032 }
1033 let Some(refresh) = current.refresh_token.clone() else {
1034 return Some(current_access);
1035 };
1036 let base = current.api_base.clone();
1037 let expected = refresh_cas(¤t);
1038 match refresh_grant(&base, &refresh).await {
1039 Ok(tokens) => {
1040 let access = tokens.access_token.clone();
1041 match commit_refreshed_credentials(expected, base, tokens, false).await {
1042 Ok(CasOutcome::Committed) => Some(access),
1043 Ok(CasOutcome::Conflict) => active_state_for_network()
1044 .await
1045 .ok()
1046 .flatten()
1047 .map(|active| active.access_token),
1048 Err(error) => {
1049 eprintln!(
1050 "car-auth: refreshed Parslee token could not be committed; using current token ({error})"
1051 );
1052 Some(current_access)
1053 }
1054 }
1055 }
1056 Err(e) => {
1062 eprintln!("car-auth: proactive Parslee token refresh failed; using stored token (it may 401 — re-run `car auth login`) ({e})");
1063 Some(current_access)
1064 }
1065 }
1066}
1067
1068pub async fn force_refresh() -> Option<String> {
1082 if let Ok(tok) = std::env::var(PARSLEE_ACCESS_TOKEN_KEY) {
1083 if !tok.is_empty() {
1084 return None;
1085 }
1086 }
1087 let current = match active_state_for_network().await {
1088 Ok(Some(value)) => value,
1089 Ok(None) => return None,
1090 Err(error) => {
1091 eprintln!("car-auth: reactive Parslee refresh cannot read credentials ({error})");
1092 return None;
1093 }
1094 };
1095 let Some(refresh) = current.refresh_token.clone() else {
1096 eprintln!(
1097 "car-auth: reactive Parslee refresh: no refresh token stored — run `car auth login`"
1098 );
1099 return None;
1100 };
1101 let base = current.api_base.clone();
1102 let expected = refresh_cas(¤t);
1103 match refresh_grant(&base, &refresh).await {
1104 Ok(tokens) => {
1105 let access = tokens.access_token.clone();
1106 match commit_refreshed_credentials(expected, base, tokens, false).await {
1107 Ok(CasOutcome::Committed) => Some(access),
1108 Ok(CasOutcome::Conflict) => active_state_for_network()
1109 .await
1110 .ok()
1111 .flatten()
1112 .map(|active| active.access_token),
1113 Err(error) => {
1114 eprintln!(
1115 "car-auth: reactive Parslee refresh commit failed (401 will surface) ({error})"
1116 );
1117 None
1118 }
1119 }
1120 }
1121 Err(e) => {
1122 eprintln!("car-auth: reactive Parslee token refresh failed (401 will surface) — re-run `car auth login` ({e})");
1123 None
1124 }
1125 }
1126}
1127
1128pub fn api_base(override_: Option<&str>) -> String {
1133 override_
1134 .map(str::to_string)
1135 .or_else(|| {
1136 std::env::var(PARSLEE_API_BASE_KEY)
1137 .ok()
1138 .filter(|value| !value.trim().is_empty())
1139 })
1140 .or_else(|| {
1141 read_published_state_without_migration()
1142 .ok()
1143 .flatten()
1144 .and_then(|state| state.active.map(|active| active.api_base))
1145 })
1146 .unwrap_or_else(|| DEFAULT_API_BASE.to_string())
1147 .trim_end_matches('/')
1148 .to_string()
1149}
1150
1151pub async fn fetch_status(api_base_override: Option<&str>) -> Result<Option<String>, String> {
1154 let Some(access) = access_token_refreshing().await else {
1161 return Ok(None);
1162 };
1163 let base = api_base(api_base_override);
1164 let url = format!("{}/connect/session", base.trim_end_matches('/'));
1165 let client = reqwest::Client::builder()
1166 .timeout(PARSLEE_STATUS_REQUEST_TIMEOUT)
1167 .build()
1168 .map_err(|error| format!("build Parslee session client: {error}"))?;
1169
1170 let mut response = client
1171 .get(&url)
1172 .bearer_auth(&access)
1173 .send()
1174 .await
1175 .map_err(|e| format!("fetch Parslee session: {e}"))?;
1176
1177 if response.status() == reqwest::StatusCode::UNAUTHORIZED {
1181 if let Some(fresh) = force_refresh().await {
1182 response = client
1183 .get(&url)
1184 .bearer_auth(&fresh)
1185 .send()
1186 .await
1187 .map_err(|e| format!("fetch Parslee session: {e}"))?;
1188 }
1189 }
1190
1191 let status = response.status();
1192 let text = response
1193 .text()
1194 .await
1195 .map_err(|e| format!("read Parslee session response: {e}"))?;
1196 if !status.is_success() {
1197 return Err(format!(
1198 "Parslee session check failed: HTTP {status}: {text}"
1199 ));
1200 }
1201 Ok(Some(text))
1202}
1203
1204pub async fn fetch_status_with_access(
1210 api_base: &str,
1211 access_token: &str,
1212) -> Result<String, String> {
1213 fetch_status_with_access_timeout(api_base, access_token, PARSLEE_STATUS_REQUEST_TIMEOUT).await
1214}
1215
1216async fn fetch_status_with_access_timeout(
1217 api_base: &str,
1218 access_token: &str,
1219 request_timeout: Duration,
1220) -> Result<String, String> {
1221 let url = format!("{}/connect/session", api_base.trim_end_matches('/'));
1222 let client = reqwest::Client::builder()
1223 .timeout(request_timeout)
1224 .build()
1225 .map_err(|e| format!("build Parslee session client: {e}"))?;
1226 let response = client
1227 .get(url)
1228 .bearer_auth(access_token)
1229 .send()
1230 .await
1231 .map_err(|e| {
1232 if e.is_timeout() {
1233 format!(
1234 "fetch Parslee session timed out after {}ms",
1235 request_timeout.as_millis()
1236 )
1237 } else {
1238 format!("fetch Parslee session: {e}")
1239 }
1240 })?;
1241 let status = response.status();
1242 let text = response.text().await.map_err(|e| {
1243 if e.is_timeout() {
1244 format!(
1245 "read Parslee session response timed out after {}ms",
1246 request_timeout.as_millis()
1247 )
1248 } else {
1249 format!("read Parslee session response: {e}")
1250 }
1251 })?;
1252 if !status.is_success() {
1253 return Err(format!(
1254 "Parslee session check failed: HTTP {status}: {text}"
1255 ));
1256 }
1257 Ok(text)
1258}
1259
1260pub async fn set_active_org(
1269 api_base_override: Option<&str>,
1270 organization_id: &str,
1271) -> Result<String, String> {
1272 let Some(access) = access_token_refreshing().await else {
1273 return Err("not signed in".to_string());
1274 };
1275 let base = api_base(api_base_override);
1276 set_active_org_with_access(&base, &access, organization_id).await
1278}
1279
1280async fn set_active_org_with_access(
1281 base: &str,
1282 access_token: &str,
1283 organization_id: &str,
1284) -> Result<String, String> {
1285 let body = serde_json::json!({ "organizationId": organization_id }).to_string();
1286 let response = reqwest::Client::builder()
1287 .timeout(PARSLEE_STATUS_REQUEST_TIMEOUT)
1288 .build()
1289 .map_err(|error| format!("build set-active-org client: {error}"))?
1290 .put(format!(
1291 "{}/api/v1/accounts/me/active-org",
1292 base.trim_end_matches('/')
1293 ))
1294 .bearer_auth(access_token)
1295 .header("content-type", "application/json")
1296 .body(body)
1297 .send()
1298 .await
1299 .map_err(|e| format!("set active org: {e}"))?;
1300 let status = response.status();
1301 let text = response
1302 .text()
1303 .await
1304 .map_err(|e| format!("read set-active-org response: {e}"))?;
1305 if !status.is_success() {
1306 return Err(format!("set active org failed: HTTP {status}: {text}"));
1307 }
1308 Ok(text)
1309}
1310
1311pub async fn switch_org(api_base_override: Option<&str>, org_id: &str) -> Result<(), String> {
1320 #[derive(Deserialize)]
1321 struct Resp {
1322 access_token: String,
1323 #[serde(default)]
1324 refresh_token: Option<String>,
1325 #[serde(default)]
1326 expires_in: Option<u64>,
1327 }
1328 let current = active_state_for_network()
1329 .await?
1330 .ok_or_else(|| "not signed in".to_string())?;
1331 let Some(refresh) = current.refresh_token.clone() else {
1332 return Err("not signed in".to_string());
1333 };
1334 let expected = refresh_cas(¤t);
1335 let base = api_base_override
1336 .map(|value| value.trim_end_matches('/').to_string())
1337 .unwrap_or_else(|| current.api_base.clone());
1338 let body = form_body(&[
1339 ("grant_type", "refresh_token"),
1340 ("refresh_token", &refresh),
1341 ("organization_id", org_id),
1342 ]);
1343 let (status, text) = post_token_form_with_timeout(
1344 format!("{}/connect/token", base.trim_end_matches('/')),
1345 body,
1346 "switch Parslee organization token",
1347 PARSLEE_TOKEN_REQUEST_TIMEOUT,
1348 )
1349 .await?;
1350 if !status.is_success() {
1351 return Err(format!("switch org failed: HTTP {status}: {text}"));
1352 }
1353 let r: Resp =
1354 serde_json::from_str(&text).map_err(|e| format!("parse switch-org response: {e}"))?;
1355 let access_token = r.access_token.clone();
1356 let outcome = commit_refreshed_credentials(
1357 expected,
1358 base.clone(),
1359 RefreshedTokens {
1360 access_token: r.access_token,
1361 refresh_token: r.refresh_token,
1362 expires_in: r.expires_in,
1363 },
1364 true,
1365 )
1366 .await?;
1367 if outcome == CasOutcome::Conflict {
1368 return Err(
1369 "Parslee credentials changed while switching organizations; retry the switch".into(),
1370 );
1371 }
1372 let _ = set_active_org_with_access(&base, &access_token, org_id).await;
1375 Ok(())
1376}
1377
1378#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1380pub struct AccountMeta {
1381 pub id: String,
1382 #[serde(default)]
1383 pub email: Option<String>,
1384 #[serde(default)]
1385 pub name: Option<String>,
1386 #[serde(default)]
1388 pub active: bool,
1389}
1390
1391struct SessionIdentity {
1392 id: String,
1393 email: Option<String>,
1394 name: Option<String>,
1395}
1396
1397fn session_identity(session: &str) -> Result<SessionIdentity, String> {
1398 let value: serde_json::Value =
1399 serde_json::from_str(session).map_err(|error| format!("parse session: {error}"))?;
1400 let account = value
1401 .get("Account")
1402 .or_else(|| value.get("account"))
1403 .ok_or_else(|| "session has no account".to_string())?;
1404 let field = |pascal: &str, camel: &str| {
1405 account
1406 .get(pascal)
1407 .or_else(|| account.get(camel))
1408 .and_then(serde_json::Value::as_str)
1409 .map(str::trim)
1410 .filter(|value| !value.is_empty())
1411 .map(str::to_string)
1412 };
1413 Ok(SessionIdentity {
1414 id: field("Id", "id").ok_or_else(|| "session has no account id".to_string())?,
1415 email: field("Email", "email"),
1416 name: field("Name", "name").or_else(|| field("DisplayName", "displayName")),
1417 })
1418}
1419
1420pub fn account_id_from_session(session: &str) -> Result<String, String> {
1422 session_identity(session).map(|identity| identity.id)
1423}
1424
1425pub async fn local_auth_snapshot() -> Result<LocalAuthSnapshot, String> {
1430 let env_override_active = std::env::var(PARSLEE_ACCESS_TOKEN_KEY)
1431 .map(|value| !value.is_empty())
1432 .unwrap_or(false);
1433 if env_override_active {
1434 return Ok(LocalAuthSnapshot {
1435 authenticated: true,
1436 active_account_id: None,
1437 });
1438 }
1439 with_locked_state(|coordinator| {
1440 let state = coordinator.read_snapshot()?;
1441 Ok(LocalAuthSnapshot {
1442 authenticated: state.active.is_some(),
1443 active_account_id: state.active.map(|active| active.account_id),
1444 })
1445 })
1446 .await
1447}
1448
1449pub async fn list_accounts(_api_base_override: Option<&str>) -> Result<Vec<AccountMeta>, String> {
1452 with_locked_state(|coordinator| Ok(coordinator.read_snapshot()?.account_meta())).await
1453}
1454
1455pub async fn switch_account(account_id: &str) -> Result<(), String> {
1458 let account_id = account_id.to_string();
1459 let result =
1460 with_locked_state(move |coordinator| coordinator.switch_account(&account_id).map(|_| ()))
1461 .await;
1462 invalidate_access_token_cache();
1463 result
1464}
1465
1466pub async fn remove_account(account_id: &str) -> Result<Vec<AccountMeta>, String> {
1469 let account_id = account_id.to_string();
1470 let result = with_locked_state(move |coordinator| {
1471 Ok(coordinator.remove_account(&account_id)?.account_meta())
1472 })
1473 .await;
1474 invalidate_access_token_cache();
1475 result
1476}
1477
1478#[cfg(test)]
1486mod tests {
1487 use super::*;
1488 use std::ffi::OsString;
1489
1490 static AUTH_ENV_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
1491
1492 struct RestoredEnv {
1493 values: Vec<(&'static str, Option<OsString>)>,
1494 }
1495
1496 impl RestoredEnv {
1497 fn capture(keys: &[&'static str]) -> Self {
1498 Self {
1499 values: keys
1500 .iter()
1501 .map(|key| (*key, std::env::var_os(key)))
1502 .collect(),
1503 }
1504 }
1505 }
1506
1507 impl Drop for RestoredEnv {
1508 fn drop(&mut self) {
1509 for (key, value) in self.values.drain(..) {
1510 match value {
1511 Some(value) => std::env::set_var(key, value),
1512 None => std::env::remove_var(key),
1513 }
1514 }
1515 }
1516 }
1517
1518 #[tokio::test]
1519 async fn auth_env_lock_survives_result_receiver_drop_until_owner_finishes() {
1520 let (holder_acquired_tx, holder_acquired_rx) = tokio::sync::oneshot::channel();
1521 let (release_tx, release_rx) = tokio::sync::oneshot::channel::<()>();
1522 let (owner_result_tx, owner_result_rx) = tokio::sync::oneshot::channel();
1523 let holder = tokio::spawn(async move {
1524 let _guard = AUTH_ENV_LOCK.lock().await;
1525 let _ = holder_acquired_tx.send(());
1526 let _ = release_rx.await;
1527 let _ = owner_result_tx.send(());
1528 });
1529 holder_acquired_rx.await.unwrap();
1530 drop(owner_result_rx);
1531
1532 let (contender_started_tx, contender_started_rx) = tokio::sync::oneshot::channel();
1533 let (contender_acquired_tx, mut contender_acquired_rx) = tokio::sync::oneshot::channel();
1534 let contender = tokio::spawn(async move {
1535 let _ = contender_started_tx.send(());
1536 let _guard = AUTH_ENV_LOCK.lock().await;
1537 let _ = contender_acquired_tx.send(());
1538 });
1539 contender_started_rx.await.unwrap();
1540
1541 assert!(
1542 tokio::time::timeout(
1543 std::time::Duration::from_millis(50),
1544 &mut contender_acquired_rx,
1545 )
1546 .await
1547 .is_err(),
1548 "a contender must not enter while the first future owns the environment lock"
1549 );
1550
1551 drop(release_tx);
1552 holder.await.unwrap();
1553 contender_acquired_rx.await.unwrap();
1554 contender.await.unwrap();
1555 }
1556
1557 #[test]
1558 fn local_auth_snapshot_omits_an_unattributable_active_account() {
1559 let snapshot = LocalAuthSnapshot {
1560 authenticated: true,
1561 active_account_id: None,
1562 };
1563
1564 assert_eq!(
1565 serde_json::to_value(snapshot).unwrap(),
1566 serde_json::json!({ "authenticated": true })
1567 );
1568 }
1569
1570 #[tokio::test]
1571 async fn coordinator_queue_wait_has_an_enforced_deadline() {
1572 let mutex = tokio::sync::Mutex::new(());
1573 let _held = mutex.lock().await;
1574 let timeout = Duration::from_millis(10);
1575 let error = lock_auth_state_queue(&mutex, timeout)
1576 .await
1577 .expect_err("a contended coordinator queue must fail at its own bound");
1578 assert!(
1579 matches!(error, AuthOperationError::CoordinationDeadline(_)),
1580 "bounded contention must stay typed as retryable: {error:?}"
1581 );
1582 let message = error.to_string();
1583 assert!(
1584 message.contains("in-process Parslee credential coordinator")
1585 && message.contains("10ms"),
1586 "{message}"
1587 );
1588 }
1589
1590 #[test]
1591 fn worker_lease_exceeds_the_serial_redemption_budget() {
1592 let composed_serial_budget = AUTH_STATE_OPERATION_BUDGET
1593 + AUTH_COMPLETION_NETWORK_DEADLINE
1594 + AUTH_COORDINATOR_QUEUE_TIMEOUT
1595 + AUTH_PROCESS_LOCK_TIMEOUT
1596 + AUTH_STATE_OPERATION_BUDGET;
1597
1598 assert_eq!(
1599 LOGIN_ATTEMPT_WORKER_SERIAL_BUDGET, composed_serial_budget,
1600 "serial redemption budget must compose every bounded phase exactly once"
1601 );
1602 assert!(
1603 LOGIN_ATTEMPT_WORKER_SCHEDULING_MARGIN > Duration::ZERO,
1604 "worker lease requires explicit positive scheduling margin"
1605 );
1606 assert_eq!(
1607 LOGIN_ATTEMPT_WORKER_TTL,
1608 LOGIN_ATTEMPT_WORKER_SERIAL_BUDGET + LOGIN_ATTEMPT_WORKER_SCHEDULING_MARGIN,
1609 "worker lease must be derived from the complete serial budget plus margin"
1610 );
1611 }
1612
1613 #[test]
1614 fn local_auth_snapshot_serializes_an_attributable_active_account() {
1615 let snapshot = LocalAuthSnapshot {
1616 authenticated: true,
1617 active_account_id: Some("account-1".to_string()),
1618 };
1619
1620 assert_eq!(
1621 serde_json::to_value(snapshot).unwrap(),
1622 serde_json::json!({
1623 "authenticated": true,
1624 "active_account_id": "account-1",
1625 })
1626 );
1627 }
1628
1629 #[test]
1630 fn pkce_challenge_is_s256_urlsafe_nopad() {
1631 let v = pkce_verifier();
1632 let c = pkce_challenge(&v);
1633 assert!(!c.contains('=') && !c.contains('+') && !c.contains('/'));
1634 assert_eq!(c, pkce_challenge(&v)); }
1636
1637 #[test]
1638 fn authorize_url_has_pkce_and_provider() {
1639 let u = authorize_url(
1640 "https://api.parslee.ai/",
1641 "parslee-car",
1642 "http://localhost:8765/auth/callback",
1643 "st8",
1644 "chal",
1645 Some("microsoft"),
1646 Some("select_account"),
1647 )
1648 .unwrap();
1649 assert!(u.starts_with("https://api.parslee.ai/connect/authorize?"));
1650 assert!(u.contains("code_challenge=chal"));
1651 assert!(u.contains("code_challenge_method=S256"));
1652 assert!(u.contains("client_id=parslee-car"));
1653 assert!(u.contains("provider=microsoft"));
1654 assert!(u.contains("prompt=select_account"));
1655 }
1656
1657 #[test]
1658 fn api_base_precedence() {
1659 assert_eq!(api_base(Some("https://x.test/")), "https://x.test");
1660 }
1661
1662 #[test]
1663 fn api_base_environment_override_beats_persisted_state() {
1664 let _env_lock = AUTH_ENV_LOCK.blocking_lock();
1665 let _restore = RestoredEnv::capture(&["CAR_SECRETS_FILE_DIR", PARSLEE_API_BASE_KEY]);
1666 let directory = tempfile::tempdir().unwrap();
1667 std::env::set_var("CAR_SECRETS_FILE_DIR", directory.path());
1668 std::env::set_var(PARSLEE_API_BASE_KEY, "https://env.example/");
1669 SecretStore::new()
1670 .publish(
1671 &SecretRef::with_default_service(car_secrets::PARSLEE_AUTH_STATE_V2_KEY),
1672 &serde_json::json!({
1673 "schema": 2,
1674 "revision": 7,
1675 "generation": 3,
1676 "active": {
1677 "account_id": "account-v2",
1678 "access_token": "v2-access",
1679 "expires_at": 9_999_999_999_u64,
1680 "api_base": "https://persisted.example"
1681 },
1682 "accounts": [{
1683 "account_id": "account-v2",
1684 "access_token": "v2-access",
1685 "expires_at": 9_999_999_999_u64,
1686 "api_base": "https://persisted.example"
1687 }]
1688 })
1689 .to_string(),
1690 )
1691 .unwrap();
1692
1693 assert_eq!(api_base(None), "https://env.example");
1694 }
1695
1696 #[test]
1702 fn cache_does_not_serve_a_token_that_is_due_for_refresh() {
1703 invalidate_access_token_cache();
1704 let nearly_expired = epoch_seconds() + REFRESH_SKEW_SECS / 2;
1705 store_access_token("about-to-expire", nearly_expired);
1706 assert_eq!(
1707 cached_access_token(),
1708 None,
1709 "a token inside the refresh skew must not be served from cache"
1710 );
1711
1712 invalidate_access_token_cache();
1713 store_access_token("good-for-hours", epoch_seconds() + 3_600);
1714 assert_eq!(cached_access_token().as_deref(), Some("good-for-hours"));
1715 }
1716
1717 #[test]
1721 fn cache_serves_a_token_with_no_recorded_expiry() {
1722 invalidate_access_token_cache();
1723 store_access_token("no-expiry", 0);
1724 assert_eq!(cached_access_token().as_deref(), Some("no-expiry"));
1725 }
1726
1727 #[test]
1732 fn last_known_token_survives_the_ttl_for_the_unreadable_store_path() {
1733 invalidate_access_token_cache();
1734 assert_eq!(
1735 last_known_access_token(),
1736 None,
1737 "with nothing cached we genuinely do not know — report None"
1738 );
1739
1740 store_access_token("stale-but-real", 1);
1744 assert_eq!(
1745 cached_access_token(),
1746 None,
1747 "the normal path must still refuse an expiring token"
1748 );
1749 assert_eq!(
1750 last_known_access_token().as_deref(),
1751 Some("stale-but-real"),
1752 "the unreadable-store path deliberately ignores TTL and expiry"
1753 );
1754
1755 invalidate_access_token_cache();
1758 assert_eq!(last_known_access_token(), None);
1759 }
1760
1761 #[test]
1764 fn invalidate_clears_a_cached_token() {
1765 invalidate_access_token_cache();
1766 store_access_token("live", epoch_seconds() + 3_600);
1767 assert!(cached_access_token().is_some());
1768 invalidate_access_token_cache();
1769 assert_eq!(
1770 cached_access_token(),
1771 None,
1772 "logout / switch / refresh must not leave a stale bearer readable"
1773 );
1774 }
1775
1776 #[test]
1777 fn normal_readers_never_fall_back_to_conflicting_legacy_slots() {
1778 let _env_lock = AUTH_ENV_LOCK.blocking_lock();
1779 let _restore = RestoredEnv::capture(&[
1780 "CAR_SECRETS_FILE_DIR",
1781 PARSLEE_ACCESS_TOKEN_KEY,
1782 PARSLEE_API_BASE_KEY,
1783 ]);
1784 let directory = tempfile::tempdir().unwrap();
1785 std::env::set_var("CAR_SECRETS_FILE_DIR", directory.path());
1786 std::env::remove_var(PARSLEE_ACCESS_TOKEN_KEY);
1787 std::env::remove_var(PARSLEE_API_BASE_KEY);
1788
1789 let store = SecretStore::new();
1790 store
1791 .put(
1792 &SecretRef::with_default_service(PARSLEE_ACCESS_TOKEN_KEY),
1793 "legacy-access",
1794 )
1795 .unwrap();
1796 store
1797 .put(
1798 &SecretRef::with_default_service(PARSLEE_API_BASE_KEY),
1799 "https://legacy.example",
1800 )
1801 .unwrap();
1802 let state_ref = SecretRef::with_default_service(car_secrets::PARSLEE_AUTH_STATE_V2_KEY);
1803 assert!(
1804 access_token_is_available(),
1805 "a legacy token may enter the locked request-time migration path only before V2 exists"
1806 );
1807
1808 store
1809 .publish(
1810 &state_ref,
1811 &serde_json::json!({
1812 "schema": 2,
1813 "revision": 7,
1814 "generation": 3,
1815 "active": {
1816 "account_id": "account-v2",
1817 "access_token": "v2-access",
1818 "refresh_token": "v2-refresh",
1819 "expires_at": 9_999_999_999_u64,
1820 "api_base": "https://v2.example"
1821 },
1822 "accounts": [{
1823 "account_id": "account-v2",
1824 "access_token": "v2-access",
1825 "refresh_token": "v2-refresh",
1826 "expires_at": 9_999_999_999_u64,
1827 "api_base": "https://v2.example"
1828 }],
1829 "tombstone": false
1830 })
1831 .to_string(),
1832 )
1833 .unwrap();
1834 assert_eq!(access_token().as_deref(), Some("v2-access"));
1835 assert!(access_token_is_available());
1836 assert_eq!(api_base(None), "https://v2.example");
1837
1838 store
1839 .publish(
1840 &state_ref,
1841 r#"{"schema":2,"revision":8,"generation":4,"accounts":[],"tombstone":true}"#,
1842 )
1843 .unwrap();
1844 assert_eq!(access_token(), None);
1845 assert!(
1846 !access_token_is_available(),
1847 "a published tombstone must remain authoritative over the stale legacy token"
1848 );
1849 assert_eq!(api_base(None), DEFAULT_API_BASE);
1850
1851 store.publish(&state_ref, "{not-json").unwrap();
1852 assert_eq!(access_token(), None, "invalid V2 must fail closed");
1853 assert!(
1854 !access_token_is_available(),
1855 "an invalid V2 record must fail closed instead of reviving legacy"
1856 );
1857 assert_eq!(
1858 api_base(None),
1859 DEFAULT_API_BASE,
1860 "invalid V2 must not resurrect the legacy API base"
1861 );
1862 }
1863
1864 mod mock {
1871 use std::io::{Read, Write};
1872 use std::net::TcpListener;
1873 use std::sync::{Arc, Mutex};
1874 use std::thread;
1875
1876 pub struct Recorded {
1877 pub method: String,
1878 pub path: String,
1879 pub authorization: Option<String>,
1880 #[allow(dead_code)] pub content_type: Option<String>,
1882 pub body: String,
1883 }
1884
1885 pub struct Mock {
1886 pub base: String,
1887 pub recorded: Arc<Mutex<Vec<Recorded>>>,
1888 handle: Option<thread::JoinHandle<()>>,
1889 }
1890
1891 impl Drop for Mock {
1892 fn drop(&mut self) {
1893 if let Some(h) = self.handle.take() {
1894 let _ = h.join();
1895 }
1896 }
1897 }
1898
1899 fn find(hay: &[u8], needle: &[u8]) -> Option<usize> {
1900 hay.windows(needle.len()).position(|w| w == needle)
1901 }
1902
1903 pub fn start(
1904 expected: usize,
1905 respond: impl Fn(&Recorded) -> (u16, String) + Send + 'static,
1906 ) -> Mock {
1907 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1908 let port = listener.local_addr().unwrap().port();
1909 let recorded = Arc::new(Mutex::new(Vec::new()));
1910 let rec = recorded.clone();
1911 let handle = thread::spawn(move || {
1918 listener
1919 .set_nonblocking(true)
1920 .expect("mock listener nonblocking");
1921 for _ in 0..expected {
1922 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
1923 let mut stream = loop {
1924 match listener.accept() {
1925 Ok((stream, _)) => break stream,
1926 Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
1927 if std::time::Instant::now() >= deadline {
1928 return;
1933 }
1934 thread::sleep(std::time::Duration::from_millis(5));
1935 }
1936 Err(e) => panic!("mock accept failed: {e}"),
1937 }
1938 };
1939 stream.set_nonblocking(false).expect("mock stream blocking");
1942 stream
1943 .set_read_timeout(Some(std::time::Duration::from_secs(30)))
1944 .expect("mock stream read timeout");
1945 let mut buf = Vec::new();
1946 let mut tmp = [0u8; 1024];
1947 loop {
1948 let n = stream.read(&mut tmp).unwrap();
1949 if n == 0 {
1950 break;
1951 }
1952 buf.extend_from_slice(&tmp[..n]);
1953 let Some(hdr_end) = find(&buf, b"\r\n\r\n") else {
1954 continue;
1955 };
1956 let headers = String::from_utf8_lossy(&buf[..hdr_end]).into_owned();
1957 let content_length = headers
1958 .lines()
1959 .find_map(|l| {
1960 let (k, v) = l.split_once(':')?;
1961 if k.eq_ignore_ascii_case("content-length") {
1962 v.trim().parse::<usize>().ok()
1963 } else {
1964 None
1965 }
1966 })
1967 .unwrap_or(0);
1968 let body_start = hdr_end + 4;
1969 while buf.len() < body_start + content_length {
1970 let n = stream.read(&mut tmp).unwrap();
1971 if n == 0 {
1972 break;
1973 }
1974 buf.extend_from_slice(&tmp[..n]);
1975 }
1976 let mut header_lines = headers.lines();
1977 let req_line = header_lines.next().unwrap_or("");
1978 let mut rl = req_line.split_whitespace();
1979 let method = rl.next().unwrap_or("").to_string();
1980 let path = rl.next().unwrap_or("").to_string();
1981 let mut authorization = None;
1982 let mut content_type = None;
1983 for l in header_lines {
1984 if let Some((k, v)) = l.split_once(':') {
1985 if k.eq_ignore_ascii_case("authorization") {
1986 authorization = Some(v.trim().to_string());
1987 } else if k.eq_ignore_ascii_case("content-type") {
1988 content_type = Some(v.trim().to_string());
1989 }
1990 }
1991 }
1992 let body = String::from_utf8_lossy(
1993 &buf[body_start..(body_start + content_length).min(buf.len())],
1994 )
1995 .into_owned();
1996 let r = Recorded {
1997 method,
1998 path,
1999 authorization,
2000 content_type,
2001 body,
2002 };
2003 let (code, resp_body) = respond(&r);
2004 rec.lock().unwrap().push(r);
2005 let resp = format!(
2006 "HTTP/1.1 {code} OK\r\ncontent-type: application/json\r\n\
2007 content-length: {}\r\nconnection: close\r\n\r\n{}",
2008 resp_body.len(),
2009 resp_body
2010 );
2011 stream.write_all(resp.as_bytes()).unwrap();
2012 let _ = stream.flush();
2013 break;
2014 }
2015 }
2016 });
2017 Mock {
2018 base: format!("http://127.0.0.1:{port}"),
2019 recorded,
2020 handle: Some(handle),
2021 }
2022 }
2023 }
2024
2025 #[tokio::test]
2026 async fn exchange_code_round_trips_token() {
2027 let mock = mock::start(1, |_r| {
2028 (
2029 200,
2030 r#"{"access_token":"a","refresh_token":"r","expires_in":3600,"token_type":"Bearer"}"#
2031 .to_string(),
2032 )
2033 });
2034 let token = exchange_code(
2035 &mock.base,
2036 "parslee-car",
2037 "http://localhost:1/cb",
2038 "thecode",
2039 "theverifier",
2040 )
2041 .await
2042 .unwrap();
2043 assert_eq!(token.access_token, "a");
2044 assert_eq!(token.refresh_token, "r");
2045 assert_eq!(token.expires_in, 3600);
2046
2047 let reqs = mock.recorded.lock().unwrap();
2048 assert_eq!(reqs.len(), 1);
2049 assert_eq!(reqs[0].method, "POST");
2050 assert_eq!(reqs[0].path, "/connect/token");
2051 assert!(reqs[0].body.contains("grant_type=authorization_code"));
2052 assert!(reqs[0].body.contains("code=thecode"));
2053 assert!(reqs[0].body.contains("code_verifier=theverifier"));
2054 }
2055
2056 const STUCK_FUTURE_GUARD: Duration = Duration::from_secs(30);
2070
2071 #[tokio::test]
2072 async fn exchange_code_stall_is_bounded_by_the_explicit_request_timeout() {
2073 let mock = mock::start(1, |_r| {
2074 std::thread::sleep(Duration::from_millis(250));
2075 (
2076 200,
2077 r#"{"access_token":"a","refresh_token":"r","expires_in":3600,"token_type":"Bearer"}"#
2078 .to_string(),
2079 )
2080 });
2081
2082 let error = tokio::time::timeout(
2083 STUCK_FUTURE_GUARD,
2084 exchange_code_with_timeout(
2085 &mock.base,
2086 "parslee-car",
2087 "http://localhost:1/cb",
2088 "thecode",
2089 "theverifier",
2090 Duration::from_millis(50),
2091 ),
2092 )
2093 .await
2094 .expect("the explicit token request timeout must bound the stalled endpoint")
2095 .unwrap_err();
2096
2097 assert_eq!(
2098 error,
2099 "exchange Parslee authorization code timed out after 50ms"
2100 );
2101 }
2102
2103 #[tokio::test]
2104 async fn refresh_grant_round_trips_token() {
2105 let mock = mock::start(1, |_r| {
2108 (
2109 200,
2110 r#"{"access_token":"a2","expires_in":3600,"token_type":"Bearer"}"#.to_string(),
2111 )
2112 });
2113 let tokens = refresh_grant(&mock.base, "the-refresh-token")
2114 .await
2115 .unwrap();
2116 assert_eq!(tokens.access_token, "a2");
2117 assert_eq!(tokens.refresh_token, None);
2118 assert_eq!(tokens.expires_in, Some(3600));
2119
2120 let reqs = mock.recorded.lock().unwrap();
2121 assert_eq!(reqs.len(), 1);
2122 assert_eq!(reqs[0].method, "POST");
2123 assert_eq!(reqs[0].path, "/connect/token");
2124 assert!(reqs[0].body.contains("grant_type=refresh_token"));
2125 assert!(reqs[0].body.contains("refresh_token=the-refresh-token"));
2126 assert!(!reqs[0].body.contains("client_id"));
2128 }
2129
2130 #[tokio::test]
2131 async fn fetch_status_sends_bearer() {
2132 let _env_lock = AUTH_ENV_LOCK.lock().await;
2133 let _restore = RestoredEnv::capture(&[PARSLEE_ACCESS_TOKEN_KEY]);
2134 std::env::set_var(PARSLEE_ACCESS_TOKEN_KEY, "test-token");
2137
2138 let mock = mock::start(1, |_r| (200, r#"{"authenticated":true}"#.to_string()));
2139
2140 let session = fetch_status(Some(&mock.base)).await.unwrap();
2141 assert_eq!(session.as_deref(), Some(r#"{"authenticated":true}"#));
2142
2143 let reqs = mock.recorded.lock().unwrap();
2144 assert_eq!(reqs.len(), 1);
2145 let sess = &reqs[0];
2146 assert_eq!(sess.method, "GET");
2147 assert_eq!(sess.path, "/connect/session");
2148 assert_eq!(sess.authorization.as_deref(), Some("Bearer test-token"));
2149 }
2150
2151 #[tokio::test]
2152 async fn fetch_status_with_access_has_a_total_request_timeout() {
2153 let mock = mock::start(1, |_r| {
2154 std::thread::sleep(Duration::from_millis(250));
2155 (200, r#"{"authenticated":true}"#.to_string())
2156 });
2157
2158 let error = tokio::time::timeout(
2159 STUCK_FUTURE_GUARD,
2160 fetch_status_with_access_timeout(
2161 &mock.base,
2162 "test-access-token",
2163 Duration::from_millis(50),
2164 ),
2165 )
2166 .await
2167 .expect("the explicit request timeout must bound the stalled double")
2168 .unwrap_err();
2169
2170 assert_eq!(error, "fetch Parslee session timed out after 50ms");
2171 }
2172}