use base64::Engine as _;
use subtle::ConstantTimeEq as _;
use rusty_gasket::auth::backend::AuthBackend;
use rusty_gasket::auth::error::AuthError;
use rusty_gasket::auth::identity::Identity;
#[must_use]
fn extract_basic_credentials(header_value: &str) -> Option<(String, String)> {
const PREFIX: &[u8] = b"Basic ";
let bytes = header_value.as_bytes();
let prefix_bytes = bytes.get(..PREFIX.len())?;
if !prefix_bytes.eq_ignore_ascii_case(PREFIX) {
return None;
}
let encoded = header_value[PREFIX.len()..].trim();
let decoded = base64::engine::general_purpose::STANDARD
.decode(encoded)
.ok()?;
let decoded = String::from_utf8(decoded).ok()?;
let (username, password) = decoded.split_once(':')?;
Some((username.to_owned(), password.to_owned()))
}
pub struct BasicAuthBackend {
username: secrecy::SecretString,
password: secrecy::SecretString,
subject: &'static str,
service_account: bool,
privileged: bool,
}
impl std::fmt::Debug for BasicAuthBackend {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("BasicAuthBackend")
.field("subject", &self.subject)
.field("service_account", &self.service_account)
.field("privileged", &self.privileged)
.finish_non_exhaustive()
}
}
impl BasicAuthBackend {
const DEFAULT_SUBJECT: &'static str = "basic-auth";
const AUTH_METHOD: &'static str = "basic-auth";
#[must_use]
pub fn new(username: impl Into<String>, password: impl Into<String>) -> Self {
Self {
username: secrecy::SecretString::from(username.into()),
password: secrecy::SecretString::from(password.into()),
subject: Self::DEFAULT_SUBJECT,
service_account: false,
privileged: false,
}
}
#[must_use]
pub const fn subject(mut self, subject: &'static str) -> Self {
self.subject = subject;
self
}
#[must_use]
pub const fn service_account(mut self, service_account: bool) -> Self {
self.service_account = service_account;
self
}
#[must_use]
pub const fn privileged(mut self, privileged: bool) -> Self {
self.privileged = privileged;
self
}
fn identity(&self) -> Identity {
Identity::builder(self.subject, Self::AUTH_METHOD)
.service_account(self.service_account)
.privileged(self.privileged)
.build()
}
}
impl AuthBackend for BasicAuthBackend {
fn name(&self) -> &'static str {
"basic-auth"
}
async fn authenticate(
&self,
headers: &http::HeaderMap,
_uri: &http::Uri,
) -> Result<Option<Identity>, AuthError> {
let Some(header) = headers.get(http::header::AUTHORIZATION) else {
return Ok(None);
};
let Some((username, password)) = header.to_str().ok().and_then(extract_basic_credentials)
else {
return Ok(None);
};
use secrecy::ExposeSecret as _;
let expected_user = self.username.expose_secret().as_bytes();
let expected_pass = self.password.expose_secret().as_bytes();
let user_ok = username.len() == expected_user.len()
&& bool::from(username.as_bytes().ct_eq(expected_user));
let pass_ok = password.len() == expected_pass.len()
&& bool::from(password.as_bytes().ct_eq(expected_pass));
if user_ok && pass_ok {
Ok(Some(self.identity()))
} else {
Err(AuthError::InvalidCredentials(
"basic auth credential mismatch".to_string(),
))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn basic(username: &str, password: &str) -> http::HeaderMap {
let raw = format!("{username}:{password}");
let encoded = base64::engine::general_purpose::STANDARD.encode(raw.as_bytes());
let mut headers = http::HeaderMap::new();
headers.insert(
http::header::AUTHORIZATION,
format!("Basic {encoded}")
.parse()
.expect("valid header value"),
);
headers
}
fn uri() -> http::Uri {
"/admin".parse().expect("valid uri")
}
#[tokio::test]
async fn correct_credentials_authenticate() {
let backend = BasicAuthBackend::new("admin", "hunter2");
let identity = backend
.authenticate(&basic("admin", "hunter2"), &uri())
.await
.expect("authentication should not error")
.expect("matching credentials yield an identity");
assert_eq!(identity.subject(), "basic-auth");
assert_eq!(identity.auth_method(), "basic-auth");
assert!(!identity.is_service_account());
assert!(!identity.is_privileged());
}
#[tokio::test]
async fn flags_and_subject_propagate() {
let backend = BasicAuthBackend::new("admin", "pw")
.subject("diagnostics")
.service_account(true)
.privileged(true);
let identity = backend
.authenticate(&basic("admin", "pw"), &uri())
.await
.expect("authentication should not error")
.expect("matching credentials yield an identity");
assert_eq!(identity.subject(), "diagnostics");
assert!(identity.is_service_account());
assert!(identity.is_privileged());
}
#[tokio::test]
async fn wrong_password_is_invalid_credentials() {
let backend = BasicAuthBackend::new("admin", "correct");
let err = backend
.authenticate(&basic("admin", "wrong"), &uri())
.await
.expect_err("a wrong password must be a definitive failure");
assert!(matches!(err, AuthError::InvalidCredentials(_)));
}
#[tokio::test]
async fn wrong_username_is_invalid_credentials() {
let backend = BasicAuthBackend::new("admin", "pw");
let err = backend
.authenticate(&basic("intruder", "pw"), &uri())
.await
.expect_err("a wrong username must be a definitive failure");
assert!(matches!(err, AuthError::InvalidCredentials(_)));
}
#[tokio::test]
async fn missing_authorization_defers() {
let backend = BasicAuthBackend::new("admin", "pw");
let result = backend
.authenticate(&http::HeaderMap::new(), &uri())
.await
.expect("a missing header must not error");
assert!(result.is_none(), "no header → defer to next backend");
}
#[tokio::test]
async fn non_basic_scheme_defers() {
let backend = BasicAuthBackend::new("admin", "pw");
let mut headers = http::HeaderMap::new();
headers.insert(
http::header::AUTHORIZATION,
"Bearer sometoken".parse().expect("valid header value"),
);
let result = backend
.authenticate(&headers, &uri())
.await
.expect("a non-Basic scheme must not error");
assert!(result.is_none(), "non-Basic scheme → defer to next backend");
}
#[tokio::test]
async fn malformed_base64_defers() {
let backend = BasicAuthBackend::new("admin", "pw");
let mut headers = http::HeaderMap::new();
headers.insert(
http::header::AUTHORIZATION,
"Basic !!!not-base64!!!"
.parse()
.expect("valid header value"),
);
let result = backend
.authenticate(&headers, &uri())
.await
.expect("malformed base64 must not error");
assert!(result.is_none(), "undecodable credential → defer");
}
#[tokio::test]
async fn no_colon_separator_defers() {
let encoded = base64::engine::general_purpose::STANDARD.encode("nocolonhere");
let mut headers = http::HeaderMap::new();
headers.insert(
http::header::AUTHORIZATION,
format!("Basic {encoded}")
.parse()
.expect("valid header value"),
);
let backend = BasicAuthBackend::new("admin", "pw");
let result = backend
.authenticate(&headers, &uri())
.await
.expect("missing separator must not error");
assert!(result.is_none(), "no ':' → defer");
}
#[tokio::test]
async fn password_may_contain_colons() {
let backend = BasicAuthBackend::new("admin", "a:b:c");
let identity = backend
.authenticate(&basic("admin", "a:b:c"), &uri())
.await
.expect("authentication should not error")
.expect("password with colons authenticates");
assert_eq!(identity.auth_method(), "basic-auth");
}
#[tokio::test]
async fn prefix_of_password_is_invalid() {
let backend = BasicAuthBackend::new("admin", "correct-password");
let err = backend
.authenticate(&basic("admin", "correct"), &uri())
.await
.expect_err("a prefix of the password must not authenticate");
assert!(matches!(err, AuthError::InvalidCredentials(_)));
}
#[tokio::test]
async fn scheme_is_case_insensitive() {
let raw = base64::engine::general_purpose::STANDARD.encode("admin:pw");
let mut headers = http::HeaderMap::new();
headers.insert(
http::header::AUTHORIZATION,
format!("basic {raw}").parse().expect("valid header value"),
);
let backend = BasicAuthBackend::new("admin", "pw");
let identity = backend
.authenticate(&headers, &uri())
.await
.expect("authentication should not error")
.expect("lowercase scheme authenticates");
assert_eq!(identity.subject(), "basic-auth");
}
#[test]
fn name_is_stable() {
assert_eq!(BasicAuthBackend::new("u", "p").name(), "basic-auth");
}
#[test]
fn debug_does_not_leak_credentials() {
let backend = BasicAuthBackend::new("admin-user", "super-secret-pw");
let rendered = format!("{backend:?}");
assert!(
!rendered.contains("super-secret-pw"),
"Debug leaked password: {rendered}"
);
assert!(
!rendered.contains("admin-user"),
"Debug leaked username: {rendered}"
);
}
}