use crate::error::{Error, Result};
use std::collections::HashSet;
use std::sync::{PoisonError, RwLock};
#[async_trait::async_trait]
pub trait ISessionStore: Send + Sync {
async fn revoke(&self, jti: &str) -> Result<()>;
async fn is_revoked(&self, jti: &str) -> Result<bool>;
async fn revoke_all_for_subject(&self, subject: &str) -> Result<()> {
Err(Error::Status(
501,
format!(
"this ISessionStore cannot revoke every token of '{subject}'; override revoke_all_for_subject"
),
))
}
}
#[derive(Debug, Default)]
pub struct InMemorySessionStore {
revoked: RwLock<HashSet<String>>,
}
impl InMemorySessionStore {
pub fn new() -> Self {
Self::default()
}
pub fn revoked_count(&self) -> usize {
self.revoked
.read()
.unwrap_or_else(PoisonError::into_inner)
.len()
}
}
#[async_trait::async_trait]
impl ISessionStore for InMemorySessionStore {
async fn revoke(&self, jti: &str) -> Result<()> {
if jti.is_empty() {
return Err(Error::Validation("jti must not be empty".to_string()));
}
self.revoked
.write()
.unwrap_or_else(PoisonError::into_inner)
.insert(jti.to_string());
Ok(())
}
async fn is_revoked(&self, jti: &str) -> Result<bool> {
Ok(self
.revoked
.read()
.unwrap_or_else(PoisonError::into_inner)
.contains(jti))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn revoke_marks_only_that_token() {
let store = InMemorySessionStore::new();
assert!(!store.is_revoked("jti-a").await.unwrap());
store.revoke("jti-a").await.unwrap();
assert!(store.is_revoked("jti-a").await.unwrap());
assert!(
!store.is_revoked("jti-b").await.unwrap(),
"revocation is per token, not global"
);
assert_eq!(store.revoked_count(), 1);
}
#[tokio::test]
async fn revoke_is_idempotent() {
let store = InMemorySessionStore::new();
store.revoke("jti-a").await.unwrap();
store.revoke("jti-a").await.unwrap();
assert_eq!(store.revoked_count(), 1, "logout must be idempotent");
}
#[tokio::test]
async fn empty_jti_is_a_typed_error() {
let err = InMemorySessionStore::new().revoke("").await.unwrap_err();
assert!(matches!(err, Error::Validation(_)), "got {err:?}");
}
#[tokio::test]
async fn revoke_all_for_subject_is_not_implemented_by_default() {
let err = InMemorySessionStore::new()
.revoke_all_for_subject("user-1")
.await
.expect_err("the dependency-free store has no subject index");
assert!(
matches!(err, Error::Status(501, _)),
"an unsupported capability is 501, not a generic 500: {err:?}"
);
assert_eq!(err.status_code(), 501);
}
}