Skip to main content

aep_agent/
platform_provider.rs

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