rust-web-server 18.15.0

Single-binary web server and reverse proxy: automatic HTTPS (Let's Encrypt), HTTP/1.1, HTTP/2 and HTTP/3, load balancing, auth and rate limiting, configured in one TOML file. Also a Rust library: routing, middleware, MCP server, ORM, background jobs. No hyper, no Tower; HTTP/2 and HTTP/3 framing via h2 and quinn.
Documentation
use super::test_support::{body, WhoAmI};
use super::*;
use crate::application::Application;
use crate::request::Request;
use crate::server::ConnectionInfo;

fn get() -> Request {
    Request::parse(b"GET / HTTP/1.1\r\nHost: x\r\n\r\n").unwrap()
}

#[test]
fn subjects_are_typed() {
    let bare = Identity::new("alice", "basic");
    assert_eq!((bare.subject(), bare.subject_type(), bare.subject_id(), bare.source()), ("user:alice", "user", "alice", "basic"));
    let typed = Identity::new("service:billing", "forward");
    assert_eq!((typed.subject_type(), typed.subject_id()), ("service", "billing"));
}

#[test]
fn identities_come_from_top_level_string_claims() {
    let claims = r#"{"sub":"alice","email":"a@example.com","n":3,"nested":{"sub":"mallory"}}"#;
    let identity = Identity::from_claims(claims, "sub", "jwt").unwrap();
    assert_eq!(identity.subject(), "user:alice");
    assert_eq!(identity.claim("email").as_deref(), Some("a@example.com"));
    assert_eq!(identity.claims(), Some(claims));
    assert_eq!(Identity::from_claims(claims, "email", "jwt").unwrap().subject(), "user:a@example.com");
    // A claim can't name a set or another type: it's always a user id.
    assert!(Identity::from_claims(r#"{"sub":"group:eng#member"}"#, "sub", "jwt").is_none(), "'#' can't be in an id");
    let typed = Identity::from_claims(r#"{"sub":"service:billing"}"#, "sub", "jwt").unwrap();
    assert_eq!((typed.subject_type(), typed.subject_id()), ("user", "service:billing"));
    assert!(Identity::user("has space", "basic").is_none() && Identity::user("", "basic").is_none());
    assert!(Identity::from_claims(claims, "n", "jwt").is_none(), "not a string");
    assert!(Identity::from_claims(claims, "missing", "jwt").is_none());
    assert!(Identity::from_claims(r#"{"sub":""}"#, "sub", "jwt").is_none(), "empty");
    assert!(Identity::from_claims("not json", "sub", "jwt").is_none());
    assert_eq!(Identity::from_claims(r#"{"sub":"a\"b"}"#, "sub", "jwt").unwrap().subject(), "user:a\"b");
}

#[test]
fn with_identity_sets_and_restores_even_on_panic() {
    assert!(Identity::current().is_none());
    with_identity(Identity::new("alice", "test"), || {
        assert_eq!(Identity::current().unwrap().subject(), "user:alice");
        let caught = std::panic::catch_unwind(|| with_identity(Identity::new("bob", "test"), || panic!("handler bug")));
        assert!(caught.is_err());
        assert_eq!(Identity::current().unwrap().subject(), "user:alice", "restored after the panic");
    });
    assert!(Identity::current().is_none());
}

#[cfg(feature = "http2")]
#[test]
fn async_code_runs_as_its_scoped_identity() {
    let runtime = tokio::runtime::Builder::new_current_thread().build().unwrap();
    let seen = runtime.block_on(scope_identity(Identity::new("carol", "test"), async {
        tokio::task::yield_now().await;
        Identity::current().map(|i| i.subject().to_string())
    }));
    assert_eq!(seen.as_deref(), Some("user:carol"));
}

#[test]
fn a_client_cannot_send_an_identity() {
    let request = Request::parse(b"GET / HTTP/1.1\r\nHost: x\r\nX-Rws-Identity: user:root\r\nIdentity: user:root\r\n\r\n").unwrap();
    assert_eq!(body(&WhoAmI.execute(&request, &ConnectionInfo::default()).unwrap()), "anonymous");
    assert_eq!(body(&WhoAmI.execute(&get(), &ConnectionInfo::default()).unwrap()), "anonymous");
}

#[cfg(feature = "auth")]
#[test]
fn jwt_and_basic_auth_hand_over_their_caller() {
    use crate::middleware::WithMiddleware;
    let jwt = crate::test_client::TestClient::new(WithMiddleware::new(WhoAmI).wrap(crate::auth::JwtLayer::new(b"secret")));
    let token = crate::auth::build_jwt(r#"{"sub":"alice","email":"a@example.com"}"#, b"secret");
    assert_eq!(jwt.get("/").header("Authorization", &format!("Bearer {token}")).send().body_text(), "user:alice via jwt");
    let by_email = crate::test_client::TestClient::new(WithMiddleware::new(WhoAmI).wrap(crate::auth::JwtLayer::new(b"secret").identity_claim("email")));
    assert_eq!(by_email.get("/").header("Authorization", &format!("Bearer {token}")).send().body_text(), "user:a@example.com via jwt");
    let no_sub = crate::auth::build_jwt(r#"{"scope":"read"}"#, b"secret");
    assert_eq!(jwt.get("/").header("Authorization", &format!("Bearer {no_sub}")).send().body_text(), "anonymous", "valid, no identity");

    let basic = crate::test_client::TestClient::new(WithMiddleware::new(WhoAmI).wrap(crate::auth::BasicAuthLayer::new(|u, p| u == "admin" && p == "pw")));
    let credentials = crate::core::base64::Base64::encode(b"admin:pw").unwrap();
    assert_eq!(basic.get("/").header("Authorization", &format!("Basic {credentials}")).send().body_text(), "user:admin via basic");
}

#[test]
fn mcp_bearer_tokens_name_the_caller() {
    use crate::mcp::{McpContent, McpServer};
    let server = McpServer::new("srv", "1.0").require_bearer_tokens([("alice-token", "alice")]).tool("whoami", "Who am I", "{}", |_| {
        Ok(McpContent::text(Identity::current().map_or("anonymous".to_string(), |i| format!("{} via {}", i.subject(), i.source()))))
    });
    let client = crate::test_client::TestClient::new(server);
    let response = client
        .post("/mcp")
        .header("Authorization", "Bearer alice-token")
        .body_json(r#"{"jsonrpc":"2.0","method":"tools/call","id":1,"params":{"name":"whoami","arguments":{}}}"#)
        .send();
    assert!(response.body_text().contains("user:alice via mcp"), "{}", response.body_text());
}

// ── AuthzLayer and object templates ──────────────────────────────────────────

fn request(raw: &str) -> Request {
    Request::parse(raw.as_bytes()).unwrap()
}

#[test]
fn object_templates_read_path_query_and_headers() {
    let t = ObjectTemplate::parse("doc:{path:2}").unwrap();
    assert_eq!(t.object_type(), Some("doc"));
    assert_eq!(t.render(&request("GET /docs/42/edit?x=1 HTTP/1.1\r\nHost: x\r\n\r\n")).unwrap().to_string(), "doc:42");
    assert!(t.render(&request("GET /docs HTTP/1.1\r\nHost: x\r\n\r\n")).is_none(), "no second segment");
    let q = ObjectTemplate::parse("org:{header:X-Org}-{query:team}").unwrap();
    assert_eq!(q.render(&request("GET /?a=b&team=core HTTP/1.1\r\nHost: x\r\nX-Org: acme\r\n\r\n")).unwrap().to_string(), "org:acme-core");
    assert!(q.render(&request("GET /?team=core HTTP/1.1\r\nHost: x\r\n\r\n")).is_none(), "missing header");
    assert_eq!(t.render(&request("GET /docs/a%20b%40c HTTP/1.1\r\nHost: x\r\n\r\n")), None, "decoded once: a space isn't an id");
    assert_eq!(t.render(&request("GET /docs/alice%40example.com HTTP/1.1\r\nHost: x\r\n\r\n")).unwrap().id, "alice@example.com");
    assert_eq!(t.render(&request("GET /docs/a%23b HTTP/1.1\r\nHost: x\r\n\r\n")), None, "'#' after decoding");
    // Dot segments anywhere (raw or encoded) and encoded slashes name nothing:
    // a backend could resolve them to another document than the one checked.
    for path in ["/docs/42/../7", "/docs/42/%2e%2e/7", "/docs/42/%2E%2E/7", "/docs/./42", "/docs/42%2F..%2F7", "/docs/42%5C..%5C7"] {
        assert_eq!(t.render(&request(&format!("GET {path} HTTP/1.1\r\nHost: x\r\n\r\n"))), None, "{path}");
    }
    for bad in ["doc:{path:0}", "doc:{path:x}", "doc:{cookie:a}", "doc:{path:1", "{path:1}", "doc:42}", ""] {
        assert!(ObjectTemplate::parse(bad).is_err(), "{bad:?}");
    }
}

fn docs_authz() -> Authorizer {
    let schema = Schema::new().relation("doc", "owner", "user").unwrap().relation("doc", "viewer", "user | owner").unwrap();
    let authz = Authorizer::new(schema, MemoryStore::new()).unwrap();
    authz.write_str("doc:42#owner@user:alice").unwrap();
    authz
}

#[test]
fn the_layer_answers_401_403_or_lets_through() {
    use crate::middleware::Middleware;
    let layer = AuthzLayer::new(docs_authz(), "viewer", "doc:{path:2}").unwrap();
    let get = |path: &str| request(&format!("GET {path} HTTP/1.1\r\nHost: x\r\n\r\n"));
    let run = |who: Option<&str>, path: &str| {
        let call = || layer.handle(&get(path), &ConnectionInfo::default(), &WhoAmI).unwrap();
        let response = match who {
            Some(subject) => with_identity(Identity::new(subject, "test"), call),
            None => call(),
        };
        (response.status_code, body(&response))
    };
    assert_eq!(run(Some("alice"), "/docs/42"), (200, "user:alice via test".to_string()));
    assert_eq!(run(Some("bob"), "/docs/42").0, 403);
    assert_eq!(run(Some("alice"), "/docs/7").0, 403);
    assert_eq!(run(Some("alice"), "/docs").0, 403, "no object");
    assert_eq!(run(None, "/docs/42").0, 401);
    assert!(AuthzLayer::new(docs_authz(), "editor", "doc:{path:2}").unwrap_err().contains("doc#editor isn't defined"));
}

const AUTHZ_TOML: &str = r#"
[authz]
tuples = ["doc:42#owner@user:alice"]
[authz.type.doc]
owner = "user"
viewer = "user | owner"

[[route]]
name = "docs"
[route.match]
path = "/docs/*"
[route.action]
type = "respond"
body = "the doc"
[route.middleware.auth]
type = "jwt"
secret_env = "RWS_TEST_AUTHZ_JWT"
[route.middleware.authz]
relation = "viewer"
object = "doc:{path:2}"
"#;

#[cfg(feature = "auth")]
#[test]
fn config_routes_check_relations_and_runtime_tuples_survive_a_reload() {
    let _g = crate::test_env::lock();
    std::env::set_var("RWS_TEST_AUTHZ_JWT", "secret");
    let report = crate::config_check::check_str(AUTHZ_TOML);
    assert!(report.errors.is_empty() && report.warnings.is_empty(), "{:?} {:?}", report.errors, report.warnings);
    let (app, _handles) = crate::proxy_config::builder::build(crate::proxy_config::ProxyConfig::from_str(AUTHZ_TOML));
    let client = crate::test_client::TestClient::new(app.clone());
    let token = |sub: &str| format!("Bearer {}", crate::auth::build_jwt(&format!(r#"{{"sub":"{sub}"}}"#), b"secret"));
    assert_eq!(client.get("/docs/42").header("Authorization", &token("alice")).send().status(), 200);
    assert_eq!(client.get("/docs/42").header("Authorization", &token("bob")).send().status(), 403);
    assert_eq!(client.get("/docs/42").send().status(), 401, "jwt layer: no token");
    // Path confusion: the check must not see doc 42 while a backend serves 7.
    assert_eq!(client.get("/docs/42/../7").header("Authorization", &token("alice")).send().status(), 403);
    assert_eq!(client.get("/docs/42/%2e%2e/7").header("Authorization", &token("alice")).send().status(), 403);
    // A tuple written at runtime (as the admin API will) survives a reload.
    let authorizer = crate::authz::config::current().unwrap().unwrap();
    authorizer.write_str("doc:42#viewer@user:bob").unwrap();
    app.reload_from_str(&AUTHZ_TOML.replace("the doc", "the doc, v2")).unwrap();
    let response = client.get("/docs/42").header("Authorization", &token("bob")).send();
    assert_eq!((response.status(), response.body_text()), (200, "the doc, v2"));
    std::env::remove_var("RWS_TEST_AUTHZ_JWT");
}

#[cfg(feature = "auth")]
#[test]
fn a_token_cannot_claim_to_be_a_group() {
    use crate::middleware::WithMiddleware;
    let schema = Schema::new()
        .relation("group", "member", "user").unwrap()
        .relation("doc", "editor", "user | group#member").unwrap();
    let authz = Authorizer::new(schema, MemoryStore::new()).unwrap();
    authz.write_str("doc:42#editor@group:eng#member").unwrap();
    authz.write_str("doc:42#editor@user:group:eng").unwrap(); // a user literally named "group:eng"
    // The first layer pushed is the outermost: the authenticator first.
    let app = WithMiddleware::new(WhoAmI)
        .wrap(crate::auth::JwtLayer::new(b"secret"))
        .wrap(AuthzLayer::new(authz, "editor", "doc:{path:2}").unwrap());
    let client = crate::test_client::TestClient::new(app);
    let call = |sub: &str| {
        let token = crate::auth::build_jwt(&format!(r#"{{"sub":"{sub}"}}"#), b"secret");
        let response = client.get("/docs/42").header("Authorization", &format!("Bearer {token}")).send();
        (response.status(), response.body_text().to_string())
    };
    assert_eq!(call("group:eng#member").0, 401, "not a usable id: no caller");
    assert_eq!(call("group:eng"), (200, "user:group:eng via jwt".to_string()), "only the user of that name");
    assert_eq!(call("group:other").0, 403);
}

#[cfg(feature = "http2")]
#[test]
fn async_handlers_on_another_thread_see_the_caller() {
    let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap();
    let seen = runtime.block_on(async {
        with_identity(Identity::new("alice", "test"), || {
            crate::async_bridge::block_on_isolated(|| async { Identity::current().map(|i| i.subject().to_string()) })
        })
    });
    assert_eq!(seen.as_deref(), Some("user:alice"));
}

#[test]
fn rws_check_reports_authz_mistakes() {
    let errors = |toml: &str| crate::config_check::check_str_offline(toml).errors.join("\n");
    let warnings = |toml: &str| crate::config_check::check_str_offline(toml).warnings.join("\n");
    assert!(errors(&AUTHZ_TOML.replace("[authz]\n", "[nothing]\n").replace("[authz.type.doc]", "[nothing.type.doc]")).contains("needs an [authz] section"));
    assert!(errors(&AUTHZ_TOML.replace(r#"relation = "viewer""#, r#"relation = "editor""#)).contains("doc#editor isn't defined"));
    assert!(errors(&AUTHZ_TOML.replace("doc:{path:2}", "doc:{path:x}")).contains("{path:N} needs N"));
    assert!(errors(&AUTHZ_TOML.replace(r#"viewer = "user | owner""#, r#"viewer = "user | team#member""#)).contains("[authz]: "));
    assert!(errors(&AUTHZ_TOML.replace("doc:42#owner@user:alice", "doc:42#owner@group:x")).contains("tuple"));
    assert!(errors(&AUTHZ_TOML.replace(r#"object = "doc:{path:2}""#, "object = \"doc:{path:2}\"\nrole = \"x\"")).contains("role: unknown key"));
    assert!(errors(&AUTHZ_TOML.replace("[authz]\n", "[authz]\nstore = \"redis\"\n")).contains("isn't supported"));
    let no_auth = AUTHZ_TOML.replace("[route.middleware.auth]\ntype = \"jwt\"\nsecret_env = \"RWS_TEST_AUTHZ_JWT\"\n", "");
    assert!(warnings(&no_auth).contains("every request gets 401"));
    let global = AUTHZ_TOML.replace("[[route]]", "[middleware.authz]\nrelation = \"viewer\"\nobject = \"doc:{path:2}\"\n\n[[route]]");
    assert!(warnings(&global).contains("/healthz, /readyz and /metrics included"), "{}", warnings(&global));
}

#[test]
fn the_admin_api_lists_writes_deletes_and_checks_tuples() {
    let _g = crate::test_env::lock(); // the admin's authorizer is process-wide
    let schema = Schema::new()
        .relation("group", "member", "user").unwrap()
        .relation("doc", "viewer", "user | group#member").unwrap();
    let authz = Authorizer::new(schema, MemoryStore::new()).unwrap();
    crate::admin::ops::register_authorizer(authz.clone());
    let api = crate::admin::AdminApi::new("t");
    let call = |method: &str, path: &str, body: &str| {
        let raw = format!("{method} {path} HTTP/1.1\r\nHost: x\r\nAuthorization: Bearer t\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{body}", body.len());
        let response = api.execute(&Request::parse(raw.as_bytes()).unwrap(), &ConnectionInfo::default()).unwrap();
        (response.status_code, body_of(&response))
    };
    fn body_of(r: &crate::response::Response) -> String {
        String::from_utf8_lossy(&r.content_range_list.first().map(|c| c.body.clone()).unwrap_or_default()).to_string()
    }
    assert_eq!(call("GET", "/admin/api/v1/authz", ""), (200, r#"{"types":{"doc":["viewer"],"group":["member"],"user":[]}}"#.to_string()));
    assert_eq!(call("POST", "/admin/api/v1/authz/tuples", r#"{"tuple":"group:eng#member@user:gina"}"#), (200, r#"{"tuple":"group:eng#member@user:gina","written":true}"#.to_string()));
    call("POST", "/admin/api/v1/authz/tuples", r#"{"tuple":"doc:42#viewer@group:eng#member"}"#);
    assert_eq!(call("POST", "/admin/api/v1/authz/tuples", r#"{"tuple":"doc:42#owner@user:x"}"#).0, 400, "not in the schema");
    assert_eq!(call("POST", "/admin/api/v1/authz/tuples", r#"{"nope":1}"#).0, 400);
    let check = r#"{"subject":"user:gina","relation":"viewer","object":"doc:42"}"#;
    assert_eq!(call("POST", "/admin/api/v1/authz/check", check), (200, r#"{"allowed":true,"path":["doc:42#viewer","group:eng#member","group:eng#member@user:gina"]}"#.to_string()));
    assert_eq!(call("GET", "/admin/api/v1/authz/tuples?object=doc", ""), (200, r#"{"tuples":["doc:42#viewer@group:eng#member"]}"#.to_string()));
    assert_eq!(call("GET", "/admin/api/v1/authz/tuples?subject=user%3Agina", "").1, r#"{"tuples":["group:eng#member@user:gina"]}"#);
    assert_eq!(call("DELETE", "/admin/api/v1/authz/tuples?tuple=group%3Aeng%23member%40user%3Agina", ""), (200, r#"{"tuple":"group:eng#member@user:gina","deleted":true}"#.to_string()));
    assert_eq!(call("POST", "/admin/api/v1/authz/check", check).1, r#"{"allowed":false,"path":[]}"#);
    assert_eq!(call("PUT", "/admin/api/v1/authz/tuples", "").0, 405);
    assert_eq!(call("GET", "/admin/api/v1/authz/nothing", "").0, 404);
    assert!(authz.check("user:gina", "viewer", "doc:42").is_ok());
}