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}