ecamo 0.1.0

SSL image proxy with JWT authentication
use ecamo::error::Error;
use ecamo::test;
use jwt_simple::prelude::ECDSAP256PublicKeyLike;

lazy_static::lazy_static! {
    pub static ref TEST_GIF: Vec<u8> = {
        let mut path = std::path::PathBuf::from(std::env!("CARGO_MANIFEST_DIR"));
        path.push("tests/test.gif");
        std::fs::read(path).unwrap()
    };
}

pub struct Environment {
    pub test_config: test::TestConfig,
    pub url: reqwest::Url,
    pub upstream_mock: mockito::Mock,
    pub upstream_mock_large: mockito::Mock,
    pub upstream_mock_chunked: mockito::Mock,
    pub upstream_mock_chunked_large: mockito::Mock,
    pub upstream_mock_404: mockito::Mock,
    pub upstream_mock_text: mockito::Mock,
}

pub async fn init_and_spawn() -> Environment {
    let _ = env_logger::builder().is_test(true).try_init();
    let test_config = crate::test::TestConfig::new();

    let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();

    let mut url = reqwest::Url::parse("http://localhost:0").unwrap();
    url.set_ip_host(listener.local_addr().unwrap().ip())
        .unwrap();
    url.set_port(Some(listener.local_addr().unwrap().port()))
        .unwrap();

    let server = ecamo::app::run(test_config.app_config.clone(), listener, true)
        .await
        .unwrap();

    tokio::spawn(async move { server.await.unwrap() });

    let upstream_mock = mockito::mock("GET", "/test.gif")
        .with_body(TEST_GIF.clone())
        .with_header("content-type", "image/gif")
        .with_header("etag", "W/\"deadbeef\"")
        .with_header("expires", "60")
        .with_header("cache-control", "max-age=60, public")
        .with_header("vary", "accept")
        .create();

    let upstream_mock_large = mockito::mock("GET", "/large.gif")
        .with_body(
            [
                TEST_GIF.clone(),
                TEST_GIF.clone(),
                TEST_GIF.clone(),
                TEST_GIF.clone(),
                TEST_GIF.clone(),
                TEST_GIF.clone(),
                TEST_GIF.clone(),
                TEST_GIF.clone(),
                TEST_GIF.clone(),
            ]
            .concat(),
        )
        .with_header("content-type", "image/gif")
        .with_header("etag", "W/\"deadbeef\"")
        .with_header("expires", "60")
        .with_header("cache-control", "max-age=60, public")
        .with_header("vary", "accept")
        .create();

    let upstream_mock_chunked = mockito::mock("GET", "/chunked.gif")
        .with_body_from_fn(upstream_mock_chunked_body)
        .with_header("content-type", "image/gif")
        .create();
    let upstream_mock_chunked_large = mockito::mock("GET", "/chunked-large.gif")
        .with_body_from_fn(upstream_mock_chunked_large_body)
        .with_header("content-type", "image/gif")
        .create();

    let upstream_mock_404 = mockito::mock("GET", "/404")
        .with_body("{\"four-oh-four\": 404}")
        .with_status(404)
        .with_header("content-type", "application/json")
        .with_header("etag", "W/\"deadbeef\"")
        .with_header("expires", "60")
        .with_header("cache-control", "max-age=60, public")
        .with_header("vary", "accept")
        .create();

    let upstream_mock_text = mockito::mock("GET", "/text")
        .with_body("Hello")
        .with_header("content-type", "text/plain")
        .create();

    Environment {
        test_config,
        url,
        upstream_mock,
        upstream_mock_large,
        upstream_mock_chunked,
        upstream_mock_chunked_large,
        upstream_mock_404,
        upstream_mock_text,
    }
}

pub fn build_reqwest_client() -> reqwest::Client {
    reqwest::Client::builder()
        .redirect(reqwest::redirect::Policy::none())
        .timeout(std::time::Duration::from_secs(2))
        .build()
        .unwrap()
}

fn upstream_mock_chunked_body(body: &mut dyn std::io::Write) -> std::io::Result<()> {
    for b in TEST_GIF.clone().into_iter() {
        body.write_all(&[b])?;
    }
    body.flush()?;
    Ok(())
}

fn upstream_mock_chunked_large_body(body: &mut dyn std::io::Write) -> std::io::Result<()> {
    upstream_mock_chunked_body(body)?;
    upstream_mock_chunked_body(body)?;
    upstream_mock_chunked_body(body)?;
    upstream_mock_chunked_body(body)?;
    upstream_mock_chunked_body(body)?;
    upstream_mock_chunked_body(body)?;
    upstream_mock_chunked_body(body)?;
    upstream_mock_chunked_body(body)?;
    upstream_mock_chunked_body(body)?;
    log::debug!("waf");
    Ok(())
}

pub struct HttptestAnonymousIDTokenMatcher {
    pub svc: String,
    pub aud: String,
    pub key: jwt_simple::algorithms::ES256PublicKey,
}

impl HttptestAnonymousIDTokenMatcher {
    fn attempt(&self, token: &str) -> Result<(), Error> {
        let metadata = jwt_simple::token::Token::decode_metadata(token)?;

        let kid = metadata
            .key_id()
            .ok_or_else(|| Error::MissingClaimError("kid".to_owned()))?;
        if kid != "prv" {
            return Err(Error::UnknownKeyError("kid != prv".to_owned()));
        }

        let mut verification = jwt_simple::common::VerificationOptions::default();
        verification.allowed_issuers = Some(std::collections::HashSet::from_iter(
            ["https://ecamo.test.invalid".to_owned()].into_iter(),
        ));
        verification.allowed_audiences = Some(std::collections::HashSet::from_iter(
            [self.aud.clone()].into_iter(),
        ));

        let claims = self
            .key
            .verify_token::<ecamo::token::AnonymousIDToken>(token, Some(verification))?;

        if claims.custom.ecamo_service_origin != self.svc {
            return Err(Error::UnknownError("invalid svc".to_owned()));
        }

        Ok(())
    }
}

impl httptest::matchers::Matcher<[httptest::matchers::KV<str, bstr::BStr>]>
    for HttptestAnonymousIDTokenMatcher
{
    fn matches(
        &mut self,
        input: &[httptest::matchers::KV<str, bstr::BStr>],
        _ctx: &mut httptest::matchers::ExecutionContext,
    ) -> bool {
        for kv in input {
            if kv.k != "authorization" {
                continue;
            }
            let hv = kv.v.to_string();
            let token = match hv.split_once(" ") {
                Some((_, t)) => t,
                _ => return false,
            };

            return match self.attempt(token) {
                Ok(_) => true,
                Err(e) => {
                    log::warn!("HttptestAnonymousIDTokenMatcher: e={:?}", e);
                    false
                }
            };
        }
        false
    }

    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str("HttptestAnonymousIDTokenMatcher")
    }
}

#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub struct UrlTokenInString {
    #[serde(rename = "ecamo:url")]
    pub ecamo_url: String,
}