From 5e3435e594e8a2cc3ce086d5a7526e18fefd8009 Mon Sep 17 00:00:00 2001 From: gbargoud Date: Thu, 23 Apr 2026 20:55:31 -0400 Subject: [PATCH 1/7] Add server side auth for basic challenge (cherry picked from commit 79022c0215b4f415951ef5620589fceedb57ecb0) --- Cargo.toml | 7 ++- src/basic.rs | 116 +++++++++++++++++++++++++++++++++++++++++++++++++- src/errors.rs | 8 ++++ src/lib.rs | 5 ++- 4 files changed, 131 insertions(+), 5 deletions(-) create mode 100644 src/errors.rs diff --git a/Cargo.toml b/Cargo.toml index 50a6ceb..a6361d3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,10 @@ repository = "https://github.com/scottlamb/http-auth" rust-version = "1.70.0" [features] -default = ["basic-scheme", "digest-scheme"] +default = ["basic-scheme", "digest-scheme", "server"] + +# Enable client side or server side libraries. +server = [] # Enable code to respond to challenges of the given scheme. basic-scheme = ["base64"] @@ -24,7 +27,7 @@ digest-scheme = ["digest", "hex", "md-5", "rand", "sha2"] # Enable per-byte trace! calls in parsing (causing code bloat). This is only # meant for testing http-auth itself. -trace = ["log"] +trace = ["dep:log"] [package.metadata.docs.rs] # https://docs.rs/about/metadata diff --git a/src/basic.rs b/src/basic.rs index 14873c8..94e6968 100644 --- a/src/basic.rs +++ b/src/basic.rs @@ -8,6 +8,11 @@ use std::convert::TryFrom; use crate::ChallengeRef; +#[cfg(feature = "server")] +use crate::errors::AuthError; + +const PREFIX: &str = "Basic "; + /// Encodes the given credentials. /// /// This can be used to preemptively send `Basic` authentication, without @@ -30,7 +35,6 @@ use crate::ChallengeRef; pub fn encode_credentials(username: &str, password: &str) -> String { use base64::Engine as _; let user_pass = format!("{}:{}", username, password); - const PREFIX: &str = "Basic "; let mut value = String::with_capacity(PREFIX.len() + base64_encoded_len(user_pass.len())); value.push_str(PREFIX); base64::engine::general_purpose::STANDARD.encode_string(&user_pass[..], &mut value); @@ -42,6 +46,46 @@ fn base64_encoded_len(input_len: usize) -> usize { (input_len + 2) / 3 * 4 } +/// Represents the username and password retrieved from Basic auth +#[cfg(feature = "server")] +pub struct Credentials { + pub username: String, + pub password: String, +} + +#[cfg(feature = "server")] +impl From<(&str, &str)> for Credentials { + fn from((username, password): (&str, &str)) -> Self { + Self { + username: username.to_string(), + password: password.to_string(), + } + } +} + +/// Decode the credentials from the header +/// +/// These are the credentials added to the `Authorization` or `Proxy-Authorization` header value by +/// the client. +/// +/// This is a reversal of `encode_credentials`. +#[cfg(feature = "server")] +pub fn decode_credentials(header_value: &str) -> Result { + use base64::Engine as _; + let encoded = header_value + .strip_prefix(PREFIX) + .ok_or(AuthError::IncorrectScheme)? + .trim(); + let decoded = base64::engine::general_purpose::STANDARD + .decode(encoded) + .map_err(|_| AuthError::MalformedRequest)?; + let decoded = String::from_utf8(decoded).map_err(|_| AuthError::MalformedRequest)?; + decoded + .split_once(":") + .map(Credentials::from) + .ok_or(AuthError::MalformedRequest) +} + /// Client for a `Basic` challenge, as in /// [RFC 7617](https://datatracker.ietf.org/doc/html/rfc7617). /// @@ -67,6 +111,32 @@ impl BasicClient { } } +// Server side implementations for BasicClient +#[cfg(feature = "server")] +impl BasicClient { + /// Creates a new client for issuing challenges and verifying responses. + pub fn new(realm: String) -> Self { + Self { + realm: realm.into(), + } + } + + /// Issues the challenge for the given client + /// + /// This should be included by the server in the `WWW-Authenticate` header of a 401 response or + /// the `Proxy-Authenticate` header in a 407 response. + #[inline] + pub fn challenge(&self) -> String { + format!("{}realm={}", PREFIX, self.realm) + } + + /// Parses the password + #[inline] + pub fn parse_response(&self, response: &str) -> Result { + decode_credentials(response) + } +} + impl TryFrom<&ChallengeRef<'_>> for BasicClient { type Error = String; @@ -93,9 +163,10 @@ impl TryFrom<&ChallengeRef<'_>> for BasicClient { #[cfg(test)] mod tests { use super::*; + use crate::ChallengeParser; #[test] - fn basic() { + fn basic_respond() { // Example from https://datatracker.ietf.org/doc/html/rfc7617#section-2 let ctx = BasicClient { realm: "WallyWorld".into(), @@ -112,4 +183,45 @@ mod tests { }; assert_eq!(ctx.respond("test", "123\u{A3}"), "Basic dGVzdDoxMjPCow=="); } + + #[test] + #[cfg(feature = "server")] + fn basic_round_trip() { + let ctx = BasicClient { + realm: "foo".into(), + }; + let challenge = ctx.challenge(); + let mut challenge_parser = ChallengeParser::new(challenge.as_str()); + let challenge_ref = challenge_parser + .next() + .expect("Missing ChallengeRef") + .expect("Malformed ChallengeRef"); + let client = BasicClient::try_from(&challenge_ref).expect("Challenge should be basic"); + assert_eq!(client.realm, ctx.realm); + + let response = ctx.respond("AzureDiamond", "hunter2"); + let credentials = ctx + .parse_response(response.as_str()) + .expect("Failed to parse credentials"); + assert_eq!(credentials.username, "AzureDiamond", "username"); + assert_eq!(credentials.password, "hunter2", "password"); + } + + #[test] + #[cfg(feature = "server")] + fn fail_to_parse() { + let ctx = BasicClient { + realm: "foo".into(), + }; + + assert!(ctx.parse_response("Does not start with Basic").is_err()); + assert!(ctx + .parse_response("Basic Invalid Base64 encoded string") + .is_err()); + use base64::Engine as _; + let mut buf = String::new(); + base64::engine::general_purpose::STANDARD + .encode_string("not username colon password", &mut buf); + assert!(ctx.parse_response(&buf).is_err()); + } } diff --git a/src/errors.rs b/src/errors.rs new file mode 100644 index 0000000..8caafa0 --- /dev/null +++ b/src/errors.rs @@ -0,0 +1,8 @@ +/// The errors that can occur when parsing the response server side +#[derive(Debug)] +pub enum AuthError { + /// The parser used did not match the scheme + IncorrectScheme, + /// The request was malformed in some way + MalformedRequest, +} \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs index 4c15995..f906cb7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -88,6 +88,9 @@ pub mod digest; mod table; +#[cfg(feature = "server")] +pub mod errors; + pub use parser::ChallengeParser; #[cfg(feature = "basic-scheme")] @@ -185,7 +188,7 @@ impl std::fmt::Debug for ParamsPrinter<'_> { /// ## Example /// #[cfg_attr( - feature = "digest", + feature = "digest-scheme", doc = r##" ```rust use http_auth::PasswordClient; From 12d1da43575318e6a04885f47521fbbf7638639d Mon Sep 17 00:00:00 2001 From: gbargoud Date: Thu, 23 Apr 2026 21:01:37 -0400 Subject: [PATCH 2/7] Remove "server" from the default features --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index a6361d3..07b1141 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,7 @@ repository = "https://github.com/scottlamb/http-auth" rust-version = "1.70.0" [features] -default = ["basic-scheme", "digest-scheme", "server"] +default = ["basic-scheme", "digest-scheme"] # Enable client side or server side libraries. server = [] From 193c3bfbbad0b15c46da6398f5970293f21ebb62 Mon Sep 17 00:00:00 2001 From: gbargoud Date: Thu, 23 Apr 2026 21:02:03 -0400 Subject: [PATCH 3/7] Remove "server" from the default features and revert an unnecessary change in feature names --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 07b1141..dd8928f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,7 +27,7 @@ digest-scheme = ["digest", "hex", "md-5", "rand", "sha2"] # Enable per-byte trace! calls in parsing (causing code bloat). This is only # meant for testing http-auth itself. -trace = ["dep:log"] +trace = ["log"] [package.metadata.docs.rs] # https://docs.rs/about/metadata From 23b65acc6780fdbd74d1a80fc3399f138719cd62 Mon Sep 17 00:00:00 2001 From: gbargoud Date: Thu, 23 Apr 2026 21:31:03 -0400 Subject: [PATCH 4/7] Fix formatting an license issues --- src/errors.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/errors.rs b/src/errors.rs index 8caafa0..2c81a18 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -1,3 +1,7 @@ +// Copyright (C) 2026 George Bargoud & Scott Lamb +// SPDX-License-Identifier: MIT OR Apache-2.0 + + /// The errors that can occur when parsing the response server side #[derive(Debug)] pub enum AuthError { @@ -5,4 +9,4 @@ pub enum AuthError { IncorrectScheme, /// The request was malformed in some way MalformedRequest, -} \ No newline at end of file +} From 41959904948cd52be226d9064b9f56fc3d8a6f7f Mon Sep 17 00:00:00 2001 From: gbargoud Date: Fri, 24 Apr 2026 09:39:54 -0400 Subject: [PATCH 5/7] Separate structs for client and server This doesn't really matter for the basic client but will be important for the digest client. --- src/basic.rs | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/src/basic.rs b/src/basic.rs index 94e6968..ed110a8 100644 --- a/src/basic.rs +++ b/src/basic.rs @@ -111,9 +111,15 @@ impl BasicClient { } } -// Server side implementations for BasicClient +/// Server side support for a `Basic` challenge, as in +/// [RFC 7617](https://datatracker.ietf.org/doc/html/rfc7617). #[cfg(feature = "server")] -impl BasicClient { +pub struct BasicServer { + realm: Box, +} + +#[cfg(feature = "server")] +impl BasicServer { /// Creates a new client for issuing challenges and verifying responses. pub fn new(realm: String) -> Self { Self { @@ -187,20 +193,20 @@ mod tests { #[test] #[cfg(feature = "server")] fn basic_round_trip() { - let ctx = BasicClient { + let server = BasicServer { realm: "foo".into(), }; - let challenge = ctx.challenge(); + let challenge = server.challenge(); let mut challenge_parser = ChallengeParser::new(challenge.as_str()); let challenge_ref = challenge_parser .next() .expect("Missing ChallengeRef") .expect("Malformed ChallengeRef"); let client = BasicClient::try_from(&challenge_ref).expect("Challenge should be basic"); - assert_eq!(client.realm, ctx.realm); + assert_eq!(client.realm, server.realm); - let response = ctx.respond("AzureDiamond", "hunter2"); - let credentials = ctx + let response = client.respond("AzureDiamond", "hunter2"); + let credentials = server .parse_response(response.as_str()) .expect("Failed to parse credentials"); assert_eq!(credentials.username, "AzureDiamond", "username"); @@ -210,18 +216,18 @@ mod tests { #[test] #[cfg(feature = "server")] fn fail_to_parse() { - let ctx = BasicClient { + let server = BasicServer { realm: "foo".into(), }; - assert!(ctx.parse_response("Does not start with Basic").is_err()); - assert!(ctx + assert!(server.parse_response("Does not start with Basic").is_err()); + assert!(server .parse_response("Basic Invalid Base64 encoded string") .is_err()); use base64::Engine as _; let mut buf = String::new(); base64::engine::general_purpose::STANDARD .encode_string("not username colon password", &mut buf); - assert!(ctx.parse_response(&buf).is_err()); + assert!(server.parse_response(&buf).is_err()); } } From 2b20344e2d48117079db81fe4966d0ea68f9f8b5 Mon Sep 17 00:00:00 2001 From: gbargoud Date: Fri, 24 Apr 2026 12:11:08 -0400 Subject: [PATCH 6/7] Move server tests to a new module --- src/basic.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/basic.rs b/src/basic.rs index ed110a8..395cc63 100644 --- a/src/basic.rs +++ b/src/basic.rs @@ -169,7 +169,6 @@ impl TryFrom<&ChallengeRef<'_>> for BasicClient { #[cfg(test)] mod tests { use super::*; - use crate::ChallengeParser; #[test] fn basic_respond() { @@ -189,9 +188,15 @@ mod tests { }; assert_eq!(ctx.respond("test", "123\u{A3}"), "Basic dGVzdDoxMjPCow=="); } +} + +#[cfg(test)] +#[cfg(feature = "server")] +mod server_tests { + use super::*; + use crate::ChallengeParser; #[test] - #[cfg(feature = "server")] fn basic_round_trip() { let server = BasicServer { realm: "foo".into(), @@ -214,7 +219,6 @@ mod tests { } #[test] - #[cfg(feature = "server")] fn fail_to_parse() { let server = BasicServer { realm: "foo".into(), From ab2a4b8e536934212b87a62337af32e5aaf2a971 Mon Sep 17 00:00:00 2001 From: gbargoud Date: Wed, 29 Apr 2026 16:44:51 -0400 Subject: [PATCH 7/7] Switch output to a Credentials impl This will make it easier to enable storing password digests instead of plaintext passwords. --- src/basic.rs | 62 +++++++++++++++++++++++++++++++++++----------- src/credentials.rs | 36 +++++++++++++++++++++++++++ src/digest.rs | 2 +- src/errors.rs | 2 ++ src/lib.rs | 2 ++ 5 files changed, 88 insertions(+), 16 deletions(-) create mode 100644 src/credentials.rs diff --git a/src/basic.rs b/src/basic.rs index 395cc63..31f5c4c 100644 --- a/src/basic.rs +++ b/src/basic.rs @@ -6,10 +6,11 @@ use std::convert::TryFrom; -use crate::ChallengeRef; - +use crate::credentials::{Credentials, User}; +use crate::digest::Algorithm; #[cfg(feature = "server")] use crate::errors::AuthError; +use crate::ChallengeRef; const PREFIX: &str = "Basic "; @@ -46,19 +47,40 @@ fn base64_encoded_len(input_len: usize) -> usize { (input_len + 2) / 3 * 4 } -/// Represents the username and password retrieved from Basic auth #[cfg(feature = "server")] -pub struct Credentials { - pub username: String, - pub password: String, +pub struct PlaintextCredentials { + username: String, + realm: String, + password: String, } #[cfg(feature = "server")] -impl From<(&str, &str)> for Credentials { - fn from((username, password): (&str, &str)) -> Self { - Self { - username: username.to_string(), - password: password.to_string(), +impl Credentials for PlaintextCredentials { + fn get_user(&self) -> User { + User::Username(self.username.clone()) + } + + fn equals_plaintext(&self, username: &str, password: &str) -> Result<(), AuthError> { + if self.username == username && self.password == password { + Ok(()) + } else { + Err(AuthError::IncorrectPassword) + } + } + + #[cfg(feature = "digest-scheme")] + fn equals_digest(&self, digested: &str, algorithm: &Algorithm) -> Result<(), AuthError> { + let my_digest = algorithm.h(&[ + self.username.as_bytes(), + b":", + self.realm.as_bytes(), + b":", + self.password.as_bytes(), + ]); + if my_digest == digested { + Ok(()) + } else { + Err(AuthError::IncorrectPassword) } } } @@ -69,8 +91,14 @@ impl From<(&str, &str)> for Credentials { /// the client. /// /// This is a reversal of `encode_credentials`. +/// +/// The realm is the realm that was set in the request, This is used to calculate the digest if +/// needed but can be ignored otherwise. #[cfg(feature = "server")] -pub fn decode_credentials(header_value: &str) -> Result { +pub fn decode_credentials( + realm: &str, + header_value: &str, +) -> Result { use base64::Engine as _; let encoded = header_value .strip_prefix(PREFIX) @@ -82,7 +110,11 @@ pub fn decode_credentials(header_value: &str) -> Result let decoded = String::from_utf8(decoded).map_err(|_| AuthError::MalformedRequest)?; decoded .split_once(":") - .map(Credentials::from) + .map(|(username, password)| PlaintextCredentials { + username: username.to_string(), + realm: realm.to_string(), + password: password.to_string(), + }) .ok_or(AuthError::MalformedRequest) } @@ -138,8 +170,8 @@ impl BasicServer { /// Parses the password #[inline] - pub fn parse_response(&self, response: &str) -> Result { - decode_credentials(response) + pub fn parse_response(&self, response: &str) -> Result { + decode_credentials(self.realm.as_ref(), response) } } diff --git a/src/credentials.rs b/src/credentials.rs new file mode 100644 index 0000000..7fb0382 --- /dev/null +++ b/src/credentials.rs @@ -0,0 +1,36 @@ +// Copyright (C) 2026 George Bargoud & Scott Lamb +// SPDX-License-Identifier: MIT OR Apache-2.0 + +use crate::digest::Algorithm; +use crate::errors::AuthError; + +/// The user that a given set of Credentials are for. +/// +/// For digest auth, this may be hashed. +pub enum User { + Username(String), + #[cfg(feature = "digest-scheme")] + UserHash(String, Algorithm), +} + +/// The credentials received from the user that can be used for verification +pub trait Credentials { + /// The user that these credentials are for. + fn get_user(&self) -> User; + + /// Whether these credentials match the given plaintext username and password + fn equals_plaintext(&self, username: &str, password: &str) -> Result<(), AuthError>; + + /// Whether these credentials match the given digest which was made with the given algorithm. + /// + /// This function allows servers to store the digest in their database in the place of plaintext + /// passwords and then use that for authentication. + /// + /// The digest should be a hash of "username:realm:password" where realm is the realm that was + /// sent in the HTTP auth request. + /// + /// The algorithm is the one that was used to calculate the digest. If using digest auth, then + /// this must match the algorithm in the request. + #[cfg(feature = "digest-scheme")] + fn equals_digest(&self, digest: &str, algorithm: &Algorithm) -> Result<(), AuthError>; +} diff --git a/src/digest.rs b/src/digest.rs index 3130258..550d0e2 100644 --- a/src/digest.rs +++ b/src/digest.rs @@ -608,7 +608,7 @@ impl Algorithm { } #[inline(never)] - fn h(&self, items: &[&[u8]]) -> String { + pub(crate) fn h(&self, items: &[&[u8]]) -> String { match self { Algorithm::Md5 => h(md5::Md5::new(), items), Algorithm::Sha256 => h(sha2::Sha256::new(), items), diff --git a/src/errors.rs b/src/errors.rs index 2c81a18..736765a 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -9,4 +9,6 @@ pub enum AuthError { IncorrectScheme, /// The request was malformed in some way MalformedRequest, + /// The password was not correct for the user. + IncorrectPassword, } diff --git a/src/lib.rs b/src/lib.rs index f906cb7..85fdf4a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -90,6 +90,8 @@ mod table; #[cfg(feature = "server")] pub mod errors; +#[cfg(feature = "server")] +pub mod credentials; pub use parser::ChallengeParser;