aep-service 0.1.1

Service-side integration for the Agent Enrollment Protocol.
Documentation
use std::{sync::Arc, time::Duration};

use aep_core::{
    AUTHORIZATION_HEADER, AssertionOperation, AuthenticationMethod, AuthorizationCarrier,
    ClientAssertionClaims, CredentialScheme, IdentityMethod, ResolveDidWebPublicKeyOptions,
    VerifyClientAssertionOptions, decode_jwt_unverified, parse_protected_resource_authorization,
    resolve_did_web_public_key, validate_client_assertion_claims_with_options,
    verify_client_assertion,
};
use async_trait::async_trait;
use http::{HeaderMap, header};
use time::{Duration as TimeDuration, OffsetDateTime};
use url::Url;

use crate::{
    ClientAssertionReplayRecord, ClientAssertionReplayStore, ClientAssertionVerificationContext,
    ClientAssertionVerifier, Clock, ServiceError,
};

pub(crate) struct AssertionAuthentication<'a> {
    pub allow_insecure_loopback: bool,
    pub assertion: &'a str,
    pub clock: &'a dyn Clock,
    pub idempotency_key: Option<&'a str>,
    pub identity_methods: &'a [IdentityMethod],
    pub maximum_clock_skew: Duration,
    pub operation: AssertionOperation,
    pub replay_store: &'a Arc<dyn ClientAssertionReplayStore>,
    pub resource: Option<&'a Url>,
    pub service_did: &'a str,
    pub signing_algorithms: &'a [aep_core::SigningAlgorithm],
    pub verifier: &'a Arc<dyn ClientAssertionVerifier>,
}

pub(crate) async fn authenticate_assertion(
    options: AssertionAuthentication<'_>,
) -> Result<Option<ClientAssertionClaims>, ServiceError> {
    let now = options.clock.now();
    let context = ClientAssertionVerificationContext {
        assertion: options.assertion.to_owned(),
        current_time: now,
        idempotency_key: options.idempotency_key.map(str::to_owned),
        operation: options.operation,
        resource: options.resource.cloned(),
        service_did: options.service_did.to_owned(),
        signing_algorithms: options.signing_algorithms.to_vec(),
    };
    let Ok(claims) = options.verifier.verify(context).await else {
        return Ok(None);
    };
    if validate_client_assertion_claims_with_options(
        &claims,
        aep_core::ClientAssertionValidationOptions {
            allow_insecure_loopback: options.allow_insecure_loopback,
        },
    )
    .is_err()
        || claims.aud != options.service_did
        || claims.iss != claims.sub
        || claims.op != options.operation
        || claims.resource.as_deref() != options.resource.map(Url::as_str)
        || !supports_identity_method(&claims.sub, options.identity_methods)
        || !inside_validity_window(&claims, now, options.maximum_clock_skew)
    {
        return Ok(None);
    }
    let skew = TimeDuration::try_from(options.maximum_clock_skew)
        .map_err(|_| ServiceError::InvalidConfiguration("clock skew is too large".to_owned()))?;
    let Some(expiration) = OffsetDateTime::from_unix_timestamp(claims.exp)
        .ok()
        .and_then(|expiration| expiration.checked_add(skew))
    else {
        return Ok(None);
    };
    let consumed = options
        .replay_store
        .consume(
            ClientAssertionReplayRecord {
                expires_at: expiration,
                jti: claims.jti.clone(),
                sub: claims.sub.clone(),
            },
            now,
        )
        .await?;
    Ok(consumed.then_some(claims))
}

