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::RefreshFailureObservation;
26use meerkat_core::auth::TokenKey;
27use meerkat_core::generated::auth_lease_durable_lifecycle_marker::AuthLeaseDurableRestorePublication;
28use meerkat_core::handles::{
29 AuthLeaseHandle, AuthLeasePhase, AuthLeaseRestoreSnapshot, AuthLeaseSnapshot,
30 AuthLeaseTransition, DslTransitionError, LeaseKey,
31};
32use meerkat_core::time_compat::{SystemTime, UNIX_EPOCH};
33
34use crate::auth_machine::dsl as auth_dsl;
35
36fn current_time_millis() -> u64 {
37 SystemTime::now()
38 .duration_since(UNIX_EPOCH)
39 .map(|duration| u64::try_from(duration.as_millis()).unwrap_or(u64::MAX))
40 .unwrap_or(0)
41}
42
43fn emit_audit(
55 lease_key: &LeaseKey,
56 action: &'static str,
57 from_phase: AuthLeasePhase,
58 to_phase: AuthLeasePhase,
59) {
60 tracing::info!(
61 target: "meerkat::auth::audit",
62 lease_key = %lease_key,
63 realm = %lease_key.realm,
64 binding = %lease_key.binding,
65 profile = lease_key.profile.as_ref().map(meerkat_core::ProfileId::as_str),
66 action = %action,
67 from_phase = ?from_phase,
68 to_phase = ?to_phase,
69 "auth lease transition"
70 );
71}
72
73#[derive(Clone)]
80pub struct RuntimeAuthLeaseHandle {
81 machines: Arc<Mutex<AuthLeaseRegistry>>,
82 #[cfg(not(target_arch = "wasm32"))]
83 release_observers: Arc<Mutex<Vec<Weak<dyn AuthLeaseReleaseObserver>>>>,
84}
85
86#[cfg(not(target_arch = "wasm32"))]
87#[derive(Debug, Clone)]
88pub(crate) struct ReleasedOAuthFlows {
89 pub lease_key: LeaseKey,
90 pub browser_flow_ids: Vec<String>,
91 pub device_flow_ids: Vec<String>,
92}
93
94#[cfg(not(target_arch = "wasm32"))]
95impl ReleasedOAuthFlows {
96 fn empty(lease_key: LeaseKey) -> Self {
97 Self {
98 lease_key,
99 browser_flow_ids: Vec::new(),
100 device_flow_ids: Vec::new(),
101 }
102 }
103
104 fn dedup(&mut self) {
105 self.browser_flow_ids.sort();
106 self.browser_flow_ids.dedup();
107 self.device_flow_ids.sort();
108 self.device_flow_ids.dedup();
109 }
110}
111
112fn restore_phase_to_dsl(phase: AuthLeasePhase) -> auth_dsl::AuthLifecyclePhase {
113 match phase {
114 AuthLeasePhase::Valid => auth_dsl::AuthLifecyclePhase::Valid,
115 AuthLeasePhase::Expiring => auth_dsl::AuthLifecyclePhase::Expiring,
116 AuthLeasePhase::Expired => auth_dsl::AuthLifecyclePhase::Expired,
117 AuthLeasePhase::Refreshing => auth_dsl::AuthLifecyclePhase::Refreshing,
118 AuthLeasePhase::ReauthRequired => auth_dsl::AuthLifecyclePhase::ReauthRequired,
119 AuthLeasePhase::Released => auth_dsl::AuthLifecyclePhase::Released,
120 }
121}
122
123fn restore_input_from_lifecycle(
124 lifecycle_phase: auth_dsl::AuthLifecyclePhase,
125 expires_at: Option<u64>,
126 last_refresh: Option<u64>,
127 refresh_attempt: u64,
128 credential_present: bool,
129 credential_generation: u64,
130 credential_published_at_millis: Option<u64>,
131) -> auth_dsl::AuthMachineInput {
132 auth_dsl::AuthMachineInput::RestoreAuthoritySnapshot {
133 lifecycle_phase,
134 expires_at,
135 last_refresh,
136 refresh_attempt,
137 credential_present,
138 credential_generation,
139 credential_published_at_millis,
140 }
141}
142
143#[allow(clippy::too_many_arguments)]
144fn restore_credential_lifecycle_snapshot_input(
145 lifecycle_phase: Option<auth_dsl::AuthLifecyclePhase>,
146 expires_at: Option<u64>,
147 last_refresh: Option<u64>,
148 refresh_attempt: u64,
149 credential_present: bool,
150 credential_generation: u64,
151 credential_published_at_millis: Option<u64>,
152 restored_oauth_membership_observed: bool,
153) -> auth_dsl::AuthMachineInput {
154 auth_dsl::AuthMachineInput::RestoreCredentialLifecycleSnapshot {
155 lifecycle_phase,
156 expires_at,
157 last_refresh,
158 refresh_attempt,
159 credential_present,
160 credential_generation,
161 credential_published_at_millis,
162 restored_oauth_membership_observed,
163 }
164}
165
166#[cfg(test)]
170fn append_restore_oauth_inputs_from_state(
171 inputs: &mut Vec<auth_dsl::AuthMachineInput>,
172 poll_inputs: &mut Vec<auth_dsl::AuthMachineInput>,
173 state: &auth_dsl::AuthMachineState,
174) {
175 for flow_id in &state.oauth_browser_flow_ids {
176 inputs.push(auth_dsl::AuthMachineInput::RestoreOAuthBrowserFlow {
177 flow_id: flow_id.clone(),
178 provider: state.oauth_browser_flow_providers.get(flow_id).cloned(),
179 redirect_uri: state.oauth_browser_flow_redirect_uris.get(flow_id).cloned(),
180 expires_at_millis: state
181 .oauth_browser_flow_expires_at_millis
182 .get(flow_id)
183 .copied(),
184 });
185 }
186 for flow_id in &state.oauth_device_flow_ids {
187 inputs.push(auth_dsl::AuthMachineInput::RestoreOAuthDeviceFlow {
188 flow_id: flow_id.clone(),
189 provider: state.oauth_device_flow_providers.get(flow_id).cloned(),
190 expires_at_millis: state
191 .oauth_device_flow_expires_at_millis
192 .get(flow_id)
193 .copied(),
194 });
195 }
196 for poll_id in &state.oauth_device_poll_ids {
197 poll_inputs.push(auth_dsl::AuthMachineInput::RestoreOAuthDevicePoll {
198 flow_id: poll_id.clone(),
199 });
200 }
201}
202
203#[cfg(test)]
207fn restore_oauth_inputs_from_states(
208 states: &[&auth_dsl::AuthMachineState],
209) -> Vec<auth_dsl::AuthMachineInput> {
210 let mut inputs = Vec::new();
211 let mut poll_inputs = Vec::new();
212 for state in states {
213 append_restore_oauth_inputs_from_state(&mut inputs, &mut poll_inputs, state);
214 }
215 inputs.extend(poll_inputs);
216 inputs
217}
218
219#[cfg(test)]
223fn restore_oauth_membership_observed(states: &[&auth_dsl::AuthMachineState]) -> bool {
224 states
225 .iter()
226 .any(|state| state.oauth_outstanding_flow_count > 0)
227}
228
229fn map_auth_machine_error(
230 err: auth_dsl::AuthMachineTransitionError,
231 context: &'static str,
232) -> DslTransitionError {
233 let reason = err.to_string();
234 match err {
235 auth_dsl::AuthMachineTransitionError::GuardRejected { .. } => {
236 DslTransitionError::guard_rejected(context, reason)
237 }
238 auth_dsl::AuthMachineTransitionError::NoMatchingTransition { .. } => {
239 DslTransitionError::no_matching(context, reason)
240 }
241 auth_dsl::AuthMachineTransitionError::RecoveredStateInvariantRejected { .. } => {
242 DslTransitionError::recovered_state_invariant_rejected(context, reason)
243 }
244 }
245}
246
247fn apply_restore_input(
248 authority: &mut auth_dsl::AuthMachineAuthority,
249 lease_key: &LeaseKey,
250 input: auth_dsl::AuthMachineInput,
251 context: &'static str,
252) -> Result<(AuthLeasePhase, AuthLeaseTransition), DslTransitionError> {
253 let transition = auth_dsl::AuthMachineMutator::apply(authority, input)
254 .map_err(|err| map_auth_machine_error(err, context))?;
255 let auth_transition = auth_lease_transition_from_generated_publication(
256 lease_key,
257 authority,
258 &transition,
259 context,
260 )?;
261 Ok((
262 map_phase(authority.state().lifecycle_phase),
263 auth_transition,
264 ))
265}
266
267fn apply_restore_input_to_registry(
268 registry: &mut AuthLeaseRegistry,
269 lease_key: &LeaseKey,
270 input: auth_dsl::AuthMachineInput,
271 context: &'static str,
272) -> Result<(AuthLeasePhase, AuthLeaseTransition), DslTransitionError> {
273 let authority = registry
274 .authorities
275 .entry(lease_key.clone())
276 .or_insert_with(auth_dsl::AuthMachineAuthority::new);
277 apply_restore_input(authority, lease_key, input, context)
278}
279
280#[cfg(test)]
284fn restore_authority_from_registry(
285 registry: &AuthLeaseRegistry,
286 lease_key: &LeaseKey,
287 context: &'static str,
288) -> Result<auth_dsl::AuthMachineAuthority, DslTransitionError> {
289 match registry.authorities.get(lease_key) {
290 Some(authority) => {
291 auth_dsl::AuthMachineAuthority::recover_from_state(authority.state().clone())
292 .map_err(|err| map_auth_machine_error(err, context))
293 }
294 None => Ok(auth_dsl::AuthMachineAuthority::new()),
295 }
296}
297
298#[cfg(test)]
302fn apply_restore_inputs_to_registry(
303 registry: &mut AuthLeaseRegistry,
304 lease_key: &LeaseKey,
305 lifecycle_input: auth_dsl::AuthMachineInput,
306 oauth_inputs: Vec<auth_dsl::AuthMachineInput>,
307 context: &'static str,
308) -> Result<(AuthLeasePhase, AuthLeaseTransition), DslTransitionError> {
309 let mut authority = restore_authority_from_registry(registry, lease_key, context)?;
310 let restored = apply_restore_input(&mut authority, lease_key, lifecycle_input, context)?;
311 for input in oauth_inputs {
312 apply_restore_input(&mut authority, lease_key, input, context)?;
313 }
314 registry.authorities.insert(lease_key.clone(), authority);
315 Ok(restored)
316}
317
318#[cfg(test)]
322fn restore_state_to_registry(
323 registry: &mut AuthLeaseRegistry,
324 lease_key: &LeaseKey,
325 state: &auth_dsl::AuthMachineState,
326 context: &'static str,
327) -> Result<(AuthLeasePhase, AuthLeaseTransition), DslTransitionError> {
328 restore_state_with_oauth_sources_to_registry(registry, lease_key, state, &[state], context)
329}
330
331#[cfg(test)]
335fn restore_state_with_oauth_sources_to_registry(
336 registry: &mut AuthLeaseRegistry,
337 lease_key: &LeaseKey,
338 lifecycle_state: &auth_dsl::AuthMachineState,
339 oauth_sources: &[&auth_dsl::AuthMachineState],
340 context: &'static str,
341) -> Result<(AuthLeasePhase, AuthLeaseTransition), DslTransitionError> {
342 let oauth_inputs = restore_oauth_inputs_from_states(oauth_sources);
343 apply_restore_inputs_to_registry(
344 registry,
345 lease_key,
346 restore_credential_lifecycle_snapshot_input(
347 Some(lifecycle_state.lifecycle_phase),
348 lifecycle_state.expires_at,
349 lifecycle_state.last_refresh,
350 lifecycle_state.refresh_attempt,
351 lifecycle_state.credential_present,
352 lifecycle_state.credential_generation,
353 lifecycle_state.credential_published_at_millis,
354 restore_oauth_membership_observed(oauth_sources),
355 ),
356 oauth_inputs,
357 context,
358 )
359}
360
361fn auth_lease_transition_from_generated_publication(
362 lease_key: &LeaseKey,
363 authority: &auth_dsl::AuthMachineAuthority,
364 transition: &auth_dsl::AuthMachineTransition,
365 context: &'static str,
366) -> Result<AuthLeaseTransition, DslTransitionError> {
367 match maybe_auth_lease_transition_from_generated_publication(
368 lease_key, authority, transition, context,
369 )? {
370 Some(transition) => Ok(transition),
371 None => Err(DslTransitionError::no_matching(
372 context,
373 "AuthMachine transition emitted no lifecycle publication obligation",
374 )),
375 }
376}
377
378fn maybe_auth_lease_transition_from_generated_publication(
379 lease_key: &LeaseKey,
380 authority: &auth_dsl::AuthMachineAuthority,
381 transition: &auth_dsl::AuthMachineTransition,
382 context: &'static str,
383) -> Result<Option<AuthLeaseTransition>, DslTransitionError> {
384 let mut obligations =
385 crate::protocol_auth_lease_lifecycle_publication::extract_obligations(transition);
386 if obligations.is_empty() {
387 return Ok(None);
388 }
389 if obligations.len() != 1 {
390 return Err(DslTransitionError::no_matching(
391 context,
392 format!(
393 "AuthMachine transition emitted {} lifecycle publication obligations",
394 obligations.len()
395 ),
396 ));
397 }
398 let scope =
399 crate::protocol_auth_lease_lifecycle_publication::AuthLeaseLifecyclePublicationScope::from_authority(
400 lease_key.clone(),
401 authority,
402 );
403 obligations
404 .remove(0)
405 .into_auth_lease_transition(scope)
406 .map(Some)
407 .map_err(|err| {
408 DslTransitionError::no_matching(
409 context,
410 format!("AuthMachine lifecycle publication handoff failed: {err}"),
411 )
412 })
413}
414
415#[cfg(not(target_arch = "wasm32"))]
416pub(crate) trait AuthLeaseReleaseObserver: Send + Sync {
417 fn begin_auth_lease_release<'a>(
418 &'a self,
419 _lease_key: &LeaseKey,
420 ) -> Result<Option<Box<dyn AuthLeaseReleasePermit + 'a>>, DslTransitionError> {
421 Ok(None)
422 }
423
424 fn oauth_flows_for_release(
425 &self,
426 lease_key: &LeaseKey,
427 ) -> Result<ReleasedOAuthFlows, DslTransitionError> {
428 Ok(ReleasedOAuthFlows::empty(lease_key.clone()))
429 }
430
431 fn auth_lease_released(&self, released: &ReleasedOAuthFlows) -> Result<(), DslTransitionError>;
432}
433
434#[cfg(not(target_arch = "wasm32"))]
435pub(crate) trait AuthLeaseReleasePermit {}
436
437#[cfg(test)]
438pub(crate) type ReleaseAfterAcceptHook = Arc<dyn Fn(&LeaseKey) + Send + Sync>;
439
440#[cfg(test)]
441static RELEASE_AFTER_ACCEPT_HOOK: std::sync::OnceLock<Mutex<Option<ReleaseAfterAcceptHook>>> =
442 std::sync::OnceLock::new();
443
444#[cfg(test)]
445static RELEASE_AFTER_ACCEPT_HOOK_SERIAL: std::sync::OnceLock<Mutex<()>> =
446 std::sync::OnceLock::new();
447
448#[cfg(test)]
449pub(crate) type ReleaseBeforeCommitHook = Arc<dyn Fn(&LeaseKey) + Send + Sync>;
450
451#[cfg(test)]
452static RELEASE_BEFORE_COMMIT_HOOK: std::sync::OnceLock<Mutex<Option<ReleaseBeforeCommitHook>>> =
453 std::sync::OnceLock::new();
454
455#[cfg(test)]
456static RELEASE_BEFORE_COMMIT_HOOK_SERIAL: std::sync::OnceLock<Mutex<()>> =
457 std::sync::OnceLock::new();
458
459#[cfg(test)]
460pub(crate) struct ReleaseAfterAcceptHookGuard {
461 _serial: std::sync::MutexGuard<'static, ()>,
462}
463
464#[cfg(test)]
465impl Drop for ReleaseAfterAcceptHookGuard {
466 fn drop(&mut self) {
467 set_release_after_accept_hook_for_test(None);
468 }
469}
470
471#[cfg(test)]
472pub(crate) struct ReleaseBeforeCommitHookGuard {
473 _serial: std::sync::MutexGuard<'static, ()>,
474}
475
476#[cfg(test)]
477impl Drop for ReleaseBeforeCommitHookGuard {
478 fn drop(&mut self) {
479 set_release_before_commit_hook_for_test(None);
480 }
481}
482
483#[cfg(test)]
484pub(crate) fn install_release_after_accept_hook_for_test(
485 hook: ReleaseAfterAcceptHook,
486) -> ReleaseAfterAcceptHookGuard {
487 let serial = RELEASE_AFTER_ACCEPT_HOOK_SERIAL
488 .get_or_init(|| Mutex::new(()))
489 .lock()
490 .unwrap_or_else(std::sync::PoisonError::into_inner);
491 set_release_after_accept_hook_for_test(Some(hook));
492 ReleaseAfterAcceptHookGuard { _serial: serial }
493}
494
495#[cfg(test)]
496pub(crate) fn install_release_before_commit_hook_for_test(
497 hook: ReleaseBeforeCommitHook,
498) -> ReleaseBeforeCommitHookGuard {
499 let serial = RELEASE_BEFORE_COMMIT_HOOK_SERIAL
500 .get_or_init(|| Mutex::new(()))
501 .lock()
502 .unwrap_or_else(std::sync::PoisonError::into_inner);
503 set_release_before_commit_hook_for_test(Some(hook));
504 ReleaseBeforeCommitHookGuard { _serial: serial }
505}
506
507#[cfg(test)]
508fn set_release_after_accept_hook_for_test(hook: Option<ReleaseAfterAcceptHook>) {
509 *RELEASE_AFTER_ACCEPT_HOOK
510 .get_or_init(|| Mutex::new(None))
511 .lock()
512 .unwrap_or_else(std::sync::PoisonError::into_inner) = hook;
513}
514
515#[cfg(test)]
516fn set_release_before_commit_hook_for_test(hook: Option<ReleaseBeforeCommitHook>) {
517 *RELEASE_BEFORE_COMMIT_HOOK
518 .get_or_init(|| Mutex::new(None))
519 .lock()
520 .unwrap_or_else(std::sync::PoisonError::into_inner) = hook;
521}
522
523#[cfg(test)]
524fn run_release_after_accept_hook(lease_key: &LeaseKey) {
525 let hook = RELEASE_AFTER_ACCEPT_HOOK
526 .get_or_init(|| Mutex::new(None))
527 .lock()
528 .unwrap_or_else(std::sync::PoisonError::into_inner)
529 .clone();
530 if let Some(hook) = hook {
531 hook(lease_key);
532 }
533}
534
535#[cfg(test)]
536fn run_release_before_commit_hook(lease_key: &LeaseKey) {
537 let hook = RELEASE_BEFORE_COMMIT_HOOK
538 .get_or_init(|| Mutex::new(None))
539 .lock()
540 .unwrap_or_else(std::sync::PoisonError::into_inner)
541 .clone();
542 if let Some(hook) = hook {
543 hook(lease_key);
544 }
545}
546
547#[derive(Default)]
548struct AuthLeaseRegistry {
549 authorities: HashMap<LeaseKey, auth_dsl::AuthMachineAuthority>,
550}
551
552impl std::fmt::Debug for RuntimeAuthLeaseHandle {
553 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
554 let guard = self
555 .machines
556 .lock()
557 .unwrap_or_else(std::sync::PoisonError::into_inner);
558 f.debug_struct("RuntimeAuthLeaseHandle")
559 .field("leases", &guard.authorities.keys().collect::<Vec<_>>())
560 .finish()
561 }
562}
563
564impl RuntimeAuthLeaseHandle {
565 pub fn new() -> Self {
566 Self {
567 machines: Arc::new(Mutex::new(AuthLeaseRegistry::default())),
568 #[cfg(not(target_arch = "wasm32"))]
569 release_observers: Arc::new(Mutex::new(Vec::new())),
570 }
571 }
572
573 pub fn ephemeral() -> Self {
579 Self::new()
580 }
581
582 #[cfg(not(target_arch = "wasm32"))]
583 pub(crate) fn add_release_observer(&self, observer: Weak<dyn AuthLeaseReleaseObserver>) {
584 self.release_observers
585 .lock()
586 .unwrap_or_else(std::sync::PoisonError::into_inner)
587 .push(observer);
588 }
589
590 #[cfg(not(target_arch = "wasm32"))]
591 fn live_release_observers(&self) -> Vec<Arc<dyn AuthLeaseReleaseObserver>> {
592 {
593 let mut guard = self
594 .release_observers
595 .lock()
596 .unwrap_or_else(std::sync::PoisonError::into_inner);
597 let mut observers = Vec::new();
598 guard.retain(|observer| match observer.upgrade() {
599 Some(observer) => {
600 observers.push(observer);
601 true
602 }
603 None => false,
604 });
605 observers
606 }
607 }
608
609 #[cfg(not(target_arch = "wasm32"))]
610 fn collect_release_observer_flows(
611 &self,
612 observers: &[Arc<dyn AuthLeaseReleaseObserver>],
613 lease_key: &LeaseKey,
614 ) -> Result<ReleasedOAuthFlows, DslTransitionError> {
615 let mut released = ReleasedOAuthFlows::empty(lease_key.clone());
616 for observer in observers {
617 let mut observed = observer.oauth_flows_for_release(lease_key)?;
618 released
619 .browser_flow_ids
620 .append(&mut observed.browser_flow_ids);
621 released
622 .device_flow_ids
623 .append(&mut observed.device_flow_ids);
624 }
625 released.dedup();
626 Ok(released)
627 }
628
629 #[cfg(not(target_arch = "wasm32"))]
630 fn notify_release_observers(
631 &self,
632 observers: &[Arc<dyn AuthLeaseReleaseObserver>],
633 released: &ReleasedOAuthFlows,
634 ) -> Result<(), DslTransitionError> {
635 for observer in observers {
636 observer.auth_lease_released(released)?;
637 }
638 Ok(())
639 }
640
641 fn apply(
642 &self,
643 lease_key: &LeaseKey,
644 input: auth_dsl::AuthMachineInput,
645 context: &'static str,
646 create_if_missing: bool,
647 ) -> Result<AuthLeaseTransition, DslTransitionError> {
648 let action = Self::audit_action_for(&input);
649 let mut guard = self
650 .machines
651 .lock()
652 .unwrap_or_else(std::sync::PoisonError::into_inner);
653 if !create_if_missing && !guard.authorities.contains_key(lease_key) {
654 return Err(DslTransitionError::no_matching(
655 context,
656 format!("no auth lease registered for lease_key `{lease_key}`"),
657 ));
658 }
659 let (from_phase, to_phase, auth_transition) = {
660 let entry = if create_if_missing {
661 guard
662 .authorities
663 .entry(lease_key.clone())
664 .or_insert_with(auth_dsl::AuthMachineAuthority::new)
665 } else {
666 match guard.authorities.get_mut(lease_key) {
667 Some(m) => m,
668 None => {
669 return Err(DslTransitionError::no_matching(
670 context,
671 format!("no auth lease registered for lease_key `{lease_key}`"),
672 ));
673 }
674 }
675 };
676 let from_phase = map_phase(entry.state().lifecycle_phase);
677 let transition = auth_dsl::AuthMachineMutator::apply(entry, input)
678 .map_err(|err| map_auth_machine_error(err, context))?;
679 let auth_transition = auth_lease_transition_from_generated_publication(
680 lease_key,
681 entry,
682 &transition,
683 context,
684 )?;
685 let to_phase = map_phase(entry.state().lifecycle_phase);
686 (from_phase, to_phase, auth_transition)
687 };
688 emit_audit(lease_key, action, from_phase, to_phase);
689 Ok(auth_transition)
690 }
691
692 #[cfg(not(target_arch = "wasm32"))]
693 fn oauth_global_outstanding_flow_count(registry: &AuthLeaseRegistry) -> u64 {
694 registry
695 .authorities
696 .values()
697 .map(|authority| authority.state().oauth_outstanding_flow_count)
698 .fold(0u64, u64::saturating_add)
699 }
700
701 #[cfg(not(target_arch = "wasm32"))]
702 fn attach_oauth_global_observation(
703 input: auth_dsl::AuthMachineInput,
704 observed_global_outstanding_flows: u64,
705 ) -> auth_dsl::AuthMachineInput {
706 match input {
707 auth_dsl::AuthMachineInput::AdmitOAuthBrowserFlow {
708 flow_id,
709 provider,
710 redirect_uri,
711 expires_at_millis,
712 max_outstanding_flows,
713 ..
714 } => auth_dsl::AuthMachineInput::AdmitOAuthBrowserFlow {
715 flow_id,
716 provider,
717 redirect_uri,
718 expires_at_millis,
719 max_outstanding_flows,
720 observed_global_outstanding_flows,
721 },
722 auth_dsl::AuthMachineInput::AdmitOAuthDeviceFlow {
723 flow_id,
724 provider,
725 expires_at_millis,
726 max_outstanding_flows,
727 ..
728 } => auth_dsl::AuthMachineInput::AdmitOAuthDeviceFlow {
729 flow_id,
730 provider,
731 expires_at_millis,
732 max_outstanding_flows,
733 observed_global_outstanding_flows,
734 },
735 other => other,
736 }
737 }
738
739 #[cfg(not(target_arch = "wasm32"))]
740 pub(crate) fn apply_oauth_input(
741 &self,
742 target: &AuthBindingRef,
743 input: auth_dsl::AuthMachineInput,
744 context: &'static str,
745 create_if_missing: bool,
746 ) -> Result<(), DslTransitionError> {
747 let lease_key = LeaseKey::from_auth_binding(target);
748 let mut guard = self
749 .machines
750 .lock()
751 .unwrap_or_else(std::sync::PoisonError::into_inner);
752 let input = Self::attach_oauth_global_observation(
753 input,
754 Self::oauth_global_outstanding_flow_count(&guard),
755 );
756 let action = Self::audit_action_for(&input);
757 if create_if_missing && !guard.authorities.contains_key(&lease_key) {
758 let mut authority = auth_dsl::AuthMachineAuthority::new();
759 let transition = auth_dsl::AuthMachineMutator::apply(
760 &mut authority,
761 auth_dsl::AuthMachineInput::MarkReauthRequired,
762 )
763 .map_err(|err| map_auth_machine_error(err, context))?;
764 auth_lease_transition_from_generated_publication(
765 &lease_key,
766 &authority,
767 &transition,
768 context,
769 )?;
770 guard.authorities.insert(lease_key.clone(), authority);
771 }
772 let (from_phase, to_phase) = {
773 let entry = match guard.authorities.get_mut(&lease_key) {
774 Some(m) => m,
775 None => {
776 return Err(DslTransitionError::no_matching(
777 context,
778 format!("no auth machine registered for lease_key `{lease_key}`"),
779 ));
780 }
781 };
782 let from_phase = map_phase(entry.state().lifecycle_phase);
783 let transition = auth_dsl::AuthMachineMutator::apply(entry, input)
784 .map_err(|err| map_auth_machine_error(err, context))?;
785 maybe_auth_lease_transition_from_generated_publication(
786 &lease_key,
787 entry,
788 &transition,
789 context,
790 )?;
791 let to_phase = map_phase(entry.state().lifecycle_phase);
792 (from_phase, to_phase)
793 };
794 emit_audit(&lease_key, action, from_phase, to_phase);
795 Ok(())
796 }
797
798 #[cfg(not(target_arch = "wasm32"))]
799 pub(crate) fn confirm_oauth_durable_admission(
800 &self,
801 target: &AuthBindingRef,
802 observed_global_outstanding_flows: u64,
803 max_outstanding_flows: u64,
804 context: &'static str,
805 ) -> Result<(), DslTransitionError> {
806 let lease_key = LeaseKey::from_auth_binding(target);
807 let input = auth_dsl::AuthMachineInput::ConfirmOAuthDurableAdmission {
808 observed_global_outstanding_flows,
809 max_outstanding_flows,
810 };
811 let mut guard = self
812 .machines
813 .lock()
814 .unwrap_or_else(std::sync::PoisonError::into_inner);
815 let (from_phase, to_phase) = {
816 let entry = match guard.authorities.get_mut(&lease_key) {
817 Some(m) => m,
818 None => {
819 return Err(DslTransitionError::no_matching(
820 context,
821 format!("no auth machine registered for lease_key `{lease_key}`"),
822 ));
823 }
824 };
825 let from_phase = map_phase(entry.state().lifecycle_phase);
826 let transition = auth_dsl::AuthMachineMutator::apply(entry, input)
827 .map_err(|err| map_auth_machine_error(err, context))?;
828 maybe_auth_lease_transition_from_generated_publication(
829 &lease_key,
830 entry,
831 &transition,
832 context,
833 )?;
834 let to_phase = map_phase(entry.state().lifecycle_phase);
835 (from_phase, to_phase)
836 };
837 emit_audit(
838 &lease_key,
839 "confirm_oauth_durable_admission",
840 from_phase,
841 to_phase,
842 );
843 Ok(())
844 }
845
846 #[cfg(not(target_arch = "wasm32"))]
847 pub(crate) fn has_oauth_browser_flow(&self, target: &AuthBindingRef, flow_id: &str) -> bool {
848 let lease_key = LeaseKey::from_auth_binding(target);
849 self.machines
850 .lock()
851 .unwrap_or_else(std::sync::PoisonError::into_inner)
852 .authorities
853 .get(&lease_key)
854 .is_some_and(|authority| authority.state().oauth_browser_flow_ids.contains(flow_id))
855 }
856
857 #[cfg(not(target_arch = "wasm32"))]
858 pub(crate) fn has_oauth_device_flow(&self, target: &AuthBindingRef, flow_id: &str) -> bool {
859 let lease_key = LeaseKey::from_auth_binding(target);
860 self.machines
861 .lock()
862 .unwrap_or_else(std::sync::PoisonError::into_inner)
863 .authorities
864 .get(&lease_key)
865 .is_some_and(|authority| authority.state().oauth_device_flow_ids.contains(flow_id))
866 }
867
868 #[cfg(test)]
869 pub(crate) fn has_oauth_browser_flow_for_test(
870 &self,
871 target: &AuthBindingRef,
872 flow_id: &str,
873 ) -> bool {
874 self.has_oauth_browser_flow(target, flow_id)
875 }
876
877 #[cfg(test)]
878 pub(crate) fn has_oauth_device_flow_for_test(
879 &self,
880 target: &AuthBindingRef,
881 flow_id: &str,
882 ) -> bool {
883 self.has_oauth_device_flow(target, flow_id)
884 }
885
886 fn audit_action_for(input: &auth_dsl::AuthMachineInput) -> &'static str {
887 match input {
888 auth_dsl::AuthMachineInput::Acquire { .. } => "acquire_lease",
889 auth_dsl::AuthMachineInput::MarkExpiring => "mark_expiring",
890 auth_dsl::AuthMachineInput::ObserveCredentialFreshness { .. } => {
891 "observe_credential_freshness"
892 }
893 auth_dsl::AuthMachineInput::BeginRefresh => "begin_refresh",
894 auth_dsl::AuthMachineInput::CompleteRefresh { .. } => "complete_refresh",
895 auth_dsl::AuthMachineInput::RefreshFailed { .. } => "refresh_failed",
896 auth_dsl::AuthMachineInput::MarkReauthRequired => "mark_reauth_required",
897 auth_dsl::AuthMachineInput::ClearCredentialLifecycle => "clear_credential_lifecycle",
898 auth_dsl::AuthMachineInput::ReleaseCredentialLifecycle => {
899 "release_credential_lifecycle"
900 }
901 auth_dsl::AuthMachineInput::BeginRelease => "begin_release_lease",
902 auth_dsl::AuthMachineInput::Release => "release_lease",
903 auth_dsl::AuthMachineInput::RestoreAuthoritySnapshot { .. } => {
904 "restore_authority_snapshot"
905 }
906 auth_dsl::AuthMachineInput::RestoreCredentialLifecycleSnapshot { .. } => {
907 "restore_credential_lifecycle_snapshot"
908 }
909 auth_dsl::AuthMachineInput::RestoreOAuthBrowserFlow { .. } => {
910 "restore_oauth_browser_flow"
911 }
912 auth_dsl::AuthMachineInput::RestoreOAuthDeviceFlow { .. } => {
913 "restore_oauth_device_flow"
914 }
915 auth_dsl::AuthMachineInput::RestoreOAuthDevicePoll { .. } => {
916 "restore_oauth_device_poll"
917 }
918 auth_dsl::AuthMachineInput::AdmitOAuthBrowserFlow { .. } => "admit_oauth_browser_flow",
919 auth_dsl::AuthMachineInput::VerifyOAuthBrowserFlow { .. } => {
920 "verify_oauth_browser_flow"
921 }
922 auth_dsl::AuthMachineInput::ConsumeOAuthBrowserFlow { .. } => {
923 "consume_oauth_browser_flow"
924 }
925 auth_dsl::AuthMachineInput::ExpireOAuthBrowserFlow { .. } => {
926 "expire_oauth_browser_flow"
927 }
928 auth_dsl::AuthMachineInput::AdmitOAuthDeviceFlow { .. } => "admit_oauth_device_flow",
929 auth_dsl::AuthMachineInput::ConfirmOAuthDurableAdmission { .. } => {
930 "confirm_oauth_durable_admission"
931 }
932 auth_dsl::AuthMachineInput::VerifyOAuthDeviceFlow { .. } => "verify_oauth_device_flow",
933 auth_dsl::AuthMachineInput::BeginOAuthDevicePoll { .. } => "begin_oauth_device_poll",
934 auth_dsl::AuthMachineInput::FinishOAuthDevicePoll { .. } => "finish_oauth_device_poll",
935 auth_dsl::AuthMachineInput::ConsumeOAuthDeviceFlow { .. } => {
936 "consume_oauth_device_flow"
937 }
938 auth_dsl::AuthMachineInput::ExpireOAuthDeviceFlow { .. } => "expire_oauth_device_flow",
939 auth_dsl::AuthMachineInput::ResolveCredentialUseAdmission { .. } => {
940 "resolve_credential_use_admission"
941 }
942 auth_dsl::AuthMachineInput::ResolveOAuthLoginCredentialDisposition { .. } => {
943 "resolve_oauth_login_credential_disposition"
944 }
945 }
946 }
947}
948
949fn map_phase(phase: auth_dsl::AuthLifecyclePhase) -> AuthLeasePhase {
953 match phase {
954 auth_dsl::AuthLifecyclePhase::Valid => AuthLeasePhase::Valid,
955 auth_dsl::AuthLifecyclePhase::Expiring => AuthLeasePhase::Expiring,
956 auth_dsl::AuthLifecyclePhase::Expired => AuthLeasePhase::Expired,
957 auth_dsl::AuthLifecyclePhase::Refreshing => AuthLeasePhase::Refreshing,
958 auth_dsl::AuthLifecyclePhase::ReauthRequired => AuthLeasePhase::ReauthRequired,
959 auth_dsl::AuthLifecyclePhase::Released => AuthLeasePhase::Released,
960 }
961}
962
963fn credential_use_intent_to_dsl(
964 intent: meerkat_core::handles::CredentialUseIntent,
965) -> auth_dsl::CredentialUseIntent {
966 match intent {
967 meerkat_core::handles::CredentialUseIntent::UseCredential => {
968 auth_dsl::CredentialUseIntent::UseCredential
969 }
970 meerkat_core::handles::CredentialUseIntent::HoldAuthority => {
971 auth_dsl::CredentialUseIntent::HoldAuthority
972 }
973 meerkat_core::handles::CredentialUseIntent::BeginRefresh => {
974 auth_dsl::CredentialUseIntent::BeginRefresh
975 }
976 }
977}
978
979fn credential_use_disposition_from_dsl(
980 disposition: auth_dsl::CredentialUseDisposition,
981) -> meerkat_core::handles::CredentialUseDisposition {
982 match disposition {
983 auth_dsl::CredentialUseDisposition::Authorized => {
984 meerkat_core::handles::CredentialUseDisposition::Authorized
985 }
986 auth_dsl::CredentialUseDisposition::RefreshRequired => {
987 meerkat_core::handles::CredentialUseDisposition::RefreshRequired
988 }
989 auth_dsl::CredentialUseDisposition::RefreshDisallowed => {
990 meerkat_core::handles::CredentialUseDisposition::RefreshDisallowed
991 }
992 auth_dsl::CredentialUseDisposition::ReauthRequired => {
993 meerkat_core::handles::CredentialUseDisposition::ReauthRequired
994 }
995 auth_dsl::CredentialUseDisposition::LeaseAbsent => {
996 meerkat_core::handles::CredentialUseDisposition::LeaseAbsent
997 }
998 auth_dsl::CredentialUseDisposition::AlreadyRefreshing => {
999 meerkat_core::handles::CredentialUseDisposition::AlreadyRefreshing
1000 }
1001 }
1002}
1003
1004fn restore_phase(phase: AuthLeasePhase) -> auth_dsl::AuthLifecyclePhase {
1005 match phase {
1006 AuthLeasePhase::Valid => auth_dsl::AuthLifecyclePhase::Valid,
1007 AuthLeasePhase::Expiring => auth_dsl::AuthLifecyclePhase::Expiring,
1008 AuthLeasePhase::Expired => auth_dsl::AuthLifecyclePhase::Expired,
1009 AuthLeasePhase::Refreshing => auth_dsl::AuthLifecyclePhase::Refreshing,
1010 AuthLeasePhase::ReauthRequired => auth_dsl::AuthLifecyclePhase::ReauthRequired,
1011 AuthLeasePhase::Released => auth_dsl::AuthLifecyclePhase::Released,
1012 }
1013}
1014
1015impl Default for RuntimeAuthLeaseHandle {
1016 fn default() -> Self {
1017 Self::new()
1018 }
1019}
1020
1021impl AuthLeaseHandle for RuntimeAuthLeaseHandle {
1022 fn acquire_lease(
1023 &self,
1024 lease_key: &LeaseKey,
1025 expires_at: u64,
1026 ) -> Result<AuthLeaseTransition, DslTransitionError> {
1027 let expires_at_ts = if expires_at == u64::MAX {
1028 None
1029 } else {
1030 Some(expires_at)
1031 };
1032 self.apply(
1033 lease_key,
1034 auth_dsl::AuthMachineInput::Acquire {
1035 expires_at_ts,
1036 credential_published_at_millis: current_time_millis(),
1037 },
1038 "AuthLeaseHandle::acquire_lease",
1039 true,
1040 )
1041 }
1042
1043 fn mark_expiring(&self, lease_key: &LeaseKey) -> Result<(), DslTransitionError> {
1044 self.apply(
1045 lease_key,
1046 auth_dsl::AuthMachineInput::MarkExpiring,
1047 "AuthLeaseHandle::mark_expiring",
1048 false,
1049 )
1050 .map(|_| ())
1051 }
1052
1053 fn observe_credential_freshness(
1054 &self,
1055 lease_key: &LeaseKey,
1056 now: u64,
1057 refresh_window_secs: u64,
1058 ) -> Result<(), DslTransitionError> {
1059 #[allow(clippy::unwrap_used)]
1060 if !self
1061 .machines
1062 .lock()
1063 .unwrap()
1064 .authorities
1065 .contains_key(lease_key)
1066 {
1067 return Ok(());
1068 }
1069 self.apply(
1070 lease_key,
1071 auth_dsl::AuthMachineInput::ObserveCredentialFreshness {
1072 now_ts: now,
1073 refresh_window_secs,
1074 },
1075 "AuthLeaseHandle::observe_credential_freshness",
1076 false,
1077 )
1078 .map(|_| ())
1079 }
1080
1081 fn begin_refresh(&self, lease_key: &LeaseKey) -> Result<(), DslTransitionError> {
1082 self.apply(
1083 lease_key,
1084 auth_dsl::AuthMachineInput::BeginRefresh,
1085 "AuthLeaseHandle::begin_refresh",
1086 false,
1087 )
1088 .map(|_| ())
1089 }
1090
1091 fn complete_refresh(
1092 &self,
1093 lease_key: &LeaseKey,
1094 new_expires_at: u64,
1095 now: u64,
1096 ) -> Result<AuthLeaseTransition, DslTransitionError> {
1097 let new_expires_at = if new_expires_at == u64::MAX {
1098 None
1099 } else {
1100 Some(new_expires_at)
1101 };
1102 self.apply(
1103 lease_key,
1104 auth_dsl::AuthMachineInput::CompleteRefresh {
1105 new_expires_at,
1106 now_ts: now,
1107 credential_published_at_millis: current_time_millis(),
1108 },
1109 "AuthLeaseHandle::complete_refresh",
1110 false,
1111 )
1112 }
1113
1114 fn refresh_failed(
1115 &self,
1116 lease_key: &LeaseKey,
1117 observation: RefreshFailureObservation,
1118 ) -> Result<(), DslTransitionError> {
1119 let input = auth_dsl::AuthMachineInput::RefreshFailed {
1120 http_status: observation.http_status,
1121 oauth_error_code: observation.oauth_error_code,
1122 local_credential_unusable: observation.local_credential_unusable,
1123 };
1124 self.apply(lease_key, input, "AuthLeaseHandle::refresh_failed", false)
1125 .map(|_| ())
1126 }
1127
1128 fn mark_reauth_required(&self, lease_key: &LeaseKey) -> Result<(), DslTransitionError> {
1129 self.apply(
1130 lease_key,
1131 auth_dsl::AuthMachineInput::MarkReauthRequired,
1132 "AuthLeaseHandle::mark_reauth_required",
1133 false,
1134 )
1135 .map(|_| ())
1136 }
1137
1138 fn release_lease(&self, lease_key: &LeaseKey) -> Result<(), DslTransitionError> {
1139 let context = "AuthLeaseHandle::release_lease";
1140 #[cfg(not(target_arch = "wasm32"))]
1163 let release_observers = self.live_release_observers();
1164 #[cfg(not(target_arch = "wasm32"))]
1165 let release_permits = release_observers
1166 .iter()
1167 .map(|observer| observer.begin_auth_lease_release(lease_key))
1168 .collect::<Result<Vec<_>, _>>()?;
1169 #[cfg(not(target_arch = "wasm32"))]
1170 {
1171 let machine_drain_obligation = {
1174 let mut guard = self
1175 .machines
1176 .lock()
1177 .unwrap_or_else(std::sync::PoisonError::into_inner);
1178 if let Some(entry) = guard.authorities.get_mut(lease_key) {
1179 let transition = auth_dsl::AuthMachineMutator::apply(
1180 entry,
1181 auth_dsl::AuthMachineInput::BeginRelease,
1182 )
1183 .map_err(|err| map_auth_machine_error(err, context))?;
1184 maybe_auth_lease_transition_from_generated_publication(
1188 lease_key,
1189 entry,
1190 &transition,
1191 context,
1192 )?;
1193 crate::protocol_auth_release_oauth_flow_drain::extract_obligations(&transition)
1194 .into_iter()
1195 .next()
1196 } else {
1197 None
1198 }
1199 };
1200
1201 let mut released =
1205 self.collect_release_observer_flows(&release_observers, lease_key)?;
1206 if let Some(ref obligation) = machine_drain_obligation {
1207 released
1208 .browser_flow_ids
1209 .extend(obligation.browser_flow_ids.iter().cloned());
1210 released
1211 .device_flow_ids
1212 .extend(obligation.device_flow_ids.iter().cloned());
1213 }
1214 released.dedup();
1215
1216 self.notify_release_observers(&release_observers, &released)?;
1219
1220 if let Some(obligation) = machine_drain_obligation {
1225 let mut guard = self
1226 .machines
1227 .lock()
1228 .unwrap_or_else(std::sync::PoisonError::into_inner);
1229 if let Some(entry) = guard.authorities.get_mut(lease_key) {
1230 for flow_id in obligation.browser_flow_ids {
1231 let transition = auth_dsl::AuthMachineMutator::apply(
1232 entry,
1233 auth_dsl::AuthMachineInput::ExpireOAuthBrowserFlow { flow_id },
1234 )
1235 .map_err(|err| map_auth_machine_error(err, context))?;
1236 maybe_auth_lease_transition_from_generated_publication(
1237 lease_key,
1238 entry,
1239 &transition,
1240 context,
1241 )?;
1242 }
1243 for flow_id in obligation.device_flow_ids {
1244 let transition = auth_dsl::AuthMachineMutator::apply(
1245 entry,
1246 auth_dsl::AuthMachineInput::ExpireOAuthDeviceFlow { flow_id },
1247 )
1248 .map_err(|err| map_auth_machine_error(err, context))?;
1249 maybe_auth_lease_transition_from_generated_publication(
1250 lease_key,
1251 entry,
1252 &transition,
1253 context,
1254 )?;
1255 }
1256 }
1257 }
1258 }
1259 #[cfg(test)]
1260 run_release_before_commit_hook(lease_key);
1261 let (from_phase, to_phase) = {
1267 let mut guard = self
1268 .machines
1269 .lock()
1270 .unwrap_or_else(std::sync::PoisonError::into_inner);
1271 let entry = guard
1272 .authorities
1273 .entry(lease_key.clone())
1274 .or_insert_with(auth_dsl::AuthMachineAuthority::new);
1275 let from_phase = map_phase(entry.state().lifecycle_phase);
1276 let transition =
1277 auth_dsl::AuthMachineMutator::apply(entry, auth_dsl::AuthMachineInput::Release)
1278 .map_err(|err| map_auth_machine_error(err, context))?;
1279 auth_lease_transition_from_generated_publication(
1280 lease_key,
1281 entry,
1282 &transition,
1283 context,
1284 )?;
1285 let to_phase = map_phase(entry.state().lifecycle_phase);
1286 (from_phase, to_phase)
1287 };
1288 #[cfg(not(target_arch = "wasm32"))]
1289 drop(release_permits);
1290 #[cfg(not(target_arch = "wasm32"))]
1291 drop(release_observers);
1292 emit_audit(lease_key, "release_lease", from_phase, to_phase);
1293 #[cfg(test)]
1294 run_release_after_accept_hook(lease_key);
1295 Ok(())
1296 }
1297
1298 fn release_credential_lifecycle(&self, lease_key: &LeaseKey) -> Result<(), DslTransitionError> {
1299 let context = "AuthLeaseHandle::release_credential_lifecycle";
1300 let mut guard = self
1301 .machines
1302 .lock()
1303 .unwrap_or_else(std::sync::PoisonError::into_inner);
1304 let (input, from_phase, to_phase) = {
1305 let entry = guard
1306 .authorities
1307 .entry(lease_key.clone())
1308 .or_insert_with(auth_dsl::AuthMachineAuthority::new);
1309 let input = auth_dsl::AuthMachineInput::ReleaseCredentialLifecycle;
1310 let from_phase = map_phase(entry.state().lifecycle_phase);
1311 let transition = auth_dsl::AuthMachineMutator::apply(entry, input.clone())
1312 .map_err(|err| map_auth_machine_error(err, context))?;
1313 auth_lease_transition_from_generated_publication(
1314 lease_key,
1315 entry,
1316 &transition,
1317 context,
1318 )?;
1319 let to_phase = map_phase(entry.state().lifecycle_phase);
1320 (input, from_phase, to_phase)
1321 };
1322 emit_audit(
1323 lease_key,
1324 Self::audit_action_for(&input),
1325 from_phase,
1326 to_phase,
1327 );
1328 Ok(())
1329 }
1330
1331 fn restore_auth_lifecycle_snapshot(
1332 &self,
1333 captured: &AuthLeaseRestoreSnapshot,
1334 ) -> Result<Option<AuthLeaseTransition>, DslTransitionError> {
1335 if captured.captured_by_type_id() != std::any::TypeId::of::<RuntimeAuthLeaseHandle>()
1336 || captured.captured_by_instance_id() != self.auth_lifecycle_restore_instance_id()
1337 {
1338 return Err(DslTransitionError::no_matching(
1339 "AuthLeaseHandle::restore_auth_lifecycle_snapshot",
1340 "auth lifecycle restore snapshot was not captured from this RuntimeAuthLeaseHandle",
1341 ));
1342 }
1343 let lease_key = captured.lease_key();
1344 let snapshot = captured.snapshot();
1345 let mut guard = self
1346 .machines
1347 .lock()
1348 .unwrap_or_else(std::sync::PoisonError::into_inner);
1349 let from_phase = guard
1350 .authorities
1351 .get(lease_key)
1352 .map(|authority| map_phase(authority.state().lifecycle_phase))
1353 .unwrap_or(AuthLeasePhase::Released);
1354 let (to_phase, auth_transition) = apply_restore_input_to_registry(
1355 &mut guard,
1356 lease_key,
1357 restore_credential_lifecycle_snapshot_input(
1358 snapshot.phase.map(restore_phase),
1359 snapshot.expires_at,
1360 None,
1361 0,
1362 snapshot.credential_present,
1363 snapshot.generation,
1364 snapshot.credential_published_at_millis,
1365 false,
1366 ),
1367 "AuthLeaseHandle::restore_auth_lifecycle_snapshot",
1368 )?;
1369 emit_audit(
1370 lease_key,
1371 "restore_auth_lifecycle_snapshot",
1372 from_phase,
1373 to_phase,
1374 );
1375 if snapshot.credential_present
1376 && snapshot.phase.is_some()
1377 && snapshot.phase != Some(AuthLeasePhase::Released)
1378 {
1379 Ok(Some(auth_transition))
1380 } else {
1381 Ok(None)
1382 }
1383 }
1384
1385 fn auth_lifecycle_restore_instance_id(&self) -> usize {
1386 Arc::as_ptr(&self.machines) as usize
1387 }
1388
1389 fn restore_published_credential_lifecycle(
1390 &self,
1391 lease_key: &LeaseKey,
1392 publication: &AuthLeaseDurableRestorePublication,
1393 ) -> Result<AuthLeaseTransition, DslTransitionError> {
1394 let context = "AuthLeaseHandle::restore_published_credential_lifecycle";
1395 let lease_token_key = TokenKey::new_with_profile(
1396 lease_key.realm.clone(),
1397 lease_key.binding.clone(),
1398 lease_key.profile.clone(),
1399 );
1400 if publication.token_key() != &lease_token_key {
1401 return Err(DslTransitionError::no_matching(
1402 context,
1403 "durable auth lifecycle marker identity does not match restore lease key",
1404 ));
1405 }
1406 let mut guard = self
1407 .machines
1408 .lock()
1409 .unwrap_or_else(std::sync::PoisonError::into_inner);
1410 let from_phase = guard
1411 .authorities
1412 .get(lease_key)
1413 .map(|authority| map_phase(authority.state().lifecycle_phase))
1414 .unwrap_or(AuthLeasePhase::Released);
1415 let (to_phase, auth_transition) = apply_restore_input_to_registry(
1416 &mut guard,
1417 lease_key,
1418 restore_input_from_lifecycle(
1419 restore_phase_to_dsl(publication.phase()),
1420 (publication.expires_at() != u64::MAX).then_some(publication.expires_at()),
1421 None,
1422 0,
1423 publication.phase() != AuthLeasePhase::Released,
1424 publication.generation(),
1425 Some(publication.credential_published_at_millis()),
1426 ),
1427 context,
1428 )?;
1429 emit_audit(
1430 lease_key,
1431 "restore_published_credential_lifecycle",
1432 from_phase,
1433 to_phase,
1434 );
1435 Ok(auth_transition)
1436 }
1437
1438 fn resolve_credential_use_admission(
1439 &self,
1440 lease_key: &LeaseKey,
1441 intent: meerkat_core::handles::CredentialUseIntent,
1442 ) -> Result<meerkat_core::handles::CredentialUseDisposition, DslTransitionError> {
1443 const CONTEXT: &str = "AuthLeaseHandle::resolve_credential_use_admission";
1444 let guard = self
1445 .machines
1446 .lock()
1447 .unwrap_or_else(std::sync::PoisonError::into_inner);
1448 let Some(authority) = guard.authorities.get(lease_key) else {
1451 return Ok(meerkat_core::handles::CredentialUseDisposition::LeaseAbsent);
1452 };
1453 let mut transient =
1456 auth_dsl::AuthMachineAuthority::recover_from_state(authority.state().clone())
1457 .map_err(|err| map_auth_machine_error(err, CONTEXT))?;
1458 let transition = auth_dsl::AuthMachineMutator::apply(
1459 &mut transient,
1460 auth_dsl::AuthMachineInput::ResolveCredentialUseAdmission {
1461 intent: credential_use_intent_to_dsl(intent),
1462 },
1463 )
1464 .map_err(|err| map_auth_machine_error(err, CONTEXT))?;
1465
1466 let mut resolved = None;
1467 for effect in transition.effects() {
1468 if let auth_dsl::AuthMachineEffect::CredentialUseAdmissionResolved { disposition } =
1469 effect
1470 && resolved.replace(*disposition).is_some()
1471 {
1472 return Err(DslTransitionError::no_matching(
1473 CONTEXT,
1474 format!(
1475 "AuthMachine emitted multiple credential-use dispositions for `{lease_key}`"
1476 ),
1477 ));
1478 }
1479 }
1480
1481 resolved
1482 .map(credential_use_disposition_from_dsl)
1483 .ok_or_else(|| {
1484 DslTransitionError::no_matching(
1485 CONTEXT,
1486 format!("AuthMachine emitted no credential-use disposition for `{lease_key}`"),
1487 )
1488 })
1489 }
1490
1491 fn resolve_oauth_login_credential_disposition(
1492 &self,
1493 lease_key: &LeaseKey,
1494 facts: meerkat_core::handles::OAuthLoginCredentialFacts,
1495 ) -> Result<meerkat_core::handles::CredentialUseDisposition, DslTransitionError> {
1496 const CONTEXT: &str = "AuthLeaseHandle::resolve_oauth_login_credential_disposition";
1497 let guard = self
1498 .machines
1499 .lock()
1500 .unwrap_or_else(std::sync::PoisonError::into_inner);
1501 let Some(authority) = guard.authorities.get(lease_key) else {
1504 return Ok(meerkat_core::handles::CredentialUseDisposition::LeaseAbsent);
1505 };
1506 let mut transient =
1509 auth_dsl::AuthMachineAuthority::recover_from_state(authority.state().clone())
1510 .map_err(|err| map_auth_machine_error(err, CONTEXT))?;
1511 let transition = auth_dsl::AuthMachineMutator::apply(
1512 &mut transient,
1513 auth_dsl::AuthMachineInput::ResolveOAuthLoginCredentialDisposition {
1514 credential_present: facts.credential_present,
1515 force_refresh: facts.force_refresh,
1516 refresh_allowed: facts.refresh_allowed,
1517 },
1518 )
1519 .map_err(|err| map_auth_machine_error(err, CONTEXT))?;
1520
1521 let mut resolved = None;
1522 for effect in transition.effects() {
1523 if let auth_dsl::AuthMachineEffect::CredentialUseAdmissionResolved { disposition } =
1524 effect
1525 && resolved.replace(*disposition).is_some()
1526 {
1527 return Err(DslTransitionError::no_matching(
1528 CONTEXT,
1529 format!(
1530 "AuthMachine emitted multiple OAuth-login credential dispositions for `{lease_key}`"
1531 ),
1532 ));
1533 }
1534 }
1535
1536 resolved
1537 .map(credential_use_disposition_from_dsl)
1538 .ok_or_else(|| {
1539 DslTransitionError::no_matching(
1540 CONTEXT,
1541 format!(
1542 "AuthMachine emitted no OAuth-login credential disposition for `{lease_key}`"
1543 ),
1544 )
1545 })
1546 }
1547
1548 fn snapshot(&self, lease_key: &LeaseKey) -> AuthLeaseSnapshot {
1549 let guard = self
1550 .machines
1551 .lock()
1552 .unwrap_or_else(std::sync::PoisonError::into_inner);
1553 match guard.authorities.get(lease_key) {
1554 Some(machine) => {
1555 let state = machine.state();
1556 let phase = (state.lifecycle_phase != auth_dsl::AuthLifecyclePhase::Released)
1557 .then(|| map_phase(state.lifecycle_phase));
1558 AuthLeaseSnapshot {
1559 phase,
1560 expires_at: state.expires_at,
1561 credential_present: state.credential_present,
1562 generation: state.credential_generation,
1563 credential_published_at_millis: state
1564 .credential_present
1565 .then_some(state.credential_published_at_millis)
1566 .flatten(),
1567 }
1568 }
1569 None => AuthLeaseSnapshot {
1570 phase: None,
1571 expires_at: None,
1572 credential_present: false,
1573 generation: 0,
1574 credential_published_at_millis: None,
1575 },
1576 }
1577 }
1578}
1579
1580#[cfg(test)]
1581#[allow(clippy::unwrap_used, clippy::expect_used)]
1582mod tests {
1583 use super::*;
1584 use meerkat_core::connection::{BindingId, BindingOrigin, RealmId};
1585
1586 fn lease(realm: &str, binding: &str) -> LeaseKey {
1587 LeaseKey::new(
1588 RealmId::parse(realm).expect("valid realm"),
1589 BindingId::parse(binding).expect("valid binding"),
1590 None,
1591 )
1592 }
1593
1594 #[cfg(not(target_arch = "wasm32"))]
1595 fn auth_binding(realm: &str, binding: &str) -> AuthBindingRef {
1596 AuthBindingRef {
1597 realm: RealmId::parse(realm).expect("valid realm"),
1598 binding: BindingId::parse(binding).expect("valid binding"),
1599 profile: None,
1600 origin: BindingOrigin::Configured,
1601 }
1602 }
1603
1604 fn empty_auth_state() -> auth_dsl::AuthMachineState {
1605 auth_dsl::AuthMachineAuthority::new().state().clone()
1606 }
1607
1608 #[test]
1609 fn acquire_and_snapshot_roundtrip() {
1610 let h = RuntimeAuthLeaseHandle::new();
1611 let key = lease("dev", "default_openai");
1612 h.acquire_lease(&key, 1_800_000_000).unwrap();
1613 let snap = h.snapshot(&key);
1614 assert_eq!(snap.phase, Some(AuthLeasePhase::Valid));
1615 assert_eq!(snap.expires_at, Some(1_800_000_000));
1616 }
1617
1618 #[test]
1619 fn lifecycle_transitions() {
1620 let h = RuntimeAuthLeaseHandle::new();
1621 let k = lease("dev", "default_anthropic");
1622
1623 h.acquire_lease(&k, 1_800_000_000).unwrap();
1624 assert_eq!(h.snapshot(&k).phase, Some(AuthLeasePhase::Valid));
1625
1626 h.mark_expiring(&k).unwrap();
1627 assert_eq!(h.snapshot(&k).phase, Some(AuthLeasePhase::Expiring));
1628
1629 h.begin_refresh(&k).unwrap();
1630 assert_eq!(h.snapshot(&k).phase, Some(AuthLeasePhase::Refreshing));
1631
1632 h.complete_refresh(&k, 1_800_000_900, 1_800_000_000)
1633 .unwrap();
1634 let snap = h.snapshot(&k);
1635 assert_eq!(snap.phase, Some(AuthLeasePhase::Valid));
1636 assert_eq!(snap.expires_at, Some(1_800_000_900));
1637 }
1638
1639 #[test]
1640 fn credential_use_admission_is_decided_by_authmachine() {
1641 use meerkat_core::handles::CredentialUseDisposition as Disp;
1642 use meerkat_core::handles::CredentialUseIntent as Intent;
1643
1644 let h = RuntimeAuthLeaseHandle::new();
1645 let k = lease("dev", "creduse_openai");
1646
1647 for intent in [
1649 Intent::UseCredential,
1650 Intent::HoldAuthority,
1651 Intent::BeginRefresh,
1652 ] {
1653 assert_eq!(
1654 h.resolve_credential_use_admission(&k, intent).unwrap(),
1655 Disp::LeaseAbsent,
1656 "unregistered lease must classify LeaseAbsent for {intent:?}"
1657 );
1658 }
1659
1660 h.acquire_lease(&k, 1_800_000_000).unwrap();
1662 assert_eq!(h.snapshot(&k).phase, Some(AuthLeasePhase::Valid));
1663 assert_eq!(
1664 h.resolve_credential_use_admission(&k, Intent::UseCredential)
1665 .unwrap(),
1666 Disp::Authorized
1667 );
1668 assert_eq!(
1669 h.resolve_credential_use_admission(&k, Intent::HoldAuthority)
1670 .unwrap(),
1671 Disp::Authorized
1672 );
1673 assert_eq!(
1674 h.resolve_credential_use_admission(&k, Intent::BeginRefresh)
1675 .unwrap(),
1676 Disp::RefreshRequired
1677 );
1678
1679 h.mark_expiring(&k).unwrap();
1682 assert_eq!(h.snapshot(&k).phase, Some(AuthLeasePhase::Expiring));
1683 assert_eq!(
1684 h.resolve_credential_use_admission(&k, Intent::UseCredential)
1685 .unwrap(),
1686 Disp::RefreshRequired
1687 );
1688 assert_eq!(
1689 h.resolve_credential_use_admission(&k, Intent::HoldAuthority)
1690 .unwrap(),
1691 Disp::Authorized
1692 );
1693 assert_eq!(
1694 h.resolve_credential_use_admission(&k, Intent::BeginRefresh)
1695 .unwrap(),
1696 Disp::RefreshRequired
1697 );
1698
1699 h.begin_refresh(&k).unwrap();
1702 assert_eq!(h.snapshot(&k).phase, Some(AuthLeasePhase::Refreshing));
1703 assert_eq!(
1704 h.resolve_credential_use_admission(&k, Intent::UseCredential)
1705 .unwrap(),
1706 Disp::RefreshRequired
1707 );
1708 assert_eq!(
1709 h.resolve_credential_use_admission(&k, Intent::HoldAuthority)
1710 .unwrap(),
1711 Disp::Authorized
1712 );
1713 assert_eq!(
1714 h.resolve_credential_use_admission(&k, Intent::BeginRefresh)
1715 .unwrap(),
1716 Disp::AlreadyRefreshing
1717 );
1718
1719 let kx = lease("dev", "creduse_expired");
1721 h.acquire_lease(&kx, 1_800_000_000).unwrap();
1722 h.observe_credential_freshness(&kx, 1_800_000_001, 60)
1723 .unwrap();
1724 assert_eq!(h.snapshot(&kx).phase, Some(AuthLeasePhase::Expired));
1725 for intent in [
1726 Intent::UseCredential,
1727 Intent::HoldAuthority,
1728 Intent::BeginRefresh,
1729 ] {
1730 assert_eq!(
1731 h.resolve_credential_use_admission(&kx, intent).unwrap(),
1732 Disp::RefreshRequired,
1733 "expired lease must classify RefreshRequired for {intent:?}"
1734 );
1735 }
1736
1737 let kr = lease("dev", "creduse_reauth");
1739 h.acquire_lease(&kr, 1_800_000_000).unwrap();
1740 h.mark_reauth_required(&kr).unwrap();
1741 assert_eq!(h.snapshot(&kr).phase, Some(AuthLeasePhase::ReauthRequired));
1742 for intent in [
1743 Intent::UseCredential,
1744 Intent::HoldAuthority,
1745 Intent::BeginRefresh,
1746 ] {
1747 assert_eq!(
1748 h.resolve_credential_use_admission(&kr, intent).unwrap(),
1749 Disp::ReauthRequired,
1750 "reauth-required lease must classify ReauthRequired for {intent:?}"
1751 );
1752 }
1753
1754 let kl = lease("dev", "creduse_released");
1756 h.acquire_lease(&kl, 1_800_000_000).unwrap();
1757 h.release_lease(&kl).unwrap();
1758 for intent in [
1759 Intent::UseCredential,
1760 Intent::HoldAuthority,
1761 Intent::BeginRefresh,
1762 ] {
1763 assert_eq!(
1764 h.resolve_credential_use_admission(&kl, intent).unwrap(),
1765 Disp::LeaseAbsent,
1766 "released lease must classify LeaseAbsent for {intent:?}"
1767 );
1768 }
1769
1770 assert_eq!(h.snapshot(&k).phase, Some(AuthLeasePhase::Refreshing));
1772 }
1773
1774 #[test]
1785 fn oauth_login_credential_disposition_matches_legacy_composite() {
1786 use meerkat_core::handles::CredentialUseDisposition as Disp;
1787 use meerkat_core::handles::OAuthLoginCredentialFacts;
1788
1789 fn lease_in_phase(
1794 phase: AuthLeasePhase,
1795 binding: &str,
1796 ) -> (RuntimeAuthLeaseHandle, LeaseKey) {
1797 let h = RuntimeAuthLeaseHandle::new();
1798 let k = lease("dev", binding);
1799 h.acquire_lease(&k, 1_800_000_000).unwrap();
1800 match phase {
1801 AuthLeasePhase::Valid => {}
1802 AuthLeasePhase::Expiring => h.mark_expiring(&k).unwrap(),
1803 AuthLeasePhase::Expired => {
1804 h.observe_credential_freshness(&k, 1_800_000_001, 60)
1805 .unwrap();
1806 }
1807 AuthLeasePhase::Refreshing => h.begin_refresh(&k).unwrap(),
1808 AuthLeasePhase::ReauthRequired => h.mark_reauth_required(&k).unwrap(),
1809 AuthLeasePhase::Released => h.release_lease(&k).unwrap(),
1810 }
1811 (h, k)
1812 }
1813
1814 let phases = [
1815 AuthLeasePhase::Valid,
1816 AuthLeasePhase::Expiring,
1817 AuthLeasePhase::Expired,
1818 AuthLeasePhase::Refreshing,
1819 AuthLeasePhase::ReauthRequired,
1820 AuthLeasePhase::Released,
1821 ];
1822 let mut counter = 0u32;
1823 for phase in phases {
1824 for credential_present in [false, true] {
1825 for force_refresh in [false, true] {
1826 for refresh_allowed in [false, true] {
1827 counter += 1;
1828 let (h, k) = lease_in_phase(phase, &format!("oauthdisp_{counter}"));
1829 let got = h
1830 .resolve_oauth_login_credential_disposition(
1831 &k,
1832 OAuthLoginCredentialFacts {
1833 credential_present,
1834 force_refresh,
1835 refresh_allowed,
1836 },
1837 )
1838 .unwrap();
1839
1840 let use_cached =
1845 phase == AuthLeasePhase::Valid && credential_present && !force_refresh;
1846 let expected = if use_cached {
1847 Disp::Authorized
1848 } else if refresh_allowed {
1849 Disp::RefreshRequired
1850 } else {
1851 Disp::RefreshDisallowed
1852 };
1853 assert_eq!(
1854 got, expected,
1855 "phase={phase:?} cred_present={credential_present} \
1856 force_refresh={force_refresh} refresh_allowed={refresh_allowed}"
1857 );
1858 }
1859 }
1860 }
1861 }
1862
1863 let h = RuntimeAuthLeaseHandle::new();
1865 let absent = lease("dev", "oauthdisp_absent");
1866 assert_eq!(
1867 h.resolve_oauth_login_credential_disposition(
1868 &absent,
1869 OAuthLoginCredentialFacts {
1870 credential_present: true,
1871 force_refresh: false,
1872 refresh_allowed: true,
1873 },
1874 )
1875 .unwrap(),
1876 Disp::LeaseAbsent
1877 );
1878 }
1879
1880 #[test]
1881 fn observe_credential_freshness_marks_expired_through_authmachine() {
1882 let h = RuntimeAuthLeaseHandle::new();
1883 let k = lease("dev", "expired_openai");
1884
1885 h.acquire_lease(&k, 1_800_000_000).unwrap();
1886 h.observe_credential_freshness(&k, 1_800_000_001, 60)
1887 .unwrap();
1888
1889 assert_eq!(h.snapshot(&k).phase, Some(AuthLeasePhase::Expired));
1890 h.begin_refresh(&k).unwrap();
1891 assert_eq!(h.snapshot(&k).phase, Some(AuthLeasePhase::Refreshing));
1892 }
1893
1894 #[test]
1895 fn refresh_failed_permanent_routes_to_reauth() {
1896 let h = RuntimeAuthLeaseHandle::new();
1897 let k = lease("dev", "default_google");
1898
1899 h.acquire_lease(&k, 1_800_000_000).unwrap();
1900 h.begin_refresh(&k).unwrap();
1901 h.refresh_failed(&k, RefreshFailureObservation::local_credential_unusable())
1902 .unwrap();
1903
1904 assert_eq!(h.snapshot(&k).phase, Some(AuthLeasePhase::ReauthRequired));
1905 }
1906
1907 #[test]
1908 fn refresh_failed_transient_routes_to_expiring() {
1909 let h = RuntimeAuthLeaseHandle::new();
1910 let k = lease("dev", "foo");
1911
1912 h.acquire_lease(&k, 1_800_000_000).unwrap();
1913 h.begin_refresh(&k).unwrap();
1914 h.refresh_failed(&k, RefreshFailureObservation::transient())
1915 .unwrap();
1916
1917 assert_eq!(h.snapshot(&k).phase, Some(AuthLeasePhase::Expiring));
1918 }
1919
1920 #[test]
1921 fn transient_refresh_failure_preserves_credential_marker_generation() {
1922 let h = RuntimeAuthLeaseHandle::new();
1923 let k = lease("dev", "retryable_refresh");
1924
1925 h.acquire_lease(&k, 1_800_000_000).unwrap();
1926 let before = h.snapshot(&k);
1927 h.begin_refresh(&k).unwrap();
1928 h.refresh_failed(&k, RefreshFailureObservation::transient())
1929 .unwrap();
1930 let after = h.snapshot(&k);
1931
1932 assert_eq!(after.phase, Some(AuthLeasePhase::Expiring));
1933 assert_eq!(after.expires_at, before.expires_at);
1934 assert_eq!(after.generation, before.generation);
1935 assert_eq!(
1936 after.credential_published_at_millis,
1937 before.credential_published_at_millis
1938 );
1939 }
1940
1941 #[test]
1942 fn snapshot_for_unknown_binding_is_none() {
1943 let h = RuntimeAuthLeaseHandle::new();
1944 let snap = h.snapshot(&lease("dev", "never_registered"));
1945 assert!(snap.phase.is_none());
1946 assert!(snap.expires_at.is_none());
1947 }
1948
1949 #[test]
1950 fn mark_expiring_before_acquire_errors() {
1951 let h = RuntimeAuthLeaseHandle::new();
1952 let err = h.mark_expiring(&lease("dev", "ghost")).unwrap_err();
1953 assert_eq!(err.context, "AuthLeaseHandle::mark_expiring");
1954 }
1955
1956 #[test]
1957 fn release_before_acquire_is_idempotent() {
1958 let h = RuntimeAuthLeaseHandle::new();
1959 let key = lease("dev", "ghost");
1960
1961 h.release_lease(&key).unwrap();
1962
1963 let snap = h.snapshot(&key);
1964 assert!(snap.phase.is_none());
1965 assert!(snap.expires_at.is_none());
1966 }
1967
1968 #[test]
1969 fn release_does_not_remove_concurrent_reacquire() {
1970 let h = RuntimeAuthLeaseHandle::new();
1971 let key = lease("dev", "shared");
1972 h.acquire_lease(&key, 1_800_000_000).unwrap();
1973
1974 let acquire_handle = h.clone();
1975 let acquire_key = key.clone();
1976 let hook_key = key.clone();
1977 let acquired_generation = Arc::new(Mutex::new(None));
1978 let hook_generation = Arc::clone(&acquired_generation);
1979 let _hook_guard =
1980 install_release_after_accept_hook_for_test(Arc::new(move |released_key| {
1981 if released_key != &hook_key {
1982 return;
1983 }
1984 let generation = acquire_handle
1985 .acquire_lease(&acquire_key, 1_800_000_000)
1986 .unwrap()
1987 .generation();
1988 *hook_generation.lock().unwrap() = Some(generation);
1989 }));
1990
1991 h.release_lease(&key).unwrap();
1992
1993 let acquired_generation = acquired_generation
1994 .lock()
1995 .unwrap()
1996 .expect("release hook should reacquire the lease");
1997
1998 let snap = h.snapshot(&key);
1999 assert_eq!(
2000 snap.phase,
2001 Some(AuthLeasePhase::Valid),
2002 "accepted reacquire generation {acquired_generation} must remain visible after release completes; snapshot was {snap:?}"
2003 );
2004 assert_eq!(snap.expires_at, Some(1_800_000_000));
2005 assert_eq!(snap.generation, acquired_generation);
2006 }
2007
2008 #[cfg(not(target_arch = "wasm32"))]
2009 struct FailingReleaseObserver;
2010
2011 #[cfg(not(target_arch = "wasm32"))]
2012 impl AuthLeaseReleaseObserver for FailingReleaseObserver {
2013 fn auth_lease_released(
2014 &self,
2015 _released: &ReleasedOAuthFlows,
2016 ) -> Result<(), DslTransitionError> {
2017 Err(DslTransitionError::no_matching(
2018 "test_release_observer",
2019 "injected release observer failure",
2020 ))
2021 }
2022 }
2023
2024 #[cfg(not(target_arch = "wasm32"))]
2031 #[test]
2032 fn release_observer_failure_aborts_release_fail_closed() {
2033 let h = RuntimeAuthLeaseHandle::new();
2034 let key = lease("dev", "staged_failure");
2035 let acquired = h.acquire_lease(&key, 1_800_000_000).unwrap();
2036
2037 let observer: Arc<dyn AuthLeaseReleaseObserver> = Arc::new(FailingReleaseObserver);
2038 h.add_release_observer(Arc::downgrade(&observer));
2039
2040 let hook_key = key.clone();
2043 let hook_fired = Arc::new(std::sync::atomic::AtomicBool::new(false));
2044 let hook_fired_flag = Arc::clone(&hook_fired);
2045 let _hook_guard =
2046 install_release_after_accept_hook_for_test(Arc::new(move |released_key| {
2047 if released_key == &hook_key {
2048 hook_fired_flag.store(true, std::sync::atomic::Ordering::Release);
2049 }
2050 }));
2051
2052 let err = h
2053 .release_lease(&key)
2054 .expect_err("observer fault must surface typed");
2055 assert!(
2056 err.to_string()
2057 .contains("injected release observer failure"),
2058 "typed fault must carry the observer failure, got: {err}"
2059 );
2060 assert!(
2061 !hook_fired.load(std::sync::atomic::Ordering::Acquire),
2062 "Release transition must not be applied when an observer faulted"
2063 );
2064
2065 let snap = h.snapshot(&key);
2067 assert_eq!(snap.phase, Some(AuthLeasePhase::Valid));
2068 assert_eq!(snap.expires_at, Some(1_800_000_000));
2069 assert!(snap.credential_present);
2070 assert_eq!(snap.generation, acquired.generation());
2071 }
2072
2073 #[cfg(not(target_arch = "wasm32"))]
2074 #[test]
2075 fn oauth_global_capacity_rejection_comes_from_generated_authority() {
2076 let h = RuntimeAuthLeaseHandle::new();
2077 let first = auth_binding("dev", "oauth_a");
2078 let second = auth_binding("dev", "oauth_b");
2079
2080 h.apply_oauth_input(
2081 &first,
2082 auth_dsl::AuthMachineInput::AdmitOAuthBrowserFlow {
2083 flow_id: "first".to_string(),
2084 provider: "provider".to_string(),
2085 redirect_uri: "http://localhost/first".to_string(),
2086 expires_at_millis: 1_900_000_000,
2087 max_outstanding_flows: 1,
2088 observed_global_outstanding_flows: u64::MAX,
2089 },
2090 "test_admit_oauth_browser_flow",
2091 true,
2092 )
2093 .unwrap();
2094
2095 let err = h
2096 .apply_oauth_input(
2097 &second,
2098 auth_dsl::AuthMachineInput::AdmitOAuthBrowserFlow {
2099 flow_id: "second".to_string(),
2100 provider: "provider".to_string(),
2101 redirect_uri: "http://localhost/second".to_string(),
2102 expires_at_millis: 1_900_000_000,
2103 max_outstanding_flows: 1,
2104 observed_global_outstanding_flows: 0,
2105 },
2106 "test_admit_oauth_browser_flow",
2107 true,
2108 )
2109 .unwrap_err();
2110
2111 assert!(
2112 err.is_guard_rejected(),
2113 "expected generated guard rejection: {err:?}"
2114 );
2115 assert_eq!(err.context, "test_admit_oauth_browser_flow");
2116 assert!(
2117 err.reason.contains("AdmitOAuthBrowserFlow"),
2118 "guard rejection should come from the generated AuthMachine transition: {err:?}"
2119 );
2120 assert!(h.has_oauth_browser_flow_for_test(&first, "first"));
2121 assert!(!h.has_oauth_browser_flow_for_test(&second, "second"));
2122 }
2123
2124 #[test]
2125 fn restore_oauth_missing_payload_rejected_by_generated_authority() {
2126 let mut registry = AuthLeaseRegistry::default();
2127 let key = lease("dev", "restore_missing_provider");
2128 let mut state = empty_auth_state();
2129 state.lifecycle_phase = auth_dsl::AuthLifecyclePhase::ReauthRequired;
2130 state
2131 .oauth_browser_flow_ids
2132 .insert("browser-flow".to_string());
2133 state.oauth_browser_flow_redirect_uris.insert(
2134 "browser-flow".to_string(),
2135 "http://localhost/callback".to_string(),
2136 );
2137 state
2138 .oauth_browser_flow_expires_at_millis
2139 .insert("browser-flow".to_string(), 1_900_000_000);
2140 state.oauth_outstanding_flow_count = 1;
2141
2142 let err = restore_state_to_registry(
2143 &mut registry,
2144 &key,
2145 &state,
2146 "test_restore_oauth_missing_payload",
2147 )
2148 .unwrap_err();
2149
2150 assert!(
2151 err.is_guard_rejected(),
2152 "missing restored OAuth payload must be rejected by generated guards: {err:?}"
2153 );
2154 assert!(
2155 err.reason.contains("RestoreOAuthBrowserFlow"),
2156 "rejection should name the generated restore input: {err:?}"
2157 );
2158 assert!(!registry.authorities.contains_key(&key));
2159 }
2160
2161 #[test]
2162 fn restore_oauth_orphan_device_poll_rejected_by_generated_authority() {
2163 let mut registry = AuthLeaseRegistry::default();
2164 let key = lease("dev", "restore_orphan_poll");
2165 let mut state = empty_auth_state();
2166 state.lifecycle_phase = auth_dsl::AuthLifecyclePhase::ReauthRequired;
2167 state
2168 .oauth_device_poll_ids
2169 .insert("orphan-device".to_string());
2170
2171 let err = restore_state_to_registry(
2172 &mut registry,
2173 &key,
2174 &state,
2175 "test_restore_oauth_orphan_device_poll",
2176 )
2177 .unwrap_err();
2178
2179 assert!(
2180 err.is_guard_rejected(),
2181 "orphan restored OAuth poll must be rejected by generated guards: {err:?}"
2182 );
2183 assert!(
2184 err.reason.contains("RestoreOAuthDevicePoll"),
2185 "rejection should name the generated restore input: {err:?}"
2186 );
2187 assert!(!registry.authorities.contains_key(&key));
2188 }
2189
2190 #[test]
2191 fn restore_released_oauth_membership_reauths_through_generated_authority() {
2192 let mut registry = AuthLeaseRegistry::default();
2193 let key = lease("dev", "restore_released_oauth");
2194 let mut state = empty_auth_state();
2195 state.lifecycle_phase = auth_dsl::AuthLifecyclePhase::Released;
2196 state
2197 .oauth_browser_flow_ids
2198 .insert("browser-flow".to_string());
2199 state
2200 .oauth_browser_flow_providers
2201 .insert("browser-flow".to_string(), "provider".to_string());
2202 state.oauth_browser_flow_redirect_uris.insert(
2203 "browser-flow".to_string(),
2204 "http://localhost/callback".to_string(),
2205 );
2206 state
2207 .oauth_browser_flow_expires_at_millis
2208 .insert("browser-flow".to_string(), 1_900_000_000);
2209 state.oauth_outstanding_flow_count = 1;
2210
2211 let (phase, transition) = restore_state_to_registry(
2212 &mut registry,
2213 &key,
2214 &state,
2215 "test_restore_released_oauth_membership",
2216 )
2217 .unwrap();
2218
2219 assert_eq!(phase, AuthLeasePhase::ReauthRequired);
2220 assert_eq!(transition.phase(), AuthLeasePhase::ReauthRequired);
2221 let restored = registry.authorities.get(&key).unwrap().state();
2222 assert_eq!(
2223 restored.lifecycle_phase,
2224 auth_dsl::AuthLifecyclePhase::ReauthRequired
2225 );
2226 assert!(restored.oauth_browser_flow_ids.contains("browser-flow"));
2227 assert_eq!(restored.oauth_outstanding_flow_count, 1);
2228 }
2229
2230 #[test]
2231 fn repeated_acquire_updates_existing_lease() {
2232 let h = RuntimeAuthLeaseHandle::new();
2233 let key = lease("dev", "default");
2234
2235 h.acquire_lease(&key, 1_800_000_000).unwrap();
2236 h.acquire_lease(&key, 1_900_000_000).unwrap();
2237
2238 let snap = h.snapshot(&key);
2239 assert_eq!(snap.phase, Some(AuthLeasePhase::Valid));
2240 assert_eq!(snap.expires_at, Some(1_900_000_000));
2241 }
2242
2243 #[test]
2244 fn restore_snapshot_preserves_publication_marker_without_lowering_generation() {
2245 let h = RuntimeAuthLeaseHandle::new();
2246 let key = lease("dev", "shared");
2247
2248 h.acquire_lease(&key, 1_800_000_000).unwrap();
2249 let before = h.snapshot(&key);
2250 let before_restore = h.capture_auth_lifecycle_restore_snapshot(&key);
2251 assert_eq!(before.phase, Some(AuthLeasePhase::Valid));
2252 assert!(before.credential_present);
2253 assert!(before.credential_published_at_millis.is_some());
2254
2255 h.acquire_lease(&key, 1_900_000_000).unwrap();
2256 let advanced = h.snapshot(&key);
2257 assert!(advanced.generation > before.generation);
2258
2259 h.restore_auth_lifecycle_snapshot(&before_restore).unwrap();
2260
2261 let restored = h.snapshot(&key);
2262 assert_eq!(restored.phase, before.phase);
2263 assert_eq!(restored.expires_at, before.expires_at);
2264 assert_eq!(restored.credential_present, before.credential_present);
2265 assert_eq!(
2266 restored.credential_published_at_millis,
2267 before.credential_published_at_millis
2268 );
2269 assert_eq!(restored.generation, advanced.generation);
2270 }
2271
2272 #[test]
2273 fn restore_empty_zero_generation_snapshot_releases_through_generated_authority() {
2274 let h = RuntimeAuthLeaseHandle::new();
2275 let key = lease("dev", "shared");
2276 let empty = h.capture_auth_lifecycle_restore_snapshot(&key);
2277 h.acquire_lease(&key, 1_800_000_000).unwrap();
2278 let acquired_generation = h.snapshot(&key).generation;
2279 assert!(acquired_generation > empty.snapshot().generation);
2280
2281 h.restore_auth_lifecycle_snapshot(&empty).unwrap();
2282
2283 let restored = h.snapshot(&key);
2284 assert_eq!(restored.phase, None);
2285 assert_eq!(restored.expires_at, None);
2286 assert!(!restored.credential_present);
2287 assert_eq!(restored.generation, acquired_generation);
2288 assert_eq!(restored.credential_published_at_millis, None);
2289 }
2290
2291 #[test]
2292 fn restore_snapshot_rejects_capture_from_different_runtime_handle() {
2293 let first = RuntimeAuthLeaseHandle::new();
2294 let second = RuntimeAuthLeaseHandle::new();
2295 let key = lease("dev", "shared");
2296 first.acquire_lease(&key, 1_800_000_000).unwrap();
2297 let captured = first.capture_auth_lifecycle_restore_snapshot(&key);
2298
2299 let err = second
2300 .restore_auth_lifecycle_snapshot(&captured)
2301 .unwrap_err();
2302
2303 assert_eq!(
2304 err.context,
2305 "AuthLeaseHandle::restore_auth_lifecycle_snapshot"
2306 );
2307 assert!(
2308 err.reason.contains("this RuntimeAuthLeaseHandle"),
2309 "restore must reject snapshots captured from a different runtime handle: {err:?}"
2310 );
2311 assert_eq!(second.snapshot(&key).phase, None);
2312 }
2313
2314 #[tokio::test]
2315 async fn restore_published_credential_lifecycle_uses_generated_authority() {
2316 struct SingleTokenStore {
2317 key: meerkat_core::auth::TokenKey,
2318 tokens: meerkat_core::auth::PersistedTokens,
2319 }
2320
2321 #[async_trait::async_trait]
2322 impl meerkat_core::auth::TokenStore for SingleTokenStore {
2323 async fn load(
2324 &self,
2325 key: &meerkat_core::auth::TokenKey,
2326 ) -> Result<
2327 Option<meerkat_core::auth::PersistedTokens>,
2328 meerkat_core::auth::TokenStoreError,
2329 > {
2330 Ok((key == &self.key).then(|| self.tokens.clone()))
2331 }
2332
2333 async fn save(
2334 &self,
2335 _key: &meerkat_core::auth::TokenKey,
2336 _tokens: &meerkat_core::auth::PersistedTokens,
2337 ) -> Result<(), meerkat_core::auth::TokenStoreError> {
2338 Ok(())
2339 }
2340
2341 async fn clear(
2342 &self,
2343 _key: &meerkat_core::auth::TokenKey,
2344 ) -> Result<(), meerkat_core::auth::TokenStoreError> {
2345 Ok(())
2346 }
2347
2348 async fn list(
2349 &self,
2350 ) -> Result<Vec<meerkat_core::auth::TokenKey>, meerkat_core::auth::TokenStoreError>
2351 {
2352 Ok(vec![self.key.clone()])
2353 }
2354
2355 fn backend_name(&self) -> &'static str {
2356 "single-token-test"
2357 }
2358 }
2359
2360 let source = Arc::new(RuntimeAuthLeaseHandle::new());
2361 let restored = Arc::new(RuntimeAuthLeaseHandle::new());
2362 let generated_restored =
2363 crate::protocol_auth_lease_lifecycle_publication::generated_auth_lease_handle(
2364 Arc::clone(&restored),
2365 )
2366 .expect("test AuthLeaseHandle must be generated-authority certified");
2367 let key = lease("dev", "shared");
2368 let transition = source.acquire_lease(&key, 1_800_000_000).unwrap();
2369 let published_at = transition
2370 .credential_published_at_millis()
2371 .expect("acquire transition carries publication time");
2372 let key_for_tokens = meerkat_core::auth::TokenKey::new_with_profile(
2373 key.realm.clone(),
2374 key.binding.clone(),
2375 key.profile.clone(),
2376 );
2377 let tokens = meerkat_core::auth::PersistedTokens {
2378 auth_mode: meerkat_core::auth::PersistedAuthMode::ChatgptOauth,
2379 primary_secret: Some("access-token".into()),
2380 refresh_token: Some("refresh-token".into()),
2381 id_token: None,
2382 expires_at: Some(
2383 chrono::DateTime::from_timestamp(transition.expires_at() as i64, 0)
2384 .expect("fixture expiry is representable"),
2385 ),
2386 last_refresh: None,
2387 scopes: Vec::new(),
2388 account_id: None,
2389 metadata: serde_json::Value::Null,
2390 };
2391 let marked = meerkat_core::mark_tokens_lifecycle_published_for_transition(
2392 &key_for_tokens,
2393 &tokens,
2394 &transition,
2395 )
2396 .expect("generated transition marks durable publication");
2397
2398 let auth_binding = AuthBindingRef {
2399 realm: key.realm.clone(),
2400 binding: key.binding.clone(),
2401 profile: key.profile.clone(),
2402 origin: BindingOrigin::Configured,
2403 };
2404 let store = SingleTokenStore {
2405 key: key_for_tokens,
2406 tokens: marked,
2407 };
2408 meerkat_core::rehydrate_marked_tokens_for_status(
2409 &store,
2410 &generated_restored,
2411 &auth_binding,
2412 meerkat_core::auth::PersistedAuthMode::ChatgptOauth,
2413 chrono::Utc::now(),
2414 )
2415 .await
2416 .expect("generated marker restores through AuthMachine")
2417 .expect("marker is present");
2418
2419 let snapshot = restored.snapshot(&key);
2420 assert_eq!(snapshot.phase, Some(AuthLeasePhase::Valid));
2421 assert_eq!(snapshot.expires_at, Some(transition.expires_at()));
2422 assert_eq!(snapshot.generation, transition.generation());
2423 assert_eq!(snapshot.credential_published_at_millis, Some(published_at));
2424 assert!(snapshot.credential_present);
2425 }
2426
2427 #[test]
2428 fn per_binding_isolation() {
2429 let h = RuntimeAuthLeaseHandle::new();
2430 let openai = lease("dev", "openai");
2431 let anthropic = lease("dev", "anthropic");
2432 h.acquire_lease(&openai, 1_800_000_000).unwrap();
2433 h.acquire_lease(&anthropic, 1_900_000_000).unwrap();
2434 h.mark_expiring(&openai).unwrap();
2435
2436 assert_eq!(h.snapshot(&openai).phase, Some(AuthLeasePhase::Expiring));
2437 assert_eq!(h.snapshot(&anthropic).phase, Some(AuthLeasePhase::Valid));
2438 assert_eq!(h.snapshot(&anthropic).expires_at, Some(1_900_000_000));
2439 }
2440 #[cfg(not(target_arch = "wasm32"))]
2448 #[tokio::test]
2449 async fn clear_flow_observer_failure_fails_closed_and_never_clears_durable() {
2450 use std::sync::Mutex as StdMutex;
2451
2452 struct RecordingStore {
2453 tokens: StdMutex<Option<meerkat_core::auth::PersistedTokens>>,
2454 key: meerkat_core::auth::TokenKey,
2455 clear_called: std::sync::atomic::AtomicBool,
2456 }
2457
2458 #[async_trait::async_trait]
2459 impl meerkat_core::auth::TokenStore for RecordingStore {
2460 async fn load(
2461 &self,
2462 key: &meerkat_core::auth::TokenKey,
2463 ) -> Result<
2464 Option<meerkat_core::auth::PersistedTokens>,
2465 meerkat_core::auth::TokenStoreError,
2466 > {
2467 if key == &self.key {
2468 Ok(self.tokens.lock().unwrap().clone())
2469 } else {
2470 Ok(None)
2471 }
2472 }
2473
2474 async fn save(
2475 &self,
2476 _key: &meerkat_core::auth::TokenKey,
2477 tokens: &meerkat_core::auth::PersistedTokens,
2478 ) -> Result<(), meerkat_core::auth::TokenStoreError> {
2479 *self.tokens.lock().unwrap() = Some(tokens.clone());
2480 Ok(())
2481 }
2482
2483 async fn clear(
2484 &self,
2485 _key: &meerkat_core::auth::TokenKey,
2486 ) -> Result<(), meerkat_core::auth::TokenStoreError> {
2487 self.clear_called
2488 .store(true, std::sync::atomic::Ordering::Release);
2489 *self.tokens.lock().unwrap() = None;
2490 Ok(())
2491 }
2492
2493 async fn list(
2494 &self,
2495 ) -> Result<Vec<meerkat_core::auth::TokenKey>, meerkat_core::auth::TokenStoreError>
2496 {
2497 Ok(vec![self.key.clone()])
2498 }
2499
2500 fn backend_name(&self) -> &'static str {
2501 "recording-clear-test"
2502 }
2503 }
2504
2505 let handle = Arc::new(RuntimeAuthLeaseHandle::new());
2506 let generated =
2507 crate::protocol_auth_lease_lifecycle_publication::generated_auth_lease_handle(
2508 Arc::clone(&handle),
2509 )
2510 .expect("test AuthLeaseHandle must be generated-authority certified");
2511 let binding = auth_binding("dev", "clear_stage");
2512 let key = meerkat_core::auth::TokenKey::from_auth_binding(&binding);
2513
2514 let tokens = meerkat_core::auth::PersistedTokens {
2515 auth_mode: meerkat_core::auth::PersistedAuthMode::ChatgptOauth,
2516 primary_secret: Some("access-token".into()),
2517 refresh_token: Some("refresh-token".into()),
2518 id_token: None,
2519 expires_at: None,
2520 last_refresh: None,
2521 scopes: Vec::new(),
2522 account_id: None,
2523 metadata: serde_json::Value::Null,
2524 };
2525 meerkat_core::publish_token_lifecycle_acquired(&generated, &binding, &tokens)
2526 .expect("acquire lease");
2527 let store = RecordingStore {
2528 tokens: StdMutex::new(Some(tokens)),
2529 key: key.clone(),
2530 clear_called: std::sync::atomic::AtomicBool::new(false),
2531 };
2532
2533 let observer: Arc<dyn AuthLeaseReleaseObserver> = Arc::new(FailingReleaseObserver);
2534 handle.add_release_observer(Arc::downgrade(&observer));
2535
2536 let err =
2537 meerkat_core::clear_tokens_and_publish_lifecycle_released(&store, &generated, &binding)
2538 .await
2539 .expect_err("staged release observer fault must fail the clear typed");
2540 assert!(
2541 matches!(
2542 err,
2543 meerkat_core::TokenLifecycleClearError::AuthMachineRelease(_)
2544 ),
2545 "expected typed AuthMachineRelease fault, got: {err:?}"
2546 );
2547
2548 assert!(
2550 !store
2551 .clear_called
2552 .load(std::sync::atomic::Ordering::Acquire),
2553 "durable clear must not run when release staging faulted"
2554 );
2555 assert!(store.tokens.lock().unwrap().is_some());
2556
2557 let lease_key = meerkat_core::handles::LeaseKey::from_auth_binding(&binding);
2563 let snap = handle.snapshot(&lease_key);
2564 assert_eq!(snap.phase, Some(AuthLeasePhase::Valid));
2565 assert!(
2566 snap.credential_present,
2567 "aborted clear must leave the live lease aligned with the retained durable record, got {snap:?}"
2568 );
2569 }
2570
2571 fn apply_machine(
2578 authority: &mut auth_dsl::AuthMachineAuthority,
2579 input: auth_dsl::AuthMachineInput,
2580 ) -> Result<auth_dsl::AuthMachineTransition, auth_dsl::AuthMachineTransitionError> {
2581 auth_dsl::AuthMachineMutator::apply(authority, input)
2582 }
2583
2584 fn admit_browser_flow_input(flow_id: &str) -> auth_dsl::AuthMachineInput {
2585 auth_dsl::AuthMachineInput::AdmitOAuthBrowserFlow {
2586 flow_id: flow_id.to_string(),
2587 provider: "openai-chatgpt".to_string(),
2588 redirect_uri: "http://127.0.0.1/callback".to_string(),
2589 expires_at_millis: u64::MAX,
2590 max_outstanding_flows: 16,
2591 observed_global_outstanding_flows: 0,
2592 }
2593 }
2594
2595 fn admit_device_flow_input(flow_id: &str) -> auth_dsl::AuthMachineInput {
2596 auth_dsl::AuthMachineInput::AdmitOAuthDeviceFlow {
2597 flow_id: flow_id.to_string(),
2598 provider: "openai-chatgpt".to_string(),
2599 expires_at_millis: u64::MAX,
2600 max_outstanding_flows: 16,
2601 observed_global_outstanding_flows: 0,
2602 }
2603 }
2604
2605 #[test]
2611 fn begin_release_emits_machine_owned_drain_obligation_and_guards_release() {
2612 let mut machine = auth_dsl::AuthMachineAuthority::new();
2613 apply_machine(
2614 &mut machine,
2615 auth_dsl::AuthMachineInput::Acquire {
2616 expires_at_ts: Some(2_000_000_000),
2617 credential_published_at_millis: 1,
2618 },
2619 )
2620 .expect("acquire");
2621 apply_machine(&mut machine, admit_browser_flow_input("b-1")).expect("admit browser flow");
2622 apply_machine(&mut machine, admit_device_flow_input("d-1")).expect("admit device flow");
2623
2624 let err = apply_machine(&mut machine, auth_dsl::AuthMachineInput::Release)
2627 .expect_err("Release must not commit while flows are in flight");
2628 assert!(
2629 matches!(
2630 err,
2631 auth_dsl::AuthMachineTransitionError::GuardRejected { .. }
2632 ),
2633 "expected guard rejection, got: {err:?}"
2634 );
2635
2636 let transition = apply_machine(&mut machine, auth_dsl::AuthMachineInput::BeginRelease)
2639 .expect("begin release");
2640 assert!(machine.state().release_draining);
2641 let cancel = transition
2642 .effects()
2643 .iter()
2644 .find_map(|effect| match effect {
2645 auth_dsl::AuthMachineEffect::CancelOAuthFlowsForRelease {
2646 browser_flow_ids,
2647 device_flow_ids,
2648 } => Some((browser_flow_ids.clone(), device_flow_ids.clone())),
2649 _ => None,
2650 })
2651 .expect("BeginRelease with in-flight flows must emit CancelOAuthFlowsForRelease");
2652 assert_eq!(
2653 cancel.0.iter().collect::<Vec<_>>(),
2654 vec!["b-1"],
2655 "cancellation obligation must carry the in-flight browser flow"
2656 );
2657 assert_eq!(
2658 cancel.1.iter().collect::<Vec<_>>(),
2659 vec!["d-1"],
2660 "cancellation obligation must carry the in-flight device flow"
2661 );
2662
2663 let err = apply_machine(&mut machine, admit_browser_flow_input("b-2"))
2666 .expect_err("admission during release drain must be refused");
2667 assert!(
2668 matches!(
2669 err,
2670 auth_dsl::AuthMachineTransitionError::GuardRejected { .. }
2671 ),
2672 "expected guard rejection, got: {err:?}"
2673 );
2674
2675 apply_machine(
2677 &mut machine,
2678 auth_dsl::AuthMachineInput::ExpireOAuthBrowserFlow {
2679 flow_id: "b-1".to_string(),
2680 },
2681 )
2682 .expect("terminal browser cancellation");
2683 apply_machine(
2684 &mut machine,
2685 auth_dsl::AuthMachineInput::ExpireOAuthDeviceFlow {
2686 flow_id: "d-1".to_string(),
2687 },
2688 )
2689 .expect("terminal device cancellation");
2690 assert_eq!(machine.state().oauth_outstanding_flow_count, 0);
2691
2692 apply_machine(&mut machine, auth_dsl::AuthMachineInput::Release).expect("drained release");
2694 assert!(matches!(
2695 machine.state().lifecycle_phase,
2696 auth_dsl::AuthLifecyclePhase::Released
2697 ));
2698 assert!(!machine.state().release_draining);
2699 assert_eq!(machine.state().oauth_outstanding_flow_count, 0);
2700 assert!(machine.state().oauth_browser_flow_ids.is_empty());
2701 assert!(machine.state().oauth_device_flow_ids.is_empty());
2702 }
2703
2704 #[test]
2708 fn begin_release_without_flows_emits_no_drain_obligation() {
2709 let mut machine = auth_dsl::AuthMachineAuthority::new();
2710 apply_machine(
2711 &mut machine,
2712 auth_dsl::AuthMachineInput::Acquire {
2713 expires_at_ts: Some(2_000_000_000),
2714 credential_published_at_millis: 1,
2715 },
2716 )
2717 .expect("acquire");
2718
2719 let transition = apply_machine(&mut machine, auth_dsl::AuthMachineInput::BeginRelease)
2720 .expect("begin release");
2721 assert!(machine.state().release_draining);
2722 assert!(
2723 transition.effects().iter().all(|effect| !matches!(
2724 effect,
2725 auth_dsl::AuthMachineEffect::CancelOAuthFlowsForRelease { .. }
2726 )),
2727 "no cancellation obligation without in-flight flows"
2728 );
2729
2730 apply_machine(&mut machine, auth_dsl::AuthMachineInput::Release).expect("release");
2731 assert!(matches!(
2732 machine.state().lifecycle_phase,
2733 auth_dsl::AuthLifecyclePhase::Released
2734 ));
2735 assert!(!machine.state().release_draining);
2736 }
2737
2738 #[test]
2743 fn post_release_oauth_observation_inputs_are_total_noops() {
2744 let mut machine = auth_dsl::AuthMachineAuthority::new();
2745 apply_machine(&mut machine, auth_dsl::AuthMachineInput::Release).expect("release");
2746 assert!(matches!(
2747 machine.state().lifecycle_phase,
2748 auth_dsl::AuthLifecyclePhase::Released
2749 ));
2750 let before = machine.state().clone();
2751
2752 for input in [
2753 auth_dsl::AuthMachineInput::ExpireOAuthBrowserFlow {
2754 flow_id: "ghost-browser".to_string(),
2755 },
2756 auth_dsl::AuthMachineInput::ExpireOAuthDeviceFlow {
2757 flow_id: "ghost-device".to_string(),
2758 },
2759 auth_dsl::AuthMachineInput::FinishOAuthDevicePoll {
2760 flow_id: "ghost-poll".to_string(),
2761 },
2762 auth_dsl::AuthMachineInput::ConfirmOAuthDurableAdmission {
2763 observed_global_outstanding_flows: 0,
2764 max_outstanding_flows: 16,
2765 },
2766 auth_dsl::AuthMachineInput::BeginRelease,
2767 ] {
2768 let description = format!("{input:?}");
2769 let transition = apply_machine(&mut machine, input).unwrap_or_else(|err| {
2770 panic!("`{description}` must be a total no-op in Released, got: {err:?}")
2771 });
2772 assert!(
2773 transition.effects().is_empty(),
2774 "`{description}` must not emit effects in Released"
2775 );
2776 assert_eq!(
2777 machine.state(),
2778 &before,
2779 "`{description}` must not mutate Released state"
2780 );
2781 }
2782 }
2783
2784 #[test]
2789 fn stale_oauth_cleanup_observations_are_total_noops_in_live_phases() {
2790 let mut machine = auth_dsl::AuthMachineAuthority::new();
2791 apply_machine(
2792 &mut machine,
2793 auth_dsl::AuthMachineInput::Acquire {
2794 expires_at_ts: Some(2_000_000_000),
2795 credential_published_at_millis: 1,
2796 },
2797 )
2798 .expect("acquire");
2799 apply_machine(&mut machine, admit_browser_flow_input("b-1")).expect("admit browser flow");
2800 let before = machine.state().clone();
2801
2802 for input in [
2803 auth_dsl::AuthMachineInput::ExpireOAuthBrowserFlow {
2804 flow_id: "ghost-browser".to_string(),
2805 },
2806 auth_dsl::AuthMachineInput::ExpireOAuthDeviceFlow {
2807 flow_id: "ghost-device".to_string(),
2808 },
2809 auth_dsl::AuthMachineInput::FinishOAuthDevicePoll {
2810 flow_id: "ghost-poll".to_string(),
2811 },
2812 ] {
2813 let description = format!("{input:?}");
2814 apply_machine(&mut machine, input).unwrap_or_else(|err| {
2815 panic!("stale `{description}` must be a total no-op, got: {err:?}")
2816 });
2817 assert_eq!(
2818 machine.state(),
2819 &before,
2820 "stale `{description}` must not mutate live-phase state"
2821 );
2822 }
2823
2824 apply_machine(
2826 &mut machine,
2827 auth_dsl::AuthMachineInput::ExpireOAuthBrowserFlow {
2828 flow_id: "b-1".to_string(),
2829 },
2830 )
2831 .expect("present flow still expires");
2832 assert_eq!(machine.state().oauth_outstanding_flow_count, 0);
2833 }
2834}