Skip to main content

identity_kit/domain_linkage/
mod.rs

1//! DIF Well Known DID Configuration: proving that the controller of an origin and the
2//! controller of a DID are the same entity.
3//!
4//! An origin publishes a [`DomainLinkageConfiguration`] at `/.well-known/did-configuration.json`
5//! ([`configuration_url`]) holding one domain linkage credential per linked DID. This crate
6//! handles the JSON Web Token proof format: [`DomainLinkageCredentialBuilder`] issues such a
7//! credential (a VCDM 1.1 [`Credential`] of type `DomainLinkageCredential` whose subject is
8//! the DID with the `origin`), and [`DomainLinkageValidator`] checks one, or a whole
9//! configuration, against the origin it was fetched from, resolving the DID's key under
10//! `assertionMethod`. Linked Data Proof entries are carried as data and not validated.
11//!
12//! The specification writes the origin with its scheme (`https://identity.foundation`), its
13//! own JWT example without (`identity.foundation`); the validator normalizes both to the
14//! `scheme://host[:port]` form, `https` when none is given, before comparing.
15//!
16//! # Fetching
17//!
18//! [`fetch_configuration`] gets the resource of an origin through an [`HttpClient`], the one
19//! `did:web` and status lists use, telling an origin that publishes no configuration
20//! ([`DomainLinkageError::NotPublished`], on `404` or `410`) apart from one whose
21//! configuration could not be fetched or read. [`DomainLinkageClient`] fetches and validates
22//! in one call, for an origin, for one DID at an origin, or for every origin a DID document
23//! lists in its `LinkedDomains` services ([`services`]), reporting per origin.
24//!
25//! # Examples
26//!
27//! Verifying a domain: the specification's example resource, served for
28//! `https://identity.foundation`, links one `did:key`, and another origin publishes nothing.
29//!
30//! ```
31//! # #[cfg(feature = "did-key")]
32//! # {
33//! use chrono::{TimeZone as _, Utc};
34//! use identity_kit::did::Did;
35//! use identity_kit::domain_linkage::{DomainLinkageClient, DomainLinkageValidator};
36//! use identity_kit::jose::DefaultVerifier;
37//! use identity_kit::methods::DidKey;
38//! use identity_kit::methods::web::{HttpClient, HttpError, HttpResponse};
39//! use identity_kit::resolver::Resolver;
40//! use identity_kit::vc::validation::{CredentialValidationOptions, TimeOptions};
41//! use url::Url;
42//!
43//! /// Serves the example resource of `identity.foundation` and nothing else.
44//! struct Static;
45//!
46//! #[async_trait::async_trait]
47//! impl HttpClient for Static {
48//!     async fn get(&self, url: &Url) -> Result<HttpResponse, HttpError> {
49//!         Ok(match url.as_str() {
50//!             "https://identity.foundation/.well-known/did-configuration.json" => {
51//!                 # let resource = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/vectors/did_configuration/resource.json"));
52//!                 HttpResponse::new(200, resource)
53//!             }
54//!             _ => HttpResponse::new(404, Vec::new()),
55//!         })
56//!     }
57//! }
58//!
59//! # tokio::runtime::Builder::new_current_thread().build()?.block_on(async {
60//! let resolver = Resolver::builder().with(DidKey::default()).build();
61//! let client = DomainLinkageClient::new(Static, DomainLinkageValidator::new(resolver, DefaultVerifier));
62//! // The example credential is valid from 2020-12-04 to 2025-12-04.
63//! let options = CredentialValidationOptions::new()
64//!     .with_time(TimeOptions::at(Utc.with_ymd_and_hms(2021, 1, 1, 0, 0, 0).unwrap()));
65//!
66//! let report = client
67//!     .validate_origin(&Url::parse("https://identity.foundation")?, &options)
68//!     .await?;
69//! assert_eq!(
70//!     report.dids().map(Did::as_str).collect::<Vec<_>>(),
71//!     ["did:key:z6MkoTHsgNNrby8JzCNQ1iRLyW5QQ6R8Xuu6AA8igGrMVPUM"]
72//! );
73//!
74//! let error = client
75//!     .validate_origin(&Url::parse("https://example.com")?, &options)
76//!     .await
77//!     .unwrap_err();
78//! assert!(error.is_not_published());
79//! # Ok::<(), Box<dyn std::error::Error>>(())
80//! # })?;
81//! # }
82//! # Ok::<(), Box<dyn std::error::Error>>(())
83//! ```
84
85use core::fmt;
86use std::collections::BTreeMap;
87
88use chrono::{DateTime, Utc};
89use serde::{Deserialize, Deserializer, Serialize};
90use serde_json::Value;
91use url::Url;
92
93use crate::did::{Did, DidDocument, DidUrl, ResolutionError, Service, ServiceEndpoint, Uri};
94use crate::jose::{DefaultVerifier, JwsHeader, JwsVerifier, Signer};
95use crate::methods::web::{HttpClient, HttpError};
96use crate::resolver::Resolver;
97use crate::vc::validation::{
98    CredentialValidationOptions, DecodedCredential, JwtCredentialValidator, KeyReferenceError,
99    KeyResolver, ValidationError, decode_credential,
100};
101use crate::vc::{Credential, CredentialError, Jwt, JwtClaims, Subject};
102
103/// The context of the DID configuration resource and of domain linkage credentials.
104pub const CONTEXT: &str = "https://identity.foundation/.well-known/did-configuration/v1";
105
106/// The credential type, `DomainLinkageCredential`.
107pub const CREDENTIAL_TYPE: &str = "DomainLinkageCredential";
108
109/// The path of the resource under an origin.
110pub const WELL_KNOWN_PATH: &str = "/.well-known/did-configuration.json";
111
112/// The type of the DID document service listing the origins of a DID, `LinkedDomains`.
113pub const SERVICE_TYPE: &str = "LinkedDomains";
114
115/// The member of a map `serviceEndpoint` holding the origins of a `LinkedDomains` service.
116const ORIGINS: &str = "origins";
117
118/// The URL of the DID configuration resource of `origin`.
119///
120/// # Errors
121///
122/// [`DomainLinkageError::InvalidOrigin`] when `origin` has no host.
123///
124/// # Examples
125///
126/// ```
127/// use identity_kit::domain_linkage::configuration_url;
128/// use url::Url;
129///
130/// let url = configuration_url(&Url::parse("https://identity.foundation")?)?;
131/// assert_eq!(url.as_str(), "https://identity.foundation/.well-known/did-configuration.json");
132/// # Ok::<(), Box<dyn std::error::Error>>(())
133/// ```
134pub fn configuration_url(origin: &Url) -> Result<Url, DomainLinkageError> {
135    let origin = normalize_origin(origin.as_str())?;
136    Url::parse(&format!("{origin}{WELL_KNOWN_PATH}"))
137        .map_err(|e| DomainLinkageError::InvalidOrigin(format!("{origin}: {e}")))
138}
139
140/// `origin` as `scheme://host[:port]`, lowercased, with `https` assumed when no scheme is
141/// given and a path, query or fragment dropped.
142///
143/// # Errors
144///
145/// [`DomainLinkageError::InvalidOrigin`] when `origin` has no host, or one that is not an
146/// RFC 3986 host.
147pub fn normalize_origin(origin: &str) -> Result<String, DomainLinkageError> {
148    let with_scheme = if has_scheme(origin) {
149        origin.to_owned()
150    } else {
151        format!("https://{origin}")
152    };
153    let url = Url::parse(&with_scheme)
154        .map_err(|e| DomainLinkageError::InvalidOrigin(format!("{origin:?}: {e}")))?;
155    if url.host_str().is_none() {
156        return Err(DomainLinkageError::InvalidOrigin(format!(
157            "{origin:?} has no host"
158        )));
159    }
160    let normalized = url.origin();
161    if !normalized.is_tuple() {
162        return Err(DomainLinkageError::InvalidOrigin(format!(
163            "{origin:?} is not a domain origin"
164        )));
165    }
166    let normalized = normalized.ascii_serialization();
167    // The WHATWG URL parser accepts host characters RFC 3986 does not, such as `` ` ``.
168    Uri::parse(&normalized)
169        .map_err(|e| DomainLinkageError::InvalidOrigin(format!("{origin:?}: {e}")))?;
170    Ok(normalized)
171}
172
173/// Whether `s` starts with a URI scheme (RFC 3986 §3.1) rather than a `host:port`.
174fn has_scheme(s: &str) -> bool {
175    let Some((scheme, rest)) = s.split_once(':') else {
176        return false;
177    };
178    let mut chars = scheme.chars();
179    chars.next().is_some_and(|c| c.is_ascii_alphabetic())
180        && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
181        && !rest.starts_with(|c: char| c.is_ascii_digit())
182}
183
184/// A `LinkedDomains` service of `id` listing `origins`, normalized: the origin string when
185/// there is one, `{"origins": [...]}` otherwise, the two forms the specification defines.
186///
187/// # Errors
188///
189/// [`DomainLinkageError::InvalidService`] when `origins` is empty or an origin has no host.
190///
191/// # Examples
192///
193/// ```
194/// use identity_kit::did::{DidUrl, ServiceEndpoint};
195/// use identity_kit::domain_linkage::{SERVICE_TYPE, service};
196/// use url::Url;
197///
198/// let service = service(
199///     DidUrl::parse("did:example:123#foo")?,
200///     [Url::parse("https://foo.example.com")?, Url::parse("https://identity.foundation")?],
201/// )?;
202/// assert!(service.has_type(SERVICE_TYPE));
203/// assert_eq!(
204///     serde_json::to_value(&service.service_endpoint)?,
205///     serde_json::json!({ "origins": ["https://foo.example.com", "https://identity.foundation"] })
206/// );
207/// # Ok::<(), Box<dyn std::error::Error>>(())
208/// ```
209pub fn service(
210    id: DidUrl,
211    origins: impl IntoIterator<Item = Url>,
212) -> Result<Service, DomainLinkageError> {
213    let invalid = |reason: String| DomainLinkageError::InvalidService {
214        id: id.clone(),
215        reason,
216    };
217    let origins = origins
218        .into_iter()
219        .map(|origin| normalize_origin(origin.as_str()).map_err(|e| invalid(e.to_string())))
220        .collect::<Result<Vec<_>, _>>()?;
221    let endpoint = match origins.as_slice() {
222        [] => return Err(invalid("no origin".to_owned())),
223        [origin] => ServiceEndpoint::Uri(
224            Uri::parse(origin).map_err(|e| invalid(format!("`{origin}` is not a URI: {e}")))?,
225        ),
226        _ => ServiceEndpoint::Map([(ORIGINS.to_owned(), Value::from(origins))].into()),
227    };
228    Ok(Service::new(id, SERVICE_TYPE, endpoint))
229}
230
231/// A `LinkedDomains` service of a DID document, with its origins.
232#[derive(Debug, Clone, PartialEq, Eq)]
233#[non_exhaustive]
234pub struct LinkedDomainsService<'a> {
235    /// The service entry.
236    pub service: &'a Service,
237    /// Its origins, normalized, in document order.
238    pub origins: Vec<Url>,
239}
240
241impl LinkedDomainsService<'_> {
242    /// The id of the service.
243    pub fn id(&self) -> &DidUrl {
244        &self.service.id
245    }
246}
247
248/// The `LinkedDomains` services of `document`, in document order, with their origins
249/// normalized as by [`normalize_origin`].
250///
251/// `serviceEndpoint` is an origin string or an object whose `origins` member is a non-empty
252/// array of origin strings, the two forms the specification defines, or an array of origins,
253/// the set form DID Core allows for any service. A service of that type whose endpoint is
254/// none of these, or holds something that is not an origin, is a
255/// [`DomainLinkageError::InvalidService`] rather than silently skipped.
256///
257/// # Examples
258///
259/// ```
260/// use identity_kit::did::DidDocument;
261/// use identity_kit::domain_linkage::services;
262/// use serde_json::json;
263///
264/// let document: DidDocument = serde_json::from_value(json!({
265///   "id": "did:example:123",
266///   "service": [
267///     {
268///       "id": "did:example:123#foo",
269///       "type": "LinkedDomains",
270///       "serviceEndpoint": { "origins": ["https://foo.example.com", "https://identity.foundation"] }
271///     },
272///     { "id": "did:example:123#bar", "type": "LinkedDomains", "serviceEndpoint": "https://bar.example.com" }
273///   ]
274/// }))?;
275/// let origins: Vec<String> = services(&document)
276///     .map(|service| service.map(|service| service.origins))
277///     .collect::<Result<Vec<_>, _>>()?
278///     .into_iter()
279///     .flatten()
280///     .map(|origin| origin.origin().ascii_serialization())
281///     .collect();
282/// assert_eq!(origins, ["https://foo.example.com", "https://identity.foundation", "https://bar.example.com"]);
283/// # Ok::<(), Box<dyn std::error::Error>>(())
284/// ```
285pub fn services(
286    document: &DidDocument,
287) -> impl Iterator<Item = Result<LinkedDomainsService<'_>, DomainLinkageError>> {
288    document
289        .service
290        .iter()
291        .filter(|service| service.has_type(SERVICE_TYPE))
292        .map(linked_domains_service)
293}
294
295/// `service` with its origins read and normalized.
296fn linked_domains_service(
297    service: &Service,
298) -> Result<LinkedDomainsService<'_>, DomainLinkageError> {
299    let invalid = |reason: String| DomainLinkageError::InvalidService {
300        id: service.id.clone(),
301        reason,
302    };
303    let origins: Vec<&str> = match &service.service_endpoint {
304        ServiceEndpoint::Map(map) => {
305            let Some(Value::Array(origins)) = map.get(ORIGINS) else {
306                return Err(invalid(format!(
307                    "`serviceEndpoint` is a map without an `{ORIGINS}` array"
308                )));
309            };
310            origins
311                .iter()
312                .map(|origin| {
313                    origin
314                        .as_str()
315                        .ok_or_else(|| invalid(format!("origin {origin} is not a string")))
316                })
317                .collect::<Result<_, _>>()?
318        }
319        endpoint => endpoint.uris().map(Uri::as_str).collect(),
320    };
321    if origins.is_empty() {
322        return Err(invalid("no origin".to_owned()));
323    }
324    let origins = origins
325        .into_iter()
326        .map(|origin| {
327            let normalized = normalize_origin(origin).map_err(|e| invalid(e.to_string()))?;
328            Url::parse(&normalized).map_err(|e| invalid(format!("{origin:?}: {e}")))
329        })
330        .collect::<Result<_, _>>()?;
331    Ok(LinkedDomainsService { service, origins })
332}
333
334/// One entry of `linked_dids`.
335#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
336#[serde(untagged)]
337#[non_exhaustive]
338pub enum LinkedDid {
339    /// A domain linkage credential in the JSON Web Token proof format.
340    Jwt(Jwt),
341    /// A domain linkage credential in the Linked Data Proof format, carried as data. An entry
342    /// that is neither a compact JWT nor an object is a parse error, not a silently skipped
343    /// credential.
344    LinkedDataProof(serde_json::Map<String, Value>),
345}
346
347/// The DID configuration resource: `@context` and the domain linkage credentials.
348///
349/// # Examples
350///
351/// ```
352/// use identity_kit::domain_linkage::{DomainLinkageConfiguration, LinkedDid};
353/// use serde_json::json;
354///
355/// let configuration: DomainLinkageConfiguration = serde_json::from_value(json!({
356///   "@context": "https://identity.foundation/.well-known/did-configuration/v1",
357///   "linked_dids": [
358///     "eyJhbGciOiJFZERTQSJ9.RXhhbXBsZSBvZiBFZDI1NTE5IHNpZ25pbmc.hgyY0il_MGCjP0JzlnLWG1PPOt7-09PGcvMg3AIbQR6dWbhijcNR4ki4iylGjg5BhVsPt9g7sVvpAr_MuM0KAg"
359///   ]
360/// }))?;
361/// assert_eq!(configuration.jwts().count(), 1);
362/// assert!(serde_json::from_value::<DomainLinkageConfiguration>(json!({
363///   "@context": "https://example.com/other", "linked_dids": []
364/// })).is_err());
365/// # Ok::<(), serde_json::Error>(())
366/// ```
367#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
368#[non_exhaustive]
369pub struct DomainLinkageConfiguration {
370    /// `@context`, [`CONTEXT`].
371    #[serde(rename = "@context")]
372    pub context: String,
373    /// The domain linkage credentials, one per linked DID.
374    pub linked_dids: Vec<LinkedDid>,
375}
376
377impl Default for DomainLinkageConfiguration {
378    fn default() -> Self {
379        Self::new()
380    }
381}
382
383impl DomainLinkageConfiguration {
384    /// An empty configuration.
385    pub fn new() -> Self {
386        Self {
387            context: CONTEXT.to_owned(),
388            linked_dids: Vec::new(),
389        }
390    }
391
392    /// This configuration with `credential` added.
393    #[must_use]
394    pub fn with_credential(mut self, credential: Jwt) -> Self {
395        self.linked_dids.push(LinkedDid::Jwt(credential));
396        self
397    }
398
399    /// The credentials in the JSON Web Token proof format, with their positions.
400    pub fn jwts(&self) -> impl Iterator<Item = (usize, &Jwt)> {
401        self.linked_dids
402            .iter()
403            .enumerate()
404            .filter_map(|(i, entry)| match entry {
405                LinkedDid::Jwt(jwt) => Some((i, jwt)),
406                _ => None,
407            })
408    }
409
410    /// Checks that `@context` is [`CONTEXT`].
411    ///
412    /// # Errors
413    ///
414    /// [`DomainLinkageError::InvalidContext`].
415    pub fn check(&self) -> Result<(), DomainLinkageError> {
416        if self.context != CONTEXT {
417            return Err(DomainLinkageError::InvalidContext(self.context.clone()));
418        }
419        Ok(())
420    }
421}
422
423impl<'de> Deserialize<'de> for DomainLinkageConfiguration {
424    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
425        #[derive(Deserialize)]
426        #[serde(deny_unknown_fields)]
427        struct Raw {
428            #[serde(rename = "@context")]
429            context: String,
430            linked_dids: Vec<LinkedDid>,
431        }
432        let raw = Raw::deserialize(deserializer)?;
433        let configuration = Self {
434            context: raw.context,
435            linked_dids: raw.linked_dids,
436        };
437        configuration.check().map_err(serde::de::Error::custom)?;
438        Ok(configuration)
439    }
440}
441
442/// Builds a domain linkage credential in the JSON Web Token proof format: a VCDM 1.1
443/// credential of type `DomainLinkageCredential` issued by `did` about `did` and `origin`,
444/// carried in the `vc` claim with `iss`, `sub`, `nbf` and `exp` and nothing else.
445///
446/// # Examples
447///
448/// ```
449/// use chrono::{TimeZone as _, Utc};
450/// use identity_kit::did::Did;
451/// use identity_kit::domain_linkage::DomainLinkageCredentialBuilder;
452/// use url::Url;
453///
454/// let did = Did::parse("did:key:z6MkoTHsgNNrby8JzCNQ1iRLyW5QQ6R8Xuu6AA8igGrMVPUM")?;
455/// let builder = DomainLinkageCredentialBuilder::new(
456///     did.clone(),
457///     &Url::parse("https://identity.foundation")?,
458///     Utc.with_ymd_and_hms(2020, 12, 4, 20, 12, 19).unwrap(),
459///     Utc.with_ymd_and_hms(2025, 12, 4, 20, 12, 19).unwrap(),
460/// )?;
461/// let claims = builder.jwt_claims();
462/// assert_eq!(claims.iss.as_deref(), Some(did.as_str()));
463/// assert_eq!(claims.sub, claims.iss);
464/// assert_eq!(claims.nbf, Some(1607112739));
465/// assert_eq!(claims.exp, Some(1764879139));
466/// assert_eq!(claims.additional["vc"]["credentialSubject"]["origin"], "https://identity.foundation");
467/// # Ok::<(), Box<dyn std::error::Error>>(())
468/// ```
469#[derive(Debug, Clone, PartialEq, Eq)]
470#[non_exhaustive]
471pub struct DomainLinkageCredentialBuilder {
472    /// The DID: issuer and subject of the credential.
473    pub did: Did,
474    /// The origin, normalized.
475    pub origin: String,
476    /// `issuanceDate` and `nbf`.
477    pub issuance_date: DateTime<Utc>,
478    /// `expirationDate` and `exp`.
479    pub expiration_date: DateTime<Utc>,
480}
481
482impl DomainLinkageCredentialBuilder {
483    /// A credential linking `did` and `origin`, valid from `issuance_date` to
484    /// `expiration_date`.
485    ///
486    /// # Errors
487    ///
488    /// [`DomainLinkageError::InvalidOrigin`] when `origin` has no host.
489    pub fn new(
490        did: Did,
491        origin: &Url,
492        issuance_date: DateTime<Utc>,
493        expiration_date: DateTime<Utc>,
494    ) -> Result<Self, DomainLinkageError> {
495        Ok(Self {
496            did,
497            origin: normalize_origin(origin.as_str())?,
498            issuance_date,
499            expiration_date,
500        })
501    }
502
503    /// The credential.
504    pub fn credential(&self) -> Credential {
505        let subject =
506            Subject::new(Uri::from(self.did.clone())).with_property("origin", self.origin.clone());
507        Credential::new(self.did.clone(), self.issuance_date, subject)
508            .with_context(CONTEXT)
509            .with_type(CREDENTIAL_TYPE)
510            .with_expiration_date(self.expiration_date)
511    }
512
513    /// The JWT claims: `iss` and `sub` the DID, `nbf` and `exp` the validity period, `vc` the
514    /// whole credential.
515    pub fn jwt_claims(&self) -> JwtClaims {
516        let mut claims = JwtClaims::new()
517            .with_nbf(self.issuance_date)
518            .with_exp(self.expiration_date);
519        claims.iss = Some(self.did.as_str().to_owned());
520        claims.sub = Some(self.did.as_str().to_owned());
521        claims.additional.insert(
522            "vc".to_owned(),
523            serde_json::to_value(self.credential()).expect("a credential serializes"),
524        );
525        claims
526    }
527
528    /// The credential signed with `signer` under a header of `alg` and `kid` only, `kid`
529    /// naming the verification method of the DID.
530    ///
531    /// # Errors
532    ///
533    /// [`DomainLinkageError::Validation`] with [`ValidationError::Jws`] when signing fails.
534    pub async fn sign(&self, kid: &DidUrl, signer: &dyn Signer) -> Result<Jwt, DomainLinkageError> {
535        let header = JwsHeader::new(signer.alg()).with_kid(kid.as_str());
536        let jwt = Jwt::sign(&header, &self.jwt_claims(), signer)
537            .await
538            .map_err(ValidationError::from)?;
539        Ok(jwt)
540    }
541}
542
543/// Why a domain linkage credential or configuration did not validate.
544#[derive(Debug, thiserror::Error)]
545#[non_exhaustive]
546pub enum DomainLinkageError {
547    /// An origin is not `scheme://host[:port]`.
548    #[error("invalid origin: {0}")]
549    InvalidOrigin(String),
550    /// The configuration's `@context` is not [`CONTEXT`].
551    #[error("`@context` is {0:?}, not `{CONTEXT}`")]
552    InvalidContext(String),
553    /// The JWT header has a `typ`, which the format forbids.
554    #[error("header `typ` {0:?} must not be present")]
555    TypPresent(String),
556    /// The JWT header has a parameter other than `alg` and `kid`.
557    #[error("header parameter `{0}` must not be present")]
558    UnexpectedHeaderParameter(String),
559    /// The JWT payload has a claim other than `iss`, `sub`, `nbf`, `exp` and `vc`.
560    #[error("claim `{0}` must not be present")]
561    UnexpectedClaim(String),
562    /// A required claim is missing: `iss`, `sub`, `exp` or `nbf`.
563    #[error("claim `{0}` is required")]
564    MissingClaim(&'static str),
565    /// The credential has an `id`, which the format forbids.
566    #[error("the credential must not have an `id`")]
567    IdPresent,
568    /// The credential's context does not include [`CONTEXT`].
569    #[error("the credential's `@context` does not include `{CONTEXT}`")]
570    MissingContext,
571    /// The credential's type does not include [`CREDENTIAL_TYPE`].
572    #[error("the credential's `type` does not include `{CREDENTIAL_TYPE}`")]
573    MissingType,
574    /// The credential has no `expirationDate`.
575    #[error("the credential must have an `expirationDate`")]
576    MissingExpirationDate,
577    /// `credentialSubject.id` is missing or not a DID.
578    #[error("`credentialSubject.id` {0:?} is not a DID")]
579    SubjectNotDid(Option<String>),
580    /// The issuer, `iss` or `sub` differs from `credentialSubject.id`.
581    #[error("{role} `{found}` is not the subject `{subject}`")]
582    SubjectMismatch {
583        /// `issuer`, `iss` or `sub`.
584        role: &'static str,
585        /// The value found.
586        found: String,
587        /// `credentialSubject.id`.
588        subject: Did,
589    },
590    /// `credentialSubject.origin` is missing or not one string.
591    #[error("`credentialSubject.origin` {0:?} is not one origin")]
592    InvalidOriginProperty(Option<Value>),
593    /// `credentialSubject.origin` is not the origin the resource was fetched from.
594    #[error("origin {found:?} is not {expected:?}")]
595    OriginMismatch {
596        /// The normalized origin expected.
597        expected: String,
598        /// The credential's origin, normalized.
599        found: String,
600    },
601    /// The credential did not decode or validate as a credential JWT.
602    #[error(transparent)]
603    Validation(#[from] ValidationError),
604    /// The configuration holds no JWT credential for the DID.
605    #[error("the configuration holds no JWT credential for `{0}`")]
606    NoCredentialFor(Did),
607    /// No credential of the configuration validated; the error of each JWT entry by its
608    /// position.
609    #[error("no domain linkage credential validated: {}", format_errors(.0))]
610    NoValidCredential(BTreeMap<usize, DomainLinkageError>),
611    /// A `LinkedDomains` service does not list one or more origins.
612    #[error("service `{id}`: {reason}")]
613    InvalidService {
614        /// The id of the service.
615        id: DidUrl,
616        /// What is wrong with it.
617        reason: String,
618    },
619    /// The origin is not `https`, the only scheme the resource is fetched over.
620    #[error("`{0}` is not an HTTPS origin")]
621    InsecureOrigin(String),
622    /// The [`HttpClient`] got no response.
623    #[error("fetching `{url}` failed: {source}")]
624    Http {
625        /// The URL of the resource.
626        url: Url,
627        /// Why there was no response.
628        #[source]
629        source: HttpError,
630    },
631    /// The origin publishes no DID configuration: the server answered `404` or `410`. A
632    /// legitimate state of an origin rather than a failure; see
633    /// [`is_not_published`](Self::is_not_published).
634    #[error("`{url}` publishes no DID configuration (HTTP {status})")]
635    NotPublished {
636        /// The URL of the resource.
637        url: Url,
638        /// `404` or `410`.
639        status: u16,
640    },
641    /// The server answered with a status other than `2xx`, `404` and `410`.
642    #[error("fetching `{url}` returned HTTP {status}")]
643    HttpStatus {
644        /// The URL of the resource.
645        url: Url,
646        /// The status code.
647        status: u16,
648    },
649    /// The body is not a DID configuration resource: not JSON, not of its shape, or of
650    /// another `@context`.
651    #[error("`{url}` is not a DID configuration resource: {source}")]
652    InvalidConfiguration {
653        /// The URL of the resource.
654        url: Url,
655        /// Why the body did not deserialize.
656        #[source]
657        source: serde_json::Error,
658    },
659    /// The DID did not resolve.
660    #[error("resolving the DID failed: {0}")]
661    Resolution(#[from] ResolutionError),
662    /// The origin was not fetched: the DID document lists more origins than the client's
663    /// limit, given here.
664    #[error("the DID document lists more than {0} origins")]
665    TooManyOrigins(usize),
666    /// The entry was not validated: the configuration holds more JWT entries, or more naming
667    /// the DID, than the validator's limit, given here.
668    #[error("the configuration holds more than {0} credentials to validate")]
669    TooManyCredentials(usize),
670}
671
672impl DomainLinkageError {
673    /// Whether the origin publishes no DID configuration ([`NotPublished`](Self::NotPublished)),
674    /// as opposed to a configuration that could not be fetched, read or validated.
675    pub fn is_not_published(&self) -> bool {
676        matches!(self, Self::NotPublished { .. })
677    }
678}
679
680impl From<CredentialError> for DomainLinkageError {
681    fn from(error: CredentialError) -> Self {
682        Self::Validation(error.into())
683    }
684}
685
686/// `errors` as `index: error; …`.
687fn format_errors(errors: &BTreeMap<usize, DomainLinkageError>) -> String {
688    errors
689        .iter()
690        .map(|(index, error)| format!("{index}: {error}"))
691        .collect::<Vec<_>>()
692        .join("; ")
693}
694
695/// A validated DID configuration resource: which entries linked which DIDs, and which failed.
696#[derive(Debug)]
697#[non_exhaustive]
698pub struct DomainLinkageReport {
699    /// The entries that validated, by position, with the DID each links.
700    pub linked: Vec<(usize, Did, DecodedCredential)>,
701    /// The JWT entries that failed, by position.
702    pub failed: BTreeMap<usize, DomainLinkageError>,
703}
704
705impl DomainLinkageReport {
706    /// The DIDs linked to the origin, in the order of the resource.
707    pub fn dids(&self) -> impl Iterator<Item = &Did> {
708        self.linked.iter().map(|(_, did, _)| did)
709    }
710}
711
712/// Validates domain linkage credentials and DID configuration resources (DID Configuration
713/// Resource Verification): the subject is a DID equal to the issuer, `iss` and `sub`, the
714/// one `origin` is the requested origin, the credential has an expiration date, no `id`, no
715/// extra claims or header parameters, and its signature verifies under a key of the DID
716/// listed under `assertionMethod`.
717///
718/// A configuration is written by whoever controls the origin, and every entry validated
719/// resolves its issuer's DID, so [`validate_configuration`](Self::validate_configuration) and
720/// [`validate_linkage`](Self::validate_linkage) validate at most
721/// [`max_credentials`](Self::max_credentials) entries of one configuration.
722///
723/// # Examples
724///
725/// ```
726/// # #[cfg(feature = "did-key")]
727/// # {
728/// use identity_kit::domain_linkage::DomainLinkageValidator;
729/// use identity_kit::jose::DefaultVerifier;
730/// use identity_kit::methods::DidKey;
731/// use identity_kit::resolver::Resolver;
732///
733/// let resolver = Resolver::builder().with(DidKey::default()).build();
734/// let validator = DomainLinkageValidator::new(resolver, DefaultVerifier);
735/// assert!(!validator.credential_validator().has_status_check());
736/// # }
737/// ```
738#[derive(Debug)]
739pub struct DomainLinkageValidator<R, V = DefaultVerifier> {
740    credentials: JwtCredentialValidator<R, V>,
741    max_credentials: usize,
742}
743
744impl<R, V> DomainLinkageValidator<R, V> {
745    /// The most entries of one configuration [`new`](Self::new) validates: 32.
746    pub const DEFAULT_MAX_CREDENTIALS: usize = 32;
747}
748
749impl<R: KeyResolver, V: JwsVerifier> DomainLinkageValidator<R, V> {
750    /// A validator resolving keys with `resolver` and verifying signatures with `verifier`,
751    /// validating at most [`DEFAULT_MAX_CREDENTIALS`](Self::DEFAULT_MAX_CREDENTIALS) entries
752    /// of a configuration.
753    pub fn new(resolver: R, verifier: V) -> Self {
754        Self::from_credential_validator(JwtCredentialValidator::new(resolver, verifier))
755    }
756
757    /// A validator over `credentials`, validating at most
758    /// [`DEFAULT_MAX_CREDENTIALS`](Self::DEFAULT_MAX_CREDENTIALS) entries of a configuration.
759    pub fn from_credential_validator(credentials: JwtCredentialValidator<R, V>) -> Self {
760        Self {
761            credentials,
762            max_credentials: Self::DEFAULT_MAX_CREDENTIALS,
763        }
764    }
765
766    /// This validator validating at most `max_credentials` entries of a configuration.
767    #[must_use]
768    pub fn with_max_credentials(mut self, max_credentials: usize) -> Self {
769        self.max_credentials = max_credentials;
770        self
771    }
772
773    /// The most entries of one configuration [`validate_configuration`](Self::validate_configuration)
774    /// and [`validate_linkage`](Self::validate_linkage) validate.
775    pub fn max_credentials(&self) -> usize {
776        self.max_credentials
777    }
778
779    /// The credential validator.
780    pub fn credential_validator(&self) -> &JwtCredentialValidator<R, V> {
781        &self.credentials
782    }
783
784    /// Validates `jwt` as a domain linkage credential for `origin`.
785    ///
786    /// # Errors
787    ///
788    /// The first [`DomainLinkageError`] found: the format checks first, then the signature,
789    /// issuer binding and validity period of [`JwtCredentialValidator::validate`].
790    pub async fn validate_credential(
791        &self,
792        jwt: &Jwt,
793        origin: &Url,
794        options: &CredentialValidationOptions,
795    ) -> Result<DecodedCredential, DomainLinkageError> {
796        let expected = normalize_origin(origin.as_str())?;
797        let decoded = decode_credential(jwt)?;
798        check_format(&decoded, &expected)?;
799        self.credentials.validate(jwt, options).await?;
800        Ok(decoded)
801    }
802
803    /// Validates the credential of `configuration` linking `did` to `origin`: the first JWT
804    /// entry issued by `did` that validates. Entries naming `did` past the first
805    /// [`max_credentials`](Self::max_credentials) are not validated.
806    ///
807    /// # Errors
808    ///
809    /// [`DomainLinkageError::NoCredentialFor`] when no JWT entry names `did` as issuer,
810    /// [`DomainLinkageError::NoValidCredential`] with the error of each such entry otherwise,
811    /// [`DomainLinkageError::TooManyCredentials`] for those past the limit.
812    pub async fn validate_linkage(
813        &self,
814        configuration: &DomainLinkageConfiguration,
815        did: &Did,
816        origin: &Url,
817        options: &CredentialValidationOptions,
818    ) -> Result<DecodedCredential, DomainLinkageError> {
819        configuration.check()?;
820        let mut errors = BTreeMap::new();
821        let mut validated = 0;
822        for (index, jwt) in configuration.jwts() {
823            if issuer_of(jwt).as_ref() != Some(did) {
824                continue;
825            }
826            if validated == self.max_credentials {
827                errors.insert(
828                    index,
829                    DomainLinkageError::TooManyCredentials(self.max_credentials),
830                );
831                continue;
832            }
833            validated += 1;
834            match self.validate_credential(jwt, origin, options).await {
835                Ok(decoded) => return Ok(decoded),
836                Err(error) => {
837                    errors.insert(index, error);
838                }
839            }
840        }
841        if errors.is_empty() {
842            return Err(DomainLinkageError::NoCredentialFor(did.clone()));
843        }
844        Err(DomainLinkageError::NoValidCredential(errors))
845    }
846
847    /// Validates every JWT entry of `configuration` for `origin`: succeeds when at least one
848    /// does, reporting the rest as errors. Entries past the first
849    /// [`max_credentials`](Self::max_credentials) are not validated and report
850    /// [`DomainLinkageError::TooManyCredentials`].
851    ///
852    /// # Errors
853    ///
854    /// [`DomainLinkageError::InvalidContext`], or [`DomainLinkageError::NoValidCredential`]
855    /// with the error of every JWT entry.
856    pub async fn validate_configuration(
857        &self,
858        configuration: &DomainLinkageConfiguration,
859        origin: &Url,
860        options: &CredentialValidationOptions,
861    ) -> Result<DomainLinkageReport, DomainLinkageError> {
862        configuration.check()?;
863        let mut linked = Vec::new();
864        let mut failed = BTreeMap::new();
865        for (position, (index, jwt)) in configuration.jwts().enumerate() {
866            if position >= self.max_credentials {
867                failed.insert(
868                    index,
869                    DomainLinkageError::TooManyCredentials(self.max_credentials),
870                );
871                continue;
872            }
873            match self.validate_credential(jwt, origin, options).await {
874                Ok(decoded) => {
875                    let did = Did::parse(decoded.credential.issuer.id().as_str())
876                        .expect("the format check accepted the issuer as a DID");
877                    linked.push((index, did, decoded));
878                }
879                Err(error) => {
880                    failed.insert(index, error);
881                }
882            }
883        }
884        if linked.is_empty() {
885            return Err(DomainLinkageError::NoValidCredential(failed));
886        }
887        Ok(DomainLinkageReport { linked, failed })
888    }
889}
890
891/// Fetches the DID configuration resource of `origin` with `http`: a `GET` of
892/// [`configuration_url`] over HTTPS, the body deserialized as a
893/// [`DomainLinkageConfiguration`], so its `@context` is checked and Linked Data Proof entries
894/// are carried as data. Nothing is validated.
895///
896/// # Errors
897///
898/// - [`DomainLinkageError::InvalidOrigin`] when `origin` has no host, and
899///   [`DomainLinkageError::InsecureOrigin`] when it is not `https`, both without fetching;
900/// - [`DomainLinkageError::NotPublished`] on `404` or `410`: the origin publishes no
901///   configuration;
902/// - [`DomainLinkageError::Http`] when there is no response, and
903///   [`DomainLinkageError::HttpStatus`] for any other status that is not `2xx`;
904/// - [`DomainLinkageError::InvalidConfiguration`] when the body is not a configuration.
905pub async fn fetch_configuration<C: HttpClient + ?Sized>(
906    http: &C,
907    origin: &Url,
908) -> Result<DomainLinkageConfiguration, DomainLinkageError> {
909    let url = configuration_url(origin)?;
910    if url.scheme() != "https" {
911        return Err(DomainLinkageError::InsecureOrigin(
912            url.origin().ascii_serialization(),
913        ));
914    }
915    let response = match http.get(&url).await {
916        Ok(response) => response,
917        Err(source) => return Err(DomainLinkageError::Http { url, source }),
918    };
919    match response.status {
920        200..=299 => {}
921        status @ (404 | 410) => return Err(DomainLinkageError::NotPublished { url, status }),
922        status => return Err(DomainLinkageError::HttpStatus { url, status }),
923    }
924    serde_json::from_slice(&response.body)
925        .map_err(|source| DomainLinkageError::InvalidConfiguration { url, source })
926}
927
928/// What became of one origin of a `LinkedDomains` service.
929#[derive(Debug)]
930#[non_exhaustive]
931pub struct LinkedDomainReport {
932    /// The id of the service.
933    pub service: DidUrl,
934    /// The origin, normalized; `None` when the service entry itself is invalid.
935    pub origin: Option<Url>,
936    /// The domain linkage credential of the DID at that origin, validated, or why there is
937    /// none. [`DomainLinkageError::is_not_published`] tells an origin without a configuration
938    /// apart.
939    pub result: Result<DecodedCredential, DomainLinkageError>,
940}
941
942/// Fetches DID configuration resources with an [`HttpClient`] and validates them with a
943/// [`DomainLinkageValidator`]: for an origin, for one DID at an origin, or for every origin a
944/// DID document lists.
945///
946/// Origins come from callers and from DID documents, so the client fetches whatever a DID
947/// controller names, and validating a whole configuration resolves every DID it names:
948/// `http`, and the `did:web` client of the resolver, must enforce the protections
949/// `ReqwestClient` (feature `reqwest`) does (HTTPS only, no redirects, no non-public
950/// addresses after name resolution, a body limit and a timeout), or fetching becomes a
951/// request forgery primitive. A DID document can also list any number of origins, so
952/// [`validate_document`](Self::validate_document) fetches at most
953/// [`max_origins`](Self::max_origins) of them.
954#[derive(Debug)]
955pub struct DomainLinkageClient<C, R, V = DefaultVerifier> {
956    http: C,
957    validator: DomainLinkageValidator<R, V>,
958    max_origins: usize,
959}
960
961impl<C, R, V> DomainLinkageClient<C, R, V> {
962    /// The most origins [`new`](Self::new) fetches for one DID document: 32.
963    pub const DEFAULT_MAX_ORIGINS: usize = 32;
964}
965
966impl<C: HttpClient, R: KeyResolver, V: JwsVerifier> DomainLinkageClient<C, R, V> {
967    /// A client fetching with `http` and validating with `validator`, fetching at most
968    /// [`DEFAULT_MAX_ORIGINS`](Self::DEFAULT_MAX_ORIGINS) origins of a DID document.
969    pub fn new(http: C, validator: DomainLinkageValidator<R, V>) -> Self {
970        Self {
971            http,
972            validator,
973            max_origins: Self::DEFAULT_MAX_ORIGINS,
974        }
975    }
976
977    /// This client fetching at most `max_origins` origins of a DID document.
978    #[must_use]
979    pub fn with_max_origins(mut self, max_origins: usize) -> Self {
980        self.max_origins = max_origins;
981        self
982    }
983
984    /// The most origins [`validate_document`](Self::validate_document) fetches for one
985    /// document.
986    pub fn max_origins(&self) -> usize {
987        self.max_origins
988    }
989
990    /// The HTTP client.
991    pub fn http(&self) -> &C {
992        &self.http
993    }
994
995    /// The validator.
996    pub fn validator(&self) -> &DomainLinkageValidator<R, V> {
997        &self.validator
998    }
999
1000    /// The DID configuration resource of `origin`, unvalidated.
1001    ///
1002    /// # Errors
1003    ///
1004    /// As [`fetch_configuration`].
1005    pub async fn fetch_configuration(
1006        &self,
1007        origin: &Url,
1008    ) -> Result<DomainLinkageConfiguration, DomainLinkageError> {
1009        fetch_configuration(&self.http, origin).await
1010    }
1011
1012    /// Fetches the configuration of `origin` and validates every JWT entry, as
1013    /// [`DomainLinkageValidator::validate_configuration`].
1014    ///
1015    /// # Errors
1016    ///
1017    /// As [`fetch_configuration`] and
1018    /// [`validate_configuration`](DomainLinkageValidator::validate_configuration).
1019    pub async fn validate_origin(
1020        &self,
1021        origin: &Url,
1022        options: &CredentialValidationOptions,
1023    ) -> Result<DomainLinkageReport, DomainLinkageError> {
1024        let configuration = self.fetch_configuration(origin).await?;
1025        self.validator
1026            .validate_configuration(&configuration, origin, options)
1027            .await
1028    }
1029
1030    /// Fetches the configuration of `origin` and validates the credential linking `did`, as
1031    /// [`DomainLinkageValidator::validate_linkage`]. Only `did` is resolved.
1032    ///
1033    /// # Errors
1034    ///
1035    /// As [`fetch_configuration`] and
1036    /// [`validate_linkage`](DomainLinkageValidator::validate_linkage).
1037    pub async fn validate_linkage(
1038        &self,
1039        did: &Did,
1040        origin: &Url,
1041        options: &CredentialValidationOptions,
1042    ) -> Result<DecodedCredential, DomainLinkageError> {
1043        let configuration = self.fetch_configuration(origin).await?;
1044        self.validator
1045            .validate_linkage(&configuration, did, origin, options)
1046            .await
1047    }
1048
1049    /// Validates the linkage of the DID of `document` to every origin of its `LinkedDomains`
1050    /// services: one report per origin in document order, and one for each invalid service
1051    /// entry. Origins past the first [`max_origins`](Self::max_origins) are not fetched and
1052    /// report [`DomainLinkageError::TooManyOrigins`].
1053    pub async fn validate_document(
1054        &self,
1055        document: &DidDocument,
1056        options: &CredentialValidationOptions,
1057    ) -> Vec<LinkedDomainReport> {
1058        let mut reports = Vec::new();
1059        let mut fetched = 0;
1060        for service in document
1061            .service
1062            .iter()
1063            .filter(|service| service.has_type(SERVICE_TYPE))
1064        {
1065            let linked = match linked_domains_service(service) {
1066                Ok(linked) => linked,
1067                Err(error) => {
1068                    reports.push(LinkedDomainReport {
1069                        service: service.id.clone(),
1070                        origin: None,
1071                        result: Err(error),
1072                    });
1073                    continue;
1074                }
1075            };
1076            for origin in linked.origins {
1077                let result = if fetched < self.max_origins {
1078                    fetched += 1;
1079                    self.validate_linkage(&document.id, &origin, options).await
1080                } else {
1081                    Err(DomainLinkageError::TooManyOrigins(self.max_origins))
1082                };
1083                reports.push(LinkedDomainReport {
1084                    service: service.id.clone(),
1085                    origin: Some(origin),
1086                    result,
1087                });
1088            }
1089        }
1090        reports
1091    }
1092}
1093
1094impl<C: HttpClient, V: JwsVerifier> DomainLinkageClient<C, Resolver, V> {
1095    /// Resolves `did` with the validator's resolver, then validates its linkage to every
1096    /// origin its document lists, as [`validate_document`](Self::validate_document).
1097    ///
1098    /// # Errors
1099    ///
1100    /// [`DomainLinkageError::Resolution`] when `did` does not resolve to a document.
1101    pub async fn validate_did(
1102        &self,
1103        did: &Did,
1104        options: &CredentialValidationOptions,
1105    ) -> Result<Vec<LinkedDomainReport>, DomainLinkageError> {
1106        let document = self
1107            .validator
1108            .credential_validator()
1109            .resolver()
1110            .resolve_document(did)
1111            .await?;
1112        Ok(self.validate_document(&document, options).await)
1113    }
1114}
1115
1116/// The issuer DID of `jwt`, read without verifying; `None` when it has none or it is not a
1117/// DID.
1118fn issuer_of(jwt: &Jwt) -> Option<Did> {
1119    crate::vc::validation::extract_issuer(jwt).ok()
1120}
1121
1122/// The checks of the JSON Web Token proof format and of DID Configuration Resource
1123/// Verification that need no key.
1124fn check_format(
1125    decoded: &DecodedCredential,
1126    expected_origin: &str,
1127) -> Result<(), DomainLinkageError> {
1128    let header = &decoded.header;
1129    if let Some(typ) = &header.typ {
1130        return Err(DomainLinkageError::TypPresent(typ.clone()));
1131    }
1132    if header.kid.is_none() {
1133        return Err(ValidationError::KeyReference(KeyReferenceError::Missing).into());
1134    }
1135    for (name, present) in [
1136        ("cty", header.cty.is_some()),
1137        ("jwk", header.jwk.is_some()),
1138        ("crit", header.crit.is_some()),
1139    ] {
1140        if present {
1141            return Err(DomainLinkageError::UnexpectedHeaderParameter(
1142                name.to_owned(),
1143            ));
1144        }
1145    }
1146    if let Some(name) = header.additional.keys().next() {
1147        return Err(DomainLinkageError::UnexpectedHeaderParameter(name.clone()));
1148    }
1149
1150    let claims = &decoded.claims;
1151    for (name, present) in [
1152        ("aud", claims.aud.is_some()),
1153        ("iat", claims.iat.is_some()),
1154        ("jti", claims.jti.is_some()),
1155    ] {
1156        if present {
1157            return Err(DomainLinkageError::UnexpectedClaim(name.to_owned()));
1158        }
1159    }
1160    if let Some(name) = claims.additional.keys().find(|name| *name != "vc") {
1161        return Err(DomainLinkageError::UnexpectedClaim(name.clone()));
1162    }
1163    let iss = claims
1164        .iss
1165        .as_deref()
1166        .ok_or(DomainLinkageError::MissingClaim("iss"))?;
1167    let sub = claims
1168        .sub
1169        .as_deref()
1170        .ok_or(DomainLinkageError::MissingClaim("sub"))?;
1171    if claims.nbf.is_none() {
1172        return Err(DomainLinkageError::MissingClaim("nbf"));
1173    }
1174    if claims.exp.is_none() {
1175        return Err(DomainLinkageError::MissingClaim("exp"));
1176    }
1177
1178    let credential = &decoded.credential;
1179    if credential.id.is_some() {
1180        return Err(DomainLinkageError::IdPresent);
1181    }
1182    if !credential.context.contains(CONTEXT) {
1183        return Err(DomainLinkageError::MissingContext);
1184    }
1185    if !credential.has_type(CREDENTIAL_TYPE) {
1186        return Err(DomainLinkageError::MissingType);
1187    }
1188    if credential.expiration_date.is_none() {
1189        return Err(DomainLinkageError::MissingExpirationDate);
1190    }
1191    // A JWT with `sub` and several subjects does not decode as a credential, so one subject
1192    // is all that gets here.
1193    let subject = credential
1194        .subject()
1195        .ok_or(DomainLinkageError::SubjectNotDid(None))?;
1196    let subject_id = subject.id.as_ref().map(Uri::as_str);
1197    let subject_did = subject_id
1198        .and_then(|id| Did::parse(id).ok())
1199        .ok_or_else(|| DomainLinkageError::SubjectNotDid(subject_id.map(str::to_owned)))?;
1200    for (role, found) in [
1201        ("issuer", credential.issuer.id().as_str()),
1202        ("iss", iss),
1203        ("sub", sub),
1204    ] {
1205        if found != subject_did.as_str() {
1206            return Err(DomainLinkageError::SubjectMismatch {
1207                role,
1208                found: found.to_owned(),
1209                subject: subject_did,
1210            });
1211        }
1212    }
1213    let origin = subject.properties.get("origin");
1214    let Some(Value::String(origin)) = origin else {
1215        return Err(DomainLinkageError::InvalidOriginProperty(origin.cloned()));
1216    };
1217    let found = normalize_origin(origin)?;
1218    if found != expected_origin {
1219        return Err(DomainLinkageError::OriginMismatch {
1220            expected: expected_origin.to_owned(),
1221            found,
1222        });
1223    }
1224    Ok(())
1225}
1226
1227impl fmt::Display for DomainLinkageConfiguration {
1228    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1229        let json = serde_json::to_string(self).map_err(|_| fmt::Error)?;
1230        f.write_str(&json)
1231    }
1232}