rama_net/user/credentials/
basic.rs

1use base64::Engine;
2use base64::engine::general_purpose::STANDARD as ENGINE;
3use rama_core::error::{ErrorContext, OpaqueError};
4use std::borrow::Cow;
5
6#[cfg(feature = "http")]
7use rama_http_types::HeaderValue;
8
9#[derive(Debug, Clone)]
10/// Basic credentials.
11pub struct Basic {
12    data: BasicData,
13}
14
15#[derive(Debug, Clone)]
16enum BasicData {
17    Username(Cow<'static, str>),
18    Pair {
19        username: Cow<'static, str>,
20        password: Cow<'static, str>,
21    },
22    Decoded {
23        decoded: String,
24        colon_pos: usize,
25    },
26}
27
28impl Basic {
29    /// Creates a new [`Basic`] credential.
30    pub fn new(
31        username: impl Into<Cow<'static, str>>,
32        password: impl Into<Cow<'static, str>>,
33    ) -> Self {
34        let data = BasicData::Pair {
35            username: username.into(),
36            password: password.into(),
37        };
38        Basic { data }
39    }
40
41    /// Try to create a [`Basic`] credential from a header string,
42    /// encoded as 'Basic <base64(username:{password}?)>'.
43    pub fn try_from_header_str(s: impl AsRef<str>) -> Result<Self, OpaqueError> {
44        let value = s.as_ref();
45
46        if value.len() <= BASIC_SCHEME.len() + 1 {
47            return Err(OpaqueError::from_display(
48                "invalid scheme length in basic str",
49            ));
50        }
51        if !value.as_bytes()[..BASIC_SCHEME.len()].eq_ignore_ascii_case(BASIC_SCHEME.as_bytes()) {
52            return Err(OpaqueError::from_display("invalid scheme in basic str"));
53        }
54
55        let bytes = &value.as_bytes()[BASIC_SCHEME.len() + 1..];
56        let non_space_pos = bytes
57            .iter()
58            .position(|b| *b != b' ')
59            .ok_or_else(|| OpaqueError::from_display("missing space separator in basic str"))?;
60        let bytes = &bytes[non_space_pos..];
61
62        let bytes = ENGINE
63            .decode(bytes)
64            .context("failed to decode base64 basic str")?;
65
66        let decoded = String::from_utf8(bytes).context("base64 decoded basic str is not utf-8")?;
67
68        Self::try_from_clear_str(decoded)
69    }
70
71    /// Try to create a [`Basic`] credential from a clear string,
72    /// encoded as 'username:{password}?'.
73    pub fn try_from_clear_str(s: String) -> Result<Self, OpaqueError> {
74        let colon_pos = s
75            .find(':')
76            .ok_or_else(|| OpaqueError::from_display("missing colon separator in clear str"))?;
77        if colon_pos == 0 {
78            return Err(OpaqueError::from_display(
79                "missing username in basic credential",
80            ));
81        }
82        let data = BasicData::Decoded {
83            decoded: s,
84            colon_pos,
85        };
86        Ok(Basic { data })
87    }
88
89    /// Serialize this [`Basic`] credential as a header string.
90    pub fn as_header_string(&self) -> String {
91        let mut encoded = format!("{BASIC_SCHEME} ");
92
93        match &self.data {
94            BasicData::Username(username) => {
95                let decoded = format!("{username}:");
96                ENGINE.encode_string(&decoded, &mut encoded);
97            }
98            BasicData::Pair { username, password } => {
99                let decoded = format!("{username}:{password}");
100                ENGINE.encode_string(&decoded, &mut encoded);
101            }
102            BasicData::Decoded { decoded, .. } => {
103                ENGINE.encode_string(decoded, &mut encoded);
104            }
105        }
106
107        encoded
108    }
109
110    #[cfg(feature = "http")]
111    /// View this [`Basic`] as a [`HeaderValue`]
112    pub fn as_header_value(&self) -> HeaderValue {
113        let encoded = self.as_header_string();
114        // we validate the inner value upon creation
115        HeaderValue::from_str(&encoded).expect("inner value should always be valid")
116    }
117
118    /// Serialize this [`Basic`] credential as a clear (not encoded) string.
119    pub fn as_clear_string(&self) -> String {
120        match &self.data {
121            BasicData::Username(username) => {
122                format!("{username}:")
123            }
124            BasicData::Pair { username, password } => {
125                format!("{username}:{password}")
126            }
127            BasicData::Decoded { decoded, .. } => decoded.clone(),
128        }
129    }
130
131    /// Creates a new [`Basic`] credential with only a username.
132    pub fn unprotected(username: impl Into<Cow<'static, str>>) -> Self {
133        let data: BasicData = BasicData::Username(username.into());
134        Basic { data }
135    }
136
137    /// View the decoded username.
138    pub fn username(&self) -> &str {
139        match &self.data {
140            BasicData::Username(username) => username,
141            BasicData::Pair { username, .. } => username,
142            BasicData::Decoded { decoded, colon_pos } => &decoded[..*colon_pos],
143        }
144    }
145
146    /// View the decoded password.
147    pub fn password(&self) -> &str {
148        match &self.data {
149            BasicData::Username(_) => "",
150            BasicData::Pair { password, .. } => password,
151            BasicData::Decoded { decoded, colon_pos } => &decoded[*colon_pos + 1..],
152        }
153    }
154}
155
156impl PartialEq<Basic> for Basic {
157    fn eq(&self, other: &Basic) -> bool {
158        self.username() == other.username() && self.password() == other.password()
159    }
160}
161
162impl Eq for Basic {}
163
164/// Http Credentail scheme for basic credentails
165pub const BASIC_SCHEME: &str = "Basic";
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    #[test]
172    fn basic_parse_empty() {
173        let value = Basic::try_from_header_str("");
174        assert!(value.is_err());
175    }
176
177    #[test]
178    fn basic_clear_text_empty() {
179        let value = Basic::try_from_clear_str("".to_owned());
180        assert!(value.is_err());
181    }
182
183    #[test]
184    fn basic_missing_username() {
185        let value = Basic::try_from_clear_str(":".to_owned());
186        assert!(value.is_err());
187    }
188
189    #[test]
190    fn basic_header() {
191        let auth = Basic::try_from_header_str("Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==").unwrap();
192        assert_eq!(auth.username(), "Aladdin");
193        assert_eq!(auth.password(), "open sesame");
194        assert_eq!(
195            "Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==",
196            auth.as_header_string()
197        );
198    }
199
200    #[test]
201    fn basic_header_no_password() {
202        let auth = Basic::try_from_header_str("Basic QWxhZGRpbjo=").unwrap();
203        assert_eq!(auth.username(), "Aladdin");
204        assert_eq!(auth.password(), "");
205        assert_eq!("Basic QWxhZGRpbjo=", auth.as_header_string());
206    }
207
208    #[test]
209    fn basic_clear() {
210        let auth = Basic::try_from_clear_str("Aladdin:open sesame".to_owned()).unwrap();
211        assert_eq!(auth.username(), "Aladdin");
212        assert_eq!(auth.password(), "open sesame");
213        assert_eq!("Aladdin:open sesame", auth.as_clear_string());
214    }
215
216    #[test]
217    fn basic_clear_no_password() {
218        let auth = Basic::try_from_clear_str("Aladdin:".to_owned()).unwrap();
219        assert_eq!(auth.username(), "Aladdin");
220        assert_eq!(auth.password(), "");
221        assert_eq!("Aladdin:", auth.as_clear_string());
222    }
223}