Skip to main content

allowthem_core/
auth_client.rs

1use std::future::Future;
2use std::pin::Pin;
3
4use crate::error::AuthError;
5use crate::handle::AllowThem;
6use crate::types::{PermissionName, RoleName, SessionToken, User, UserId};
7
8/// Convenience alias for boxed futures returned by `AuthClient` methods.
9pub type AuthFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T, AuthError>> + Send + 'a>>;
10
11/// Abstraction over embedded and external authentication modes.
12///
13/// Consuming projects use this trait instead of `AllowThem` directly, enabling
14/// a config-flag switch between embedded mode (local SQLite) and external mode
15/// (OIDC/JWT, Block 11) without changing handler or middleware code.
16///
17/// All session validation, role/permission checks, and logout flow through
18/// this trait. Login is intentionally excluded — embedded mode handles
19/// credentials directly, external mode redirects to OIDC.
20pub trait AuthClient: Send + Sync {
21    /// Validate a session token and return the active user.
22    ///
23    /// Returns `Ok(None)` when the token is invalid, expired, or the user is
24    /// inactive. Returns `Err` only on infrastructure failures (DB, network).
25    fn validate_session<'a>(&'a self, token: &'a SessionToken) -> AuthFuture<'a, Option<User>>;
26
27    /// Check whether a user has the given role.
28    fn check_role<'a>(&'a self, user_id: &'a UserId, role: &'a RoleName) -> AuthFuture<'a, bool>;
29
30    /// Check whether a user has the given permission (direct or via role).
31    fn check_permission<'a>(
32        &'a self,
33        user_id: &'a UserId,
34        permission: &'a PermissionName,
35    ) -> AuthFuture<'a, bool>;
36
37    /// Return the name of the first role in `hierarchy` that the user holds.
38    ///
39    /// `hierarchy[0]` is the highest role. Returns `None` if the user holds none.
40    fn resolve_highest_role<'a>(
41        &'a self,
42        user_id: &'a UserId,
43        hierarchy: &'a [&str],
44    ) -> AuthFuture<'a, Option<String>>;
45
46    /// Invalidate a session. Fire-and-forget — non-existent sessions are not errors.
47    fn logout<'a>(&'a self, token: &'a SessionToken) -> AuthFuture<'a, ()>;
48
49    /// The URL/path where users should be directed to log in.
50    fn login_url(&self) -> &str;
51
52    /// The cookie name used for session tokens.
53    fn session_cookie_name(&self) -> &str;
54}
55
56/// `AuthClient` implementation backed by an embedded `AllowThem` handle.
57///
58/// Wraps an `AllowThem` and a login URL. Consuming projects that also need
59/// direct `AllowThem` access (login flow, registration, cookie generation) can
60/// keep a separate clone in their state — cloning `AllowThem` is cheap (Arc).
61pub struct EmbeddedAuthClient {
62    ath: AllowThem,
63    login_url: String,
64}
65
66impl EmbeddedAuthClient {
67    /// Create a new `EmbeddedAuthClient`.
68    ///
69    /// `login_url` is the local path (e.g. `"/login"`) where unauthenticated
70    /// users should be redirected.
71    pub fn new(ath: AllowThem, login_url: impl Into<String>) -> Self {
72        Self {
73            ath,
74            login_url: login_url.into(),
75        }
76    }
77}
78
79impl AuthClient for EmbeddedAuthClient {
80    fn validate_session<'a>(&'a self, token: &'a SessionToken) -> AuthFuture<'a, Option<User>> {
81        Box::pin(async move {
82            let ttl = self.ath.session_config().ttl;
83            let session = match self.ath.db().validate_session(token, ttl).await? {
84                Some(s) => s,
85                None => return Ok(None),
86            };
87            match self.ath.db().get_user(session.user_id).await {
88                Ok(user) if user.is_active => Ok(Some(user)),
89                Ok(_) => Ok(None),                    // inactive
90                Err(AuthError::NotFound) => Ok(None), // orphaned session
91                Err(e) => Err(e),
92            }
93        })
94    }
95
96    fn check_role<'a>(&'a self, user_id: &'a UserId, role: &'a RoleName) -> AuthFuture<'a, bool> {
97        Box::pin(async move { self.ath.db().has_role(user_id, role).await })
98    }
99
100    fn check_permission<'a>(
101        &'a self,
102        user_id: &'a UserId,
103        permission: &'a PermissionName,
104    ) -> AuthFuture<'a, bool> {
105        Box::pin(async move { self.ath.db().has_permission(user_id, permission).await })
106    }
107
108    fn resolve_highest_role<'a>(
109        &'a self,
110        user_id: &'a UserId,
111        hierarchy: &'a [&str],
112    ) -> AuthFuture<'a, Option<String>> {
113        Box::pin(async move { self.ath.db().resolve_highest_role(user_id, hierarchy).await })
114    }
115
116    fn logout<'a>(&'a self, token: &'a SessionToken) -> AuthFuture<'a, ()> {
117        Box::pin(async move {
118            let _ = self.ath.db().delete_session(token).await?;
119            Ok(())
120        })
121    }
122
123    fn login_url(&self) -> &str {
124        &self.login_url
125    }
126
127    fn session_cookie_name(&self) -> &str {
128        self.ath.session_config().cookie_name
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use std::sync::Arc;
135
136    use chrono::{Duration, Utc};
137
138    use super::*;
139    use crate::handle::AllowThemBuilder;
140    use crate::sessions::{generate_token, hash_token};
141    use crate::types::{Email, PermissionName, RoleName};
142
143    async fn setup() -> EmbeddedAuthClient {
144        let ath = AllowThemBuilder::new("sqlite::memory:")
145            .cookie_secure(false)
146            .build()
147            .await
148            .unwrap();
149        EmbeddedAuthClient::new(ath, "/login")
150    }
151
152    #[tokio::test]
153    async fn validate_session_valid_token_returns_user() {
154        let client = setup().await;
155        let email = Email::new("valid@example.com".into()).unwrap();
156        let user = client
157            .ath
158            .db()
159            .create_user(email, "password123", None, None)
160            .await
161            .unwrap();
162
163        let token = generate_token();
164        let token_hash = hash_token(&token);
165        let expires = Utc::now() + Duration::hours(24);
166        client
167            .ath
168            .db()
169            .create_session(user.id, token_hash, None, None, expires)
170            .await
171            .unwrap();
172
173        let result = client.validate_session(&token).await.unwrap();
174        assert!(result.is_some());
175        assert_eq!(result.unwrap().email.as_str(), "valid@example.com");
176    }
177
178    #[tokio::test]
179    async fn validate_session_expired_token_returns_none() {
180        let client = setup().await;
181        let email = Email::new("expired@example.com".into()).unwrap();
182        let user = client
183            .ath
184            .db()
185            .create_user(email, "password123", None, None)
186            .await
187            .unwrap();
188
189        let token = generate_token();
190        let token_hash = hash_token(&token);
191        let expires = Utc::now() - Duration::hours(1);
192        client
193            .ath
194            .db()
195            .create_session(user.id, token_hash, None, None, expires)
196            .await
197            .unwrap();
198
199        let result = client.validate_session(&token).await.unwrap();
200        assert!(result.is_none());
201    }
202
203    #[tokio::test]
204    async fn validate_session_invalid_token_returns_none() {
205        let client = setup().await;
206        let token = generate_token();
207        let result = client.validate_session(&token).await.unwrap();
208        assert!(result.is_none());
209    }
210
211    #[tokio::test]
212    async fn validate_session_inactive_user_returns_none() {
213        let client = setup().await;
214        let email = Email::new("inactive@example.com".into()).unwrap();
215        let user = client
216            .ath
217            .db()
218            .create_user(email, "password123", None, None)
219            .await
220            .unwrap();
221
222        let token = generate_token();
223        let token_hash = hash_token(&token);
224        let expires = Utc::now() + Duration::hours(24);
225        client
226            .ath
227            .db()
228            .create_session(user.id, token_hash, None, None, expires)
229            .await
230            .unwrap();
231
232        client
233            .ath
234            .db()
235            .update_user_active(user.id, false)
236            .await
237            .unwrap();
238
239        let result = client.validate_session(&token).await.unwrap();
240        assert!(result.is_none());
241    }
242
243    #[tokio::test]
244    async fn validate_session_deleted_user_returns_none() {
245        let client = setup().await;
246        let email = Email::new("deleted@example.com".into()).unwrap();
247        let user = client
248            .ath
249            .db()
250            .create_user(email, "password123", None, None)
251            .await
252            .unwrap();
253
254        let token = generate_token();
255        let token_hash = hash_token(&token);
256        let expires = Utc::now() + Duration::hours(24);
257        client
258            .ath
259            .db()
260            .create_session(user.id, token_hash, None, None, expires)
261            .await
262            .unwrap();
263
264        client.ath.db().delete_user(user.id).await.unwrap();
265
266        let result = client.validate_session(&token).await.unwrap();
267        assert!(result.is_none());
268    }
269
270    #[tokio::test]
271    async fn check_role_returns_true_when_assigned() {
272        let client = setup().await;
273        let email = Email::new("roleuser@example.com".into()).unwrap();
274        let user = client
275            .ath
276            .db()
277            .create_user(email, "password123", None, None)
278            .await
279            .unwrap();
280
281        let rn = RoleName::new("admin");
282        let role = client.ath.db().create_role(&rn, None).await.unwrap();
283        client
284            .ath
285            .db()
286            .assign_role(&user.id, &role.id)
287            .await
288            .unwrap();
289
290        let result = client.check_role(&user.id, &rn).await.unwrap();
291        assert!(result);
292    }
293
294    #[tokio::test]
295    async fn check_role_returns_false_when_not_assigned() {
296        let client = setup().await;
297        let email = Email::new("norole@example.com".into()).unwrap();
298        let user = client
299            .ath
300            .db()
301            .create_user(email, "password123", None, None)
302            .await
303            .unwrap();
304
305        let rn = RoleName::new("admin");
306        let result = client.check_role(&user.id, &rn).await.unwrap();
307        assert!(!result);
308    }
309
310    #[tokio::test]
311    async fn check_permission_returns_true_direct() {
312        let client = setup().await;
313        let email = Email::new("permdirect@example.com".into()).unwrap();
314        let user = client
315            .ath
316            .db()
317            .create_user(email, "password123", None, None)
318            .await
319            .unwrap();
320
321        let pn = PermissionName::new("posts:write");
322        let perm = client.ath.db().create_permission(&pn, None).await.unwrap();
323        client
324            .ath
325            .db()
326            .assign_permission_to_user(&user.id, &perm.id)
327            .await
328            .unwrap();
329
330        let result = client.check_permission(&user.id, &pn).await.unwrap();
331        assert!(result);
332    }
333
334    #[tokio::test]
335    async fn check_permission_returns_true_via_role() {
336        let client = setup().await;
337        let email = Email::new("permviarole@example.com".into()).unwrap();
338        let user = client
339            .ath
340            .db()
341            .create_user(email, "password123", None, None)
342            .await
343            .unwrap();
344
345        let rn = RoleName::new("editor");
346        let role = client.ath.db().create_role(&rn, None).await.unwrap();
347
348        let pn = PermissionName::new("posts:read");
349        let perm = client.ath.db().create_permission(&pn, None).await.unwrap();
350        client
351            .ath
352            .db()
353            .assign_permission_to_role(&role.id, &perm.id)
354            .await
355            .unwrap();
356
357        client
358            .ath
359            .db()
360            .assign_role(&user.id, &role.id)
361            .await
362            .unwrap();
363
364        let result = client.check_permission(&user.id, &pn).await.unwrap();
365        assert!(result);
366    }
367
368    #[tokio::test]
369    async fn check_permission_returns_false_when_missing() {
370        let client = setup().await;
371        let email = Email::new("noperm@example.com".into()).unwrap();
372        let user = client
373            .ath
374            .db()
375            .create_user(email, "password123", None, None)
376            .await
377            .unwrap();
378
379        let pn = PermissionName::new("posts:delete");
380        let result = client.check_permission(&user.id, &pn).await.unwrap();
381        assert!(!result);
382    }
383
384    #[tokio::test]
385    async fn logout_deletes_session() {
386        let client = setup().await;
387        let email = Email::new("logout@example.com".into()).unwrap();
388        let user = client
389            .ath
390            .db()
391            .create_user(email, "password123", None, None)
392            .await
393            .unwrap();
394
395        let token = generate_token();
396        let token_hash = hash_token(&token);
397        let expires = Utc::now() + Duration::hours(24);
398        client
399            .ath
400            .db()
401            .create_session(user.id, token_hash, None, None, expires)
402            .await
403            .unwrap();
404
405        client.logout(&token).await.unwrap();
406
407        let result = client.validate_session(&token).await.unwrap();
408        assert!(result.is_none());
409    }
410
411    #[tokio::test]
412    async fn logout_nonexistent_token_succeeds() {
413        let client = setup().await;
414        let token = generate_token();
415        let result = client.logout(&token).await;
416        assert!(result.is_ok());
417    }
418
419    #[tokio::test]
420    async fn login_url_returns_configured_path() {
421        let ath = AllowThemBuilder::new("sqlite::memory:")
422            .build()
423            .await
424            .unwrap();
425        let client = EmbeddedAuthClient::new(ath, "/login");
426        assert_eq!(client.login_url(), "/login");
427    }
428
429    #[tokio::test]
430    async fn session_cookie_name_returns_config_name() {
431        let ath = AllowThemBuilder::new("sqlite::memory:")
432            .build()
433            .await
434            .unwrap();
435        let client = EmbeddedAuthClient::new(ath, "/login");
436        assert_eq!(client.session_cookie_name(), "allowthem_session");
437
438        let ath_custom = AllowThemBuilder::new("sqlite::memory:")
439            .cookie_name("my_session")
440            .build()
441            .await
442            .unwrap();
443        let client_custom = EmbeddedAuthClient::new(ath_custom, "/login");
444        assert_eq!(client_custom.session_cookie_name(), "my_session");
445    }
446
447    // Verify it works as Arc<dyn AuthClient>
448    #[tokio::test]
449    async fn works_as_arc_dyn_auth_client() {
450        let ath = AllowThemBuilder::new("sqlite::memory:")
451            .build()
452            .await
453            .unwrap();
454        let _client: Arc<dyn AuthClient> = Arc::new(EmbeddedAuthClient::new(ath, "/login"));
455    }
456
457    #[tokio::test]
458    async fn resolve_highest_role_returns_correct_role_via_trait() {
459        let client = setup().await;
460        let email = Email::new("roletest@example.com".into()).unwrap();
461        let user = client
462            .ath
463            .db()
464            .create_user(email, "password123", None, None)
465            .await
466            .unwrap();
467        let roles = client
468            .ath
469            .db()
470            .bootstrap_roles(&["admin", "editor"])
471            .await
472            .unwrap();
473        client
474            .ath
475            .db()
476            .assign_role(&user.id, &roles[1].id) // editor
477            .await
478            .unwrap();
479        let result = client
480            .resolve_highest_role(&user.id, &["admin", "editor"])
481            .await
482            .unwrap();
483        assert_eq!(result, Some("editor".to_owned()));
484    }
485
486    #[tokio::test]
487    async fn resolve_highest_role_returns_none_via_trait() {
488        let client = setup().await;
489        let email = Email::new("noroletest@example.com".into()).unwrap();
490        let user = client
491            .ath
492            .db()
493            .create_user(email, "password123", None, None)
494            .await
495            .unwrap();
496        let result = client
497            .resolve_highest_role(&user.id, &["admin", "editor"])
498            .await
499            .unwrap();
500        assert!(result.is_none());
501    }
502
503    #[tokio::test]
504    async fn resolve_highest_role_works_as_arc_dyn() {
505        let client = setup().await;
506        let email = Email::new("arcdyn@example.com".into()).unwrap();
507        let user = client
508            .ath
509            .db()
510            .create_user(email, "password123", None, None)
511            .await
512            .unwrap();
513        let roles = client
514            .ath
515            .db()
516            .bootstrap_roles(&["admin", "editor"])
517            .await
518            .unwrap();
519        client
520            .ath
521            .db()
522            .assign_role(&user.id, &roles[0].id) // admin
523            .await
524            .unwrap();
525        let arc_client: Arc<dyn AuthClient> = Arc::new(client);
526        let result = arc_client
527            .resolve_highest_role(&user.id, &["admin", "editor"])
528            .await
529            .unwrap();
530        assert_eq!(result, Some("admin".to_owned()));
531    }
532}