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 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#[derive(Clone)]
48pub struct BasicCredentials {
49 pub username: String,
50 pub password: SecretString,
51}
52
53impl BasicCredentials {
54 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 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 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 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}