rust-webx-host 1.0.2

rust-webx HTTP layer: Host builder, middleware pipeline, Trie-based router, hyper integration
Documentation
mod test_utils;

use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
use webx_core::auth::IAuthenticationHandler;
use webx_host::auth_jwt::JwtAuth;

fn now_plus_seconds(secs: u64) -> usize {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs() + secs)
        .unwrap() as usize
}

fn now_minus_seconds(secs: u64) -> usize {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs().saturating_sub(secs))
        .unwrap() as usize
}

#[derive(Debug, Serialize, Deserialize)]
struct TestClaims {
    sub: String,
    #[serde(default)]
    roles: Vec<String>,
    #[serde(default)]
    permissions: Vec<String>,
    exp: usize,
}

fn create_test_token(secret: &[u8], sub: &str, roles: &[&str], permissions: &[&str]) -> String {
    let claims = TestClaims {
        sub: sub.to_string(),
        roles: roles.iter().map(|s| s.to_string()).collect(),
        permissions: permissions.iter().map(|s| s.to_string()).collect(),
        exp: now_plus_seconds(3600),
    };
    jsonwebtoken::encode(
        &Header::default(),
        &claims,
        &EncodingKey::from_secret(secret),
    )
    .unwrap()
}

fn create_expired_token(secret: &[u8]) -> String {
    let claims = TestClaims {
        sub: "expired-user".to_string(),
        roles: vec![],
        permissions: vec![],
        exp: now_minus_seconds(3600),
    };
    jsonwebtoken::encode(
        &Header::default(),
        &claims,
        &EncodingKey::from_secret(secret),
    )
    .unwrap()
}

#[tokio::test]
async fn auth_valid_token_returns_claims() {
    let secret = b"test-secret-key-for-auth-testing";
    let token = create_test_token(secret, "user-123", &["user"], &["read"]);
    let auth = JwtAuth::new(DecodingKey::from_secret(secret), Validation::default());

    let mut ctx = test_utils::TestHttpContext::new("GET", "/protected")
        .with_header("authorization", &format!("Bearer {}", token));

    let result = auth.authenticate(&mut ctx).await;
    assert!(result.is_ok());
    let claims = result.unwrap();
    assert!(claims.is_some());
    let claims = claims.unwrap();
    assert_eq!(claims.subject(), "user-123");
    assert!(claims.roles().contains(&"user".to_string()));
    assert!(claims.permissions().contains(&"read".to_string()));
}

#[tokio::test]
async fn auth_no_authorization_header_returns_none() {
    let secret = b"test-secret-key";
    let auth = JwtAuth::new(DecodingKey::from_secret(secret), Validation::default());

    let mut ctx = test_utils::TestHttpContext::new("GET", "/public");
    let result = auth.authenticate(&mut ctx).await.unwrap();
    assert!(result.is_none(), "No auth header should return None");
}

#[tokio::test]
async fn auth_empty_token_returns_none() {
    let secret = b"test-secret-key";
    let auth = JwtAuth::new(DecodingKey::from_secret(secret), Validation::default());

    let mut ctx =
        test_utils::TestHttpContext::new("GET", "/").with_header("authorization", "Bearer ");

    let result = auth.authenticate(&mut ctx).await.unwrap();
    assert!(result.is_none(), "Empty Bearer token should return None");
}

// #18: a bad token is "not authenticated", never "reject the request".
// The handler returns `Ok(None)` so the request continues as anonymous and a
// public endpoint (login, health, docs) stays reachable while the client still
// holds a stale token. Protected endpoints turn the missing claims into a 401
// later in the pipeline.

#[tokio::test]
async fn auth_invalid_token_degrades_to_anonymous() {
    let secret = b"test-secret-key";
    let auth = JwtAuth::new(DecodingKey::from_secret(secret), Validation::default());

    let mut ctx = test_utils::TestHttpContext::new("GET", "/")
        .with_header("authorization", "Bearer invalid-token-data");

    let result = auth.authenticate(&mut ctx).await;
    assert!(result.is_ok(), "a bad token must not fail authentication");
    assert!(
        result.unwrap().is_none(),
        "a bad token means anonymous, not an error"
    );
}

#[tokio::test]
async fn auth_wrong_secret_key_degrades_to_anonymous() {
    let secret = b"real-secret";
    let wrong_secret = b"wrong-secret";
    let token = create_test_token(secret, "user-1", &[], &[]);
    let auth = JwtAuth::new(
        DecodingKey::from_secret(wrong_secret),
        Validation::default(),
    );

    let mut ctx = test_utils::TestHttpContext::new("GET", "/")
        .with_header("authorization", &format!("Bearer {}", token));

    let result = auth.authenticate(&mut ctx).await;
    assert!(result.is_ok(), "a token from another key is just anonymous");
    assert!(result.unwrap().is_none());
}

#[tokio::test]
async fn auth_expired_token_degrades_to_anonymous() {
    let secret = b"test-secret-key-expiry";
    let token = create_expired_token(secret);
    let auth = JwtAuth::new(DecodingKey::from_secret(secret), Validation::default());

    let mut ctx = test_utils::TestHttpContext::new("GET", "/")
        .with_header("authorization", &format!("Bearer {}", token));

    let result = auth.authenticate(&mut ctx).await;
    assert!(result.is_ok(), "an expired token is just anonymous");
    assert!(result.unwrap().is_none());
}

#[tokio::test]
async fn auth_bearer_prefix_case_insensitive() {
    let secret = b"test-secret-key";
    let token = create_test_token(secret, "user-1", &[], &[]);
    let auth = JwtAuth::new(DecodingKey::from_secret(secret), Validation::default());

    let mut ctx = test_utils::TestHttpContext::new("GET", "/")
        .with_header("authorization", &format!("bearer {}", token));

    let result = auth.authenticate(&mut ctx).await.unwrap();
    assert!(
        result.is_some(),
        "Lowercase 'bearer' should be accepted (RFC 6750)"
    );
}

#[tokio::test]
async fn auth_token_with_extra_whitespace_handled() {
    let secret = b"test-secret-key";
    let token = create_test_token(secret, "user-1", &[], &[]);
    let auth = JwtAuth::new(DecodingKey::from_secret(secret), Validation::default());

    let mut ctx = test_utils::TestHttpContext::new("GET", "/")
        .with_header("authorization", &format!("Bearer  {}", token));

    let result = auth.authenticate(&mut ctx).await;
    assert!(
        result.is_ok(),
        "Token with extra whitespace after Bearer should work"
    );
    let claims = result.unwrap();
    assert!(claims.is_some());
}