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}