Skip to main content

aep_agent/
client.rs

1use std::{sync::Arc, time::Duration};
2
3use aep_core::{
4    ClientAssertionClaims, DidWebDocumentUrlOptions, HttpTransport, IdentityMethod,
5    MAX_ASSERTION_LIFETIME, SigningAlgorithm, did_web_document_url_with_options,
6};
7use url::Url;
8use uuid::Uuid;
9
10use crate::{
11    AgentError, AgentIdentity, AssertionSigner, ClientOptions, Clock, CredentialStore, Delay,
12    IdempotencyKeyProvider, IdentityProvider, IdentityRequest, IdentityStore, InspectCache,
13    Inspection, MemoryCredentialStore, MemoryIdentityStore, MemoryInspectCache,
14    RandomIdempotencyKeyProvider, ReqwestTransport, SystemClock, TimerDelay,
15};
16
17pub struct Client {
18    pub(crate) allow_insecure_loopback: bool,
19    pub(crate) assertion_lifetime: Duration,
20    pub(crate) clock: Arc<dyn Clock>,
21    pub(crate) command_transport: Arc<dyn HttpTransport>,
22    pub(crate) credential_store: Arc<dyn CredentialStore>,
23    pub(crate) delay: Arc<dyn Delay>,
24    pub(crate) identity_provider: Arc<dyn IdentityProvider>,
25    pub(crate) identity_lock: futures::lock::Mutex<()>,
26    pub(crate) identity_store: Arc<dyn IdentityStore>,
27    pub(crate) idempotency_keys: Arc<dyn IdempotencyKeyProvider>,
28    pub(crate) inspect_cache: Arc<dyn InspectCache>,
29    pub(crate) inspect_transport: Arc<dyn HttpTransport>,
30    pub(crate) maximum_response_bytes: usize,
31}
32
33impl Client {
34    pub fn new(options: ClientOptions) -> Result<Arc<Self>, AgentError> {
35        if options.assertion_lifetime < Duration::from_secs(1)
36            || options.assertion_lifetime > MAX_ASSERTION_LIFETIME
37            || options.assertion_lifetime.subsec_nanos() != 0
38        {
39            return Err(AgentError::InvalidConfiguration(
40                "AEP Agent assertion lifetime must be whole seconds from 1 through 300".to_owned(),
41            ));
42        }
43        if options.maximum_response_bytes == 0 {
44            return Err(AgentError::InvalidConfiguration(
45                "AEP Agent maximum response bytes must be positive".to_owned(),
46            ));
47        }
48        if options.request_timeout.is_zero() {
49            return Err(AgentError::InvalidConfiguration(
50                "AEP Agent request timeout must be positive".to_owned(),
51            ));
52        }
53        let clock = options.clock.unwrap_or_else(|| Arc::new(SystemClock));
54        let new_transport = || -> Result<Arc<dyn HttpTransport>, AgentError> {
55            Ok(Arc::new(
56                ReqwestTransport::new(options.maximum_response_bytes, options.request_timeout)
57                    .map_err(|error| AgentError::Transport(error.to_string()))?,
58            ))
59        };
60        let default_transport =
61            if options.inspect_transport.is_none() || options.command_transport.is_none() {
62                Some(new_transport()?)
63            } else {
64                None
65            };
66        let inspect_transport = resolve_transport(options.inspect_transport, &default_transport)?;
67        let command_transport = resolve_transport(options.command_transport, &default_transport)?;
68        Ok(Arc::new(Self {
69            allow_insecure_loopback: options.allow_insecure_loopback,
70            assertion_lifetime: options.assertion_lifetime,
71            command_transport,
72            credential_store: options
73                .credential_store
74                .unwrap_or_else(|| Arc::new(MemoryCredentialStore::new(clock.clone()))),
75            delay: options.delay.unwrap_or_else(|| Arc::new(TimerDelay)),
76            identity_provider: options.identity_provider,
77            identity_lock: futures::lock::Mutex::new(()),
78            identity_store: options
79                .identity_store
80                .unwrap_or_else(|| Arc::new(MemoryIdentityStore::default())),
81            idempotency_keys: options
82                .idempotency_keys
83                .unwrap_or_else(|| Arc::new(RandomIdempotencyKeyProvider)),
84            inspect_cache: options
85                .inspect_cache
86                .unwrap_or_else(|| Arc::new(MemoryInspectCache::default())),
87            inspect_transport,
88            maximum_response_bytes: options.maximum_response_bytes,
89            clock,
90        }))
91    }
92
93    pub fn service(self: &Arc<Self>, reference: &str) -> Result<Session, AgentError> {
94        Ok(Session {
95            client: self.clone(),
96            inspect_lock: Arc::new(futures::lock::Mutex::new(())),
97            service_url: resolve_service_reference(reference, self.allow_insecure_loopback)?,
98        })
99    }
100
101    pub(crate) async fn sign_assertion(
102        &self,
103        inspection: &Inspection,
104        identity: &AgentIdentity,
105        signer: &dyn AssertionSigner,
106        operation: aep_core::AssertionOperation,
107        resource: Option<&Url>,
108    ) -> Result<String, AgentError> {
109        validate_identity(identity, inspection)?;
110        let iat = self.clock.now().unix_timestamp();
111        let lifetime = i64::try_from(self.assertion_lifetime.as_secs())
112            .map_err(|_| AgentError::Identity("AEP assertion lifetime is too large".to_owned()))?;
113        let claims = ClientAssertionClaims {
114            aud: inspection.document.service.did.clone(),
115            exp: iat.checked_add(lifetime).ok_or_else(|| {
116                AgentError::Identity(
117                    "AEP assertion expiration exceeds the supported time range".to_owned(),
118                )
119            })?,
120            iat,
121            iss: identity.agent_did.clone(),
122            jti: Uuid::new_v4().to_string(),
123            op: operation,
124            resource: resource.map(Url::to_string),
125            sub: identity.agent_did.clone(),
126            additional: Default::default(),
127        };
128        aep_core::validate_client_assertion_claims_with_options(
129            &claims,
130            aep_core::ClientAssertionValidationOptions {
131                allow_insecure_loopback: self.allow_insecure_loopback,
132            },
133        )
134        .map_err(aep_core::CoreError::from)?;
135        let algorithms = compatible_algorithms(
136            &identity.signing_algorithms,
137            &inspection.document.core.signing_algorithms,
138        );
139        if algorithms.is_empty() {
140            return Err(AgentError::Identity(
141                "AEP identity and Service have no compatible signing algorithm".to_owned(),
142            ));
143        }
144        let assertion = signer.sign(&claims, &algorithms).await?;
145        if assertion.is_empty() {
146            return Err(AgentError::Identity(
147                "AEP assertion signer returned an empty assertion".to_owned(),
148            ));
149        }
150        Ok(assertion)
151    }
152}
153
154fn resolve_transport(
155    provided: Option<Arc<dyn HttpTransport>>,
156    default: &Option<Arc<dyn HttpTransport>>,
157) -> Result<Arc<dyn HttpTransport>, AgentError> {
158    provided.or_else(|| default.clone()).ok_or_else(|| {
159        AgentError::InvalidConfiguration("AEP Agent HTTP transport is unavailable".to_owned())
160    })
161}
162
163#[derive(Clone)]
164pub struct Session {
165    pub(crate) client: Arc<Client>,
166    pub(crate) inspect_lock: Arc<futures::lock::Mutex<()>>,
167    pub(crate) service_url: Url,
168}
169
170impl Session {
171    pub fn service_url(&self) -> &Url {
172        &self.service_url
173    }
174
175    pub async fn identity(&self) -> Result<AgentIdentity, AgentError> {
176        let inspection = self.inspect().await?;
177        self.resolve_identity(&inspection, true).await
178    }
179
180    pub(crate) async fn resolve_identity(
181        &self,
182        inspection: &Inspection,
183        create: bool,
184    ) -> Result<AgentIdentity, AgentError> {
185        let _guard = self.client.identity_lock.lock().await;
186        let service_did = &inspection.document.service.did;
187        if let Some(identity) = self.client.identity_store.find(service_did).await? {
188            validate_identity(&identity, inspection)?;
189            return Ok(identity);
190        }
191        if !create {
192            return Err(AgentError::Identity(
193                "AEP Grant requires an existing enrolled identity".to_owned(),
194            ));
195        }
196        let identity = self
197            .client
198            .identity_provider
199            .get_or_create_identity(IdentityRequest {
200                inspection: inspection.clone(),
201            })
202            .await?;
203        validate_identity(&identity, inspection)?;
204        self.client.identity_store.save(identity.clone()).await?;
205        Ok(identity)
206    }
207}
208
209fn resolve_service_reference(
210    reference: &str,
211    allow_insecure_loopback: bool,
212) -> Result<Url, AgentError> {
213    let value = reference.trim();
214    if value.is_empty() {
215        return Err(AgentError::InvalidServiceReference(
216            "invalid AEP Service reference".to_owned(),
217        ));
218    }
219    let mut url = if value.starts_with("did:web:") {
220        let document = did_web_document_url_with_options(
221            value,
222            DidWebDocumentUrlOptions {
223                allow_insecure_loopback,
224            },
225        )?;
226        let mut origin = document;
227        origin.set_path("/");
228        origin.set_query(None);
229        origin.set_fragment(None);
230        origin
231    } else {
232        Url::parse(value).or_else(|_| Url::parse(&format!("https://{value}")))?
233    };
234    if !url.username().is_empty()
235        || url.password().is_some()
236        || url.host_str().is_none()
237        || url.cannot_be_a_base()
238    {
239        return Err(AgentError::InvalidServiceReference(
240            "invalid AEP Service reference".to_owned(),
241        ));
242    }
243    if url.scheme() != "https"
244        && !(allow_insecure_loopback && url.scheme() == "http" && is_loopback(&url))
245    {
246        return Err(AgentError::InvalidServiceReference(
247            "AEP Service references require HTTPS".to_owned(),
248        ));
249    }
250    url.set_path("/");
251    url.set_query(None);
252    url.set_fragment(None);
253    Ok(url)
254}
255
256/// Reports whether plaintext HTTP is admissible for this URL under the development affordance.
257///
258/// The rule is the one `aep-core` applies to `did:web` identifiers and OpenAPI URLs, so a
259/// reference this crate resolves through `aep-core` and then re-checks here reaches the same
260/// answer in both places.
261pub(crate) fn is_loopback(url: &Url) -> bool {
262    url.host_str().is_some_and(aep_core::is_loopback_host)
263}
264
265pub(crate) fn same_origin(left: &Url, right: &Url) -> bool {
266    left.scheme().eq_ignore_ascii_case(right.scheme())
267        && left
268            .host_str()
269            .zip(right.host_str())
270            .is_some_and(|(left, right)| left.eq_ignore_ascii_case(right))
271        && left.port_or_known_default() == right.port_or_known_default()
272}
273
274pub(crate) fn validate_identity(
275    identity: &AgentIdentity,
276    inspection: &Inspection,
277) -> Result<(), AgentError> {
278    if !identity.agent_did.starts_with("did:")
279        || identity.service_did != inspection.document.service.did
280        || identity.signing_algorithms.is_empty()
281    {
282        return Err(AgentError::Identity(
283            "AEP identity provider returned an invalid Service-scoped identity".to_owned(),
284        ));
285    }
286    if identity.identity_method != IdentityMethod::DidWeb
287        || !identity.agent_did.starts_with("did:web:")
288    {
289        return Err(AgentError::Identity(
290            "AEP Agent identity method has no supported origin binding".to_owned(),
291        ));
292    }
293    if !inspection
294        .document
295        .identity
296        .methods
297        .contains(&identity.identity_method)
298    {
299        return Err(AgentError::Identity(
300            "AEP Service does not advertise the Agent identity method".to_owned(),
301        ));
302    }
303    if compatible_algorithms(
304        &identity.signing_algorithms,
305        &inspection.document.core.signing_algorithms,
306    )
307    .is_empty()
308    {
309        return Err(AgentError::Identity(
310            "AEP identity and Service have no compatible signing algorithm".to_owned(),
311        ));
312    }
313    Ok(())
314}
315
316fn compatible_algorithms(
317    available: &[SigningAlgorithm],
318    advertised: &[SigningAlgorithm],
319) -> Vec<SigningAlgorithm> {
320    advertised
321        .iter()
322        .filter(|algorithm| available.contains(algorithm))
323        .cloned()
324        .collect()
325}