aauth 0.0.1

Rust implementation of the AAuth authorization protocol
Documentation
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};

use async_trait::async_trait;
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use ed25519_dalek::{Signer, SigningKey};

use crate::error::{AAuthError, Result};
use crate::headers::{build_capabilities_header, build_mission_header};
use crate::http::{HttpRequest, HttpResponse};
use crate::jwt::OkpSigningJwk;
use crate::types::{Capability, KeyMaterial, Mission, SignatureKey};

pub type SignedFetch = Arc<
    dyn Fn(HttpRequest) -> Pin<Box<dyn Future<Output = Result<HttpResponse>> + Send>> + Send + Sync,
>;

#[async_trait]
pub trait KeyMaterialProvider: Send + Sync {
    async fn key_material(&self) -> Result<KeyMaterial>;
}

#[derive(Debug, Clone, Default)]
pub struct SignedFetchOptions {
    pub capabilities: Option<Vec<Capability>>,
    pub mission: Option<Mission>,
}

pub fn create_signed_fetch(
    client: Arc<dyn HttpClientAdapter>,
    provider: Arc<dyn KeyMaterialProvider>,
    options: Option<SignedFetchOptions>,
) -> SignedFetch {
    let mut builder = SignedFetchBuilder::new(client, provider);
    if let Some(opts) = options {
        builder = builder.options(opts);
    }
    builder.build()
}

struct SignedFetchBuilder {
    client: Arc<dyn HttpClientAdapter>,
    provider: Arc<dyn KeyMaterialProvider>,
    options: SignedFetchOptions,
}

impl SignedFetchBuilder {
    fn new(client: Arc<dyn HttpClientAdapter>, provider: Arc<dyn KeyMaterialProvider>) -> Self {
        Self {
            client,
            provider,
            options: SignedFetchOptions::default(),
        }
    }

    fn options(mut self, options: SignedFetchOptions) -> Self {
        self.options = options;
        self
    }

    fn build(self) -> SignedFetch {
        let client = self.client;
        let provider = self.provider;
        let options = self.options;
        Arc::new(move |mut request: HttpRequest| {
            let client = Arc::clone(&client);
            let provider = Arc::clone(&provider);
            let options = options.clone();
            Box::pin(async move {
                let material = provider.key_material().await?;
                apply_optional_headers(&mut request, &options);
                sign_request(&mut request, &material)?;
                client.send(request).await
            })
        })
    }
}

#[async_trait]
pub trait HttpClientAdapter: Send + Sync {
    async fn send(&self, request: HttpRequest) -> Result<HttpResponse>;
}

#[async_trait]
impl HttpClientAdapter for crate::http::ReqwestClient {
    async fn send(&self, request: HttpRequest) -> Result<HttpResponse> {
        use crate::http::HttpClient;
        HttpClient::send(self, request)
            .await
            .map_err(AAuthError::Http)
    }
}

fn apply_optional_headers(request: &mut HttpRequest, options: &SignedFetchOptions) {
    if let Some(capabilities) = &options.capabilities {
        if !capabilities.is_empty() {
            request.headers.insert(
                "aauth-capabilities".to_string(),
                build_capabilities_header(capabilities),
            );
        }
    }
    if let Some(mission) = &options.mission {
        request
            .headers
            .insert("aauth-mission".to_string(), build_mission_header(mission));
    }
}

pub(crate) fn sign_request(request: &mut HttpRequest, material: &KeyMaterial) -> Result<()> {
    let token = match &material.signature_key {
        SignatureKey::Jwt(j) => &j.jwt,
        SignatureKey::JktJwt(j) => &j.jwt,
        SignatureKey::Hwk(_) => {
            return Err(AAuthError::Message(
                "hwk signature key not supported for AAuth requests".into(),
            ));
        }
    };
    let jwt = format!("sig=jwt;jwt=\"{token}\"");

    request
        .headers
        .insert("signature-key".to_string(), jwt.clone());

    let signing_key = signing_key_from_jwk(&material.signing_jwk)?;
    let created = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap()
        .as_secs();

    let signature_input =
        format!("sig=(\"@method\" \"@authority\" \"@path\" \"signature-key\");created={created}");
    let signature_base = build_signature_base(request, &jwt, created);
    let signature_bytes = signing_key.sign(signature_base.as_bytes());
    let signature = URL_SAFE_NO_PAD.encode(signature_bytes.to_bytes());

    request
        .headers
        .insert("signature-input".to_string(), signature_input);
    request
        .headers
        .insert("signature".to_string(), format!("sig=:{signature}:"));

    Ok(())
}

pub fn sign_request_with_auth_token(
    request: &mut HttpRequest,
    material: &KeyMaterial,
    auth_token: &str,
) -> Result<()> {
    let mut auth_material = material.clone();
    auth_material.signature_key = SignatureKey::Jwt(crate::types::SignatureKeyJwt {
        jwt: auth_token.to_string(),
    });
    sign_request(request, &auth_material)
}

fn build_signature_base(request: &HttpRequest, signature_key: &str, created: u64) -> String {
    let url = url::Url::parse(&request.url).expect("valid request url");
    let authority = url.authority();
    let path = url.path();
    format!(
        "\"@method\": {}\n\"@authority\": {}\n\"@path\": {}\n\"signature-key\": {}\n\"@signature-params\": (\"@method\" \"@authority\" \"@path\" \"signature-key\");created={created}",
        request.method.to_lowercase(),
        authority,
        path,
        signature_key,
    )
}

fn signing_key_from_jwk(jwk: &OkpSigningJwk) -> Result<SigningKey> {
    let bytes = URL_SAFE_NO_PAD
        .decode(&jwk.d)
        .map_err(|e| AAuthError::Message(e.to_string()))?;
    let key_bytes: [u8; 32] = bytes
        .try_into()
        .map_err(|_| AAuthError::Message("invalid Ed25519 private key length".into()))?;
    Ok(SigningKey::from_bytes(&key_bytes))
}

#[cfg(test)]
mod tests {
    use std::collections::HashMap;

    use super::*;

    #[test]
    fn signature_base_includes_components() {
        let request = HttpRequest {
            method: "GET".into(),
            url: "https://resource.example/api/data".into(),
            headers: HashMap::new(),
            body: None,
        };
        let base = build_signature_base(&request, "sig=jwt;jwt=\"abc\"", 1);
        assert!(base.contains("@method"));
        assert!(base.contains("@authority"));
        assert!(base.contains("@path"));
        assert!(base.contains("signature-key"));
    }
}