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