use std::fmt;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use axum::body::Body;
use axum::extract::Request;
use axum::http::header::{HeaderValue, AUTHORIZATION, WWW_AUTHENTICATE};
use axum::http::request::Parts;
use axum::http::StatusCode;
use axum::middleware::Next;
use axum::response::Response;
use rmcp::service::RequestContext;
use rmcp::RoleServer;
use subtle::ConstantTimeEq as _;
use crate::config::Transport;
pub const WRITE_TOKEN_VAR: &str = "BUGWARDEN_HTTP_TOKEN";
pub const READ_TOKEN_VAR: &str = "BUGWARDEN_HTTP_READ_TOKEN";
const MIN_TOKEN_LEN: usize = 32;
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)]
pub enum Scope {
Read,
Write,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum AuthMode {
Insecure,
Write,
Read,
WriteAndRead,
}
impl fmt::Display for AuthMode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::Insecure => "disabled",
Self::Write => "bearer, write scope only",
Self::Read => "bearer, read scope only",
Self::WriteAndRead => "bearer, write and read scopes",
})
}
}
#[derive(Default, Clone)]
pub struct HttpEnv {
pub write: Option<String>,
pub read: Option<String>,
}
impl HttpEnv {
#[must_use]
pub fn from_env() -> Self {
Self {
write: env_token(WRITE_TOKEN_VAR),
read: env_token(READ_TOKEN_VAR),
}
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.write.is_none() && self.read.is_none()
}
}
fn env_token(var: &str) -> Option<String> {
match std::env::var(var) {
Ok(value) if !value.is_empty() => Some(value),
_ => None,
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum TokenFlaw {
TooShort,
NotBearerSafe,
}
#[derive(Clone, PartialEq, Eq, Debug)]
pub enum AuthConfigError {
MissingCredential,
ContradictoryOptOut,
UnusableToken {
var: &'static str,
flaw: TokenFlaw,
},
IndistinguishableScopes,
}
impl fmt::Display for AuthConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingCredential => write!(
f,
"--transport http requires a bearer token: put one in {WRITE_TOKEN_VAR} for \
the write scope or {READ_TOKEN_VAR} for the read scope, or pass \
--insecure-no-auth to serve the endpoint to anyone who reaches it"
),
Self::ContradictoryOptOut => write!(
f,
"--insecure-no-auth conflicts with a configured bearer token \
({WRITE_TOKEN_VAR} / {READ_TOKEN_VAR}); keep one of the two"
),
Self::UnusableToken {
var,
flaw: TokenFlaw::TooShort,
} => write!(
f,
"{var} holds fewer than {MIN_TOKEN_LEN} characters; generate one with \
`openssl rand -hex 32`"
),
Self::UnusableToken {
var,
flaw: TokenFlaw::NotBearerSafe,
} => write!(
f,
"{var} holds a character a bearer credential cannot carry; use printable \
ASCII, and no spaces"
),
Self::IndistinguishableScopes => write!(
f,
"{WRITE_TOKEN_VAR} and {READ_TOKEN_VAR} hold identical values, which would \
make the two scopes indistinguishable"
),
}
}
}
impl std::error::Error for AuthConfigError {}
struct Token(String);
impl Token {
fn parse(var: &'static str, raw: &str) -> Result<Self, AuthConfigError> {
let flawed = |flaw| AuthConfigError::UnusableToken { var, flaw };
if !raw.bytes().all(|byte| byte.is_ascii_graphic()) {
return Err(flawed(TokenFlaw::NotBearerSafe));
}
if raw.len() < MIN_TOKEN_LEN {
return Err(flawed(TokenFlaw::TooShort));
}
Ok(Self(raw.to_owned()))
}
fn verifies(&self, candidate: &str) -> bool {
self.0.as_bytes().ct_eq(candidate.as_bytes()).into()
}
fn is(&self, other: &Self) -> bool {
self.0 == other.0
}
}
pub struct HttpAuth {
write: Option<Token>,
read: Option<Token>,
mode: AuthMode,
refusals: AtomicU64,
}
impl HttpAuth {
pub fn resolve(env: &HttpEnv, insecure: bool) -> Result<Self, AuthConfigError> {
let write = env
.write
.as_deref()
.map(|raw| Token::parse(WRITE_TOKEN_VAR, raw))
.transpose()?;
let read = env
.read
.as_deref()
.map(|raw| Token::parse(READ_TOKEN_VAR, raw))
.transpose()?;
if let (Some(write), Some(read)) = (&write, &read) {
if write.is(read) {
return Err(AuthConfigError::IndistinguishableScopes);
}
}
let mode = match (insecure, write.is_some(), read.is_some()) {
(true, false, false) => AuthMode::Insecure,
(true, _, _) => return Err(AuthConfigError::ContradictoryOptOut),
(false, false, false) => return Err(AuthConfigError::MissingCredential),
(false, true, true) => AuthMode::WriteAndRead,
(false, true, false) => AuthMode::Write,
(false, false, true) => AuthMode::Read,
};
Ok(Self {
write,
read,
mode,
refusals: AtomicU64::new(0),
})
}
#[must_use]
pub fn mode(&self) -> AuthMode {
self.mode
}
#[must_use]
pub fn is_insecure(&self) -> bool {
self.mode == AuthMode::Insecure
}
pub fn log_startup_mode(&self) {
match self.mode {
AuthMode::Insecure => tracing::warn!(
"--insecure-no-auth: the http endpoint is served WITHOUT authentication; \
every caller that can reach the port gets the full write scope"
),
mode => tracing::info!("http authentication: {mode}"),
}
}
fn credentials(&self) -> impl Iterator<Item = (Scope, &Token)> {
[
(Scope::Write, self.write.as_ref()),
(Scope::Read, self.read.as_ref()),
]
.into_iter()
.filter_map(|(scope, token)| token.map(|token| (scope, token)))
}
#[must_use]
pub fn scope_for(&self, header: Option<&str>) -> Option<Scope> {
let candidate = header.and_then(bearer_credential)?;
self.credentials()
.filter(|(_, token)| token.verifies(candidate))
.fold(None, |best, (scope, _)| best.max(Some(scope)))
}
fn note_refusal(&self) {
let total = self.refusals.fetch_add(1, Ordering::Relaxed) + 1;
if total.is_power_of_two() {
tracing::warn!(
refused = total,
"http bearer authentication refused a request"
);
}
}
}
fn bearer_credential(header: &str) -> Option<&str> {
let mut pieces = header.split_ascii_whitespace();
let scheme = pieces.next()?;
let credential = pieces.next()?;
match (scheme.eq_ignore_ascii_case("bearer"), pieces.next()) {
(true, None) => Some(credential),
_ => None,
}
}
pub fn resolve_for(
transport: Transport,
env: &HttpEnv,
insecure: bool,
) -> Result<Option<HttpAuth>, AuthConfigError> {
match transport {
Transport::Http => HttpAuth::resolve(env, insecure).map(Some),
Transport::Stdio => {
if !env.is_empty() || insecure {
tracing::debug!(
"http bearer settings are configured but the transport is stdio; ignoring"
);
}
Ok(None)
}
}
}
pub fn guard_router(router: axum::Router, auth: Arc<HttpAuth>) -> axum::Router {
router.layer(axum::middleware::from_fn(move |request, next| {
gate(Arc::clone(&auth), request, next)
}))
}
async fn gate(auth: Arc<HttpAuth>, mut request: Request, next: Next) -> Response {
if auth.is_insecure() {
return next.run(request).await;
}
match auth.scope_for(sole_authorization(&request)) {
Some(scope) => {
request.extensions_mut().insert(scope);
next.run(request).await
}
None => {
auth.note_refusal();
unauthorized()
}
}
}
fn sole_authorization(request: &Request) -> Option<&str> {
let mut values = request.headers().get_all(AUTHORIZATION).iter();
match (values.next(), values.next()) {
(Some(only), None) => only.to_str().ok(),
_ => None,
}
}
fn unauthorized() -> Response {
let mut response = Response::new(Body::empty());
*response.status_mut() = StatusCode::UNAUTHORIZED;
response
.headers_mut()
.insert(WWW_AUTHENTICATE, HeaderValue::from_static("Bearer"));
response
}
#[must_use]
pub fn scope_of(context: &RequestContext<RoleServer>) -> Option<Scope> {
let parts = context.extensions.get::<Parts>()?;
parts.extensions.get::<Scope>().copied()
}
#[cfg(test)]
mod tests {
use super::*;
const WRITE: &str = "0123456789abcdef0123456789abcdef";
const READ: &str = "fedcba9876543210fedcba9876543210";
fn env(write: Option<&str>, read: Option<&str>) -> HttpEnv {
HttpEnv {
write: write.map(ToOwned::to_owned),
read: read.map(ToOwned::to_owned),
}
}
fn unusable(var: &'static str, flaw: TokenFlaw) -> Option<AuthConfigError> {
Some(AuthConfigError::UnusableToken { var, flaw })
}
#[test]
fn http_without_any_token_refuses_to_start() {
assert_eq!(
HttpAuth::resolve(&HttpEnv::default(), false).err(),
Some(AuthConfigError::MissingCredential)
);
}
#[test]
fn insecure_with_a_token_refuses_to_start() {
for e in [env(Some(WRITE), None), env(None, Some(READ))] {
assert_eq!(
HttpAuth::resolve(&e, true).err(),
Some(AuthConfigError::ContradictoryOptOut)
);
}
}
#[test]
fn insecure_without_a_token_starts_with_authentication_off() {
let auth = HttpAuth::resolve(&HttpEnv::default(), true).expect("resolve");
assert!(auth.is_insecure());
assert_eq!(auth.mode(), AuthMode::Insecure);
assert_eq!(auth.scope_for(None), None);
}
#[test]
fn short_or_unprintable_tokens_refuse_to_start() {
let just_short = &WRITE[..MIN_TOKEN_LEN - 1];
assert_eq!(
HttpAuth::resolve(&env(Some(just_short), None), false).err(),
unusable(WRITE_TOKEN_VAR, TokenFlaw::TooShort)
);
assert!(HttpAuth::resolve(&env(Some(WRITE), None), false).is_ok());
assert_eq!(
HttpAuth::resolve(&env(None, Some(just_short)), false).err(),
unusable(READ_TOKEN_VAR, TokenFlaw::TooShort)
);
for bad in [
"0123456789abcdef 0123456789abcdef",
"0123456789abcdef\t0123456789abcdef",
"0123456789abcdef\n0123456789abcdef",
"0123456789abcdef\u{e9}123456789abcdef",
] {
assert_eq!(
HttpAuth::resolve(&env(Some(bad), None), false).err(),
unusable(WRITE_TOKEN_VAR, TokenFlaw::NotBearerSafe),
"{bad:?} must be refused"
);
}
}
#[test]
fn identical_tokens_refuse_to_start() {
assert_eq!(
HttpAuth::resolve(&env(Some(WRITE), Some(WRITE)), false).err(),
Some(AuthConfigError::IndistinguishableScopes)
);
}
#[test]
fn either_token_may_be_set_alone() {
assert_eq!(
HttpAuth::resolve(&env(Some(WRITE), None), false)
.expect("write alone")
.mode(),
AuthMode::Write
);
assert_eq!(
HttpAuth::resolve(&env(None, Some(READ)), false)
.expect("read alone")
.mode(),
AuthMode::Read
);
assert_eq!(
HttpAuth::resolve(&env(Some(WRITE), Some(READ)), false)
.expect("both")
.mode(),
AuthMode::WriteAndRead
);
}
#[test]
fn each_token_grants_exactly_its_own_scope() {
let auth = HttpAuth::resolve(&env(Some(WRITE), Some(READ)), false).expect("resolve");
assert_eq!(
auth.scope_for(Some(&format!("Bearer {WRITE}"))),
Some(Scope::Write)
);
assert_eq!(
auth.scope_for(Some(&format!("Bearer {READ}"))),
Some(Scope::Read)
);
assert_eq!(
auth.scope_for(Some(&format!("bEaReR {WRITE}"))),
Some(Scope::Write)
);
assert_eq!(
auth.scope_for(Some(&format!("Bearer {}", WRITE.to_uppercase()))),
None
);
}
#[test]
fn a_read_token_alone_never_grants_write() {
let auth = HttpAuth::resolve(&env(None, Some(READ)), false).expect("resolve");
assert_eq!(
auth.scope_for(Some(&format!("Bearer {READ}"))),
Some(Scope::Read)
);
assert_eq!(auth.scope_for(Some(&format!("Bearer {WRITE}"))), None);
assert_eq!(auth.scope_for(Some("Bearer ")), None);
}
#[test]
fn missing_or_malformed_headers_grant_nothing() {
let auth = HttpAuth::resolve(&env(Some(WRITE), Some(READ)), false).expect("resolve");
for header in [
None,
Some(""),
Some("Bearer"),
Some("Bearer "),
Some(WRITE),
Some("Basic dXNlcjpwYXNz"),
Some("Bearer 0123456789abcdef0123456789abcde"),
Some("Bearer 0123456789abcdef0123456789abcdef0"),
Some("Bearer 0123456789abcdef0123456789abcdef extra"),
] {
assert_eq!(
auth.scope_for(header),
None,
"{header:?} must not authorize"
);
}
}
fn body_of(src: &'static str, signature: &str) -> String {
let tail = src
.split(signature)
.nth(1)
.unwrap_or_else(|| panic!("{signature} must exist"));
let end = tail.find("\n }").unwrap_or(0);
tail[..end]
.lines()
.filter(|line| !line.trim_start().starts_with("//"))
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn token_comparison_is_constant_time_and_unconditional() {
let src = include_str!("http_auth.rs");
let verifies = body_of(src, "fn verifies(&self, candidate: &str) -> bool {");
assert!(
verifies.contains("ct_eq"),
"the comparison must use ct_eq: {verifies}"
);
assert!(
!verifies.contains("=="),
"the comparison must not use ==: {verifies}"
);
let scope_for = body_of(src, "pub fn scope_for(&self, header: Option<&str>)");
assert!(
scope_for.contains("let candidate"),
"scope_for must extract a credential first: {scope_for}"
);
let after_extraction = scope_for
.lines()
.skip_while(|line| !line.contains("let candidate"))
.skip(1)
.collect::<Vec<_>>()
.join("\n");
assert!(
after_extraction.contains(".fold("),
"scope_for must fold over every credential: {after_extraction}"
);
assert_eq!(
after_extraction.matches("verifies(").count(),
1,
"one comparison site, applied to every credential: {after_extraction}"
);
for early_exit in ["return", "break", "if ", "else", "?"] {
assert!(
!after_extraction.contains(early_exit),
"no {early_exit:?} may cut the comparison short: {after_extraction}"
);
}
}
#[test]
fn stdio_ignores_tokens_and_the_insecure_flag() {
for (e, insecure) in [
(HttpEnv::default(), false),
(env(Some("short"), None), false),
(env(Some(WRITE), Some(WRITE)), false),
(env(Some(WRITE), None), true),
(env(Some("not printable at all"), None), false),
] {
let gate = resolve_for(Transport::Stdio, &e, insecure).expect("stdio never refuses");
assert!(gate.is_none(), "stdio must resolve no bearer gate");
}
assert!(resolve_for(Transport::Http, &HttpEnv::default(), false).is_err());
}
#[test]
fn refusal_logging_is_bounded_and_names_no_material() {
let auth = HttpAuth::resolve(&env(Some(WRITE), None), false).expect("resolve");
let ((), logs) = crate::testlog::capture_logs(|| {
for _ in 0..5 {
auth.note_refusal();
}
});
let text = logs.as_str();
assert_eq!(
text.matches("http bearer authentication refused a request")
.count(),
3,
"{text}"
);
crate::testlog::assert_logged(&logs, "refused=4");
logs.assert_not_contains(WRITE);
}
#[test]
fn the_startup_mode_line_never_carries_token_material() {
for (e, insecure) in [
(env(Some(WRITE), Some(READ)), false),
(env(Some(WRITE), None), false),
(env(None, Some(READ)), false),
(HttpEnv::default(), true),
] {
let auth = HttpAuth::resolve(&e, insecure).expect("resolve");
let ((), logs) = crate::testlog::capture_logs(|| auth.log_startup_mode());
let text = logs.as_str();
assert!(
text.contains("http authentication:") || text.contains("--insecure-no-auth"),
"{text}"
);
logs.assert_not_contains(WRITE);
logs.assert_not_contains(READ);
}
}
#[test]
fn config_errors_name_the_variable_and_never_the_token() {
let secret = "SUPERSECRETTOKENSUPERSECRETTOKEN";
for e in [
AuthConfigError::UnusableToken {
var: WRITE_TOKEN_VAR,
flaw: TokenFlaw::TooShort,
},
AuthConfigError::UnusableToken {
var: READ_TOKEN_VAR,
flaw: TokenFlaw::NotBearerSafe,
},
AuthConfigError::MissingCredential,
AuthConfigError::ContradictoryOptOut,
AuthConfigError::IndistinguishableScopes,
] {
let msg = e.to_string();
assert!(
msg.contains("BUGWARDEN_HTTP_"),
"the message must name a variable: {msg}"
);
assert!(!msg.contains(secret), "{msg}");
}
}
}