Skip to main content

meerkat_core/auth/
lifecycle.rs

1//! Auth lifecycle publication helpers.
2//!
3//! TokenStore owns credential material. AuthMachine owns lifecycle state.
4//! Surfaces that write or clear credentials use these helpers so the public
5//! status path observes the machine-owned lease instead of deriving phase from
6//! persisted token bytes.
7
8use 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/// Process-local guard that serializes credential commit, terminal OAuth
39/// consume, and compensating rollback for one auth binding.
40#[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
267/// Clear persisted token material and release the AuthMachine lifecycle as one
268/// fail-closed boundary.
269///
270/// When the previous token snapshot can be loaded, the AuthMachine release
271/// happens first. If the token clear then fails, the previous lifecycle snapshot
272/// is restored so public status does not commit a split "token exists but
273/// lifecycle is gone" state. When token material is unreadable, there is no
274/// durable token snapshot to restore, so release is delayed until clear succeeds.
275pub 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
318/// Restore an AuthMachine lease projection from a previously captured
319/// snapshot.
320///
321/// Callers that need to compensate a token write after a later step fails can
322/// use this with the token snapshot captured before the write. If the previous
323/// snapshot had no credential-backed active phase, this helper is a no-op; it
324/// must not recreate credential authority from token bytes alone.
325pub 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    // Lifecycle markers in persisted OAuth material are projection data only.
386    // Auth status must not recreate an AuthMachine credential lease from token
387    // JSON, otherwise durable material can shadow machine-owned lease truth.
388    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}