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