Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ rust-version = "1.70.0"
[features]
default = ["basic-scheme", "digest-scheme"]

# Enable client side or server side libraries.
server = []

# Enable code to respond to challenges of the given scheme.
basic-scheme = ["base64"]
digest-scheme = ["digest", "hex", "md-5", "rand", "sha2"]
Expand Down
158 changes: 156 additions & 2 deletions src/basic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,14 @@

use std::convert::TryFrom;

use crate::credentials::{Credentials, User};
use crate::digest::Algorithm;
#[cfg(feature = "server")]
use crate::errors::AuthError;
use crate::ChallengeRef;

const PREFIX: &str = "Basic ";

/// Encodes the given credentials.
///
/// This can be used to preemptively send `Basic` authentication, without
Expand All @@ -30,7 +36,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);
Expand All @@ -42,6 +47,77 @@ fn base64_encoded_len(input_len: usize) -> usize {
(input_len + 2) / 3 * 4
}

#[cfg(feature = "server")]
pub struct PlaintextCredentials {
username: String,
realm: String,
password: String,
}

#[cfg(feature = "server")]
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)
}
}
}

/// 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`.
///
/// 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(
realm: &str,
header_value: &str,
) -> Result<PlaintextCredentials, AuthError> {
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(|(username, password)| PlaintextCredentials {
username: username.to_string(),
realm: realm.to_string(),
password: password.to_string(),
})
.ok_or(AuthError::MalformedRequest)
}

/// Client for a `Basic` challenge, as in
/// [RFC 7617](https://datatracker.ietf.org/doc/html/rfc7617).
///
Expand All @@ -67,6 +143,38 @@ impl BasicClient {
}
}

/// Server side support for a `Basic` challenge, as in
/// [RFC 7617](https://datatracker.ietf.org/doc/html/rfc7617).
#[cfg(feature = "server")]
pub struct BasicServer {
realm: Box<str>,
}

#[cfg(feature = "server")]
impl BasicServer {
/// 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<PlaintextCredentials, AuthError> {
decode_credentials(self.realm.as_ref(), response)
}
}

impl TryFrom<&ChallengeRef<'_>> for BasicClient {
type Error = String;

Expand Down Expand Up @@ -95,7 +203,7 @@ mod tests {
use super::*;

#[test]
fn basic() {
fn basic_respond() {
// Example from https://datatracker.ietf.org/doc/html/rfc7617#section-2
let ctx = BasicClient {
realm: "WallyWorld".into(),
Expand All @@ -113,3 +221,49 @@ 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]
fn basic_round_trip() {
let server = BasicServer {
realm: "foo".into(),
};
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, server.realm);

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");
assert_eq!(credentials.password, "hunter2", "password");
}

#[test]
fn fail_to_parse() {
let server = BasicServer {
realm: "foo".into(),
};

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!(server.parse_response(&buf).is_err());
}
}
36 changes: 36 additions & 0 deletions src/credentials.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// Copyright (C) 2026 George Bargoud <george@bargoud.nyc> & Scott Lamb <slamb@slamb.org>
// 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>;
}
2 changes: 1 addition & 1 deletion src/digest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
14 changes: 14 additions & 0 deletions src/errors.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// Copyright (C) 2026 George Bargoud <george@bargoud.nyc> & Scott Lamb <slamb@slamb.org>
// SPDX-License-Identifier: MIT OR Apache-2.0


/// 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,
/// The password was not correct for the user.
IncorrectPassword,
}
7 changes: 6 additions & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,11 @@ pub mod digest;

mod table;

#[cfg(feature = "server")]
pub mod errors;
#[cfg(feature = "server")]
pub mod credentials;

pub use parser::ChallengeParser;

#[cfg(feature = "basic-scheme")]
Expand Down Expand Up @@ -185,7 +190,7 @@ impl std::fmt::Debug for ParamsPrinter<'_> {
/// ## Example
///
#[cfg_attr(
feature = "digest",
feature = "digest-scheme",
doc = r##"
```rust
use http_auth::PasswordClient;
Expand Down