kcode-k1-http 0.1.0

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::header::{ALLOW, HOST};
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::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
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 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 register(Extension(principal): Extension<RegistrationPrincipal>) -> String {
    principal.username().as_str().to_owned()
}

#[async_trait]
impl Candidate for Adapter {
    async fn open(&self, fixture: Fixture) -> Result<Router, 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())?;
        let app = 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())?;
        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), authenticated))
    }
}

fn epoch_file(label: &str) -> PathBuf {
    let unique = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .expect("system clock before Unix epoch")
        .as_nanos();
    std::env::temp_dir().join(format!(
        "kcode-k1-http-{label}-{}-{unique}.epoch",
        std::process::id()
    ))
}

async fn local_router(
    label: &str,
    registration: MethodRouter,
    authenticated: Router,
) -> (Router, PathBuf) {
    let epoch_file = epoch_file(label);
    let replay = ReplayWindow::open(ReplayConfig {
        epoch_file: epoch_file.clone(),
        max_nonces_per_epoch: 64,
    })
    .await
    .expect("local replay opens");
    let app = K1Http::new(
        Config {
            server_id: "local-test".to_owned(),
            public_origin: "https://example.test".to_owned(),
            max_body_bytes: 64 * 1024,
        },
        replay,
        Arc::new(Provider {
            identities: Vec::new(),
            blocked: None,
        }),
    )
    .expect("local configuration is valid");
    (app.router(registration, authenticated), epoch_file)
}

#[tokio::test]
async fn authentication() {
    kcode_k1_http_testkit::verify_authentication(&Adapter).await;
}

#[tokio::test]
async fn routing() {
    kcode_k1_http_testkit::verify_routing(&Adapter).await;
}

#[tokio::test]
async fn restart_and_replay() {
    kcode_k1_http_testkit::verify_restart_and_replay(&Adapter).await;
}

#[tokio::test]
async fn cors_and_isolation() {
    kcode_k1_http_testkit::verify_cors_and_isolation(&Adapter).await;
}

#[tokio::test]
async fn authenticated_fallback_requires_authentication() {
    let handler_runs = Arc::new(AtomicUsize::new(0));
    let fallback_runs = handler_runs.clone();
    let authenticated = Router::new()
        .route("/unused", get(|| async { StatusCode::OK }))
        .fallback(move || {
            let fallback_runs = fallback_runs.clone();
            async move {
                fallback_runs.fetch_add(1, Ordering::SeqCst);
                StatusCode::OK
            }
        });
    let (router, epoch_file) = local_router(
        "authenticated-fallback",
        post(|| async { StatusCode::OK }),
        authenticated,
    )
    .await;
    let response = router
        .oneshot(
            Request::builder()
                .uri("/api/missing")
                .header(HOST, "example.test")
                .body(Body::empty())
                .expect("request builds"),
        )
        .await
        .expect("request completes");
    assert_eq!(response.status(), StatusCode::BAD_REQUEST);
    assert_eq!(handler_runs.load(Ordering::SeqCst), 0);
    let _ = std::fs::remove_file(epoch_file);
}

#[tokio::test]
async fn registration_rejects_accidental_non_post_handler() {
    let handler_runs = Arc::new(AtomicUsize::new(0));
    let get_runs = handler_runs.clone();
    let registration = get(move || {
        let get_runs = get_runs.clone();
        async move {
            get_runs.fetch_add(1, Ordering::SeqCst);
            StatusCode::OK
        }
    });
    let authenticated = Router::new().route("/unused", get(|| async { StatusCode::OK }));
    let (router, epoch_file) =
        local_router("registration-method", registration, authenticated).await;
    let response = router
        .oneshot(
            Request::builder()
                .method(Method::GET)
                .uri("/api/register")
                .header(HOST, "example.test")
                .body(Body::empty())
                .expect("request builds"),
        )
        .await
        .expect("request completes");
    assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED);
    assert_eq!(
        response.headers().get(ALLOW).expect("Allow is present"),
        "POST"
    );
    assert_eq!(handler_runs.load(Ordering::SeqCst), 0);
    let _ = std::fs::remove_file(epoch_file);
}

#[tokio::test]
async fn host_validation_precedes_authentication() {
    let authenticated = Router::new().route("/whoami", get(|| async { StatusCode::OK }));
    let (router, epoch_file) = local_router(
        "host-precedence",
        post(|| async { StatusCode::OK }),
        authenticated,
    )
    .await;
    let response = router
        .oneshot(
            Request::builder()
                .uri("/api/whoami")
                .header(HOST, "wrong.example")
                .body(Body::empty())
                .expect("request builds"),
        )
        .await
        .expect("request completes");
    assert_eq!(response.status(), StatusCode::MISDIRECTED_REQUEST);
    let body = to_bytes(response.into_body(), 1024)
        .await
        .expect("response body collects");
    assert_eq!(&body[..], br#"{"error":"invalid_request_authority"}"#);
    let _ = std::fs::remove_file(epoch_file);
}