use std::sync::Arc;
use std::time::SystemTime;
use axum::body::Body;
use axum::extract::{Request, State};
use axum::http::header::{AUTHORIZATION, CONTENT_LENGTH};
use axum::http::HeaderValue;
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use axum::Router;
use crate::error::ApiError;
use crate::keys::{parse_bearer, KeyStore};
use crate::metering::Metering;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct KeyId(pub String);
impl KeyId {
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for KeyId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
pub const MAX_RESPONSE_BYTES: usize = 64 * 1024 * 1024;
#[derive(Debug, Clone)]
pub struct AuthMetering {
keys: Arc<KeyStore>,
metering: Arc<Metering>,
}
impl AuthMetering {
pub fn new(keys: Arc<KeyStore>, metering: Arc<Metering>) -> Self {
AuthMetering { keys, metering }
}
}
pub fn metered<S>(router: Router<S>, auth: Arc<AuthMetering>) -> Router<S>
where
S: Clone + Send + Sync + 'static,
{
router.route_layer(axum::middleware::from_fn_with_state(auth, auth_and_meter))
}
async fn auth_and_meter(
State(auth): State<Arc<AuthMetering>>,
mut request: Request,
next: Next,
) -> Response {
let started = SystemTime::now();
let method = request.method().as_str().to_string();
let path = request.uri().path().to_string();
let presented = parse_bearer(
request
.headers()
.get(AUTHORIZATION)
.and_then(|value| value.to_str().ok()),
);
let Some((key_id, secret)) = presented else {
return unauthorized();
};
let key_id = match auth.keys.verify(key_id, secret) {
Ok(key_id) => key_id,
Err(_) => return unauthorized(),
};
request.extensions_mut().insert(KeyId(key_id.clone()));
let response = next.run(request).await;
measure_and_meter(
auth.metering.as_ref(),
&key_id,
&method,
path,
response,
started,
)
.await
}
fn unauthorized() -> Response {
let mut response = ApiError::Unauthorized.into_response();
response.headers_mut().insert(
axum::http::header::WWW_AUTHENTICATE,
HeaderValue::from_static("Bearer realm=\"codetopo\""),
);
response
}
async fn measure_and_meter(
metering: &Metering,
key_id: &str,
method: &str,
path: String,
response: Response,
started: SystemTime,
) -> Response {
let status = response.status().as_u16();
let (parts, body) = response.into_parts();
let (body, response_bytes) = match declared_length(&parts) {
Some(len) if len > MAX_RESPONSE_BYTES => (body, len as u64),
_ => match axum::body::to_bytes(body, MAX_RESPONSE_BYTES).await {
Ok(bytes) => {
let len = bytes.len() as u64;
(Body::from(bytes), len)
}
Err(err) => {
eprintln!("warning: response body exceeded the metering buffer cap: {err}");
return ApiError::Internal(format!(
"response exceeded the {MAX_RESPONSE_BYTES}-byte metering buffer limit"
))
.into_response();
}
},
};
let record = metering.build(key_id, method, &path, status, response_bytes, started);
metering.append(&record);
Response::from_parts(parts, body)
}
fn declared_length(parts: &axum::http::response::Parts) -> Option<usize> {
parts
.headers
.get(CONTENT_LENGTH)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.parse().ok())
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::HeaderMap;
fn auth(key: &str) -> (Arc<AuthMetering>, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("temp dir");
let keys = KeyStore::parse(&format!("{key}:s3cr3t\n"));
let metering = Arc::new(Metering::new(&dir.path().join("metering.log")));
(Arc::new(AuthMetering::new(Arc::new(keys), metering)), dir)
}
fn bearer(value: &str) -> HeaderMap {
let mut headers = HeaderMap::new();
headers.insert(AUTHORIZATION, value.parse().expect("header value"));
headers
}
fn resolve(auth: &AuthMetering, headers: &HeaderMap) -> Result<String, ApiError> {
let Some((key_id, secret)) = parse_bearer(
headers
.get(AUTHORIZATION)
.and_then(|value| value.to_str().ok()),
) else {
return Err(ApiError::Unauthorized);
};
auth.keys.verify(key_id, secret)
}
#[test]
fn key_id_exposes_identifier_not_secret() {
let key_id = KeyId("research-agent".to_string());
assert_eq!(key_id.as_str(), "research-agent");
assert_eq!(key_id.to_string(), "research-agent");
}
#[test]
fn correct_credential_authenticates() {
let (auth, _dir) = auth("agent");
assert_eq!(
resolve(&auth, &bearer("Bearer agent:s3cr3t")).unwrap(),
"agent"
);
}
#[test]
fn scheme_is_case_insensitive() {
let (auth, _dir) = auth("agent");
assert_eq!(
resolve(&auth, &bearer("bearer agent:s3cr3t")).unwrap(),
"agent"
);
}
#[test]
fn wrong_secret_is_unauthorized() {
let (auth, _dir) = auth("agent");
assert!(matches!(
resolve(&auth, &bearer("Bearer agent:wrong")),
Err(ApiError::Unauthorized)
));
}
#[test]
fn unknown_key_is_unauthorized() {
let (auth, _dir) = auth("agent");
assert!(matches!(
resolve(&auth, &bearer("Bearer nobody:s3cr3t")),
Err(ApiError::Unauthorized)
));
}
#[test]
fn missing_header_is_unauthorized() {
let (auth, _dir) = auth("agent");
assert!(matches!(
resolve(&auth, &HeaderMap::new()),
Err(ApiError::Unauthorized)
));
}
#[test]
fn wrong_scheme_is_unauthorized() {
let (auth, _dir) = auth("agent");
assert!(matches!(
resolve(&auth, &bearer("Basic agent:s3cr3t")),
Err(ApiError::Unauthorized)
));
}
#[test]
fn token_without_colon_is_unauthorized() {
let (auth, _dir) = auth("agent");
assert!(matches!(
resolve(&auth, &bearer("Bearer agentonly")),
Err(ApiError::Unauthorized)
));
}
#[test]
fn declared_length_reads_content_length() {
let response = Response::builder()
.header(CONTENT_LENGTH, "42")
.body(Body::empty())
.unwrap();
let (parts, _body) = response.into_parts();
assert_eq!(declared_length(&parts), Some(42));
}
#[test]
fn declared_length_absent_when_undeclared() {
let (parts, _body) = Response::new(Body::empty()).into_parts();
assert_eq!(declared_length(&parts), None);
}
#[test]
fn metering_records_the_served_body() {
let (auth, dir) = auth("agent");
let response = Response::builder()
.status(200)
.header(CONTENT_LENGTH, "5")
.body(Body::from("hello"))
.unwrap();
let out = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(measure_and_meter(
auth.metering.as_ref(),
"agent",
"GET",
"/v1/graphs/x/stats".to_string(),
response,
SystemTime::now(),
));
assert_eq!(out.status(), 200);
let log = std::fs::read_to_string(dir.path().join("metering.log")).unwrap();
let record: serde_json::Value = serde_json::from_str(log.trim()).unwrap();
assert_eq!(record["key_id"], "agent");
assert_eq!(record["method"], "GET");
assert_eq!(record["path"], "/v1/graphs/x/stats");
assert_eq!(record["status"], 200);
assert_eq!(record["response_bytes"], 5);
}
}