Skip to main content

identity_kit/
openapi.rs

1//! OpenAPI schemas for the JSON types (feature `utoipa`): [`utoipa::ToSchema`] for
2//! [`DidDocument`], [`VerificationMethod`], [`MethodRef`], [`Service`], [`Jwk`],
3//! `DomainLinkageConfiguration`, `ClaimPath` and `TypeMetadata` with its display and claim
4//! metadata.
5//!
6//! The schemas are written by hand rather than derived: every type carries open extension
7//! properties and custom serialization that a derive cannot express. Each schema describes the
8//! properties its specification defines and, where the specification leaves the object open,
9//! allows additional properties.
10
11use std::borrow::Cow;
12
13use utoipa::openapi::RefOr;
14use utoipa::openapi::schema::{
15    AdditionalProperties, ArrayBuilder, ArrayItems, ObjectBuilder, OneOfBuilder, Ref, RefBuilder,
16    Schema, SchemaFormat, SchemaType, Type,
17};
18use utoipa::{PartialSchema, ToSchema};
19
20use crate::did::{DidDocument, MethodRef, Service, VerificationMethod};
21use crate::jose::Jwk;
22
23/// DID Core §3.1 as a regular expression: `did:`, a method name of lowercase letters and
24/// digits, then a method-specific id of `idchar`s and `:` that does not end in `:`.
25const DID_PATTERN: &str =
26    r"^did:[a-z0-9]+:(([A-Za-z0-9._-]|%[0-9A-Fa-f]{2})*:)*([A-Za-z0-9._-]|%[0-9A-Fa-f]{2})+$";
27
28type Schemas = Vec<(String, RefOr<Schema>)>;
29
30/// Adds the schema of `T`, and the schemas it references, unless already present.
31fn collect<T: ToSchema>(schemas: &mut Schemas) {
32    let name = T::name();
33    if schemas.iter().any(|(present, _)| *present == name) {
34        return;
35    }
36    schemas.push((name.into_owned(), T::schema()));
37    T::schemas(schemas);
38}
39
40fn schema_ref<T: ToSchema>() -> Ref {
41    Ref::from_schema_name(T::name().into_owned())
42}
43
44fn string(description: &str) -> ObjectBuilder {
45    ObjectBuilder::new()
46        .schema_type(Type::String)
47        .description(Some(description))
48}
49
50fn did(description: &str) -> ObjectBuilder {
51    string(description).pattern(Some(DID_PATTERN))
52}
53
54fn uri(description: &str) -> ObjectBuilder {
55    string(description).format(Some(SchemaFormat::Custom("uri".to_owned())))
56}
57
58/// An object with any properties.
59fn any_object(description: &str) -> ObjectBuilder {
60    ObjectBuilder::new()
61        .description(Some(description))
62        .additional_properties(Some(AdditionalProperties::FreeForm(true)))
63}
64
65fn array(items: impl Into<RefOr<Schema>>, description: &str) -> ArrayBuilder {
66    ArrayBuilder::new()
67        .items(ArrayItems::RefOrSchema(Box::new(items.into())))
68        .description(Some(description))
69}
70
71fn one_of<const N: usize>(items: [RefOr<Schema>; N], description: &str) -> OneOfBuilder {
72    items
73        .into_iter()
74        .fold(OneOfBuilder::new(), OneOfBuilder::item)
75        .description(Some(description))
76}
77
78/// A string, or a non-empty array of strings.
79fn string_or_strings(item: &str, description: &str) -> OneOfBuilder {
80    one_of(
81        [
82            string(item).into(),
83            array(string(item), "")
84                .min_items(Some(1))
85                .description(None::<String>)
86                .into(),
87        ],
88        description,
89    )
90}
91
92// ---------------------------------------------------------------------------------------------
93// DID documents
94// ---------------------------------------------------------------------------------------------
95
96#[cfg_attr(docsrs, doc(cfg(feature = "utoipa")))]
97impl PartialSchema for DidDocument {
98    fn schema() -> RefOr<Schema> {
99        let relationship = |name: &str, section: &str| {
100            array(
101                schema_ref::<MethodRef>(),
102                &format!("The `{name}` relationship (DID Core §{section})."),
103            )
104        };
105        ObjectBuilder::new()
106            .description(Some(
107                "A DID document (DID Core §4): the core properties, plus any extension \
108                 property.",
109            ))
110            .property(
111                "@context",
112                one_of(
113                    [
114                        uri("A context URI.").into(),
115                        array(
116                            ObjectBuilder::new().schema_type(SchemaType::AnyValue),
117                            "Context URIs and objects.",
118                        )
119                        .min_items(Some(1))
120                        .into(),
121                        any_object("A context object.").into(),
122                    ],
123                    "The JSON-LD context, carried as data (DID Core §6.3.1).",
124                ),
125            )
126            .property("id", did("The DID of the subject (DID Core §5.1.1)."))
127            .required("id")
128            .property(
129                "alsoKnownAs",
130                array(
131                    uri("A URI."),
132                    "Other identifiers of the subject (DID Core §5.1.3).",
133                ),
134            )
135            .property(
136                "controller",
137                one_of(
138                    [
139                        did("The DID of the controller.").into(),
140                        array(did("The DID of a controller."), "")
141                            .description(None::<String>)
142                            .into(),
143                    ],
144                    "The DIDs whose controllers may change this document (DID Core §5.1.2); \
145                     an empty set means none.",
146                ),
147            )
148            .property(
149                "verificationMethod",
150                array(
151                    schema_ref::<VerificationMethod>(),
152                    "Verification methods the relationships may reference (DID Core §5.2).",
153                ),
154            )
155            .property("authentication", relationship("authentication", "5.3.1"))
156            .property("assertionMethod", relationship("assertionMethod", "5.3.2"))
157            .property("keyAgreement", relationship("keyAgreement", "5.3.3"))
158            .property(
159                "capabilityInvocation",
160                relationship("capabilityInvocation", "5.3.4"),
161            )
162            .property(
163                "capabilityDelegation",
164                relationship("capabilityDelegation", "5.3.5"),
165            )
166            .property(
167                "service",
168                array(
169                    schema_ref::<Service>(),
170                    "Ways of interacting with the subject (DID Core §5.4).",
171                ),
172            )
173            .additional_properties(Some(AdditionalProperties::FreeForm(true)))
174            .into()
175    }
176}
177
178#[cfg_attr(docsrs, doc(cfg(feature = "utoipa")))]
179impl ToSchema for DidDocument {
180    fn name() -> Cow<'static, str> {
181        Cow::Borrowed("DidDocument")
182    }
183
184    fn schemas(schemas: &mut Schemas) {
185        collect::<VerificationMethod>(schemas);
186        collect::<MethodRef>(schemas);
187        collect::<Service>(schemas);
188    }
189}
190
191#[cfg_attr(docsrs, doc(cfg(feature = "utoipa")))]
192impl PartialSchema for VerificationMethod {
193    fn schema() -> RefOr<Schema> {
194        ObjectBuilder::new()
195            .description(Some(
196                "A verification method (DID Core §5.2): a public key, carried as a JWK or a \
197                 multibase string, with its type and controller.",
198            ))
199            .property(
200                "id",
201                string("The DID URL of the method, usually the document's DID with a fragment."),
202            )
203            .required("id")
204            .property(
205                "type",
206                string(
207                    "The verification method type, such as `JsonWebKey2020`, `JsonWebKey` or \
208                     `Multikey`.",
209                ),
210            )
211            .required("type")
212            .property("controller", did("The DID of the controller of the key."))
213            .required("controller")
214            .property(
215                "publicKeyJwk",
216                RefBuilder::new()
217                    .ref_location_from_schema_name(Jwk::name().into_owned())
218                    .description(Some("The public key as a JWK (DID Core §5.2.1)."))
219                    .build(),
220            )
221            .property(
222                "publicKeyMultibase",
223                string("The public key as a multibase string (DID Core §5.2.1)."),
224            )
225            .additional_properties(Some(AdditionalProperties::FreeForm(true)))
226            .into()
227    }
228}
229
230#[cfg_attr(docsrs, doc(cfg(feature = "utoipa")))]
231impl ToSchema for VerificationMethod {
232    fn name() -> Cow<'static, str> {
233        Cow::Borrowed("VerificationMethod")
234    }
235
236    fn schemas(schemas: &mut Schemas) {
237        collect::<Jwk>(schemas);
238    }
239}
240
241#[cfg_attr(docsrs, doc(cfg(feature = "utoipa")))]
242impl PartialSchema for MethodRef {
243    fn schema() -> RefOr<Schema> {
244        one_of(
245            [
246                string("A DID URL, possibly relative, of a method under `verificationMethod`.")
247                    .into(),
248                schema_ref::<VerificationMethod>().into(),
249            ],
250            "An entry of a verification relationship (DID Core §5.3): a reference to a \
251             verification method, or a method embedded in the relationship.",
252        )
253        .into()
254    }
255}
256
257#[cfg_attr(docsrs, doc(cfg(feature = "utoipa")))]
258impl ToSchema for MethodRef {
259    fn name() -> Cow<'static, str> {
260        Cow::Borrowed("MethodRef")
261    }
262
263    fn schemas(schemas: &mut Schemas) {
264        collect::<VerificationMethod>(schemas);
265    }
266}
267
268#[cfg_attr(docsrs, doc(cfg(feature = "utoipa")))]
269impl PartialSchema for Service {
270    fn schema() -> RefOr<Schema> {
271        ObjectBuilder::new()
272            .description(Some(
273                "A service (DID Core §5.4): a way of interacting with the DID subject.",
274            ))
275            .property(
276                "id",
277                string("The DID URL of the service, usually the document's DID with a fragment."),
278            )
279            .required("id")
280            .property(
281                "type",
282                string_or_strings("A service type.", "The service type or types."),
283            )
284            .required("type")
285            .property(
286                "serviceEndpoint",
287                one_of(
288                    [
289                        uri("The endpoint URI.").into(),
290                        array(uri("An endpoint URI."), "")
291                            .min_items(Some(1))
292                            .description(None::<String>)
293                            .into(),
294                        any_object("An endpoint map.").into(),
295                    ],
296                    "Where the service is: a URI, a set of URIs, or a map.",
297                ),
298            )
299            .required("serviceEndpoint")
300            .additional_properties(Some(AdditionalProperties::FreeForm(true)))
301            .into()
302    }
303}
304
305#[cfg_attr(docsrs, doc(cfg(feature = "utoipa")))]
306impl ToSchema for Service {
307    fn name() -> Cow<'static, str> {
308        Cow::Borrowed("Service")
309    }
310}
311
312// ---------------------------------------------------------------------------------------------
313// JOSE
314// ---------------------------------------------------------------------------------------------
315
316#[cfg_attr(docsrs, doc(cfg(feature = "utoipa")))]
317impl PartialSchema for Jwk {
318    fn schema() -> RefOr<Schema> {
319        let base64url = |description: &str| string(description);
320        ObjectBuilder::new()
321            .description(Some(
322                "A JSON Web Key (RFC 7517 §4): the key type, the parameters of RFC 7518 §6 and \
323                 RFC 8037 §2 for that type, and the common members. Parameters are \
324                 base64url-encoded without padding.",
325            ))
326            .property(
327                "kty",
328                string("The key type (RFC 7517 §4.1): `EC`, `OKP`, `RSA` or `oct`."),
329            )
330            .required("kty")
331            .property(
332                "crv",
333                string("The curve of an `EC` or `OKP` key: `P-256`, `secp256k1`, `Ed25519`, ..."),
334            )
335            .property("x", base64url("The x coordinate of an `EC` key, or the `OKP` public key."))
336            .property("y", base64url("The y coordinate of an `EC` key."))
337            .property("n", base64url("The modulus of an `RSA` key."))
338            .property("e", base64url("The public exponent of an `RSA` key."))
339            .property(
340                "d",
341                base64url("The private key of an `EC` or `OKP` key, or the private exponent of an `RSA` key."),
342            )
343            .property("p", base64url("The first prime factor of an `RSA` private key."))
344            .property("q", base64url("The second prime factor of an `RSA` private key."))
345            .property("dp", base64url("The first factor CRT exponent of an `RSA` private key."))
346            .property("dq", base64url("The second factor CRT exponent of an `RSA` private key."))
347            .property("qi", base64url("The first CRT coefficient of an `RSA` private key."))
348            .property(
349                "oth",
350                array(
351                    ObjectBuilder::new()
352                        .property("r", base64url("The prime factor."))
353                        .required("r")
354                        .property("d", base64url("The factor CRT exponent."))
355                        .required("d")
356                        .property("t", base64url("The factor CRT coefficient."))
357                        .required("t"),
358                    "Other primes of an `RSA` private key (RFC 7518 §6.3.2.7).",
359                ),
360            )
361            .property("k", base64url("The value of an `oct` key."))
362            .property(
363                "use",
364                string("The intended use of the key (RFC 7517 §4.2): `sig` or `enc`."),
365            )
366            .property(
367                "key_ops",
368                array(
369                    string("An operation, such as `sign` or `verify`."),
370                    "The operations the key is intended for (RFC 7517 §4.3).",
371                ),
372            )
373            .property(
374                "alg",
375                string("The algorithm intended for use with the key (RFC 7517 §4.4)."),
376            )
377            .property("kid", string("The key id (RFC 7517 §4.5)."))
378            .additional_properties(Some(AdditionalProperties::FreeForm(true)))
379            .into()
380    }
381}
382
383#[cfg_attr(docsrs, doc(cfg(feature = "utoipa")))]
384impl ToSchema for Jwk {
385    fn name() -> Cow<'static, str> {
386        Cow::Borrowed("Jwk")
387    }
388}
389
390// ---------------------------------------------------------------------------------------------
391// DIF Well Known DID Configuration
392// ---------------------------------------------------------------------------------------------
393
394#[cfg(feature = "domain-linkage")]
395#[cfg_attr(docsrs, doc(cfg(all(feature = "utoipa", feature = "domain-linkage"))))]
396impl PartialSchema for crate::domain_linkage::DomainLinkageConfiguration {
397    fn schema() -> RefOr<Schema> {
398        ObjectBuilder::new()
399            .description(Some(
400                "A DID configuration resource (Well Known DID Configuration §3.1): the context \
401                 and the domain linkage credentials, one per linked DID.",
402            ))
403            .property(
404                "@context",
405                string("The DID configuration context.")
406                    .enum_values(Some([crate::domain_linkage::CONTEXT])),
407            )
408            .required("@context")
409            .property(
410                "linked_dids",
411                array(
412                    one_of(
413                        [
414                            string(
415                                "A domain linkage credential in the JSON Web Token proof \
416                                 format: a compact JWS.",
417                            )
418                            .into(),
419                            any_object(
420                                "A domain linkage credential in the Linked Data Proof format.",
421                            )
422                            .into(),
423                        ],
424                        "A domain linkage credential.",
425                    ),
426                    "The domain linkage credentials.",
427                ),
428            )
429            .required("linked_dids")
430            .additional_properties(Some(AdditionalProperties::FreeForm(false)))
431            .into()
432    }
433}
434
435#[cfg(feature = "domain-linkage")]
436#[cfg_attr(docsrs, doc(cfg(all(feature = "utoipa", feature = "domain-linkage"))))]
437impl ToSchema for crate::domain_linkage::DomainLinkageConfiguration {
438    fn name() -> Cow<'static, str> {
439        Cow::Borrowed("DomainLinkageConfiguration")
440    }
441}
442
443// ---------------------------------------------------------------------------------------------
444// Claim paths
445// ---------------------------------------------------------------------------------------------
446
447#[cfg(feature = "vc")]
448mod claim_path {
449    use super::*;
450    use crate::vc::ClaimPath;
451
452    #[cfg_attr(docsrs, doc(cfg(all(feature = "utoipa", feature = "vc"))))]
453    impl PartialSchema for ClaimPath {
454        fn schema() -> RefOr<Schema> {
455            array(
456                one_of(
457                    [
458                        string("An object key.").into(),
459                        ObjectBuilder::new()
460                            .schema_type(Type::Integer)
461                            .minimum(Some(0))
462                            .description(Some("An array index."))
463                            .into(),
464                        ObjectBuilder::new()
465                            .schema_type(Type::Null)
466                            .description(Some("Every element of an array."))
467                            .into(),
468                    ],
469                    "A path component.",
470                ),
471                "A claim path (SD-JWT VC §5.6.1), the claims path pointer of OID4VP §7: where a \
472                 claim sits in a JSON credential.",
473            )
474            .min_items(Some(1))
475            .into()
476        }
477    }
478
479    #[cfg_attr(docsrs, doc(cfg(all(feature = "utoipa", feature = "vc"))))]
480    impl ToSchema for ClaimPath {
481        fn name() -> Cow<'static, str> {
482            Cow::Borrowed("ClaimPath")
483        }
484    }
485}
486
487// ---------------------------------------------------------------------------------------------
488// SD-JWT VC type metadata
489// ---------------------------------------------------------------------------------------------
490
491#[cfg(feature = "sd-jwt")]
492mod type_metadata {
493    use super::*;
494    use crate::sd_jwt_vc::{ClaimDisplay, ClaimMetadata, DisplayMetadata, TypeMetadata};
495    use crate::vc::ClaimPath;
496
497    #[cfg_attr(docsrs, doc(cfg(all(feature = "utoipa", feature = "sd-jwt"))))]
498    impl PartialSchema for TypeMetadata {
499        fn schema() -> RefOr<Schema> {
500            ObjectBuilder::new()
501                .description(Some(
502                    "SD-JWT VC type metadata (SD-JWT VC §5): what a `vct` means, how to \
503                     display it, and which claims it carries.",
504                ))
505                .property(
506                    "vct",
507                    string("The type described: a case-sensitive collision-resistant name."),
508                )
509                .required("vct")
510                .property("name", string("A name for developers."))
511                .property("description", string("A description for developers."))
512                .property("extends", uri("The type this one extends."))
513                .property(
514                    "extends#integrity",
515                    string(
516                        "The integrity metadata of the extended type's document (SD-JWT VC §6).",
517                    ),
518                )
519                .property(
520                    "display",
521                    array(
522                        schema_ref::<DisplayMetadata>(),
523                        "Display information per locale (SD-JWT VC §5.5).",
524                    ),
525                )
526                .property(
527                    "claims",
528                    array(
529                        schema_ref::<ClaimMetadata>(),
530                        "Claim metadata (SD-JWT VC §5.6).",
531                    ),
532                )
533                .additional_properties(Some(AdditionalProperties::FreeForm(true)))
534                .into()
535        }
536    }
537
538    #[cfg_attr(docsrs, doc(cfg(all(feature = "utoipa", feature = "sd-jwt"))))]
539    impl ToSchema for TypeMetadata {
540        fn name() -> Cow<'static, str> {
541            Cow::Borrowed("TypeMetadata")
542        }
543
544        fn schemas(schemas: &mut Schemas) {
545            collect::<DisplayMetadata>(schemas);
546            collect::<ClaimMetadata>(schemas);
547        }
548    }
549
550    #[cfg_attr(docsrs, doc(cfg(all(feature = "utoipa", feature = "sd-jwt"))))]
551    impl PartialSchema for DisplayMetadata {
552        fn schema() -> RefOr<Schema> {
553            ObjectBuilder::new()
554                .description(Some(
555                    "Display information for a type in one locale (SD-JWT VC §5.5).",
556                ))
557                .property("locale", string("The language tag (RFC 5646)."))
558                .required("locale")
559                .property("name", string("The name of the type for end users."))
560                .required("name")
561                .property("description", string("A description for end users."))
562                .property(
563                    "rendering",
564                    any_object("Rendering information: `simple`, `svg_templates`."),
565                )
566                .additional_properties(Some(AdditionalProperties::FreeForm(true)))
567                .into()
568        }
569    }
570
571    #[cfg_attr(docsrs, doc(cfg(all(feature = "utoipa", feature = "sd-jwt"))))]
572    impl ToSchema for DisplayMetadata {
573        fn name() -> Cow<'static, str> {
574            Cow::Borrowed("DisplayMetadata")
575        }
576    }
577
578    #[cfg_attr(docsrs, doc(cfg(all(feature = "utoipa", feature = "sd-jwt"))))]
579    impl PartialSchema for ClaimMetadata {
580        fn schema() -> RefOr<Schema> {
581            ObjectBuilder::new()
582                .description(Some(
583                    "Metadata for the claim or claims at a path (SD-JWT VC §5.6).",
584                ))
585                .property(
586                    "path",
587                    RefBuilder::new()
588                        .ref_location_from_schema_name(ClaimPath::name())
589                        .description(Some("The claim path (SD-JWT VC §5.6.1).")),
590                )
591                .required("path")
592                .property(
593                    "display",
594                    array(
595                        schema_ref::<ClaimDisplay>(),
596                        "Display information per locale (SD-JWT VC §5.6.2).",
597                    ),
598                )
599                .property(
600                    "mandatory",
601                    ObjectBuilder::new()
602                        .schema_type(Type::Boolean)
603                        .description(Some(
604                            "Whether the issuer must include the claim; default false.",
605                        )),
606                )
607                .property(
608                    "sd",
609                    string(
610                        "Whether the claim is selectively disclosable (SD-JWT VC §5.6.4); \
611                         default `allowed`.",
612                    )
613                    .enum_values(Some(["always", "allowed", "never"])),
614                )
615                .property("svg_id", string("The id of the claim in SVG templates."))
616                .additional_properties(Some(AdditionalProperties::FreeForm(true)))
617                .into()
618        }
619    }
620
621    #[cfg_attr(docsrs, doc(cfg(all(feature = "utoipa", feature = "sd-jwt"))))]
622    impl ToSchema for ClaimMetadata {
623        fn name() -> Cow<'static, str> {
624            Cow::Borrowed("ClaimMetadata")
625        }
626
627        fn schemas(schemas: &mut Schemas) {
628            collect::<ClaimDisplay>(schemas);
629            collect::<ClaimPath>(schemas);
630        }
631    }
632
633    #[cfg_attr(docsrs, doc(cfg(all(feature = "utoipa", feature = "sd-jwt"))))]
634    impl PartialSchema for ClaimDisplay {
635        fn schema() -> RefOr<Schema> {
636            ObjectBuilder::new()
637                .description(Some(
638                    "Display information for a claim in one locale (SD-JWT VC §5.6.2).",
639                ))
640                .property("locale", string("The language tag (RFC 5646)."))
641                .required("locale")
642                .property("label", string("The label of the claim for end users."))
643                .required("label")
644                .property("description", string("A description for end users."))
645                .additional_properties(Some(AdditionalProperties::FreeForm(true)))
646                .into()
647        }
648    }
649
650    #[cfg_attr(docsrs, doc(cfg(all(feature = "utoipa", feature = "sd-jwt"))))]
651    impl ToSchema for ClaimDisplay {
652        fn name() -> Cow<'static, str> {
653            Cow::Borrowed("ClaimDisplay")
654        }
655    }
656}