Skip to main content

codlet_core/auth/
session.rs

1//! Session manager (RFC-013 §3).
2//!
3//! [`SessionManager`] composes [`SessionStore`], [`SecretHasher`], [`Clock`],
4//! [`CookiePolicy`], and [`AuditSink`] into the three session operations:
5//! issue (after a won claim), validate (on every authenticated request), and
6//! revoke (on logout or incident response).
7
8use crate::audit::{AuditSink, CodeAuthEvent};
9use crate::clock::Clock;
10use crate::cookie::CookiePolicy;
11use crate::hashing::{KeyProvider, SecretDomain, SecretHasher};
12use crate::rng::RandomSource;
13use crate::secret::{SessionId, SessionSecret};
14use crate::state::{SessionValidationOutcome, classify_session};
15use crate::store::code::expires_at_from_ttl;
16use crate::store::session::{SessionRecord, SessionStore};
17
18use super::error::{IssuedSession, RedeemSuccess, SessionError};
19
20/// Manages session issuance, validation, and revocation (RFC-013 §3).
21///
22/// Session issuance requires a [`RedeemSuccess`] proof to enforce the
23/// invariant that sessions can only be created after a confirmed won claim
24/// (RFC-013 §5, acceptance checklist: "session issuance cannot occur before
25/// claim success").
26pub struct SessionManager<SS, K, C, A> {
27    store: SS,
28    hasher: SecretHasher<K>,
29    clock: C,
30    audit: A,
31    cookie_policy: CookiePolicy,
32}
33
34impl<SS, K, C, A> SessionManager<SS, K, C, A>
35where
36    SS: SessionStore,
37    K: KeyProvider,
38    C: Clock,
39    A: AuditSink,
40{
41    /// Construct a session manager.
42    #[must_use]
43    pub fn new(
44        store: SS,
45        hasher: SecretHasher<K>,
46        clock: C,
47        audit: A,
48        cookie_policy: CookiePolicy,
49    ) -> Self {
50        Self {
51            store,
52            hasher,
53            clock,
54            audit,
55            cookie_policy,
56        }
57    }
58
59    /// Issue a new session for the authenticated subject.
60    ///
61    /// Requires a [`RedeemSuccess`] proof so this cannot be called without a
62    /// prior confirmed won claim. Generates a high-entropy session secret,
63    /// derives the HMAC lookup key, inserts the record, and returns the
64    /// `Set-Cookie` header value.
65    ///
66    /// The plaintext session secret leaves this function only inside
67    /// [`IssuedSession::set_cookie`]; it is never stored or logged by codlet.
68    ///
69    /// # Errors
70    /// Returns [`SessionError::Internal`] if the RNG, hasher, or store fails.
71    pub async fn issue<R: RandomSource>(
72        &self,
73        success: &RedeemSuccess,
74        session_id: SessionId,
75        rng: &mut R,
76    ) -> Result<IssuedSession, SessionError> {
77        // Generate a high-entropy session secret (256 bits / 32 bytes).
78        let mut raw = [0u8; 32];
79        rng.fill_bytes(&mut raw)
80            .map_err(|e| SessionError::Internal {
81                cause: format!("rng: {e}"),
82                public: crate::error::PublicSessionError::TemporarilyUnavailable,
83            })?;
84
85        // Hex-encode for cookie transport (64 ASCII chars, URL-safe).
86        let secret_hex = hex_lower(&raw);
87        let secret = SessionSecret::new(secret_hex.clone());
88
89        let (lookup_key, key_version) = self
90            .hasher
91            .lookup_key(SecretDomain::Session, secret.expose())
92            .map_err(SessionError::from_key)?;
93
94        let now = self.clock.unix_now();
95        let expires_at = expires_at_from_ttl(now, self.cookie_policy.max_age_duration());
96
97        self.store
98            .insert_session(SessionRecord {
99                id: session_id.clone(),
100                lookup_key,
101                key_version,
102                subject: success.subject.clone(),
103                created_at: now,
104                expires_at,
105            })
106            .await
107            .map_err(SessionError::from_store)?;
108
109        self.audit.record(CodeAuthEvent::SessionIssued {
110            session_id: session_id.clone(),
111            subject_id: success.subject.clone(),
112        });
113
114        let set_cookie = self.cookie_policy.build_set_cookie(secret.expose());
115        Ok(IssuedSession {
116            session_id,
117            set_cookie,
118        })
119    }
120
121    /// Validate a session from the bearer credential in a cookie.
122    ///
123    /// Derives the lookup key from `cookie_value`, queries the store for an
124    /// active (unexpired, unrevoked) session, and returns the authentication
125    /// outcome. Expired and revoked sessions both collapse to
126    /// `Unauthenticated` (INV-8).
127    ///
128    /// # Errors
129    /// Returns [`SessionError::Internal`] only on store/key failure.
130    /// A missing or invalid session returns `Ok(Unauthenticated)`, not an error.
131    pub async fn validate(
132        &self,
133        cookie_value: &str,
134    ) -> Result<SessionValidationOutcome, SessionError> {
135        let (lookup_key, _) = self
136            .hasher
137            .lookup_key(SecretDomain::Session, cookie_value)
138            .map_err(SessionError::from_key)?;
139
140        let now = self.clock.unix_now();
141        let record = self
142            .store
143            .find_active_session(&[lookup_key], now)
144            .await
145            .map_err(SessionError::from_store)?;
146
147        let outcome = classify_session(record.map(|r| (r.subject, r.id, r.expires_at)));
148
149        if !outcome.is_authenticated() {
150            self.audit.record(CodeAuthEvent::SessionValidateFailed);
151        }
152
153        Ok(outcome)
154    }
155
156    /// Revoke a session (logout or incident response).
157    ///
158    /// Returns the `Set-Cookie` header value that clears the session cookie
159    /// from the client.
160    ///
161    /// # Errors
162    /// Returns [`SessionError::Internal`] on store failure.
163    pub async fn revoke(&self, session_id: &SessionId) -> Result<String, SessionError> {
164        let now = self.clock.unix_now();
165        self.store
166            .revoke_session(session_id, now)
167            .await
168            .map_err(SessionError::from_store)?;
169
170        self.audit.record(CodeAuthEvent::SessionRevoked {
171            session_id: session_id.clone(),
172        });
173
174        Ok(self.cookie_policy.build_clear_cookie())
175    }
176
177    /// Borrow the cookie policy (e.g. to build the initial `Set-Cookie` name
178    /// for extraction on the next request).
179    #[must_use]
180    pub fn cookie_policy(&self) -> &CookiePolicy {
181        &self.cookie_policy
182    }
183}
184
185fn hex_lower(bytes: &[u8]) -> String {
186    const HEX: &[u8; 16] = b"0123456789abcdef";
187    let mut s = String::with_capacity(bytes.len() * 2);
188    for &b in bytes {
189        s.push(HEX[(b >> 4) as usize] as char);
190        s.push(HEX[(b & 0xf) as usize] as char);
191    }
192    s
193}