aep-agent 0.1.0

Agent-side workflows for the Agent Enrollment Protocol.
Documentation
use aep_core::{
    AssertionOperation, AuthenticationMethod, AuthorizationCarrier, BuiltInGrantResponse,
    CredentialScheme, parse_built_in_grant_response, render_protected_resource_authorization,
};
use base64::{Engine as _, engine::general_purpose::STANDARD};
use http::{HeaderMap, HeaderName, HeaderValue};

use crate::{
    AgentError, AuthenticationOptions, AuthenticationResult, CredentialRecord, Inspection, Session,
    authorization, same_origin,
};

impl Session {
    pub async fn authentication(
        &self,
        options: AuthenticationOptions,
    ) -> Result<AuthenticationResult, AgentError> {
        if options.client_assertion_only
            && (options.credential_id.is_some() || options.grant_type.is_some())
        {
            return Err(AgentError::InvalidConfiguration("AEP credential selection cannot be combined with client-assertion-only authentication".to_owned()));
        }
        validate_resource(
            &options.resource,
            &self.service_url,
            self.client.allow_insecure_loopback,
        )?;
        let inspection = self.inspect().await?;
        let methods = inspection
            .document
            .authentication
            .as_ref()
            .map_or(&[][..], |authentication| authentication.methods.as_slice());
        if let Some(grant_type) = options.grant_type.as_ref()
            && !methods
                .iter()
                .any(|method| method.as_str() == grant_type.as_str())
        {
            return Err(AgentError::NoAuthenticationMethod);
        }
        if !options.client_assertion_only {
            let credential_methods = implicit_credential_methods(methods, &options);
            if !credential_methods.is_empty()
                || options.credential_id.is_some()
                || options.grant_type.is_some()
            {
                if let Some(record) = self
                    .find_credential(&inspection, credential_methods, &options)
                    .await?
                {
                    return credential_authentication(
                        &record,
                        options.carrier,
                        self.client.clock.now(),
                    );
                }
                if options.credential_id.is_some() || options.grant_type.is_some() {
                    return Err(AgentError::Credential(
                        "requested AEP credential was not found".to_owned(),
                    ));
                }
            }
        }
        if !methods.contains(&AuthenticationMethod::AepJwt) {
            return Err(AgentError::NoAuthenticationMethod);
        }
        let identity = self.resolve_identity(&inspection, true).await?;
        let signer = self.client.identity_provider.signer_for(&identity).await?;
        let assertion = self
            .client
            .sign_assertion(
                &inspection,
                &identity,
                signer.as_ref(),
                AssertionOperation::Authenticate,
                Some(&options.resource),
            )
            .await?;
        let (name, value) = render_protected_resource_authorization(&authorization(
            options.carrier,
            CredentialScheme::Aep,
            assertion,
        ))
        .map_err(|error| AgentError::Credential(error.to_string()))?;
        Ok(AuthenticationResult {
            headers: one_header(&name, &value)?,
            method: AuthenticationMethod::AepJwt,
        })
    }

    pub async fn forget_credential(&self, credential_id: &str) -> Result<(), AgentError> {
        if credential_id.is_empty() {
            return Err(AgentError::Credential(
                "AEP credential ID is required".to_owned(),
            ));
        }
        let inspection = self.inspect().await?;
        self.client
            .credential_store
            .delete(&inspection.document.service.did, credential_id)
            .await
    }

    async fn find_credential(
        &self,
        inspection: &Inspection,
        methods: &[AuthenticationMethod],
        options: &AuthenticationOptions,
    ) -> Result<Option<CredentialRecord>, AgentError> {
        let service_did = &inspection.document.service.did;
        if let Some(credential_id) = options.credential_id.as_deref() {
            let Some(record) = self
                .client
                .credential_store
                .find(service_did, credential_id)
                .await?
            else {
                return Ok(None);
            };
            validate_record(&record, service_did, self.client.clock.now())?;
            if options
                .grant_type
                .as_ref()
                .is_some_and(|grant_type| grant_type != &record.grant_type)
            {
                return Err(AgentError::Credential(
                    "stored AEP credential does not match the requested grant type".to_owned(),
                ));
            }
            if !methods
                .iter()
                .any(|method| method.as_str() == record.grant_type.as_str())
            {
                return Err(AgentError::NoAuthenticationMethod);
            }
            return Ok(Some(record));
        }
        let records = self.client.credential_store.list(service_did).await?;
        for method in methods {
            if let Some(record) = records.iter().find(|record| {
                method.as_str() == record.grant_type.as_str()
                    && options
                        .grant_type
                        .as_ref()
                        .is_none_or(|grant_type| grant_type == &record.grant_type)
            }) {
                validate_record(record, service_did, self.client.clock.now())?;
                return Ok(Some(record.clone()));
            }
        }
        Ok(None)
    }
}

