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
16use core::fmt;
17use std::collections::BTreeMap;
18
19use chrono::{DateTime, Utc};
20use serde::{Deserialize, Deserializer, Serialize};
21use serde_json::Value;
22use url::Url;
23
24use crate::did::{Did, DidUrl, Uri};
25use crate::jose::{DefaultVerifier, JwsHeader, JwsVerifier, Signer};
26use crate::vc::validation::{
27    CredentialValidationOptions, DecodedCredential, JwtCredentialValidator, KeyResolver,
28    ValidationError, decode_credential,
29};
30use crate::vc::{Credential, CredentialError, Jwt, JwtClaims, Subject};
31
32/// The context of the DID configuration resource and of domain linkage credentials.
33pub const CONTEXT: &str = "https://identity.foundation/.well-known/did-configuration/v1";
34
35/// The credential type, `DomainLinkageCredential`.
36pub const CREDENTIAL_TYPE: &str = "DomainLinkageCredential";
37
38/// The path of the resource under an origin.
39pub const WELL_KNOWN_PATH: &str = "/.well-known/did-configuration.json";
40
41/// The URL of the DID configuration resource of `origin`.
42///
43/// # Errors
44///
45/// [`DomainLinkageError::InvalidOrigin`] when `origin` has no host.
46///
47/// # Examples
48///
49/// ```
50/// use identity_kit::domain_linkage::configuration_url;
51/// use url::Url;
52///
53/// let url = configuration_url(&Url::parse("https://identity.foundation")?)?;
54/// assert_eq!(url.as_str(), "https://identity.foundation/.well-known/did-configuration.json");
55/// # Ok::<(), Box<dyn std::error::Error>>(())
56/// ```
57pub fn configuration_url(origin: &Url) -> Result<Url, DomainLinkageError> {
58    let origin = normalize_origin(origin.as_str())?;
59    Url::parse(&format!("{origin}{WELL_KNOWN_PATH}"))
60        .map_err(|e| DomainLinkageError::InvalidOrigin(format!("{origin}: {e}")))
61}
62
63/// `origin` as `scheme://host[:port]`, lowercased, with `https` assumed when no scheme is
64/// given and a path, query or fragment dropped.
65///
66/// # Errors
67///
68/// [`DomainLinkageError::InvalidOrigin`] when `origin` has no host.
69pub fn normalize_origin(origin: &str) -> Result<String, DomainLinkageError> {
70    let with_scheme = if has_scheme(origin) {
71        origin.to_owned()
72    } else {
73        format!("https://{origin}")
74    };
75    let url = Url::parse(&with_scheme)
76        .map_err(|e| DomainLinkageError::InvalidOrigin(format!("{origin:?}: {e}")))?;
77    if url.host_str().is_none() {
78        return Err(DomainLinkageError::InvalidOrigin(format!(
79            "{origin:?} has no host"
80        )));
81    }
82    let normalized = url.origin();
83    if !normalized.is_tuple() {
84        return Err(DomainLinkageError::InvalidOrigin(format!(
85            "{origin:?} is not a domain origin"
86        )));
87    }
88    Ok(normalized.ascii_serialization())
89}
90
91/// Whether `s` starts with a URI scheme (RFC 3986 §3.1) rather than a `host:port`.
92fn has_scheme(s: &str) -> bool {
93    let Some((scheme, rest)) = s.split_once(':') else {
94        return false;
95    };
96    let mut chars = scheme.chars();
97    chars.next().is_some_and(|c| c.is_ascii_alphabetic())
98        && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
99        && !rest.starts_with(|c: char| c.is_ascii_digit())
100}
101
102/// One entry of `linked_dids`.
103#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
104#[serde(untagged)]
105#[non_exhaustive]
106pub enum LinkedDid {
107    /// A domain linkage credential in the JSON Web Token proof format.
108    Jwt(Jwt),
109    /// A domain linkage credential in the Linked Data Proof format, carried as data. An entry
110    /// that is neither a compact JWT nor an object is a parse error, not a silently skipped
111    /// credential.
112    LinkedDataProof(serde_json::Map<String, Value>),
113}
114
115/// The DID configuration resource: `@context` and the domain linkage credentials.
116///
117/// # Examples
118///
119/// ```
120/// use identity_kit::domain_linkage::{DomainLinkageConfiguration, LinkedDid};
121/// use serde_json::json;
122///
123/// let configuration: DomainLinkageConfiguration = serde_json::from_value(json!({
124///   "@context": "https://identity.foundation/.well-known/did-configuration/v1",
125///   "linked_dids": [
126///     "eyJhbGciOiJFZERTQSJ9.RXhhbXBsZSBvZiBFZDI1NTE5IHNpZ25pbmc.hgyY0il_MGCjP0JzlnLWG1PPOt7-09PGcvMg3AIbQR6dWbhijcNR4ki4iylGjg5BhVsPt9g7sVvpAr_MuM0KAg"
127///   ]
128/// }))?;
129/// assert_eq!(configuration.jwts().count(), 1);
130/// assert!(serde_json::from_value::<DomainLinkageConfiguration>(json!({
131///   "@context": "https://example.com/other", "linked_dids": []
132/// })).is_err());
133/// # Ok::<(), serde_json::Error>(())
134/// ```
135#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
136#[non_exhaustive]
137pub struct DomainLinkageConfiguration {
138    /// `@context`, [`CONTEXT`].
139    #[serde(rename = "@context")]
140    pub context: String,
141    /// The domain linkage credentials, one per linked DID.
142    pub linked_dids: Vec<LinkedDid>,
143}
144
145impl Default for DomainLinkageConfiguration {
146    fn default() -> Self {
147        Self::new()
148    }
149}
150
151impl DomainLinkageConfiguration {
152    /// An empty configuration.
153    pub fn new() -> Self {
154        Self {
155            context: CONTEXT.to_owned(),
156            linked_dids: Vec::new(),
157        }
158    }
159
160    /// This configuration with `credential` added.
161    #[must_use]
162    pub fn with_credential(mut self, credential: Jwt) -> Self {
163        self.linked_dids.push(LinkedDid::Jwt(credential));
164        self
165    }
166
167    /// The credentials in the JSON Web Token proof format, with their positions.
168    pub fn jwts(&self) -> impl Iterator<Item = (usize, &Jwt)> {
169        self.linked_dids
170            .iter()
171            .enumerate()
172            .filter_map(|(i, entry)| match entry {
173                LinkedDid::Jwt(jwt) => Some((i, jwt)),
174                _ => None,
175            })
176    }
177
178    /// Checks that `@context` is [`CONTEXT`].
179    ///
180    /// # Errors
181    ///
182    /// [`DomainLinkageError::InvalidContext`].
183    pub fn check(&self) -> Result<(), DomainLinkageError> {
184        if self.context != CONTEXT {
185            return Err(DomainLinkageError::InvalidContext(self.context.clone()));
186        }
187        Ok(())
188    }
189}
190
191impl<'de> Deserialize<'de> for DomainLinkageConfiguration {
192    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
193        #[derive(Deserialize)]
194        #[serde(deny_unknown_fields)]
195        struct Raw {
196            #[serde(rename = "@context")]
197            context: String,
198            linked_dids: Vec<LinkedDid>,
199        }
200        let raw = Raw::deserialize(deserializer)?;
201        let configuration = Self {
202            context: raw.context,
203            linked_dids: raw.linked_dids,
204        };
205        configuration.check().map_err(serde::de::Error::custom)?;
206        Ok(configuration)
207    }
208}
209
210/// Builds a domain linkage credential in the JSON Web Token proof format: a VCDM 1.1
211/// credential of type `DomainLinkageCredential` issued by `did` about `did` and `origin`,
212/// carried in the `vc` claim with `iss`, `sub`, `nbf` and `exp` and nothing else.
213///
214/// # Examples
215///
216/// ```
217/// use chrono::{TimeZone as _, Utc};
218/// use identity_kit::did::Did;
219/// use identity_kit::domain_linkage::DomainLinkageCredentialBuilder;
220/// use url::Url;
221///
222/// let did = Did::parse("did:key:z6MkoTHsgNNrby8JzCNQ1iRLyW5QQ6R8Xuu6AA8igGrMVPUM")?;
223/// let builder = DomainLinkageCredentialBuilder::new(
224///     did.clone(),
225///     &Url::parse("https://identity.foundation")?,
226///     Utc.with_ymd_and_hms(2020, 12, 4, 20, 12, 19).unwrap(),
227///     Utc.with_ymd_and_hms(2025, 12, 4, 20, 12, 19).unwrap(),
228/// )?;
229/// let claims = builder.jwt_claims();
230/// assert_eq!(claims.iss.as_deref(), Some(did.as_str()));
231/// assert_eq!(claims.sub, claims.iss);
232/// assert_eq!(claims.nbf, Some(1607112739));
233/// assert_eq!(claims.exp, Some(1764879139));
234/// assert_eq!(claims.additional["vc"]["credentialSubject"]["origin"], "https://identity.foundation");
235/// # Ok::<(), Box<dyn std::error::Error>>(())
236/// ```
237#[derive(Debug, Clone, PartialEq, Eq)]
238#[non_exhaustive]
239pub struct DomainLinkageCredentialBuilder {
240    /// The DID: issuer and subject of the credential.
241    pub did: Did,
242    /// The origin, normalized.
243    pub origin: String,
244    /// `issuanceDate` and `nbf`.
245    pub issuance_date: DateTime<Utc>,
246    /// `expirationDate` and `exp`.
247    pub expiration_date: DateTime<Utc>,
248}
249
250impl DomainLinkageCredentialBuilder {
251    /// A credential linking `did` and `origin`, valid from `issuance_date` to
252    /// `expiration_date`.
253    ///
254    /// # Errors
255    ///
256    /// [`DomainLinkageError::InvalidOrigin`] when `origin` has no host.
257    pub fn new(
258        did: Did,
259        origin: &Url,
260        issuance_date: DateTime<Utc>,
261        expiration_date: DateTime<Utc>,
262    ) -> Result<Self, DomainLinkageError> {
263        Ok(Self {
264            did,
265            origin: normalize_origin(origin.as_str())?,
266            issuance_date,
267            expiration_date,
268        })
269    }
270
271    /// The credential.
272    pub fn credential(&self) -> Credential {
273        let subject =
274            Subject::new(Uri::from(self.did.clone())).with_property("origin", self.origin.clone());
275        Credential::new(self.did.clone(), self.issuance_date, subject)
276            .with_context(CONTEXT)
277            .with_type(CREDENTIAL_TYPE)
278            .with_expiration_date(self.expiration_date)
279    }
280
281    /// The JWT claims: `iss` and `sub` the DID, `nbf` and `exp` the validity period, `vc` the
282    /// whole credential.
283    pub fn jwt_claims(&self) -> JwtClaims {
284        let mut claims = JwtClaims::new()
285            .with_nbf(self.issuance_date)
286            .with_exp(self.expiration_date);
287        claims.iss = Some(self.did.as_str().to_owned());
288        claims.sub = Some(self.did.as_str().to_owned());
289        claims.additional.insert(
290            "vc".to_owned(),
291            serde_json::to_value(self.credential()).expect("a credential serializes"),
292        );
293        claims
294    }
295
296    /// The credential signed with `signer` under a header of `alg` and `kid` only, `kid`
297    /// naming the verification method of the DID.
298    ///
299    /// # Errors
300    ///
301    /// [`DomainLinkageError::Validation`] with [`ValidationError::Jws`] when signing fails.
302    pub async fn sign(&self, kid: &DidUrl, signer: &dyn Signer) -> Result<Jwt, DomainLinkageError> {
303        let header = JwsHeader::new(signer.alg()).with_kid(kid.as_str());
304        let jwt = Jwt::sign(&header, &self.jwt_claims(), signer)
305            .await
306            .map_err(ValidationError::from)?;
307        Ok(jwt)
308    }
309}
310
311/// Why a domain linkage credential or configuration did not validate.
312#[derive(Debug, thiserror::Error)]
313#[non_exhaustive]
314pub enum DomainLinkageError {
315    /// An origin is not `scheme://host[:port]`.
316    #[error("invalid origin: {0}")]
317    InvalidOrigin(String),
318    /// The configuration's `@context` is not [`CONTEXT`].
319    #[error("`@context` is {0:?}, not `{CONTEXT}`")]
320    InvalidContext(String),
321    /// The JWT header has a `typ`, which the format forbids.
322    #[error("header `typ` {0:?} must not be present")]
323    TypPresent(String),
324    /// The JWT header has a parameter other than `alg` and `kid`.
325    #[error("header parameter `{0}` must not be present")]
326    UnexpectedHeaderParameter(String),
327    /// The JWT payload has a claim other than `iss`, `sub`, `nbf`, `exp` and `vc`.
328    #[error("claim `{0}` must not be present")]
329    UnexpectedClaim(String),
330    /// A required claim is missing: `iss`, `sub`, `exp` or `nbf`.
331    #[error("claim `{0}` is required")]
332    MissingClaim(&'static str),
333    /// The credential has an `id`, which the format forbids.
334    #[error("the credential must not have an `id`")]
335    IdPresent,
336    /// The credential's context does not include [`CONTEXT`].
337    #[error("the credential's `@context` does not include `{CONTEXT}`")]
338    MissingContext,
339    /// The credential's type does not include [`CREDENTIAL_TYPE`].
340    #[error("the credential's `type` does not include `{CREDENTIAL_TYPE}`")]
341    MissingType,
342    /// The credential has no `expirationDate`.
343    #[error("the credential must have an `expirationDate`")]
344    MissingExpirationDate,
345    /// `credentialSubject.id` is missing or not a DID.
346    #[error("`credentialSubject.id` {0:?} is not a DID")]
347    SubjectNotDid(Option<String>),
348    /// The issuer, `iss` or `sub` differs from `credentialSubject.id`.
349    #[error("{role} `{found}` is not the subject `{subject}`")]
350    SubjectMismatch {
351        /// `issuer`, `iss` or `sub`.
352        role: &'static str,
353        /// The value found.
354        found: String,
355        /// `credentialSubject.id`.
356        subject: Did,
357    },
358    /// `credentialSubject.origin` is missing or not one string.
359    #[error("`credentialSubject.origin` {0:?} is not one origin")]
360    InvalidOriginProperty(Option<Value>),
361    /// `credentialSubject.origin` is not the origin the resource was fetched from.
362    #[error("origin {found:?} is not {expected:?}")]
363    OriginMismatch {
364        /// The normalized origin expected.
365        expected: String,
366        /// The credential's origin, normalized.
367        found: String,
368    },
369    /// The credential did not decode or validate as a credential JWT.
370    #[error(transparent)]
371    Validation(#[from] ValidationError),
372    /// The configuration holds no JWT credential for the DID.
373    #[error("the configuration holds no JWT credential for `{0}`")]
374    NoCredentialFor(Did),
375    /// No credential of the configuration validated; the error of each JWT entry by its
376    /// position.
377    #[error("no domain linkage credential validated: {}", format_errors(.0))]
378    NoValidCredential(BTreeMap<usize, DomainLinkageError>),
379}
380
381impl From<CredentialError> for DomainLinkageError {
382    fn from(error: CredentialError) -> Self {
383        Self::Validation(error.into())
384    }
385}
386
387/// `errors` as `index: error; …`.
388fn format_errors(errors: &BTreeMap<usize, DomainLinkageError>) -> String {
389    errors
390        .iter()
391        .map(|(index, error)| format!("{index}: {error}"))
392        .collect::<Vec<_>>()
393        .join("; ")
394}
395
396/// A validated DID configuration resource: which entries linked which DIDs, and which failed.
397#[derive(Debug)]
398#[non_exhaustive]
399pub struct DomainLinkageReport {
400    /// The entries that validated, by position, with the DID each links.
401    pub linked: Vec<(usize, Did, DecodedCredential)>,
402    /// The JWT entries that failed, by position.
403    pub failed: BTreeMap<usize, DomainLinkageError>,
404}
405
406impl DomainLinkageReport {
407    /// The DIDs linked to the origin, in the order of the resource.
408    pub fn dids(&self) -> impl Iterator<Item = &Did> {
409        self.linked.iter().map(|(_, did, _)| did)
410    }
411}
412
413/// Validates domain linkage credentials and DID configuration resources (DID Configuration
414/// Resource Verification): the subject is a DID equal to the issuer, `iss` and `sub`, the
415/// one `origin` is the requested origin, the credential has an expiration date, no `id`, no
416/// extra claims or header parameters, and its signature verifies under a key of the DID
417/// listed under `assertionMethod`.
418///
419/// # Examples
420///
421/// ```
422/// # #[cfg(feature = "did-key")]
423/// # {
424/// use identity_kit::domain_linkage::DomainLinkageValidator;
425/// use identity_kit::jose::DefaultVerifier;
426/// use identity_kit::methods::DidKey;
427/// use identity_kit::resolver::Resolver;
428///
429/// let resolver = Resolver::builder().with(DidKey::default()).build();
430/// let validator = DomainLinkageValidator::new(resolver, DefaultVerifier);
431/// assert!(!validator.credential_validator().has_status_check());
432/// # }
433/// ```
434#[derive(Debug)]
435pub struct DomainLinkageValidator<R, V = DefaultVerifier> {
436    credentials: JwtCredentialValidator<R, V>,
437}
438
439impl<R: KeyResolver, V: JwsVerifier> DomainLinkageValidator<R, V> {
440    /// A validator resolving keys with `resolver` and verifying signatures with `verifier`.
441    pub fn new(resolver: R, verifier: V) -> Self {
442        Self {
443            credentials: JwtCredentialValidator::new(resolver, verifier),
444        }
445    }
446
447    /// A validator over `credentials`.
448    pub fn from_credential_validator(credentials: JwtCredentialValidator<R, V>) -> Self {
449        Self { credentials }
450    }
451
452    /// The credential validator.
453    pub fn credential_validator(&self) -> &JwtCredentialValidator<R, V> {
454        &self.credentials
455    }
456
457    /// Validates `jwt` as a domain linkage credential for `origin`.
458    ///
459    /// # Errors
460    ///
461    /// The first [`DomainLinkageError`] found: the format checks first, then the signature,
462    /// issuer binding and validity period of [`JwtCredentialValidator::validate`].
463    pub async fn validate_credential(
464        &self,
465        jwt: &Jwt,
466        origin: &Url,
467        options: &CredentialValidationOptions,
468    ) -> Result<DecodedCredential, DomainLinkageError> {
469        let expected = normalize_origin(origin.as_str())?;
470        let decoded = decode_credential(jwt)?;
471        check_format(&decoded, &expected)?;
472        self.credentials.validate(jwt, options).await?;
473        Ok(decoded)
474    }
475
476    /// Validates the credential of `configuration` linking `did` to `origin`: the first JWT
477    /// entry issued by `did` that validates.
478    ///
479    /// # Errors
480    ///
481    /// [`DomainLinkageError::NoCredentialFor`] when no JWT entry names `did` as issuer,
482    /// [`DomainLinkageError::NoValidCredential`] with the error of each such entry otherwise.
483    pub async fn validate_linkage(
484        &self,
485        configuration: &DomainLinkageConfiguration,
486        did: &Did,
487        origin: &Url,
488        options: &CredentialValidationOptions,
489    ) -> Result<DecodedCredential, DomainLinkageError> {
490        configuration.check()?;
491        let mut errors = BTreeMap::new();
492        for (index, jwt) in configuration.jwts() {
493            if issuer_of(jwt).as_ref() != Some(did) {
494                continue;
495            }
496            match self.validate_credential(jwt, origin, options).await {
497                Ok(decoded) => return Ok(decoded),
498                Err(error) => {
499                    errors.insert(index, error);
500                }
501            }
502        }
503        if errors.is_empty() {
504            return Err(DomainLinkageError::NoCredentialFor(did.clone()));
505        }
506        Err(DomainLinkageError::NoValidCredential(errors))
507    }
508
509    /// Validates every JWT entry of `configuration` for `origin`: succeeds when at least one
510    /// does, reporting the rest as errors.
511    ///
512    /// # Errors
513    ///
514    /// [`DomainLinkageError::InvalidContext`], or [`DomainLinkageError::NoValidCredential`]
515    /// with the error of every JWT entry.
516    pub async fn validate_configuration(
517        &self,
518        configuration: &DomainLinkageConfiguration,
519        origin: &Url,
520        options: &CredentialValidationOptions,
521    ) -> Result<DomainLinkageReport, DomainLinkageError> {
522        configuration.check()?;
523        let mut linked = Vec::new();
524        let mut failed = BTreeMap::new();
525        for (index, jwt) in configuration.jwts() {
526            match self.validate_credential(jwt, origin, options).await {
527                Ok(decoded) => {
528                    let did = Did::parse(decoded.credential.issuer.id().as_str())
529                        .expect("the format check accepted the issuer as a DID");
530                    linked.push((index, did, decoded));
531                }
532                Err(error) => {
533                    failed.insert(index, error);
534                }
535            }
536        }
537        if linked.is_empty() {
538            return Err(DomainLinkageError::NoValidCredential(failed));
539        }
540        Ok(DomainLinkageReport { linked, failed })
541    }
542}
543
544/// The issuer DID of `jwt`, read without verifying; `None` when it has none or it is not a
545/// DID.
546fn issuer_of(jwt: &Jwt) -> Option<Did> {
547    crate::vc::validation::extract_issuer(jwt).ok()
548}
549
550/// The checks of the JSON Web Token proof format and of DID Configuration Resource
551/// Verification that need no key.
552fn check_format(
553    decoded: &DecodedCredential,
554    expected_origin: &str,
555) -> Result<(), DomainLinkageError> {
556    let header = &decoded.header;
557    if let Some(typ) = &header.typ {
558        return Err(DomainLinkageError::TypPresent(typ.clone()));
559    }
560    if header.kid.is_none() {
561        return Err(ValidationError::MissingKid.into());
562    }
563    for (name, present) in [
564        ("cty", header.cty.is_some()),
565        ("jwk", header.jwk.is_some()),
566        ("crit", header.crit.is_some()),
567    ] {
568        if present {
569            return Err(DomainLinkageError::UnexpectedHeaderParameter(
570                name.to_owned(),
571            ));
572        }
573    }
574    if let Some(name) = header.additional.keys().next() {
575        return Err(DomainLinkageError::UnexpectedHeaderParameter(name.clone()));
576    }
577
578    let claims = &decoded.claims;
579    for (name, present) in [
580        ("aud", claims.aud.is_some()),
581        ("iat", claims.iat.is_some()),
582        ("jti", claims.jti.is_some()),
583    ] {
584        if present {
585            return Err(DomainLinkageError::UnexpectedClaim(name.to_owned()));
586        }
587    }
588    if let Some(name) = claims.additional.keys().find(|name| *name != "vc") {
589        return Err(DomainLinkageError::UnexpectedClaim(name.clone()));
590    }
591    let iss = claims
592        .iss
593        .as_deref()
594        .ok_or(DomainLinkageError::MissingClaim("iss"))?;
595    let sub = claims
596        .sub
597        .as_deref()
598        .ok_or(DomainLinkageError::MissingClaim("sub"))?;
599    if claims.nbf.is_none() {
600        return Err(DomainLinkageError::MissingClaim("nbf"));
601    }
602    if claims.exp.is_none() {
603        return Err(DomainLinkageError::MissingClaim("exp"));
604    }
605
606    let credential = &decoded.credential;
607    if credential.id.is_some() {
608        return Err(DomainLinkageError::IdPresent);
609    }
610    if !credential.context.contains(CONTEXT) {
611        return Err(DomainLinkageError::MissingContext);
612    }
613    if !credential.has_type(CREDENTIAL_TYPE) {
614        return Err(DomainLinkageError::MissingType);
615    }
616    if credential.expiration_date.is_none() {
617        return Err(DomainLinkageError::MissingExpirationDate);
618    }
619    // A JWT with `sub` and several subjects does not decode as a credential, so one subject
620    // is all that gets here.
621    let subject = credential
622        .subject()
623        .ok_or(DomainLinkageError::SubjectNotDid(None))?;
624    let subject_id = subject.id.as_ref().map(Uri::as_str);
625    let subject_did = subject_id
626        .and_then(|id| Did::parse(id).ok())
627        .ok_or_else(|| DomainLinkageError::SubjectNotDid(subject_id.map(str::to_owned)))?;
628    for (role, found) in [
629        ("issuer", credential.issuer.id().as_str()),
630        ("iss", iss),
631        ("sub", sub),
632    ] {
633        if found != subject_did.as_str() {
634            return Err(DomainLinkageError::SubjectMismatch {
635                role,
636                found: found.to_owned(),
637                subject: subject_did,
638            });
639        }
640    }
641    let origin = subject.properties.get("origin");
642    let Some(Value::String(origin)) = origin else {
643        return Err(DomainLinkageError::InvalidOriginProperty(origin.cloned()));
644    };
645    let found = normalize_origin(origin)?;
646    if found != expected_origin {
647        return Err(DomainLinkageError::OriginMismatch {
648            expected: expected_origin.to_owned(),
649            found,
650        });
651    }
652    Ok(())
653}
654
655impl fmt::Display for DomainLinkageConfiguration {
656    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
657        let json = serde_json::to_string(self).map_err(|_| fmt::Error)?;
658        f.write_str(&json)
659    }
660}