use std::collections::HashMap;
use std::fmt;
#[derive(Clone, Default)]
pub enum AuthIdentity {
#[default]
Anonymous,
Authenticated {
owner: String,
claims: Option<serde_json::Value>,
},
}
impl AuthIdentity {
pub fn is_authenticated(&self) -> bool {
matches!(self, Self::Authenticated { .. })
}
pub fn owner(&self) -> &str {
match self {
Self::Anonymous => "anonymous",
Self::Authenticated { owner, .. } => owner,
}
}
pub fn claims(&self) -> Option<&serde_json::Value> {
match self {
Self::Anonymous => None,
Self::Authenticated { claims, .. } => claims.as_ref(),
}
}
}
impl fmt::Debug for AuthIdentity {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Anonymous => write!(f, "Anonymous"),
Self::Authenticated { owner, claims } => f
.debug_struct("Authenticated")
.field("owner", owner)
.field(
"claims",
&match claims {
Some(_) => "<redacted>",
None => "<none>",
},
)
.finish(),
}
}
}
#[derive(Clone)]
pub struct RequestContext {
pub bearer_token: Option<String>,
pub headers: http::HeaderMap,
pub identity: AuthIdentity,
pub extensions: HashMap<String, serde_json::Value>,
}
impl RequestContext {
pub fn new() -> Self {
Self {
bearer_token: None,
headers: http::HeaderMap::new(),
identity: AuthIdentity::default(),
extensions: HashMap::new(),
}
}
}
impl Default for RequestContext {
fn default() -> Self {
Self::new()
}
}
impl fmt::Debug for RequestContext {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
struct Redacted;
impl fmt::Debug for Redacted {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "<redacted>")
}
}
struct HeadersDbg<'a>(&'a http::HeaderMap);
impl<'a> fmt::Debug for HeadersDbg<'a> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut m = f.debug_map();
for (name, value) in self.0.iter() {
if is_allowlisted_header(name.as_str()) {
m.entry(&name.as_str(), &value.to_str().unwrap_or("<non-utf8>"));
} else {
m.entry(&name.as_str(), &"<redacted>");
}
}
m.finish()
}
}
struct ExtensionsDbg<'a>(&'a HashMap<String, serde_json::Value>);
impl<'a> fmt::Debug for ExtensionsDbg<'a> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut m = f.debug_map();
for key in self.0.keys() {
m.entry(key, &"<redacted>");
}
m.finish()
}
}
let token_field: &dyn fmt::Debug = match &self.bearer_token {
Some(_) => &Some(Redacted),
None => &None::<Redacted>,
};
f.debug_struct("RequestContext")
.field("bearer_token", token_field)
.field("headers", &HeadersDbg(&self.headers))
.field("identity", &self.identity)
.field("extensions", &ExtensionsDbg(&self.extensions))
.finish()
}
}
fn is_allowlisted_header(name: &str) -> bool {
const ALLOWLIST: &[&str] = &[
"content-type",
"content-length",
"accept",
"user-agent",
"host",
];
let lower = name.to_ascii_lowercase();
ALLOWLIST.iter().any(|h| *h == lower)
}
#[cfg(test)]
mod tests {
use super::*;
use http::{HeaderMap, HeaderValue};
#[test]
fn anonymous_debug_is_plain_string() {
let s = format!("{:?}", AuthIdentity::Anonymous);
assert_eq!(s, "Anonymous");
}
#[test]
fn authenticated_debug_shows_owner_but_redacts_claims() {
let identity = AuthIdentity::Authenticated {
owner: "user-123".into(),
claims: Some(serde_json::json!({
"sub": "user-123",
"email": "secret@example.com",
"roles": ["admin"]
})),
};
let s = format!("{identity:?}");
assert!(s.contains("user-123"), "owner should be visible: {s}");
assert!(s.contains("<redacted>"), "claims should be redacted: {s}");
assert!(
!s.contains("secret@example.com"),
"email must not leak: {s}"
);
assert!(!s.contains("admin"), "role must not leak: {s}");
}
#[test]
fn authenticated_debug_with_no_claims_shows_none_marker() {
let identity = AuthIdentity::Authenticated {
owner: "u".into(),
claims: None,
};
let s = format!("{identity:?}");
assert!(s.contains("<none>"), "absent claims should be marked: {s}");
}
fn make_request_context() -> RequestContext {
let mut headers = HeaderMap::new();
headers.insert(
http::header::AUTHORIZATION,
HeaderValue::from_static("Bearer eyJabcdef.secret-jwt-body.signature"),
);
headers.insert("x-api-key", HeaderValue::from_static("super-secret-key"));
headers.insert(
http::header::CONTENT_TYPE,
HeaderValue::from_static("application/json"),
);
headers.insert(
http::header::COOKIE,
HeaderValue::from_static("session=sensitive"),
);
headers.insert("x-custom-auth", HeaderValue::from_static("custom-secret"));
let mut extensions = HashMap::new();
extensions.insert("api_key".to_string(), serde_json::json!("another-secret"));
RequestContext {
bearer_token: Some("eyJabcdef.secret-jwt-body.signature".into()),
headers,
identity: AuthIdentity::Authenticated {
owner: "user-123".into(),
claims: Some(serde_json::json!({"sub": "user-123"})),
},
extensions,
}
}
#[test]
fn request_context_debug_redacts_bearer_token() {
let s = format!("{:?}", make_request_context());
assert!(
!s.contains("secret-jwt-body"),
"bearer token must not leak: {s}"
);
assert!(!s.contains("eyJabcdef"), "bearer token must not leak: {s}");
}
#[test]
fn request_context_debug_redacts_sensitive_headers() {
let s = format!("{:?}", make_request_context());
assert!(
!s.contains("super-secret-key"),
"X-API-Key must not leak: {s}"
);
assert!(
!s.contains("session=sensitive"),
"Cookie must not leak: {s}"
);
assert!(
!s.contains("custom-secret"),
"adopter-defined credential must not leak: {s}"
);
}
#[test]
fn request_context_debug_shows_allowlisted_header_values() {
let s = format!("{:?}", make_request_context());
assert!(
s.contains("application/json"),
"Content-Type value should be visible: {s}"
);
}
#[test]
fn request_context_debug_shows_header_names() {
let s = format!("{:?}", make_request_context());
assert!(
s.contains("x-api-key"),
"header name should be visible: {s}"
);
assert!(
s.contains("x-custom-auth"),
"header name should be visible: {s}"
);
}
#[test]
fn request_context_debug_redacts_extension_values() {
let s = format!("{:?}", make_request_context());
assert!(
s.contains("api_key"),
"extension key should be visible: {s}"
);
assert!(
!s.contains("another-secret"),
"extension value must not leak: {s}"
);
}
#[test]
fn allowlist_is_case_insensitive() {
assert!(is_allowlisted_header("Content-Type"));
assert!(is_allowlisted_header("CONTENT-TYPE"));
assert!(is_allowlisted_header("content-type"));
assert!(!is_allowlisted_header("Authorization"));
}
}