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}