watchword 0.1.0

Authentication for axum servers and Yew frontends: password, Cloudflare Access and Google sign-in, sessions and user management.
Documentation
//! Cloudflare Access sign-in on [`Auth`] (feature `cloudflare`).
//!
//! Access vouches for an email, which finds an existing user; users are never
//! created. Signing in mints an ordinary session, so Access is consulted only
//! when signing in. A pending user who signs in becomes active, and a revoked
//! user is refused.

use http::HeaderMap;

use super::{Auth, AuthBuilder, AuthEvent, Result};
use crate::store::{Session, User};
use crate::types::{Access, AuthError, CloudflareConfig};

impl AuthBuilder {
    /// The Cloudflare Access settings to start with, which are logged when
    /// the service is built. `None` and settings that are not `enabled` both
    /// turn Access sign-in off. Change them later with
    /// [`Auth::set_cloudflare`]. The default is `None`.
    #[cfg_attr(watchword_docsrs, doc(cfg(feature = "cloudflare")))]
    pub fn cloudflare(mut self, config: Option<CloudflareConfig>) -> Self {
        self.cloudflare = config;
        self
    }
}

impl Auth {
    /// Applies changed Cloudflare Access settings, such as when an admin
    /// saves them in the app's settings page. `None` and settings that are
    /// not `enabled` both turn Access sign-in off. Settings that differ from
    /// the current ones are logged, with a warning when they are unsafe or
    /// let no one sign in; the same settings again change nothing.
    #[cfg_attr(watchword_docsrs, doc(cfg(feature = "cloudflare")))]
    pub fn set_cloudflare(&self, config: Option<CloudflareConfig>) {
        self.inner.cloudflare.set(config);
    }

    /// Whether the request carries Cloudflare Access credentials that sign in
    /// an existing, not revoked user, as
    /// [`sign_in_cloudflare`](Auth::sign_in_cloudflare) would. This is
    /// [`AuthConfig::cloudflare`](crate::AuthConfig::cloudflare).
    ///
    /// When the request carries Access credentials that do not, the reason
    /// is logged as a warning, at most once a minute.
    #[cfg_attr(watchword_docsrs, doc(cfg(feature = "cloudflare")))]
    pub async fn cloudflare_available(&self, headers: &HeaderMap) -> Result<bool> {
        Ok(self.cloudflare_user(headers).await?.is_some())
    }

    /// Signs in the user whose email the request's Cloudflare Access
    /// credentials vouch for, whatever their [`Method`](crate::Method).
    ///
    /// A pending user becomes active, because Access has vouched for their
    /// email. Anything else that is not an active user, including a revoked
    /// user, an unknown email and Access being disabled, is
    /// [`AuthError::Unauthorized`], with the reason logged as for
    /// [`cloudflare_available`](Auth::cloudflare_available).
    #[cfg_attr(watchword_docsrs, doc(cfg(feature = "cloudflare")))]
    pub async fn sign_in_cloudflare(&self, headers: &HeaderMap) -> Result<(User, Session)> {
        let mut user = self
            .cloudflare_user(headers)
            .await?
            .ok_or(AuthError::Unauthorized)?;

        if user.access == Access::Pending {
            // Only a user who is still pending is activated, so a revocation
            // since the read above is not undone.
            let activated = self.store().activate(user.id).await?;

            user = self
                .store()
                .user(user.id)
                .await?
                .ok_or(AuthError::Unauthorized)?;

            if activated {
                tracing::info!(user = %user.id, "Cloudflare Access activated a pending user");
                self.emit(AuthEvent::UserChanged(user.info()));
            }
        }

        if user.access != Access::Active {
            return Err(AuthError::Unauthorized.into());
        }

        let session = self.create_session(user.id).await?;
        Ok((user, session))
    }

    /// The user the request's Access credentials sign in: an existing user
    /// with the vouched-for email who is not revoked.
    async fn cloudflare_user(&self, headers: &HeaderMap) -> Result<Option<User>> {
        let provider = &self.inner.cloudflare;

        let Some(email) = provider.email(headers).await else {
            return Ok(None);
        };

        let Some(user) = self.store().user_by_email(&email).await? else {
            provider.warn(format!(
                "Cloudflare Access sign-in failed: no user has the email {email}"
            ));
            return Ok(None);
        };

        if user.access == Access::Revoked {
            provider.warn(format!(
                "Cloudflare Access sign-in failed: the user with the email {email} has had their access revoked"
            ));
            return Ok(None);
        }

        Ok(Some(user))
    }

    /// The Cloudflare Access warnings logged within the last minute.
    #[cfg(test)]
    pub(crate) fn cloudflare_warnings(&self) -> Vec<String> {
        self.inner.cloudflare.warnings()
    }
}

#[cfg(test)]
mod tests;