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    /// Invalidate a session. Fire-and-forget — non-existent sessions are not errors.
38    fn logout<'a>(&'a self, token: &'a SessionToken) -> AuthFuture<'a, ()>;
39
40    /// The URL/path where users should be directed to log in.
41    fn login_url(&self) -> &str;
42
43    /// The cookie name used for session tokens.
44    fn session_cookie_name(&self) -> &str;
45}
46
47/// `AuthClient` implementation backed by an embedded `AllowThem` handle.
48///
49/// Wraps an `AllowThem` and a login URL. Consuming projects that also need
50/// direct `AllowThem` access (login flow, registration, cookie generation) can
51/// keep a separate clone in their state — cloning `AllowThem` is cheap (Arc).
52pub struct EmbeddedAuthClient {
53    ath: AllowThem,
54    login_url: String,
55}
56
57impl EmbeddedAuthClient {
58    /// Create a new `EmbeddedAuthClient`.
59    ///
60    /// `login_url` is the local path (e.g. `"/login"`) where unauthenticated
61    /// users should be redirected.
62    pub fn new(ath: AllowThem, login_url: impl Into<String>) -> Self {
63        Self {
64            ath,
65            login_url: login_url.into(),
66        }
67    }
68}
69
70impl AuthClient for EmbeddedAuthClient {
71    fn validate_session<'a>(&'a self, token: &'a SessionToken) -> AuthFuture<'a, Option<User>> {
72        Box::pin(async move {
73            let ttl = self.ath.session_config().ttl;
74            let session = match self.ath.db().validate_session(token, ttl).await? {
75                Some(s) => s,
76                None => return Ok(None),
77            };
78            match self.ath.db().get_user(session.user_id).await {
79                Ok(user) if user.is_active => Ok(Some(user)),
80                Ok(_) => Ok(None),                    // inactive
81                Err(AuthError::NotFound) => Ok(None), // orphaned session
82                Err(e) => Err(e),
83            }
84        })
85    }
86
87    fn check_role<'a>(&'a self, user_id: &'a UserId, role: &'a RoleName) -> AuthFuture<'a, bool> {
88        Box::pin(async move { self.ath.db().has_role(user_id, role).await })
89    }
90
91    fn check_permission<'a>(
92        &'a self,
93        user_id: &'a UserId,
94        permission: &'a PermissionName,
95    ) -> AuthFuture<'a, bool> {
96        Box::pin(async move { self.ath.db().has_permission(user_id, permission).await })
97    }
98
99    fn logout<'a>(&'a self, token: &'a SessionToken) -> AuthFuture<'a, ()> {
100        Box::pin(async move {
101            let _ = self.ath.db().delete_session(token).await?;
102            Ok(())
103        })
104    }
105
106    fn login_url(&self) -> &str {
107        &self.login_url
108    }
109
110    fn session_cookie_name(&self) -> &str {
111        self.ath.session_config().cookie_name
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use std::sync::Arc;
118
119    use chrono::{Duration, Utc};
120
121    use super::*;
122    use crate::handle::AllowThemBuilder;
123    use crate::sessions::{generate_token, hash_token};
124    use crate::types::{Email, PermissionName, RoleName};
125
126    async fn setup() -> EmbeddedAuthClient {
127        let ath = AllowThemBuilder::new("sqlite::memory:")
128            .cookie_secure(false)
129            .build()
130            .await
131            .unwrap();
132        EmbeddedAuthClient::new(ath, "/login")
133    }
134
135    #[tokio::test]
136    async fn validate_session_valid_token_returns_user() {
137        let client = setup().await;
138        let email = Email::new("valid@example.com".into()).unwrap();
139        let user = client
140            .ath
141            .db()
142            .create_user(email, "password123", None)
143            .await
144            .unwrap();
145
146        let token = generate_token();
147        let token_hash = hash_token(&token);
148        let expires = Utc::now() + Duration::hours(24);
149        client
150            .ath
151            .db()
152            .create_session(user.id, token_hash, None, None, expires)
153            .await
154            .unwrap();
155
156        let result = client.validate_session(&token).await.unwrap();
157        assert!(result.is_some());
158        assert_eq!(result.unwrap().email.as_str(), "valid@example.com");
159    }
160
161    #[tokio::test]
162    async fn validate_session_expired_token_returns_none() {
163        let client = setup().await;
164        let email = Email::new("expired@example.com".into()).unwrap();
165        let user = client
166            .ath
167            .db()
168            .create_user(email, "password123", None)
169            .await
170            .unwrap();
171
172        let token = generate_token();
173        let token_hash = hash_token(&token);
174        let expires = Utc::now() - Duration::hours(1);
175        client
176            .ath
177            .db()
178            .create_session(user.id, token_hash, None, None, expires)
179            .await
180            .unwrap();
181
182        let result = client.validate_session(&token).await.unwrap();
183        assert!(result.is_none());
184    }
185
186    #[tokio::test]
187    async fn validate_session_invalid_token_returns_none() {
188        let client = setup().await;
189        let token = generate_token();
190        let result = client.validate_session(&token).await.unwrap();
191        assert!(result.is_none());
192    }
193
194    #[tokio::test]
195    async fn validate_session_inactive_user_returns_none() {
196        let client = setup().await;
197        let email = Email::new("inactive@example.com".into()).unwrap();
198        let user = client
199            .ath
200            .db()
201            .create_user(email, "password123", None)
202            .await
203            .unwrap();
204
205        let token = generate_token();
206        let token_hash = hash_token(&token);
207        let expires = Utc::now() + Duration::hours(24);
208        client
209            .ath
210            .db()
211            .create_session(user.id, token_hash, None, None, expires)
212            .await
213            .unwrap();
214
215        client
216            .ath
217            .db()
218            .update_user_active(user.id, false)
219            .await
220            .unwrap();
221
222        let result = client.validate_session(&token).await.unwrap();
223        assert!(result.is_none());
224    }
225
226    #[tokio::test]
227    async fn validate_session_deleted_user_returns_none() {
228        let client = setup().await;
229        let email = Email::new("deleted@example.com".into()).unwrap();
230        let user = client
231            .ath
232            .db()
233            .create_user(email, "password123", None)
234            .await
235            .unwrap();
236
237        let token = generate_token();
238        let token_hash = hash_token(&token);
239        let expires = Utc::now() + Duration::hours(24);
240        client
241            .ath
242            .db()
243            .create_session(user.id, token_hash, None, None, expires)
244            .await
245            .unwrap();
246
247        client.ath.db().delete_user(user.id).await.unwrap();
248
249        let result = client.validate_session(&token).await.unwrap();
250        assert!(result.is_none());
251    }
252
253    #[tokio::test]
254    async fn check_role_returns_true_when_assigned() {
255        let client = setup().await;
256        let email = Email::new("roleuser@example.com".into()).unwrap();
257        let user = client
258            .ath
259            .db()
260            .create_user(email, "password123", None)
261            .await
262            .unwrap();
263
264        let rn = RoleName::new("admin");
265        let role = client.ath.db().create_role(&rn, None).await.unwrap();
266        client
267            .ath
268            .db()
269            .assign_role(&user.id, &role.id)
270            .await
271            .unwrap();
272
273        let result = client.check_role(&user.id, &rn).await.unwrap();
274        assert!(result);
275    }
276
277    #[tokio::test]
278    async fn check_role_returns_false_when_not_assigned() {
279        let client = setup().await;
280        let email = Email::new("norole@example.com".into()).unwrap();
281        let user = client
282            .ath
283            .db()
284            .create_user(email, "password123", None)
285            .await
286            .unwrap();
287
288        let rn = RoleName::new("admin");
289        let result = client.check_role(&user.id, &rn).await.unwrap();
290        assert!(!result);
291    }
292
293    #[tokio::test]
294    async fn check_permission_returns_true_direct() {
295        let client = setup().await;
296        let email = Email::new("permdirect@example.com".into()).unwrap();
297        let user = client
298            .ath
299            .db()
300            .create_user(email, "password123", None)
301            .await
302            .unwrap();
303
304        let pn = PermissionName::new("posts:write");
305        let perm = client.ath.db().create_permission(&pn, None).await.unwrap();
306        client
307            .ath
308            .db()
309            .assign_permission_to_user(&user.id, &perm.id)
310            .await
311            .unwrap();
312
313        let result = client.check_permission(&user.id, &pn).await.unwrap();
314        assert!(result);
315    }
316
317    #[tokio::test]
318    async fn check_permission_returns_true_via_role() {
319        let client = setup().await;
320        let email = Email::new("permviarole@example.com".into()).unwrap();
321        let user = client
322            .ath
323            .db()
324            .create_user(email, "password123", None)
325            .await
326            .unwrap();
327
328        let rn = RoleName::new("editor");
329        let role = client.ath.db().create_role(&rn, None).await.unwrap();
330
331        let pn = PermissionName::new("posts:read");
332        let perm = client.ath.db().create_permission(&pn, None).await.unwrap();
333        client
334            .ath
335            .db()
336            .assign_permission_to_role(&role.id, &perm.id)
337            .await
338            .unwrap();
339
340        client
341            .ath
342            .db()
343            .assign_role(&user.id, &role.id)
344            .await
345            .unwrap();
346
347        let result = client.check_permission(&user.id, &pn).await.unwrap();
348        assert!(result);
349    }
350
351    #[tokio::test]
352    async fn check_permission_returns_false_when_missing() {
353        let client = setup().await;
354        let email = Email::new("noperm@example.com".into()).unwrap();
355        let user = client
356            .ath
357            .db()
358            .create_user(email, "password123", None)
359            .await
360            .unwrap();
361
362        let pn = PermissionName::new("posts:delete");
363        let result = client.check_permission(&user.id, &pn).await.unwrap();
364        assert!(!result);
365    }
366
367    #[tokio::test]
368    async fn logout_deletes_session() {
369        let client = setup().await;
370        let email = Email::new("logout@example.com".into()).unwrap();
371        let user = client
372            .ath
373            .db()
374            .create_user(email, "password123", None)
375            .await
376            .unwrap();
377
378        let token = generate_token();
379        let token_hash = hash_token(&token);
380        let expires = Utc::now() + Duration::hours(24);
381        client
382            .ath
383            .db()
384            .create_session(user.id, token_hash, None, None, expires)
385            .await
386            .unwrap();
387
388        client.logout(&token).await.unwrap();
389
390        let result = client.validate_session(&token).await.unwrap();
391        assert!(result.is_none());
392    }
393
394    #[tokio::test]
395    async fn logout_nonexistent_token_succeeds() {
396        let client = setup().await;
397        let token = generate_token();
398        let result = client.logout(&token).await;
399        assert!(result.is_ok());
400    }
401
402    #[tokio::test]
403    async fn login_url_returns_configured_path() {
404        let ath = AllowThemBuilder::new("sqlite::memory:")
405            .build()
406            .await
407            .unwrap();
408        let client = EmbeddedAuthClient::new(ath, "/login");
409        assert_eq!(client.login_url(), "/login");
410    }
411
412    #[tokio::test]
413    async fn session_cookie_name_returns_config_name() {
414        let ath = AllowThemBuilder::new("sqlite::memory:")
415            .build()
416            .await
417            .unwrap();
418        let client = EmbeddedAuthClient::new(ath, "/login");
419        assert_eq!(client.session_cookie_name(), "allowthem_session");
420
421        let ath_custom = AllowThemBuilder::new("sqlite::memory:")
422            .cookie_name("my_session")
423            .build()
424            .await
425            .unwrap();
426        let client_custom = EmbeddedAuthClient::new(ath_custom, "/login");
427        assert_eq!(client_custom.session_cookie_name(), "my_session");
428    }
429
430    // Verify it works as Arc<dyn AuthClient>
431    #[tokio::test]
432    async fn works_as_arc_dyn_auth_client() {
433        let ath = AllowThemBuilder::new("sqlite::memory:")
434            .build()
435            .await
436            .unwrap();
437        let _client: Arc<dyn AuthClient> = Arc::new(EmbeddedAuthClient::new(ath, "/login"));
438    }
439}