Skip to main content

rustlavel_auth/tokens/
store.rs

1//! Where API tokens live between requests.
2//!
3//! One driver ships: [`MemoryTokenStore`], which is the right answer for tests
4//! and a development server and the wrong answer for anything that restarts or
5//! runs more than one process.
6//!
7//! There is deliberately no database driver here, and this crate deliberately
8//! does not depend on `rustlavel-db`. Tokens belong next to the users they
9//! authenticate, in the application's own schema, under whatever key type that
10//! table already uses — so an application implements [`TokenStore`] over its
11//! own `personal_access_tokens` table. Everything needed to write a row is
12//! reachable from [`Token`]: `id`, `identity`, `name`, `digest`, `scopes`,
13//! `created_at`, `expires_at`, `last_used_at`.
14
15use super::Token;
16use crate::guard::Identity;
17use rustlavel_core::Result;
18use std::collections::HashMap;
19use std::future::Future;
20use std::pin::Pin;
21use std::sync::{Arc, Mutex};
22
23/// What a token store's operations return.
24///
25/// A boxed future rather than an `async fn` in the trait, for the same reason
26/// [`crate::store::StoreFuture`] is: the middleware holds `dyn TokenStore` —
27/// whichever driver the application picked — and cannot be generic over it
28/// without a type parameter spreading into every route.
29pub type TokenFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T>> + Send + 'a>>;
30
31pub trait TokenStore: Send + Sync + 'static {
32    /// Persist a newly issued token and return it as stored.
33    ///
34    /// The returned token is what the credential is built from, so a table
35    /// that allocates its own primary key returns the token with that id
36    /// ([`Token::with_id`]). The id must not contain a `|`, or the credential
37    /// cannot be split back apart.
38    fn create(&self, token: Token) -> TokenFuture<'_, Token>;
39
40    /// Load one token by id, or `None` when there is no such row.
41    ///
42    /// Expiry is *not* checked here — [`super::authenticate`] does that, after
43    /// the secret, so that an expiry cannot be probed without the secret.
44    fn find<'a>(&'a self, id: &'a str) -> TokenFuture<'a, Option<Token>>;
45
46    /// Revoke one token. Deleting an id that is already gone is not an error.
47    fn delete<'a>(&'a self, id: &'a str) -> TokenFuture<'a, ()>;
48
49    /// Revoke every token belonging to one user.
50    ///
51    /// What "log out everywhere" and a password change both call. A password
52    /// change that leaves the API tokens alive has not actually locked anybody
53    /// out.
54    fn delete_for_identity<'a>(&'a self, identity: &'a Identity) -> TokenFuture<'a, ()>;
55
56    /// Stamp `last_used_at`, in unix seconds.
57    ///
58    /// Called on every authenticated request, so a driver over a real database
59    /// may reasonably coarsen it — writing a row per request to record a
60    /// timestamp nobody reads to the second is a lot of write load for a
61    /// "last used 2 hours ago" label.
62    fn touch<'a>(&'a self, id: &'a str, at: u64) -> TokenFuture<'a, ()>;
63}
64
65/// A token store shared between the middleware and whatever issues tokens.
66pub type SharedTokenStore = Arc<dyn TokenStore>;
67
68/// Tokens held in this process's memory.
69///
70/// Everything is lost on restart and nothing is shared between processes, which
71/// makes it right for tests and for `cargo run`, and wrong for a deployment
72/// where a token issued by one worker must be accepted by another.
73pub struct MemoryTokenStore {
74    tokens: Mutex<HashMap<String, Token>>,
75}
76
77impl MemoryTokenStore {
78    pub fn new() -> Self {
79        MemoryTokenStore { tokens: Mutex::new(HashMap::new()) }
80    }
81
82    /// How many tokens are held. For tests.
83    pub fn len(&self) -> usize {
84        self.lock().len()
85    }
86
87    pub fn is_empty(&self) -> bool {
88        self.len() == 0
89    }
90
91    fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<String, Token>> {
92        self.tokens.lock().expect("token store lock poisoned")
93    }
94
95    /// Everything held, digests spelled out in hex.
96    ///
97    /// Only for the test that proves a plain-text secret never reaches the
98    /// store: [`Token`]'s own `Debug` redacts the digest, so a dump built from
99    /// it could not tell that test anything.
100    #[cfg(test)]
101    pub(crate) fn dump(&self) -> String {
102        let mut out = String::new();
103        for (id, token) in self.lock().iter() {
104            let digest: String = token.digest().iter().map(|byte| format!("{byte:02x}")).collect();
105            out.push_str(&format!("{id} {token:?} {digest}\n"));
106        }
107        out
108    }
109}
110
111impl Default for MemoryTokenStore {
112    fn default() -> Self {
113        MemoryTokenStore::new()
114    }
115}
116
117/// A count, and nothing else.
118///
119/// Printing the tokens themselves would put thirty-two digests into whatever
120/// log line printed the store.
121impl std::fmt::Debug for MemoryTokenStore {
122    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
123        f.debug_struct("MemoryTokenStore").field("tokens", &self.len()).finish()
124    }
125}
126
127impl TokenStore for MemoryTokenStore {
128    fn create(&self, token: Token) -> TokenFuture<'_, Token> {
129        Box::pin(async move {
130            self.lock().insert(token.id().to_string(), token.clone());
131            Ok(token)
132        })
133    }
134
135    fn find<'a>(&'a self, id: &'a str) -> TokenFuture<'a, Option<Token>> {
136        Box::pin(async move { Ok(self.lock().get(id).cloned()) })
137    }
138
139    fn delete<'a>(&'a self, id: &'a str) -> TokenFuture<'a, ()> {
140        Box::pin(async move {
141            self.lock().remove(id);
142            Ok(())
143        })
144    }
145
146    fn delete_for_identity<'a>(&'a self, identity: &'a Identity) -> TokenFuture<'a, ()> {
147        Box::pin(async move {
148            self.lock().retain(|_, token| token.identity() != identity);
149            Ok(())
150        })
151    }
152
153    fn touch<'a>(&'a self, id: &'a str, at: u64) -> TokenFuture<'a, ()> {
154        Box::pin(async move {
155            if let Some(token) = self.lock().get_mut(id) {
156                token.set_last_used_at(at);
157            }
158            Ok(())
159        })
160    }
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166    use crate::tokens::{NewToken, Scopes, digest};
167
168    #[tokio::test]
169    async fn the_memory_store_round_trips_a_token() {
170        let store = MemoryTokenStore::new();
171        assert!(store.is_empty());
172
173        let token = Token::new("7", Identity::new("41"), "iPhone", digest("s"), Scopes::any());
174        let stored = store.create(token).await.unwrap();
175
176        assert_eq!(store.len(), 1);
177
178        let found = store.find("7").await.unwrap().expect("it was just created");
179        assert_eq!(found.id(), stored.id());
180        assert_eq!(found.name(), "iPhone");
181        assert_eq!(found.digest(), &digest("s"));
182    }
183
184    #[tokio::test]
185    async fn an_unknown_id_is_not_found_rather_than_an_error() {
186        let store = MemoryTokenStore::new();
187
188        assert!(store.find("nobody").await.unwrap().is_none());
189        assert!(store.find("").await.unwrap().is_none());
190
191        // Deleting something that is not there is not a failure either.
192        store.delete("nobody").await.unwrap();
193        store.touch("nobody", 1).await.unwrap();
194    }
195
196    #[tokio::test]
197    async fn deleting_for_one_identity_leaves_the_others() {
198        let store = MemoryTokenStore::new();
199        for (id, owner) in [("a", "41"), ("b", "41"), ("c", "99")] {
200            let token = Token::new(id, Identity::new(owner), id, digest(id), Scopes::any());
201            store.create(token).await.unwrap();
202        }
203
204        store.delete_for_identity(&Identity::new("41")).await.unwrap();
205
206        assert!(store.find("a").await.unwrap().is_none());
207        assert!(store.find("b").await.unwrap().is_none());
208        assert!(store.find("c").await.unwrap().is_some());
209        assert_eq!(store.len(), 1);
210    }
211
212    #[tokio::test]
213    async fn touching_records_the_moment_and_nothing_else() {
214        let store = MemoryTokenStore::new();
215        let token = Token::new("7", Identity::new("41"), "iPhone", digest("s"), Scopes::any());
216        store.create(token).await.unwrap();
217
218        store.touch("7", 1_700_000_000).await.unwrap();
219
220        let found = store.find("7").await.unwrap().unwrap();
221        assert_eq!(found.last_used_at(), Some(1_700_000_000));
222        assert_eq!(found.name(), "iPhone");
223        assert_eq!(found.digest(), &digest("s"));
224    }
225
226    #[tokio::test]
227    async fn a_store_works_behind_a_trait_object() {
228        let store: SharedTokenStore = Arc::new(MemoryTokenStore::new());
229
230        let issued =
231            NewToken::new(Identity::new("41"), "iPhone").any().issue(&*store).await.unwrap();
232
233        assert!(store.find(issued.token().id()).await.unwrap().is_some());
234        store.delete(issued.token().id()).await.unwrap();
235        assert!(store.find(issued.token().id()).await.unwrap().is_none());
236    }
237
238    #[test]
239    fn the_stores_debug_output_is_a_count() {
240        let printed = format!("{:?}", MemoryTokenStore::new());
241
242        assert!(printed.contains("MemoryTokenStore"), "printed {printed}");
243        assert!(printed.contains('0'));
244    }
245}