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