Skip to main content

codlet_core/state/
session.rs

1//! Session validation state machine (RFC-006).
2//!
3//! Pure, storage-free. The store is responsible for querying and providing the
4//! record state; this module classifies the outcome without any I/O.
5
6use crate::secret::SubjectId;
7
8/// The result of validating a session secret against the store (RFC-006 §13.3).
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub enum SessionValidationOutcome {
11    /// Session is valid. The host application must still check authorization
12    /// (RFC-001: codlet authenticates; the host authorizes).
13    Authenticated {
14        /// The host-owned subject this session is bound to.
15        subject: SubjectId,
16        /// The opaque session record identifier (not a bearer credential).
17        session_id: crate::secret::SessionId,
18        /// Expiry as Unix seconds (UTC). For display / renewal decisions only;
19        /// the store already filtered out expired sessions.
20        expires_at: u64,
21    },
22    /// No valid session: cookie absent, not found, expired, or revoked.
23    /// All cases collapse to one response type to prevent enumeration
24    /// (INV-8, RFC-006 §13.5).
25    Unauthenticated,
26}
27
28impl SessionValidationOutcome {
29    /// Return `true` if the outcome is [`SessionValidationOutcome::Authenticated`].
30    #[must_use]
31    pub fn is_authenticated(&self) -> bool {
32        matches!(self, Self::Authenticated { .. })
33    }
34
35    /// Return the authenticated subject, if any.
36    #[must_use]
37    pub fn subject(&self) -> Option<&SubjectId> {
38        match self {
39            Self::Authenticated { subject, .. } => Some(subject),
40            Self::Unauthenticated => None,
41        }
42    }
43}
44
45/// Classify a session lookup from the store's query result.
46///
47/// `record` is `None` when the store found no active row for the given lookup
48/// key (expired, revoked, or never issued). When `Some`, the tuple is
49/// `(subject_id, session_id, expires_at_unix_secs)`.
50#[must_use]
51pub fn classify_session(
52    record: Option<(SubjectId, crate::secret::SessionId, u64)>,
53) -> SessionValidationOutcome {
54    match record {
55        Some((subject, session_id, expires_at)) => SessionValidationOutcome::Authenticated {
56            subject,
57            session_id,
58            expires_at,
59        },
60        None => SessionValidationOutcome::Unauthenticated,
61    }
62}
63
64#[cfg(test)]
65mod tests {
66    use super::*;
67
68    fn subject() -> SubjectId {
69        SubjectId::new("user-42".to_string())
70    }
71
72    fn sid() -> crate::secret::SessionId {
73        crate::secret::SessionId::new("sess-abc".to_string())
74    }
75
76    #[test]
77    fn some_record_authenticates() {
78        let out = classify_session(Some((subject(), sid(), 9_999_999)));
79        assert!(out.is_authenticated());
80        assert_eq!(out.subject().unwrap().as_str(), "user-42");
81    }
82
83    #[test]
84    fn none_is_unauthenticated() {
85        assert_eq!(
86            classify_session(None),
87            SessionValidationOutcome::Unauthenticated
88        );
89        assert!(!classify_session(None).is_authenticated());
90        assert!(classify_session(None).subject().is_none());
91    }
92
93    #[test]
94    fn authenticated_carries_session_id_and_expiry() {
95        let out = classify_session(Some((subject(), sid(), 12_345)));
96        if let SessionValidationOutcome::Authenticated {
97            session_id,
98            expires_at,
99            ..
100        } = out
101        {
102            assert_eq!(session_id.as_str(), "sess-abc");
103            assert_eq!(expires_at, 12_345);
104        } else {
105            panic!("expected Authenticated");
106        }
107    }
108}