Skip to main content

webx_core/security/
session.rs

1//! Session revocation port ([`ISessionStore`]).
2//!
3//! A token stays signature-valid until `exp`. Logout and forced sign-out need a
4//! server-side record the authentication middleware can consult after verifying
5//! the token; that record is keyed by the token's `jti` claim.
6
7use crate::error::{Error, Result};
8use std::collections::HashSet;
9use std::sync::{PoisonError, RwLock};
10
11/// Revocation state for issued tokens.
12///
13/// The authentication middleware calls [`ISessionStore::is_revoked`] with the
14/// `jti` of an already verified token; `true` turns the request into a 401
15/// without waiting for the token to expire. Implementations back this with
16/// whatever survives their deployment — process memory, a cache with TTL, or a
17/// database table.
18///
19/// Errors are not swallowed: a store failure fails the request (the middleware
20/// must not authorize when it cannot prove the token is unrevoked).
21#[async_trait::async_trait]
22pub trait ISessionStore: Send + Sync {
23    /// Revoke one token by its `jti` (logout of a single session).
24    ///
25    /// Revoking an already-revoked or unknown `jti` is not an error: logout is
26    /// idempotent.
27    async fn revoke(&self, jti: &str) -> Result<()>;
28
29    /// Whether the token `jti` has been revoked.
30    async fn is_revoked(&self, jti: &str) -> Result<bool>;
31
32    /// Revoke every token of `subject` ("sign out everywhere").
33    ///
34    /// Optional capability: the default implementation returns
35    /// `501 Not Implemented` ([`Error::Status`]) instead of silently succeeding,
36    /// because a store that cannot enumerate a subject's sessions would
37    /// otherwise report a logout that did not happen. `501` lets the caller (and
38    /// the logs) tell "not supported" from a real server failure. Override it
39    /// when the backing store keeps a subject-to-session index.
40    async fn revoke_all_for_subject(&self, subject: &str) -> Result<()> {
41        Err(Error::Status(
42            501,
43            format!(
44                "this ISessionStore cannot revoke every token of '{subject}'; override revoke_all_for_subject"
45            ),
46        ))
47    }
48}
49
50/// Process-local [`ISessionStore`] — the dependency-free default.
51///
52/// Suitable for a single-process deployment, tests and development. Two
53/// limitations are deliberate and documented rather than hidden:
54///
55/// * Revocations die with the process (a restart resurrects every token that
56///   has not expired yet) — use a persistent store in production when that
57///   matters.
58/// * Revoked `jti`s are never garbage-collected (the store cannot know when a
59///   token expires), so a long-running process accumulates one `String` per
60///   revoked token.
61/// * [`ISessionStore::revoke_all_for_subject`] is not supported: the store keeps
62///   no subject index.
63#[derive(Debug, Default)]
64pub struct InMemorySessionStore {
65    revoked: RwLock<HashSet<String>>,
66}
67
68impl InMemorySessionStore {
69    /// Create an empty store.
70    pub fn new() -> Self {
71        Self::default()
72    }
73
74    /// Number of revoked token ids currently tracked (diagnostics/tests).
75    pub fn revoked_count(&self) -> usize {
76        self.revoked
77            .read()
78            .unwrap_or_else(PoisonError::into_inner)
79            .len()
80    }
81}
82
83#[async_trait::async_trait]
84impl ISessionStore for InMemorySessionStore {
85    async fn revoke(&self, jti: &str) -> Result<()> {
86        if jti.is_empty() {
87            // An empty `jti` never identifies a token: storing it would revoke
88            // nothing while reporting success.
89            return Err(Error::Validation("jti must not be empty".to_string()));
90        }
91        self.revoked
92            .write()
93            .unwrap_or_else(PoisonError::into_inner)
94            .insert(jti.to_string());
95        Ok(())
96    }
97
98    async fn is_revoked(&self, jti: &str) -> Result<bool> {
99        Ok(self
100            .revoked
101            .read()
102            .unwrap_or_else(PoisonError::into_inner)
103            .contains(jti))
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110
111    #[tokio::test]
112    async fn revoke_marks_only_that_token() {
113        let store = InMemorySessionStore::new();
114        assert!(!store.is_revoked("jti-a").await.unwrap());
115
116        store.revoke("jti-a").await.unwrap();
117
118        assert!(store.is_revoked("jti-a").await.unwrap());
119        assert!(
120            !store.is_revoked("jti-b").await.unwrap(),
121            "revocation is per token, not global"
122        );
123        assert_eq!(store.revoked_count(), 1);
124    }
125
126    #[tokio::test]
127    async fn revoke_is_idempotent() {
128        let store = InMemorySessionStore::new();
129        store.revoke("jti-a").await.unwrap();
130        store.revoke("jti-a").await.unwrap();
131        assert_eq!(store.revoked_count(), 1, "logout must be idempotent");
132    }
133
134    #[tokio::test]
135    async fn empty_jti_is_a_typed_error() {
136        let err = InMemorySessionStore::new().revoke("").await.unwrap_err();
137        assert!(matches!(err, Error::Validation(_)), "got {err:?}");
138    }
139
140    #[tokio::test]
141    async fn revoke_all_for_subject_is_not_implemented_by_default() {
142        let err = InMemorySessionStore::new()
143            .revoke_all_for_subject("user-1")
144            .await
145            .expect_err("the dependency-free store has no subject index");
146        assert!(
147            matches!(err, Error::Status(501, _)),
148            "an unsupported capability is 501, not a generic 500: {err:?}"
149        );
150        assert_eq!(err.status_code(), 501);
151    }
152}