rok-auth-basic 0.1.0

HTTP Basic authentication guard for the rok ecosystem
Documentation
use std::{
    future::Future,
    pin::Pin,
    sync::Arc,
    task::{Context, Poll},
};

use axum::{
    body::Body,
    http::{header, Request, Response, StatusCode},
};
use base64::{engine::general_purpose::STANDARD, Engine};
use tower::{Layer, Service};

use rok_auth::Claims;

// ── VerifyFn type alias ───────────────────────────────────────────────────────

/// A boxed async function that maps `(username, password)` to `Option<Claims>`.
///
/// Return `Some(claims)` to allow the request through; `None` to reject with
/// `401 Unauthorized`.
pub type VerifyFn = Arc<
    dyn Fn(String, String) -> Pin<Box<dyn Future<Output = Option<Claims>> + Send>> + Send + Sync,
>;

// ── BasicAuthLayer ────────────────────────────────────────────────────────────

/// Tower [`Layer`] that enforces HTTP Basic authentication on every request.
///
/// On success, the verified [`Claims`] are injected into request extensions so
/// the `Ctx` extractor in `rok-auth` can see the authenticated user without
/// re-verifying the credentials.
///
/// On failure the layer short-circuits and returns `401 Unauthorized` with a
/// `WWW-Authenticate: Basic realm="…"` challenge header.
///
/// # Example
///
/// ```rust,ignore
/// use rok_auth::Claims;
/// use rok_auth_basic::BasicAuthLayer;
///
/// let layer = BasicAuthLayer::new("My API", |user: String, pass: String| async move {
///     if user == "admin" && pass == std::env::var("API_PASS").unwrap_or_default() {
///         Some(Claims::new(user, vec!["admin"]))
///     } else {
///         None
///     }
/// });
///
/// let app = Router::new()
///     .route("/metrics", get(metrics_handler))
///     .layer(layer);
/// ```
#[derive(Clone)]
pub struct BasicAuthLayer {
    verifier: VerifyFn,
    realm: String,
}

impl BasicAuthLayer {
    /// Build a layer with a custom `realm` and async `verifier` closure.
    ///
    /// `verifier` receives `(username, password)` and should return
    /// `Some(Claims)` on success or `None` to reject the request.
    pub fn new<F, Fut>(realm: impl Into<String>, verifier: F) -> Self
    where
        F: Fn(String, String) -> Fut + Send + Sync + 'static,
        Fut: Future<Output = Option<Claims>> + Send + 'static,
    {
        Self {
            realm: realm.into(),
            verifier: Arc::new(move |u, p| Box::pin(verifier(u, p))),
        }
    }
}

impl<Svc> Layer<Svc> for BasicAuthLayer {
    type Service = BasicAuthService<Svc>;

    fn layer(&self, inner: Svc) -> Self::Service {
        BasicAuthService {
            inner,
            verifier: Arc::clone(&self.verifier),
            realm: self.realm.clone(),
        }
    }
}

// ── BasicAuthService ──────────────────────────────────────────────────────────

#[derive(Clone)]
pub struct BasicAuthService<Svc> {
    inner: Svc,
    verifier: VerifyFn,
    realm: String,
}

type BoxFut<T, E> = Pin<Box<dyn Future<Output = Result<T, E>> + Send>>;

impl<Svc, ReqBody> Service<Request<ReqBody>> for BasicAuthService<Svc>
where
    Svc: Service<Request<ReqBody>, Response = Response<Body>> + Clone + Send + 'static,
    Svc::Error: Send,
    Svc::Future: Send,
    ReqBody: Send + 'static,
{
    type Response = Response<Body>;
    type Error = Svc::Error;
    type Future = BoxFut<Self::Response, Self::Error>;

    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
        self.inner.poll_ready(cx)
    }

    fn call(&mut self, mut req: Request<ReqBody>) -> Self::Future {
        let verifier = Arc::clone(&self.verifier);
        let realm = self.realm.clone();
        let clone = self.inner.clone();
        let mut inner = std::mem::replace(&mut self.inner, clone);

        Box::pin(async move {
            // ── 1. Parse Authorization: Basic <base64> ─────────────────────────
            let Some((username, password)) = parse_basic_header(req.headers()) else {
                return Ok(deny(&realm));
            };

            // ── 2. Verify credentials ──────────────────────────────────────────
            let Some(claims) = verifier(username, password).await else {
                return Ok(deny(&realm));
            };

            // ── 3. Inject Claims into extensions for Ctx extractor ─────────────
            req.extensions_mut().insert(claims);

            // ── 4. Proceed to inner service ────────────────────────────────────
            inner.call(req).await
        })
    }
}

// ── Helpers ───────────────────────────────────────────────────────────────────

/// Parse `Authorization: Basic <base64(user:pass)>` → `(username, password)`.
pub fn parse_basic_header(headers: &axum::http::HeaderMap) -> Option<(String, String)> {
    let value = headers.get(header::AUTHORIZATION)?.to_str().ok()?;
    let encoded = value.strip_prefix("Basic ")?;
    let decoded = STANDARD.decode(encoded.trim()).ok()?;
    let s = String::from_utf8(decoded).ok()?;
    let mut parts = s.splitn(2, ':');
    let username = parts.next()?.to_string();
    let password = parts.next()?.to_string();
    Some((username, password))
}

fn deny(realm: &str) -> Response<Body> {
    Response::builder()
        .status(StatusCode::UNAUTHORIZED)
        .header(header::WWW_AUTHENTICATE, format!("Basic realm=\"{realm}\""))
        .header(header::CONTENT_TYPE, "application/json")
        .body(Body::from(
            r#"{"error":"Unauthorized","message":"Basic authentication required"}"#,
        ))
        .unwrap()
}