Skip to main content

allowthem_core/
handle.rs

1use std::sync::Arc;
2
3use chrono::Duration;
4use sqlx::SqlitePool;
5
6use crate::db::Db;
7use crate::error::AuthError;
8use crate::sessions::{self, SessionConfig};
9use crate::types::{SessionToken, User};
10
11/// Outcome of a successful login or session creation.
12pub struct LoginOutcome {
13    pub user: User,
14    pub token: SessionToken,
15    /// Value for the `Set-Cookie` response header.
16    pub set_cookie: String,
17}
18
19/// Error type for builder construction and validation failures.
20#[derive(Debug, thiserror::Error)]
21pub enum BuildError {
22    /// Database connection or migration failure.
23    #[error("database error: {0}")]
24    Database(#[from] AuthError),
25
26    /// Invalid builder configuration.
27    /// Reserved for future validation; not currently produced.
28    #[error("invalid configuration: {0}")]
29    InvalidConfig(&'static str),
30}
31
32enum PoolSource {
33    Url(String),
34    Pool(SqlitePool),
35}
36
37/// Builder for constructing a configured [`AllowThem`] handle.
38pub struct AllowThemBuilder {
39    pool_source: PoolSource,
40    session_ttl: Option<Duration>,
41    cookie_name: Option<&'static str>,
42    cookie_secure: Option<bool>,
43    cookie_domain: String,
44    mfa_key: Option<[u8; 32]>,
45    signing_key: Option<[u8; 32]>,
46    csrf_key: Option<[u8; 32]>,
47    base_url: Option<String>,
48}
49
50impl AllowThemBuilder {
51    /// Start building from a database URL.
52    ///
53    /// At build time, calls `Db::connect(url)` which creates the pool,
54    /// sets pragmas (foreign_keys, WAL, busy_timeout), and runs migrations.
55    pub fn new(url: impl Into<String>) -> Self {
56        Self {
57            pool_source: PoolSource::Url(url.into()),
58            session_ttl: None,
59            cookie_name: None,
60            cookie_secure: None,
61            cookie_domain: String::new(),
62            mfa_key: None,
63            signing_key: None,
64            csrf_key: None,
65            base_url: None,
66        }
67    }
68
69    /// Start building from an existing pool.
70    ///
71    /// At build time, calls `Db::new(pool)` which runs migrations.
72    /// The caller is responsible for pragma configuration on their pool.
73    pub fn with_pool(pool: SqlitePool) -> Self {
74        Self {
75            pool_source: PoolSource::Pool(pool),
76            session_ttl: None,
77            cookie_name: None,
78            cookie_secure: None,
79            cookie_domain: String::new(),
80            mfa_key: None,
81            signing_key: None,
82            csrf_key: None,
83            base_url: None,
84        }
85    }
86
87    /// Override session TTL. Default: 24 hours.
88    pub fn session_ttl(mut self, ttl: Duration) -> Self {
89        self.session_ttl = Some(ttl);
90        self
91    }
92
93    /// Override session cookie name. Default: `"allowthem_session"`.
94    pub fn cookie_name(mut self, name: &'static str) -> Self {
95        self.cookie_name = Some(name);
96        self
97    }
98
99    /// Set the Secure attribute on session cookies.
100    ///
101    /// Default: `true`. Set to `false` for local development over HTTP.
102    pub fn cookie_secure(mut self, secure: bool) -> Self {
103        self.cookie_secure = Some(secure);
104        self
105    }
106
107    /// Set the Domain attribute on session cookies.
108    ///
109    /// Default: empty (omitted). When set, the cookie is sent to the domain
110    /// and all its subdomains.
111    pub fn cookie_domain(mut self, domain: impl Into<String>) -> Self {
112        self.cookie_domain = domain.into();
113        self
114    }
115
116    /// Set the AES-256-GCM encryption key for MFA secrets.
117    ///
118    /// When not set, all MFA operations return `AuthError::MfaNotConfigured`.
119    /// This keeps MFA opt-in for embedded integrators who don't need it.
120    pub fn mfa_key(mut self, key: [u8; 32]) -> Self {
121        self.mfa_key = Some(key);
122        self
123    }
124
125    /// Set the AES-256-GCM encryption key for RS256 signing key storage.
126    ///
127    /// Required for OIDC/standalone mode. When not set, all signing key
128    /// operations return `AuthError::SigningKeyNotConfigured`.
129    pub fn signing_key(mut self, key: [u8; 32]) -> Self {
130        self.signing_key = Some(key);
131        self
132    }
133
134    /// Set the base URL (issuer) for the OIDC provider.
135    ///
136    /// Required for standalone mode. Used as the `iss` claim in tokens
137    /// and for issuer validation on incoming access tokens.
138    /// When not set, OIDC operations return `AuthError::BaseUrlNotConfigured`.
139    pub fn base_url(mut self, url: impl Into<String>) -> Self {
140        self.base_url = Some(url.into());
141        self
142    }
143
144    /// Set the HMAC key for session-bound CSRF token derivation.
145    ///
146    /// Required for `csrf_middleware` in `crates/server`. If not set,
147    /// `csrf_middleware` returns 500. Use 32 random bytes distinct from
148    /// `mfa_key` and `signing_key`.
149    pub fn csrf_key(mut self, key: [u8; 32]) -> Self {
150        self.csrf_key = Some(key);
151        self
152    }
153
154    /// Construct the [`AllowThem`] handle.
155    ///
156    /// Connects to (or wraps) the database, runs migrations, and assembles
157    /// the session configuration from overrides plus defaults.
158    pub async fn build(self) -> Result<AllowThem, BuildError> {
159        let db = match self.pool_source {
160            PoolSource::Url(url) => Db::connect(&url).await?,
161            PoolSource::Pool(pool) => Db::new(pool).await?,
162        };
163
164        let defaults = SessionConfig::default();
165        let session_config = SessionConfig {
166            ttl: self.session_ttl.unwrap_or(defaults.ttl),
167            cookie_name: self.cookie_name.unwrap_or(defaults.cookie_name),
168            secure: self.cookie_secure.unwrap_or(defaults.secure),
169        };
170
171        Ok(AllowThem {
172            inner: Arc::new(Inner {
173                db,
174                session_config,
175                cookie_domain: self.cookie_domain,
176                mfa_key: self.mfa_key,
177                signing_key: self.signing_key,
178                csrf_key: self.csrf_key,
179                base_url: self.base_url,
180            }),
181        })
182    }
183}
184
185struct Inner {
186    db: Db,
187    session_config: SessionConfig,
188    cookie_domain: String,
189    mfa_key: Option<[u8; 32]>,
190    signing_key: Option<[u8; 32]>,
191    csrf_key: Option<[u8; 32]>,
192    base_url: Option<String>,
193}
194
195/// Configured allowthem handle.
196///
197/// Bundles a `Db`, `SessionConfig`, and cookie domain into a single value
198/// that is cheaply cloneable and safe to share across Axum handlers via
199/// `State<AllowThem>` or `Extension<AllowThem>`.
200#[derive(Clone)]
201pub struct AllowThem {
202    inner: Arc<Inner>,
203}
204
205impl AllowThem {
206    /// Access the underlying database handle.
207    ///
208    /// Escape hatch for callers who need direct `Db` access for operations
209    /// not yet wrapped by `AllowThem` methods (e.g., user CRUD, role management).
210    pub fn db(&self) -> &Db {
211        &self.inner.db
212    }
213
214    /// Access the session configuration.
215    pub fn session_config(&self) -> &SessionConfig {
216        &self.inner.session_config
217    }
218
219    /// Build a `Set-Cookie` header value for the given session token.
220    ///
221    /// Uses the stored `SessionConfig` and cookie domain. Delegates to
222    /// `sessions::session_cookie()`.
223    pub fn session_cookie(&self, token: &SessionToken) -> String {
224        sessions::session_cookie(token, &self.inner.session_config, &self.inner.cookie_domain)
225    }
226
227    /// Returns the MFA encryption key, or `Err(MfaNotConfigured)` if not set.
228    pub(crate) fn mfa_key(&self) -> Result<&[u8; 32], AuthError> {
229        self.inner
230            .mfa_key
231            .as_ref()
232            .ok_or(AuthError::MfaNotConfigured)
233    }
234
235    /// Returns the signing key encryption key, or `Err(SigningKeyNotConfigured)` if not set.
236    pub(crate) fn signing_key(&self) -> Result<&[u8; 32], AuthError> {
237        self.inner
238            .signing_key
239            .as_ref()
240            .ok_or(AuthError::SigningKeyNotConfigured)
241    }
242
243    /// Returns the base URL (issuer), or `Err(BaseUrlNotConfigured)` if not set.
244    pub fn base_url(&self) -> Result<&str, AuthError> {
245        self.inner
246            .base_url
247            .as_deref()
248            .ok_or(AuthError::BaseUrlNotConfigured)
249    }
250
251    pub fn csrf_key(&self) -> Result<&[u8; 32], AuthError> {
252        self.inner
253            .csrf_key
254            .as_ref()
255            .ok_or(AuthError::CsrfKeyNotConfigured)
256    }
257
258    /// Fetch the active signing key and decrypt its private key PEM.
259    ///
260    /// Combines the encryption key, active key lookup, and decryption into
261    /// a single call. Keeps the raw encryption key private to the core crate.
262    pub async fn get_decrypted_signing_key(
263        &self,
264    ) -> Result<(crate::signing_keys::SigningKey, String), AuthError> {
265        let enc_key = self.signing_key()?;
266        let key = self.db().get_active_signing_key().await?;
267        let pem = crate::signing_keys::decrypt_private_key(&key, enc_key)?;
268        Ok((key, pem))
269    }
270
271    /// Build a `Set-Cookie` header value that expires the session cookie.
272    ///
273    /// Returns `Max-Age=0` with the same cookie name, path, domain, and flags
274    /// used by `session_cookie()`. Pass this as the `Set-Cookie` header on a
275    /// logout response to clear the browser's stored session cookie.
276    pub fn clear_session_cookie(&self) -> String {
277        sessions::clear_session_cookie(&self.inner.session_config, &self.inner.cookie_domain)
278    }
279
280    /// Extract the session token from a `Cookie` header value.
281    ///
282    /// Uses the stored cookie name. Delegates to `sessions::parse_session_cookie()`.
283    pub fn parse_session_cookie(&self, cookie_header: &str) -> Option<SessionToken> {
284        sessions::parse_session_cookie(cookie_header, self.inner.session_config.cookie_name)
285    }
286
287    /// Authenticate with credentials and create a session.
288    ///
289    /// Returns `Err(AuthError::InvalidCredentials)` for any credential failure —
290    /// unknown identifier, wrong password, no local password hash (SSO-only
291    /// account), or inactive user — to prevent account enumeration.
292    ///
293    /// Records an `AuditEvent::Login` on success. IP and user-agent are not
294    /// available at this layer; callers who need them in the audit log should
295    /// use the low-level `Db` methods directly.
296    pub async fn login(&self, identifier: &str, password: &str) -> Result<LoginOutcome, AuthError> {
297        use chrono::Utc;
298
299        use crate::audit::AuditEvent;
300        use crate::password::verify_password;
301
302        let user = self
303            .db()
304            .find_for_login(identifier)
305            .await
306            .map_err(|e| match e {
307                AuthError::NotFound => AuthError::InvalidCredentials,
308                other => other,
309            })?;
310
311        if !user.is_active {
312            return Err(AuthError::InvalidCredentials);
313        }
314
315        let hash = user
316            .password_hash
317            .as_ref()
318            .ok_or(AuthError::InvalidCredentials)?;
319
320        if !verify_password(password, hash)? {
321            return Err(AuthError::InvalidCredentials);
322        }
323
324        let token = sessions::generate_token();
325        let token_hash = sessions::hash_token(&token);
326        let expires_at = Utc::now() + self.inner.session_config.ttl;
327        self.db()
328            .create_session(user.id, token_hash, None, None, expires_at)
329            .await?;
330
331        let _ = self
332            .db()
333            .log_audit(AuditEvent::Login, Some(&user.id), None, None, None, None)
334            .await;
335
336        let set_cookie = self.session_cookie(&token);
337        Ok(LoginOutcome {
338            user,
339            token,
340            set_cookie,
341        })
342    }
343
344    /// Create a session for an already-authenticated user.
345    ///
346    /// Does not verify credentials. Intended for use after OAuth, TOTP, or
347    /// other non-password authentication flows. The calling flow is responsible
348    /// for audit logging.
349    pub async fn create_session_cookie(
350        &self,
351        user_id: crate::types::UserId,
352    ) -> Result<LoginOutcome, AuthError> {
353        use chrono::Utc;
354
355        let user = self.db().get_user(user_id).await?;
356        let token = sessions::generate_token();
357        let token_hash = sessions::hash_token(&token);
358        let expires_at = Utc::now() + self.inner.session_config.ttl;
359        self.db()
360            .create_session(user_id, token_hash, None, None, expires_at)
361            .await?;
362
363        let set_cookie = self.session_cookie(&token);
364        Ok(LoginOutcome {
365            user,
366            token,
367            set_cookie,
368        })
369    }
370}
371
372#[cfg(test)]
373mod tests {
374    use sqlx::sqlite::SqliteConnectOptions;
375    use std::str::FromStr;
376
377    use super::*;
378    use crate::sessions::generate_token;
379    use crate::types::Email;
380
381    #[tokio::test]
382    async fn build_with_url_defaults() {
383        let ath = AllowThemBuilder::new("sqlite::memory:")
384            .build()
385            .await
386            .unwrap();
387
388        let config = ath.session_config();
389        assert_eq!(config.ttl, Duration::hours(24));
390        assert_eq!(config.cookie_name, "allowthem_session");
391        assert!(config.secure);
392
393        let token = generate_token();
394        let cookie = ath.session_cookie(&token);
395        assert!(!cookie.contains("; Domain="));
396    }
397
398    #[tokio::test]
399    async fn build_with_pool() {
400        let opts = SqliteConnectOptions::from_str("sqlite::memory:")
401            .unwrap()
402            .pragma("foreign_keys", "ON");
403        let pool = sqlx::SqlitePool::connect_with(opts).await.unwrap();
404
405        let ath = AllowThemBuilder::with_pool(pool).build().await.unwrap();
406
407        let email = Email::new("test@example.com".into()).unwrap();
408        let user = ath.db().create_user(email, "password123", None, None).await;
409        assert!(user.is_ok());
410    }
411
412    #[tokio::test]
413    async fn build_with_overrides() {
414        let ath = AllowThemBuilder::new("sqlite::memory:")
415            .session_ttl(Duration::hours(48))
416            .cookie_name("my_session")
417            .cookie_secure(false)
418            .cookie_domain("example.com")
419            .build()
420            .await
421            .unwrap();
422
423        let config = ath.session_config();
424        assert_eq!(config.ttl, Duration::hours(48));
425        assert_eq!(config.cookie_name, "my_session");
426        assert!(!config.secure);
427    }
428
429    #[tokio::test]
430    async fn session_cookie_uses_config() {
431        let ath = AllowThemBuilder::new("sqlite::memory:")
432            .cookie_name("custom")
433            .cookie_secure(false)
434            .cookie_domain("example.com")
435            .build()
436            .await
437            .unwrap();
438
439        let token = generate_token();
440        let cookie = ath.session_cookie(&token);
441
442        assert!(cookie.contains("custom="));
443        assert!(cookie.contains("; Domain=example.com"));
444        assert!(!cookie.contains("; Secure"));
445    }
446
447    #[tokio::test]
448    async fn clear_session_cookie_defaults() {
449        let ath = AllowThemBuilder::new("sqlite::memory:")
450            .build()
451            .await
452            .unwrap();
453
454        let cookie = ath.clear_session_cookie();
455        assert!(cookie.starts_with("allowthem_session=;"));
456        assert!(cookie.contains("; Max-Age=0"));
457        assert!(!cookie.contains("; Domain="));
458        assert!(cookie.contains("; Secure"));
459    }
460
461    #[tokio::test]
462    async fn clear_session_cookie_name_matches_session_cookie() {
463        let ath = AllowThemBuilder::new("sqlite::memory:")
464            .cookie_name("app_session")
465            .build()
466            .await
467            .unwrap();
468
469        let token = generate_token();
470        let set = ath.session_cookie(&token);
471        let clear = ath.clear_session_cookie();
472
473        // Both must share the same cookie name prefix so the browser matches them.
474        assert!(set.starts_with("app_session="));
475        assert!(clear.starts_with("app_session=;"));
476        assert!(clear.contains("; Path=/"));
477        assert!(clear.contains("; Max-Age=0"));
478    }
479
480    #[tokio::test]
481    async fn clear_session_cookie_with_domain_and_no_secure() {
482        let ath = AllowThemBuilder::new("sqlite::memory:")
483            .cookie_name("my_session")
484            .cookie_secure(false)
485            .cookie_domain("example.com")
486            .build()
487            .await
488            .unwrap();
489
490        let cookie = ath.clear_session_cookie();
491        assert!(cookie.starts_with("my_session=;"));
492        assert!(cookie.contains("; Max-Age=0"));
493        assert!(cookie.contains("; Domain=example.com"));
494        assert!(!cookie.contains("; Secure"));
495    }
496
497    #[tokio::test]
498    async fn parse_session_cookie_uses_config() {
499        let ath = AllowThemBuilder::new("sqlite::memory:")
500            .cookie_name("custom")
501            .build()
502            .await
503            .unwrap();
504
505        let header = "custom=abc123; other=xyz";
506        let result = ath.parse_session_cookie(header);
507
508        assert!(result.is_some());
509        assert_eq!(result.unwrap().as_str(), "abc123");
510    }
511
512    #[tokio::test]
513    async fn build_with_bad_url_fails() {
514        let result = AllowThemBuilder::new("not-a-url").build().await;
515
516        assert!(result.is_err());
517        assert!(matches!(result.err().unwrap(), BuildError::Database(_)));
518    }
519
520    #[tokio::test]
521    async fn clone_shares_state() {
522        let ath = AllowThemBuilder::new("sqlite::memory:")
523            .build()
524            .await
525            .unwrap();
526        let ath2 = ath.clone();
527
528        let email = Email::new("shared@example.com".into()).unwrap();
529        let user = ath
530            .db()
531            .create_user(email, "password123", None, None)
532            .await
533            .unwrap();
534
535        let found = ath2.db().get_user(user.id).await;
536        assert!(found.is_ok());
537        assert_eq!(found.unwrap().id, user.id);
538    }
539
540    #[tokio::test]
541    async fn signing_key_not_configured_returns_error() {
542        let ath = AllowThemBuilder::new("sqlite::memory:")
543            .build()
544            .await
545            .unwrap();
546        let result = ath.signing_key();
547        assert!(matches!(
548            result,
549            Err(crate::error::AuthError::SigningKeyNotConfigured)
550        ));
551    }
552
553    #[tokio::test]
554    async fn base_url_not_configured_returns_error() {
555        let ath = AllowThemBuilder::new("sqlite::memory:")
556            .build()
557            .await
558            .unwrap();
559        let result = ath.base_url();
560        assert!(matches!(
561            result,
562            Err(crate::error::AuthError::BaseUrlNotConfigured)
563        ));
564    }
565
566    #[tokio::test]
567    async fn base_url_configured_returns_value() {
568        let ath = AllowThemBuilder::new("sqlite::memory:")
569            .base_url("https://auth.example.com")
570            .build()
571            .await
572            .unwrap();
573        let result = ath.base_url();
574        assert!(matches!(result, Ok("https://auth.example.com")));
575    }
576
577    #[tokio::test]
578    async fn login_success() {
579        let ath = AllowThemBuilder::new("sqlite::memory:")
580            .cookie_secure(false)
581            .build()
582            .await
583            .unwrap();
584
585        let email = Email::new("login@example.com".into()).unwrap();
586        ath.db()
587            .create_user(email, "secret", None, None)
588            .await
589            .unwrap();
590
591        let outcome = ath.login("login@example.com", "secret").await.unwrap();
592        assert_eq!(outcome.user.email.as_str(), "login@example.com");
593        assert!(!outcome.token.as_str().is_empty());
594        assert!(outcome.set_cookie.contains("allowthem_session="));
595    }
596
597    #[tokio::test]
598    async fn login_wrong_password() {
599        let ath = AllowThemBuilder::new("sqlite::memory:")
600            .build()
601            .await
602            .unwrap();
603
604        let email = Email::new("wp@example.com".into()).unwrap();
605        ath.db()
606            .create_user(email, "correct", None, None)
607            .await
608            .unwrap();
609
610        let result = ath.login("wp@example.com", "wrong").await;
611        assert!(matches!(result, Err(AuthError::InvalidCredentials)));
612    }
613
614    #[tokio::test]
615    async fn login_unknown_identifier() {
616        let ath = AllowThemBuilder::new("sqlite::memory:")
617            .build()
618            .await
619            .unwrap();
620
621        let result = ath.login("nobody@example.com", "any").await;
622        assert!(matches!(result, Err(AuthError::InvalidCredentials)));
623    }
624
625    #[tokio::test]
626    async fn login_inactive_user() {
627        let ath = AllowThemBuilder::new("sqlite::memory:")
628            .build()
629            .await
630            .unwrap();
631
632        let email = Email::new("inactive@example.com".into()).unwrap();
633        let user = ath
634            .db()
635            .create_user(email, "secret", None, None)
636            .await
637            .unwrap();
638        ath.db().update_user_active(user.id, false).await.unwrap();
639
640        let result = ath.login("inactive@example.com", "secret").await;
641        assert!(matches!(result, Err(AuthError::InvalidCredentials)));
642    }
643
644    #[tokio::test]
645    async fn login_no_password_hash() {
646        use crate::types::UserId;
647
648        let ath = AllowThemBuilder::new("sqlite::memory:")
649            .build()
650            .await
651            .unwrap();
652
653        // Insert a user directly with password_hash = NULL (SSO-only account).
654        // UserId uses UUID v7; bind it properly so SQLx can round-trip it.
655        let id = UserId::new();
656        let now = chrono::Utc::now()
657            .format("%Y-%m-%dT%H:%M:%S%.3fZ")
658            .to_string();
659        sqlx::query(
660            "INSERT INTO allowthem_users \
661             (id, email, username, password_hash, email_verified, is_active, created_at, updated_at) \
662             VALUES (?, 'sso@example.com', NULL, NULL, 1, 1, ?, ?)",
663        )
664        .bind(id)
665        .bind(&now)
666        .bind(&now)
667        .execute(ath.db().pool())
668        .await
669        .unwrap();
670
671        let result = ath.login("sso@example.com", "any").await;
672        assert!(matches!(result, Err(AuthError::InvalidCredentials)));
673    }
674
675    #[tokio::test]
676    async fn create_session_cookie_success() {
677        let ath = AllowThemBuilder::new("sqlite::memory:")
678            .cookie_secure(false)
679            .build()
680            .await
681            .unwrap();
682
683        let email = Email::new("sess@example.com".into()).unwrap();
684        let user = ath
685            .db()
686            .create_user(email, "secret", None, None)
687            .await
688            .unwrap();
689
690        let outcome = ath.create_session_cookie(user.id).await.unwrap();
691        assert_eq!(outcome.user.id, user.id);
692        assert!(!outcome.token.as_str().is_empty());
693        assert!(outcome.set_cookie.contains("allowthem_session="));
694
695        // Session must exist in DB
696        let session = ath.db().lookup_session(&outcome.token).await.unwrap();
697        assert!(session.is_some());
698    }
699
700    #[tokio::test]
701    async fn create_session_cookie_unknown_user() {
702        use crate::types::UserId;
703
704        let ath = AllowThemBuilder::new("sqlite::memory:")
705            .build()
706            .await
707            .unwrap();
708
709        let result = ath.create_session_cookie(UserId::new()).await;
710        assert!(matches!(result, Err(AuthError::NotFound)));
711    }
712}