Skip to main content

solid_pod_rs_idp/
user_store.rs

1//! Pluggable user-storage trait.
2//!
3//! Port of `JavaScriptSolidServer/src/idp/accounts.js` (the subset
4//! the IdP itself reaches into: find-by-email + verify-password).
5//! Real persistence is the consumer's responsibility; we ship an
6//! in-memory store for tests and single-user dev.
7
8use std::collections::HashMap;
9
10use argon2::password_hash::SaltString;
11use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
12use async_trait::async_trait;
13use parking_lot::RwLock;
14use rand::rngs::OsRng;
15use thiserror::Error;
16
17use crate::credentials::MIN_PASSWORD_LENGTH;
18
19/// Errors surfaced by [`UserStore`].
20#[derive(Debug, Error)]
21pub enum UserStoreError {
22    /// Hashing / verification failure.
23    #[error("password hash: {0}")]
24    Hash(String),
25
26    /// Store-specific back-end failure (DB down, etc).
27    #[error("backend: {0}")]
28    Backend(String),
29
30    /// Password does not meet the minimum length requirement.
31    /// JSS commit `1feead2` enforces >= 8 characters at registration.
32    #[error("password must be at least {min_length} characters")]
33    PasswordTooShort {
34        /// The minimum length that was not met.
35        min_length: usize,
36    },
37
38    /// The store does not implement this operation. Surfaced by the
39    /// default [`UserStore::delete`] so that stores opting out of
40    /// Sprint-11 `account delete` still compile.
41    #[error("not implemented")]
42    NotImplemented,
43}
44
45/// User record. `password_hash` is an Argon2id PHC string.
46#[derive(Debug, Clone)]
47pub struct User {
48    /// Stable internal identifier.
49    pub id: String,
50    /// Primary email (case-normalised before storage).
51    pub email: String,
52    /// Solid WebID URL — what the access-token `webid` claim surfaces.
53    pub webid: String,
54    /// Display name (free-form).
55    pub name: Option<String>,
56    /// Argon2id PHC-encoded password hash.
57    pub password_hash: String,
58}
59
60/// Async user-store contract.
61#[async_trait]
62pub trait UserStore: Send + Sync + 'static {
63    /// Look up a user by email. Returns `Ok(None)` on no-match
64    /// (distinct from `Err(_)` which means the backend failed).
65    async fn find_by_email(&self, email: &str) -> Result<Option<User>, UserStoreError>;
66
67    /// Look up a user by internal id.
68    async fn find_by_id(&self, id: &str) -> Result<Option<User>, UserStoreError>;
69
70    /// Verify `password` against the user's stored hash. This lives
71    /// on the trait rather than free-function so stores that use
72    /// external auth (LDAP, OAuth federation) can override the
73    /// verification path.
74    async fn verify_password(
75        &self,
76        user: &User,
77        password: &str,
78    ) -> Result<bool, UserStoreError> {
79        let parsed = PasswordHash::new(&user.password_hash)
80            .map_err(|e| UserStoreError::Hash(e.to_string()))?;
81        let ok = Argon2::default()
82            .verify_password(password.as_bytes(), &parsed)
83            .is_ok();
84        Ok(ok)
85    }
86
87    /// Delete a user and every record they own (pods, WebID profile,
88    /// sessions). Mirrors JSS commit `d9e56d8` (#292).
89    ///
90    /// Default impl returns [`UserStoreError::NotImplemented`] so
91    /// existing stores compile unchanged; operators wire this on the
92    /// concrete store they ship. Returns `Ok(false)` when the `id` is
93    /// unknown (already deleted / never existed), `Ok(true)` when a
94    /// row was actually removed.
95    async fn delete(&self, _id: &str) -> Result<bool, UserStoreError> {
96        Err(UserStoreError::NotImplemented)
97    }
98}
99
100/// Reference in-memory implementation.
101#[derive(Default)]
102pub struct InMemoryUserStore {
103    inner: RwLock<HashMap<String, User>>,
104}
105
106impl InMemoryUserStore {
107    /// Construct an empty store.
108    pub fn new() -> Self {
109        Self::default()
110    }
111
112    /// Create a user with an Argon2id hash of `password`. Returns
113    /// the inserted record. Email is case-normalised (lowercased) on
114    /// storage so `find_by_email` can match case-insensitively.
115    ///
116    /// Passwords shorter than [`MIN_PASSWORD_LENGTH`] (8 chars) are
117    /// rejected with [`UserStoreError::PasswordTooShort`], matching
118    /// JSS commit `1feead2`.
119    pub fn insert_user(
120        &self,
121        id: impl Into<String>,
122        email: impl Into<String>,
123        webid: impl Into<String>,
124        name: Option<String>,
125        password: &str,
126    ) -> Result<User, UserStoreError> {
127        if password.len() < MIN_PASSWORD_LENGTH {
128            return Err(UserStoreError::PasswordTooShort {
129                min_length: MIN_PASSWORD_LENGTH,
130            });
131        }
132        let salt = SaltString::generate(&mut OsRng);
133        let hash = Argon2::default()
134            .hash_password(password.as_bytes(), &salt)
135            .map_err(|e| UserStoreError::Hash(e.to_string()))?
136            .to_string();
137        let user = User {
138            id: id.into(),
139            email: email.into().to_ascii_lowercase(),
140            webid: webid.into(),
141            name,
142            password_hash: hash,
143        };
144        self.inner.write().insert(user.email.clone(), user.clone());
145        Ok(user)
146    }
147}
148
149#[async_trait]
150impl UserStore for InMemoryUserStore {
151    async fn find_by_email(&self, email: &str) -> Result<Option<User>, UserStoreError> {
152        Ok(self.inner.read().get(&email.to_ascii_lowercase()).cloned())
153    }
154
155    async fn find_by_id(&self, id: &str) -> Result<Option<User>, UserStoreError> {
156        Ok(self
157            .inner
158            .read()
159            .values()
160            .find(|u| u.id == id)
161            .cloned())
162    }
163
164    async fn delete(&self, id: &str) -> Result<bool, UserStoreError> {
165        let mut guard = self.inner.write();
166        // Find the keyed entry whose row matches this id and remove it.
167        let email_key = guard
168            .iter()
169            .find(|(_, u)| u.id == id)
170            .map(|(k, _)| k.clone());
171        match email_key {
172            Some(k) => {
173                guard.remove(&k);
174                Ok(true)
175            }
176            None => Ok(false),
177        }
178    }
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184
185    #[tokio::test]
186    async fn inmemory_stores_and_verifies() {
187        let store = InMemoryUserStore::new();
188        let user = store
189            .insert_user(
190                "u-1",
191                "Ada@Example.COM",
192                "https://ada.example/profile#me",
193                Some("Ada".into()),
194                "correct-horse-battery-staple",
195            )
196            .unwrap();
197        assert_eq!(user.email, "ada@example.com");
198
199        let found = store.find_by_email("ada@example.com").await.unwrap().unwrap();
200        assert_eq!(found.id, "u-1");
201
202        // Case-insensitive email lookup.
203        let found2 = store.find_by_email("ADA@example.COM").await.unwrap().unwrap();
204        assert_eq!(found2.id, "u-1");
205
206        assert!(store.verify_password(&found, "correct-horse-battery-staple").await.unwrap());
207        assert!(!store.verify_password(&found, "wrong-password").await.unwrap());
208    }
209
210    #[tokio::test]
211    async fn inmemory_delete_removes_user() {
212        let store = InMemoryUserStore::new();
213        store
214            .insert_user(
215                "u-del",
216                "del@example.com",
217                "https://del.example/profile#me",
218                None,
219                "password",
220            )
221            .unwrap();
222        assert!(store.find_by_id("u-del").await.unwrap().is_some());
223
224        let removed = store.delete("u-del").await.unwrap();
225        assert!(removed, "first delete should return true");
226        assert!(store.find_by_id("u-del").await.unwrap().is_none());
227
228        let removed_again = store.delete("u-del").await.unwrap();
229        assert!(!removed_again, "second delete should return false");
230    }
231
232    #[tokio::test]
233    async fn inmemory_find_by_id() {
234        let store = InMemoryUserStore::new();
235        store
236            .insert_user(
237                "u-2",
238                "bob@example.com",
239                "https://bob.example/profile#me",
240                None,
241                "password",
242            )
243            .unwrap();
244        let found = store.find_by_id("u-2").await.unwrap().unwrap();
245        assert_eq!(found.email, "bob@example.com");
246        assert!(store.find_by_id("missing").await.unwrap().is_none());
247    }
248
249    // ---- password-length validation at registration (JSS 1feead2) ----
250
251    #[test]
252    fn insert_user_rejects_7_char_password() {
253        let store = InMemoryUserStore::new();
254        let err = store
255            .insert_user(
256                "u-short",
257                "short@example.com",
258                "https://short.example/profile#me",
259                None,
260                "1234567",
261            )
262            .unwrap_err();
263        match err {
264            UserStoreError::PasswordTooShort { min_length } => {
265                assert_eq!(min_length, 8);
266            }
267            other => panic!("expected PasswordTooShort, got {other:?}"),
268        }
269    }
270
271    #[test]
272    fn insert_user_accepts_8_char_password() {
273        let store = InMemoryUserStore::new();
274        let user = store
275            .insert_user(
276                "u-ok",
277                "ok@example.com",
278                "https://ok.example/profile#me",
279                None,
280                "12345678",
281            )
282            .unwrap();
283        assert_eq!(user.id, "u-ok");
284    }
285
286    #[test]
287    fn insert_user_rejects_empty_password() {
288        let store = InMemoryUserStore::new();
289        let err = store
290            .insert_user(
291                "u-empty",
292                "empty@example.com",
293                "https://empty.example/profile#me",
294                None,
295                "",
296            )
297            .unwrap_err();
298        match err {
299            UserStoreError::PasswordTooShort { min_length } => {
300                assert_eq!(min_length, 8);
301            }
302            other => panic!("expected PasswordTooShort, got {other:?}"),
303        }
304    }
305}