1#![forbid(unsafe_code)]
3
4use async_trait::async_trait;
5use chrono::{DateTime, TimeDelta, Utc};
6use hmac::{Hmac, KeyInit, 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 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#[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#[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}