Skip to main content

soaprs_auth/
identifier.rs

1//! Validated auth identities and logical names.
2
3use std::fmt;
4
5use soaprs_core::{SoapError, SoapResult};
6
7macro_rules! identifier {
8    ($name:ident, $description:literal, $validator:ident) => {
9        #[doc = $description]
10        #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
11        pub struct $name(String);
12
13        impl $name {
14            /// Validates and wraps an identifier.
15            pub fn new(value: impl Into<String>) -> SoapResult<Self> {
16                let value = value.into();
17                $validator(stringify!($name), &value)?;
18                Ok(Self(value))
19            }
20
21            /// Returns the identifier as text.
22            pub fn as_str(&self) -> &str {
23                &self.0
24            }
25        }
26
27        impl fmt::Display for $name {
28            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
29                formatter.write_str(&self.0)
30            }
31        }
32
33        impl TryFrom<String> for $name {
34            type Error = SoapError;
35
36            fn try_from(value: String) -> Result<Self, Self::Error> {
37                Self::new(value)
38            }
39        }
40
41        impl TryFrom<&str> for $name {
42            type Error = SoapError;
43
44            fn try_from(value: &str) -> Result<Self, Self::Error> {
45                Self::new(value)
46            }
47        }
48    };
49}
50
51identifier!(
52    AuthorizationName,
53    "Stable authentication strategy, authorization policy, role, or permission name.",
54    validate_logical_name
55);
56identifier!(
57    PrincipalId,
58    "Opaque authenticated principal identity.",
59    validate_opaque_id
60);
61identifier!(
62    SessionId,
63    "Opaque session identity safe to transport as a cookie value.",
64    validate_session_id
65);
66
67fn validate_logical_name(kind: &str, value: &str) -> SoapResult<()> {
68    if value.is_empty()
69        || !value.chars().all(|character| {
70            character == '.'
71                || character == '_'
72                || character == '-'
73                || character == ':'
74                || character.is_ascii_alphanumeric()
75        })
76    {
77        return Err(SoapError::validation(format!("invalid {kind} `{value}`")));
78    }
79    Ok(())
80}
81
82fn validate_opaque_id(kind: &str, value: &str) -> SoapResult<()> {
83    if value.is_empty()
84        || value.trim() != value
85        || value.len() > 1024
86        || value.chars().any(char::is_control)
87    {
88        return Err(SoapError::validation(format!("invalid {kind}")));
89    }
90    Ok(())
91}
92
93fn validate_session_id(kind: &str, value: &str) -> SoapResult<()> {
94    if value.is_empty()
95        || value.len() > 1024
96        || !value
97            .bytes()
98            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~'))
99    {
100        return Err(SoapError::validation(format!("invalid {kind}")));
101    }
102    Ok(())
103}
104
105#[cfg(test)]
106mod tests {
107    use super::{AuthorizationName, PrincipalId, SessionId};
108
109    #[test]
110    fn identifiers_reject_transport_and_control_fragments() {
111        assert!(AuthorizationName::new("jwt.access").is_ok());
112        assert!(AuthorizationName::new("bad policy").is_err());
113        assert!(PrincipalId::new("urn:user:42").is_ok());
114        assert!(PrincipalId::new("user\n42").is_err());
115        assert!(SessionId::new("session_42-token").is_ok());
116        assert!(SessionId::new("session=42").is_err());
117    }
118}