1use 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#[derive(Debug, Error)]
35pub enum HttpAuthBasicError {
36 #[error("Missing `Basic ` prefix in Authorization value")]
38 MissingPrefix,
39 #[error("Invalid base64 in Authorization value")]
42 InvalidBase64,
43 #[error("Decoded credentials are not valid UTF-8")]
45 InvalidUtf8,
46 #[error("Decoded credentials are missing the `:` separator")]
48 MissingColon,
49}
50
51#[derive(Clone)]
54pub struct HttpAuthBasic {
55 pub username: String,
57 pub password: SecretString,
59}
60
61impl HttpAuthBasic {
62 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 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 let encoded = from_utf8(&encoded).unwrap_or_default();
77 format!("Basic {encoded}")
78 }
79
80 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 #[test]
190 fn from_authorization_lenient_padding() {
191 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 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}