Skip to main content

minco_plugin_sessions/
lib.rs

1//! Provider-neutral, revocable browser and API session primitives.
2#![forbid(unsafe_code)]
3
4use async_trait::async_trait;
5use chrono::{DateTime, TimeDelta, Utc};
6use hmac::{Hmac, Mac};
7use minco_core::{
8    CapabilityProvision, DataClass, Plugin, PluginContext, PluginDescriptor, PluginError, PluginId,
9    PluginStability,
10};
11use semver::{Version, VersionReq};
12use serde::{Deserialize, Serialize};
13use sha2::{Digest, Sha256};
14use std::{collections::BTreeMap, sync::Arc};
15use subtle::ConstantTimeEq;
16use tokio::sync::RwLock;
17use uuid::Uuid;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
20#[serde(transparent)]
21pub struct SessionId(pub Uuid);
22
23impl SessionId {
24    pub fn new() -> Self {
25        Self(Uuid::now_v7())
26    }
27}
28
29impl Default for SessionId {
30    fn default() -> Self {
31        Self::new()
32    }
33}
34
35#[derive(Clone, PartialEq, Eq)]
36pub struct SessionToken(String);
37
38impl SessionToken {
39    /// Creates a high-entropy opaque token from two independently generated `UUIDv4` values.
40    pub fn generate() -> Self {
41        Self(format!(
42            "{}.{}",
43            Uuid::new_v4().simple(),
44            Uuid::new_v4().simple()
45        ))
46    }
47
48    pub fn parse(value: impl Into<String>) -> Result<Self, SessionError> {
49        let value = value.into();
50        if value.len() < 48 || value.len() > 256 || value.chars().any(char::is_control) {
51            return Err(SessionError::InvalidToken);
52        }
53        Ok(Self(value))
54    }
55
56    pub fn expose(&self) -> &str {
57        &self.0
58    }
59
60    fn hash(&self) -> SessionTokenHash {
61        SessionTokenHash(Sha256::digest(self.0.as_bytes()).into())
62    }
63}
64
65impl std::fmt::Debug for SessionToken {
66    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67        formatter.write_str("SessionToken([REDACTED])")
68    }
69}
70
71#[derive(Clone, Copy, PartialEq, Eq, Hash)]
72pub struct SessionTokenHash([u8; 32]);
73
74impl SessionTokenHash {
75    pub fn constant_time_eq(&self, other: &Self) -> bool {
76        bool::from(self.0.ct_eq(&other.0))
77    }
78
79    pub const fn from_bytes(bytes: [u8; 32]) -> Self {
80        Self(bytes)
81    }
82
83    pub const fn as_bytes(&self) -> &[u8; 32] {
84        &self.0
85    }
86}
87
88impl std::fmt::Debug for SessionTokenHash {
89    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90        formatter.write_str("SessionTokenHash([REDACTED])")
91    }
92}
93
94type HmacSha256 = Hmac<Sha256>;
95
96/// Signed double-submit token bound to one session identifier.
97#[derive(Clone, PartialEq, Eq)]
98pub struct CsrfToken(String);
99
100impl CsrfToken {
101    pub fn parse(value: impl Into<String>) -> Result<Self, SessionError> {
102        let value = value.into();
103        let Some((nonce, signature)) = value.split_once('.') else {
104            return Err(SessionError::InvalidCsrfToken);
105        };
106        if nonce.len() != 32
107            || signature.len() != 64
108            || Uuid::parse_str(nonce).is_err()
109            || decode_hex(signature).is_none()
110        {
111            return Err(SessionError::InvalidCsrfToken);
112        }
113        Ok(Self(value))
114    }
115
116    pub fn expose(&self) -> &str {
117        &self.0
118    }
119}
120
121impl std::fmt::Debug for CsrfToken {
122    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
123        formatter.write_str("CsrfToken([REDACTED])")
124    }
125}
126
127/// HMAC-backed CSRF token issuer and verifier.
128///
129/// Production applications must inject the same secret into every application instance and rotate
130/// it independently from session records. The token is suitable for a signed double-submit cookie
131/// flow when the cookie and request header values are compared by the HTTP adapter.
132#[derive(Clone)]
133pub struct CsrfService {
134    secret: Arc<[u8]>,
135}
136
137impl std::fmt::Debug for CsrfService {
138    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
139        formatter
140            .debug_struct("CsrfService")
141            .field("secret", &"[REDACTED]")
142            .finish()
143    }
144}
145
146impl CsrfService {
147    pub fn new(secret: impl Into<Vec<u8>>) -> Result<Self, SessionError> {
148        let secret = secret.into();
149        if secret.len() < 32 {
150            return Err(SessionError::InvalidCsrfSecret);
151        }
152        Ok(Self {
153            secret: Arc::from(secret),
154        })
155    }
156
157    pub fn issue(&self, session_id: SessionId) -> CsrfToken {
158        let nonce = Uuid::new_v4().simple().to_string();
159        let signature = self.signature(session_id, &nonce);
160        CsrfToken(format!("{nonce}.{}", encode_hex(&signature)))
161    }
162
163    pub fn verify(&self, session_id: SessionId, token: &CsrfToken) -> Result<(), SessionError> {
164        let (nonce, encoded_signature) = token
165            .0
166            .split_once('.')
167            .ok_or(SessionError::InvalidCsrfToken)?;
168        let signature = decode_hex(encoded_signature).ok_or(SessionError::InvalidCsrfToken)?;
169        let mut mac = HmacSha256::new_from_slice(&self.secret)
170            .map_err(|_| SessionError::InvalidCsrfSecret)?;
171        mac.update(session_id.0.as_bytes());
172        mac.update(nonce.as_bytes());
173        mac.verify_slice(&signature)
174            .map_err(|_| SessionError::InvalidCsrfToken)
175    }
176
177    fn signature(&self, session_id: SessionId, nonce: &str) -> Vec<u8> {
178        let mut mac =
179            HmacSha256::new_from_slice(&self.secret).expect("validated HMAC secret length");
180        mac.update(session_id.0.as_bytes());
181        mac.update(nonce.as_bytes());
182        mac.finalize().into_bytes().to_vec()
183    }
184}
185
186fn encode_hex(bytes: &[u8]) -> String {
187    use std::fmt::Write as _;
188
189    let mut encoded = String::with_capacity(bytes.len() * 2);
190    for byte in bytes {
191        write!(&mut encoded, "{byte:02x}").expect("writing to String cannot fail");
192    }
193    encoded
194}
195
196fn decode_hex(value: &str) -> Option<Vec<u8>> {
197    if !value.len().is_multiple_of(2) {
198        return None;
199    }
200    value
201        .as_bytes()
202        .chunks_exact(2)
203        .map(|pair| {
204            let text = std::str::from_utf8(pair).ok()?;
205            u8::from_str_radix(text, 16).ok()
206        })
207        .collect()
208}
209
210#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
211pub struct SessionRecord {
212    pub id: SessionId,
213    pub subject: String,
214    pub created_at: DateTime<Utc>,
215    pub expires_at: DateTime<Utc>,
216    #[serde(default, skip_serializing_if = "Option::is_none")]
217    pub revoked_at: Option<DateTime<Utc>>,
218    #[serde(default)]
219    pub attributes: BTreeMap<String, String>,
220}
221
222impl SessionRecord {
223    pub fn active_at(&self, now: DateTime<Utc>) -> bool {
224        self.revoked_at.is_none() && self.expires_at > now
225    }
226}
227
228#[derive(Debug, Clone, PartialEq, Eq)]
229pub struct IssuedSession {
230    pub token: SessionToken,
231    pub session: SessionRecord,
232}
233
234#[derive(Debug, Clone, PartialEq, Eq)]
235pub struct CreateSession {
236    pub subject: String,
237    pub ttl: TimeDelta,
238    pub attributes: BTreeMap<String, String>,
239}
240
241#[async_trait]
242pub trait SessionStore: Send + Sync + std::fmt::Debug {
243    async fn create(
244        &self,
245        token_hash: SessionTokenHash,
246        session: SessionRecord,
247    ) -> Result<(), SessionError>;
248
249    async fn find_by_token_hash(
250        &self,
251        token_hash: SessionTokenHash,
252    ) -> Result<Option<SessionRecord>, SessionError>;
253
254    async fn revoke(&self, id: SessionId, at: DateTime<Utc>) -> Result<bool, SessionError>;
255
256    async fn revoke_subject(&self, subject: &str, at: DateTime<Utc>)
257    -> Result<usize, SessionError>;
258}
259
260#[derive(Debug, Clone)]
261pub struct SessionService {
262    store: Arc<dyn SessionStore>,
263}
264
265impl SessionService {
266    pub fn new(store: Arc<dyn SessionStore>) -> Self {
267        Self { store }
268    }
269
270    pub async fn issue(&self, command: CreateSession) -> Result<IssuedSession, SessionError> {
271        if command.subject.trim().is_empty() || command.ttl <= TimeDelta::zero() {
272            return Err(SessionError::InvalidSession);
273        }
274        let token = SessionToken::generate();
275        let now = Utc::now();
276        let expires_at = now
277            .checked_add_signed(command.ttl)
278            .ok_or(SessionError::InvalidSession)?;
279        let session = SessionRecord {
280            id: SessionId::new(),
281            subject: command.subject,
282            created_at: now,
283            expires_at,
284            revoked_at: None,
285            attributes: command.attributes,
286        };
287        self.store.create(token.hash(), session.clone()).await?;
288        Ok(IssuedSession { token, session })
289    }
290
291    pub async fn resolve(&self, token: &SessionToken) -> Result<SessionRecord, SessionError> {
292        let session = self
293            .store
294            .find_by_token_hash(token.hash())
295            .await?
296            .ok_or(SessionError::Unauthenticated)?;
297        if session.active_at(Utc::now()) {
298            Ok(session)
299        } else {
300            Err(SessionError::Unauthenticated)
301        }
302    }
303
304    pub async fn revoke(&self, id: SessionId) -> Result<bool, SessionError> {
305        self.store.revoke(id, Utc::now()).await
306    }
307
308    pub async fn revoke_subject(&self, subject: &str) -> Result<usize, SessionError> {
309        if subject.trim().is_empty() {
310            return Err(SessionError::InvalidSession);
311        }
312        self.store.revoke_subject(subject, Utc::now()).await
313    }
314}
315
316#[derive(Debug, Default)]
317pub struct MemorySessionStore {
318    sessions: RwLock<BTreeMap<SessionId, (SessionTokenHash, SessionRecord)>>,
319}
320
321#[async_trait]
322impl SessionStore for MemorySessionStore {
323    async fn create(
324        &self,
325        token_hash: SessionTokenHash,
326        session: SessionRecord,
327    ) -> Result<(), SessionError> {
328        let mut sessions = self.sessions.write().await;
329        if sessions.contains_key(&session.id)
330            || sessions
331                .values()
332                .any(|(existing, _)| existing.constant_time_eq(&token_hash))
333        {
334            return Err(SessionError::Duplicate);
335        }
336        sessions.insert(session.id, (token_hash, session));
337        drop(sessions);
338        Ok(())
339    }
340
341    async fn find_by_token_hash(
342        &self,
343        token_hash: SessionTokenHash,
344    ) -> Result<Option<SessionRecord>, SessionError> {
345        Ok(self
346            .sessions
347            .read()
348            .await
349            .values()
350            .find(|(candidate, _)| candidate.constant_time_eq(&token_hash))
351            .map(|(_, session)| session.clone()))
352    }
353
354    async fn revoke(&self, id: SessionId, at: DateTime<Utc>) -> Result<bool, SessionError> {
355        let mut sessions = self.sessions.write().await;
356        let Some((_, session)) = sessions.get_mut(&id) else {
357            return Ok(false);
358        };
359        session.revoked_at.get_or_insert(at);
360        drop(sessions);
361        Ok(true)
362    }
363
364    async fn revoke_subject(
365        &self,
366        subject: &str,
367        at: DateTime<Utc>,
368    ) -> Result<usize, SessionError> {
369        let mut count = 0;
370        for (_, session) in self.sessions.write().await.values_mut() {
371            if session.subject == subject && session.revoked_at.is_none() {
372                session.revoked_at = Some(at);
373                count += 1;
374            }
375        }
376        Ok(count)
377    }
378}
379
380#[derive(Debug, Clone)]
381pub struct SessionsPlugin {
382    service: SessionService,
383    csrf: Option<CsrfService>,
384}
385
386impl SessionsPlugin {
387    pub fn new(store: Arc<dyn SessionStore>) -> Self {
388        Self {
389            service: SessionService::new(store),
390            csrf: None,
391        }
392    }
393
394    pub fn memory() -> Self {
395        let secret = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple());
396        Self::new(Arc::new(MemorySessionStore::default()))
397            .with_csrf_secret(secret.into_bytes())
398            .expect("ephemeral CSRF secret is sufficiently long")
399    }
400
401    pub fn with_csrf_secret(mut self, secret: impl Into<Vec<u8>>) -> Result<Self, SessionError> {
402        self.csrf = Some(CsrfService::new(secret)?);
403        Ok(self)
404    }
405}
406
407impl Default for SessionsPlugin {
408    fn default() -> Self {
409        Self::memory()
410    }
411}
412
413impl Plugin for SessionsPlugin {
414    fn descriptor(&self) -> PluginDescriptor {
415        let mut descriptor = PluginDescriptor::new(
416            PluginId::new("sessions").expect("static plugin ID"),
417            Version::new(1, 0, 0),
418            "Provider-neutral session issuance, lookup, expiry, and revocation",
419        );
420        descriptor.core_compatibility =
421            VersionReq::parse(concat!("^", env!("CARGO_PKG_VERSION"))).expect("package version");
422        descriptor.stability = PluginStability::Beta;
423        descriptor.documentation = Some("https://docs.rs/minco-plugin-sessions".into());
424        descriptor
425            .data_classes
426            .extend([DataClass::Personal, DataClass::Secret]);
427        descriptor.provides.extend([
428            CapabilityProvision {
429                name: "sessions.issue".into(),
430                version: Version::new(1, 0, 0),
431            },
432            CapabilityProvision {
433                name: "sessions.resolve".into(),
434                version: Version::new(1, 0, 0),
435            },
436            CapabilityProvision {
437                name: "sessions.revoke".into(),
438                version: Version::new(1, 0, 0),
439            },
440        ]);
441        if self.csrf.is_some() {
442            descriptor.provides.push(CapabilityProvision {
443                name: "sessions.csrf".into(),
444                version: Version::new(1, 0, 0),
445            });
446        }
447        descriptor
448    }
449
450    fn install(&self, context: &mut PluginContext<'_>) -> Result<(), PluginError> {
451        context.services().insert(Arc::new(self.service.clone()))?;
452        if let Some(csrf) = &self.csrf {
453            context.services().insert(Arc::new(csrf.clone()))?;
454        }
455        Ok(())
456    }
457}
458
459#[derive(Debug, thiserror::Error)]
460pub enum SessionError {
461    #[error("session token is malformed")]
462    InvalidToken,
463    #[error("session subject and a positive, representable TTL are required")]
464    InvalidSession,
465    #[error("session is not authenticated")]
466    Unauthenticated,
467    #[error("session identifier or token already exists")]
468    Duplicate,
469    #[error("CSRF token is malformed or does not match the session")]
470    InvalidCsrfToken,
471    #[error("CSRF signing secret must contain at least 32 bytes")]
472    InvalidCsrfSecret,
473    #[error("session store failed: {0}")]
474    Store(String),
475}
476
477#[cfg(test)]
478mod tests {
479    use super::*;
480
481    #[tokio::test]
482    async fn sessions_resolve_and_revoke_without_storing_plaintext_tokens() {
483        let service = SessionService::new(Arc::new(MemorySessionStore::default()));
484        let issued = service
485            .issue(CreateSession {
486                subject: "client-1".into(),
487                ttl: TimeDelta::hours(1),
488                attributes: BTreeMap::new(),
489            })
490            .await
491            .unwrap();
492        assert_eq!(
493            service.resolve(&issued.token).await.unwrap().subject,
494            "client-1"
495        );
496        assert!(service.revoke(issued.session.id).await.unwrap());
497        assert!(matches!(
498            service.resolve(&issued.token).await,
499            Err(SessionError::Unauthenticated)
500        ));
501    }
502
503    #[tokio::test]
504    async fn session_expiry_overflow_fails_without_panicking() {
505        let service = SessionService::new(Arc::new(MemorySessionStore::default()));
506        assert!(matches!(
507            service
508                .issue(CreateSession {
509                    subject: "client-1".into(),
510                    ttl: TimeDelta::MAX,
511                    attributes: BTreeMap::new(),
512                })
513                .await,
514            Err(SessionError::InvalidSession)
515        ));
516    }
517
518    #[test]
519    fn csrf_tokens_are_bound_to_one_session_and_tamper_evident() {
520        let service = CsrfService::new(vec![7_u8; 32]).unwrap();
521        let first = SessionId::new();
522        let second = SessionId::new();
523        let token = service.issue(first);
524        assert!(service.verify(first, &token).is_ok());
525        assert!(matches!(
526            service.verify(second, &token),
527            Err(SessionError::InvalidCsrfToken)
528        ));
529    }
530
531    #[tokio::test]
532    async fn subject_revocation_ends_all_sessions() {
533        let service = SessionService::new(Arc::new(MemorySessionStore::default()));
534        let first = service
535            .issue(CreateSession {
536                subject: "client-1".into(),
537                ttl: TimeDelta::hours(1),
538                attributes: BTreeMap::new(),
539            })
540            .await
541            .unwrap();
542        let second = service
543            .issue(CreateSession {
544                subject: "client-1".into(),
545                ttl: TimeDelta::hours(1),
546                attributes: BTreeMap::new(),
547            })
548            .await
549            .unwrap();
550        assert_eq!(service.revoke_subject("client-1").await.unwrap(), 2);
551        assert!(service.resolve(&first.token).await.is_err());
552        assert!(service.resolve(&second.token).await.is_err());
553    }
554}