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