Skip to main content

puerto_core/
auth.rs

1//! Auth (ADR-0021): who is calling and the port that says so. A route sees a [`Principal`], never a
2//! token; where the token was checked — an OIDC provider, a local session — is the
3//! [`Authenticator`]'s business. The adapters are `puerto-auth`'s and `puerto-sqlx`'s, the HTTP
4//! side (`BearerAuth`, `#[require_role]`) `puerto-web`'s.
5
6use async_trait::async_trait;
7use chrono::{DateTime, Utc};
8use thiserror::Error;
9use uuid::Uuid;
10
11use crate::RepositoryError;
12
13/// The authenticated caller.
14///
15/// `user_id` is the local `User` row every caller has (ADR-0021 § 3); `subject` is who the
16/// provider says it is. `roles` decide `#[require_role]`; rules that depend on data take the
17/// principal in their params and decide in the use case.
18///
19/// `#[non_exhaustive]`: built with [`Principal::new`] and the `with_*` methods, so a new field
20/// later breaks no one.
21#[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    /// Whether the caller holds `role` — compared exactly, as the provider or the `roles` column
67    /// spells it.
68    pub fn has_role(&self, role: &str) -> bool {
69        self.roles.iter().any(|held| held == role)
70    }
71}
72
73/// Why a token did not become a [`Principal`]. The first two are the caller's (401), the last is
74/// ours (503): the adapter logs the cause, the API answers the code.
75#[non_exhaustive]
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
77pub enum AuthError {
78    /// Malformed, badly signed, for another issuer or audience, or an unknown session.
79    #[error("auth.invalid")]
80    Invalid,
81    /// Was valid once: the token or the session expired.
82    #[error("auth.expired")]
83    Expired,
84    /// Could not be checked: the provider or the database is unreachable.
85    #[error("auth.unavailable")]
86    Unavailable,
87}
88
89/// Turns a bearer token into the caller. `bootstrap.rs` registers one on the routes;
90/// `puerto_web::auth::BearerAuth` asks it on every protected request.
91#[async_trait]
92pub trait Authenticator: Send + Sync {
93    async fn authenticate(&self, token: &str) -> Result<Principal, AuthError>;
94}
95
96// ── Local mode (ADR-0021 § 2): the API is the identity provider ─────────────────
97
98/// Hashing a password failed — ours, not the caller's (500).
99#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
100#[error("auth.hash_failed")]
101pub struct HashError;
102
103/// Turns passwords into stored hashes and checks them. `puerto_auth::Argon2Hasher` in a project;
104/// the generated `register_user` and `login` use cases see only this port.
105#[async_trait]
106pub trait PasswordHasher: Send + Sync {
107    async fn hash(&self, password: &str) -> Result<String, HashError>;
108
109    /// Whether `password` matches `hash`. `None` — an unknown email — still does the work against
110    /// a fixed hash and answers `false`: the caller cannot tell the two cases apart by the time
111    /// the answer takes.
112    async fn verify(&self, password: &str, hash: Option<String>) -> bool;
113}
114
115/// Where each user's password hash is kept, apart from the user (`User` carries no secret).
116#[async_trait]
117pub trait CredentialStore: Send + Sync {
118    async fn password_hash(&self, user_id: Uuid) -> Result<Option<String>, RepositoryError>;
119    /// Sets or replaces the user's hash.
120    async fn set_password_hash(&self, user_id: Uuid, hash: &str) -> Result<(), RepositoryError>;
121}
122
123/// A session `login` opened: the opaque token the client sends back as a bearer token, and when
124/// it stops being accepted.
125#[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/// Opens and revokes sessions. `puerto_sqlx::auth::PgSessions` keeps them, and is the
142/// [`Authenticator`] of a local project too.
143#[async_trait]
144pub trait SessionStore: Send + Sync {
145    async fn open(&self, user_id: Uuid) -> Result<Session, RepositoryError>;
146    /// Revokes the session `token` opened; an unknown token is already revoked.
147    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        // Arrange
205        let user_id = Uuid::now_v7();
206
207        // Act
208        let principal = Principal::new(user_id, "auth0|ada");
209
210        // Assert
211        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        // Arrange
221        let user_id = Uuid::now_v7();
222
223        // Act
224        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
231        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        // Arrange
240        let principal = Principal::new(Uuid::nil(), "ada").with_roles(["admin"]);
241
242        // Act / Assert
243        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        // Arrange
259        let expires_at = chrono::Utc::now();
260
261        // Act
262        let session = Session::new("opaque", expires_at);
263
264        // Assert
265        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        // Arrange
273        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        // Act
293        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
299        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        // Arrange
308        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        // Act
318        let accepted = authenticator.authenticate("good").await;
319        let rejected = authenticator.authenticate("old").await;
320
321        // Assert
322        assert_eq!(accepted.map(|p| p.subject), Ok("ada".to_string()));
323        assert_eq!(rejected, Err(AuthError::Expired));
324    }
325}