Skip to main content

aep_agent/
platform_provider.rs

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;
29/// How many `202 Accepted` Sign stages one assertion may go through before the Agent gives up.
30///
31/// `retry_after_seconds` paces polling but sets no total deadline, so the Agent carries its own
32/// bound and fails closed rather than letting a Platform that never completes hold the caller.
33const 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, &current) {
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;