use core::{fmt, str::from_utf8};
use alloc::{
format,
string::{String, ToString},
vec::Vec,
};
use base64_codec::codec::Base64Codec;
use secrecy::{ExposeSecret, SecretString};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum HttpAuthBasicError {
#[error("Missing `Basic ` prefix in Authorization value")]
MissingPrefix,
#[error("Invalid base64 in Authorization value")]
InvalidBase64,
#[error("Decoded credentials are not valid UTF-8")]
InvalidUtf8,
#[error("Decoded credentials are missing the `:` separator")]
MissingColon,
}
#[derive(Clone)]
pub struct HttpAuthBasic {
pub username: String,
pub password: SecretString,
}
impl HttpAuthBasic {
pub fn new(username: impl Into<String>, password: impl Into<String>) -> Self {
Self {
username: username.into(),
password: SecretString::from(password.into()),
}
}
pub fn to_authorization(&self) -> String {
let payload = format!("{}:{}", self.username, self.password.expose_secret());
let mut encoded = Vec::new();
Base64Codec::default().encode_vec(payload.as_bytes(), &mut encoded);
let encoded = from_utf8(&encoded).unwrap_or_default();
format!("Basic {encoded}")
}
pub fn from_authorization(value: &str) -> Result<Self, HttpAuthBasicError> {
let encoded = value
.strip_prefix("Basic ")
.ok_or(HttpAuthBasicError::MissingPrefix)?;
let mut decoded = Vec::new();
if !Base64Codec::default().decode_vec(encoded.as_bytes(), &mut decoded) {
return Err(HttpAuthBasicError::InvalidBase64);
}
let s = from_utf8(&decoded).map_err(|_| HttpAuthBasicError::InvalidUtf8)?;
let (username, password) = s.split_once(':').ok_or(HttpAuthBasicError::MissingColon)?;
Ok(Self {
username: username.into(),
password: SecretString::from(password.to_string()),
})
}
}
impl fmt::Debug for HttpAuthBasic {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("HttpAuthBasic")
.field("username", &self.username)
.field("password", &"[REDACTED]")
.finish()
}
}
impl PartialEq for HttpAuthBasic {
fn eq(&self, other: &Self) -> bool {
self.username == other.username
&& self.password.expose_secret() == other.password.expose_secret()
}
}
impl Eq for HttpAuthBasic {}
#[cfg(test)]
mod tests {
use alloc::format;
use secrecy::ExposeSecret;
use crate::rfc7617::basic::*;
#[test]
fn to_authorization_rfc_test_vector() {
let creds = HttpAuthBasic::new("Aladdin", "open sesame");
assert_eq!(
creds.to_authorization(),
"Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ=="
);
}
#[test]
fn to_authorization_has_basic_prefix() {
let creds = HttpAuthBasic::new("user", "pass");
assert!(creds.to_authorization().starts_with("Basic "));
}
#[test]
fn to_authorization_empty_password() {
let creds = HttpAuthBasic::new("user", "");
let value = creds.to_authorization();
let decoded = HttpAuthBasic::from_authorization(&value).unwrap();
assert_eq!(decoded.username, "user");
assert_eq!(decoded.password.expose_secret(), "");
}
#[test]
fn from_authorization_roundtrip() {
let original = HttpAuthBasic::new("user@example.com", "p@$$w0rd!");
let header = original.to_authorization();
let parsed = HttpAuthBasic::from_authorization(&header).unwrap();
assert_eq!(parsed, original);
}
#[test]
fn from_authorization_colon_in_password() {
let original = HttpAuthBasic::new("user", "pa:ss:word");
let parsed = HttpAuthBasic::from_authorization(&original.to_authorization()).unwrap();
assert_eq!(parsed.username, "user");
assert_eq!(parsed.password.expose_secret(), "pa:ss:word");
}
#[test]
fn from_authorization_missing_prefix() {
assert!(matches!(
HttpAuthBasic::from_authorization("Bearer token"),
Err(HttpAuthBasicError::MissingPrefix)
));
}
#[test]
fn from_authorization_invalid_base64() {
assert!(matches!(
HttpAuthBasic::from_authorization("Basic !!!not-b64!!!"),
Err(HttpAuthBasicError::InvalidBase64)
));
}
#[test]
fn from_authorization_lenient_padding() {
let parsed = HttpAuthBasic::from_authorization("Basic dTo").unwrap();
assert_eq!(parsed.username, "u");
assert_eq!(parsed.password.expose_secret(), "");
assert!(matches!(
HttpAuthBasic::from_authorization("Basic dTo=dTpw"),
Err(HttpAuthBasicError::InvalidBase64)
));
}
#[test]
fn from_authorization_missing_colon() {
assert!(matches!(
HttpAuthBasic::from_authorization("Basic bm9jb2xvbg=="),
Err(HttpAuthBasicError::MissingColon)
));
}
#[test]
fn debug_redacts_password() {
let creds = HttpAuthBasic::new("alice", "hunter2");
let debug = format!("{creds:?}");
assert!(
!debug.contains("hunter2"),
"password must not appear in debug"
);
assert!(debug.contains("[REDACTED]"));
assert!(debug.contains("alice"), "username must appear in debug");
}
}