soaprs-auth 0.4.0

Protocol-neutral authentication and authorization contracts for soaprs
Documentation
//! Validated auth identities and logical names.

use std::fmt;

use soaprs_core::{SoapError, SoapResult};

macro_rules! identifier {
    ($name:ident, $description:literal, $validator:ident) => {
        #[doc = $description]
        #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
        pub struct $name(String);

        impl $name {
            /// Validates and wraps an identifier.
            pub fn new(value: impl Into<String>) -> SoapResult<Self> {
                let value = value.into();
                $validator(stringify!($name), &value)?;
                Ok(Self(value))
            }

            /// Returns the identifier as text.
            pub fn as_str(&self) -> &str {
                &self.0
            }
        }

        impl fmt::Display for $name {
            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
                formatter.write_str(&self.0)
            }
        }

        impl TryFrom<String> for $name {
            type Error = SoapError;

            fn try_from(value: String) -> Result<Self, Self::Error> {
                Self::new(value)
            }
        }

        impl TryFrom<&str> for $name {
            type Error = SoapError;

            fn try_from(value: &str) -> Result<Self, Self::Error> {
                Self::new(value)
            }
        }
    };
}

identifier!(
    AuthorizationName,
    "Stable authentication strategy, authorization policy, role, or permission name.",
    validate_logical_name
);
identifier!(
    PrincipalId,
    "Opaque authenticated principal identity.",
    validate_opaque_id
);
identifier!(
    SessionId,
    "Opaque session identity safe to transport as a cookie value.",
    validate_session_id
);

fn validate_logical_name(kind: &str, value: &str) -> SoapResult<()> {
    if value.is_empty()
        || !value.chars().all(|character| {
            character == '.'
                || character == '_'
                || character == '-'
                || character == ':'
                || character.is_ascii_alphanumeric()
        })
    {
        return Err(SoapError::validation(format!("invalid {kind} `{value}`")));
    }
    Ok(())
}

fn validate_opaque_id(kind: &str, value: &str) -> SoapResult<()> {
    if value.is_empty()
        || value.trim() != value
        || value.len() > 1024
        || value.chars().any(char::is_control)
    {
        return Err(SoapError::validation(format!("invalid {kind}")));
    }
    Ok(())
}

fn validate_session_id(kind: &str, value: &str) -> SoapResult<()> {
    if value.is_empty()
        || value.len() > 1024
        || !value
            .bytes()
            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~'))
    {
        return Err(SoapError::validation(format!("invalid {kind}")));
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::{AuthorizationName, PrincipalId, SessionId};

    #[test]
    fn identifiers_reject_transport_and_control_fragments() {
        assert!(AuthorizationName::new("jwt.access").is_ok());
        assert!(AuthorizationName::new("bad policy").is_err());
        assert!(PrincipalId::new("urn:user:42").is_ok());
        assert!(PrincipalId::new("user\n42").is_err());
        assert!(SessionId::new("session_42-token").is_ok());
        assert!(SessionId::new("session=42").is_err());
    }
}