fn implicit_credential_methods<'a>(
    methods: &'a [AuthenticationMethod],
    options: &AuthenticationOptions,
) -> &'a [AuthenticationMethod] {
    if options.credential_id.is_some() || options.grant_type.is_some() {
        return methods;
    }
    methods
        .iter()
        .position(|method| method == &AuthenticationMethod::AepJwt)
        .map_or(methods, |index| &methods[..index])
}

fn credential_authentication(
    record: &CredentialRecord,
    carrier: AuthorizationCarrier,
    now: time::OffsetDateTime,
) -> Result<AuthenticationResult, AgentError> {
    validate_record(record, &record.service_did, now)?;
    let encoded = serde_json::to_vec(&record.payload)?;
    let credential = parse_built_in_grant_response(&record.grant_type, &encoded)?;
    match credential {
        BuiltInGrantResponse::OAuthBearer(value) => {
            let (name, value) = render_protected_resource_authorization(&authorization(
                carrier,
                CredentialScheme::Bearer,
                value.access_token,
            ))
            .map_err(|error| AgentError::Credential(error.to_string()))?;
            Ok(AuthenticationResult {
                headers: one_header(&name, &value)?,
                method: AuthenticationMethod::OAuthBearer,
            })
        }
        BuiltInGrantResponse::ApiKey(value) => Ok(AuthenticationResult {
            headers: one_header(&value.header, &value.api_key)?,
            method: AuthenticationMethod::ApiKey,
        }),
        BuiltInGrantResponse::Basic(value) => {
            let credentials = STANDARD.encode(format!("{}:{}", value.username, value.password));
            let (name, value) = render_protected_resource_authorization(&authorization(
                carrier,
                CredentialScheme::Basic,
                credentials,
            ))
            .map_err(|error| AgentError::Credential(error.to_string()))?;
            Ok(AuthenticationResult {
                headers: one_header(&name, &value)?,
                method: AuthenticationMethod::Basic,
            })
        }
    }
}

pub(crate) fn validate_record(
    record: &CredentialRecord,
    service_did: &str,
    now: time::OffsetDateTime,
) -> Result<(), AgentError> {
    if record.credential_id.is_empty()
        || record.service_did != service_did
        || record.expires_at <= now
    {
        return Err(AgentError::Credential(
            "stored AEP credential metadata is invalid".to_owned(),
        ));
    }
    let encoded = serde_json::to_vec(&record.payload)?;
    let credential = parse_built_in_grant_response(&record.grant_type, &encoded)?;
    let (credential_id, expires_at) = match credential {
        BuiltInGrantResponse::OAuthBearer(value) => (value.credential_id, value.expires_at),
        BuiltInGrantResponse::ApiKey(value) => (value.credential_id, value.expires_at),
        BuiltInGrantResponse::Basic(value) => (value.credential_id, value.expires_at),
    };
    let expires_at =
        time::OffsetDateTime::parse(&expires_at, &time::format_description::well_known::Rfc3339)
            .map_err(|_| {
                AgentError::Credential("stored AEP credential expiration is invalid".to_owned())
            })?;
    if credential_id != record.credential_id || expires_at != record.expires_at {
        return Err(AgentError::Credential(
            "stored AEP credential metadata does not match its payload".to_owned(),
        ));
    }
    Ok(())
}

fn validate_resource(
    resource: &url::Url,
    service: &url::Url,
    allow_insecure_loopback: bool,
) -> Result<(), AgentError> {
    if !resource.username().is_empty()
        || resource.password().is_some()
        || resource.fragment().is_some()
        || resource.host_str().is_none()
    {
        return Err(AgentError::InvalidServiceReference(
            "AEP protected resource URL is invalid".to_owned(),
        ));
    }
    if resource.scheme() != "https"
        && !(allow_insecure_loopback && resource.scheme() == "http" && crate::is_loopback(resource))
    {
        return Err(AgentError::InvalidServiceReference(
            "AEP protected resource requires HTTPS".to_owned(),
        ));
    }
    if !same_origin(resource, service) {
        return Err(AgentError::InvalidServiceReference(
            "AEP protected resource must use the Service origin".to_owned(),
        ));
    }
    Ok(())
}

fn one_header(name: &str, value: &str) -> Result<HeaderMap, AgentError> {
    let name = HeaderName::from_bytes(name.as_bytes())
        .map_err(|_| AgentError::Credential("AEP credential header name is invalid".to_owned()))?;
    let value = HeaderValue::from_str(value)
        .map_err(|_| AgentError::Credential("AEP credential header value is invalid".to_owned()))?;
    let mut headers = HeaderMap::new();
    headers.insert(name, value);
    Ok(headers)
}