Skip to main content

aep_agent/
platform_provider.rs

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