Skip to main content

aura_authentication/
view.rs

1//! Authentication View Delta and Reducer
2//!
3//! This module provides view deltas and reducers for authentication facts.
4//! Views are derived state computed from the append-only fact log.
5//!
6//! # Architecture
7//!
8//! Views follow the pattern established in `aura-journal`:
9//! - Facts are immutable, append-only records
10//! - Views are derived state computed from facts
11//! - Reducers transform facts into view deltas
12//! - Views are incrementally updated by applying deltas
13//!
14//! # View Types
15//!
16//! - `AuthView`: Aggregated authentication state
17//! - `SessionView`: Active sessions for an authority
18//! - `RecoveryView`: Pending recovery operations
19//! - `GuardianApprovalView`: Guardian approval status
20
21use crate::facts::{AuthFact, AuthFactDelta, AuthFactReducer, RecoveryFailureReason};
22use crate::guards::RecoveryOperationType;
23use aura_core::types::identifiers::AuthorityId;
24use aura_signature::session::SessionScope;
25use serde::{Deserialize, Serialize};
26use std::collections::HashMap;
27
28// =============================================================================
29// Authentication View
30// =============================================================================
31
32/// Aggregated authentication view state
33///
34/// This view represents the current authentication state for an authority,
35/// including active sessions, pending challenges, and recovery operations.
36#[derive(Debug, Clone, Default)]
37pub struct AuthView {
38    /// Active sessions indexed by session ID
39    pub active_sessions: HashMap<String, SessionInfo>,
40
41    /// Pending challenges indexed by session ID
42    pub pending_challenges: HashMap<String, ChallengeInfo>,
43
44    /// Pending recovery operations indexed by request ID
45    pub pending_recoveries: HashMap<String, RecoveryInfo>,
46
47    /// Guardian approvals by request ID
48    pub guardian_approvals: HashMap<String, Vec<AuthorityId>>,
49
50    /// Recent authentication failures for rate limiting
51    pub recent_failures: Vec<FailureRecord>,
52
53    /// Recent recovery failures and denials
54    pub recent_recovery_failures: Vec<RecoveryFailureRecord>,
55}
56
57/// Information about an active session
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct SessionInfo {
60    /// Session ID
61    pub session_id: String,
62    /// Authority the session belongs to
63    pub authority_id: AuthorityId,
64    /// Session scope
65    pub scope: SessionScope,
66    /// Issued timestamp (ms)
67    pub issued_at_ms: u64,
68    /// Expiration timestamp (ms)
69    pub expires_at_ms: u64,
70}
71
72/// Information about a pending challenge
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct ChallengeInfo {
75    /// Session ID for the challenge
76    pub session_id: String,
77    /// Authority requesting authentication
78    pub authority_id: AuthorityId,
79    /// Expiration timestamp (ms)
80    pub expires_at_ms: u64,
81}
82
83/// Information about a pending recovery operation
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct RecoveryInfo {
86    /// Request ID
87    pub request_id: String,
88    /// Account being recovered
89    pub account_id: AuthorityId,
90    /// Requester authority
91    pub requester_id: AuthorityId,
92    /// Recovery operation type
93    pub operation_type: RecoveryOperationType,
94    /// Required number of guardian approvals
95    pub required_guardians: u32,
96    /// Current approval count
97    pub approval_count: u32,
98    /// Guardians who have approved
99    pub approvers: Vec<AuthorityId>,
100    /// Whether this is an emergency operation
101    pub is_emergency: bool,
102    /// Expiration timestamp (ms)
103    pub expires_at_ms: u64,
104}
105
106/// Record of a recovery denial/failure
107#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
108pub struct RecoveryFailureRecord {
109    /// Request ID that failed
110    pub request_id: String,
111    /// Account tied to the failure if known
112    pub account_id: Option<AuthorityId>,
113    /// Typed failure reason
114    pub reason: RecoveryFailureReason,
115}
116
117/// Record of an authentication failure
118#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct FailureRecord {
120    /// Session ID that failed
121    pub session_id: String,
122    /// Authority that failed
123    pub authority_id: AuthorityId,
124    /// Failure reason
125    pub reason: AuthFailureReason,
126    /// Failure timestamp (ms)
127    pub failed_at_ms: u64,
128}
129
130#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
131pub enum AuthFailureReason {
132    VerificationFailed { reason: String },
133}
134
135impl AuthView {
136    /// Create a new empty authentication view
137    pub fn new() -> Self {
138        Self::default()
139    }
140
141    /// Check if a session is active and not expired
142    pub fn is_session_active(&self, session_id: &str, now_ms: u64) -> bool {
143        self.active_sessions
144            .get(session_id)
145            .map(|s| s.expires_at_ms > now_ms)
146            .unwrap_or(false)
147    }
148
149    /// Get all active sessions for an authority
150    pub fn sessions_for_authority(&self, authority_id: AuthorityId) -> Vec<&SessionInfo> {
151        self.active_sessions
152            .values()
153            .filter(|s| s.authority_id == authority_id)
154            .collect()
155    }
156
157    /// Check if a recovery request has met its approval threshold
158    pub fn is_recovery_approved(&self, request_id: &str) -> bool {
159        self.pending_recoveries
160            .get(request_id)
161            .is_some_and(|r| r.approval_count >= r.required_guardians)
162    }
163
164    /// Get expired sessions that should be cleaned up
165    pub fn get_expired_sessions(&self, now_ms: u64) -> Vec<String> {
166        self.active_sessions
167            .iter()
168            .filter(|(_, s)| s.expires_at_ms <= now_ms)
169            .map(|(id, _)| id.clone())
170            .collect()
171    }
172
173    /// Get expired challenges that should be cleaned up
174    pub fn get_expired_challenges(&self, now_ms: u64) -> Vec<String> {
175        self.pending_challenges
176            .iter()
177            .filter(|(_, c)| c.expires_at_ms <= now_ms)
178            .map(|(id, _)| id.clone())
179            .collect()
180    }
181
182    /// Get expired recovery requests that should be cleaned up
183    pub fn get_expired_recoveries(&self, now_ms: u64) -> Vec<String> {
184        self.pending_recoveries
185            .iter()
186            .filter(|(_, r)| r.expires_at_ms <= now_ms)
187            .map(|(id, _)| id.clone())
188            .collect()
189    }
190}
191
192// =============================================================================
193// View Reducer
194// =============================================================================
195
196/// Reducer for authentication views
197///
198/// Transforms authentication facts into view state changes.
199#[derive(Debug, Clone, Default)]
200pub struct AuthViewReducer {
201    fact_reducer: AuthFactReducer,
202}
203
204impl AuthViewReducer {
205    /// Create a new view reducer
206    pub fn new() -> Self {
207        Self {
208            fact_reducer: AuthFactReducer::new(),
209        }
210    }
211
212    /// Apply a fact to the view and return the updated view
213    pub fn apply(&self, view: &mut AuthView, fact: &AuthFact) {
214        let delta = self.fact_reducer.reduce(fact);
215        self.apply_delta(view, delta, fact);
216    }
217
218    /// Apply a delta to the view
219    fn apply_delta(&self, view: &mut AuthView, delta: AuthFactDelta, fact: &AuthFact) {
220        match delta {
221            AuthFactDelta::NoChange => {}
222
223            AuthFactDelta::PendingChallenge {
224                session_id,
225                authority_id,
226                expires_at_ms,
227            } => {
228                view.pending_challenges.insert(
229                    session_id.clone(),
230                    ChallengeInfo {
231                        session_id,
232                        authority_id,
233                        expires_at_ms,
234                    },
235                );
236            }
237
238            AuthFactDelta::ActiveSession {
239                session_id,
240                authority_id,
241                scope,
242                expires_at_ms,
243            } => {
244                // Remove from pending challenges
245                view.pending_challenges.remove(&session_id);
246
247                // Get issued_at from the fact
248                let issued_at_ms = fact.timestamp_ms();
249
250                // Add to active sessions
251                view.active_sessions.insert(
252                    session_id.clone(),
253                    SessionInfo {
254                        session_id,
255                        authority_id,
256                        scope,
257                        issued_at_ms,
258                        expires_at_ms,
259                    },
260                );
261            }
262
263            AuthFactDelta::SessionRemoved { session_id } => {
264                view.active_sessions.remove(&session_id);
265            }
266
267            AuthFactDelta::PendingRecovery {
268                request_id,
269                account_id,
270                required_guardians,
271                expires_at_ms,
272                ..
273            } => {
274                // Extract additional info from the fact
275                let (requester_id, operation_type, is_emergency) =
276                    if let AuthFact::GuardianApprovalRequested {
277                        requester_id,
278                        operation_type,
279                        is_emergency,
280                        ..
281                    } = fact
282                    {
283                        (*requester_id, operation_type.clone(), *is_emergency)
284                    } else {
285                        (
286                            account_id,
287                            RecoveryOperationType::AccountAccessRecovery,
288                            false,
289                        )
290                    };
291
292                view.pending_recoveries.insert(
293                    request_id.clone(),
294                    RecoveryInfo {
295                        request_id,
296                        account_id,
297                        requester_id,
298                        operation_type,
299                        required_guardians,
300                        approval_count: 0,
301                        approvers: vec![],
302                        is_emergency,
303                        expires_at_ms,
304                    },
305                );
306            }
307
308            AuthFactDelta::GuardianApprovalAdded {
309                request_id,
310                guardian_id,
311            } => {
312                // Update approval count in pending recovery
313                if let Some(recovery) = view.pending_recoveries.get_mut(&request_id) {
314                    if !recovery.approvers.contains(&guardian_id) {
315                        recovery.approvers.push(guardian_id);
316                        recovery.approval_count += 1;
317                    }
318                }
319
320                // Also track in guardian_approvals map
321                view.guardian_approvals
322                    .entry(request_id)
323                    .or_default()
324                    .push(guardian_id);
325            }
326
327            AuthFactDelta::RecoveryCompleted { request_id } => {
328                view.pending_recoveries.remove(&request_id);
329            }
330
331            AuthFactDelta::RecoveryFailed { request_id, reason } => {
332                let account_id = view
333                    .pending_recoveries
334                    .get(&request_id)
335                    .map(|r| r.account_id);
336                view.recent_recovery_failures.push(RecoveryFailureRecord {
337                    request_id: request_id.clone(),
338                    account_id,
339                    reason,
340                });
341                view.pending_recoveries.remove(&request_id);
342            }
343        }
344
345        // Handle failure records from AuthFailed facts
346        if let AuthFact::AuthFailed {
347            session_id,
348            authority_id,
349            reason,
350            failed_at_ms,
351            ..
352        } = fact
353        {
354            view.recent_failures.push(FailureRecord {
355                session_id: session_id.clone(),
356                authority_id: *authority_id,
357                reason: AuthFailureReason::VerificationFailed {
358                    reason: reason.clone(),
359                },
360                failed_at_ms: *failed_at_ms,
361            });
362
363            // Keep only last 100 failures
364            if view.recent_failures.len() > 100 {
365                view.recent_failures.remove(0);
366            }
367        }
368    }
369
370    /// Reduce a sequence of facts into a view
371    pub fn reduce_all(&self, facts: &[AuthFact]) -> AuthView {
372        let mut view = AuthView::new();
373        for fact in facts {
374            self.apply(&mut view, fact);
375        }
376        view
377    }
378}
379
380// =============================================================================
381// Tests
382// =============================================================================
383
384#[cfg(test)]
385mod tests {
386    use super::*;
387    use aura_core::ContextId;
388    use aura_signature::session::SessionScope;
389
390    fn test_authority() -> AuthorityId {
391        AuthorityId::new_from_entropy([1u8; 32])
392    }
393
394    fn test_authority_2() -> AuthorityId {
395        AuthorityId::new_from_entropy([2u8; 32])
396    }
397
398    fn test_context_id() -> ContextId {
399        ContextId::new_from_entropy([9u8; 32])
400    }
401
402    #[test]
403    fn test_auth_view_new() {
404        let view = AuthView::new();
405        assert!(view.active_sessions.is_empty());
406        assert!(view.pending_challenges.is_empty());
407        assert!(view.pending_recoveries.is_empty());
408    }
409
410    /// Session progresses from created → active → revoked. If revocation
411    /// doesn't propagate, revoked sessions remain usable.
412    #[test]
413    fn test_session_lifecycle() {
414        let reducer = AuthViewReducer::new();
415        let mut view = AuthView::new();
416
417        // Issue a session
418        let fact = AuthFact::SessionIssued {
419            context_id: test_context_id(),
420            session_id: "session_123".to_string(),
421            authority_id: test_authority(),
422            device_id: None,
423            scope: SessionScope::Protocol {
424                protocol_type: "test".to_string(),
425            },
426            issued_at_ms: 1000,
427            expires_at_ms: 2000,
428        };
429
430        reducer.apply(&mut view, &fact);
431        assert!(view.active_sessions.contains_key("session_123"));
432        assert!(view.is_session_active("session_123", 1500));
433        assert!(!view.is_session_active("session_123", 2500)); // Expired
434
435        // Revoke the session
436        let revoke_fact = AuthFact::SessionRevoked {
437            context_id: test_context_id(),
438            session_id: "session_123".to_string(),
439            revoked_by: test_authority(),
440            reason: "User requested".to_string(),
441            revoked_at_ms: 1500,
442        };
443
444        reducer.apply(&mut view, &revoke_fact);
445        assert!(!view.active_sessions.contains_key("session_123"));
446    }
447
448    #[test]
449    fn test_recovery_approval_flow() {
450        let reducer = AuthViewReducer::new();
451        let mut view = AuthView::new();
452
453        // Request guardian approval
454        let request_fact = AuthFact::GuardianApprovalRequested {
455            context_id: test_context_id(),
456            request_id: "recovery_123".to_string(),
457            account_id: test_authority(),
458            requester_id: test_authority(),
459            operation_type: RecoveryOperationType::DeviceKeyRecovery,
460            required_guardians: 2,
461            is_emergency: false,
462            justification: "Lost device".to_string(),
463            requested_at_ms: 1000,
464            expires_at_ms: 86400000,
465        };
466
467        reducer.apply(&mut view, &request_fact);
468        assert!(view.pending_recoveries.contains_key("recovery_123"));
469        assert!(!view.is_recovery_approved("recovery_123"));
470
471        // First guardian approves
472        let approve1 = AuthFact::GuardianApproved {
473            context_id: test_context_id(),
474            request_id: "recovery_123".to_string(),
475            guardian_id: test_authority(),
476            signature: vec![0u8; 64],
477            justification: "Approved".to_string(),
478            approved_at_ms: 2000,
479        };
480
481        reducer.apply(&mut view, &approve1);
482        assert_eq!(
483            view.pending_recoveries
484                .get("recovery_123")
485                .map(|r| r.approval_count),
486            Some(1)
487        );
488        assert!(!view.is_recovery_approved("recovery_123"));
489
490        // Second guardian approves
491        let approve2 = AuthFact::GuardianApproved {
492            context_id: test_context_id(),
493            request_id: "recovery_123".to_string(),
494            guardian_id: test_authority_2(),
495            signature: vec![0u8; 64],
496            justification: "Approved".to_string(),
497            approved_at_ms: 3000,
498        };
499
500        reducer.apply(&mut view, &approve2);
501        assert_eq!(
502            view.pending_recoveries
503                .get("recovery_123")
504                .map(|r| r.approval_count),
505            Some(2)
506        );
507        assert!(view.is_recovery_approved("recovery_123"));
508    }
509
510    #[test]
511    fn test_failure_tracking() {
512        let reducer = AuthViewReducer::new();
513        let mut view = AuthView::new();
514
515        let fail_fact = AuthFact::AuthFailed {
516            context_id: test_context_id(),
517            session_id: "session_456".to_string(),
518            authority_id: test_authority(),
519            reason: "Invalid signature".to_string(),
520            failed_at_ms: 1000,
521        };
522
523        reducer.apply(&mut view, &fail_fact);
524        assert_eq!(view.recent_failures.len(), 1);
525        assert_eq!(view.recent_failures[0].session_id, "session_456");
526        assert_eq!(
527            view.recent_failures[0].reason,
528            AuthFailureReason::VerificationFailed {
529                reason: "Invalid signature".to_string(),
530            }
531        );
532    }
533
534    #[test]
535    fn test_recovery_failure_tracking() {
536        let reducer = AuthViewReducer::new();
537        let mut view = AuthView::new();
538
539        let request_fact = AuthFact::GuardianApprovalRequested {
540            context_id: test_context_id(),
541            request_id: "recovery_789".to_string(),
542            account_id: test_authority(),
543            requester_id: test_authority_2(),
544            operation_type: RecoveryOperationType::AccountAccessRecovery,
545            required_guardians: 2,
546            is_emergency: false,
547            justification: "Need recovery".to_string(),
548            requested_at_ms: 1000,
549            expires_at_ms: 5000,
550        };
551        reducer.apply(&mut view, &request_fact);
552
553        let fail_fact = AuthFact::GuardianDenied {
554            context_id: test_context_id(),
555            request_id: "recovery_789".to_string(),
556            guardian_id: test_authority_2(),
557            reason: "guardian denied".to_string(),
558            denied_at_ms: 1500,
559        };
560        reducer.apply(&mut view, &fail_fact);
561
562        assert!(!view.pending_recoveries.contains_key("recovery_789"));
563        assert_eq!(view.recent_recovery_failures.len(), 1);
564        assert_eq!(
565            view.recent_recovery_failures[0].reason,
566            RecoveryFailureReason::GuardianDenied {
567                reason: "guardian denied".to_string(),
568            }
569        );
570    }
571
572    /// Expired sessions are detected at the given timestamp — prevents
573    /// stale sessions from being treated as active.
574    #[test]
575    fn test_expired_session_detection() {
576        let view = AuthView {
577            active_sessions: {
578                let mut map = HashMap::new();
579                map.insert(
580                    "session_old".to_string(),
581                    SessionInfo {
582                        session_id: "session_old".to_string(),
583                        authority_id: test_authority(),
584                        scope: SessionScope::Protocol {
585                            protocol_type: "test".to_string(),
586                        },
587                        issued_at_ms: 0,
588                        expires_at_ms: 1000,
589                    },
590                );
591                map.insert(
592                    "session_new".to_string(),
593                    SessionInfo {
594                        session_id: "session_new".to_string(),
595                        authority_id: test_authority(),
596                        scope: SessionScope::Protocol {
597                            protocol_type: "test".to_string(),
598                        },
599                        issued_at_ms: 500,
600                        expires_at_ms: 2000,
601                    },
602                );
603                map
604            },
605            ..Default::default()
606        };
607
608        let expired = view.get_expired_sessions(1500);
609        assert_eq!(expired.len(), 1);
610        assert!(expired.contains(&"session_old".to_string()));
611    }
612
613    #[test]
614    fn test_sessions_for_authority() {
615        let view = AuthView {
616            active_sessions: {
617                let mut map = HashMap::new();
618                map.insert(
619                    "session_1".to_string(),
620                    SessionInfo {
621                        session_id: "session_1".to_string(),
622                        authority_id: test_authority(),
623                        scope: SessionScope::Protocol {
624                            protocol_type: "test".to_string(),
625                        },
626                        issued_at_ms: 0,
627                        expires_at_ms: 2000,
628                    },
629                );
630                map.insert(
631                    "session_2".to_string(),
632                    SessionInfo {
633                        session_id: "session_2".to_string(),
634                        authority_id: test_authority_2(),
635                        scope: SessionScope::Protocol {
636                            protocol_type: "test".to_string(),
637                        },
638                        issued_at_ms: 0,
639                        expires_at_ms: 2000,
640                    },
641                );
642                map
643            },
644            ..Default::default()
645        };
646
647        let sessions = view.sessions_for_authority(test_authority());
648        assert_eq!(sessions.len(), 1);
649        assert_eq!(sessions[0].session_id, "session_1");
650    }
651
652    #[test]
653    fn test_reduce_all() {
654        let reducer = AuthViewReducer::new();
655
656        let facts = vec![
657            AuthFact::SessionIssued {
658                context_id: test_context_id(),
659                session_id: "session_1".to_string(),
660                authority_id: test_authority(),
661                device_id: None,
662                scope: SessionScope::Protocol {
663                    protocol_type: "test".to_string(),
664                },
665                issued_at_ms: 1000,
666                expires_at_ms: 2000,
667            },
668            AuthFact::SessionIssued {
669                context_id: test_context_id(),
670                session_id: "session_2".to_string(),
671                authority_id: test_authority_2(),
672                device_id: None,
673                scope: SessionScope::Protocol {
674                    protocol_type: "test".to_string(),
675                },
676                issued_at_ms: 1500,
677                expires_at_ms: 2500,
678            },
679        ];
680
681        let view = reducer.reduce_all(&facts);
682        assert_eq!(view.active_sessions.len(), 2);
683    }
684
685    /// Disjoint session facts commute under reduction — peers reducing
686    /// the same facts in different order get the same view.
687    #[test]
688    fn test_reduce_all_commutes_for_disjoint_sessions() {
689        let reducer = AuthViewReducer::new();
690        let fact_a = AuthFact::SessionIssued {
691            context_id: test_context_id(),
692            session_id: "session_a".to_string(),
693            authority_id: test_authority(),
694            device_id: None,
695            scope: SessionScope::Protocol {
696                protocol_type: "test".to_string(),
697            },
698            issued_at_ms: 1000,
699            expires_at_ms: 2000,
700        };
701        let fact_b = AuthFact::SessionIssued {
702            context_id: test_context_id(),
703            session_id: "session_b".to_string(),
704            authority_id: test_authority_2(),
705            device_id: None,
706            scope: SessionScope::Protocol {
707                protocol_type: "test".to_string(),
708            },
709            issued_at_ms: 1000,
710            expires_at_ms: 2000,
711        };
712
713        let view1 = reducer.reduce_all(&[fact_a.clone(), fact_b.clone()]);
714        let view2 = reducer.reduce_all(&[fact_b, fact_a]);
715
716        assert_eq!(view1.active_sessions.len(), view2.active_sessions.len());
717        assert!(view1.active_sessions.contains_key("session_a"));
718        assert!(view1.active_sessions.contains_key("session_b"));
719        assert!(view2.active_sessions.contains_key("session_a"));
720        assert!(view2.active_sessions.contains_key("session_b"));
721    }
722}