1use std::{collections::BTreeMap, sync::Arc, time::Duration};
2
3use aep_core::{
4 ClientAssertionClaims, DidWebDocumentUrlOptions, HttpRequest, HttpResponse, HttpTransport,
5 IdentityMethod, MEDIA_TYPE, PROBLEM_MEDIA_TYPE, SigningAlgorithm, VERSION,
6 did_web_document_url_with_options, is_version_compatible, parse_problem_details,
7};
8use async_trait::async_trait;
9use http::{HeaderMap, HeaderValue, Method, StatusCode, header};
10use serde::Deserialize;
11use serde_json::Value;
12use time::OffsetDateTime;
13use url::Url;
14use uuid::Uuid;
15
16use crate::{
17 AgentError, AgentIdentity, AssertionSigner, Clock, IdentityProvider, IdentityRequest,
18 ReqwestTransport, SystemClock, is_loopback, same_origin,
19};
20
21const PLATFORM_WELL_KNOWN_PATH: &str = "/.well-known/aep-platform";
22const MAXIMUM_REDIRECTS: usize = 5;
23const DEFAULT_DISCOVERY_FRESHNESS: Duration = Duration::from_secs(300);
24
25#[async_trait]
26pub trait PlatformAuthenticationHeaders: Send + Sync {
27 async fn headers(&self) -> Result<HeaderMap, AgentError>;
28}
29
30pub trait PlatformIdempotencyKeyProvider: Send + Sync {
31 fn create_key(&self) -> Result<String, AgentError>;
32}
33
34#[async_trait]
35pub trait PlatformContextProvider: Send + Sync {
36 async fn context(
37 &self,
38 identity: &AgentIdentity,
39 claims: &ClientAssertionClaims,
40 ) -> Result<BTreeMap<String, Value>, AgentError>;
41}
42
43#[derive(Clone, Debug, PartialEq)]
44pub struct PlatformPendingSign {
45 pub identity: AgentIdentity,
46 pub platform_context: BTreeMap<String, Value>,
47 pub retry_after: Duration,
48}
49
50#[async_trait]
51pub trait PlatformPendingSignResolver: Send + Sync {
52 async fn resolve(
53 &self,
54 pending: PlatformPendingSign,
55 ) -> Result<BTreeMap<String, Value>, AgentError>;
56}
57
58#[derive(Clone)]
59pub struct PlatformIdentityProviderOptions {
60 pub allow_insecure_loopback: bool,
61 pub authentication_headers: Option<Arc<dyn PlatformAuthenticationHeaders>>,
62 pub authorization: Option<String>,
63 pub clock: Option<Arc<dyn Clock>>,
64 pub idempotency_keys: Option<Arc<dyn PlatformIdempotencyKeyProvider>>,
65 pub maximum_response_bytes: usize,
66 pub pending_sign_resolver: Option<Arc<dyn PlatformPendingSignResolver>>,
67 pub platform_context: Option<Arc<dyn PlatformContextProvider>>,
68 pub platform_url: String,
69 pub request_timeout: Duration,
70 pub transport: Option<Arc<dyn HttpTransport>>,
71}
72
73impl PlatformIdentityProviderOptions {
74 pub fn new(platform_url: impl Into<String>) -> Self {
75 Self {
76 allow_insecure_loopback: false,
77 authentication_headers: None,
78 authorization: None,
79 clock: None,
80 idempotency_keys: None,
81 maximum_response_bytes: 1 << 20,
82 pending_sign_resolver: None,
83 platform_context: None,
84 platform_url: platform_url.into(),
85 request_timeout: Duration::from_secs(30),
86 transport: None,
87 }
88 }
89}
90
91#[derive(Clone)]
92pub struct PlatformIdentityProvider {
93 allow_insecure_loopback: bool,
94 authentication_headers: Option<Arc<dyn PlatformAuthenticationHeaders>>,
95 authorization: Option<HeaderValue>,
96 clock: Arc<dyn Clock>,
97 discovery: Arc<futures::lock::Mutex<Option<DiscoveryCacheEntry>>>,
98 idempotency_keys: Arc<dyn PlatformIdempotencyKeyProvider>,
99 maximum_response_bytes: usize,
100 pending_sign_resolver: Option<Arc<dyn PlatformPendingSignResolver>>,
101 platform_context: Option<Arc<dyn PlatformContextProvider>>,
102 platform_url: Url,
103 transport: Arc<dyn HttpTransport>,
104}
105
106impl PlatformIdentityProvider {
107 pub fn new(options: PlatformIdentityProviderOptions) -> Result<Arc<Self>, AgentError> {
108 if options.maximum_response_bytes == 0 {
109 return Err(AgentError::InvalidConfiguration(
110 "AEP Platform maximum response bytes must be positive".to_owned(),
111 ));
112 }
113 if options.request_timeout.is_zero() {
114 return Err(AgentError::InvalidConfiguration(
115 "AEP Platform request timeout must be positive".to_owned(),
116 ));
117 }
118 let platform_url = platform_url(&options.platform_url, options.allow_insecure_loopback)?;
119 let authorization = options
120 .authorization
121 .as_deref()
122 .map(HeaderValue::from_str)
123 .transpose()
124 .map_err(|_| {
125 AgentError::InvalidConfiguration(
126 "AEP Platform authorization is not a valid HTTP field value".to_owned(),
127 )
128 })?;
129 let transport = match options.transport {
130 Some(transport) => transport,
131 None => Arc::new(
132 ReqwestTransport::new(options.maximum_response_bytes, options.request_timeout)
133 .map_err(|error| AgentError::Transport(error.to_string()))?,
134 ),
135 };
136 Ok(Arc::new(Self {
137 allow_insecure_loopback: options.allow_insecure_loopback,
138 authentication_headers: options.authentication_headers,
139 authorization,
140 clock: options.clock.unwrap_or_else(|| Arc::new(SystemClock)),
141 discovery: Arc::new(futures::lock::Mutex::new(None)),
142 idempotency_keys: options
143 .idempotency_keys
144 .unwrap_or_else(|| Arc::new(RandomPlatformIdempotencyKeyProvider)),
145 maximum_response_bytes: options.maximum_response_bytes,
146 pending_sign_resolver: options.pending_sign_resolver,
147 platform_context: options.platform_context,
148 platform_url,
149 transport,
150 }))
151 }
152
153 pub async fn find_identity_by_service_did(
154 &self,
155 service_did: &str,
156 ) -> Result<Option<AgentIdentity>, AgentError> {
157 validate_did(service_did, "AEP Service DID")?;
158 let discovery = self.discover().await?;
159 let mut endpoint = self.endpoint(&discovery.document.endpoints.list, None)?;
160 endpoint
161 .query_pairs_mut()
162 .append_pair("descending", "true")
163 .append_pair("limit", "100")
164 .append_pair("service_did", service_did);
165 let response: PlatformCommandResult<PlatformIdentityList> =
166 self.command(Method::GET, endpoint, None, None).await?;
167 validate_identity_list(&response.body, self.allow_insecure_loopback)?;
168 response
169 .body
170 .data
171 .into_iter()
172 .find(|identity| identity.service_did == service_did && identity.status == "active")
173 .map(|identity| self.agent_identity(identity))
174 .transpose()
175 }
176
177 async fn discover(&self) -> Result<DiscoveryCacheEntry, AgentError> {
178 let mut cache = self.discovery.lock().await;
179 if let Some(entry) = cache
180 .as_ref()
181 .filter(|entry| discovery_fresh(entry, self.clock.now()))
182 {
183 return Ok(entry.clone());
184 }
185 let discovery_url = self.platform_url.join(PLATFORM_WELL_KNOWN_PATH)?;
186 let mut current = cache
187 .as_ref()
188 .map_or_else(|| discovery_url.clone(), |entry| entry.final_url.clone());
189 for redirects in 0..=MAXIMUM_REDIRECTS {
190 let mut headers = HeaderMap::new();
191 headers.insert(header::ACCEPT, HeaderValue::from_static(MEDIA_TYPE));
192 if let Some(entry) = cache.as_ref() {
193 if let Some(value) = entry.etag.as_deref().and_then(header_value) {
194 headers.insert(header::IF_NONE_MATCH, value);
195 }
196 if let Some(value) = entry.last_modified.as_deref().and_then(header_value) {
197 headers.insert(header::IF_MODIFIED_SINCE, value);
198 }
199 }
200 let response = self
201 .send(Method::GET, current.clone(), headers, Vec::new())
202 .await?;
203 if response.final_url != current {
204 return Err(AgentError::Transport(
205 "AEP Platform transport followed a discovery redirect".to_owned(),
206 ));
207 }
208 if is_redirect(response.status) {
209 if redirects == MAXIMUM_REDIRECTS {
210 return Err(AgentError::Transport(
211 "AEP Platform discovery exceeded five redirects".to_owned(),
212 ));
213 }
214 let location = response
215 .headers
216 .get(header::LOCATION)
217 .and_then(|value| value.to_str().ok())
218 .ok_or_else(|| {
219 AgentError::Transport(
220 "AEP Platform discovery redirect omitted Location".to_owned(),
221 )
222 })?;
223 let next = current.join(location).map_err(|_| {
224 AgentError::Transport(
225 "AEP Platform discovery redirect Location is invalid".to_owned(),
226 )
227 })?;
228 if !safe_discovery_target(&next, ¤t) {
229 return Err(AgentError::Transport(
230 "AEP Platform discovery redirect changed origin or scheme".to_owned(),
231 ));
232 }
233 current = next;
234 continue;
235 }
236 let entry = if response.status == StatusCode::NOT_MODIFIED {
237 let mut entry = cache.clone().ok_or_else(|| {
238 AgentError::Transport(
239 "AEP Platform discovery returned 304 without a cached document".to_owned(),
240 )
241 })?;
242 entry.cached_at = self.clock.now();
243 entry.final_url = current;
244 merge_cache_headers(&mut entry, &response.headers);
245 entry
246 } else {
247 self.parse_discovery(response, current)?
248 };
249 if cache_directive(entry.cache_control.as_deref(), "no-store").is_some() {
250 *cache = None;
251 } else {
252 *cache = Some(entry.clone());
253 }
254 return Ok(entry);
255 }
256 unreachable!("discovery redirect loop returns or continues within its bound")
257 }
258
259 fn parse_discovery(
260 &self,
261 response: HttpResponse,
262 final_url: Url,
263 ) -> Result<DiscoveryCacheEntry, AgentError> {
264 if !response.status.is_success() {
265 return Err(AgentError::Transport(format!(
266 "AEP Platform discovery failed with HTTP {}",
267 response.status.as_u16()
268 )));
269 }
270 validate_media_type(&response.headers, MEDIA_TYPE, "discovery")?;
271 self.validate_response_size(&response.body)?;
272 let document: PlatformDiscovery = serde_json::from_slice(&response.body).map_err(|_| {
273 AgentError::Identity("AEP Platform discovery document is invalid".to_owned())
274 })?;
275 validate_discovery(&document, self.allow_insecure_loopback)?;
276 Ok(DiscoveryCacheEntry {
277 cache_control: header_string(&response.headers, header::CACHE_CONTROL),
278 cached_at: self.clock.now(),
279 document,
280 etag: header_string(&response.headers, header::ETAG),
281 final_url,
282 last_modified: header_string(&response.headers, header::LAST_MODIFIED),
283 })
284 }
285
286 async fn command<T: for<'de> Deserialize<'de>>(
287 &self,
288 method: Method,
289 endpoint: Url,
290 idempotency_key: Option<&str>,
291 body: Option<Value>,
292 ) -> Result<PlatformCommandResult<T>, AgentError> {
293 let mut headers = self.headers().await?;
294 headers.insert(header::ACCEPT, HeaderValue::from_static(MEDIA_TYPE));
295 let encoded = body
296 .map(|value| serde_json::to_vec(&value))
297 .transpose()?
298 .unwrap_or_default();
299 if !encoded.is_empty() {
300 headers.insert(header::CONTENT_TYPE, HeaderValue::from_static(MEDIA_TYPE));
301 }
302 if let Some(key) = idempotency_key {
303 headers.insert(
304 "idempotency-key",
305 HeaderValue::from_str(key).map_err(|_| {
306 AgentError::InvalidConfiguration(
307 "AEP Platform idempotency key is not a valid HTTP field value".to_owned(),
308 )
309 })?,
310 );
311 }
312 let response = self
313 .send(method, endpoint.clone(), headers, encoded)
314 .await?;
315 if response.final_url != endpoint {
316 return Err(AgentError::Transport(
317 "AEP Platform command redirects are not allowed".to_owned(),
318 ));
319 }
320 self.validate_response_size(&response.body)?;
321 if !response.status.is_success() {
322 let problem = media_type_matches(&response.headers, PROBLEM_MEDIA_TYPE)
323 .then(|| parse_problem_details(&response.body).ok())
324 .flatten()
325 .filter(|problem| problem.status == i64::from(response.status.as_u16()))
326 .map(Box::new);
327 return Err(AgentError::PlatformCommand {
328 status: response.status.as_u16(),
329 problem,
330 });
331 }
332 validate_media_type(&response.headers, MEDIA_TYPE, "command")?;
333 let body = serde_json::from_slice(&response.body).map_err(|_| {
334 AgentError::Identity("AEP Platform response is invalid JSON".to_owned())
335 })?;
336 Ok(PlatformCommandResult {
337 body,
338 headers: response.headers,
339 status: response.status,
340 })
341 }
342
343 async fn headers(&self) -> Result<HeaderMap, AgentError> {
344 let mut headers = HeaderMap::new();
345 if let Some(value) = &self.authorization {
346 headers.insert(header::AUTHORIZATION, value.clone());
347 }
348 if let Some(provider) = &self.authentication_headers {
349 for (name, value) in provider.headers().await? {
350 let Some(name) = name else { continue };
351 if name == header::ACCEPT
352 || name == header::CONTENT_TYPE
353 || name.as_str().eq_ignore_ascii_case("idempotency-key")
354 {
355 continue;
356 }
357 headers.insert(name, value);
358 }
359 }
360 Ok(headers)
361 }
362
363 async fn send(
364 &self,
365 method: Method,
366 url: Url,
367 headers: HeaderMap,
368 body: Vec<u8>,
369 ) -> Result<HttpResponse, AgentError> {
370 self.transport
371 .send(HttpRequest {
372 method,
373 url,
374 headers,
375 body,
376 })
377 .await
378 .map_err(|error| AgentError::Transport(error.to_string()))
379 }
380
381 fn endpoint(&self, path: &str, identity_id: Option<&str>) -> Result<Url, AgentError> {
382 let path = match identity_id {
383 Some(identity_id) => path.replace("{agent_identity_id}", &encode_path(identity_id)),
384 None => path.to_owned(),
385 };
386 if !valid_endpoint_path(&path) || path.contains('{') {
387 return Err(AgentError::Identity(
388 "AEP Platform advertised an invalid endpoint".to_owned(),
389 ));
390 }
391 let endpoint = self.platform_url.join(&path)?;
392 if !same_origin(&endpoint, &self.platform_url) {
393 return Err(AgentError::Identity(
394 "AEP Platform endpoint changed origin".to_owned(),
395 ));
396 }
397 Ok(endpoint)
398 }
399
400 fn agent_identity(&self, identity: PlatformAgentIdentity) -> Result<AgentIdentity, AgentError> {
401 validate_platform_identity(&identity, self.allow_insecure_loopback)?;
402 Ok(AgentIdentity {
403 agent_did: identity.agent_did,
404 identity_method: IdentityMethod::DidWeb,
405 service_did: identity.service_did,
406 signing_algorithms: identity.signing_algorithms,
407 metadata: BTreeMap::from([
408 ("agent_identity_id".to_owned(), identity.agent_identity_id),
409 ("created_at".to_owned(), identity.created_at),
410 ("did_document_url".to_owned(), identity.did_document_url),
411 ("key_id".to_owned(), identity.key_id),
412 ("platform_url".to_owned(), self.platform_url.to_string()),
413 ("status".to_owned(), identity.status),
414 ("updated_at".to_owned(), identity.updated_at),
415 ]),
416 })
417 }
418
419 fn validate_owned_identity(&self, identity: &AgentIdentity) -> Result<String, AgentError> {
420 let Some(identity_id) = identity
421 .metadata
422 .get("agent_identity_id")
423 .filter(|value| !value.is_empty())
424 .cloned()
425 else {
426 return Err(AgentError::Identity(
427 "AEP identity is not an active identity from this Platform".to_owned(),
428 ));
429 };
430 if identity.identity_method != IdentityMethod::DidWeb
431 || !identity.agent_did.starts_with("did:web:")
432 || identity.service_did.is_empty()
433 || identity.signing_algorithms.is_empty()
434 || identity.metadata.get("platform_url") != Some(&self.platform_url.to_string())
435 || identity.metadata.get("status").map(String::as_str) != Some("active")
436 {
437 return Err(AgentError::Identity(
438 "AEP identity is not an active identity from this Platform".to_owned(),
439 ));
440 }
441 Ok(identity_id)
442 }
443
444 fn validate_response_size(&self, body: &[u8]) -> Result<(), AgentError> {
445 if body.len() > self.maximum_response_bytes {
446 return Err(AgentError::Transport(
447 "AEP Platform response exceeds the configured limit".to_owned(),
448 ));
449 }
450 Ok(())
451 }
452
453 fn idempotency_key(&self) -> Result<String, AgentError> {
454 let key = self.idempotency_keys.create_key()?;
455 if key.trim().is_empty() {
456 return Err(AgentError::InvalidConfiguration(
457 "AEP Platform idempotency key provider returned an empty key".to_owned(),
458 ));
459 }
460 Ok(key)
461 }
462}
463
464#[async_trait]
465impl IdentityProvider for PlatformIdentityProvider {
466 async fn get_or_create_identity(
467 &self,
468 request: IdentityRequest,
469 ) -> Result<AgentIdentity, AgentError> {
470 let service_did = request.inspection.document.service.did;
471 if let Some(identity) = self.find_identity_by_service_did(&service_did).await? {
472 return Ok(identity);
473 }
474 let discovery = self.discover().await?;
475 let endpoint = self.endpoint(&discovery.document.endpoints.provision, None)?;
476 let key = self.idempotency_key()?;
477 let provisioned: PlatformCommandResult<PlatformAgentIdentity> = self
478 .command(
479 Method::POST,
480 endpoint,
481 Some(&key),
482 Some(serde_json::json!({ "service_did": service_did })),
483 )
484 .await?;
485 if provisioned.body.service_did != service_did || provisioned.body.status != "active" {
486 return Err(AgentError::Identity(
487 "AEP Platform provisioned an identity outside the requested Service scope"
488 .to_owned(),
489 ));
490 }
491 self.agent_identity(provisioned.body)
492 }
493
494 async fn signer_for(
495 &self,
496 identity: &AgentIdentity,
497 ) -> Result<Arc<dyn AssertionSigner>, AgentError> {
498 let identity_id = self.validate_owned_identity(identity)?;
499 Ok(Arc::new(PlatformAssertionSigner {
500 identity: identity.clone(),
501 identity_id,
502 provider: Arc::new(self.clone()),
503 }))
504 }
505}
506
507struct PlatformAssertionSigner {
508 identity: AgentIdentity,
509 identity_id: String,
510 provider: Arc<PlatformIdentityProvider>,
511}
512
513#[async_trait]
514impl AssertionSigner for PlatformAssertionSigner {
515 async fn sign(
516 &self,
517 claims: &ClientAssertionClaims,
518 algorithms: &[SigningAlgorithm],
519 ) -> Result<String, AgentError> {
520 if claims.iss != self.identity.agent_did
521 || claims.sub != self.identity.agent_did
522 || claims.aud != self.identity.service_did
523 {
524 return Err(AgentError::Identity(
525 "AEP Platform signer received claims for another identity".to_owned(),
526 ));
527 }
528 if !algorithms
529 .iter()
530 .any(|algorithm| self.identity.signing_algorithms.contains(algorithm))
531 {
532 return Err(AgentError::Identity(
533 "AEP Platform and Service have no compatible signing algorithm".to_owned(),
534 ));
535 }
536 let mut context = match &self.provider.platform_context {
537 Some(provider) => provider.context(&self.identity, claims).await?,
538 None => BTreeMap::new(),
539 };
540 let mut previous_key = None;
541 loop {
542 let key = self.provider.idempotency_key()?;
543 if previous_key.as_ref() == Some(&key) {
544 return Err(AgentError::InvalidConfiguration(
545 "AEP Platform pending Sign stages require distinct idempotency keys".to_owned(),
546 ));
547 }
548 let result = self.sign_once(claims, context, &key).await?;
549 if result.body.status == "completed" {
550 if result.status != StatusCode::OK {
551 return Err(AgentError::Identity(
552 "AEP Platform returned an invalid completed Sign response".to_owned(),
553 ));
554 }
555 return validate_completed_sign(&result.body, claims, &self.identity);
556 }
557 if result.status != StatusCode::ACCEPTED
558 || result.headers.contains_key(header::RETRY_AFTER)
559 {
560 return Err(AgentError::Identity(
561 "AEP Platform returned an invalid pending Sign response".to_owned(),
562 ));
563 }
564 let retry_after = validate_pending_sign(&result.body)?;
565 let pending = PlatformPendingSign {
566 identity: self.identity.clone(),
567 platform_context: result.body.platform_context,
568 retry_after,
569 };
570 let Some(resolver) = &self.provider.pending_sign_resolver else {
571 return Err(AgentError::PlatformSignPending {
572 pending: Box::new(pending),
573 });
574 };
575 previous_key = Some(key);
576 context = resolver.resolve(pending).await?;
577 }
578 }
579}
580
581impl PlatformAssertionSigner {
582 async fn sign_once(
583 &self,
584 claims: &ClientAssertionClaims,
585 platform_context: BTreeMap<String, Value>,
586 key: &str,
587 ) -> Result<PlatformCommandResult<PlatformSignResponse>, AgentError> {
588 let discovery = self.provider.discover().await?;
589 let endpoint = self
590 .provider
591 .endpoint(&discovery.document.endpoints.sign, Some(&self.identity_id))?;
592 let lifetime = claims.exp.checked_sub(claims.iat).ok_or_else(|| {
593 AgentError::Identity("AEP Platform signing lifetime is invalid".to_owned())
594 })?;
595 let mut body = serde_json::Map::from_iter([
596 ("jti".to_owned(), Value::String(claims.jti.clone())),
597 (
598 "lifetime_seconds".to_owned(),
599 Value::String(lifetime.to_string()),
600 ),
601 ("op".to_owned(), serde_json::to_value(claims.op)?),
602 (
603 "platform_context".to_owned(),
604 serde_json::to_value(platform_context)?,
605 ),
606 ("service_did".to_owned(), Value::String(claims.aud.clone())),
607 ]);
608 if let Some(resource) = &claims.resource {
609 body.insert("resource".to_owned(), Value::String(resource.clone()));
610 }
611 self.provider
612 .command(Method::POST, endpoint, Some(key), Some(Value::Object(body)))
613 .await
614 }
615}
616
617struct RandomPlatformIdempotencyKeyProvider;
618
619impl PlatformIdempotencyKeyProvider for RandomPlatformIdempotencyKeyProvider {
620 fn create_key(&self) -> Result<String, AgentError> {
621 Ok(Uuid::new_v4().to_string())
622 }
623}
624
625#[derive(Clone, Debug, Deserialize)]
626struct PlatformDiscovery {
627 aep_version: String,
628 endpoints: PlatformEndpoints,
629 http: PlatformHttp,
630 identity: PlatformIdentityConfiguration,
631 platform: PlatformDescription,
632 signing: PlatformSigning,
633}
634
635#[derive(Clone, Debug, Deserialize)]
636struct PlatformEndpoints {
637 hosted_verification: Option<String>,
638 lifecycle: String,
639 list: String,
640 provision: String,
641 sign: String,
642}
643
644#[derive(Clone, Debug, Deserialize)]
645struct PlatformHttp {
646 endpoint_base: String,
647}
648
649#[derive(Clone, Debug, Deserialize)]
650struct PlatformIdentityConfiguration {
651 did_methods: Vec<String>,
652 did_url_template: String,
653}
654
655#[derive(Clone, Debug, Deserialize)]
656struct PlatformDescription {
657 did: Option<String>,
658 hosted_verification: bool,
659 name: String,
660}
661
662#[derive(Clone, Debug, Deserialize)]
663struct PlatformSigning {
664 algorithms: Vec<SigningAlgorithm>,
665 default_lifetime_seconds: String,
666}
667
668#[derive(Clone, Debug, Deserialize)]
669struct PlatformAgentIdentity {
670 agent_did: String,
671 agent_identity_id: String,
672 created_at: String,
673 did_document_url: String,
674 key_id: String,
675 service_did: String,
676 signing_algorithms: Vec<SigningAlgorithm>,
677 status: String,
678 updated_at: String,
679}
680
681#[derive(Deserialize)]
682struct PlatformIdentityList {
683 count: String,
684 data: Vec<PlatformAgentIdentity>,
685 total: String,
686}
687
688#[derive(Deserialize)]
689struct PlatformSignResponse {
690 agent_did: Option<String>,
691 client_assertion: Option<String>,
692 expires_at: Option<String>,
693 issued_at: Option<String>,
694 jti: Option<String>,
695 #[serde(default)]
696 platform_context: BTreeMap<String, Value>,
697 retry_after_seconds: Option<String>,
698 service_did: Option<String>,
699 status: String,
700}
701
702struct PlatformCommandResult<T> {
703 body: T,
704 headers: HeaderMap,
705 status: StatusCode,
706}
707
708#[derive(Clone)]
709struct DiscoveryCacheEntry {
710 cache_control: Option<String>,
711 cached_at: OffsetDateTime,
712 document: PlatformDiscovery,
713 etag: Option<String>,
714 final_url: Url,
715 last_modified: Option<String>,
716}
717
718fn platform_url(value: &str, allow_insecure_loopback: bool) -> Result<Url, AgentError> {
719 let mut url = Url::parse(value.trim())
720 .map_err(|_| AgentError::InvalidConfiguration("invalid AEP Platform URL".to_owned()))?;
721 if !url.username().is_empty()
722 || url.password().is_some()
723 || url.host_str().is_none()
724 || url.cannot_be_a_base()
725 || (url.scheme() != "https"
726 && !(allow_insecure_loopback && url.scheme() == "http" && is_loopback(&url)))
727 {
728 return Err(AgentError::InvalidConfiguration(
729 "invalid AEP Platform URL".to_owned(),
730 ));
731 }
732 url.set_path("/");
733 url.set_query(None);
734 url.set_fragment(None);
735 Ok(url)
736}
737
738fn validate_discovery(
739 document: &PlatformDiscovery,
740 allow_insecure_loopback: bool,
741) -> Result<(), AgentError> {
742 let lifetime = document
743 .signing
744 .default_lifetime_seconds
745 .parse::<u64>()
746 .ok();
747 let paths = [
748 &document.http.endpoint_base,
749 &document.endpoints.lifecycle,
750 &document.endpoints.list,
751 &document.endpoints.provision,
752 &document.endpoints.sign,
753 ];
754 if !is_version_compatible(&document.aep_version, VERSION)
755 || document.platform.name.is_empty()
756 || document
757 .identity
758 .did_methods
759 .iter()
760 .all(|method| method != "did:web")
761 || document.signing.algorithms.is_empty()
762 || lifetime.is_none_or(|value| value == 0 || value > 300)
763 || paths.iter().any(|path| !valid_endpoint_path(path))
764 || document
765 .endpoints
766 .lifecycle
767 .matches("{agent_identity_id}")
768 .count()
769 != 1
770 || document
771 .endpoints
772 .sign
773 .matches("{agent_identity_id}")
774 .count()
775 != 1
776 || document.endpoints.list.contains('{')
777 || document.endpoints.provision.contains('{')
778 || document.http.endpoint_base.contains('{')
779 || document
780 .identity
781 .did_url_template
782 .matches("{agent_did_id}")
783 .count()
784 != 1
785 || document.platform.hosted_verification != document.endpoints.hosted_verification.is_some()
786 || document
787 .platform
788 .did
789 .as_deref()
790 .is_some_and(|did| !did.starts_with("did:"))
791 || document.signing.algorithms.iter().any(|algorithm| {
792 !matches!(algorithm, SigningAlgorithm::EdDsa | SigningAlgorithm::Es256)
793 })
794 {
795 return invalid_discovery();
796 }
797 if document
798 .endpoints
799 .hosted_verification
800 .as_deref()
801 .is_some_and(|path| !valid_endpoint_path(path) || path.contains('{'))
802 {
803 return invalid_discovery();
804 }
805 let did_url = document
806 .identity
807 .did_url_template
808 .replace("{agent_did_id}", "validation");
809 let parsed = Url::parse(&did_url).map_err(|_| invalid_discovery_error())?;
810 if !parsed.username().is_empty()
811 || parsed.password().is_some()
812 || parsed.host_str().is_none()
813 || parsed.fragment().is_some()
814 || (parsed.scheme() != "https"
815 && !(allow_insecure_loopback && parsed.scheme() == "http" && is_loopback(&parsed)))
816 {
817 return invalid_discovery();
818 }
819 Ok(())
820}
821
822fn invalid_discovery<T>() -> Result<T, AgentError> {
823 Err(invalid_discovery_error())
824}
825
826fn invalid_discovery_error() -> AgentError {
827 AgentError::Identity("AEP Platform discovery document is invalid".to_owned())
828}
829
830fn validate_identity_list(
831 response: &PlatformIdentityList,
832 allow_insecure_loopback: bool,
833) -> Result<(), AgentError> {
834 let count = response.count.parse::<usize>().ok();
835 let total = response.total.parse::<usize>().ok();
836 if count != Some(response.data.len()) || total.is_none_or(|total| total < response.data.len()) {
837 return Err(AgentError::Identity(
838 "AEP Platform returned an invalid identity list".to_owned(),
839 ));
840 }
841 for identity in &response.data {
842 validate_platform_identity(identity, allow_insecure_loopback)?;
843 }
844 Ok(())
845}
846
847fn validate_platform_identity(
848 identity: &PlatformAgentIdentity,
849 allow_insecure_loopback: bool,
850) -> Result<(), AgentError> {
851 let valid_status = matches!(
852 identity.status.as_str(),
853 "active" | "revoked" | "suspended" | "terminated"
854 );
855 if identity.agent_identity_id.is_empty()
856 || !identity.agent_did.starts_with("did:web:")
857 || identity.key_id != identity.agent_did
858 || !identity.service_did.starts_with("did:")
859 || identity.signing_algorithms.is_empty()
860 || identity.signing_algorithms.iter().any(|algorithm| {
861 !matches!(algorithm, SigningAlgorithm::EdDsa | SigningAlgorithm::Es256)
862 })
863 || !valid_status
864 || OffsetDateTime::parse(
865 &identity.created_at,
866 &time::format_description::well_known::Rfc3339,
867 )
868 .is_err()
869 || OffsetDateTime::parse(
870 &identity.updated_at,
871 &time::format_description::well_known::Rfc3339,
872 )
873 .is_err()
874 {
875 return Err(AgentError::Identity(
876 "AEP Platform returned an invalid identity".to_owned(),
877 ));
878 }
879 let document_url = Url::parse(&identity.did_document_url).map_err(|_| {
880 AgentError::Identity("AEP Platform returned an invalid DID document URL".to_owned())
881 })?;
882 let expected = did_web_document_url_with_options(
883 &identity.agent_did,
884 DidWebDocumentUrlOptions {
885 allow_insecure_loopback,
886 },
887 )?;
888 if document_url != expected {
889 return Err(AgentError::Identity(
890 "AEP Platform DID document URL does not match the Agent DID".to_owned(),
891 ));
892 }
893 Ok(())
894}
895
896fn validate_completed_sign(
897 response: &PlatformSignResponse,
898 claims: &ClientAssertionClaims,
899 identity: &AgentIdentity,
900) -> Result<String, AgentError> {
901 let issued_at = response.issued_at.as_deref().and_then(|value| {
902 OffsetDateTime::parse(value, &time::format_description::well_known::Rfc3339).ok()
903 });
904 let expires_at = response.expires_at.as_deref().and_then(|value| {
905 OffsetDateTime::parse(value, &time::format_description::well_known::Rfc3339).ok()
906 });
907 let Some(assertion) = response
908 .client_assertion
909 .as_deref()
910 .filter(|assertion| !assertion.is_empty())
911 else {
912 return Err(AgentError::Identity(
913 "AEP Platform returned an invalid completed Sign response".to_owned(),
914 ));
915 };
916 if response.agent_did.as_deref() != Some(identity.agent_did.as_str())
917 || response.service_did.as_deref() != Some(identity.service_did.as_str())
918 || response.jti.as_deref() != Some(claims.jti.as_str())
919 || issued_at.map(OffsetDateTime::unix_timestamp) != Some(claims.iat)
920 || expires_at.map(OffsetDateTime::unix_timestamp) != Some(claims.exp)
921 {
922 return Err(AgentError::Identity(
923 "AEP Platform returned an invalid completed Sign response".to_owned(),
924 ));
925 }
926 Ok(assertion.to_owned())
927}
928
929fn validate_pending_sign(response: &PlatformSignResponse) -> Result<Duration, AgentError> {
930 if response.status != "pending"
931 || response.client_assertion.is_some()
932 || response.agent_did.is_some()
933 || response.service_did.is_some()
934 || response.jti.is_some()
935 || response.issued_at.is_some()
936 || response.expires_at.is_some()
937 {
938 return Err(AgentError::Identity(
939 "AEP Platform returned an invalid Sign status".to_owned(),
940 ));
941 }
942 let seconds = response
943 .retry_after_seconds
944 .as_deref()
945 .and_then(|value| value.parse::<u64>().ok())
946 .filter(|value| (1..=300).contains(value))
947 .ok_or_else(|| {
948 AgentError::Identity(
949 "AEP Platform returned an invalid pending Sign response".to_owned(),
950 )
951 })?;
952 Ok(Duration::from_secs(seconds))
953}
954
955fn validate_did(value: &str, name: &str) -> Result<(), AgentError> {
956 if !value.starts_with("did:") || value.len() <= 4 {
957 return Err(AgentError::Identity(format!("invalid {name}")));
958 }
959 Ok(())
960}
961
962fn valid_endpoint_path(path: &str) -> bool {
963 path.starts_with('/')
964 && !path.starts_with("//")
965 && Url::parse(&format!("https://validation.example{path}"))
966 .is_ok_and(|url| url.query().is_none() && url.fragment().is_none())
967}
968
969fn encode_path(value: &str) -> String {
970 percent_encoding::utf8_percent_encode(value, percent_encoding::NON_ALPHANUMERIC).to_string()
971}
972
973fn discovery_fresh(entry: &DiscoveryCacheEntry, now: OffsetDateTime) -> bool {
974 if cache_directive(entry.cache_control.as_deref(), "no-cache").is_some()
975 || cache_directive(entry.cache_control.as_deref(), "no-store").is_some()
976 {
977 return false;
978 }
979 let freshness = match cache_directive(entry.cache_control.as_deref(), "max-age") {
980 Some(value) => match value.parse::<u64>() {
981 Ok(value) => Duration::from_secs(value),
982 Err(_) => return false,
983 },
984 None => DEFAULT_DISCOVERY_FRESHNESS,
985 };
986 let Ok(freshness) = time::Duration::try_from(freshness) else {
987 return false;
988 };
989 entry
990 .cached_at
991 .checked_add(freshness)
992 .is_some_and(|expires| expires > now)
993}
994
995fn cache_directive<'a>(value: Option<&'a str>, name: &str) -> Option<&'a str> {
996 value?.split(',').find_map(|part| {
997 let mut fields = part.trim().splitn(2, '=');
998 let field = fields.next()?;
999 field
1000 .eq_ignore_ascii_case(name)
1001 .then(|| fields.next().unwrap_or("").trim_matches('"'))
1002 })
1003}
1004
1005fn merge_cache_headers(entry: &mut DiscoveryCacheEntry, headers: &HeaderMap) {
1006 if let Some(value) = header_string(headers, header::CACHE_CONTROL) {
1007 entry.cache_control = Some(value);
1008 }
1009 if let Some(value) = header_string(headers, header::ETAG) {
1010 entry.etag = Some(value);
1011 }
1012 if let Some(value) = header_string(headers, header::LAST_MODIFIED) {
1013 entry.last_modified = Some(value);
1014 }
1015}
1016
1017fn validate_media_type(headers: &HeaderMap, expected: &str, kind: &str) -> Result<(), AgentError> {
1018 if media_type_matches(headers, expected) {
1019 return Ok(());
1020 }
1021 Err(AgentError::Transport(format!(
1022 "AEP Platform {kind} response media type is invalid"
1023 )))
1024}
1025
1026fn media_type_matches(headers: &HeaderMap, expected: &str) -> bool {
1027 headers
1028 .get(header::CONTENT_TYPE)
1029 .and_then(|value| value.to_str().ok())
1030 .and_then(|value| value.split(';').next())
1031 .is_some_and(|value| value.trim().eq_ignore_ascii_case(expected))
1032}
1033
1034fn safe_discovery_target(target: &Url, reference: &Url) -> bool {
1035 target.username().is_empty()
1036 && target.password().is_none()
1037 && target.fragment().is_none()
1038 && target.scheme() == reference.scheme()
1039 && same_origin(target, reference)
1040}
1041
1042fn is_redirect(status: StatusCode) -> bool {
1043 matches!(
1044 status,
1045 StatusCode::MOVED_PERMANENTLY
1046 | StatusCode::FOUND
1047 | StatusCode::SEE_OTHER
1048 | StatusCode::TEMPORARY_REDIRECT
1049 | StatusCode::PERMANENT_REDIRECT
1050 )
1051}
1052
1053fn header_string(headers: &HeaderMap, name: header::HeaderName) -> Option<String> {
1054 headers
1055 .get(name)
1056 .and_then(|value| value.to_str().ok())
1057 .map(str::to_owned)
1058}
1059
1060fn header_value(value: &str) -> Option<HeaderValue> {
1061 HeaderValue::from_str(value).ok()
1062}
1063
1064#[cfg(test)]
1065#[path = "platform_provider_tests.rs"]
1066mod tests;