Skip to content
//! 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
        );
    }
}