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