kcode-k1-http 0.2.2

Axum orchestration for authenticated K1 HTTP requests
Documentation
use async_trait::async_trait;
use axum::body::{Body, Bytes, to_bytes};
use axum::extract::OriginalUri;
use axum::http::{Method, Request, StatusCode};
use axum::routing::{MethodRouter, get, post};
use axum::{Extension, Json, Router};
use kcode_k1_http::{
    CanonicalUsername, Config, Identity, IdentityError, IdentityProvider, K1Http, Principal,
    RegistrationPrincipal,
};
use kcode_k1_http_replay::{ReplayConfig, ReplayWindow};
use kcode_k1_http_testkit::{Candidate, Fixture, LookupBlock};
use serde::Deserialize;
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use tower::ServiceExt;

struct Adapter;

struct Provider {
    identities: Vec<(CanonicalUsername, Identity)>,
    blocked: Option<(CanonicalUsername, LookupBlock)>,
}

#[async_trait]
impl IdentityProvider for Provider {
    async fn lookup(
        &self,
        username: &CanonicalUsername,
    ) -> Result<Option<Identity>, IdentityError> {
        if let Some((blocked_username, block)) = &self.blocked
            && blocked_username == username
        {
            let release = block.release.notified();
            tokio::pin!(release);
            release.as_mut().enable();
            block.entered.notify_one();
            release.await;
        }
        Ok(self
            .identities
            .iter()
            .find(|(candidate, _)| candidate == username)
            .map(|(_, identity)| *identity))
    }
}

#[derive(Deserialize)]
struct Known {
    known: String,
}

async fn register(Extension(principal): Extension<RegistrationPrincipal>) -> String {
    principal.username().as_str().to_owned()
}

async fn whoami(Extension(principal): Extension<Principal>) -> String {
    principal.username().as_str().to_owned()
}

async fn target(OriginalUri(uri): OriginalUri) -> String {
    uri.path_and_query()
        .map(|value| value.as_str())
        .unwrap_or("/")
        .to_owned()
}

async fn echo(body: Bytes) -> Bytes {
    body
}

async fn json(Json(value): Json<Known>) -> String {
    value.known
}

async fn prepare(fixture: Fixture) -> Result<K1Http, String> {
    let identities = fixture
        .identities
        .into_iter()
        .map(|identity| {
            CanonicalUsername::parse(&identity.username)
                .map(|username| {
                    (
                        username,
                        Identity::new(identity.user_id, identity.public_key),
                    )
                })
                .map_err(|_| "invalid fixture identity".to_owned())
        })
        .collect::<Result<Vec<_>, _>>()?;
    let blocked = fixture
        .blocked_lookup
        .map(|block| {
            CanonicalUsername::parse(&block.username)
                .map(|username| (username, block))
                .map_err(|_| "invalid blocked identity".to_owned())
        })
        .transpose()?;
    let replay = ReplayWindow::open(ReplayConfig {
        epoch_file: fixture.epoch_file,
        max_nonces_per_epoch: fixture.max_nonces_per_epoch,
    })
    .await
    .map_err(|_| "replay unavailable".to_owned())?;
    K1Http::new(
        Config {
            server_id: fixture.server_id,
            public_origin: fixture.public_origin,
            max_body_bytes: fixture.max_body_bytes,
        },
        replay,
        Arc::new(Provider {
            identities,
            blocked,
        }),
    )
    .map_err(|_| "configuration invalid".to_owned())
}

#[async_trait]
impl Candidate for Adapter {
    async fn open(&self, fixture: Fixture) -> Result<Router, String> {
        let app = prepare(fixture).await?;
        let authenticated = Router::new()
            .route("/whoami", get(whoami))
            .route("/target", get(target))
            .route("/echo", post(echo))
            .route("/json", post(json));
        Ok(app.router(post(register), kcode_k1_terms::endpoint(), authenticated))
    }
}

