use std::collections::{HashMap, HashSet};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use axum::extract::{Request, State};
use axum::http::{StatusCode, header::AUTHORIZATION};
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use crate::daemon_token::{credentials_match, mint_token};
pub const TICKET_QUERY_PARAM: &str = "ticket";
pub const TICKET_TTL: Duration = Duration::from_secs(30);
#[derive(Clone, Copy, Debug)]
pub struct Authenticated;
#[derive(Clone)]
pub struct DaemonAuth(Arc<Inner>);
struct Inner {
token: String,
public_paths: HashSet<String>,
tickets: Mutex<HashMap<String, Ticket>>,
}
struct Ticket {
path: String,
issued: Instant,
}
#[derive(Debug, thiserror::Error)]
#[error(
"daemon credential is {got} characters; at least {} are required",
crate::daemon_token::MIN_TOKEN_LEN
)]
pub struct WeakCredential {
pub got: usize,
}
impl DaemonAuth {
pub fn new<I, S>(token: impl Into<String>, public_paths: I) -> Result<Self, WeakCredential>
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
let token = token.into();
if token.len() < crate::daemon_token::MIN_TOKEN_LEN {
return Err(WeakCredential { got: token.len() });
}
Ok(Self(Arc::new(Inner {
token,
public_paths: public_paths.into_iter().map(Into::into).collect(),
tickets: Mutex::new(HashMap::new()),
})))
}
pub fn issue_ticket(&self, path: impl Into<String>) -> String {
let ticket = mint_token();
if let Ok(mut tickets) = self.0.tickets.lock() {
let now = Instant::now();
tickets.retain(|_, t| now.duration_since(t.issued) < TICKET_TTL);
tickets.insert(
ticket.clone(),
Ticket {
path: path.into(),
issued: now,
},
);
}
ticket
}
fn consume_ticket(&self, ticket: &str, method: &axum::http::Method, path: &str) -> bool {
let Ok(mut tickets) = self.0.tickets.lock() else {
return false;
};
match tickets.remove(ticket) {
Some(t) => {
method == axum::http::Method::GET
&& t.path == path
&& Instant::now().duration_since(t.issued) < TICKET_TTL
}
None => false,
}
}
fn header_is_valid(&self, header: Option<&axum::http::HeaderValue>) -> bool {
let Some(value) = header.and_then(|h| h.to_str().ok()) else {
return false;
};
let Some((scheme, presented)) = value.split_once(' ') else {
return false;
};
scheme.eq_ignore_ascii_case("Bearer") && credentials_match(&self.0.token, presented.trim())
}
fn ticket_in_query(query: Option<&str>) -> Option<&str> {
query?.split('&').find_map(|pair| {
let (key, value) = pair.split_once('=')?;
(key == TICKET_QUERY_PARAM).then_some(value)
})
}
}
pub async fn require_bearer(
State(auth): State<DaemonAuth>,
mut req: Request,
next: Next,
) -> Response {
let authenticated = auth.header_is_valid(req.headers().get(AUTHORIZATION)) || {
let (method, path) = (req.method().clone(), req.uri().path().to_string());
DaemonAuth::ticket_in_query(req.uri().query())
.is_some_and(|ticket| auth.consume_ticket(ticket, &method, &path))
};
if authenticated {
req.extensions_mut().insert(Authenticated);
return next.run(req).await;
}
if auth.0.public_paths.contains(req.uri().path()) {
return next.run(req).await;
}
(
StatusCode::UNAUTHORIZED,
[(axum::http::header::WWW_AUTHENTICATE, "Bearer")],
)
.into_response()
}
#[cfg(test)]
mod bearer_auth_tests {
use super::*;
use axum::{Extension, Router, body::Body, routing::get};
use tower::util::ServiceExt;
async fn marker_handler(auth: Option<Extension<Authenticated>>) -> &'static str {
if auth.is_some() { "authed" } else { "anon" }
}
fn router(auth: DaemonAuth) -> Router {
Router::new()
.route("/private", get(marker_handler))
.route("/health", get(marker_handler))
.layer(axum::middleware::from_fn_with_state(auth, require_bearer))
}
fn guarded(token: &str) -> Router {
router(DaemonAuth::new(token, ["/health"]).expect("test token clears the floor"))
}
async fn get_with(app: Router, uri: &str, header: Option<&str>) -> (StatusCode, String) {
let mut req = Request::builder().uri(uri);
if let Some(value) = header {
req = req.header(AUTHORIZATION, value);
}
let resp = app
.oneshot(req.body(Body::empty()).expect("build request"))
.await
.expect("router response");
let status = resp.status();
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.expect("read body");
(status, String::from_utf8_lossy(&bytes).to_string())
}
#[tokio::test]
async fn missing_credential_is_rejected() {
let (status, body) = get_with(guarded(&mint_token()), "/private", None).await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
assert!(body.is_empty(), "401 body must disclose nothing: {body:?}");
}
#[tokio::test]
async fn correct_credential_is_accepted_and_marked() {
let token = mint_token();
let (status, body) = get_with(
guarded(&token),
"/private",
Some(&format!("Bearer {token}")),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body, "authed");
}
#[tokio::test]
async fn malformed_and_wrong_credentials_are_rejected() {
let token = mint_token();
for header in [
format!("Bearer {}", mint_token()),
token.clone(),
format!("Basic {token}"),
"Bearer".to_string(),
format!("Bearer {token} extra"),
String::new(),
] {
let (status, _) = get_with(guarded(&token), "/private", Some(&header)).await;
assert_eq!(
status,
StatusCode::UNAUTHORIZED,
"header {header:?} must not authenticate"
);
}
}
#[tokio::test]
async fn bearer_scheme_is_case_insensitive() {
let token = mint_token();
let (status, _) = get_with(
guarded(&token),
"/private",
Some(&format!("bearer {token}")),
)
.await;
assert_eq!(status, StatusCode::OK);
}
#[tokio::test]
async fn public_path_passes_without_a_credential() {
let (status, body) = get_with(guarded(&mint_token()), "/health", None).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body, "anon");
}
#[tokio::test]
async fn public_path_with_a_credential_is_marked() {
let token = mint_token();
let (status, body) =
get_with(guarded(&token), "/health", Some(&format!("Bearer {token}"))).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body, "authed");
}
#[tokio::test]
async fn public_path_match_is_exact_not_prefix() {
let auth = DaemonAuth::new(mint_token(), ["/priv"]).expect("mint_token clears the floor");
let (status, _) = get_with(router(auth), "/private", None).await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn unknown_ticket_is_rejected() {
let app = guarded(&mint_token());
let (status, _) = get_with(app, "/private?ticket=nope", None).await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn ticket_authenticates_once_then_is_spent() {
let auth = DaemonAuth::new(mint_token(), ["/health"]).expect("mint_token clears the floor");
let ticket = auth.issue_ticket("/private");
let uri = format!("/private?{TICKET_QUERY_PARAM}={ticket}");
let (status, body) = get_with(router(auth.clone()), &uri, None).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body, "authed");
let (status, _) = get_with(router(auth), &uri, None).await;
assert_eq!(status, StatusCode::UNAUTHORIZED, "replay must fail");
}
#[tokio::test]
async fn a_ticket_opens_only_the_path_it_was_issued_for() {
let auth = DaemonAuth::new(mint_token(), ["/health"]).expect("mint_token clears the floor");
let ticket = auth.issue_ticket("/health");
let (status, _) = get_with(
router(auth.clone()),
&format!("/private?{TICKET_QUERY_PARAM}={ticket}"),
None,
)
.await;
assert_eq!(
status,
StatusCode::UNAUTHORIZED,
"a ticket for /health must not authenticate /private"
);
assert!(
!auth.consume_ticket(&ticket, &axum::http::Method::GET, "/health"),
"a ticket presented on the wrong route must not survive it"
);
}
#[test]
fn a_ticket_does_not_authenticate_a_non_get_method() {
let auth = DaemonAuth::new(mint_token(), Vec::<String>::new())
.expect("mint_token clears the floor");
let ticket = auth.issue_ticket("/rpc");
assert!(!auth.consume_ticket(&ticket, &axum::http::Method::POST, "/rpc"));
}
#[tokio::test]
async fn ticket_table_is_shared_across_clones() {
let auth = DaemonAuth::new(mint_token(), Vec::<String>::new())
.expect("mint_token clears the floor");
let ticket = auth.clone().issue_ticket("/events");
assert!(auth.consume_ticket(&ticket, &axum::http::Method::GET, "/events"));
}
#[test]
fn expired_ticket_is_rejected() {
let auth = DaemonAuth::new(mint_token(), Vec::<String>::new())
.expect("mint_token clears the floor");
let stale = mint_token();
let Some(issued) = Instant::now().checked_sub(TICKET_TTL + Duration::from_secs(1)) else {
return;
};
if let Ok(mut tickets) = auth.0.tickets.lock() {
tickets.insert(
stale.clone(),
Ticket {
path: "/events".to_string(),
issued,
},
);
}
assert!(
!auth.consume_ticket(&stale, &axum::http::Method::GET, "/events"),
"an expired ticket must fail"
);
}
#[test]
fn a_weak_token_is_refused_at_construction() {
for weak in ["", " ", "short", &"a".repeat(31)] {
let err = DaemonAuth::new(weak, ["/health"])
.err()
.unwrap_or_else(|| panic!("{weak:?} must be refused"));
assert_eq!(err.got, weak.len());
}
let err = DaemonAuth::new("sekrit-and-distinctive", ["/health"])
.err()
.expect("refused");
assert!(
!err.to_string().contains("sekrit"),
"the error must not echo the credential: {err}"
);
assert!(DaemonAuth::new("a".repeat(32), ["/health"]).is_ok());
}
#[test]
fn ticket_in_query_reads_only_the_named_parameter() {
assert_eq!(DaemonAuth::ticket_in_query(None), None);
assert_eq!(DaemonAuth::ticket_in_query(Some("")), None);
assert_eq!(DaemonAuth::ticket_in_query(Some("other=1")), None);
assert_eq!(DaemonAuth::ticket_in_query(Some("myticket=1")), None);
assert_eq!(DaemonAuth::ticket_in_query(Some("ticket=abc")), Some("abc"));
assert_eq!(
DaemonAuth::ticket_in_query(Some("a=1&ticket=abc&b=2")),
Some("abc")
);
}
}