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