#[async_trait]
impl crate::ClientAssertionVerifier for crate::DidWebClientAssertionVerifier {
    async fn verify(
        &self,
        context: ClientAssertionVerificationContext,
    ) -> Result<ClientAssertionClaims, ServiceError> {
        let decoded = decode_jwt_unverified(&context.assertion)?;
        let issuer = decoded
            .payload
            .get("iss")
            .and_then(|value| value.as_str())
            .ok_or_else(|| ServiceError::Handler("client assertion iss is required".to_owned()))?;
        let key_id =
            decoded.header.key_id.as_deref().ok_or_else(|| {
                ServiceError::Handler("client assertion kid is required".to_owned())
            })?;
        let key = resolve_did_web_public_key(ResolveDidWebPublicKeyOptions {
            algorithm: decoded.header.algorithm,
            allow_insecure_loopback: self.allow_insecure_loopback,
            did: issuer,
            key_id,
            transport: self.transport.as_ref(),
        })
        .await?;
        verify_client_assertion(
            &context.assertion,
            &key,
            &VerifyClientAssertionOptions {
                algorithms: context.signing_algorithms,
                allow_insecure_loopback: self.allow_insecure_loopback,
                audience: Some(context.service_did),
                current_time: Some(context.current_time.unix_timestamp()),
                issuer: Some(issuer.to_owned()),
                operation: Some(context.operation),
                resource: context.resource.map(|resource| resource.to_string()),
                subject: Some(issuer.to_owned()),
                ..VerifyClientAssertionOptions::default()
            },
        )
        .map_err(ServiceError::from)
    }
}

pub(crate) enum SelectedAuthorization {
    Aep(String),
    Session(AuthenticationMethod),
    Unrelated,
    Unauthenticated,
}

pub(crate) fn select_authorization(headers: &HeaderMap) -> Result<SelectedAuthorization, ()> {
    let dedicated_values = headers
        .get_all(AUTHORIZATION_HEADER)
        .iter()
        .collect::<Vec<_>>();
    if dedicated_values.len() > 1 {
        return Err(());
    }
    let standard_values = headers
        .get_all(header::AUTHORIZATION)
        .iter()
        .collect::<Vec<_>>();
    if standard_values.len() > 1 {
        return Err(());
    }
    if let Some(value) = dedicated_values.first() {
        let value = value.to_str().map_err(|_| ())?;
        let parsed = parse_protected_resource_authorization(value, AuthorizationCarrier::Dedicated)
            .map_err(|_| ())?;
        if standard_values.first().is_some_and(|standard| {
            standard.to_str().is_ok_and(|standard| {
                parse_protected_resource_authorization(standard, AuthorizationCarrier::Standard)
                    .is_ok()
            })
        }) {
            return Err(());
        }
        return match parsed.scheme {
            CredentialScheme::Aep => Ok(SelectedAuthorization::Aep(parsed.credentials)),
            CredentialScheme::Bearer => Ok(SelectedAuthorization::Session(
                AuthenticationMethod::OAuthBearer,
            )),
            CredentialScheme::Basic => {
                Ok(SelectedAuthorization::Session(AuthenticationMethod::Basic))
            }
        };
    }
    let Some(value) = standard_values.first() else {
        return Ok(SelectedAuthorization::Unauthenticated);
    };
    let Ok(value) = value.to_str() else {
        return Ok(SelectedAuthorization::Unrelated);
    };
    match parse_protected_resource_authorization(value, AuthorizationCarrier::Standard) {
        Ok(parsed) if parsed.scheme == CredentialScheme::Aep => {
            Ok(SelectedAuthorization::Aep(parsed.credentials))
        }
        Ok(parsed) if parsed.scheme == CredentialScheme::Bearer => Ok(
            SelectedAuthorization::Session(AuthenticationMethod::OAuthBearer),
        ),
        Ok(parsed) if parsed.scheme == CredentialScheme::Basic => {
            Ok(SelectedAuthorization::Session(AuthenticationMethod::Basic))
        }
        Ok(_) | Err(_) => Ok(SelectedAuthorization::Unrelated),
    }
}

fn inside_validity_window(
    claims: &ClientAssertionClaims,
    now: OffsetDateTime,
    maximum_clock_skew: Duration,
) -> bool {
    let Ok(skew) = i64::try_from(maximum_clock_skew.as_secs()) else {
        return false;
    };
    let timestamp = now.unix_timestamp();
    claims.iat <= timestamp.saturating_add(skew) && claims.exp > timestamp.saturating_sub(skew)
}

fn supports_identity_method(agent_did: &str, methods: &[IdentityMethod]) -> bool {
    methods.iter().any(|method| match method {
        IdentityMethod::DidWeb => agent_did.starts_with("did:web:"),
        IdentityMethod::Other(method) => agent_did.starts_with(&format!("{method}:")),
    })
}