Skip to main content

io_http/rfc7617/
basic.rs

1//! HTTP Basic authentication scheme: credentials are sent as a
2//! base64-encoded `username:password` pair in the `Authorization`
3//! request header ([RFC 7617 §2]).
4//!
5//! # Example
6//!
7//! ```rust
8//! use io_http::rfc7617::basic::BasicCredentials;
9//! use secrecy::ExposeSecret;
10//!
11//! let creds = BasicCredentials::new("Aladdin", "open sesame");
12//! assert_eq!(creds.to_authorization(), "Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==");
13//!
14//! let parsed = BasicCredentials::from_authorization("Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==").unwrap();
15//! assert_eq!(parsed.username, "Aladdin");
16//! assert_eq!(parsed.password.expose_secret(), "open sesame");
17//! ```
18//!
19//! [RFC 7617 §2]: https://www.rfc-editor.org/rfc/rfc7617#section-2
20
21use core::{fmt, str::from_utf8};
22
23use alloc::{
24    format,
25    string::{String, ToString},
26};
27
28use base64::{DecodeError, prelude::BASE64_STANDARD, prelude::Engine as _};
29use secrecy::{ExposeSecret, SecretString};
30use thiserror::Error;
31
32/// Failure causes when parsing a `Basic` authorization value.
33#[derive(Debug, Error)]
34pub enum BasicError {
35    #[error("Missing `Basic ` prefix in Authorization value")]
36    MissingPrefix,
37    #[error("Invalid base64 in Authorization value: {0}")]
38    InvalidBase64(DecodeError),
39    #[error("Decoded credentials are not valid UTF-8")]
40    InvalidUtf8,
41    #[error("Decoded credentials are missing the `:` separator")]
42    MissingColon,
43}
44
45/// HTTP `Basic` credential pair; `password` is redacted in
46/// [`fmt::Debug`] and zeroed on drop.
47#[derive(Clone)]
48pub struct BasicCredentials {
49    pub username: String,
50    pub password: SecretString,
51}
52
53impl BasicCredentials {
54    /// Wraps a username + password.
55    pub fn new(username: impl Into<String>, password: impl Into<String>) -> Self {
56        Self {
57            username: username.into(),
58            password: SecretString::from(password.into()),
59        }
60    }
61
62    /// Returns the `Basic <base64(user:pass)>` header value.
63    pub fn to_authorization(&self) -> String {
64        let payload = format!("{}:{}", self.username, self.password.expose_secret());
65        let encoded = BASE64_STANDARD.encode(payload.as_bytes());
66        format!("Basic {encoded}")
67    }
68
69    /// Parses a `Basic <b64>` header value.
70    pub fn from_authorization(value: &str) -> Result<Self, BasicError> {
71        let encoded = value
72            .strip_prefix("Basic ")
73            .ok_or(BasicError::MissingPrefix)?;
74
75        let decoded = BASE64_STANDARD
76            .decode(encoded)
77            .map_err(BasicError::InvalidBase64)?;
78
79        let s = from_utf8(&decoded).map_err(|_| BasicError::InvalidUtf8)?;
80        let (username, password) = s.split_once(':').ok_or(BasicError::MissingColon)?;
81
82        Ok(Self {
83            username: username.into(),
84            password: SecretString::from(password.to_string()),
85        })
86    }
87}
88
89impl fmt::Debug for BasicCredentials {
90    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
91        f.debug_struct("BasicCredentials")
92            .field("username", &self.username)
93            .field("password", &"[REDACTED]")
94            .finish()
95    }
96}
97
98impl PartialEq for BasicCredentials {
99    fn eq(&self, other: &Self) -> bool {
100        self.username == other.username
101            && self.password.expose_secret() == other.password.expose_secret()
102    }
103}
104
105impl Eq for BasicCredentials {}
106
107#[cfg(test)]
108mod tests {
109    use alloc::format;
110
111    use secrecy::ExposeSecret;
112
113    use crate::rfc7617::basic::*;
114
115    #[test]
116    fn to_authorization_rfc_test_vector() {
117        let creds = BasicCredentials::new("Aladdin", "open sesame");
118        assert_eq!(
119            creds.to_authorization(),
120            "Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ=="
121        );
122    }
123
124    #[test]
125    fn to_authorization_has_basic_prefix() {
126        let creds = BasicCredentials::new("user", "pass");
127        assert!(creds.to_authorization().starts_with("Basic "));
128    }
129
130    #[test]
131    fn to_authorization_empty_password() {
132        let creds = BasicCredentials::new("user", "");
133        let value = creds.to_authorization();
134        let decoded = BasicCredentials::from_authorization(&value).unwrap();
135        assert_eq!(decoded.username, "user");
136        assert_eq!(decoded.password.expose_secret(), "");
137    }
138
139    #[test]
140    fn from_authorization_roundtrip() {
141        let original = BasicCredentials::new("user@example.com", "p@$$w0rd!");
142        let header = original.to_authorization();
143        let parsed = BasicCredentials::from_authorization(&header).unwrap();
144        assert_eq!(parsed, original);
145    }
146
147    #[test]
148    fn from_authorization_colon_in_password() {
149        let original = BasicCredentials::new("user", "pa:ss:word");
150        let parsed = BasicCredentials::from_authorization(&original.to_authorization()).unwrap();
151        assert_eq!(parsed.username, "user");
152        assert_eq!(parsed.password.expose_secret(), "pa:ss:word");
153    }
154
155    #[test]
156    fn from_authorization_missing_prefix() {
157        assert!(matches!(
158            BasicCredentials::from_authorization("Bearer token"),
159            Err(BasicError::MissingPrefix)
160        ));
161    }
162
163    #[test]
164    fn from_authorization_invalid_base64() {
165        assert!(matches!(
166            BasicCredentials::from_authorization("Basic !!!not-b64!!!"),
167            Err(BasicError::InvalidBase64(_))
168        ));
169    }
170
171    #[test]
172    fn from_authorization_missing_colon() {
173        // base64("nocolon") = "bm9jb2xvbg=="
174        assert!(matches!(
175            BasicCredentials::from_authorization("Basic bm9jb2xvbg=="),
176            Err(BasicError::MissingColon)
177        ));
178    }
179
180    #[test]
181    fn debug_redacts_password() {
182        let creds = BasicCredentials::new("alice", "hunter2");
183        let debug = format!("{creds:?}");
184        assert!(
185            !debug.contains("hunter2"),
186            "password must not appear in debug"
187        );
188        assert!(debug.contains("[REDACTED]"));
189        assert!(debug.contains("alice"), "username must appear in debug");
190    }
191}