soaprs-auth 0.5.0

Protocol-neutral authentication and authorization contracts for soaprs
Documentation
//! Type-safe routing across authentication strategies with one principal type.

use std::collections::BTreeMap;

use soaprs_core::{BoxFuture, SoapError, SoapResult};

use crate::{Authentication, Authenticator, AuthorizationName, Credential};

/// Named credential authenticator registered at the application composition root.
pub trait AuthenticationStrategy<P>: Authenticator<Credential, P>
where
    P: Send,
{
    /// Returns the stable strategy identity.
    fn strategy(&self) -> &AuthorizationName;
}

/// Deterministic strategy router without runtime output downcasting.
pub struct AuthenticationRegistry<P> {
    strategies: BTreeMap<AuthorizationName, Box<dyn AuthenticationStrategy<P>>>,
}

impl<P> AuthenticationRegistry<P>
where
    P: Send,
{
    /// Creates an empty strategy registry.
    pub fn new() -> Self {
        Self {
            strategies: BTreeMap::new(),
        }
    }

    /// Registers one uniquely named strategy.
    pub fn register<S>(&mut self, strategy: S) -> SoapResult<()>
    where
        S: AuthenticationStrategy<P> + 'static,
    {
        let name = strategy.strategy().clone();
        if self.strategies.contains_key(&name) {
            return Err(SoapError::conflict(format!(
                "authentication strategy `{name}` is already registered"
            )));
        }
        self.strategies.insert(name, Box::new(strategy));
        Ok(())
    }

    /// Returns the number of registered strategies.
    pub fn len(&self) -> usize {
        self.strategies.len()
    }

    /// Reports whether no strategies are registered.
    pub fn is_empty(&self) -> bool {
        self.strategies.is_empty()
    }
}

impl<P> Default for AuthenticationRegistry<P>
where
    P: Send,
{
    fn default() -> Self {
        Self::new()
    }
}

impl<P> Authenticator<Credential, P> for AuthenticationRegistry<P>
where
    P: Send,
{
    fn authenticate(&self, credential: Credential) -> BoxFuture<'_, SoapResult<Authentication<P>>> {
        Box::pin(async move {
            let requested = credential.strategy().clone();
            let Some(strategy) = self.strategies.get(&requested) else {
                return Err(SoapError::unsupported(format!(
                    "authentication strategy `{requested}` is not registered"
                )));
            };
            let authentication = strategy.authenticate(credential).await?;
            if authentication.strategy() != &requested {
                return Err(SoapError::infrastructure(
                    "authentication strategy returned a mismatched strategy identity",
                ));
            }
            Ok(authentication)
        })
    }
}

#[cfg(test)]
mod tests {
    use std::{
        future::Future,
        task::{Context, Poll, Waker},
    };

    use soaprs_core::{BoxFuture, SoapError, SoapErrorKind, SoapResult};

    use super::{AuthenticationRegistry, AuthenticationStrategy};
    use crate::{Authentication, Authenticator, AuthorizationName, Credential, StandardPrincipal};

    struct FixedStrategy {
        name: AuthorizationName,
    }

    impl Authenticator<Credential, StandardPrincipal> for FixedStrategy {
        fn authenticate(
            &self,
            credential: Credential,
        ) -> BoxFuture<'_, SoapResult<Authentication<StandardPrincipal>>> {
            Box::pin(async move {
                if credential.secret().expose_secret() != "valid" {
                    return Err(SoapError::unauthorized());
                }
                Authentication::new("jwt", StandardPrincipal::new("user-1")?)
            })
        }
    }

    impl AuthenticationStrategy<StandardPrincipal> for FixedStrategy {
        fn strategy(&self) -> &AuthorizationName {
            &self.name
        }
    }

    #[test]
    fn registry_rejects_duplicates_and_routes_without_type_erasure() {
        let Some(name) = AuthorizationName::new("jwt").ok() else {
            panic!("valid strategy name");
        };
        let mut registry = AuthenticationRegistry::new();
        assert!(
            registry
                .register(FixedStrategy { name: name.clone() })
                .is_ok()
        );
        assert_eq!(registry.len(), 1);
        assert!(registry.register(FixedStrategy { name }).is_err());

        let valid = Credential::bearer("jwt", "valid")
            .unwrap_or_else(|error| panic!("valid credential: {error}"));
        let result = block_on(registry.authenticate(valid));
        assert!(result.is_ok());

        let unknown = Credential::bearer("unknown", "valid")
            .unwrap_or_else(|error| panic!("valid credential: {error}"));
        let result = block_on(registry.authenticate(unknown));
        assert_eq!(
            result.as_ref().map_err(|error| error.kind()),
            Err(SoapErrorKind::Unsupported)
        );
    }

    fn block_on<F>(future: F) -> F::Output
    where
        F: Future,
    {
        let mut context = Context::from_waker(Waker::noop());
        let mut future = std::pin::pin!(future);
        loop {
            match future.as_mut().poll(&mut context) {
                Poll::Ready(output) => return output,
                Poll::Pending => std::thread::yield_now(),
            }
        }
    }
}