1use 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#[derive(Debug, Error)]
34pub enum HttpAuthBasicError {
35 #[error("Missing `Basic ` prefix in Authorization value")]
37 MissingPrefix,
38 #[error("Invalid base64 in Authorization value: {0}")]
40 InvalidBase64(DecodeError),
41 #[error("Decoded credentials are not valid UTF-8")]
43 InvalidUtf8,
44 #[error("Decoded credentials are missing the `:` separator")]
46 MissingColon,
47}
48
49#[derive(Clone)]
52pub struct HttpAuthBasic {
53 pub username: String,
55 pub password: SecretString,
57}
58
59impl HttpAuthBasic {
60 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 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 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 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}