Skip to main content

aep_platform/
platform.rs

1use std::{collections::BTreeMap, sync::Arc, time::Duration};
2
3use aep_core::{
4    AssertionOperation, ClientAssertionClaims, ErrorCode, ProblemDetails, SigningAlgorithm,
5    VerifyClientAssertionOptions, decode_jwt_unverified, new_problem_details,
6    verify_client_assertion,
7};
8use futures::FutureExt as _;
9use http::{HeaderMap, HeaderValue, header};
10use serde::{Serialize, de::DeserializeOwned};
11use sha2::{Digest as _, Sha256};
12use time::{OffsetDateTime, format_description::well_known::Rfc3339};
13use uuid::Uuid;
14
15use crate::{
16    AgentIdentity, AgentIdentityListResponse, AuthorizationRequest, Authorizer, Clock,
17    DID_MEDIA_TYPE, DefaultLifecyclePolicy, DidDocument, DiscoveryDocument, IdempotencyInput,
18    IdempotencyResult, IdempotencyStore, IdempotentOperation, IdentityListQuery, IdentityRecord,
19    IdentityStore, KeyStore, LifecyclePolicy, LifecycleRequest, ManagedAgentStatus,
20    MemoryIdempotencyStore, MemoryIdentityStore, PlatformError, PlatformOptions, PlatformResponse,
21    ReplayStore, RequestContext, ResponseBody, ServiceDidResolver, SignHandler, SignRequest,
22    SignResponse, SignStatus, StoredResponse, SystemClock, VerificationRequest,
23    VerificationResponse, create_did_document, create_discovery_document,
24    create_service_scoped_agent_did, is_absolute_https_url, is_did, lifecycle_error_code,
25    render_did_url,
26};
27
28const MAXIMUM_LIST_LIMIT: usize = 100;
29
30pub struct Platform {
31    agent_did_id_generator: Arc<dyn Fn() -> Result<String, PlatformError> + Send + Sync>,
32    authorizer: Arc<dyn Authorizer>,
33    clock: Arc<dyn Clock>,
34    default_lifetime: Duration,
35    did_host: String,
36    did_path_prefix: String,
37    did_url_template: String,
38    discovery: DiscoveryDocument,
39    discovery_etag: String,
40    discovery_last_modified: String,
41    hosted_verification: bool,
42    identifier: Arc<dyn Fn() -> Result<String, PlatformError> + Send + Sync>,
43    idempotency_store: Arc<dyn IdempotencyStore>,
44    identity_store: Arc<dyn IdentityStore>,
45    key_store: Arc<dyn KeyStore>,
46    lifecycle_policy: Arc<dyn LifecyclePolicy>,
47    maximum_lifetime: Duration,
48    pub(crate) replay_store: Option<Arc<dyn ReplayStore>>,
49    service_did_resolver: Arc<dyn ServiceDidResolver>,
50    sign_handler: Option<Arc<dyn SignHandler>>,
51    signing_algorithms: Vec<SigningAlgorithm>,
52}
53
54impl Platform {
55    pub fn new(options: PlatformOptions) -> Result<Arc<Self>, PlatformError> {
56        let probe_did = create_service_scoped_agent_did(
57            &options.did_host,
58            &options.did_path_prefix,
59            "validation",
60        )?;
61        // DWB-RES-001 resolves a minted DID to one document URL, and PLT-DISC-006 advertises where
62        // this Platform publishes that document. A Platform whose two answers disagree issues
63        // identities no Service can resolve, so the pair is checked once, here.
64        let probe_url = render_did_url(&options.did_url_template, "validation")?;
65        if aep_core::did_web_document_url(&probe_did)?.as_str() != probe_url {
66            return Err(PlatformError::InvalidConfiguration(
67                "AEP Platform DID URL template does not resolve the Agent DIDs it mints".to_owned(),
68            ));
69        }
70        validate_signing_algorithms(&options.signing_algorithms)?;
71        validate_lifetime(
72            "maximum",
73            options.maximum_lifetime,
74            aep_core::MAX_ASSERTION_LIFETIME,
75        )?;
76        validate_lifetime(
77            "default",
78            options.default_lifetime,
79            options.maximum_lifetime,
80        )?;
81        if options.hosted_verification && options.replay_store.is_none() {
82            return Err(PlatformError::InvalidConfiguration(
83                "AEP Platform hosted verification requires a replay store".to_owned(),
84            ));
85        }
86        let discovery = create_discovery_document(
87            &options.discovery,
88            &options.did_url_template,
89            options.hosted_verification,
90            options.signing_algorithms.clone(),
91            options.default_lifetime,
92        )?;
93        let clock = options.clock.unwrap_or_else(|| Arc::new(SystemClock));
94        // The document is built once and never changes, so its validators are derived once: a
95        // strong entity tag over the representation, and the moment this Platform published it.
96        let discovery_etag = format!("\"{}\"", hex_digest(&serde_json::to_vec(&discovery)?));
97        let discovery_last_modified = http_date(clock.now());
98        let idempotency_store = options
99            .idempotency_store
100            .unwrap_or_else(|| Arc::new(MemoryIdempotencyStore::new(clock.clone())));
101        Ok(Arc::new(Self {
102            agent_did_id_generator: options
103                .agent_did_id_generator
104                .unwrap_or_else(|| Arc::new(random_identifier)),
105            authorizer: options.authorizer,
106            clock,
107            default_lifetime: options.default_lifetime,
108            did_host: options.did_host,
109            did_path_prefix: options.did_path_prefix,
110            did_url_template: options.did_url_template,
111            discovery,
112            discovery_etag,
113            discovery_last_modified,
114            hosted_verification: options.hosted_verification,
115            identifier: options
116                .identifier
117                .unwrap_or_else(|| Arc::new(random_identifier)),
118            idempotency_store,
119            identity_store: options
120                .identity_store
121                .unwrap_or_else(|| Arc::new(MemoryIdentityStore::default())),
122            key_store: options.key_store,
123            lifecycle_policy: options
124                .lifecycle_policy
125                .unwrap_or_else(|| Arc::new(DefaultLifecyclePolicy)),
126            maximum_lifetime: options.maximum_lifetime,
127            replay_store: options.replay_store,
128            service_did_resolver: options.service_did_resolver,
129            sign_handler: options.sign_handler,
130            signing_algorithms: options.signing_algorithms,
131        }))
132    }
133
134    pub fn discovery(&self) -> PlatformResponse<DiscoveryDocument> {
135        let mut response = success(200, self.discovery.clone());
136        response.headers.insert(
137            header::CACHE_CONTROL,
138            HeaderValue::from_static("max-age=300"),
139        );
140        // PLT-DISC-012: the validators travel with the freshness policy, so a client can
141        // revalidate this representation instead of re-fetching it.
142        if let Ok(value) = HeaderValue::from_str(&self.discovery_etag) {
143            response.headers.insert(header::ETAG, value);
144        }
145        if let Ok(value) = HeaderValue::from_str(&self.discovery_last_modified) {
146            response.headers.insert(header::LAST_MODIFIED, value);
147        }
148        response
149    }
150
151    pub async fn did_document(
152        &self,
153        agent_did_id: &str,
154    ) -> Result<PlatformResponse<DidDocument>, PlatformError> {
155        let Some(identity) = self
156            .identity_store
157            .find_by_agent_did_id(agent_did_id)
158            .await?
159        else {
160            return Ok(problem(404, ErrorCode::NotRecognized));
161        };
162        if identity.status != ManagedAgentStatus::Active {
163            return Ok(problem(404, ErrorCode::NotRecognized));
164        }
165        let method = self.key_store.did_verification_method(&identity).await?;
166        let document = create_did_document(&identity, method)?;
167        let mut response = success(200, document);
168        response.headers.insert(
169            header::CONTENT_TYPE,
170            HeaderValue::from_static(DID_MEDIA_TYPE),
171        );
172        response.headers.insert(
173            header::CACHE_CONTROL,
174            HeaderValue::from_static("max-age=300"),
175        );
176        Ok(response)
177    }
178
179    pub async fn get_identity(
180        &self,
181        agent_identity_id: &str,
182        context: &RequestContext,
183    ) -> Result<PlatformResponse<AgentIdentity>, PlatformError> {
184        let Some(identity) = self
185            .authorized_identity(agent_identity_id, context, |identity| {
186                AuthorizationRequest::GetIdentity { identity }
187            })
188            .await?
189        else {
190            return Ok(problem(404, ErrorCode::NotRecognized));
191        };
192        Ok(success(200, agent_identity(&identity)?))
193    }
194
195    pub async fn list(
196        &self,
197        mut query: IdentityListQuery,
198        context: &RequestContext,
199    ) -> Result<PlatformResponse<AgentIdentityListResponse>, PlatformError> {
200        if query.limit > MAXIMUM_LIST_LIMIT
201            || query
202                .service_did
203                .as_ref()
204                .is_some_and(|service_did| !is_did(service_did))
205        {
206            return Ok(problem(400, ErrorCode::InvalidRequest));
207        }
208        if query.limit == 0 {
209            query.limit = MAXIMUM_LIST_LIMIT;
210        }
211        let authorized = self
212            .authorizer
213            .authorize(
214                &AuthorizationRequest::ListIdentities {
215                    query: query.clone(),
216                },
217                context,
218            )
219            .await?;
220        if !authorized || context.principal.is_empty() {
221            return Ok(problem(404, ErrorCode::NotRecognized));
222        }
223        let result = self.identity_store.list(&context.principal, &query).await?;
224        let data = result
225            .identities
226            .iter()
227            .map(agent_identity)
228            .collect::<Result<Vec<_>, _>>()?;
229        Ok(success(
230            200,
231            AgentIdentityListResponse {
232                additional: BTreeMap::new(),
233                count: data.len().to_string(),
234                data,
235                total: result.total.to_string(),
236            },
237        ))
238    }
239
240    pub async fn provision(
241        self: &Arc<Self>,
242        request: crate::ProvisionRequest,
243        context: RequestContext,
244    ) -> Result<PlatformResponse<AgentIdentity>, PlatformError> {
245        if !is_did(&request.service_did) {
246            return Ok(problem(400, ErrorCode::InvalidRequest));
247        }
248        if !self
249            .authorizer
250            .authorize(
251                &AuthorizationRequest::Provision {
252                    request: request.clone(),
253                },
254                &context,
255            )
256            .await?
257        {
258            return Ok(problem(404, ErrorCode::NotRecognized));
259        }
260        let material = request.clone();
261        let platform = self.clone();
262        self.execute_idempotent(
263            IdempotentOperation::Provision,
264            &material,
265            context.clone(),
266            move || async move {
267                if !platform
268                    .service_did_resolver
269                    .resolve(&request.service_did)
270                    .await?
271                {
272                    return Ok(problem(400, ErrorCode::InvalidRequest));
273                }
274                let creation_platform = platform.clone();
275                let principal = context.principal.clone();
276                let service_did = request.service_did.clone();
277                let creation_principal = principal.clone();
278                let creation_service_did = service_did.clone();
279                let identity = platform
280                    .identity_store
281                    .find_or_create(
282                        &principal,
283                        &service_did,
284                        Box::new(move || {
285                            async move {
286                                creation_platform
287                                    .create_identity(creation_principal, creation_service_did)
288                                    .await
289                            }
290                            .boxed()
291                        }),
292                    )
293                    .await?;
294                Ok(success(200, agent_identity(&identity)?))
295            },
296        )
297        .await
298    }
299
300    pub async fn sign(
301        self: &Arc<Self>,
302        agent_identity_id: &str,
303        request: SignRequest,
304        context: RequestContext,
305    ) -> Result<PlatformResponse<SignResponse>, PlatformError> {
306        let lifetime = match self.sign_lifetime(&request) {
307            Ok(lifetime) => lifetime,
308            Err(_) => return Ok(problem(400, ErrorCode::InvalidRequest)),
309        };
310        let Some(identity) = self
311            .authorized_identity(agent_identity_id, &context, |identity| {
312                AuthorizationRequest::Sign {
313                    identity,
314                    request: request.clone(),
315                }
316            })
317            .await?
318        else {
319            return Ok(problem(404, ErrorCode::NotRecognized));
320        };
321        if identity.service_did != request.service_did {
322            return Ok(problem(404, ErrorCode::NotRecognized));
323        }
324        #[derive(Serialize)]
325        struct SignMaterial {
326            agent_identity_id: String,
327            request: SignRequest,
328        }
329        let material = SignMaterial {
330            agent_identity_id: agent_identity_id.to_owned(),
331            request: request.clone(),
332        };
333        let platform = self.clone();
334        self.execute_idempotent(
335            IdempotentOperation::Sign,
336            &material,
337            context.clone(),
338            move || async move {
339                // Authorization also guards stored results; lifecycle checks guard new work.
340                if identity.status != ManagedAgentStatus::Active
341                    || !platform
342                        .lifecycle_policy
343                        .can_sign(&identity, &context)
344                        .await?
345                {
346                    return Ok(problem(403, lifecycle_error_code(identity.status)));
347                }
348                if let Some(handler) = &platform.sign_handler
349                    && let Some(response) = handler.sign(&identity, &request, &context).await?
350                {
351                    validate_sign_response(&response, &identity, &request, lifetime)?;
352                    return Ok(response);
353                }
354                let now = context.now.unwrap_or_else(|| platform.clock.now());
355                let expires_at = now + lifetime;
356                let claims = ClientAssertionClaims {
357                    additional: BTreeMap::new(),
358                    aud: request.service_did.clone(),
359                    exp: expires_at.unix_timestamp(),
360                    iat: now.unix_timestamp(),
361                    iss: identity.agent_did.clone(),
362                    jti: request.jti.clone(),
363                    op: request.op,
364                    resource: request.resource.clone(),
365                    sub: identity.agent_did.clone(),
366                };
367                // PLT-SIGN-020: the claims are checked against the AEP core contract before the
368                // key store is asked to sign them, so a Platform never mints an assertion no
369                // Service would accept.
370                aep_core::validate_client_assertion_claims(&claims)
371                    .map_err(aep_core::CoreError::from)?;
372                let assertion = platform.key_store.sign(&identity, &claims).await?;
373                if assertion.is_empty() {
374                    return Err(PlatformError::Handler(
375                        "AEP Platform key store returned an empty assertion".to_owned(),
376                    ));
377                }
378                Ok(success(
379                    200,
380                    SignResponse {
381                        additional: BTreeMap::new(),
382                        agent_did: Some(identity.agent_did),
383                        client_assertion: Some(assertion),
384                        expires_at: Some(format_time(expires_at)?),
385                        issued_at: Some(format_time(now)?),
386                        jti: Some(request.jti),
387                        platform_context: request.platform_context,
388                        retry_after_seconds: None,
389                        service_did: Some(request.service_did),
390                        status: SignStatus::Completed,
391                    },
392                ))
393            },
394        )
395        .await
396    }
397
398    pub async fn update_identity(
399        &self,
400        agent_identity_id: &str,
401        request: LifecycleRequest,
402        context: &RequestContext,
403    ) -> Result<PlatformResponse<AgentIdentity>, PlatformError> {
404        let Some(identity) = self
405            .authorized_identity(agent_identity_id, context, |identity| {
406                AuthorizationRequest::UpdateIdentity {
407                    identity,
408                    request: request.clone(),
409                }
410            })
411            .await?
412        else {
413            return Ok(problem(404, ErrorCode::NotRecognized));
414        };
415        if !self
416            .lifecycle_policy
417            .can_transition(&identity, request.status, context)
418            .await?
419        {
420            return Ok(problem(403, lifecycle_error_code(identity.status)));
421        }
422        let Some(updated) = self
423            .identity_store
424            .update_status(agent_identity_id, request.status, self.clock.now())
425            .await?
426        else {
427            return Ok(problem(404, ErrorCode::NotRecognized));
428        };
429        Ok(success(200, agent_identity(&updated)?))
430    }
431
432    pub async fn verify(
433        self: &Arc<Self>,
434        request: VerificationRequest,
435        context: RequestContext,
436    ) -> Result<PlatformResponse<VerificationResponse>, PlatformError> {
437        if !self.hosted_verification {
438            return Ok(problem(404, ErrorCode::NotRecognized));
439        }
440        if request.client_assertion.is_empty()
441            || !is_did(&request.service_did)
442            || match (&request.op, &request.resource) {
443                (AssertionOperation::Authenticate, Some(resource)) => {
444                    !is_absolute_https_url(resource)
445                }
446                (AssertionOperation::Authenticate, None) => true,
447                (_, Some(_)) => true,
448                (_, None) => false,
449            }
450        {
451            return Ok(problem(400, ErrorCode::InvalidRequest));
452        }
453        let Some(identity) = self
454            .authorized_verification_identity(&request, &context)
455            .await?
456        else {
457            return Ok(unrecognized_verification(&request));
458        };
459        let material = request.clone();
460        let platform = self.clone();
461        self.execute_idempotent(
462            IdempotentOperation::HostedVerification,
463            &material,
464            context.clone(),
465            move || async move { platform.verify_assertion(request, context, identity).await },
466        )
467        .await
468    }
469
470    async fn authorized_verification_identity(
471        &self,
472        request: &VerificationRequest,
473        context: &RequestContext,
474    ) -> Result<Option<IdentityRecord>, PlatformError> {
475        let Ok(decoded) = decode_jwt_unverified(&request.client_assertion) else {
476            return Ok(None);
477        };
478        let Some(agent_did) = decoded
479            .payload
480            .get("iss")
481            .and_then(serde_json::Value::as_str)
482        else {
483            return Ok(None);
484        };
485        let subject = decoded
486            .payload
487            .get("sub")
488            .and_then(serde_json::Value::as_str);
489        if subject != Some(agent_did) || decoded.header.key_id.as_deref() != Some(agent_did) {
490            return Ok(None);
491        }
492        let Some(identity) = self.identity_store.find_by_agent_did(agent_did).await? else {
493            return Ok(None);
494        };
495        // Verification callers may be authorized Services rather than the identity owner.
496        if identity.service_did != request.service_did || context.principal.is_empty() {
497            return Ok(None);
498        }
499        let authorized = self
500            .authorizer
501            .authorize(
502                &AuthorizationRequest::Verify {
503                    identity: identity.clone(),
504                    request: request.clone(),
505                },
506                context,
507            )
508            .await?;
509        Ok(authorized.then_some(identity))
510    }
511
512    async fn verify_assertion(
513        &self,
514        request: VerificationRequest,
515        context: RequestContext,
516        identity: IdentityRecord,
517    ) -> Result<PlatformResponse<VerificationResponse>, PlatformError> {
518        let unrecognized = || unrecognized_verification(&request);
519        let agent_did = &identity.agent_did;
520        if identity.status != ManagedAgentStatus::Active
521            || !self
522                .lifecycle_policy
523                .can_verify(&identity, &context)
524                .await?
525        {
526            return Ok(unrecognized());
527        }
528        let key = self.key_store.verification_key(&identity).await?;
529        let now = context.now.unwrap_or_else(|| self.clock.now());
530        let verified = verify_client_assertion(
531            &request.client_assertion,
532            &key,
533            &VerifyClientAssertionOptions {
534                algorithms: identity.signing_algorithms.clone(),
535                audience: Some(request.service_did.clone()),
536                current_time: Some(now.unix_timestamp()),
537                issuer: Some(agent_did.to_owned()),
538                operation: Some(request.op),
539                resource: request.resource.clone(),
540                subject: Some(agent_did.to_owned()),
541                ..VerifyClientAssertionOptions::default()
542            },
543        );
544        let Ok(claims) = verified else {
545            return Ok(unrecognized());
546        };
547        if request.op != AssertionOperation::Authenticate && claims.resource.is_some() {
548            return Ok(unrecognized());
549        }
550        let replay_key = format!(
551            "{}\0{}\0{}\0{}",
552            request.service_did,
553            request.op.as_str(),
554            agent_did,
555            claims.jti
556        );
557        let Some(replay_store) = &self.replay_store else {
558            return Err(PlatformError::InvalidConfiguration(
559                "AEP Platform hosted verification requires a replay store".to_owned(),
560            ));
561        };
562        let expires_at = OffsetDateTime::from_unix_timestamp(claims.exp)
563            .map_err(|error| PlatformError::Handler(error.to_string()))?;
564        if !replay_store.consume(&replay_key, expires_at, now).await? {
565            return Ok(unrecognized());
566        }
567        Ok(success(
568            200,
569            VerificationResponse {
570                additional: BTreeMap::new(),
571                agent_did: Some(identity.agent_did),
572                agent_identity_id: Some(identity.agent_identity_id),
573                op: Some(request.op),
574                reason: "verified".to_owned(),
575                service_did: request.service_did,
576                status: Some(identity.status),
577                verified: true,
578            },
579        ))
580    }
581
582    async fn create_identity(
583        &self,
584        principal: String,
585        service_did: String,
586    ) -> Result<IdentityRecord, PlatformError> {
587        let mut agent_identity_id = (self.identifier)()?;
588        if agent_identity_id.is_empty() {
589            return Err(PlatformError::Handler(
590                "AEP Platform identity generator returned an empty identifier".to_owned(),
591            ));
592        }
593        if !agent_identity_id.starts_with("pai_") {
594            agent_identity_id = format!("pai_{agent_identity_id}");
595        }
596        let agent_did_id = (self.agent_did_id_generator)()?;
597        let agent_did =
598            create_service_scoped_agent_did(&self.did_host, &self.did_path_prefix, &agent_did_id)?;
599        let now = self.clock.now();
600        let identity = IdentityRecord {
601            agent_did: agent_did.clone(),
602            agent_did_id: agent_did_id.clone(),
603            agent_identity_id,
604            created_at: now,
605            did_document_url: render_did_url(&self.did_url_template, &agent_did_id)?,
606            key_id: agent_did,
607            principal,
608            service_did,
609            signing_algorithms: self.signing_algorithms.clone(),
610            status: ManagedAgentStatus::Active,
611            updated_at: now,
612        };
613        self.key_store.create_key(&identity).await?;
614        Ok(identity)
615    }
616
617    async fn authorized_identity(
618        &self,
619        agent_identity_id: &str,
620        context: &RequestContext,
621        request: impl FnOnce(IdentityRecord) -> AuthorizationRequest,
622    ) -> Result<Option<IdentityRecord>, PlatformError> {
623        let Some(identity) = self.identity_store.get(agent_identity_id).await? else {
624            return Ok(None);
625        };
626        let authorized = self
627            .authorizer
628            .authorize(&request(identity.clone()), context)
629            .await?;
630        if !authorized || context.principal.is_empty() || identity.principal != context.principal {
631            return Ok(None);
632        }
633        Ok(Some(identity))
634    }
635
636    fn sign_lifetime(&self, request: &SignRequest) -> Result<Duration, PlatformError> {
637        if request.jti.is_empty() || !is_did(&request.service_did) {
638            return Err(PlatformError::InvalidConfiguration(
639                "AEP Platform signing request is invalid".to_owned(),
640            ));
641        }
642        match (&request.op, &request.resource) {
643            (AssertionOperation::Authenticate, Some(resource))
644                if is_absolute_https_url(resource) => {}
645            (AssertionOperation::Authenticate, _) => {
646                return Err(PlatformError::InvalidConfiguration(
647                    "AEP Platform authenticate signing requires an HTTPS resource".to_owned(),
648                ));
649            }
650            (_, None) => {}
651            (_, Some(_)) => {
652                return Err(PlatformError::InvalidConfiguration(
653                    "AEP Platform resource is only permitted for authenticate".to_owned(),
654                ));
655            }
656        }
657        let Some(seconds) = &request.lifetime_seconds else {
658            return Ok(self.default_lifetime);
659        };
660        let seconds = decimal_seconds(seconds).ok_or_else(|| {
661            PlatformError::InvalidConfiguration(
662                "AEP Platform lifetime_seconds must be a positive integer string".to_owned(),
663            )
664        })?;
665        let lifetime = Duration::from_secs(seconds);
666        validate_lifetime("requested", lifetime, self.maximum_lifetime)?;
667        Ok(lifetime)
668    }
669
670    async fn execute_idempotent<T, M, Execute, Future>(
671        &self,
672        operation: IdempotentOperation,
673        material: &M,
674        context: RequestContext,
675        execute: Execute,
676    ) -> Result<PlatformResponse<T>, PlatformError>
677    where
678        T: DeserializeOwned + Serialize + Send + 'static,
679        M: Serialize + ?Sized,
680        Execute: FnOnce() -> Future + Send + 'static,
681        Future: std::future::Future<Output = Result<PlatformResponse<T>, PlatformError>>
682            + Send
683            + 'static,
684    {
685        let Some(idempotency_key) = context.idempotency_key.clone() else {
686            return Ok(problem(400, ErrorCode::InvalidRequest));
687        };
688        if idempotency_key.is_empty() || context.principal.is_empty() {
689            return Ok(problem(400, ErrorCode::InvalidRequest));
690        }
691        let request_hash = hex_digest(&serde_json::to_vec(material)?);
692        let result = self
693            .idempotency_store
694            .execute(
695                IdempotencyInput {
696                    idempotency_key,
697                    operation,
698                    principal: context.principal,
699                    request_hash,
700                },
701                Box::new(move || {
702                    async move {
703                        let response = execute().await?;
704                        store_response(response)
705                    }
706                    .boxed()
707                }),
708            )
709            .await?;
710        match result {
711            IdempotencyResult::Conflict => Ok(problem(409, ErrorCode::IdempotencyConflict)),
712            IdempotencyResult::Created(response) | IdempotencyResult::Replayed(response) => {
713                restore_response(response)
714            }
715        }
716    }
717}
718
719pub(crate) fn success<T>(status: u16, body: T) -> PlatformResponse<T> {
720    let mut headers = HeaderMap::new();
721    headers.insert(
722        header::CONTENT_TYPE,
723        HeaderValue::from_static(aep_core::MEDIA_TYPE),
724    );
725    PlatformResponse {
726        body: ResponseBody::Success(body),
727        headers,
728        status,
729    }
730}
731
732fn unrecognized_verification(
733    request: &VerificationRequest,
734) -> PlatformResponse<VerificationResponse> {
735    success(
736        200,
737        VerificationResponse {
738            additional: BTreeMap::new(),
739            agent_did: None,
740            agent_identity_id: None,
741            op: None,
742            reason: "not_recognized".to_owned(),
743            service_did: request.service_did.clone(),
744            status: None,
745            verified: false,
746        },
747    )
748}
749
750pub(crate) fn problem<T>(status: u16, code: ErrorCode) -> PlatformResponse<T> {
751    let title = code
752        .as_str()
753        .split('_')
754        .map(|part| {
755            let mut characters = part.chars();
756            characters.next().map_or_else(String::new, |first| {
757                first.to_uppercase().collect::<String>() + characters.as_str()
758            })
759        })
760        .collect::<Vec<_>>()
761        .join(" ");
762    let mut headers = HeaderMap::new();
763    headers.insert(
764        header::CONTENT_TYPE,
765        HeaderValue::from_static(aep_core::PROBLEM_MEDIA_TYPE),
766    );
767    PlatformResponse {
768        body: ResponseBody::Problem(Box::new(new_problem_details(
769            code,
770            title,
771            i64::from(status),
772        ))),
773        headers,
774        status,
775    }
776}
777
778fn store_response<T: Serialize>(
779    response: PlatformResponse<T>,
780) -> Result<StoredResponse, PlatformError> {
781    let content_type = response
782        .headers
783        .get(header::CONTENT_TYPE)
784        .and_then(|value| value.to_str().ok())
785        .unwrap_or(aep_core::MEDIA_TYPE)
786        .to_owned();
787    let body = match response.body {
788        ResponseBody::Success(body) => serde_json::to_vec(&body)?,
789        ResponseBody::Problem(body) => serde_json::to_vec(&body)?,
790    };
791    Ok(StoredResponse {
792        body,
793        content_type,
794        created_at: OffsetDateTime::UNIX_EPOCH,
795        headers: response.headers,
796        status: response.status,
797    })
798}
799
800fn restore_response<T: DeserializeOwned>(
801    stored: StoredResponse,
802) -> Result<PlatformResponse<T>, PlatformError> {
803    let body = if stored.content_type == aep_core::PROBLEM_MEDIA_TYPE {
804        ResponseBody::Problem(Box::new(serde_json::from_slice::<ProblemDetails>(
805            &stored.body,
806        )?))
807    } else {
808        ResponseBody::Success(serde_json::from_slice(&stored.body)?)
809    };
810    Ok(PlatformResponse {
811        body,
812        headers: stored.headers,
813        status: stored.status,
814    })
815}
816
817fn agent_identity(identity: &IdentityRecord) -> Result<AgentIdentity, PlatformError> {
818    Ok(AgentIdentity {
819        additional: BTreeMap::new(),
820        agent_did: identity.agent_did.clone(),
821        agent_identity_id: identity.agent_identity_id.clone(),
822        created_at: format_time(identity.created_at)?,
823        did_document_url: identity.did_document_url.clone(),
824        key_id: identity.key_id.clone(),
825        service_did: identity.service_did.clone(),
826        signing_algorithms: identity.signing_algorithms.clone(),
827        status: identity.status,
828        updated_at: format_time(identity.updated_at)?,
829    })
830}
831
832fn validate_signing_algorithms(algorithms: &[SigningAlgorithm]) -> Result<(), PlatformError> {
833    if algorithms.is_empty()
834        || algorithms.iter().enumerate().any(|(index, algorithm)| {
835            !matches!(algorithm, SigningAlgorithm::EdDsa | SigningAlgorithm::Es256)
836                || algorithms[..index].contains(algorithm)
837        })
838    {
839        return Err(PlatformError::InvalidConfiguration(
840            "AEP Platform requires unique supported signing algorithms".to_owned(),
841        ));
842    }
843    Ok(())
844}
845
846fn validate_lifetime(
847    name: &str,
848    lifetime: Duration,
849    maximum: Duration,
850) -> Result<(), PlatformError> {
851    if lifetime.is_zero()
852        || lifetime > maximum
853        || lifetime > aep_core::MAX_ASSERTION_LIFETIME
854        || lifetime.subsec_nanos() != 0
855    {
856        return Err(PlatformError::InvalidConfiguration(format!(
857            "AEP Platform {name} assertion lifetime must be whole seconds within the supported maximum"
858        )));
859    }
860    Ok(())
861}
862
863fn validate_sign_response(
864    response: &PlatformResponse<SignResponse>,
865    identity: &IdentityRecord,
866    request: &SignRequest,
867    lifetime: Duration,
868) -> Result<(), PlatformError> {
869    match &response.body {
870        ResponseBody::Problem(problem) => {
871            if response.status != u16::try_from(problem.status).unwrap_or_default() {
872                return Err(PlatformError::Handler(
873                    "AEP Platform sign handler returned invalid Problem Details".to_owned(),
874                ));
875            }
876        }
877        ResponseBody::Success(body) if body.status == SignStatus::Pending => {
878            let retry = body
879                .retry_after_seconds
880                .as_deref()
881                .and_then(decimal_seconds);
882            if response.status != 202
883                || !matches!(retry, Some(1..=300))
884                || response.headers.contains_key(header::RETRY_AFTER)
885                || body.agent_did.is_some()
886                || body.client_assertion.is_some()
887                || body.expires_at.is_some()
888                || body.issued_at.is_some()
889                || body.jti.is_some()
890                || body.service_did.is_some()
891            {
892                return Err(PlatformError::Handler(
893                    "AEP Platform sign handler returned an invalid pending response".to_owned(),
894                ));
895            }
896        }
897        ResponseBody::Success(body) => {
898            let issued_at = body
899                .issued_at
900                .as_deref()
901                .and_then(|value| OffsetDateTime::parse(value, &Rfc3339).ok());
902            let expires_at = body
903                .expires_at
904                .as_deref()
905                .and_then(|value| OffsetDateTime::parse(value, &Rfc3339).ok());
906            if response.status != 200
907                || body.status != SignStatus::Completed
908                || body.agent_did.as_deref() != Some(&identity.agent_did)
909                || body.client_assertion.as_deref().is_none_or(str::is_empty)
910                || body.jti.as_deref() != Some(&request.jti)
911                || body.service_did.as_deref() != Some(&request.service_did)
912                // PLT-SIGN-010 caps the window at 300 seconds and PLT-SIGN-011 lets a Platform
913                // cap it lower; the effective lifetime already reflects both, and an Agent reads
914                // the window back to confirm it received the one it asked for.
915                || !matches!((issued_at, expires_at), (Some(issued), Some(expires)) if expires - issued == lifetime)
916                || body.retry_after_seconds.is_some()
917            {
918                return Err(PlatformError::Handler(
919                    "AEP Platform sign handler returned an invalid completed response".to_owned(),
920                ));
921            }
922        }
923    }
924    Ok(())
925}
926
927fn hex_digest(value: &[u8]) -> String {
928    Sha256::digest(value)
929        .iter()
930        .map(|byte| format!("{byte:02x}"))
931        .collect()
932}
933
934/// Renders an instant as the IMF-fixdate form RFC 9110 defines for `Last-Modified`.
935fn http_date(value: OffsetDateTime) -> String {
936    const DAYS: [&str; 7] = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
937    const MONTHS: [&str; 12] = [
938        "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
939    ];
940    let value = value.to_offset(time::UtcOffset::UTC);
941    format!(
942        "{}, {:02} {} {:04} {:02}:{:02}:{:02} GMT",
943        DAYS[usize::from(value.weekday().number_days_from_monday())],
944        value.day(),
945        MONTHS[usize::from(u8::from(value.month())) - 1],
946        value.year(),
947        value.hour(),
948        value.minute(),
949        value.second()
950    )
951}
952
953/// Parses one of the decimal-second strings the Platform schemas publish: digits only, no sign,
954/// no leading zero, and within the AEP assertion window.
955fn decimal_seconds(value: &str) -> Option<u64> {
956    if value.is_empty()
957        || !value.bytes().all(|byte| byte.is_ascii_digit())
958        || (value.len() > 1 && value.starts_with('0'))
959    {
960        return None;
961    }
962    value.parse().ok()
963}
964
965fn format_time(value: OffsetDateTime) -> Result<String, PlatformError> {
966    value
967        .format(&Rfc3339)
968        .map_err(|error| PlatformError::Handler(error.to_string()))
969}
970
971fn random_identifier() -> Result<String, PlatformError> {
972    Ok(Uuid::new_v4().simple().to_string())
973}