#[async_trait]
impl kcode_k1_http_integration_testkit::Candidate for Adapter {
    async fn compose(
        &self,
        fixture: Fixture,
        registration: MethodRouter,
        terms: MethodRouter,
        authenticated: Router,
    ) -> Result<Router, String> {
        Ok(prepare(fixture)
            .await?
            .router(registration, terms, authenticated))
    }
}

async fn configuration_router() -> Router {
    let epoch_file = std::env::temp_dir().join(format!(
        "kcode-k1-http-config-{}.epoch",
        SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .expect("system time is before the Unix epoch")
            .as_nanos()
    ));
    let replay = ReplayWindow::open(ReplayConfig {
        epoch_file,
        max_nonces_per_epoch: 16,
    })
    .await
    .expect("replay opens");
    K1Http::new(
        Config {
            server_id: "server-for-config-test".to_owned(),
            public_origin: "http://k1.test".to_owned(),
            max_body_bytes: 1024,
        },
        replay,
        Arc::new(Provider {
            identities: Vec::new(),
            blocked: None,
        }),
    )
    .expect("configuration is valid")
    .router(
        post(|| async { "registered" }),
        get(|| async { "terms" }),
        Router::new().route("/protected", get(|| async { "protected" })),
    )
}

fn config_request(method: Method) -> Request<Body> {
    Request::builder()
        .method(method)
        .uri("/api/config.json")
        .header("host", "k1.test")
        .header("origin", "http://localhost:4321")
        .body(Body::empty())
        .expect("request is valid")
}

#[tokio::test]
async fn public_configuration_has_exact_json_cors_and_api_decoration() {
    let response = configuration_router()
        .await
        .oneshot(config_request(Method::GET))
        .await
        .expect("router responds");

    assert_eq!(response.status(), StatusCode::OK);
    assert_eq!(response.headers()["access-control-allow-origin"], "*");
    assert_eq!(
        response.headers()["access-control-expose-headers"],
        "k1-epoch"
    );
    assert_eq!(response.headers()["cache-control"], "no-store");
    assert_eq!(response.headers()["x-content-type-options"], "nosniff");
    assert!(response.headers().contains_key("k1-epoch"));
    assert_eq!(
        to_bytes(response.into_body(), usize::MAX)
            .await
            .expect("response body is readable"),
        "{\"protocol\":\"K1-HTTP-1\",\"server_id\":\"server-for-config-test\",\"public_origin\":\"http://k1.test\"}"
    );
}

#[tokio::test]
async fn public_configuration_supports_head_and_rejects_other_methods_without_fallback() {
    let app = configuration_router().await;
    let head = app
        .clone()
        .oneshot(config_request(Method::HEAD))
        .await
        .expect("router responds");
    assert_eq!(head.status(), StatusCode::OK);
    assert_eq!(
        to_bytes(head.into_body(), usize::MAX)
            .await
            .expect("head response body is readable"),
        Bytes::new()
    );

    let post = app
        .oneshot(config_request(Method::POST))
        .await
        .expect("router responds");
    assert_eq!(post.status(), StatusCode::METHOD_NOT_ALLOWED);
}

#[tokio::test]
async fn configuration_remains_behind_the_existing_authority_gate() {
    let invalid_authority = Request::builder()
        .uri("/api/config.json")
        .header("host", "other.test")
        .body(Body::empty())
        .expect("request is valid");
    let response = configuration_router()
        .await
        .oneshot(invalid_authority)
        .await
        .expect("router responds");

    assert_eq!(response.status(), StatusCode::MISDIRECTED_REQUEST);
    assert_eq!(
        to_bytes(response.into_body(), usize::MAX)
            .await
            .expect("response body is readable"),
        "{\"error\":\"invalid_request_authority\"}"
    );
}

#[tokio::test]
async fn integration() {
    kcode_k1_http_integration_testkit::verify(&Adapter).await;
}