//! API key authentication.
//!
//! The middleware depends only on [`ServerConfig`], not on the full handler
//! state, so it can be exercised without a Docker daemon.
use std::sync::Arc;
use axum::extract::{Request, State};
use axum::middleware::Next;
use axum::response::Response;
use twice_core::API_KEY_HEADER;
use crate::config::ServerConfig;
use crate::error::ApiError;
/// Rejects any request that does not present the configured API key, either as
/// `x-api-key: <key>` or `Authorization: Bearer <key>`.
pub(crate) async fn require_api_key(
State(config): State<Arc<ServerConfig>>,
request: Request,
next: Next,
) -> Result<Response, ApiError> {
let headers = request.headers();
let presented = headers
.get(API_KEY_HEADER)
.and_then(|value| value.to_str().ok())
.or_else(|| {
headers
.get(axum::http::header::AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.strip_prefix("Bearer "))
})
.ok_or(ApiError::Unauthorized)?;
if config.api_key.matches(presented) {
Ok(next.run(request).await)
} else {
Err(ApiError::Unauthorized)
}
}
#[cfg(test)]
mod tests {
use std::net::SocketAddr;
use std::path::PathBuf;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use axum::routing::get;
use axum::{Router, middleware};
use tower::ServiceExt as _;
use super::*;
use crate::config::ApiKey;
fn guarded_router(config: Arc<ServerConfig>) -> Router {
Router::new()
.route("/apps", get(|| async { "ok" }))
.route_layer(middleware::from_fn_with_state(config, require_api_key))
}
fn config() -> Arc<ServerConfig> {
Arc::new(ServerConfig {
bind: SocketAddr::from(([127, 0, 0, 1], 7373)),
api_key: ApiKey::generate(),
once_binary: PathBuf::from("/usr/bin/once"),
namespace: "once".to_owned(),
})
}
async fn status_for(header: Option<(&str, &str)>) -> StatusCode {
let config = config();
let mut request = Request::builder().uri("/apps");
if let Some((name, value)) = header {
let value = value.replace("SECRET", config.api_key.reveal());
request = request.header(name, value);
}
let request = request.body(Body::empty()).unwrap();
guarded_router(config)
.oneshot(request)
.await
.unwrap()
.status()
}
#[tokio::test]
async fn a_request_without_a_key_is_rejected() {
assert_eq!(status_for(None).await, StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn a_request_with_the_wrong_key_is_rejected() {
assert_eq!(
status_for(Some(("x-api-key", "definitely-not-the-key"))).await,
StatusCode::UNAUTHORIZED
);
}
#[tokio::test]
async fn a_bearer_token_that_is_not_the_key_is_rejected() {
assert_eq!(
status_for(Some((
"authorization",
"Bearer 0000000000000000000000000000000000000000000000000000000000000000"
)))
.await,
StatusCode::UNAUTHORIZED
);
}
#[tokio::test]
async fn the_configured_key_is_accepted_in_the_x_api_key_header() {
assert_eq!(
status_for(Some(("x-api-key", "SECRET"))).await,
StatusCode::OK
);
}
#[tokio::test]
async fn the_configured_key_is_accepted_as_a_bearer_token() {
assert_eq!(
status_for(Some(("authorization", "Bearer SECRET"))).await,
StatusCode::OK
);
}
}