1use std::collections::HashMap;
23
24pub const NEEDS_REAUTH_THRESHOLD: u32 = 5;
32
33#[derive(Debug, Clone, PartialEq)]
35pub enum CredentialState {
36 Fresh,
38 Refreshing { prior_failures: u32 },
47 Failed {
50 consecutive_failures: u32,
51 last_error: String,
52 },
53 NeedsReauth {
62 since_unix: u64,
63 reason: String,
64 rejected: bool,
65 },
66}
67
68#[derive(Debug, Clone, PartialEq)]
70pub enum RefreshErrorKind {
71 Transient(String),
73 PermanentAuthFailure(String),
77}
78
79impl RefreshErrorKind {
80 fn message(&self) -> &str {
81 match self {
82 RefreshErrorKind::Transient(m) | RefreshErrorKind::PermanentAuthFailure(m) => m,
83 }
84 }
85}
86
87#[derive(Debug, Clone, PartialEq)]
88pub enum Command {
89 Register { id: String },
90 #[cfg(test)]
91 Unregister { id: String },
92 CheckRequested { id: String },
95 FreshnessChecked {
99 id: String,
100 is_fresh: bool,
101 generation: u64,
102 },
103 RefreshSucceeded { id: String, generation: u64 },
106 RefreshFailed {
107 id: String,
108 error: RefreshErrorKind,
109 generation: u64,
110 },
111}
112
113#[derive(Debug, Clone, PartialEq)]
114pub enum Event {
115 RunFreshnessCheck { id: String, generation: u64 },
126 RunRefresh { id: String, generation: u64 },
129 AlreadyInFlight { id: String },
133 Unregistered { id: String },
135 BecameFresh { id: String },
137 BecameFailed {
138 id: String,
139 consecutive_failures: u32,
140 error: String,
141 },
142 BecameNeedsReauth { id: String, reason: String },
143}
144
145fn now_unix() -> u64 {
146 agentmux_common::time::now_secs_u64()
147}
148
149pub fn update(
162 states: &mut HashMap<String, CredentialState>,
163 generations: &mut HashMap<String, u64>,
164 next_generation: &mut u64,
165 cmd: Command,
166) -> Vec<Event> {
167 match cmd {
168 Command::Register { id } => {
169 let generation = *next_generation;
176 *next_generation += 1;
177 generations.insert(id.clone(), generation);
178 states.insert(id, CredentialState::Fresh);
179 Vec::new()
180 }
181 #[cfg(test)]
182 Command::Unregister { id } => {
183 generations.remove(&id);
188 states.remove(&id);
189 vec![Event::Unregistered { id }]
190 }
191 Command::CheckRequested { id } => match states.get(&id) {
192 None => vec![Event::Unregistered { id }],
193 Some(CredentialState::Refreshing { .. }) => vec![Event::AlreadyInFlight { id }],
194 Some(state) => {
195 let prior_failures = match state {
201 CredentialState::Failed {
202 consecutive_failures,
203 ..
204 } => *consecutive_failures,
205 CredentialState::Fresh | CredentialState::NeedsReauth { .. } => 0,
206 CredentialState::Refreshing { .. } => unreachable!("matched above"),
207 };
208 states.insert(id.clone(), CredentialState::Refreshing { prior_failures });
209 let generation = *generations.get(&id).unwrap_or(&0);
210 vec![Event::RunFreshnessCheck { id, generation }]
211 }
212 },
213 Command::FreshnessChecked {
214 id,
215 is_fresh,
216 generation,
217 } => {
218 if generations.get(&id).copied() != Some(generation) {
219 return Vec::new();
225 }
226 if is_fresh {
227 let became_fresh = !matches!(states.get(&id), Some(CredentialState::Fresh));
228 states.insert(id.clone(), CredentialState::Fresh);
229 if became_fresh {
230 vec![Event::BecameFresh { id }]
231 } else {
232 Vec::new()
233 }
234 } else {
235 vec![Event::RunRefresh { id, generation }]
237 }
238 }
239 Command::RefreshSucceeded { id, generation } => {
240 if generations.get(&id).copied() != Some(generation) {
241 return Vec::new();
243 }
244 states.insert(id.clone(), CredentialState::Fresh);
245 vec![Event::BecameFresh { id }]
246 }
247 Command::RefreshFailed {
248 id,
249 error,
250 generation,
251 } => {
252 if generations.get(&id).copied() != Some(generation) {
253 return Vec::new();
255 }
256 let reason = error.message().to_string();
257 let permanent = matches!(error, RefreshErrorKind::PermanentAuthFailure(_));
258 let prior_failures = match states.get(&id) {
259 Some(CredentialState::Refreshing { prior_failures }) => *prior_failures,
260 _ => 0,
261 };
262 let consecutive_failures = prior_failures + 1;
263 if permanent || consecutive_failures >= NEEDS_REAUTH_THRESHOLD {
264 states.insert(
265 id.clone(),
266 CredentialState::NeedsReauth {
267 since_unix: now_unix(),
268 reason: reason.clone(),
269 rejected: permanent,
270 },
271 );
272 vec![Event::BecameNeedsReauth { id, reason }]
273 } else {
274 states.insert(
275 id.clone(),
276 CredentialState::Failed {
277 consecutive_failures,
278 last_error: reason.clone(),
279 },
280 );
281 vec![Event::BecameFailed {
282 id,
283 consecutive_failures,
284 error: reason,
285 }]
286 }
287 }
288 }
289}
290
291#[cfg(test)]
292mod tests {
293 use super::*;
294
295 #[derive(Default)]
299 struct Harness {
300 states: HashMap<String, CredentialState>,
301 generations: HashMap<String, u64>,
302 next_generation: u64,
303 }
304
305 impl Harness {
306 fn dispatch(&mut self, cmd: Command) -> Vec<Event> {
307 update(
308 &mut self.states,
309 &mut self.generations,
310 &mut self.next_generation,
311 cmd,
312 )
313 }
314
315 fn get(&self, id: &str) -> Option<&CredentialState> {
316 self.states.get(id)
317 }
318
319 fn contains_key(&self, id: &str) -> bool {
320 self.states.contains_key(id)
321 }
322
323 fn generation_of(&self, id: &str) -> u64 {
324 *self.generations.get(id).expect("id should be registered")
325 }
326 }
327
328 fn registered() -> Harness {
329 let mut h = Harness::default();
330 h.dispatch(Command::Register { id: "cred".into() });
331 h
332 }
333
334 #[test]
335 fn register_starts_fresh() {
336 let m = registered();
337 assert_eq!(m.get("cred"), Some(&CredentialState::Fresh));
338 }
339
340 #[test]
341 fn check_on_fresh_transitions_to_refreshing_and_requests_a_check() {
342 let mut m = registered();
343 let gen = m.generation_of("cred");
344 let events = m.dispatch(Command::CheckRequested { id: "cred".into() });
345 assert_eq!(
346 events,
347 vec![Event::RunFreshnessCheck {
348 id: "cred".into(),
349 generation: gen
350 }]
351 );
352 assert_eq!(
353 m.get("cred"),
354 Some(&CredentialState::Refreshing { prior_failures: 0 })
355 );
356 }
357
358 #[test]
359 fn a_second_check_while_refreshing_gets_already_in_flight_not_a_second_run() {
360 let mut m = registered();
361 m.dispatch(Command::CheckRequested { id: "cred".into() });
362 let events = m.dispatch(Command::CheckRequested { id: "cred".into() });
363 assert_eq!(events, vec![Event::AlreadyInFlight { id: "cred".into() }]);
364 assert_eq!(
367 m.get("cred"),
368 Some(&CredentialState::Refreshing { prior_failures: 0 })
369 );
370 }
371
372 #[test]
373 fn freshness_checked_true_returns_to_fresh_and_reports_became_fresh() {
374 let mut m = registered();
375 let gen = m.generation_of("cred");
376 m.dispatch(Command::CheckRequested { id: "cred".into() });
377 let events = m.dispatch(Command::FreshnessChecked {
378 id: "cred".into(),
379 is_fresh: true,
380 generation: gen,
381 });
382 assert_eq!(m.get("cred"), Some(&CredentialState::Fresh));
383 assert_eq!(events, vec![Event::BecameFresh { id: "cred".into() }]);
384 }
385
386 #[test]
387 fn freshness_checked_false_requests_a_refresh_and_stays_refreshing() {
388 let mut m = registered();
389 let gen = m.generation_of("cred");
390 m.dispatch(Command::CheckRequested { id: "cred".into() });
391 let events = m.dispatch(Command::FreshnessChecked {
392 id: "cred".into(),
393 is_fresh: false,
394 generation: gen,
395 });
396 assert_eq!(
397 events,
398 vec![Event::RunRefresh {
399 id: "cred".into(),
400 generation: gen
401 }]
402 );
403 assert_eq!(
404 m.get("cred"),
405 Some(&CredentialState::Refreshing { prior_failures: 0 })
406 );
407 }
408
409 #[test]
410 fn refresh_succeeded_returns_to_fresh_and_resets_any_failure_count() {
411 let mut m = registered();
412 let gen = m.generation_of("cred");
413 m.dispatch(Command::CheckRequested { id: "cred".into() });
414 m.dispatch(Command::RefreshFailed {
415 id: "cred".into(),
416 error: RefreshErrorKind::Transient("boom".into()),
417 generation: gen,
418 });
419 m.dispatch(Command::CheckRequested { id: "cred".into() });
420 let events = m.dispatch(Command::RefreshSucceeded {
421 id: "cred".into(),
422 generation: gen,
423 });
424 assert_eq!(events, vec![Event::BecameFresh { id: "cred".into() }]);
425 assert_eq!(m.get("cred"), Some(&CredentialState::Fresh));
426 }
427
428 #[test]
429 fn refresh_failed_transient_moves_to_failed_with_count_one() {
430 let mut m = registered();
431 let gen = m.generation_of("cred");
432 m.dispatch(Command::CheckRequested { id: "cred".into() });
433 let events = m.dispatch(Command::RefreshFailed {
434 id: "cred".into(),
435 error: RefreshErrorKind::Transient("network blip".into()),
436 generation: gen,
437 });
438 assert_eq!(
439 events,
440 vec![Event::BecameFailed {
441 id: "cred".into(),
442 consecutive_failures: 1,
443 error: "network blip".into(),
444 }]
445 );
446 assert_eq!(
447 m.get("cred"),
448 Some(&CredentialState::Failed {
449 consecutive_failures: 1,
450 last_error: "network blip".into(),
451 })
452 );
453 }
454
455 #[test]
456 fn a_failed_refresh_does_not_mark_the_credential_fresh() {
457 let mut m = registered();
460 let gen = m.generation_of("cred");
461 m.dispatch(Command::CheckRequested { id: "cred".into() });
462 m.dispatch(Command::RefreshFailed {
463 id: "cred".into(),
464 error: RefreshErrorKind::Transient("nope".into()),
465 generation: gen,
466 });
467 assert!(!matches!(m.get("cred"), Some(CredentialState::Fresh)));
468 let events = m.dispatch(Command::CheckRequested { id: "cred".into() });
469 assert_eq!(
470 events,
471 vec![Event::RunFreshnessCheck {
472 id: "cred".into(),
473 generation: gen
474 }]
475 );
476 }
477
478 #[test]
479 fn permanent_auth_failure_skips_straight_to_needs_reauth_on_the_first_failure() {
480 let mut m = registered();
481 let gen = m.generation_of("cred");
482 m.dispatch(Command::CheckRequested { id: "cred".into() });
483 let events = m.dispatch(Command::RefreshFailed {
484 id: "cred".into(),
485 error: RefreshErrorKind::PermanentAuthFailure("invalid_grant".into()),
486 generation: gen,
487 });
488 assert!(matches!(
489 events.as_slice(),
490 [Event::BecameNeedsReauth { .. }]
491 ));
492 assert!(matches!(
493 m.get("cred"),
494 Some(CredentialState::NeedsReauth { rejected: true, .. })
495 ));
496 }
497
498 #[test]
499 fn five_consecutive_transient_failures_escalate_to_needs_reauth() {
500 let mut m = registered();
501 let gen = m.generation_of("cred");
502 for i in 1..=(NEEDS_REAUTH_THRESHOLD - 1) {
503 m.dispatch(Command::CheckRequested { id: "cred".into() });
504 let events = m.dispatch(Command::RefreshFailed {
505 id: "cred".into(),
506 error: RefreshErrorKind::Transient(format!("attempt {i}")),
507 generation: gen,
508 });
509 assert!(
510 matches!(events.as_slice(), [Event::BecameFailed { .. }]),
511 "attempt {i} should still be a transient Failed, not NeedsReauth yet"
512 );
513 }
514 m.dispatch(Command::CheckRequested { id: "cred".into() });
515 let events = m.dispatch(Command::RefreshFailed {
516 id: "cred".into(),
517 error: RefreshErrorKind::Transient("final straw".into()),
518 generation: gen,
519 });
520 assert!(matches!(
521 events.as_slice(),
522 [Event::BecameNeedsReauth { .. }]
523 ));
524 assert!(matches!(
527 m.get("cred"),
528 Some(CredentialState::NeedsReauth { rejected: false, .. })
529 ));
530 }
531
532 #[test]
533 fn check_requested_on_needs_reauth_still_runs_a_fresh_check_on_demand() {
534 let mut m = registered();
538 let gen = m.generation_of("cred");
539 m.dispatch(Command::CheckRequested { id: "cred".into() });
540 m.dispatch(Command::RefreshFailed {
541 id: "cred".into(),
542 error: RefreshErrorKind::PermanentAuthFailure("dead".into()),
543 generation: gen,
544 });
545 let events = m.dispatch(Command::CheckRequested { id: "cred".into() });
546 assert_eq!(
547 events,
548 vec![Event::RunFreshnessCheck {
549 id: "cred".into(),
550 generation: gen
551 }]
552 );
553 }
554
555 #[test]
556 fn check_requested_on_an_unregistered_id_reports_unregistered() {
557 let mut m = Harness::default();
558 let events = m.dispatch(Command::CheckRequested { id: "nope".into() });
559 assert_eq!(events, vec![Event::Unregistered { id: "nope".into() }]);
560 }
561
562 #[test]
563 fn unregister_then_check_reports_unregistered() {
564 let mut m = registered();
565 m.dispatch(Command::Unregister { id: "cred".into() });
566 let events = m.dispatch(Command::CheckRequested { id: "cred".into() });
567 assert_eq!(events, vec![Event::Unregistered { id: "cred".into() }]);
568 }
569
570 #[test]
571 fn re_registering_an_id_that_is_mid_refresh_does_not_leave_anything_orphaned() {
572 let mut m = registered();
579 m.dispatch(Command::CheckRequested { id: "cred".into() });
580 assert_eq!(
581 m.get("cred"),
582 Some(&CredentialState::Refreshing { prior_failures: 0 })
583 );
584 m.dispatch(Command::Register { id: "cred".into() });
585 assert_eq!(m.get("cred"), Some(&CredentialState::Fresh));
586 let new_gen = m.generation_of("cred");
587 let events = m.dispatch(Command::CheckRequested { id: "cred".into() });
588 assert_eq!(
589 events,
590 vec![Event::RunFreshnessCheck {
591 id: "cred".into(),
592 generation: new_gen
593 }]
594 );
595 }
596
597 #[test]
598 fn a_stale_completion_after_unregister_does_not_resurrect_a_phantom_entry() {
599 fn mid_check_then_unregistered() -> (Harness, u64) {
605 let mut m = registered();
606 let gen = m.generation_of("cred");
607 m.dispatch(Command::CheckRequested { id: "cred".into() });
608 m.dispatch(Command::Unregister { id: "cred".into() });
609 assert!(!m.contains_key("cred"));
610 (m, gen)
611 }
612
613 let stale_completion_builders: [fn(u64) -> Command; 4] = [
614 |gen| Command::FreshnessChecked {
615 id: "cred".into(),
616 is_fresh: true,
617 generation: gen,
618 },
619 |gen| Command::FreshnessChecked {
620 id: "cred".into(),
621 is_fresh: false,
622 generation: gen,
623 },
624 |gen| Command::RefreshSucceeded {
625 id: "cred".into(),
626 generation: gen,
627 },
628 |gen| Command::RefreshFailed {
629 id: "cred".into(),
630 error: RefreshErrorKind::Transient("late".into()),
631 generation: gen,
632 },
633 ];
634 for build_stale_completion in stale_completion_builders {
635 let (mut m, gen) = mid_check_then_unregistered();
636 let events = m.dispatch(build_stale_completion(gen));
637 assert_eq!(events, Vec::new());
638 assert!(
639 !m.contains_key("cred"),
640 "a stale completion must not resurrect the entry"
641 );
642 }
643 }
644
645 #[test]
646 fn a_stale_completion_after_re_register_does_not_clobber_the_new_epoch() {
647 let mut m = registered();
654 let old_gen = m.generation_of("cred");
655 m.dispatch(Command::CheckRequested { id: "cred".into() });
656
657 m.dispatch(Command::Register { id: "cred".into() });
660 assert_eq!(m.get("cred"), Some(&CredentialState::Fresh));
661 let new_gen = m.generation_of("cred");
662 assert_ne!(old_gen, new_gen);
663
664 let events = m.dispatch(Command::RefreshFailed {
666 id: "cred".into(),
667 error: RefreshErrorKind::PermanentAuthFailure("stale invalid_grant".into()),
668 generation: old_gen,
669 });
670 assert_eq!(events, Vec::new());
671 assert_eq!(
672 m.get("cred"),
673 Some(&CredentialState::Fresh),
674 "a stale completion from a superseded registration must not mask the new one"
675 );
676 }
677
678 #[test]
679 fn a_stale_completion_survives_an_unregister_then_re_register_of_the_same_id() {
680 let mut m = registered();
690 let old_gen = m.generation_of("cred");
691 m.dispatch(Command::CheckRequested { id: "cred".into() });
692
693 m.dispatch(Command::Unregister { id: "cred".into() });
694 m.dispatch(Command::Register { id: "cred".into() });
695 let new_gen = m.generation_of("cred");
696 assert_ne!(
697 old_gen, new_gen,
698 "unregister-then-register must never reissue a prior generation number"
699 );
700
701 let events = m.dispatch(Command::RefreshFailed {
702 id: "cred".into(),
703 error: RefreshErrorKind::PermanentAuthFailure("stale invalid_grant".into()),
704 generation: old_gen,
705 });
706 assert_eq!(events, Vec::new());
707 assert_eq!(
708 m.get("cred"),
709 Some(&CredentialState::Fresh),
710 "a stale completion from before the unregister must not mask the re-registered credential"
711 );
712 }
713}