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::HttpAuthBasic;
9//! use secrecy::ExposeSecret;
10//!
11//! let creds = HttpAuthBasic::new("Aladdin", "open sesame");
12//! assert_eq!(creds.to_authorization(), "Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==");
13//!
14//! let parsed = HttpAuthBasic::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    vec::Vec,
27};
28
29use base64_codec::codec::Base64Codec;
30use secrecy::{ExposeSecret, SecretString};
31use thiserror::Error;
32
33/// Failure causes when parsing a `Basic` authorization value.
34#[derive(Debug, Error)]
35pub enum HttpAuthBasicError {
36    /// The value does not start with the basic scheme prefix.
37    #[error("Missing `Basic ` prefix in Authorization value")]
38    MissingPrefix,
39    /// The credentials payload holds a byte outside the base64 alphabet, or
40    /// data after its padding.
41    #[error("Invalid base64 in Authorization value")]
42    InvalidBase64,
43    /// The decoded credentials are not valid UTF-8.
44    #[error("Decoded credentials are not valid UTF-8")]
45    InvalidUtf8,
46    /// The decoded credentials carry no colon separator.
47    #[error("Decoded credentials are missing the `:` separator")]
48    MissingColon,
49}
50
51/// HTTP `Basic` credential pair; `password` is redacted in
52/// [`fmt::Debug`] and zeroed on drop.
53#[derive(Clone)]
54pub struct HttpAuthBasic {
55    /// The username, in clear.
56    pub username: String,
57    /// The password, redacted in [`fmt::Debug`] and zeroed on drop.
58    pub password: SecretString,
59}
60
61impl HttpAuthBasic {
62    /// Wraps a username + password.
63    pub fn new(username: impl Into<String>, password: impl Into<String>) -> Self {
64        Self {
65            username: username.into(),
66            password: SecretString::from(password.into()),
67        }
68    }
69
70    /// Returns the `Basic <base64(user:pass)>` header value.
71    pub fn to_authorization(&self) -> String {
72        let payload = format!("{}:{}", self.username, self.password.expose_secret());
73        let mut encoded = Vec::new();
74        Base64Codec::default().encode_vec(payload.as_bytes(), &mut encoded);
75        // NOTE: the base64 alphabet is ASCII, so this never falls back.
76        let encoded = from_utf8(&encoded).unwrap_or_default();
77        format!("Basic {encoded}")
78    }
79
80    /// Parses a `Basic <b64>` header value.
81    ///
82    /// The payload decodes leniently, as base64-codec does: missing padding,
83    /// non-zero trailing bits and blanks are accepted, any other byte outside
84    /// the alphabet refused.
85    pub fn from_authorization(value: &str) -> Result<Self, HttpAuthBasicError> {
86        let encoded = value
87            .strip_prefix("Basic ")
88            .ok_or(HttpAuthBasicError::MissingPrefix)?;
89
90        let mut decoded = Vec::new();
91        if !Base64Codec::default().decode_vec(encoded.as_bytes(), &mut decoded) {
92            return Err(HttpAuthBasicError::InvalidBase64);
93        }
94
95        let s = from_utf8(&decoded).map_err(|_| HttpAuthBasicError::InvalidUtf8)?;
96        let (username, password) = s.split_once(':').ok_or(HttpAuthBasicError::MissingColon)?;
97
98        Ok(Self {
99            username: username.into(),
100            password: SecretString::from(password.to_string()),
101        })
102    }
103}
104
105impl fmt::Debug for HttpAuthBasic {
106    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
107        f.debug_struct("HttpAuthBasic")
108            .field("username", &self.username)
109            .field("password", &"[REDACTED]")
110            .finish()
111    }
112}
113
114impl PartialEq for HttpAuthBasic {
115    fn eq(&self, other: &Self) -> bool {
116        self.username == other.username
117            && self.password.expose_secret() == other.password.expose_secret()
118    }
119}
120
121impl Eq for HttpAuthBasic {}
122
123#[cfg(test)]
124mod tests {
125    use alloc::format;
126
127    use secrecy::ExposeSecret;
128
129    use crate::rfc7617::basic::*;
130
131    #[test]
132    fn to_authorization_rfc_test_vector() {
133        let creds = HttpAuthBasic::new("Aladdin", "open sesame");
134        assert_eq!(
135            creds.to_authorization(),
136            "Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ=="
137        );
138    }
139
140    #[test]
141    fn to_authorization_has_basic_prefix() {
142        let creds = HttpAuthBasic::new("user", "pass");
143        assert!(creds.to_authorization().starts_with("Basic "));
144    }
145
146    #[test]
147    fn to_authorization_empty_password() {
148        let creds = HttpAuthBasic::new("user", "");
149        let value = creds.to_authorization();
150        let decoded = HttpAuthBasic::from_authorization(&value).unwrap();
151        assert_eq!(decoded.username, "user");
152        assert_eq!(decoded.password.expose_secret(), "");
153    }
154
155    #[test]
156    fn from_authorization_roundtrip() {
157        let original = HttpAuthBasic::new("user@example.com", "p@$$w0rd!");
158        let header = original.to_authorization();
159        let parsed = HttpAuthBasic::from_authorization(&header).unwrap();
160        assert_eq!(parsed, original);
161    }
162
163    #[test]
164    fn from_authorization_colon_in_password() {
165        let original = HttpAuthBasic::new("user", "pa:ss:word");
166        let parsed = HttpAuthBasic::from_authorization(&original.to_authorization()).unwrap();
167        assert_eq!(parsed.username, "user");
168        assert_eq!(parsed.password.expose_secret(), "pa:ss:word");
169    }
170
171    #[test]
172    fn from_authorization_missing_prefix() {
173        assert!(matches!(
174            HttpAuthBasic::from_authorization("Bearer token"),
175            Err(HttpAuthBasicError::MissingPrefix)
176        ));
177    }
178
179    #[test]
180    fn from_authorization_invalid_base64() {
181        assert!(matches!(
182            HttpAuthBasic::from_authorization("Basic !!!not-b64!!!"),
183            Err(HttpAuthBasicError::InvalidBase64)
184        ));
185    }
186
187    /// The payload decodes leniently: a missing padding is accepted, data
188    /// after the padding is not.
189    #[test]
190    fn from_authorization_lenient_padding() {
191        // NOTE: base64("u:p") = "dTpw", base64("u:") = "dTo=".
192        let parsed = HttpAuthBasic::from_authorization("Basic dTo").unwrap();
193        assert_eq!(parsed.username, "u");
194        assert_eq!(parsed.password.expose_secret(), "");
195
196        assert!(matches!(
197            HttpAuthBasic::from_authorization("Basic dTo=dTpw"),
198            Err(HttpAuthBasicError::InvalidBase64)
199        ));
200    }
201
202    #[test]
203    fn from_authorization_missing_colon() {
204        // NOTE: base64("nocolon") = "bm9jb2xvbg=="
205        assert!(matches!(
206            HttpAuthBasic::from_authorization("Basic bm9jb2xvbg=="),
207            Err(HttpAuthBasicError::MissingColon)
208        ));
209    }
210
211    #[test]
212    fn debug_redacts_password() {
213        let creds = HttpAuthBasic::new("alice", "hunter2");
214        let debug = format!("{creds:?}");
215        assert!(
216            !debug.contains("hunter2"),
217            "password must not appear in debug"
218        );
219        assert!(debug.contains("[REDACTED]"));
220        assert!(debug.contains("alice"), "username must appear in debug");
221    }
222}