codlet_core/auth/
session.rs1use 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
20pub 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 #[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 pub async fn issue<R: RandomSource>(
72 &self,
73 success: &RedeemSuccess,
74 session_id: SessionId,
75 rng: &mut R,
76 ) -> Result<IssuedSession, SessionError> {
77 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 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 pub async fn validate(
132 &self,
133 cookie_value: &str,
134 ) -> Result<SessionValidationOutcome, SessionError> {
135 let candidates: Vec<_> = self
138 .hasher
139 .lookup_key_candidates(SecretDomain::Session, cookie_value)
140 .map_err(SessionError::from_key)?
141 .into_iter()
142 .map(|(lk, _)| lk)
143 .collect();
144
145 let now = self.clock.unix_now();
146 let record = self
147 .store
148 .find_active_session(&candidates, now)
149 .await
150 .map_err(SessionError::from_store)?;
151
152 let outcome = classify_session(record.map(|r| (r.subject, r.id, r.expires_at)));
153
154 if !outcome.is_authenticated() {
155 self.audit.record(CodeAuthEvent::SessionValidateFailed);
156 }
157
158 Ok(outcome)
159 }
160
161 pub async fn revoke(&self, session_id: &SessionId) -> Result<String, SessionError> {
169 let now = self.clock.unix_now();
170 self.store
171 .revoke_session(session_id, now)
172 .await
173 .map_err(SessionError::from_store)?;
174
175 self.audit.record(CodeAuthEvent::SessionRevoked {
176 session_id: session_id.clone(),
177 });
178
179 Ok(self.cookie_policy.build_clear_cookie())
180 }
181
182 #[must_use]
185 pub fn cookie_policy(&self) -> &CookiePolicy {
186 &self.cookie_policy
187 }
188}
189
190fn hex_lower(bytes: &[u8]) -> String {
191 const HEX: &[u8; 16] = b"0123456789abcdef";
192 let mut s = String::with_capacity(bytes.len() * 2);
193 for &b in bytes {
194 s.push(HEX[(b >> 4) as usize] as char);
195 s.push(HEX[(b & 0xf) as usize] as char);
196 }
197 s
198}