1use std::collections::HashMap;
19#[cfg(not(target_arch = "wasm32"))]
20use std::sync::Weak;
21use std::sync::{Arc, Mutex};
22
23#[cfg(not(target_arch = "wasm32"))]
24use meerkat_core::AuthBindingRef;
25use meerkat_core::handles::{
26 AuthLeaseHandle, AuthLeasePhase, AuthLeaseSnapshot, AuthLeaseTransition, DslTransitionError,
27 LeaseKey,
28};
29use meerkat_core::time_compat::{SystemTime, UNIX_EPOCH};
30
31use crate::auth_machine::dsl as auth_dsl;
32
33fn current_time_millis() -> u64 {
34 SystemTime::now()
35 .duration_since(UNIX_EPOCH)
36 .map(|duration| u64::try_from(duration.as_millis()).unwrap_or(u64::MAX))
37 .unwrap_or(0)
38}
39
40fn emit_audit(
52 lease_key: &LeaseKey,
53 action: &'static str,
54 from_phase: AuthLeasePhase,
55 to_phase: AuthLeasePhase,
56) {
57 tracing::info!(
58 target: "meerkat::auth::audit",
59 lease_key = %lease_key,
60 realm = %lease_key.realm,
61 binding = %lease_key.binding,
62 profile = lease_key.profile.as_ref().map(meerkat_core::ProfileId::as_str),
63 action = %action,
64 from_phase = ?from_phase,
65 to_phase = ?to_phase,
66 "auth lease transition"
67 );
68}
69
70#[derive(Clone)]
77pub struct RuntimeAuthLeaseHandle {
78 machines: Arc<Mutex<AuthLeaseRegistry>>,
79 #[cfg(not(target_arch = "wasm32"))]
80 release_observers: Arc<Mutex<Vec<Weak<dyn AuthLeaseReleaseObserver>>>>,
81}
82
83#[cfg(not(target_arch = "wasm32"))]
84#[derive(Debug, Clone)]
85pub(crate) struct ReleasedOAuthFlows {
86 pub lease_key: LeaseKey,
87 pub browser_flow_ids: Vec<String>,
88 pub device_flow_ids: Vec<String>,
89}
90
91#[cfg(not(target_arch = "wasm32"))]
92impl ReleasedOAuthFlows {
93 fn empty(lease_key: LeaseKey) -> Self {
94 Self {
95 lease_key,
96 browser_flow_ids: Vec::new(),
97 device_flow_ids: Vec::new(),
98 }
99 }
100
101 fn dedup(&mut self) {
102 self.browser_flow_ids.sort();
103 self.browser_flow_ids.dedup();
104 self.device_flow_ids.sort();
105 self.device_flow_ids.dedup();
106 }
107}
108
109fn has_oauth_membership(state: &auth_dsl::AuthMachineState) -> bool {
110 !state.oauth_browser_flow_ids.is_empty()
111 || !state.oauth_device_flow_ids.is_empty()
112 || !state.oauth_device_poll_ids.is_empty()
113}
114
115#[cfg(not(target_arch = "wasm32"))]
116fn merge_oauth_membership(
117 restored: &mut auth_dsl::AuthMachineState,
118 current: &auth_dsl::AuthMachineState,
119) {
120 for flow_id in ¤t.oauth_browser_flow_ids {
121 restored.oauth_browser_flow_ids.insert(flow_id.clone());
122 if let Some(provider) = current.oauth_browser_flow_providers.get(flow_id).cloned() {
123 restored
124 .oauth_browser_flow_providers
125 .insert(flow_id.clone(), provider);
126 }
127 if let Some(redirect_uri) = current
128 .oauth_browser_flow_redirect_uris
129 .get(flow_id)
130 .cloned()
131 {
132 restored
133 .oauth_browser_flow_redirect_uris
134 .insert(flow_id.clone(), redirect_uri);
135 }
136 if let Some(expires_at_millis) = current
137 .oauth_browser_flow_expires_at_millis
138 .get(flow_id)
139 .copied()
140 {
141 restored
142 .oauth_browser_flow_expires_at_millis
143 .insert(flow_id.clone(), expires_at_millis);
144 }
145 }
146 for flow_id in ¤t.oauth_device_flow_ids {
147 restored.oauth_device_flow_ids.insert(flow_id.clone());
148 if let Some(provider) = current.oauth_device_flow_providers.get(flow_id).cloned() {
149 restored
150 .oauth_device_flow_providers
151 .insert(flow_id.clone(), provider);
152 }
153 if let Some(expires_at_millis) = current
154 .oauth_device_flow_expires_at_millis
155 .get(flow_id)
156 .copied()
157 {
158 restored
159 .oauth_device_flow_expires_at_millis
160 .insert(flow_id.clone(), expires_at_millis);
161 }
162 }
163 for poll_id in ¤t.oauth_device_poll_ids {
164 restored.oauth_device_poll_ids.insert(poll_id.clone());
165 }
166 let browser_count = u64::try_from(restored.oauth_browser_flow_ids.len()).unwrap_or(u64::MAX);
167 let device_count = u64::try_from(restored.oauth_device_flow_ids.len()).unwrap_or(u64::MAX);
168 restored.oauth_outstanding_flow_count = browser_count.saturating_add(device_count);
169}
170
171#[cfg(not(target_arch = "wasm32"))]
172pub(crate) trait AuthLeaseReleaseObserver: Send + Sync {
173 fn oauth_flows_for_release(
174 &self,
175 lease_key: &LeaseKey,
176 ) -> Result<ReleasedOAuthFlows, DslTransitionError> {
177 Ok(ReleasedOAuthFlows::empty(lease_key.clone()))
178 }
179
180 fn auth_lease_released(&self, released: &ReleasedOAuthFlows) -> Result<(), DslTransitionError>;
181}
182
183#[cfg(test)]
184pub(crate) type ReleaseAfterAcceptHook = Arc<dyn Fn(&LeaseKey) + Send + Sync>;
185
186#[cfg(test)]
187static RELEASE_AFTER_ACCEPT_HOOK: std::sync::OnceLock<Mutex<Option<ReleaseAfterAcceptHook>>> =
188 std::sync::OnceLock::new();
189
190#[cfg(test)]
191static RELEASE_AFTER_ACCEPT_HOOK_SERIAL: std::sync::OnceLock<Mutex<()>> =
192 std::sync::OnceLock::new();
193
194#[cfg(test)]
195pub(crate) struct ReleaseAfterAcceptHookGuard {
196 _serial: std::sync::MutexGuard<'static, ()>,
197}
198
199#[cfg(test)]
200impl Drop for ReleaseAfterAcceptHookGuard {
201 fn drop(&mut self) {
202 set_release_after_accept_hook_for_test(None);
203 }
204}
205
206#[cfg(test)]
207pub(crate) fn install_release_after_accept_hook_for_test(
208 hook: ReleaseAfterAcceptHook,
209) -> ReleaseAfterAcceptHookGuard {
210 let serial = RELEASE_AFTER_ACCEPT_HOOK_SERIAL
211 .get_or_init(|| Mutex::new(()))
212 .lock()
213 .unwrap_or_else(std::sync::PoisonError::into_inner);
214 set_release_after_accept_hook_for_test(Some(hook));
215 ReleaseAfterAcceptHookGuard { _serial: serial }
216}
217
218#[cfg(test)]
219fn set_release_after_accept_hook_for_test(hook: Option<ReleaseAfterAcceptHook>) {
220 *RELEASE_AFTER_ACCEPT_HOOK
221 .get_or_init(|| Mutex::new(None))
222 .lock()
223 .unwrap_or_else(std::sync::PoisonError::into_inner) = hook;
224}
225
226#[cfg(test)]
227fn run_release_after_accept_hook(lease_key: &LeaseKey) {
228 let hook = RELEASE_AFTER_ACCEPT_HOOK
229 .get_or_init(|| Mutex::new(None))
230 .lock()
231 .unwrap_or_else(std::sync::PoisonError::into_inner)
232 .clone();
233 if let Some(hook) = hook {
234 hook(lease_key);
235 }
236}
237
238#[derive(Default)]
239struct AuthLeaseRegistry {
240 authorities: HashMap<LeaseKey, auth_dsl::AuthMachineAuthority>,
241 generations: HashMap<LeaseKey, u64>,
245 credential_published_at_millis: HashMap<LeaseKey, u64>,
246}
247
248impl std::fmt::Debug for RuntimeAuthLeaseHandle {
249 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
250 let guard = self
251 .machines
252 .lock()
253 .unwrap_or_else(std::sync::PoisonError::into_inner);
254 f.debug_struct("RuntimeAuthLeaseHandle")
255 .field("leases", &guard.authorities.keys().collect::<Vec<_>>())
256 .finish()
257 }
258}
259
260impl RuntimeAuthLeaseHandle {
261 pub fn new() -> Self {
262 Self {
263 machines: Arc::new(Mutex::new(AuthLeaseRegistry::default())),
264 #[cfg(not(target_arch = "wasm32"))]
265 release_observers: Arc::new(Mutex::new(Vec::new())),
266 }
267 }
268
269 pub fn ephemeral() -> Self {
275 Self::new()
276 }
277
278 #[cfg(not(target_arch = "wasm32"))]
279 pub(crate) fn add_release_observer(&self, observer: Weak<dyn AuthLeaseReleaseObserver>) {
280 self.release_observers
281 .lock()
282 .unwrap_or_else(std::sync::PoisonError::into_inner)
283 .push(observer);
284 }
285
286 #[cfg(not(target_arch = "wasm32"))]
287 fn live_release_observers(&self) -> Vec<Arc<dyn AuthLeaseReleaseObserver>> {
288 {
289 let mut guard = self
290 .release_observers
291 .lock()
292 .unwrap_or_else(std::sync::PoisonError::into_inner);
293 let mut observers = Vec::new();
294 guard.retain(|observer| match observer.upgrade() {
295 Some(observer) => {
296 observers.push(observer);
297 true
298 }
299 None => false,
300 });
301 observers
302 }
303 }
304
305 #[cfg(not(target_arch = "wasm32"))]
306 fn collect_release_observer_flows(
307 &self,
308 observers: &[Arc<dyn AuthLeaseReleaseObserver>],
309 lease_key: &LeaseKey,
310 ) -> Result<ReleasedOAuthFlows, DslTransitionError> {
311 let mut released = ReleasedOAuthFlows::empty(lease_key.clone());
312 for observer in observers {
313 let mut observed = observer.oauth_flows_for_release(lease_key)?;
314 released
315 .browser_flow_ids
316 .append(&mut observed.browser_flow_ids);
317 released
318 .device_flow_ids
319 .append(&mut observed.device_flow_ids);
320 }
321 released.dedup();
322 Ok(released)
323 }
324
325 #[cfg(not(target_arch = "wasm32"))]
326 fn notify_release_observers(
327 &self,
328 observers: &[Arc<dyn AuthLeaseReleaseObserver>],
329 released: &ReleasedOAuthFlows,
330 ) -> Result<(), DslTransitionError> {
331 for observer in observers {
332 observer.auth_lease_released(released)?;
333 }
334 Ok(())
335 }
336
337 #[cfg(not(target_arch = "wasm32"))]
338 fn oauth_flow_bootstrap_state() -> auth_dsl::AuthMachineState {
339 auth_dsl::AuthMachineState {
340 lifecycle_phase: auth_dsl::AuthLifecyclePhase::ReauthRequired,
341 ..Default::default()
342 }
343 }
344
345 fn apply(
346 &self,
347 lease_key: &LeaseKey,
348 input: auth_dsl::AuthMachineInput,
349 context: &'static str,
350 create_if_missing: bool,
351 ) -> Result<AuthLeaseTransition, DslTransitionError> {
352 let action = Self::audit_action_for(&input);
353 let remove_after_accept = matches!(&input, auth_dsl::AuthMachineInput::Release);
354 let publishes_credential = matches!(
355 &input,
356 auth_dsl::AuthMachineInput::Acquire { .. }
357 | auth_dsl::AuthMachineInput::CompleteRefresh { .. }
358 );
359 let mut guard = self
360 .machines
361 .lock()
362 .unwrap_or_else(std::sync::PoisonError::into_inner);
363 if !create_if_missing
364 && guard.generations.get(lease_key).copied().unwrap_or(0) == 0
365 && !remove_after_accept
366 {
367 return Err(DslTransitionError::new(
368 context,
369 format!("no auth lease registered for lease_key `{lease_key}`"),
370 ));
371 }
372 let (from_phase, to_phase) = {
373 let entry = if create_if_missing {
374 guard
375 .authorities
376 .entry(lease_key.clone())
377 .or_insert_with(|| {
378 auth_dsl::AuthMachineAuthority::from_state(
379 auth_dsl::AuthMachineState::default(),
380 )
381 })
382 } else {
383 match guard.authorities.get_mut(lease_key) {
384 Some(m) => m,
385 None => {
386 return Err(DslTransitionError::new(
387 context,
388 format!("no auth lease registered for lease_key `{lease_key}`"),
389 ));
390 }
391 }
392 };
393 let from_phase = map_phase(entry.state.lifecycle_phase);
394 auth_dsl::AuthMachineMutator::apply(entry, input)
395 .map_err(|err| DslTransitionError::new(context, format!("{err}")))?;
396 let to_phase = map_phase(entry.state.lifecycle_phase);
397 (from_phase, to_phase)
398 };
399 let generation = guard.generations.entry(lease_key.clone()).or_insert(0);
400 if publishes_credential {
401 *generation = generation.saturating_add(1);
402 }
403 let accepted_generation = *generation;
404 let credential_published_at_millis = if publishes_credential {
405 let published_at = current_time_millis();
406 guard
407 .credential_published_at_millis
408 .insert(lease_key.clone(), published_at);
409 Some(published_at)
410 } else {
411 guard.credential_published_at_millis.get(lease_key).copied()
412 };
413 if remove_after_accept {
414 guard.authorities.remove(lease_key);
415 guard.credential_published_at_millis.remove(lease_key);
416 }
417 emit_audit(lease_key, action, from_phase, to_phase);
418 Ok(AuthLeaseTransition::new(
419 accepted_generation,
420 credential_published_at_millis,
421 ))
422 }
423
424 #[cfg(not(target_arch = "wasm32"))]
425 pub(crate) fn apply_oauth_input(
426 &self,
427 target: &AuthBindingRef,
428 input: auth_dsl::AuthMachineInput,
429 context: &'static str,
430 create_if_missing: bool,
431 ) -> Result<(), DslTransitionError> {
432 let lease_key = LeaseKey::from_auth_binding(target);
433 let action = Self::audit_action_for(&input);
434 let mut guard = self
435 .machines
436 .lock()
437 .unwrap_or_else(std::sync::PoisonError::into_inner);
438 if let Some((transition, max_outstanding_flows)) = match &input {
439 auth_dsl::AuthMachineInput::AdmitOAuthBrowserFlow {
440 max_outstanding_flows,
441 ..
442 } => Some(("AdmitOAuthBrowserFlow", *max_outstanding_flows)),
443 auth_dsl::AuthMachineInput::AdmitOAuthDeviceFlow {
444 max_outstanding_flows,
445 ..
446 } => Some(("AdmitOAuthDeviceFlow", *max_outstanding_flows)),
447 _ => None,
448 } {
449 let outstanding = guard
450 .authorities
451 .values()
452 .map(|authority| authority.state.oauth_outstanding_flow_count)
453 .fold(0u64, u64::saturating_add);
454 if outstanding >= max_outstanding_flows {
455 return Err(DslTransitionError::guard_rejected(
456 context,
457 format!(
458 "transition {transition} guard oauth_global_capacity_available failed: {outstanding} outstanding OAuth flows >= {max_outstanding_flows} max"
459 ),
460 ));
461 }
462 }
463 let (from_phase, to_phase) = {
464 let entry = if create_if_missing {
465 guard
466 .authorities
467 .entry(lease_key.clone())
468 .or_insert_with(|| {
469 auth_dsl::AuthMachineAuthority::from_state(
470 Self::oauth_flow_bootstrap_state(),
471 )
472 })
473 } else {
474 match guard.authorities.get_mut(&lease_key) {
475 Some(m) => m,
476 None => {
477 return Err(DslTransitionError::new(
478 context,
479 format!("no auth machine registered for lease_key `{lease_key}`"),
480 ));
481 }
482 }
483 };
484 let from_phase = map_phase(entry.state.lifecycle_phase);
485 auth_dsl::AuthMachineMutator::apply(entry, input)
486 .map_err(|err| DslTransitionError::new(context, format!("{err}")))?;
487 let to_phase = map_phase(entry.state.lifecycle_phase);
488 (from_phase, to_phase)
489 };
490 emit_audit(&lease_key, action, from_phase, to_phase);
491 Ok(())
492 }
493
494 #[cfg(not(target_arch = "wasm32"))]
495 pub(crate) fn has_oauth_browser_flow(&self, target: &AuthBindingRef, flow_id: &str) -> bool {
496 let lease_key = LeaseKey::from_auth_binding(target);
497 self.machines
498 .lock()
499 .unwrap_or_else(std::sync::PoisonError::into_inner)
500 .authorities
501 .get(&lease_key)
502 .is_some_and(|authority| authority.state.oauth_browser_flow_ids.contains(flow_id))
503 }
504
505 #[cfg(not(target_arch = "wasm32"))]
506 pub(crate) fn has_oauth_device_flow(&self, target: &AuthBindingRef, flow_id: &str) -> bool {
507 let lease_key = LeaseKey::from_auth_binding(target);
508 self.machines
509 .lock()
510 .unwrap_or_else(std::sync::PoisonError::into_inner)
511 .authorities
512 .get(&lease_key)
513 .is_some_and(|authority| authority.state.oauth_device_flow_ids.contains(flow_id))
514 }
515
516 #[cfg(test)]
517 pub(crate) fn has_oauth_browser_flow_for_test(
518 &self,
519 target: &AuthBindingRef,
520 flow_id: &str,
521 ) -> bool {
522 self.has_oauth_browser_flow(target, flow_id)
523 }
524
525 #[cfg(test)]
526 pub(crate) fn has_oauth_device_flow_for_test(
527 &self,
528 target: &AuthBindingRef,
529 flow_id: &str,
530 ) -> bool {
531 self.has_oauth_device_flow(target, flow_id)
532 }
533
534 #[cfg(not(target_arch = "wasm32"))]
535 fn restore_released_lease_after_observer_failure(
536 &self,
537 lease_key: &LeaseKey,
538 previous_state: Option<auth_dsl::AuthMachineState>,
539 previous_generation: Option<u64>,
540 previous_published_at: Option<u64>,
541 ) {
542 let mut guard = self
543 .machines
544 .lock()
545 .unwrap_or_else(std::sync::PoisonError::into_inner);
546 let current_generation = guard.generations.get(lease_key).copied().unwrap_or(0);
547 let previous_generation_value = previous_generation.unwrap_or(0);
548 if current_generation > previous_generation_value {
549 let to_phase = match previous_state {
550 Some(previous_state) => {
551 if let Some(current) = guard.authorities.get(lease_key) {
552 let mut current_state = current.state.clone();
553 merge_oauth_membership(&mut current_state, &previous_state);
554 let to_phase = map_phase(current_state.lifecycle_phase);
555 guard.authorities.insert(
556 lease_key.clone(),
557 auth_dsl::AuthMachineAuthority::from_state(current_state),
558 );
559 to_phase
560 } else if has_oauth_membership(&previous_state) {
561 guard.authorities.insert(
562 lease_key.clone(),
563 auth_dsl::AuthMachineAuthority::from_state(
564 auth_dsl::AuthMachineState {
565 lifecycle_phase: auth_dsl::AuthLifecyclePhase::ReauthRequired,
566 credential_present: false,
567 oauth_browser_flow_ids: previous_state.oauth_browser_flow_ids,
568 oauth_browser_flow_providers: previous_state
569 .oauth_browser_flow_providers,
570 oauth_browser_flow_redirect_uris: previous_state
571 .oauth_browser_flow_redirect_uris,
572 oauth_browser_flow_expires_at_millis: previous_state
573 .oauth_browser_flow_expires_at_millis,
574 oauth_device_flow_ids: previous_state.oauth_device_flow_ids,
575 oauth_device_flow_providers: previous_state
576 .oauth_device_flow_providers,
577 oauth_device_flow_expires_at_millis: previous_state
578 .oauth_device_flow_expires_at_millis,
579 oauth_device_poll_ids: previous_state.oauth_device_poll_ids,
580 oauth_outstanding_flow_count: previous_state
581 .oauth_outstanding_flow_count,
582 ..Default::default()
583 },
584 ),
585 );
586 AuthLeasePhase::ReauthRequired
587 } else {
588 AuthLeasePhase::Released
589 }
590 }
591 None => guard
592 .authorities
593 .get(lease_key)
594 .map(|current| map_phase(current.state.lifecycle_phase))
595 .unwrap_or(AuthLeasePhase::Released),
596 };
597 drop(guard);
598 emit_audit(
599 lease_key,
600 "rollback_release_lease",
601 AuthLeasePhase::Released,
602 to_phase,
603 );
604 return;
605 }
606 let to_phase = if let Some(mut restored_state) = previous_state {
607 if let Some(current) = guard.authorities.get(lease_key) {
608 if current.state.credential_present {
609 let mut current_state = current.state.clone();
610 merge_oauth_membership(&mut current_state, &restored_state);
611 let to_phase = map_phase(current_state.lifecycle_phase);
612 guard.authorities.insert(
613 lease_key.clone(),
614 auth_dsl::AuthMachineAuthority::from_state(current_state),
615 );
616 drop(guard);
617 emit_audit(
618 lease_key,
619 "rollback_release_lease",
620 AuthLeasePhase::Released,
621 to_phase,
622 );
623 return;
624 }
625 merge_oauth_membership(&mut restored_state, ¤t.state);
626 }
627 let to_phase = map_phase(restored_state.lifecycle_phase);
628 guard.authorities.insert(
629 lease_key.clone(),
630 auth_dsl::AuthMachineAuthority::from_state(restored_state),
631 );
632 match previous_generation {
633 Some(generation) => {
634 guard.generations.insert(lease_key.clone(), generation);
635 }
636 None => {
637 guard.generations.remove(lease_key);
638 }
639 }
640 match previous_published_at {
641 Some(published_at) => {
642 guard
643 .credential_published_at_millis
644 .insert(lease_key.clone(), published_at);
645 }
646 None => {
647 guard.credential_published_at_millis.remove(lease_key);
648 }
649 }
650 to_phase
651 } else if let Some(current) = guard.authorities.get(lease_key) {
652 map_phase(current.state.lifecycle_phase)
653 } else {
654 guard.authorities.remove(lease_key);
655 match previous_generation {
656 Some(generation) => {
657 guard.generations.insert(lease_key.clone(), generation);
658 }
659 None => {
660 guard.generations.remove(lease_key);
661 }
662 }
663 match previous_published_at {
664 Some(published_at) => {
665 guard
666 .credential_published_at_millis
667 .insert(lease_key.clone(), published_at);
668 }
669 None => {
670 guard.credential_published_at_millis.remove(lease_key);
671 }
672 }
673 AuthLeasePhase::Released
674 };
675 drop(guard);
676 emit_audit(
677 lease_key,
678 "rollback_release_lease",
679 AuthLeasePhase::Released,
680 to_phase,
681 );
682 }
683
684 fn audit_action_for(input: &auth_dsl::AuthMachineInput) -> &'static str {
685 match input {
686 auth_dsl::AuthMachineInput::Acquire { .. } => "acquire_lease",
687 auth_dsl::AuthMachineInput::MarkExpiring => "mark_expiring",
688 auth_dsl::AuthMachineInput::BeginRefresh => "begin_refresh",
689 auth_dsl::AuthMachineInput::CompleteRefresh { .. } => "complete_refresh",
690 auth_dsl::AuthMachineInput::RefreshFailedTransient => "refresh_failed_transient",
691 auth_dsl::AuthMachineInput::RefreshFailedPermanent => "refresh_failed_permanent",
692 auth_dsl::AuthMachineInput::MarkReauthRequired => "mark_reauth_required",
693 auth_dsl::AuthMachineInput::ClearCredentialLifecycle => "clear_credential_lifecycle",
694 auth_dsl::AuthMachineInput::Release => "release_lease",
695 auth_dsl::AuthMachineInput::AdmitOAuthBrowserFlow { .. } => "admit_oauth_browser_flow",
696 auth_dsl::AuthMachineInput::VerifyOAuthBrowserFlow { .. } => {
697 "verify_oauth_browser_flow"
698 }
699 auth_dsl::AuthMachineInput::ConsumeOAuthBrowserFlow { .. } => {
700 "consume_oauth_browser_flow"
701 }
702 auth_dsl::AuthMachineInput::ExpireOAuthBrowserFlow { .. } => {
703 "expire_oauth_browser_flow"
704 }
705 auth_dsl::AuthMachineInput::AdmitOAuthDeviceFlow { .. } => "admit_oauth_device_flow",
706 auth_dsl::AuthMachineInput::VerifyOAuthDeviceFlow { .. } => "verify_oauth_device_flow",
707 auth_dsl::AuthMachineInput::BeginOAuthDevicePoll { .. } => "begin_oauth_device_poll",
708 auth_dsl::AuthMachineInput::FinishOAuthDevicePoll { .. } => "finish_oauth_device_poll",
709 auth_dsl::AuthMachineInput::ConsumeOAuthDeviceFlow { .. } => {
710 "consume_oauth_device_flow"
711 }
712 auth_dsl::AuthMachineInput::ExpireOAuthDeviceFlow { .. } => "expire_oauth_device_flow",
713 }
714 }
715}
716
717fn map_phase(phase: auth_dsl::AuthLifecyclePhase) -> AuthLeasePhase {
721 match phase {
722 auth_dsl::AuthLifecyclePhase::Valid => AuthLeasePhase::Valid,
723 auth_dsl::AuthLifecyclePhase::Expiring => AuthLeasePhase::Expiring,
724 auth_dsl::AuthLifecyclePhase::Refreshing => AuthLeasePhase::Refreshing,
725 auth_dsl::AuthLifecyclePhase::ReauthRequired => AuthLeasePhase::ReauthRequired,
726 auth_dsl::AuthLifecyclePhase::Released => AuthLeasePhase::Released,
727 }
728}
729
730fn restore_phase(phase: AuthLeasePhase) -> auth_dsl::AuthLifecyclePhase {
731 match phase {
732 AuthLeasePhase::Valid => auth_dsl::AuthLifecyclePhase::Valid,
733 AuthLeasePhase::Expiring => auth_dsl::AuthLifecyclePhase::Expiring,
734 AuthLeasePhase::Refreshing => auth_dsl::AuthLifecyclePhase::Refreshing,
735 AuthLeasePhase::ReauthRequired => auth_dsl::AuthLifecyclePhase::ReauthRequired,
736 AuthLeasePhase::Released => auth_dsl::AuthLifecyclePhase::Released,
737 }
738}
739
740impl Default for RuntimeAuthLeaseHandle {
741 fn default() -> Self {
742 Self::new()
743 }
744}
745
746impl AuthLeaseHandle for RuntimeAuthLeaseHandle {
747 fn acquire_lease(
748 &self,
749 lease_key: &LeaseKey,
750 expires_at: u64,
751 ) -> Result<AuthLeaseTransition, DslTransitionError> {
752 let expires_at_ts = if expires_at == u64::MAX {
753 None
754 } else {
755 Some(expires_at)
756 };
757 self.apply(
758 lease_key,
759 auth_dsl::AuthMachineInput::Acquire { expires_at_ts },
760 "AuthLeaseHandle::acquire_lease",
761 true,
762 )
763 }
764
765 fn mark_expiring(&self, lease_key: &LeaseKey) -> Result<(), DslTransitionError> {
766 self.apply(
767 lease_key,
768 auth_dsl::AuthMachineInput::MarkExpiring,
769 "AuthLeaseHandle::mark_expiring",
770 false,
771 )
772 .map(|_| ())
773 }
774
775 fn begin_refresh(&self, lease_key: &LeaseKey) -> Result<(), DslTransitionError> {
776 self.apply(
777 lease_key,
778 auth_dsl::AuthMachineInput::BeginRefresh,
779 "AuthLeaseHandle::begin_refresh",
780 false,
781 )
782 .map(|_| ())
783 }
784
785 fn complete_refresh(
786 &self,
787 lease_key: &LeaseKey,
788 new_expires_at: u64,
789 now: u64,
790 ) -> Result<AuthLeaseTransition, DslTransitionError> {
791 let new_expires_at = if new_expires_at == u64::MAX {
792 None
793 } else {
794 Some(new_expires_at)
795 };
796 self.apply(
797 lease_key,
798 auth_dsl::AuthMachineInput::CompleteRefresh {
799 new_expires_at,
800 now_ts: now,
801 },
802 "AuthLeaseHandle::complete_refresh",
803 false,
804 )
805 }
806
807 fn refresh_failed(
808 &self,
809 lease_key: &LeaseKey,
810 permanent: bool,
811 ) -> Result<(), DslTransitionError> {
812 let input = if permanent {
813 auth_dsl::AuthMachineInput::RefreshFailedPermanent
814 } else {
815 auth_dsl::AuthMachineInput::RefreshFailedTransient
816 };
817 self.apply(lease_key, input, "AuthLeaseHandle::refresh_failed", false)
818 .map(|_| ())
819 }
820
821 fn mark_reauth_required(&self, lease_key: &LeaseKey) -> Result<(), DslTransitionError> {
822 self.apply(
823 lease_key,
824 auth_dsl::AuthMachineInput::MarkReauthRequired,
825 "AuthLeaseHandle::mark_reauth_required",
826 false,
827 )
828 .map(|_| ())
829 }
830
831 fn release_lease(&self, lease_key: &LeaseKey) -> Result<(), DslTransitionError> {
832 let context = "AuthLeaseHandle::release_lease";
833 #[cfg(not(target_arch = "wasm32"))]
838 let release_observers = self.live_release_observers();
839 #[cfg(not(target_arch = "wasm32"))]
840 let mut released = self.collect_release_observer_flows(&release_observers, lease_key)?;
841 #[cfg(not(target_arch = "wasm32"))]
842 let previous_state;
843 #[cfg(not(target_arch = "wasm32"))]
844 let previous_generation;
845 #[cfg(not(target_arch = "wasm32"))]
846 let previous_published_at;
847 let (from_phase, to_phase) = {
848 let mut guard = self
849 .machines
850 .lock()
851 .unwrap_or_else(std::sync::PoisonError::into_inner);
852 #[cfg(not(target_arch = "wasm32"))]
853 {
854 previous_generation = guard.generations.get(lease_key).copied();
855 previous_published_at =
856 guard.credential_published_at_millis.get(lease_key).copied();
857 previous_state = guard
858 .authorities
859 .get(lease_key)
860 .map(|authority| authority.state.clone());
861 }
862 let entry =
863 guard
864 .authorities
865 .entry(lease_key.clone())
866 .or_insert_with(|| {
867 auth_dsl::AuthMachineAuthority::from_state(
868 auth_dsl::AuthMachineState::default(),
869 )
870 });
871 #[cfg(not(target_arch = "wasm32"))]
872 {
873 released
874 .browser_flow_ids
875 .extend(entry.state.oauth_browser_flow_ids.iter().cloned());
876 released
877 .device_flow_ids
878 .extend(entry.state.oauth_device_flow_ids.iter().cloned());
879 released.dedup();
880 }
881 let from_phase = map_phase(entry.state.lifecycle_phase);
882 auth_dsl::AuthMachineMutator::apply(entry, auth_dsl::AuthMachineInput::Release)
883 .map_err(|err| DslTransitionError::new(context, format!("{err}")))?;
884 let to_phase = map_phase(entry.state.lifecycle_phase);
885 guard.generations.entry(lease_key.clone()).or_insert(0);
886 guard.authorities.remove(lease_key);
887 guard.credential_published_at_millis.remove(lease_key);
888 (from_phase, to_phase)
889 };
890 emit_audit(lease_key, "release_lease", from_phase, to_phase);
891 #[cfg(test)]
892 run_release_after_accept_hook(lease_key);
893 #[cfg(not(target_arch = "wasm32"))]
894 if let Err(err) = self.notify_release_observers(&release_observers, &released) {
895 self.restore_released_lease_after_observer_failure(
896 lease_key,
897 previous_state,
898 previous_generation,
899 previous_published_at,
900 );
901 return Err(err);
902 }
903 Ok(())
904 }
905
906 fn release_credential_lifecycle(&self, lease_key: &LeaseKey) -> Result<(), DslTransitionError> {
907 let context = "AuthLeaseHandle::release_credential_lifecycle";
908 let mut guard = self
909 .machines
910 .lock()
911 .unwrap_or_else(std::sync::PoisonError::into_inner);
912 let (input, remove_after_accept, from_phase, to_phase) = {
913 let entry =
914 guard
915 .authorities
916 .entry(lease_key.clone())
917 .or_insert_with(|| {
918 auth_dsl::AuthMachineAuthority::from_state(
919 auth_dsl::AuthMachineState::default(),
920 )
921 });
922 let has_oauth_membership = has_oauth_membership(&entry.state);
923 let input = if has_oauth_membership {
924 auth_dsl::AuthMachineInput::ClearCredentialLifecycle
925 } else {
926 auth_dsl::AuthMachineInput::Release
927 };
928 let remove_after_accept = matches!(&input, auth_dsl::AuthMachineInput::Release);
929 let from_phase = map_phase(entry.state.lifecycle_phase);
930 auth_dsl::AuthMachineMutator::apply(entry, input.clone())
931 .map_err(|err| DslTransitionError::new(context, format!("{err}")))?;
932 let to_phase = map_phase(entry.state.lifecycle_phase);
933 (input, remove_after_accept, from_phase, to_phase)
934 };
935 guard.credential_published_at_millis.remove(lease_key);
936 if remove_after_accept {
937 guard.authorities.remove(lease_key);
938 }
939 emit_audit(
940 lease_key,
941 Self::audit_action_for(&input),
942 from_phase,
943 to_phase,
944 );
945 Ok(())
946 }
947
948 fn restore_auth_lifecycle_snapshot(
949 &self,
950 lease_key: &LeaseKey,
951 snapshot: &AuthLeaseSnapshot,
952 expires_at: Option<u64>,
953 ) -> Result<(), DslTransitionError> {
954 let mut guard = self
955 .machines
956 .lock()
957 .unwrap_or_else(std::sync::PoisonError::into_inner);
958 let current_generation = guard.generations.get(lease_key).copied().unwrap_or(0);
959 let from_phase = guard
960 .authorities
961 .get(lease_key)
962 .map(|authority| map_phase(authority.state.lifecycle_phase))
963 .unwrap_or(AuthLeasePhase::Released);
964 let existing_oauth = guard
965 .authorities
966 .get(lease_key)
967 .map(|authority| authority.state.clone());
968 let has_oauth_membership = existing_oauth.as_ref().is_some_and(has_oauth_membership);
969
970 if !snapshot.credential_present
971 || snapshot.phase.is_none()
972 || snapshot.phase == Some(AuthLeasePhase::Released)
973 {
974 guard.credential_published_at_millis.remove(lease_key);
975 if has_oauth_membership {
976 let oauth = existing_oauth.unwrap_or_default();
977 guard.authorities.insert(
978 lease_key.clone(),
979 auth_dsl::AuthMachineAuthority::from_state(auth_dsl::AuthMachineState {
980 lifecycle_phase: auth_dsl::AuthLifecyclePhase::ReauthRequired,
981 credential_present: false,
982 oauth_browser_flow_ids: oauth.oauth_browser_flow_ids,
983 oauth_browser_flow_providers: oauth.oauth_browser_flow_providers,
984 oauth_browser_flow_redirect_uris: oauth.oauth_browser_flow_redirect_uris,
985 oauth_browser_flow_expires_at_millis: oauth
986 .oauth_browser_flow_expires_at_millis,
987 oauth_device_flow_ids: oauth.oauth_device_flow_ids,
988 oauth_device_flow_providers: oauth.oauth_device_flow_providers,
989 oauth_device_flow_expires_at_millis: oauth
990 .oauth_device_flow_expires_at_millis,
991 oauth_device_poll_ids: oauth.oauth_device_poll_ids,
992 oauth_outstanding_flow_count: oauth.oauth_outstanding_flow_count,
993 ..Default::default()
994 }),
995 );
996 guard.generations.insert(
997 lease_key.clone(),
998 current_generation.max(snapshot.generation),
999 );
1000 } else {
1001 guard.authorities.remove(lease_key);
1002 if snapshot.generation == 0 {
1003 guard.generations.remove(lease_key);
1004 } else {
1005 guard
1006 .generations
1007 .insert(lease_key.clone(), snapshot.generation);
1008 }
1009 }
1010 emit_audit(
1011 lease_key,
1012 "restore_auth_lifecycle_snapshot",
1013 from_phase,
1014 AuthLeasePhase::ReauthRequired,
1015 );
1016 return Ok(());
1017 }
1018
1019 let Some(phase) = snapshot.phase else {
1020 return Ok(());
1021 };
1022 let Some(expires_at) = expires_at else {
1023 return Ok(());
1024 };
1025 let expires_at = if expires_at == u64::MAX {
1026 None
1027 } else {
1028 Some(expires_at)
1029 };
1030 let oauth = existing_oauth.unwrap_or_default();
1031 guard.authorities.insert(
1032 lease_key.clone(),
1033 auth_dsl::AuthMachineAuthority::from_state(auth_dsl::AuthMachineState {
1034 lifecycle_phase: restore_phase(phase),
1035 expires_at,
1036 credential_present: true,
1037 oauth_browser_flow_ids: oauth.oauth_browser_flow_ids,
1038 oauth_browser_flow_providers: oauth.oauth_browser_flow_providers,
1039 oauth_browser_flow_redirect_uris: oauth.oauth_browser_flow_redirect_uris,
1040 oauth_browser_flow_expires_at_millis: oauth.oauth_browser_flow_expires_at_millis,
1041 oauth_device_flow_ids: oauth.oauth_device_flow_ids,
1042 oauth_device_flow_providers: oauth.oauth_device_flow_providers,
1043 oauth_device_flow_expires_at_millis: oauth.oauth_device_flow_expires_at_millis,
1044 oauth_device_poll_ids: oauth.oauth_device_poll_ids,
1045 oauth_outstanding_flow_count: oauth.oauth_outstanding_flow_count,
1046 ..Default::default()
1047 }),
1048 );
1049 guard.generations.insert(
1050 lease_key.clone(),
1051 current_generation.max(snapshot.generation),
1052 );
1053 match snapshot.credential_published_at_millis {
1054 Some(published_at) => {
1055 guard
1056 .credential_published_at_millis
1057 .insert(lease_key.clone(), published_at);
1058 }
1059 None => {
1060 guard.credential_published_at_millis.remove(lease_key);
1061 }
1062 }
1063 emit_audit(
1064 lease_key,
1065 "restore_auth_lifecycle_snapshot",
1066 from_phase,
1067 phase,
1068 );
1069 Ok(())
1070 }
1071
1072 fn snapshot(&self, lease_key: &LeaseKey) -> AuthLeaseSnapshot {
1073 let guard = self
1074 .machines
1075 .lock()
1076 .unwrap_or_else(std::sync::PoisonError::into_inner);
1077 let generation = guard.generations.get(lease_key).copied().unwrap_or(0);
1078 match guard.authorities.get(lease_key) {
1079 Some(machine) => AuthLeaseSnapshot {
1080 phase: Some(map_phase(machine.state.lifecycle_phase)),
1081 expires_at: machine.state.expires_at,
1082 credential_present: machine.state.credential_present,
1083 generation,
1084 credential_published_at_millis: machine
1085 .state
1086 .credential_present
1087 .then(|| guard.credential_published_at_millis.get(lease_key).copied())
1088 .flatten(),
1089 },
1090 None => AuthLeaseSnapshot {
1091 phase: None,
1092 expires_at: None,
1093 credential_present: false,
1094 generation,
1095 credential_published_at_millis: None,
1096 },
1097 }
1098 }
1099}
1100
1101#[cfg(test)]
1102#[allow(clippy::unwrap_used, clippy::expect_used)]
1103mod tests {
1104 use super::*;
1105 use meerkat_core::connection::{BindingId, RealmId};
1106
1107 fn lease(realm: &str, binding: &str) -> LeaseKey {
1108 LeaseKey::new(
1109 RealmId::parse(realm).expect("valid realm"),
1110 BindingId::parse(binding).expect("valid binding"),
1111 None,
1112 )
1113 }
1114
1115 #[test]
1116 fn acquire_and_snapshot_roundtrip() {
1117 let h = RuntimeAuthLeaseHandle::new();
1118 let key = lease("dev", "default_openai");
1119 h.acquire_lease(&key, 1_800_000_000).unwrap();
1120 let snap = h.snapshot(&key);
1121 assert_eq!(snap.phase, Some(AuthLeasePhase::Valid));
1122 assert_eq!(snap.expires_at, Some(1_800_000_000));
1123 }
1124
1125 #[test]
1126 fn lifecycle_transitions() {
1127 let h = RuntimeAuthLeaseHandle::new();
1128 let k = lease("dev", "default_anthropic");
1129
1130 h.acquire_lease(&k, 1_800_000_000).unwrap();
1131 assert_eq!(h.snapshot(&k).phase, Some(AuthLeasePhase::Valid));
1132
1133 h.mark_expiring(&k).unwrap();
1134 assert_eq!(h.snapshot(&k).phase, Some(AuthLeasePhase::Expiring));
1135
1136 h.begin_refresh(&k).unwrap();
1137 assert_eq!(h.snapshot(&k).phase, Some(AuthLeasePhase::Refreshing));
1138
1139 h.complete_refresh(&k, 1_800_000_900, 1_800_000_000)
1140 .unwrap();
1141 let snap = h.snapshot(&k);
1142 assert_eq!(snap.phase, Some(AuthLeasePhase::Valid));
1143 assert_eq!(snap.expires_at, Some(1_800_000_900));
1144 }
1145
1146 #[test]
1147 fn refresh_failed_permanent_routes_to_reauth() {
1148 let h = RuntimeAuthLeaseHandle::new();
1149 let k = lease("dev", "default_google");
1150
1151 h.acquire_lease(&k, 1_800_000_000).unwrap();
1152 h.begin_refresh(&k).unwrap();
1153 h.refresh_failed(&k, true).unwrap();
1154
1155 assert_eq!(h.snapshot(&k).phase, Some(AuthLeasePhase::ReauthRequired));
1156 }
1157
1158 #[test]
1159 fn refresh_failed_transient_routes_to_expiring() {
1160 let h = RuntimeAuthLeaseHandle::new();
1161 let k = lease("dev", "foo");
1162
1163 h.acquire_lease(&k, 1_800_000_000).unwrap();
1164 h.begin_refresh(&k).unwrap();
1165 h.refresh_failed(&k, false).unwrap();
1166
1167 assert_eq!(h.snapshot(&k).phase, Some(AuthLeasePhase::Expiring));
1168 }
1169
1170 #[test]
1171 fn transient_refresh_failure_preserves_credential_marker_generation() {
1172 let h = RuntimeAuthLeaseHandle::new();
1173 let k = lease("dev", "retryable_refresh");
1174
1175 h.acquire_lease(&k, 1_800_000_000).unwrap();
1176 let before = h.snapshot(&k);
1177 h.begin_refresh(&k).unwrap();
1178 h.refresh_failed(&k, false).unwrap();
1179 let after = h.snapshot(&k);
1180
1181 assert_eq!(after.phase, Some(AuthLeasePhase::Expiring));
1182 assert_eq!(after.expires_at, before.expires_at);
1183 assert_eq!(after.generation, before.generation);
1184 assert_eq!(
1185 after.credential_published_at_millis,
1186 before.credential_published_at_millis
1187 );
1188 }
1189
1190 #[test]
1191 fn snapshot_for_unknown_binding_is_none() {
1192 let h = RuntimeAuthLeaseHandle::new();
1193 let snap = h.snapshot(&lease("dev", "never_registered"));
1194 assert!(snap.phase.is_none());
1195 assert!(snap.expires_at.is_none());
1196 }
1197
1198 #[test]
1199 fn mark_expiring_before_acquire_errors() {
1200 let h = RuntimeAuthLeaseHandle::new();
1201 let err = h.mark_expiring(&lease("dev", "ghost")).unwrap_err();
1202 assert_eq!(err.context, "AuthLeaseHandle::mark_expiring");
1203 }
1204
1205 #[test]
1206 fn release_before_acquire_is_idempotent() {
1207 let h = RuntimeAuthLeaseHandle::new();
1208 let key = lease("dev", "ghost");
1209
1210 h.release_lease(&key).unwrap();
1211
1212 let snap = h.snapshot(&key);
1213 assert!(snap.phase.is_none());
1214 assert!(snap.expires_at.is_none());
1215 }
1216
1217 #[test]
1218 fn release_does_not_remove_concurrent_reacquire() {
1219 let h = RuntimeAuthLeaseHandle::new();
1220 let key = lease("dev", "shared");
1221 h.acquire_lease(&key, 1_800_000_000).unwrap();
1222
1223 let acquire_handle = h.clone();
1224 let acquire_key = key.clone();
1225 let hook_key = key.clone();
1226 let acquired_generation = Arc::new(Mutex::new(None));
1227 let hook_generation = Arc::clone(&acquired_generation);
1228 let _hook_guard =
1229 install_release_after_accept_hook_for_test(Arc::new(move |released_key| {
1230 if released_key != &hook_key {
1231 return;
1232 }
1233 let generation = acquire_handle
1234 .acquire_lease(&acquire_key, 1_800_000_000)
1235 .unwrap()
1236 .generation;
1237 *hook_generation.lock().unwrap() = Some(generation);
1238 }));
1239
1240 h.release_lease(&key).unwrap();
1241
1242 let acquired_generation = acquired_generation
1243 .lock()
1244 .unwrap()
1245 .expect("release hook should reacquire the lease");
1246
1247 let snap = h.snapshot(&key);
1248 assert_eq!(
1249 snap.phase,
1250 Some(AuthLeasePhase::Valid),
1251 "accepted reacquire generation {acquired_generation} must remain visible after release completes; snapshot was {snap:?}"
1252 );
1253 assert_eq!(snap.expires_at, Some(1_800_000_000));
1254 assert_eq!(snap.generation, acquired_generation);
1255 }
1256
1257 #[cfg(not(target_arch = "wasm32"))]
1258 struct FailingReleaseObserver;
1259
1260 #[cfg(not(target_arch = "wasm32"))]
1261 impl AuthLeaseReleaseObserver for FailingReleaseObserver {
1262 fn auth_lease_released(
1263 &self,
1264 _released: &ReleasedOAuthFlows,
1265 ) -> Result<(), DslTransitionError> {
1266 Err(DslTransitionError::new(
1267 "test_release_observer",
1268 "injected release observer failure",
1269 ))
1270 }
1271 }
1272
1273 #[cfg(not(target_arch = "wasm32"))]
1274 #[test]
1275 fn release_observer_failure_does_not_overwrite_concurrent_reacquire() {
1276 let h = RuntimeAuthLeaseHandle::new();
1277 let key = lease("dev", "shared_failure");
1278 h.acquire_lease(&key, 1_800_000_000).unwrap();
1279
1280 let observer: Arc<dyn AuthLeaseReleaseObserver> = Arc::new(FailingReleaseObserver);
1281 h.add_release_observer(Arc::downgrade(&observer));
1282
1283 let acquire_handle = h.clone();
1284 let acquire_key = key.clone();
1285 let hook_key = key.clone();
1286 let acquired_transition = Arc::new(Mutex::new(None));
1287 let hook_transition = Arc::clone(&acquired_transition);
1288 let _hook_guard =
1289 install_release_after_accept_hook_for_test(Arc::new(move |released_key| {
1290 if released_key != &hook_key {
1291 return;
1292 }
1293 let transition = acquire_handle
1294 .acquire_lease(&acquire_key, 1_900_000_000)
1295 .unwrap();
1296 *hook_transition.lock().unwrap() = Some(transition);
1297 }));
1298
1299 assert!(h.release_lease(&key).is_err());
1300
1301 let acquired_transition = acquired_transition
1302 .lock()
1303 .unwrap()
1304 .expect("release hook should reacquire the lease");
1305 let snap = h.snapshot(&key);
1306 assert_eq!(snap.phase, Some(AuthLeasePhase::Valid));
1307 assert_eq!(
1308 snap.expires_at,
1309 Some(1_900_000_000),
1310 "observer-failure rollback must not restore the stale released credential snapshot"
1311 );
1312 assert_eq!(snap.generation, acquired_transition.generation);
1313 assert_eq!(
1314 snap.credential_published_at_millis,
1315 acquired_transition.credential_published_at_millis
1316 );
1317 }
1318
1319 #[cfg(not(target_arch = "wasm32"))]
1320 #[test]
1321 fn release_observer_failure_does_not_resurrect_after_newer_release() {
1322 let h = RuntimeAuthLeaseHandle::new();
1323 let key = lease("dev", "shared_newer_release");
1324 h.acquire_lease(&key, 1_800_000_000).unwrap();
1325
1326 let observer: Arc<dyn AuthLeaseReleaseObserver> = Arc::new(FailingReleaseObserver);
1327 h.add_release_observer(Arc::downgrade(&observer));
1328
1329 let lifecycle_handle = h.clone();
1330 let lifecycle_key = key.clone();
1331 let hook_key = key.clone();
1332 let acquired_transition = Arc::new(Mutex::new(None));
1333 let hook_transition = Arc::clone(&acquired_transition);
1334 let _hook_guard =
1335 install_release_after_accept_hook_for_test(Arc::new(move |released_key| {
1336 if released_key != &hook_key {
1337 return;
1338 }
1339 let transition = lifecycle_handle
1340 .acquire_lease(&lifecycle_key, 1_900_000_000)
1341 .unwrap();
1342 lifecycle_handle
1343 .release_credential_lifecycle(&lifecycle_key)
1344 .unwrap();
1345 *hook_transition.lock().unwrap() = Some(transition);
1346 }));
1347
1348 assert!(h.release_lease(&key).is_err());
1349
1350 let acquired_transition = acquired_transition
1351 .lock()
1352 .unwrap()
1353 .expect("release hook should reacquire before newer release");
1354 let snap = h.snapshot(&key);
1355 assert_eq!(
1356 snap.phase, None,
1357 "older observer-failure rollback must not resurrect stale credential state after a newer release"
1358 );
1359 assert_eq!(snap.expires_at, None);
1360 assert_eq!(snap.generation, acquired_transition.generation);
1361 assert_eq!(snap.credential_published_at_millis, None);
1362 }
1363
1364 #[test]
1365 fn repeated_acquire_updates_existing_lease() {
1366 let h = RuntimeAuthLeaseHandle::new();
1367 let key = lease("dev", "default");
1368
1369 h.acquire_lease(&key, 1_800_000_000).unwrap();
1370 h.acquire_lease(&key, 1_900_000_000).unwrap();
1371
1372 let snap = h.snapshot(&key);
1373 assert_eq!(snap.phase, Some(AuthLeasePhase::Valid));
1374 assert_eq!(snap.expires_at, Some(1_900_000_000));
1375 }
1376
1377 #[test]
1378 fn restore_snapshot_preserves_publication_marker_without_lowering_generation() {
1379 let h = RuntimeAuthLeaseHandle::new();
1380 let key = lease("dev", "shared");
1381
1382 h.acquire_lease(&key, 1_800_000_000).unwrap();
1383 let before = h.snapshot(&key);
1384 assert_eq!(before.phase, Some(AuthLeasePhase::Valid));
1385 assert!(before.credential_present);
1386 assert!(before.credential_published_at_millis.is_some());
1387
1388 h.acquire_lease(&key, 1_900_000_000).unwrap();
1389 let advanced = h.snapshot(&key);
1390 assert!(advanced.generation > before.generation);
1391
1392 h.restore_auth_lifecycle_snapshot(&key, &before, before.expires_at)
1393 .unwrap();
1394
1395 let restored = h.snapshot(&key);
1396 assert_eq!(restored.phase, before.phase);
1397 assert_eq!(restored.expires_at, before.expires_at);
1398 assert_eq!(restored.credential_present, before.credential_present);
1399 assert_eq!(
1400 restored.credential_published_at_millis,
1401 before.credential_published_at_millis
1402 );
1403 assert_eq!(restored.generation, advanced.generation);
1404 }
1405
1406 #[test]
1407 fn restore_empty_zero_generation_snapshot_clears_runtime_authority() {
1408 let h = RuntimeAuthLeaseHandle::new();
1409 let key = lease("dev", "shared");
1410 h.acquire_lease(&key, 1_800_000_000).unwrap();
1411
1412 h.restore_auth_lifecycle_snapshot(
1413 &key,
1414 &AuthLeaseSnapshot {
1415 phase: None,
1416 expires_at: None,
1417 credential_present: false,
1418 generation: 0,
1419 credential_published_at_millis: None,
1420 },
1421 None,
1422 )
1423 .unwrap();
1424
1425 let restored = h.snapshot(&key);
1426 assert_eq!(restored.phase, None);
1427 assert_eq!(restored.expires_at, None);
1428 assert!(!restored.credential_present);
1429 assert_eq!(restored.generation, 0);
1430 assert_eq!(restored.credential_published_at_millis, None);
1431 }
1432
1433 #[test]
1434 fn per_binding_isolation() {
1435 let h = RuntimeAuthLeaseHandle::new();
1436 let openai = lease("dev", "openai");
1437 let anthropic = lease("dev", "anthropic");
1438 h.acquire_lease(&openai, 1_800_000_000).unwrap();
1439 h.acquire_lease(&anthropic, 1_900_000_000).unwrap();
1440 h.mark_expiring(&openai).unwrap();
1441
1442 assert_eq!(h.snapshot(&openai).phase, Some(AuthLeasePhase::Expiring));
1443 assert_eq!(h.snapshot(&anthropic).phase, Some(AuthLeasePhase::Valid));
1444 assert_eq!(h.snapshot(&anthropic).expires_at, Some(1_900_000_000));
1445 }
1446}