1use async_trait::async_trait;
7use chrono::{DateTime, Utc};
8use thiserror::Error;
9use uuid::Uuid;
10
11use crate::RepositoryError;
12
13#[non_exhaustive]
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct Principal {
24 pub user_id: Uuid,
25 pub subject: String,
26 pub email: Option<String>,
27 pub name: Option<String>,
28 pub roles: Vec<String>,
29}
30
31impl Principal {
32 pub fn new(user_id: Uuid, subject: impl Into<String>) -> Self {
33 Self {
34 user_id,
35 subject: subject.into(),
36 email: None,
37 name: None,
38 roles: Vec::new(),
39 }
40 }
41
42 pub fn with_user_id(mut self, user_id: Uuid) -> Self {
43 self.user_id = user_id;
44 self
45 }
46
47 pub fn with_email(mut self, email: impl Into<String>) -> Self {
48 self.email = Some(email.into());
49 self
50 }
51
52 pub fn with_name(mut self, name: impl Into<String>) -> Self {
53 self.name = Some(name.into());
54 self
55 }
56
57 pub fn with_roles<I, S>(mut self, roles: I) -> Self
58 where
59 I: IntoIterator<Item = S>,
60 S: Into<String>,
61 {
62 self.roles = roles.into_iter().map(Into::into).collect();
63 self
64 }
65
66 pub fn has_role(&self, role: &str) -> bool {
69 self.roles.iter().any(|held| held == role)
70 }
71}
72
73#[non_exhaustive]
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
77pub enum AuthError {
78 #[error("auth.invalid")]
80 Invalid,
81 #[error("auth.expired")]
83 Expired,
84 #[error("auth.unavailable")]
86 Unavailable,
87}
88
89#[async_trait]
92pub trait Authenticator: Send + Sync {
93 async fn authenticate(&self, token: &str) -> Result<Principal, AuthError>;
94}
95
96#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
100#[error("auth.hash_failed")]
101pub struct HashError;
102
103#[async_trait]
106pub trait PasswordHasher: Send + Sync {
107 async fn hash(&self, password: &str) -> Result<String, HashError>;
108
109 async fn verify(&self, password: &str, hash: Option<String>) -> bool;
113}
114
115#[async_trait]
117pub trait CredentialStore: Send + Sync {
118 async fn password_hash(&self, user_id: Uuid) -> Result<Option<String>, RepositoryError>;
119 async fn set_password_hash(&self, user_id: Uuid, hash: &str) -> Result<(), RepositoryError>;
121}
122
123#[non_exhaustive]
126#[derive(Debug, Clone, PartialEq, Eq)]
127pub struct Session {
128 pub token: String,
129 pub expires_at: DateTime<Utc>,
130}
131
132impl Session {
133 pub fn new(token: impl Into<String>, expires_at: DateTime<Utc>) -> Self {
134 Self {
135 token: token.into(),
136 expires_at,
137 }
138 }
139}
140
141#[async_trait]
144pub trait SessionStore: Send + Sync {
145 async fn open(&self, user_id: Uuid) -> Result<Session, RepositoryError>;
146 async fn revoke(&self, token: &str) -> Result<(), RepositoryError>;
148}
149
150#[cfg(any(test, feature = "test-utils"))]
151pub mod mocks {
152 use super::*;
153 use mockall::mock;
154
155 mock! {
156 pub Authenticator {}
157
158 #[async_trait]
159 impl Authenticator for Authenticator {
160 async fn authenticate(&self, token: &str) -> Result<Principal, AuthError>;
161 }
162 }
163
164 mock! {
165 pub PasswordHasher {}
166
167 #[async_trait]
168 impl PasswordHasher for PasswordHasher {
169 async fn hash(&self, password: &str) -> Result<String, HashError>;
170 async fn verify(&self, password: &str, hash: Option<String>) -> bool;
171 }
172 }
173
174 mock! {
175 pub CredentialStore {}
176
177 #[async_trait]
178 impl CredentialStore for CredentialStore {
179 async fn password_hash(&self, user_id: Uuid) -> Result<Option<String>, RepositoryError>;
180 async fn set_password_hash(&self, user_id: Uuid, hash: &str) -> Result<(), RepositoryError>;
181 }
182 }
183
184 mock! {
185 pub SessionStore {}
186
187 #[async_trait]
188 impl SessionStore for SessionStore {
189 async fn open(&self, user_id: Uuid) -> Result<Session, RepositoryError>;
190 async fn revoke(&self, token: &str) -> Result<(), RepositoryError>;
191 }
192 }
193}
194
195#[cfg(test)]
196mod tests {
197 use super::mocks::{
198 MockAuthenticator, MockCredentialStore, MockPasswordHasher, MockSessionStore,
199 };
200 use super::*;
201
202 #[test]
203 fn should_hold_no_role_or_profile_when_built_from_an_id_and_a_subject() {
204 let user_id = Uuid::now_v7();
206
207 let principal = Principal::new(user_id, "auth0|ada");
209
210 assert_eq!(principal.user_id, user_id);
212 assert_eq!(principal.subject, "auth0|ada");
213 assert_eq!(principal.email, None);
214 assert_eq!(principal.name, None);
215 assert!(principal.roles.is_empty());
216 }
217
218 #[test]
219 fn should_carry_the_profile_when_given_one() {
220 let user_id = Uuid::now_v7();
222
223 let principal = Principal::new(Uuid::nil(), "ada")
225 .with_user_id(user_id)
226 .with_email("ada@example.com")
227 .with_name("Ada Lovelace")
228 .with_roles(["admin", "editor"]);
229
230 assert_eq!(principal.user_id, user_id);
232 assert_eq!(principal.email.as_deref(), Some("ada@example.com"));
233 assert_eq!(principal.name.as_deref(), Some("Ada Lovelace"));
234 assert_eq!(principal.roles, vec!["admin", "editor"]);
235 }
236
237 #[test]
238 fn should_have_a_role_only_when_it_holds_exactly_that_role() {
239 let principal = Principal::new(Uuid::nil(), "ada").with_roles(["admin"]);
241
242 assert!(principal.has_role("admin"));
244 assert!(!principal.has_role("Admin"));
245 assert!(!principal.has_role("adm"));
246 assert!(!principal.has_role("editor"));
247 }
248
249 #[test]
250 fn should_keep_error_codes_stable_when_logged() {
251 assert_eq!(AuthError::Invalid.to_string(), "auth.invalid");
252 assert_eq!(AuthError::Expired.to_string(), "auth.expired");
253 assert_eq!(AuthError::Unavailable.to_string(), "auth.unavailable");
254 }
255
256 #[test]
257 fn should_keep_the_token_and_its_expiry_when_a_session_is_built() {
258 let expires_at = chrono::Utc::now();
260
261 let session = Session::new("opaque", expires_at);
263
264 assert_eq!(session.token, "opaque");
266 assert_eq!(session.expires_at, expires_at);
267 assert_eq!(HashError.to_string(), "auth.hash_failed");
268 }
269
270 #[tokio::test]
271 async fn should_drive_the_local_ports_when_mocked() {
272 let user_id = Uuid::now_v7();
274 let mut hasher = MockPasswordHasher::new();
275 hasher
276 .expect_verify()
277 .returning(|password, hash| hash.as_deref() == Some("h") && password == "secret");
278 let mut credentials = MockCredentialStore::new();
279 credentials
280 .expect_password_hash()
281 .returning(|_| Ok(Some("h".to_string())));
282 let mut sessions = MockSessionStore::new();
283 sessions
284 .expect_open()
285 .returning(|_| Ok(Session::new("t", chrono::Utc::now())));
286 let (hasher, credentials, sessions): (
287 &dyn PasswordHasher,
288 &dyn CredentialStore,
289 &dyn SessionStore,
290 ) = (&hasher, &credentials, &sessions);
291
292 let hash = credentials.password_hash(user_id).await;
294 let accepted = hasher.verify("secret", Some("h".to_string())).await;
295 let unknown = hasher.verify("secret", None).await;
296 let session = sessions.open(user_id).await;
297
298 assert_eq!(hash, Ok(Some("h".to_string())));
300 assert!(accepted);
301 assert!(!unknown);
302 assert_eq!(session.map(|s| s.token), Ok("t".to_string()));
303 }
304
305 #[tokio::test]
306 async fn should_answer_what_the_test_programs_when_mocked() {
307 let mut authenticator = MockAuthenticator::new();
309 authenticator
310 .expect_authenticate()
311 .returning(|token| match token {
312 "good" => Ok(Principal::new(Uuid::nil(), "ada")),
313 _ => Err(AuthError::Expired),
314 });
315 let authenticator: &dyn Authenticator = &authenticator;
316
317 let accepted = authenticator.authenticate("good").await;
319 let rejected = authenticator.authenticate("old").await;
320
321 assert_eq!(accepted.map(|p| p.subject), Ok("ada".to_string()));
323 assert_eq!(rejected, Err(AuthError::Expired));
324 }
325}