rok-auth-basic 0.1.0

HTTP Basic authentication guard for the rok ecosystem
Documentation
use axum::{
    body::Body,
    http::{header, Request, StatusCode},
    routing::get,
    Router,
};
use base64::{engine::general_purpose::STANDARD, Engine};
use rok_auth::Claims;
use rok_auth_basic::BasicAuthLayer;
use tower::ServiceExt;

// ── helper ────────────────────────────────────────────────────────────────────

fn test_app() -> Router {
    Router::new()
        .route("/", get(|| async { "hello" }))
        .layer(BasicAuthLayer::new(
            "Test Realm",
            |user: String, pass: String| async move {
                if user == "admin" && pass == "secret" {
                    Some(Claims::new(user, vec!["admin"]))
                } else {
                    None
                }
            },
        ))
}

fn basic_header(user: &str, pass: &str) -> String {
    format!("Basic {}", STANDARD.encode(format!("{user}:{pass}")))
}

// ── tests ─────────────────────────────────────────────────────────────────────

#[tokio::test]
async fn valid_credentials_returns_200() {
    let app = test_app();
    let response = app
        .oneshot(
            Request::builder()
                .uri("/")
                .header(header::AUTHORIZATION, basic_header("admin", "secret"))
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();

    assert_eq!(response.status(), StatusCode::OK);
}

#[tokio::test]
async fn wrong_password_returns_401() {
    let app = test_app();
    let response = app
        .oneshot(
            Request::builder()
                .uri("/")
                .header(header::AUTHORIZATION, basic_header("admin", "wrong"))
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();

    assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}

#[tokio::test]
async fn wrong_username_returns_401() {
    let app = test_app();
    let response = app
        .oneshot(
            Request::builder()
                .uri("/")
                .header(header::AUTHORIZATION, basic_header("notadmin", "secret"))
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();

    assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}

#[tokio::test]
async fn missing_header_returns_401() {
    let app = test_app();
    let response = app
        .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
        .await
        .unwrap();

    assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}

#[tokio::test]
async fn missing_header_includes_www_authenticate_challenge() {
    let app = test_app();
    let response = app
        .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
        .await
        .unwrap();

    let www_auth = response
        .headers()
        .get(header::WWW_AUTHENTICATE)
        .and_then(|v| v.to_str().ok())
        .unwrap_or("");

    assert!(
        www_auth.starts_with("Basic realm="),
        "expected WWW-Authenticate: Basic realm=..., got: {www_auth}"
    );
    assert!(www_auth.contains("Test Realm"));
}

#[tokio::test]
async fn bearer_token_header_is_rejected() {
    let app = test_app();
    let response = app
        .oneshot(
            Request::builder()
                .uri("/")
                .header(header::AUTHORIZATION, "Bearer some.jwt.token")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();

    assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}

#[tokio::test]
async fn malformed_base64_returns_401() {
    let app = test_app();
    let response = app
        .oneshot(
            Request::builder()
                .uri("/")
                .header(header::AUTHORIZATION, "Basic !!!not-valid-base64!!!")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();

    assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}

// ── parse_basic_header unit tests ─────────────────────────────────────────────

#[test]
fn parse_basic_header_valid() {
    use axum::http::HeaderMap;
    use rok_auth_basic::parse_basic_header;

    let mut headers = HeaderMap::new();
    headers.insert(
        header::AUTHORIZATION,
        basic_header("alice", "p@ss:word").parse().unwrap(),
    );
    let result = parse_basic_header(&headers);
    assert_eq!(result, Some(("alice".to_string(), "p@ss:word".to_string())));
}

#[test]
fn parse_basic_header_missing() {
    use axum::http::HeaderMap;
    use rok_auth_basic::parse_basic_header;

    let headers = HeaderMap::new();
    assert_eq!(parse_basic_header(&headers), None);
}

#[test]
fn parse_basic_header_password_with_colon() {
    use axum::http::HeaderMap;
    use rok_auth_basic::parse_basic_header;

    let mut headers = HeaderMap::new();
    // "user:pass:with:colons" — splitn(2) takes only the first colon
    headers.insert(
        header::AUTHORIZATION,
        basic_header("user", "pass:with:colons").parse().unwrap(),
    );
    let (u, p) = parse_basic_header(&headers).unwrap();
    assert_eq!(u, "user");
    assert_eq!(p, "pass:with:colons");
}