identity_credential/credential/
linked_domain_service.rs

1// Copyright 2020-2023 IOTA Stiftung
2// SPDX-License-Identifier: Apache-2.0
3
4use identity_core::common::Object;
5use identity_core::common::OrderedSet;
6use identity_core::common::Url;
7use identity_did::DIDUrl;
8use identity_document::service::Service;
9use identity_document::service::ServiceBuilder;
10use identity_document::service::ServiceEndpoint;
11use indexmap::map::IndexMap;
12
13use crate::error::Result;
14use crate::utils::url_only_includes_origin;
15use crate::Error;
16use crate::Error::DomainLinkageError;
17
18/// A service wrapper for a [Linked Domain Service Endpoint](https://identity.foundation/.well-known/resources/did-configuration/#linked-domain-service-endpoint).
19#[derive(Debug, Clone)]
20pub struct LinkedDomainService {
21  service: Service,
22}
23
24impl TryFrom<Service> for LinkedDomainService {
25  type Error = Error;
26
27  fn try_from(service: Service) -> std::result::Result<Self, Self::Error> {
28    LinkedDomainService::check_structure(&service)?;
29    Ok(LinkedDomainService { service })
30  }
31}
32
33impl From<LinkedDomainService> for Service {
34  fn from(service: LinkedDomainService) -> Self {
35    service.service
36  }
37}
38
39impl LinkedDomainService {
40  pub(crate) fn domain_linkage_service_type() -> &'static str {
41    "LinkedDomains"
42  }
43
44  /// Constructs a new `LinkedDomainService` that wraps a spec compliant [Linked Domain Service Endpoint](https://identity.foundation/.well-known/resources/did-configuration/#linked-domain-service-endpoint)
45  /// Domain URLs must include the `https` scheme in order to pass the domain linkage validation.
46  pub fn new(did_url: DIDUrl, domains: impl Into<OrderedSet<Url>>, properties: Object) -> Result<Self> {
47    let domains: OrderedSet<Url> = domains.into();
48    for domain in domains.iter() {
49      if domain.scheme() != "https" {
50        return Err(DomainLinkageError("domain does not include `https` scheme".into()));
51      }
52    }
53    let builder: ServiceBuilder = Service::builder(properties)
54      .id(did_url)
55      .type_(Self::domain_linkage_service_type());
56    if domains.len() == 1 {
57      Ok(Self {
58        service: builder
59          .service_endpoint(ServiceEndpoint::One(
60            domains.into_iter().next().expect("the len should be 1"),
61          ))
62          .build()
63          .map_err(|err| DomainLinkageError(Box::new(err)))?,
64      })
65    } else {
66      let mut map: IndexMap<String, OrderedSet<Url>> = IndexMap::new();
67      map.insert("origins".to_owned(), domains);
68      let service = builder
69        .service_endpoint(ServiceEndpoint::Map(map))
70        .build()
71        .map_err(|err| DomainLinkageError(Box::new(err)))?;
72      Ok(Self { service })
73    }
74  }
75
76  /// Checks the semantic structure of a Linked Domain Service.
77  ///
78  /// Note: `{"type": ["LinkedDomains"]}` might be serialized the same way as  `{"type": "LinkedDomains"}`
79  /// which passes the semantic check.
80  pub fn check_structure(service: &Service) -> Result<()> {
81    if service.type_().len() != 1 {
82      return Err(DomainLinkageError("invalid service type".into()));
83    }
84
85    let service_type = service
86      .type_()
87      .get(0)
88      .ok_or_else(|| DomainLinkageError("missing service type".into()))?;
89
90    if service_type != Self::domain_linkage_service_type() {
91      return Err(DomainLinkageError(
92        format!("expected `{}` service type", Self::domain_linkage_service_type()).into(),
93      ));
94    }
95
96    match service.service_endpoint() {
97      ServiceEndpoint::One(endpoint) => {
98        if endpoint.scheme() != "https" {
99          Err(DomainLinkageError("domain does not include `https` scheme".into()))?;
100        }
101        if !url_only_includes_origin(endpoint) {
102          Err(DomainLinkageError(
103            "domain must not contain any path, query or fragment".into(),
104          ))?;
105        }
106        Ok(())
107      }
108      ServiceEndpoint::Set(_) => Err(DomainLinkageError(
109        "service endpoints must be either a string or an object containing an `origins` property".into(),
110      )),
111      ServiceEndpoint::Map(endpoint) => {
112        if endpoint.is_empty() {
113          return Err(DomainLinkageError("empty service endpoint map".into()));
114        }
115        let origins: &OrderedSet<Url> = endpoint
116          .get("origins")
117          .ok_or_else(|| DomainLinkageError("missing `origins` property in service endpoint".into()))?;
118
119        for origin in origins.iter() {
120          if origin.scheme() != "https" {
121            return Err(DomainLinkageError("domain does not include `https` scheme".into()));
122          }
123          if !url_only_includes_origin(origin) {
124            Err(DomainLinkageError(
125              "domain must not contain any path, query or fragment".into(),
126            ))?;
127          }
128        }
129        Ok(())
130      }
131    }
132  }
133
134  /// Returns the domains contained in the Linked Domain Service.
135  pub fn domains(&self) -> &[Url] {
136    match self.service.service_endpoint() {
137      ServiceEndpoint::One(endpoint) => std::slice::from_ref(endpoint),
138      ServiceEndpoint::Set(_) => {
139        unreachable!("the service endpoint is never a set per the `LinkedDomainService` type invariant")
140      }
141      ServiceEndpoint::Map(endpoint) => endpoint
142        .get("origins")
143        .expect("the `origins` property exists per the `LinkedDomainService` type invariant")
144        .as_slice(),
145    }
146  }
147
148  /// Returns a reference to the `Service` id.
149  pub fn id(&self) -> &DIDUrl {
150    self.service.id()
151  }
152}
153
154#[cfg(test)]
155mod tests {
156  use crate::credential::linked_domain_service::LinkedDomainService;
157  use identity_core::common::Object;
158  use identity_core::common::OrderedSet;
159  use identity_core::common::Url;
160  use identity_core::convert::FromJson;
161  use identity_did::DIDUrl;
162  use identity_document::service::Service;
163  use serde_json::json;
164
165  #[test]
166  fn test_create_service_multiple_origins() {
167    let domain_1 = "https://foo.example-1.com";
168    let domain_2 = "https://bar.example-2.com";
169    let mut domains = OrderedSet::new();
170    domains.append(Url::parse(domain_1).unwrap());
171    domains.append(Url::parse(domain_2).unwrap());
172
173    let service: LinkedDomainService =
174      LinkedDomainService::new(DIDUrl::parse("did:example:123#foo").unwrap(), domains, Object::new()).unwrap();
175
176    let service_from_json: Service = Service::from_json_value(json!({
177        "id":"did:example:123#foo",
178        "type": "LinkedDomains",
179        "serviceEndpoint": {
180          "origins": [domain_1, domain_2]
181        }
182    }))
183    .unwrap();
184    assert_eq!(Service::from(service), service_from_json);
185  }
186
187  #[test]
188  fn test_create_service_single_origin() {
189    let mut domains: OrderedSet<Url> = OrderedSet::new();
190    domains.append(Url::parse("https://foo.example-1.com").unwrap());
191
192    let service: LinkedDomainService =
193      LinkedDomainService::new(DIDUrl::parse("did:example:123#foo").unwrap(), domains, Object::new()).unwrap();
194
195    let service_from_json: Service = Service::from_json_value(json!({
196        "id":"did:example:123#foo",
197        "type": "LinkedDomains",
198        "serviceEndpoint": "https://foo.example-1.com"
199    }))
200    .unwrap();
201    assert_eq!(Service::from(service), service_from_json);
202  }
203
204  #[test]
205  fn test_valid_domains() {
206    let service_1: Service = Service::from_json_value(json!({
207        "id":"did:example:123#foo",
208        "type": "LinkedDomains",
209        "serviceEndpoint": "https://foo.example-1.com"
210    }))
211    .unwrap();
212    let service_1: LinkedDomainService = LinkedDomainService::try_from(service_1).unwrap();
213    let domain: Vec<Url> = vec![Url::parse("https://foo.example-1.com").unwrap()];
214    assert_eq!(service_1.domains(), domain);
215
216    let service_2: Service = Service::from_json_value(json!({
217        "id":"did:example:123#foo",
218        "type": "LinkedDomains",
219        "serviceEndpoint": { "origins" : ["https://foo.example-1.com", "https://foo.example-2.com"]}
220    }))
221    .unwrap();
222    let service_2: LinkedDomainService = LinkedDomainService::try_from(service_2).unwrap();
223    let domains: Vec<Url> = vec![
224      Url::parse("https://foo.example-1.com").unwrap(),
225      Url::parse("https://foo.example-2.com").unwrap(),
226    ];
227    assert_eq!(service_2.domains(), domains);
228  }
229
230  #[test]
231  fn test_extract_domains_invalid_scheme() {
232    // http scheme instead of https.
233    let service_1: Service = Service::from_json_value(json!({
234        "id":"did:example:123#foo",
235        "type": "LinkedDomains",
236        "serviceEndpoint": "http://foo.example-1.com"
237    }))
238    .unwrap();
239    assert!(LinkedDomainService::try_from(service_1).is_err());
240
241    let service_2: Service = Service::from_json_value(json!({
242        "id":"did:example:123#foo",
243        "type": "LinkedDomains",
244        "serviceEndpoint": { "origins" : ["https://foo.example-1.com", "http://foo.example-2.com"]}
245    }))
246    .unwrap();
247    assert!(LinkedDomainService::try_from(service_2).is_err());
248  }
249
250  #[test]
251  fn test_extract_domain_type_check() {
252    // Valid type.
253    let service_1: Service = Service::from_json_value(json!({
254        "id":"did:example:123#foo",
255        "type": "LinkedDomains",
256        "serviceEndpoint": "https://foo.example-1.com"
257    }))
258    .unwrap();
259    assert!(LinkedDomainService::try_from(service_1).is_ok());
260
261    // Invalid type 'LinkedDomain` instead of `LinkedDomains`.
262    let service_2: Service = Service::from_json_value(json!({
263        "id":"did:example:123#foo",
264        "type": "LinkedDomain",
265        "serviceEndpoint": "https://foo.example-1.com"
266    }))
267    .unwrap();
268    assert!(LinkedDomainService::try_from(service_2).is_err());
269  }
270}