Skip to main content

aep_agent/
client.rs

1use std::{net::IpAddr, str::FromStr, 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
256pub(crate) fn is_loopback(url: &Url) -> bool {
257    url.host_str().is_some_and(|host| {
258        host.eq_ignore_ascii_case("localhost")
259            || IpAddr::from_str(host).is_ok_and(|address| address.is_loopback())
260    })
261}
262
263pub(crate) fn same_origin(left: &Url, right: &Url) -> bool {
264    left.scheme().eq_ignore_ascii_case(right.scheme())
265        && left
266            .host_str()
267            .zip(right.host_str())
268            .is_some_and(|(left, right)| left.eq_ignore_ascii_case(right))
269        && left.port_or_known_default() == right.port_or_known_default()
270}
271
272pub(crate) fn validate_identity(
273    identity: &AgentIdentity,
274    inspection: &Inspection,
275) -> Result<(), AgentError> {
276    if !identity.agent_did.starts_with("did:")
277        || identity.service_did != inspection.document.service.did
278        || identity.signing_algorithms.is_empty()
279    {
280        return Err(AgentError::Identity(
281            "AEP identity provider returned an invalid Service-scoped identity".to_owned(),
282        ));
283    }
284    if identity.identity_method != IdentityMethod::DidWeb
285        || !identity.agent_did.starts_with("did:web:")
286    {
287        return Err(AgentError::Identity(
288            "AEP Agent identity method has no supported origin binding".to_owned(),
289        ));
290    }
291    if !inspection
292        .document
293        .identity
294        .methods
295        .contains(&identity.identity_method)
296    {
297        return Err(AgentError::Identity(
298            "AEP Service does not advertise the Agent identity method".to_owned(),
299        ));
300    }
301    if compatible_algorithms(
302        &identity.signing_algorithms,
303        &inspection.document.core.signing_algorithms,
304    )
305    .is_empty()
306    {
307        return Err(AgentError::Identity(
308            "AEP identity and Service have no compatible signing algorithm".to_owned(),
309        ));
310    }
311    Ok(())
312}
313
314fn compatible_algorithms(
315    available: &[SigningAlgorithm],
316    advertised: &[SigningAlgorithm],
317) -> Vec<SigningAlgorithm> {
318    advertised
319        .iter()
320        .filter(|algorithm| available.contains(algorithm))
321        .cloned()
322        .collect()
323}