1use chrono::{DateTime, Utc};
9use thiserror::Error;
10
11#[cfg(not(target_arch = "wasm32"))]
12use std::{
13 collections::HashMap,
14 sync::{Arc, OnceLock, Weak},
15};
16
17use super::status::AuthStatusPhase;
18use super::token_store::{
19 PersistedAuthMode, PersistedTokens, TokenKey, TokenStore, TokenStoreError,
20};
21use crate::connection::AuthBindingRef;
22use crate::handles::{
23 AuthLeaseHandle, AuthLeasePhase, AuthLeaseSnapshot, AuthLeaseTransition, DslTransitionError,
24 LeaseKey,
25};
26
27#[cfg(not(target_arch = "wasm32"))]
28type LoginLifecycleLockMap = parking_lot::Mutex<HashMap<LeaseKey, Weak<tokio::sync::Mutex<()>>>>;
29
30#[cfg(not(target_arch = "wasm32"))]
31static LOGIN_LIFECYCLE_LOCKS: OnceLock<LoginLifecycleLockMap> = OnceLock::new();
32
33#[cfg(not(target_arch = "wasm32"))]
34fn login_lifecycle_locks() -> &'static LoginLifecycleLockMap {
35 LOGIN_LIFECYCLE_LOCKS.get_or_init(|| parking_lot::Mutex::new(HashMap::new()))
36}
37
38#[cfg(not(target_arch = "wasm32"))]
41pub struct AuthLoginLifecycleGuard {
42 _lease_key: LeaseKey,
43 _guard: tokio::sync::OwnedMutexGuard<()>,
44}
45
46#[cfg(not(target_arch = "wasm32"))]
47pub async fn acquire_auth_login_lifecycle_guard(lease_key: &LeaseKey) -> AuthLoginLifecycleGuard {
48 let lock = {
49 let mut locks = login_lifecycle_locks().lock();
50 locks.retain(|_, lock| lock.strong_count() > 0);
51 if let Some(lock) = locks.get(lease_key).and_then(Weak::upgrade) {
52 lock
53 } else {
54 let lock = Arc::new(tokio::sync::Mutex::new(()));
55 locks.insert(lease_key.clone(), Arc::downgrade(&lock));
56 lock
57 }
58 };
59 AuthLoginLifecycleGuard {
60 _lease_key: lease_key.clone(),
61 _guard: lock.lock_owned().await,
62 }
63}
64
65pub fn persisted_token_expires_at_epoch_secs(tokens: &PersistedTokens) -> u64 {
66 tokens
67 .expires_at
68 .map(|ts| ts.timestamp().max(0) as u64)
69 .unwrap_or(u64::MAX)
70}
71
72const TOKEN_LIFECYCLE_METADATA_KEY: &str = "meerkat_auth_lifecycle";
73const TOKEN_LIFECYCLE_PREVIOUS_METADATA_KEY: &str = "meerkat_previous_metadata";
74
75pub fn persisted_auth_mode_uses_oauth_login_lifecycle(mode: PersistedAuthMode) -> bool {
76 matches!(
77 mode,
78 PersistedAuthMode::ChatgptOauth
79 | PersistedAuthMode::ExternalTokens
80 | PersistedAuthMode::ClaudeAiOauth
81 | PersistedAuthMode::OauthToApiKey
82 | PersistedAuthMode::GoogleOauth
83 )
84}
85
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub struct TokenLifecyclePublication {
88 pub generation: Option<u64>,
89 pub expires_at: u64,
90 pub credential_published_at_millis: Option<u64>,
91}
92
93fn mark_tokens_lifecycle_published_inner(
94 tokens: &PersistedTokens,
95 generation: Option<u64>,
96 credential_published_at_millis: Option<u64>,
97) -> PersistedTokens {
98 if !persisted_auth_mode_uses_oauth_login_lifecycle(tokens.auth_mode) {
99 return tokens.clone();
100 }
101
102 let mut marked = tokens.clone();
103 let mut marker = serde_json::json!({
104 "published": true,
105 "version": 2,
106 "expires_at": persisted_token_expires_at_epoch_secs(tokens),
107 });
108 if let Some(generation) = generation
109 && let Some(marker) = marker.as_object_mut()
110 {
111 marker.insert("generation".to_string(), serde_json::json!(generation));
112 }
113 if let Some(credential_published_at_millis) = credential_published_at_millis
114 && let Some(marker) = marker.as_object_mut()
115 {
116 marker.insert(
117 "credential_published_at_millis".to_string(),
118 serde_json::json!(credential_published_at_millis),
119 );
120 }
121 match &mut marked.metadata {
122 serde_json::Value::Object(map) => {
123 map.insert(TOKEN_LIFECYCLE_METADATA_KEY.to_string(), marker);
124 }
125 serde_json::Value::Null => {
126 let mut metadata = serde_json::Map::new();
127 metadata.insert(TOKEN_LIFECYCLE_METADATA_KEY.to_string(), marker);
128 marked.metadata = serde_json::Value::Object(metadata);
129 }
130 _ => {
131 let previous = std::mem::replace(&mut marked.metadata, serde_json::Value::Null);
132 let mut metadata = serde_json::Map::new();
133 metadata.insert(TOKEN_LIFECYCLE_METADATA_KEY.to_string(), marker);
134 metadata.insert(TOKEN_LIFECYCLE_PREVIOUS_METADATA_KEY.to_string(), previous);
135 marked.metadata = serde_json::Value::Object(metadata);
136 }
137 }
138 marked
139}
140
141pub fn mark_tokens_lifecycle_published(tokens: &PersistedTokens) -> PersistedTokens {
142 mark_tokens_lifecycle_published_inner(tokens, None, None)
143}
144
145pub fn mark_tokens_lifecycle_published_for_generation(
146 tokens: &PersistedTokens,
147 generation: u64,
148) -> PersistedTokens {
149 mark_tokens_lifecycle_published_inner(tokens, Some(generation), None)
150}
151
152pub fn mark_tokens_lifecycle_published_for_transition(
153 tokens: &PersistedTokens,
154 transition: AuthLeaseTransition,
155) -> PersistedTokens {
156 mark_tokens_lifecycle_published_inner(
157 tokens,
158 Some(transition.generation),
159 transition.credential_published_at_millis,
160 )
161}
162
163pub fn mark_tokens_lifecycle_published_for_snapshot(
164 tokens: &PersistedTokens,
165 snapshot: &AuthLeaseSnapshot,
166) -> PersistedTokens {
167 mark_tokens_lifecycle_published_inner(
168 tokens,
169 Some(snapshot.generation),
170 snapshot.credential_published_at_millis,
171 )
172}
173
174pub fn tokens_lifecycle_published(tokens: &PersistedTokens) -> bool {
175 tokens
176 .metadata
177 .get(TOKEN_LIFECYCLE_METADATA_KEY)
178 .and_then(|marker| marker.get("published"))
179 .and_then(serde_json::Value::as_bool)
180 .unwrap_or(false)
181}
182
183pub fn tokens_lifecycle_published_generation(tokens: &PersistedTokens) -> Option<u64> {
184 tokens_lifecycle_publication(tokens).and_then(|publication| publication.generation)
185}
186
187pub fn tokens_lifecycle_publication(tokens: &PersistedTokens) -> Option<TokenLifecyclePublication> {
188 tokens_lifecycle_publication_inner(tokens, false)
189}
190
191pub fn tokens_lifecycle_publication_with_explicit_expiry(
192 tokens: &PersistedTokens,
193) -> Option<TokenLifecyclePublication> {
194 tokens_lifecycle_publication_inner(tokens, true)
195}
196
197fn tokens_lifecycle_publication_inner(
198 tokens: &PersistedTokens,
199 require_explicit_expiry: bool,
200) -> Option<TokenLifecyclePublication> {
201 if !tokens_lifecycle_published(tokens) {
202 return None;
203 }
204 let marker = tokens.metadata.get(TOKEN_LIFECYCLE_METADATA_KEY)?;
205 let generation = marker.get("generation").and_then(serde_json::Value::as_u64);
206 let explicit_expires_at = marker.get("expires_at").and_then(serde_json::Value::as_u64);
207 let expires_at = match (explicit_expires_at, require_explicit_expiry) {
208 (Some(expires_at), _) => expires_at,
209 (None, true) => return None,
210 (None, false) => persisted_token_expires_at_epoch_secs(tokens),
211 };
212 let credential_published_at_millis = marker
213 .get("credential_published_at_millis")
214 .and_then(serde_json::Value::as_u64);
215 Some(TokenLifecyclePublication {
216 generation,
217 expires_at,
218 credential_published_at_millis,
219 })
220}
221
222pub fn tokens_lifecycle_published_credential_time(tokens: &PersistedTokens) -> Option<u64> {
223 tokens
224 .metadata
225 .get(TOKEN_LIFECYCLE_METADATA_KEY)
226 .and_then(|marker| marker.get("credential_published_at_millis"))
227 .and_then(serde_json::Value::as_u64)
228}
229
230pub fn publish_token_lifecycle_acquired(
231 handle: &dyn AuthLeaseHandle,
232 auth_binding: &AuthBindingRef,
233 tokens: &PersistedTokens,
234) -> Result<AuthLeaseTransition, DslTransitionError> {
235 let lease_key = LeaseKey::from_auth_binding(auth_binding);
236 handle.acquire_lease(&lease_key, persisted_token_expires_at_epoch_secs(tokens))
237}
238
239pub fn publish_token_lifecycle_released(
240 handle: &dyn AuthLeaseHandle,
241 auth_binding: &AuthBindingRef,
242) -> Result<(), DslTransitionError> {
243 let lease_key = LeaseKey::from_auth_binding(auth_binding);
244 handle.release_lease(&lease_key)
245}
246
247#[derive(Debug, Error)]
248pub enum TokenLifecycleClearError {
249 #[error("AuthMachine lifecycle release failed: {0}")]
250 AuthMachineRelease(DslTransitionError),
251 #[error("TokenStore clear failed: {0}")]
252 TokenStoreClear(TokenStoreError),
253 #[error("TokenStore load failed: {load_error}; TokenStore clear failed: {clear_error}")]
254 TokenStoreLoadAndClear {
255 load_error: TokenStoreError,
256 clear_error: TokenStoreError,
257 },
258 #[error(
259 "TokenStore clear failed: {clear_error}; AuthMachine lifecycle restore failed: {restore_error}"
260 )]
261 TokenStoreClearAndLifecycleRestore {
262 clear_error: TokenStoreError,
263 restore_error: DslTransitionError,
264 },
265}
266
267pub async fn clear_tokens_and_publish_lifecycle_released(
276 store: &dyn TokenStore,
277 handle: &dyn AuthLeaseHandle,
278 auth_binding: &AuthBindingRef,
279) -> Result<(), TokenLifecycleClearError> {
280 let key = TokenKey::from_auth_binding(auth_binding);
281 let lease_key = LeaseKey::from_auth_binding(auth_binding);
282 let previous_lifecycle = handle.snapshot(&lease_key);
283 let previous = match store.load(&key).await {
284 Ok(previous) => previous,
285 Err(load_error) => {
286 if let Err(clear_error) = store.clear(&key).await {
287 return Err(TokenLifecycleClearError::TokenStoreLoadAndClear {
288 load_error,
289 clear_error,
290 });
291 }
292 publish_token_lifecycle_released(handle, auth_binding)
293 .map_err(TokenLifecycleClearError::AuthMachineRelease)?;
294 return Ok(());
295 }
296 };
297 publish_token_lifecycle_released(handle, auth_binding)
298 .map_err(TokenLifecycleClearError::AuthMachineRelease)?;
299 if let Err(clear_error) = store.clear(&key).await {
300 if let Err(restore_error) = restore_token_lifecycle_snapshot(
301 handle,
302 &lease_key,
303 &previous_lifecycle,
304 previous.as_ref(),
305 ) {
306 return Err(
307 TokenLifecycleClearError::TokenStoreClearAndLifecycleRestore {
308 clear_error,
309 restore_error,
310 },
311 );
312 }
313 return Err(TokenLifecycleClearError::TokenStoreClear(clear_error));
314 }
315 Ok(())
316}
317
318pub fn restore_token_lifecycle_snapshot(
326 handle: &dyn AuthLeaseHandle,
327 lease_key: &LeaseKey,
328 snapshot: &AuthLeaseSnapshot,
329 previous: Option<&PersistedTokens>,
330) -> Result<(), DslTransitionError> {
331 if !snapshot.credential_present {
332 return Ok(());
333 }
334 let Some(phase) = snapshot.phase else {
335 return Ok(());
336 };
337 if phase == AuthLeasePhase::Released {
338 return Ok(());
339 }
340
341 let Some(expires_at) = snapshot
342 .expires_at
343 .or_else(|| previous.map(persisted_token_expires_at_epoch_secs))
344 else {
345 return Ok(());
346 };
347 handle.restore_auth_lifecycle_snapshot(lease_key, snapshot, Some(expires_at))
348}
349
350pub fn lease_snapshot_expires_at_datetime(snapshot: &AuthLeaseSnapshot) -> Option<DateTime<Utc>> {
351 snapshot
352 .expires_at
353 .and_then(|secs| i64::try_from(secs).ok())
354 .and_then(|secs| DateTime::<Utc>::from_timestamp(secs, 0))
355}
356
357#[derive(Debug)]
358pub struct PublishedAuthStatus<'a> {
359 pub phase: AuthStatusPhase,
360 pub expires_at: Option<DateTime<Utc>>,
361 pub tokens: Option<&'a PersistedTokens>,
362}
363
364#[cfg(not(target_arch = "wasm32"))]
365#[derive(Debug, Error)]
366pub enum AuthStatusRehydrateError {
367 #[error("token store error: {0}")]
368 TokenStore(#[from] TokenStoreError),
369 #[error("AuthMachine lifecycle acquire failed: {0}")]
370 LifecycleAcquire(DslTransitionError),
371 #[error("AuthMachine lifecycle rollback failed after token marker save failure: {0}")]
372 LifecycleRollback(DslTransitionError),
373 #[error("TokenStore lifecycle marker save failed: {0}")]
374 MarkerSave(TokenStoreError),
375}
376
377#[cfg(not(target_arch = "wasm32"))]
378pub async fn rehydrate_marked_oauth_tokens_for_status(
379 _token_store: &dyn TokenStore,
380 _auth_lease: &dyn AuthLeaseHandle,
381 _auth_binding: &AuthBindingRef,
382 _expected_mode: PersistedAuthMode,
383 _now: DateTime<Utc>,
384) -> Result<Option<PersistedTokens>, AuthStatusRehydrateError> {
385 Ok(None)
389}
390
391pub fn project_published_auth_status<'a>(
392 now: DateTime<Utc>,
393 stored: Option<&'a PersistedTokens>,
394 snapshot: &AuthLeaseSnapshot,
395) -> PublishedAuthStatus<'a> {
396 let phase = AuthStatusPhase::from_lease_snapshot(now, snapshot);
397 if phase == AuthStatusPhase::Unknown {
398 return PublishedAuthStatus {
399 phase,
400 expires_at: None,
401 tokens: None,
402 };
403 }
404 PublishedAuthStatus {
405 phase,
406 expires_at: lease_snapshot_expires_at_datetime(snapshot)
407 .or_else(|| stored.and_then(|tokens| tokens.expires_at)),
408 tokens: stored,
409 }
410}
411
412pub fn oauth_status_projection_snapshot_from_newer_marker(
413 snapshot: &AuthLeaseSnapshot,
414 tokens: &PersistedTokens,
415) -> Option<AuthLeaseSnapshot> {
416 if !snapshot.credential_present {
417 return None;
418 }
419 let publication = tokens_lifecycle_publication_with_explicit_expiry(tokens)?;
420 if publication.expires_at != persisted_token_expires_at_epoch_secs(tokens) {
421 return None;
422 }
423 let snapshot_published_at = snapshot.credential_published_at_millis?;
424 let token_published_at = publication.credential_published_at_millis?;
425 if token_published_at < snapshot_published_at {
426 return None;
427 }
428 if token_published_at == snapshot_published_at {
429 return None;
430 }
431 Some(AuthLeaseSnapshot {
432 phase: Some(AuthLeasePhase::Valid),
433 expires_at: (publication.expires_at != u64::MAX).then_some(publication.expires_at),
434 credential_present: true,
435 generation: publication.generation.unwrap_or(snapshot.generation),
436 credential_published_at_millis: Some(token_published_at),
437 })
438}
439
440#[cfg(test)]
441#[allow(clippy::unwrap_used, clippy::expect_used)]
442mod tests {
443 use super::*;
444 use std::sync::Mutex;
445
446 use crate::auth::PersistedAuthMode;
447 use crate::connection::{BindingId, RealmId};
448 use crate::handles::{AuthLeasePhase, AuthLeaseTransition, DslTransitionError};
449 use async_trait::async_trait;
450
451 #[derive(Default)]
452 struct RecordingAuthLeaseHandle {
453 acquired: Mutex<Vec<(LeaseKey, u64)>>,
454 released: Mutex<Vec<LeaseKey>>,
455 }
456
457 impl RecordingAuthLeaseHandle {
458 fn acquired(&self) -> Vec<(LeaseKey, u64)> {
459 self.acquired
460 .lock()
461 .unwrap_or_else(std::sync::PoisonError::into_inner)
462 .clone()
463 }
464
465 fn released(&self) -> Vec<LeaseKey> {
466 self.released
467 .lock()
468 .unwrap_or_else(std::sync::PoisonError::into_inner)
469 .clone()
470 }
471 }
472
473 impl AuthLeaseHandle for RecordingAuthLeaseHandle {
474 fn acquire_lease(
475 &self,
476 lease_key: &LeaseKey,
477 expires_at: u64,
478 ) -> Result<AuthLeaseTransition, DslTransitionError> {
479 self.acquired
480 .lock()
481 .unwrap_or_else(std::sync::PoisonError::into_inner)
482 .push((lease_key.clone(), expires_at));
483 Ok(AuthLeaseTransition {
484 generation: 1,
485 credential_published_at_millis: None,
486 })
487 }
488
489 fn mark_expiring(&self, _lease_key: &LeaseKey) -> Result<(), DslTransitionError> {
490 Ok(())
491 }
492
493 fn begin_refresh(&self, _lease_key: &LeaseKey) -> Result<(), DslTransitionError> {
494 Ok(())
495 }
496
497 fn complete_refresh(
498 &self,
499 _lease_key: &LeaseKey,
500 _new_expires_at: u64,
501 _now: u64,
502 ) -> Result<AuthLeaseTransition, DslTransitionError> {
503 Ok(AuthLeaseTransition {
504 generation: 1,
505 credential_published_at_millis: None,
506 })
507 }
508
509 fn refresh_failed(
510 &self,
511 _lease_key: &LeaseKey,
512 _permanent: bool,
513 ) -> Result<(), DslTransitionError> {
514 Ok(())
515 }
516
517 fn mark_reauth_required(&self, _lease_key: &LeaseKey) -> Result<(), DslTransitionError> {
518 Ok(())
519 }
520
521 fn release_lease(&self, lease_key: &LeaseKey) -> Result<(), DslTransitionError> {
522 self.released
523 .lock()
524 .unwrap_or_else(std::sync::PoisonError::into_inner)
525 .push(lease_key.clone());
526 Ok(())
527 }
528
529 fn snapshot(&self, _lease_key: &LeaseKey) -> AuthLeaseSnapshot {
530 AuthLeaseSnapshot {
531 phase: Some(AuthLeasePhase::Valid),
532 expires_at: None,
533 credential_present: true,
534 generation: 1,
535 credential_published_at_millis: None,
536 }
537 }
538 }
539
540 fn auth_binding() -> AuthBindingRef {
541 AuthBindingRef {
542 realm: RealmId::parse("dev").expect("valid realm"),
543 binding: BindingId::parse("default_openai").expect("valid binding"),
544 profile: None,
545 }
546 }
547
548 fn tokens_with_expiry(expires_at: Option<DateTime<Utc>>) -> PersistedTokens {
549 PersistedTokens {
550 auth_mode: PersistedAuthMode::ApiKey,
551 primary_secret: Some("secret".into()),
552 refresh_token: None,
553 id_token: None,
554 expires_at,
555 last_refresh: None,
556 scopes: Vec::new(),
557 account_id: None,
558 metadata: serde_json::Value::Null,
559 }
560 }
561
562 fn oauth_tokens_with_metadata(metadata: serde_json::Value) -> PersistedTokens {
563 PersistedTokens {
564 auth_mode: PersistedAuthMode::ChatgptOauth,
565 primary_secret: Some("access".into()),
566 refresh_token: Some("refresh".into()),
567 id_token: None,
568 expires_at: None,
569 last_refresh: None,
570 scopes: Vec::new(),
571 account_id: None,
572 metadata,
573 }
574 }
575
576 struct ClearFailingTokenStore {
577 tokens: Mutex<Option<PersistedTokens>>,
578 }
579
580 impl ClearFailingTokenStore {
581 fn new(tokens: PersistedTokens) -> Self {
582 Self {
583 tokens: Mutex::new(Some(tokens)),
584 }
585 }
586 }
587
588 struct LoadFailingTokenStore {
589 cleared: Mutex<bool>,
590 clear_error: bool,
591 }
592
593 impl LoadFailingTokenStore {
594 fn new() -> Self {
595 Self {
596 cleared: Mutex::new(false),
597 clear_error: false,
598 }
599 }
600
601 fn new_with_clear_error() -> Self {
602 Self {
603 cleared: Mutex::new(false),
604 clear_error: true,
605 }
606 }
607
608 fn cleared(&self) -> bool {
609 *self
610 .cleared
611 .lock()
612 .unwrap_or_else(std::sync::PoisonError::into_inner)
613 }
614 }
615
616 #[async_trait]
617 impl TokenStore for LoadFailingTokenStore {
618 async fn load(&self, _key: &TokenKey) -> Result<Option<PersistedTokens>, TokenStoreError> {
619 Err(TokenStoreError::Serde("corrupt token".into()))
620 }
621
622 async fn save(
623 &self,
624 _key: &TokenKey,
625 _tokens: &PersistedTokens,
626 ) -> Result<(), TokenStoreError> {
627 Ok(())
628 }
629
630 async fn clear(&self, _key: &TokenKey) -> Result<(), TokenStoreError> {
631 *self
632 .cleared
633 .lock()
634 .unwrap_or_else(std::sync::PoisonError::into_inner) = true;
635 if self.clear_error {
636 Err(TokenStoreError::Unavailable("clear unavailable".into()))
637 } else {
638 Ok(())
639 }
640 }
641
642 async fn list(&self) -> Result<Vec<TokenKey>, TokenStoreError> {
643 Ok(Vec::new())
644 }
645
646 fn backend_name(&self) -> &'static str {
647 "load_failing"
648 }
649 }
650
651 #[async_trait]
652 impl TokenStore for ClearFailingTokenStore {
653 async fn load(&self, _key: &TokenKey) -> Result<Option<PersistedTokens>, TokenStoreError> {
654 Ok(self
655 .tokens
656 .lock()
657 .unwrap_or_else(std::sync::PoisonError::into_inner)
658 .clone())
659 }
660
661 async fn save(
662 &self,
663 _key: &TokenKey,
664 tokens: &PersistedTokens,
665 ) -> Result<(), TokenStoreError> {
666 *self
667 .tokens
668 .lock()
669 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(tokens.clone());
670 Ok(())
671 }
672
673 async fn clear(&self, _key: &TokenKey) -> Result<(), TokenStoreError> {
674 Err(TokenStoreError::Unavailable("clear unavailable".into()))
675 }
676
677 async fn list(&self) -> Result<Vec<TokenKey>, TokenStoreError> {
678 Ok(Vec::new())
679 }
680
681 fn backend_name(&self) -> &'static str {
682 "clear_failing"
683 }
684 }
685
686 #[test]
687 fn lifecycle_acquire_uses_persisted_token_expiry_as_machine_input() {
688 let handle = RecordingAuthLeaseHandle::default();
689 let expires_at = DateTime::<Utc>::from_timestamp(1_800_000_000, 0).unwrap();
690 let tokens = tokens_with_expiry(Some(expires_at));
691 let auth_binding = auth_binding();
692
693 publish_token_lifecycle_acquired(&handle, &auth_binding, &tokens).unwrap();
694
695 assert_eq!(
696 handle.acquired(),
697 vec![(LeaseKey::from_auth_binding(&auth_binding), 1_800_000_000)]
698 );
699 }
700
701 #[test]
702 fn lifecycle_acquire_maps_non_expiring_tokens_to_unbounded_lease() {
703 let handle = RecordingAuthLeaseHandle::default();
704 let tokens = tokens_with_expiry(None);
705
706 publish_token_lifecycle_acquired(&handle, &auth_binding(), &tokens).unwrap();
707
708 assert_eq!(handle.acquired()[0].1, u64::MAX);
709 }
710
711 #[test]
712 fn lifecycle_marker_is_only_added_to_oauth_login_tokens() {
713 let api_key = PersistedTokens::api_key("sk-test");
714 assert_eq!(mark_tokens_lifecycle_published(&api_key), api_key);
715
716 let oauth = oauth_tokens_with_metadata(serde_json::Value::Null);
717 let marked = mark_tokens_lifecycle_published(&oauth);
718 assert!(tokens_lifecycle_published(&marked));
719 assert!(!tokens_lifecycle_published(&oauth));
720 }
721
722 #[test]
723 fn lifecycle_marker_preserves_existing_object_metadata() {
724 let oauth = oauth_tokens_with_metadata(serde_json::json!({
725 "provider": "openai",
726 }));
727
728 let marked = mark_tokens_lifecycle_published(&oauth);
729
730 assert!(tokens_lifecycle_published(&marked));
731 assert_eq!(marked.metadata["provider"], "openai");
732 }
733
734 #[tokio::test]
735 async fn clear_boundary_restores_lifecycle_when_token_clear_fails() {
736 let handle = RecordingAuthLeaseHandle::default();
737 let expires_at = DateTime::<Utc>::from_timestamp(1_800_000_000, 0).unwrap();
738 let tokens = tokens_with_expiry(Some(expires_at));
739 let store = ClearFailingTokenStore::new(tokens.clone());
740 let auth_binding = auth_binding();
741
742 let err = clear_tokens_and_publish_lifecycle_released(&store, &handle, &auth_binding)
743 .await
744 .unwrap_err();
745
746 assert!(matches!(err, TokenLifecycleClearError::TokenStoreClear(_)));
747 assert_eq!(
748 handle.released(),
749 vec![LeaseKey::from_auth_binding(&auth_binding)]
750 );
751 assert_eq!(
752 handle.acquired(),
753 vec![(
754 LeaseKey::from_auth_binding(&auth_binding),
755 persisted_token_expires_at_epoch_secs(&tokens),
756 )]
757 );
758 assert!(
759 store
760 .load(&TokenKey::from_auth_binding(&auth_binding))
761 .await
762 .unwrap()
763 .is_some()
764 );
765 }
766
767 #[tokio::test]
768 async fn clear_boundary_allows_clear_when_previous_token_load_fails() {
769 let handle = RecordingAuthLeaseHandle::default();
770 let store = LoadFailingTokenStore::new();
771 let auth_binding = auth_binding();
772
773 clear_tokens_and_publish_lifecycle_released(&store, &handle, &auth_binding)
774 .await
775 .unwrap();
776
777 assert_eq!(
778 handle.released(),
779 vec![LeaseKey::from_auth_binding(&auth_binding)]
780 );
781 assert!(store.cleared());
782 assert!(
783 handle.acquired().is_empty(),
784 "unreadable previous tokens cannot be used to restore a lease"
785 );
786 }
787
788 #[tokio::test]
789 async fn clear_boundary_does_not_release_lifecycle_when_load_and_clear_fail() {
790 let handle = RecordingAuthLeaseHandle::default();
791 let store = LoadFailingTokenStore::new_with_clear_error();
792 let auth_binding = auth_binding();
793
794 let err = clear_tokens_and_publish_lifecycle_released(&store, &handle, &auth_binding)
795 .await
796 .unwrap_err();
797
798 assert!(matches!(
799 err,
800 TokenLifecycleClearError::TokenStoreLoadAndClear { .. }
801 ));
802 assert!(store.cleared());
803 assert!(
804 handle.released().is_empty(),
805 "lifecycle must remain untouched when unreadable token material cannot be cleared"
806 );
807 assert!(handle.acquired().is_empty());
808 }
809
810 #[test]
811 fn published_status_projects_lease_phase_without_token_material() {
812 let now = Utc::now();
813 let snapshot = AuthLeaseSnapshot {
814 phase: Some(AuthLeasePhase::Valid),
815 expires_at: Some((now + chrono::Duration::hours(1)).timestamp() as u64),
816 credential_present: true,
817 generation: 1,
818 credential_published_at_millis: None,
819 };
820
821 let status = project_published_auth_status(now, None, &snapshot);
822
823 assert_eq!(status.phase, AuthStatusPhase::Valid);
824 assert!(status.expires_at.is_some());
825 assert!(status.tokens.is_none());
826 }
827}