Skip to main content

secrets_auth_userpass/
lib.rs

1use async_trait::async_trait;
2use argon2::password_hash::rand_core::OsRng;
3use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
4use argon2::Argon2;
5use secrets_core::auth::{AuthError, AuthMethod, AuthOutcome, AuthResult, LoginRequest};
6use secrets_core::storage::{StorageBackend, StorageEntry};
7use serde::{Deserialize, Serialize};
8
9const USER_PREFIX: &str = "auth/userpass/users/";
10const DEFAULT_TTL_SECONDS: i64 = 3600;
11
12/// Longest username accepted by [`UserPassAuth::upsert_user`].
13pub const MAX_USERNAME_LEN: usize = 64;
14/// Shortest password accepted by [`UserPassAuth::upsert_user`], in characters.
15/// These identities belong to services, whose passwords are generated, so
16/// the floor costs nothing and rules out a guessable one slipping in.
17pub const MIN_PASSWORD_LEN: usize = 12;
18
19#[derive(Debug, Serialize, Deserialize)]
20struct UserRecord {
21    password_hash: String,
22    policies: Vec<String>,
23}
24
25/// What may be said about a user to anyone allowed to manage it. A separate
26/// type from the stored record, so the password hash cannot end up in a
27/// response by someone serializing the wrong struct.
28#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
29pub struct UserInfo {
30    pub username: String,
31    pub policies: Vec<String>,
32}
33
34pub struct UserPassAuth;
35
36impl Default for UserPassAuth {
37    fn default() -> Self {
38        Self::new()
39    }
40}
41
42impl UserPassAuth {
43    pub fn new() -> Self {
44        Self
45    }
46
47    pub fn hash_password(password: &str) -> AuthResult<String> {
48        let salt = SaltString::generate(&mut OsRng);
49        Argon2::default()
50            .hash_password(password.as_bytes(), &salt)
51            .map(|h| h.to_string())
52            .map_err(|e| AuthError::Other(e.to_string()))
53    }
54
55    pub async fn user_exists(storage: &dyn StorageBackend, username: &str) -> AuthResult<bool> {
56        Ok(storage.get(&user_key(username)).await?.is_some())
57    }
58
59    /// Writes a user without validating anything, overwriting any existing
60    /// record. This is the bootstrap path: its credentials come from operator
61    /// configuration, which predates the HTTP rules and may not meet them
62    /// (a short password, an email-shaped name), and refusing them would stop
63    /// an existing deployment from starting. Everything reachable over HTTP
64    /// goes through [`Self::upsert_user`] instead.
65    pub async fn create_user(
66        storage: &dyn StorageBackend,
67        username: &str,
68        password: &str,
69        policies: Vec<String>,
70    ) -> AuthResult<()> {
71        put_user(storage, username, password, policies).await
72    }
73
74    /// Creates the user, or replaces it outright: the password is re-hashed
75    /// under a fresh salt and the policy list is swapped, not merged, so the
76    /// caller always knows exactly what the identity can do afterwards.
77    ///
78    /// Tokens already issued keep the policies they were minted with until
79    /// they expire — a replace narrows future logins, not live sessions.
80    pub async fn upsert_user(
81        storage: &dyn StorageBackend,
82        username: &str,
83        password: &str,
84        policies: Vec<String>,
85    ) -> AuthResult<()> {
86        validate_username(username)?;
87        validate_password(password)?;
88        validate_policies(&policies)?;
89        put_user(storage, username, password, policies).await
90    }
91
92    /// The user's name and policies, or `None` if there is no such user.
93    /// Never the hash: nothing outside `login` has any use for it.
94    pub async fn read_user(
95        storage: &dyn StorageBackend,
96        username: &str,
97    ) -> AuthResult<Option<UserInfo>> {
98        let Some(record) = get_record(storage, username).await? else {
99            return Ok(None);
100        };
101        Ok(Some(UserInfo {
102            username: username.to_string(),
103            policies: record.policies,
104        }))
105    }
106
107    /// Deletes the user, returning whether it existed. Tokens it already
108    /// holds are not revoked — they are not indexed by owner — so they keep
109    /// working until they expire, and a holder that renews them keeps them
110    /// alive.
111    pub async fn delete_user(storage: &dyn StorageBackend, username: &str) -> AuthResult<bool> {
112        let key = user_key(username);
113        if storage.get(&key).await?.is_none() {
114            return Ok(false);
115        }
116        storage.delete(&key).await?;
117        Ok(true)
118    }
119}
120
121/// `[A-Za-z0-9_.-]`, 1 to [`MAX_USERNAME_LEN`] characters, not starting with
122/// `.`. The name becomes a storage key and a policy path segment, so it must
123/// not carry `/` (which would reach into another key's namespace) or look
124/// like a relative path component.
125pub fn validate_username(username: &str) -> AuthResult<()> {
126    if username.is_empty() || username.len() > MAX_USERNAME_LEN {
127        return Err(AuthError::InvalidRequest(format!(
128            "username must be 1 to {MAX_USERNAME_LEN} characters"
129        )));
130    }
131    if !username
132        .chars()
133        .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-'))
134    {
135        return Err(AuthError::InvalidRequest(
136            "username may only contain letters, digits, '_', '.' and '-'".into(),
137        ));
138    }
139    if username.starts_with('.') {
140        return Err(AuthError::InvalidRequest(
141            "username must not start with '.'".into(),
142        ));
143    }
144    Ok(())
145}
146
147/// At least [`MIN_PASSWORD_LEN`] characters — counted as characters, not
148/// bytes, so a non-ASCII password is not credited for its encoding.
149pub fn validate_password(password: &str) -> AuthResult<()> {
150    if password.chars().count() < MIN_PASSWORD_LEN {
151        return Err(AuthError::InvalidRequest(format!(
152            "password must be at least {MIN_PASSWORD_LEN} characters"
153        )));
154    }
155    Ok(())
156}
157
158/// An empty list is fine (an identity that can log in and do nothing); an
159/// empty name is not, since it can only be a mistake in the caller.
160pub fn validate_policies(policies: &[String]) -> AuthResult<()> {
161    if policies.iter().any(|p| p.is_empty()) {
162        return Err(AuthError::InvalidRequest(
163            "policy names must be non-empty".into(),
164        ));
165    }
166    Ok(())
167}
168
169fn user_key(username: &str) -> String {
170    format!("{USER_PREFIX}{username}")
171}
172
173async fn put_user(
174    storage: &dyn StorageBackend,
175    username: &str,
176    password: &str,
177    policies: Vec<String>,
178) -> AuthResult<()> {
179    let record = UserRecord {
180        password_hash: UserPassAuth::hash_password(password)?,
181        policies,
182    };
183    let value = serde_json::to_vec(&record).map_err(|e| AuthError::Other(e.to_string()))?;
184    storage
185        .put(
186            &user_key(username),
187            StorageEntry {
188                value,
189                expires_at: None,
190            },
191        )
192        .await?;
193    Ok(())
194}
195
196async fn get_record(storage: &dyn StorageBackend, username: &str) -> AuthResult<Option<UserRecord>> {
197    let Some(entry) = storage.get(&user_key(username)).await? else {
198        return Ok(None);
199    };
200    let record = serde_json::from_slice(&entry.value).map_err(|e| AuthError::Other(e.to_string()))?;
201    Ok(Some(record))
202}
203
204#[async_trait]
205impl AuthMethod for UserPassAuth {
206    async fn login(
207        &self,
208        storage: &dyn StorageBackend,
209        request: LoginRequest,
210    ) -> AuthResult<AuthOutcome> {
211        let LoginRequest::UserPass { username, password } = request else {
212            return Err(AuthError::InvalidRequest(
213                "expected username/password".into(),
214            ));
215        };
216
217        let record = get_record(storage, &username)
218            .await?
219            .ok_or(AuthError::InvalidCredentials)?;
220
221        let hash = PasswordHash::new(&record.password_hash)
222            .map_err(|e| AuthError::Other(e.to_string()))?;
223        Argon2::default()
224            .verify_password(password.as_bytes(), &hash)
225            .map_err(|_| AuthError::InvalidCredentials)?;
226
227        Ok(AuthOutcome {
228            policies: record.policies,
229            display_name: username,
230            ttl_seconds: Some(DEFAULT_TTL_SECONDS),
231        })
232    }
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238    use secrets_core::storage::StorageResult;
239    use std::collections::HashMap;
240    use std::sync::Mutex;
241
242    #[derive(Default)]
243    struct MemStorage(Mutex<HashMap<String, StorageEntry>>);
244
245    #[async_trait]
246    impl StorageBackend for MemStorage {
247        async fn get(&self, path: &str) -> StorageResult<Option<StorageEntry>> {
248            Ok(self.0.lock().unwrap().get(path).cloned())
249        }
250        async fn put(&self, path: &str, entry: StorageEntry) -> StorageResult<()> {
251            self.0.lock().unwrap().insert(path.to_string(), entry);
252            Ok(())
253        }
254        async fn delete(&self, path: &str) -> StorageResult<()> {
255            self.0.lock().unwrap().remove(path);
256            Ok(())
257        }
258        async fn list(&self, prefix: &str) -> StorageResult<Vec<String>> {
259            Ok(self
260                .0
261                .lock()
262                .unwrap()
263                .keys()
264                .filter(|k| k.starts_with(prefix))
265                .cloned()
266                .collect())
267        }
268    }
269
270    const PASSWORD: &str = "correct horse battery";
271
272    async fn login(storage: &MemStorage, username: &str, password: &str) -> AuthResult<AuthOutcome> {
273        UserPassAuth::new()
274            .login(
275                storage,
276                LoginRequest::UserPass {
277                    username: username.to_string(),
278                    password: password.to_string(),
279                },
280            )
281            .await
282    }
283
284    fn names(names: &[&str]) -> Vec<String> {
285        names.iter().map(|n| n.to_string()).collect()
286    }
287
288    #[tokio::test]
289    async fn upserted_user_logs_in_with_exactly_its_policies() {
290        let storage = MemStorage::default();
291        UserPassAuth::upsert_user(&storage, "liaison", PASSWORD, names(&["liaison"]))
292            .await
293            .unwrap();
294
295        let outcome = login(&storage, "liaison", PASSWORD).await.unwrap();
296        assert_eq!(outcome.policies, names(&["liaison"]));
297        assert_eq!(outcome.display_name, "liaison");
298        // Same key scheme as bootstrap, so both paths see the same users.
299        assert!(storage.0.lock().unwrap().contains_key("auth/userpass/users/liaison"));
300    }
301
302    /// Replace means replace: the old password stops working and the policy
303    /// list is swapped, not merged.
304    #[tokio::test]
305    async fn upsert_replaces_password_and_policies() {
306        let storage = MemStorage::default();
307        UserPassAuth::upsert_user(&storage, "app", PASSWORD, names(&["a", "b"]))
308            .await
309            .unwrap();
310        UserPassAuth::upsert_user(&storage, "app", "another long password", names(&["c"]))
311            .await
312            .unwrap();
313
314        assert!(matches!(
315            login(&storage, "app", PASSWORD).await,
316            Err(AuthError::InvalidCredentials)
317        ));
318        let outcome = login(&storage, "app", "another long password").await.unwrap();
319        assert_eq!(outcome.policies, names(&["c"]));
320    }
321
322    #[tokio::test]
323    async fn read_user_returns_policies_and_never_the_hash() {
324        let storage = MemStorage::default();
325        UserPassAuth::upsert_user(&storage, "app", PASSWORD, names(&["p"]))
326            .await
327            .unwrap();
328
329        let info = UserPassAuth::read_user(&storage, "app").await.unwrap().unwrap();
330        assert_eq!(
331            info,
332            UserInfo {
333                username: "app".into(),
334                policies: names(&["p"]),
335            }
336        );
337        let json = serde_json::to_string(&info).unwrap();
338        assert!(!json.contains("hash"), "{json}");
339        assert!(!json.contains("argon2"), "{json}");
340
341        assert_eq!(UserPassAuth::read_user(&storage, "nobody").await.unwrap(), None);
342    }
343
344    #[tokio::test]
345    async fn delete_user_reports_whether_it_existed_and_blocks_login() {
346        let storage = MemStorage::default();
347        UserPassAuth::upsert_user(&storage, "app", PASSWORD, vec![])
348            .await
349            .unwrap();
350
351        assert!(UserPassAuth::delete_user(&storage, "app").await.unwrap());
352        assert!(!UserPassAuth::delete_user(&storage, "app").await.unwrap());
353        assert!(matches!(
354            login(&storage, "app", PASSWORD).await,
355            Err(AuthError::InvalidCredentials)
356        ));
357    }
358
359    /// Bootstrap credentials come from config and must keep working even when
360    /// they would fail the HTTP rules.
361    #[tokio::test]
362    async fn create_user_skips_validation_for_bootstrap() {
363        let storage = MemStorage::default();
364        UserPassAuth::create_user(&storage, "admin@example.com", "change-me", names(&["root"]))
365            .await
366            .unwrap();
367        let outcome = login(&storage, "admin@example.com", "change-me").await.unwrap();
368        assert_eq!(outcome.policies, names(&["root"]));
369    }
370
371    #[tokio::test]
372    async fn invalid_upsert_writes_nothing() {
373        let storage = MemStorage::default();
374        for (username, password, policies) in [
375            (".hidden", PASSWORD, vec![]),
376            ("app", "too short", vec![]),
377            ("app", PASSWORD, names(&["ok", ""])),
378        ] {
379            let result = UserPassAuth::upsert_user(&storage, username, password, policies).await;
380            assert!(
381                matches!(result, Err(AuthError::InvalidRequest(_))),
382                "{username}/{password} was accepted"
383            );
384        }
385        assert!(storage.0.lock().unwrap().is_empty());
386    }
387
388    #[test]
389    fn username_rules() {
390        for ok in ["a", "typednotes-app", "svc_1.v2", "A-Z.0_9", &"x".repeat(64), "a."] {
391            assert!(validate_username(ok).is_ok(), "{ok:?} rejected");
392        }
393        for bad in [
394            "",
395            &"x".repeat(65),
396            ".",
397            ".env",
398            "a/b",
399            "../root",
400            "with space",
401            "émile",
402            "a@b",
403            "a%2Fb",
404        ] {
405            assert!(
406                matches!(validate_username(bad), Err(AuthError::InvalidRequest(_))),
407                "{bad:?} accepted"
408            );
409        }
410    }
411
412    #[test]
413    fn password_length_counts_characters() {
414        assert!(validate_password("12345678901").is_err());
415        assert!(validate_password("123456789012").is_ok());
416        // 11 characters but 22 bytes: still too short.
417        assert!(validate_password("ééééééééééé").is_err());
418    }
419
420    #[test]
421    fn policy_names_must_be_non_empty_but_the_list_may_be_empty() {
422        assert!(validate_policies(&[]).is_ok());
423        assert!(validate_policies(&names(&["a", "b"])).is_ok());
424        assert!(validate_policies(&names(&[""])).is_err());
425    }
426}