use std::fmt;
use std::sync::Arc;
pub const REQUIRED_SENTINEL: &str = "required";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RequiredHeader {
pub name: String,
pub expected: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct HeaderAuthRules {
pub required_headers: Vec<RequiredHeader>,
pub auth_required: Option<String>,
pub api_key_required: bool,
pub authorization_header_required: Option<String>,
pub api_key_header: Option<String>,
}
impl HeaderAuthRules {
#[must_use]
pub const fn has_rules(&self) -> bool {
!self.required_headers.is_empty()
|| self.auth_required.is_some()
|| self.api_key_required
|| self.authorization_header_required.is_some()
}
}
pub type AuthVerifier = Arc<dyn Fn(&str) -> bool + Send + Sync>;
#[derive(Clone, Default)]
pub struct RouteVerifiers {
pub auth: Option<AuthVerifier>,
pub api_key: Option<AuthVerifier>,
}
impl fmt::Debug for RouteVerifiers {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("RouteVerifiers")
.field("auth", &self.auth.is_some())
.field("api_key", &self.api_key.is_some())
.finish()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GuardViolation {
MissingHeader,
MismatchedHeader,
AuthorizationHeader,
RequireAuth,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GuardBlock {
pub violation: GuardViolation,
pub status: u16,
pub body: String,
}
impl fmt::Display for GuardBlock {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} {}", self.status, self.body)
}
}
#[must_use]
pub fn extract_credential<'a>(auth_header: &'a str, auth_type: &str) -> Option<&'a str> {
match auth_type {
"bearer" => auth_header.strip_prefix("Bearer "),
"basic" => auth_header.strip_prefix("Basic "),
_ if auth_header.is_empty() => None,
_ => Some(auth_header),
}
}
#[must_use]
pub fn decide<L>(
rules: &HeaderAuthRules,
verifiers: &RouteVerifiers,
lookup: L,
) -> Option<GuardBlock>
where
L: Fn(&str) -> Option<String>,
{
required_headers_block(rules, &lookup)
.or_else(|| authentication_block(rules, verifiers, &lookup))
}
fn required_headers_block<L>(rules: &HeaderAuthRules, lookup: &L) -> Option<GuardBlock>
where
L: Fn(&str) -> Option<String>,
{
for rule in &rules.required_headers {
let actual = lookup(&rule.name).filter(|value| !value.is_empty());
let Some(actual) = actual else {
return Some(GuardBlock {
violation: GuardViolation::MissingHeader,
status: 400,
body: format!("Missing required header: {}", rule.name),
});
};
if rule.expected != REQUIRED_SENTINEL && actual != rule.expected {
return Some(GuardBlock {
violation: GuardViolation::MismatchedHeader,
status: 400,
body: format!("Header '{}' does not match the required value", rule.name),
});
}
}
None
}
fn authentication_block<L>(
rules: &HeaderAuthRules,
verifiers: &RouteVerifiers,
lookup: &L,
) -> Option<GuardBlock>
where
L: Fn(&str) -> Option<String>,
{
if let Some(scheme) = rules.authorization_header_required.as_deref() {
let auth_header = lookup("authorization").unwrap_or_default();
return extract_credential(&auth_header, scheme).map_or_else(
|| {
Some(GuardBlock {
violation: GuardViolation::AuthorizationHeader,
status: 401,
body: "Authentication required".to_owned(),
})
},
|_| None,
);
}
if let Some(scheme) = rules.auth_required.as_deref() {
let auth_header = lookup("authorization").unwrap_or_default();
let Some(credential) = extract_credential(&auth_header, scheme) else {
return Some(auth_failure(GuardViolation::RequireAuth));
};
return resolve_verifier(verifiers.auth.as_ref(), credential);
}
if rules.api_key_required {
let header = rules.api_key_header.as_deref().unwrap_or("");
let credential = lookup(header).filter(|value| !value.is_empty());
let Some(credential) = credential else {
return Some(auth_failure(GuardViolation::RequireAuth));
};
return resolve_verifier(verifiers.api_key.as_ref(), &credential);
}
None
}
fn resolve_verifier(verifier: Option<&AuthVerifier>, credential: &str) -> Option<GuardBlock> {
let Some(verifier) = verifier else {
return Some(auth_failure(GuardViolation::RequireAuth));
};
if verifier(credential) {
None
} else {
Some(auth_failure(GuardViolation::RequireAuth))
}
}
fn auth_failure(violation: GuardViolation) -> GuardBlock {
GuardBlock {
violation,
status: 401,
body: "Authentication required".to_owned(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn lookup_of<'h>(headers: &'h [(&'h str, &'h str)]) -> impl Fn(&str) -> Option<String> + 'h {
move |name: &str| {
headers
.iter()
.find(|(key, _)| key.eq_ignore_ascii_case(name))
.map(|(_, value)| (*value).to_owned())
}
}
fn bearer_rules(scheme: &str) -> HeaderAuthRules {
HeaderAuthRules {
auth_required: Some(scheme.to_owned()),
..HeaderAuthRules::default()
}
}
#[test]
fn empty_rules_pass_everything() {
let rules = HeaderAuthRules::default();
assert!(!rules.has_rules());
assert!(decide(&rules, &RouteVerifiers::default(), |_| None).is_none());
}
#[test]
fn has_rules_tracks_each_field() {
let rules = HeaderAuthRules {
api_key_required: true,
..HeaderAuthRules::default()
};
assert!(rules.has_rules());
let rules = HeaderAuthRules {
authorization_header_required: Some("bearer".to_owned()),
..HeaderAuthRules::default()
};
assert!(rules.has_rules());
}
#[test]
fn required_header_missing_or_empty_is_a_dynamic_400() {
let rules = HeaderAuthRules {
required_headers: vec![RequiredHeader {
name: "X-Request-ID".to_owned(),
expected: REQUIRED_SENTINEL.to_owned(),
}],
..HeaderAuthRules::default()
};
let block = decide(&rules, &RouteVerifiers::default(), |_| None).expect("missing");
assert_eq!(block.violation, GuardViolation::MissingHeader);
assert_eq!(block.status, 400);
assert_eq!(block.body, "Missing required header: X-Request-ID");
assert_eq!(
block.to_string(),
"400 Missing required header: X-Request-ID"
);
let block = decide(
&rules,
&RouteVerifiers::default(),
lookup_of(&[("X-Request-ID", "")]),
)
.expect("empty");
assert_eq!(block.violation, GuardViolation::MissingHeader);
}
#[test]
fn required_header_lookup_is_case_insensitive_and_values_exact() {
let rules = HeaderAuthRules {
required_headers: vec![RequiredHeader {
name: "X-Role".to_owned(),
expected: "admin".to_owned(),
}],
..HeaderAuthRules::default()
};
assert!(
decide(
&rules,
&RouteVerifiers::default(),
lookup_of(&[("x-role", "admin")])
)
.is_none()
);
let block = decide(
&rules,
&RouteVerifiers::default(),
lookup_of(&[("X-ROLE", "Admin")]),
)
.expect("mismatch");
assert_eq!(block.violation, GuardViolation::MismatchedHeader);
assert_eq!(block.status, 400);
assert_eq!(
block.body,
"Header 'X-Role' does not match the required value"
);
}
#[test]
fn required_headers_report_the_first_failing_rule_in_order() {
let rules = HeaderAuthRules {
required_headers: vec![
RequiredHeader {
name: "A".to_owned(),
expected: REQUIRED_SENTINEL.to_owned(),
},
RequiredHeader {
name: "B".to_owned(),
expected: "yes".to_owned(),
},
RequiredHeader {
name: "C".to_owned(),
expected: REQUIRED_SENTINEL.to_owned(),
},
],
..HeaderAuthRules::default()
};
let lookup = lookup_of(&[("A", "1"), ("B", "no"), ("C", "")]);
let block = decide(&rules, &RouteVerifiers::default(), lookup).expect("B fails");
assert_eq!(block.body, "Header 'B' does not match the required value");
let lookup = lookup_of(&[("A", "1"), ("B", "yes")]);
let block = decide(&rules, &RouteVerifiers::default(), lookup).expect("C fails");
assert_eq!(block.body, "Missing required header: C");
}
#[test]
fn bearer_scheme_demands_the_exact_prefix() {
let verifiers = RouteVerifiers::default();
assert!(
decide(
&bearer_rules("bearer"),
&verifiers,
lookup_of(&[("authorization", "Bearer tok")])
)
.is_some()
);
let rules = bearer_rules("bearer");
let verifiers = RouteVerifiers {
auth: Some(Arc::new(|_| true)),
api_key: None,
};
assert!(
decide(
&rules,
&verifiers,
lookup_of(&[("authorization", "Bearer tok")])
)
.is_none()
);
for junk in ["bearer tok", "Bearer", "Basic tok", "tok"] {
assert!(
decide(&rules, &verifiers, lookup_of(&[("authorization", junk)])).is_some(),
"'{junk}' must fail the bearer presence check"
);
}
assert!(
decide(
&rules,
&verifiers,
lookup_of(&[("authorization", "Bearer ")])
)
.is_none()
);
assert!(decide(&rules, &verifiers, |_| None).is_some());
}
#[test]
fn basic_scheme_demands_the_exact_prefix() {
let rules = bearer_rules("basic");
let verifiers = RouteVerifiers {
auth: Some(Arc::new(|_| true)),
api_key: None,
};
assert!(
decide(
&rules,
&verifiers,
lookup_of(&[("authorization", "Basic dXNlcjpwdw==")])
)
.is_none()
);
assert!(
decide(
&rules,
&verifiers,
lookup_of(&[("authorization", "Bearer dXNlcjpwdw==")])
)
.is_some()
);
}
#[test]
fn other_schemes_carry_the_whole_header_value() {
let rules = bearer_rules("apikey");
let verifiers = RouteVerifiers {
auth: Some(Arc::new(|credential| credential == "raw-key")),
api_key: None,
};
assert!(
decide(
&rules,
&verifiers,
lookup_of(&[("authorization", "raw-key")])
)
.is_none()
);
assert!(
decide(
&rules,
&verifiers,
lookup_of(&[("authorization", "Bearer raw-key")])
)
.is_some()
);
assert!(decide(&rules, &verifiers, lookup_of(&[("authorization", "")])).is_some());
}
#[test]
fn verifier_outcomes_are_the_fixed_401_shape() {
let rules = bearer_rules("bearer");
let accept = RouteVerifiers {
auth: Some(Arc::new(|_| true)),
api_key: None,
};
let reject = RouteVerifiers {
auth: Some(Arc::new(|_| false)),
api_key: None,
};
assert!(decide(&rules, &accept, lookup_of(&[("authorization", "Bearer t")])).is_none());
let block =
decide(&rules, &reject, lookup_of(&[("authorization", "Bearer t")])).expect("rejected");
assert_eq!(block.violation, GuardViolation::RequireAuth);
assert_eq!(block.status, 401);
assert_eq!(block.body, "Authentication required");
let block = decide(
&rules,
&RouteVerifiers::default(),
lookup_of(&[("authorization", "Bearer t")]),
)
.expect("no verifier");
assert_eq!(block.status, 401);
assert_eq!(block.body, "Authentication required");
}
#[test]
fn presence_scheme_short_circuits_the_other_auth_paths() {
let rules = HeaderAuthRules {
auth_required: Some("bearer".to_owned()),
api_key_required: true,
api_key_header: Some("X-Key".to_owned()),
authorization_header_required: Some("basic".to_owned()),
..HeaderAuthRules::default()
};
let verifiers = RouteVerifiers::default();
assert!(
decide(
&rules,
&verifiers,
lookup_of(&[("authorization", "Basic ok")])
)
.is_none()
);
assert!(
decide(
&rules,
&verifiers,
lookup_of(&[("authorization", "Basic ")])
)
.is_none()
);
let block = decide(
&rules,
&verifiers,
lookup_of(&[("authorization", "bearer x")]),
)
.expect("presence");
assert_eq!(block.violation, GuardViolation::AuthorizationHeader);
}
#[test]
fn api_key_path_reads_its_header_and_verifier() {
let rules = HeaderAuthRules {
api_key_required: true,
api_key_header: Some("X-Key".to_owned()),
..HeaderAuthRules::default()
};
let verifiers = RouteVerifiers {
auth: None,
api_key: Some(Arc::new(|credential| credential == "k-1")),
};
assert!(decide(&rules, &verifiers, lookup_of(&[("X-Key", "k-1")])).is_none());
for headers in [
vec![],
vec![("X-Key", "")],
vec![("X-Key", "wrong")],
vec![("X-Other", "k-1")],
] {
let block = decide(&rules, &verifiers, lookup_of(&headers)).expect("api key failure");
assert_eq!(block.violation, GuardViolation::RequireAuth);
assert_eq!(block.status, 401);
}
let rules = HeaderAuthRules {
api_key_required: true,
..HeaderAuthRules::default()
};
let verifiers = RouteVerifiers {
api_key: Some(Arc::new(|_| true)),
auth: None,
};
assert!(decide(&rules, &verifiers, lookup_of(&[("X-Key", "k-1")])).is_some());
}
#[test]
fn required_headers_run_before_authentication() {
let rules = HeaderAuthRules {
required_headers: vec![RequiredHeader {
name: "X-Request-ID".to_owned(),
expected: REQUIRED_SENTINEL.to_owned(),
}],
auth_required: Some("bearer".to_owned()),
..HeaderAuthRules::default()
};
let verifiers = RouteVerifiers::default();
let block = decide(&rules, &verifiers, |_| None).expect("header first");
assert_eq!(block.status, 400);
assert_eq!(block.body, "Missing required header: X-Request-ID");
}
#[test]
fn auth_required_beats_api_key_when_both_set() {
let rules = HeaderAuthRules {
auth_required: Some("bearer".to_owned()),
api_key_required: true,
api_key_header: Some("X-Key".to_owned()),
..HeaderAuthRules::default()
};
let verifiers = RouteVerifiers {
auth: Some(Arc::new(|_| true)),
api_key: Some(Arc::new(|_| false)),
};
assert!(
decide(
&rules,
&verifiers,
lookup_of(&[("authorization", "Bearer t")])
)
.is_none()
);
}
#[test]
fn route_verifiers_debug_renders_the_verifier_presence() {
let verifiers = RouteVerifiers {
auth: None,
api_key: None,
};
assert!(format!("{verifiers:?}").contains("RouteVerifiers"));
}
}