[java-idp-plugin-vci] 01/02: Type mdoc values inside arrays and check mandatory claims in nested paths, document complex claims

Codeberg noreply at shibboleth.net
Fri Oct 16 11:11:40 UTC 2026


This is an automated email from the git hooks/post-receive script.

codeberg pushed a commit to branch main
in repository java-idp-plugin-vci.

View the commit online:
https://codeberg.org/Shibboleth/java-idp-plugin-vci/commit/68868534959917b9f65c2fb43d046562438b247c

commit 68868534959917b9f65c2fb43d046562438b247c
Author: Janne Lauros <janne.lauros at csc.fi>
AuthorDate: Fri Oct 16 14:08:24 2026 +0300

    Type mdoc values inside arrays and check mandatory claims in nested paths, document complex claims
---
 README.md                                          | 453 ++++++++++++++++++++-
 .../plugin/openidvci/credential/ClaimType.java     |  48 ++-
 .../credential/CredentialConfiguration.java        |  30 +-
 .../MdocCredentialConfigurationTest.java           |  41 ++
 .../openidvci/credential/impl/MdocElements.java    |  91 ++++-
 .../impl/AddClaimsToMobileSecurityObject.java      |  15 +-
 .../profile/impl/ValidateCredentialOffering.java   |  74 +++-
 .../credential/impl/MdocElementsTest.java          |  45 +-
 .../impl/ValidateCredentialOfferingTest.java       |  71 +++-
 .../conf/verifiable-credentials-mandatory.json     |  15 +
 10 files changed, 815 insertions(+), 68 deletions(-)

diff --git a/README.md b/README.md
index cf2bb56..07aeb74 100644
--- a/README.md
+++ b/README.md
@@ -33,6 +33,10 @@
 - [Several credentials of one configuration](#several-credentials-of-one-configuration)
   - [Pre-authorized code flow](#pre-authorized-code-flow-1)
   - [Authorization code flow](#authorization-code-flow-1)
+- [Complex claims](#complex-claims)
+  - [Complex Credential Configurations](#complex-credential-configurations)
+  - [Resolving complex values](#resolving-complex-values)
+  - [Getting the types right](#getting-the-types-right)
 - [Reference](#reference)
   - [Profile configuration settings](#profile-configuration-settings)
   - [Credential Configuration settings](#credential-configuration-settings)
@@ -530,7 +534,7 @@ It goes inside the credential's own configuration, next to `format` and `vct`:
 | `status_list` | Boolean | `openidvci.status-list.includeByDefault`, `true` | Whether the credential takes a Status List slot. |
 | `issuer` | String | `openidvci.issuer` | What the credential says issued it. `did:jwk`, `did:web` or `url`. See [Step 2](#step-2-add-a-did-web-credential) and [Step 3](#step-3-add-a-plain-url-credential). |
 | `oidfed` | Boolean | `true` | Whether the credential states the Entity Identifier of this deployment, once the deployment is in OpenID Federation. See [Step 4](#step-4-make-the-url-credential-an-openid-federation-credential). |
-| `claim_types` | Array | | Type of a claim value in an mdoc where JSON has none, a date for example, `{"path": [...], "type": ...}`. See [The mdoc credential](#the-mdoc-credential). |
+| `claim_types` | Array | | Type of a claim value in an mdoc where JSON has none, a date for example, `{"path": [...], "type": ...}`. See [The mdoc credential](#the-mdoc-credential) and [Complex claims](#complex-claims). |
 
 All of them are optional and so is the member itself.
 
@@ -3972,6 +3976,451 @@ The attribute has one value per dataset, and the encoder puts them in an array:
 Without `asArray="true"` the encoder joins the values into one string and they are not readable
 anymore. An attribute of one value needs neither the `dataset_id` nor `asArray`.
 
+## Complex claims
+
+**File(s):** *metadata/verifiable-credentials.json*, *conf/attribute-resolver.xml*
+**Format:** JSON, Native Spring
+
+This section configures credentials with deep claim paths and with every type of value the
+plugin issues:
+
+- `ComplexCredential_SDJWT` is an SD-JWT VC derived from the Complex Structured SD-JWT example,
+  Appendix A.2 of
+  [Selective Disclosure for JWTs](https://datatracker.ietf.org/doc/draft-ietf-oauth-selective-disclosure-jwt/),
+  and the PID of the
+  [Architecture and Reference Framework](https://github.com/eu-digital-identity-wallet/eudi-doc-architecture-and-reference-framework).
+- `ComplexCredential_MDOC` is a mobile driving licence, document type `org.iso.18013.5.1.mDL`.
+  It has the mDL elements and a name space of our own, `org.geant.incubator.complex.1`, for values
+  the mDL does not have.
+
+The SD-JWT VC has been successfully tested with Paradym, the EUDI Reference Wallet and the OpenID
+Foundation conformance suite, the mdoc with the EUDI Reference Wallet and the conformance suite.
+
+### Complex Credential Configurations
+
+#### The complex SD-JWT VC
+
+<details>
+<summary>metadata/verifiable-credentials.json</summary>
+
+```json
+{
+  "credential_configurations_supported": {
+    "ComplexCredential_SDJWT": {
+      "format": "dc+sd-jwt",
+      "vct": "ComplexCredential_SDJWT",
+      "scope": "ComplexCredential_SDJWT",
+      "cryptographic_binding_methods_supported": ["did:jwk", "jwk"],
+      "credential_signing_alg_values_supported": ["ES256"],
+      "proof_types_supported": {
+        "jwt": {
+          "proof_signing_alg_values_supported": ["ES256", "ES384", "ES512"]
+        }
+      },
+      "credential_metadata": {
+        "display": [
+          { "name": "Complex Credential", "locale": "en" }
+        ],
+        "claims": [
+          { "path": ["verified_claims"],                                          "display": [{ "name": "Verified claims",         "locale": "en" }] },
+          { "path": ["verified_claims", "verification"],                          "display": [{ "name": "Verification",            "locale": "en" }] },
+          { "path": ["verified_claims", "verification", "trust_framework"],       "display": [{ "name": "Trust framework",         "locale": "en" }] },
+          { "path": ["verified_claims", "verification", "time"],                  "display": [{ "name": "Verified at",             "locale": "en" }] },
+          { "path": ["verified_claims", "verification", "verification_process"],  "display": [{ "name": "Verification process",    "locale": "en" }] },
+          { "path": ["verified_claims", "verification", "evidence"],              "display": [{ "name": "Evidence",                "locale": "en" }] },
+          { "path": ["verified_claims", "verification", "evidence", null, "type"],   "display": [{ "name": "Evidence type",        "locale": "en" }] },
+          { "path": ["verified_claims", "verification", "evidence", null, "method"], "display": [{ "name": "Verification method",  "locale": "en" }] },
+          { "path": ["verified_claims", "verification", "evidence", null, "time"],   "display": [{ "name": "Evidence checked at",  "locale": "en" }] },
+          { "path": ["verified_claims", "verification", "evidence", null, "document", "type"],              "display": [{ "name": "Document type",           "locale": "en" }] },
+          { "path": ["verified_claims", "verification", "evidence", null, "document", "issuer", "name"],    "display": [{ "name": "Document issuer",         "locale": "en" }] },
+          { "path": ["verified_claims", "verification", "evidence", null, "document", "issuer", "country"], "display": [{ "name": "Document issuer country", "locale": "en" }] },
+          { "path": ["verified_claims", "verification", "evidence", null, "document", "number"],            "display": [{ "name": "Document number",         "locale": "en" }] },
+          { "path": ["verified_claims", "verification", "evidence", null, "document", "date_of_issuance"],  "display": [{ "name": "Document issued",         "locale": "en" }] },
+          { "path": ["verified_claims", "verification", "evidence", null, "document", "date_of_expiry"],    "display": [{ "name": "Document expires",        "locale": "en" }] },
+          { "path": ["verified_claims", "claims"],                                 "display": [{ "name": "Verified person",         "locale": "en" }] },
+          { "path": ["verified_claims", "claims", "given_name"],                   "display": [{ "name": "Given name",              "locale": "en" }], "mandatory": true },
+          { "path": ["verified_claims", "claims", "family_name"],                  "display": [{ "name": "Family name",             "locale": "en" }], "mandatory": true },
+          { "path": ["verified_claims", "claims", "nationalities"],                "display": [{ "name": "Nationalities",           "locale": "en" }] },
+          { "path": ["verified_claims", "claims", "nationalities", null],          "display": [{ "name": "Nationality",             "locale": "en" }] },
+          { "path": ["verified_claims", "claims", "birthdate"],                    "display": [{ "name": "Date of birth",           "locale": "en" }] },
+          { "path": ["verified_claims", "claims", "place_of_birth"],               "display": [{ "name": "Place of birth",          "locale": "en" }] },
+          { "path": ["verified_claims", "claims", "place_of_birth", "country"],    "display": [{ "name": "Country of birth",        "locale": "en" }] },
+          { "path": ["verified_claims", "claims", "place_of_birth", "locality"],   "display": [{ "name": "Locality of birth",       "locale": "en" }] },
+          { "path": ["verified_claims", "claims", "address"],                      "display": [{ "name": "Address",                 "locale": "en" }] },
+          { "path": ["verified_claims", "claims", "address", "street_address"],    "display": [{ "name": "Street address",          "locale": "en" }] },
+          { "path": ["verified_claims", "claims", "address", "locality"],          "display": [{ "name": "Locality",                "locale": "en" }] },
+          { "path": ["verified_claims", "claims", "address", "postal_code"],       "display": [{ "name": "Postal code",             "locale": "en" }] },
+          { "path": ["verified_claims", "claims", "address", "country"],           "display": [{ "name": "Country",                 "locale": "en" }] },
+          { "path": ["birth_middle_name"],                                         "display": [{ "name": "Middle name at birth",    "locale": "en" }] },
+          { "path": ["salutation"],                                                "display": [{ "name": "Salutation",              "locale": "en" }] },
+          { "path": ["msisdn"],                                                    "display": [{ "name": "Phone number",            "locale": "en" }] },
+          { "path": ["age_equal_or_over"],                                         "display": [{ "name": "Age equal or over",       "locale": "en" }] },
+          { "path": ["age_equal_or_over", "18"],                                   "display": [{ "name": "Age 18 or over",          "locale": "en" }] },
+          { "path": ["age_equal_or_over", "21"],                                   "display": [{ "name": "Age 21 or over",          "locale": "en" }] },
+          { "path": ["age_equal_or_over", "65"],                                   "display": [{ "name": "Age 65 or over",          "locale": "en" }] },
+          { "path": ["age_equal_or_over", "75"],                                   "display": [{ "name": "Age 75 or over",          "locale": "en" }] },
+          { "path": ["age_in_years"],                                              "display": [{ "name": "Age in years",            "locale": "en" }] },
+          { "path": ["age_birth_year"],                                            "display": [{ "name": "Year of birth",           "locale": "en" }] },
+          { "path": ["height"],                                                    "display": [{ "name": "Height in metres",        "locale": "en" }] },
+          { "path": ["picture"],                                                   "display": [{ "name": "Picture",                 "locale": "en" }] }
+        ]
+      }
+    }
+  }
+}
+```
+
+</details>
+
+#### The complex mdoc
+
+<details>
+<summary>metadata/verifiable-credentials.json</summary>
+
+```json
+{
+  "credential_configurations_supported": {
+    "ComplexCredential_MDOC": {
+      "format": "mso_mdoc",
+      "doctype": "org.iso.18013.5.1.mDL",
+      "scope": "ComplexCredential_MDOC",
+      "cryptographic_binding_methods_supported": ["cose_key"],
+      "credential_signing_alg_values_supported": [-7, -9],
+      "proof_types_supported": {
+        "jwt": {
+          "proof_signing_alg_values_supported": ["ES256", "ES384", "ES512"],
+          "key_attestations_required": {
+            "key_storage": ["iso_18045_high"],
+            "user_authentication": ["iso_18045_high"]
+          }
+        },
+        "attestation": {
+          "proof_signing_alg_values_supported": ["ES256", "ES384", "ES512"],
+          "key_attestations_required": {
+            "key_storage": ["iso_18045_high"],
+            "user_authentication": ["iso_18045_high"]
+          }
+        }
+      },
+      "_issuance_configuration": {
+        "issuer": "url",
+        "lifetime": "P45D",
+        "claim_types": [
+          { "path": ["org.iso.18013.5.1", "birth_date"],                              "type": "full-date" },
+          { "path": ["org.iso.18013.5.1", "issue_date"],                              "type": "full-date" },
+          { "path": ["org.iso.18013.5.1", "expiry_date"],                             "type": "full-date" },
+          { "path": ["org.iso.18013.5.1", "portrait"],                                "type": "bstr" },
+          { "path": ["org.iso.18013.5.1", "portrait_capture_date"],                   "type": "tdate" },
+          { "path": ["org.iso.18013.5.1", "driving_privileges", null, "issue_date"],  "type": "full-date" },
+          { "path": ["org.iso.18013.5.1", "driving_privileges", null, "expiry_date"], "type": "full-date" },
+          { "path": ["org.geant.incubator.complex.1", "medical_certificate", "issue_date"],  "type": "full-date" },
+          { "path": ["org.geant.incubator.complex.1", "medical_certificate", "expiry_date"], "type": "full-date" },
+          { "path": ["org.geant.incubator.complex.1", "endorsement_dates"],           "type": "full-date" },
+          { "path": ["org.geant.incubator.complex.1", "last_updated"],                "type": "tdate" }
+        ]
+      },
+      "credential_metadata": {
+        "display": [
+          { "name": "Complex Credential, mdoc", "locale": "en" }
+        ],
+        "claims": [
+          { "path": ["org.iso.18013.5.1", "family_name"],            "display": [{ "name": "Family name",         "locale": "en" }] },
+          { "path": ["org.iso.18013.5.1", "given_name"],             "display": [{ "name": "Given name",          "locale": "en" }] },
+          { "path": ["org.iso.18013.5.1", "birth_date"],             "display": [{ "name": "Date of birth",       "locale": "en" }] },
+          { "path": ["org.iso.18013.5.1", "issue_date"],             "display": [{ "name": "Issue date",          "locale": "en" }] },
+          { "path": ["org.iso.18013.5.1", "expiry_date"],            "display": [{ "name": "Expiry date",         "locale": "en" }] },
+          { "path": ["org.iso.18013.5.1", "issuing_country"],        "display": [{ "name": "Issuing country",     "locale": "en" }] },
+          { "path": ["org.iso.18013.5.1", "issuing_authority"],      "display": [{ "name": "Issuing authority",   "locale": "en" }] },
+          { "path": ["org.iso.18013.5.1", "document_number"],        "display": [{ "name": "Document number",     "locale": "en" }] },
+          { "path": ["org.iso.18013.5.1", "portrait"],               "display": [{ "name": "Portrait",            "locale": "en" }] },
+          { "path": ["org.iso.18013.5.1", "portrait_capture_date"],  "display": [{ "name": "Portrait taken",      "locale": "en" }] },
+          { "path": ["org.iso.18013.5.1", "driving_privileges"],     "display": [{ "name": "Driving privileges",  "locale": "en" }] },
+          { "path": ["org.iso.18013.5.1", "driving_privileges", null, "vehicle_category_code"], "display": [{ "name": "Vehicle category", "locale": "en" }] },
+          { "path": ["org.iso.18013.5.1", "driving_privileges", null, "issue_date"],            "display": [{ "name": "Category issued",  "locale": "en" }] },
+          { "path": ["org.iso.18013.5.1", "driving_privileges", null, "expiry_date"],           "display": [{ "name": "Category expires", "locale": "en" }] },
+          { "path": ["org.iso.18013.5.1", "driving_privileges", null, "codes", null, "code"],   "display": [{ "name": "Restriction code", "locale": "en" }] },
+          { "path": ["org.iso.18013.5.1", "un_distinguishing_sign"], "display": [{ "name": "Distinguishing sign", "locale": "en" }] },
+          { "path": ["org.iso.18013.5.1", "sex"],                    "display": [{ "name": "Sex",                 "locale": "en" }] },
+          { "path": ["org.iso.18013.5.1", "height"],                 "display": [{ "name": "Height in cm",        "locale": "en" }] },
+          { "path": ["org.iso.18013.5.1", "weight"],                 "display": [{ "name": "Weight in kg",        "locale": "en" }] },
+          { "path": ["org.iso.18013.5.1", "age_in_years"],           "display": [{ "name": "Age in years",        "locale": "en" }] },
+          { "path": ["org.iso.18013.5.1", "age_birth_year"],         "display": [{ "name": "Year of birth",       "locale": "en" }] },
+          { "path": ["org.iso.18013.5.1", "age_over_18"],            "display": [{ "name": "Age over 18",         "locale": "en" }] },
+          { "path": ["org.iso.18013.5.1", "age_over_21"],            "display": [{ "name": "Age over 21",         "locale": "en" }] },
+          { "path": ["org.iso.18013.5.1", "age_over_65"],            "display": [{ "name": "Age over 65",         "locale": "en" }] },
+          { "path": ["org.geant.incubator.complex.1", "medical_certificate"],                           "display": [{ "name": "Medical certificate",         "locale": "en" }] },
+          { "path": ["org.geant.incubator.complex.1", "medical_certificate", "issue_date"],             "display": [{ "name": "Medical certificate issued",  "locale": "en" }] },
+          { "path": ["org.geant.incubator.complex.1", "medical_certificate", "expiry_date"],            "display": [{ "name": "Medical certificate expires", "locale": "en" }] },
+          { "path": ["org.geant.incubator.complex.1", "medical_certificate", "fit_to_drive"],           "display": [{ "name": "Fit to drive",                "locale": "en" }] },
+          { "path": ["org.geant.incubator.complex.1", "medical_certificate", "restrictions"],           "display": [{ "name": "Medical restrictions",        "locale": "en" }] },
+          { "path": ["org.geant.incubator.complex.1", "medical_certificate", "vision"],                 "display": [{ "name": "Vision",                      "locale": "en" }] },
+          { "path": ["org.geant.incubator.complex.1", "medical_certificate", "vision", "left"],         "display": [{ "name": "Vision, left eye, dioptres",  "locale": "en" }] },
+          { "path": ["org.geant.incubator.complex.1", "medical_certificate", "vision", "right"],        "display": [{ "name": "Vision, right eye, dioptres", "locale": "en" }] },
+          { "path": ["org.geant.incubator.complex.1", "medical_certificate", "examiner"],               "display": [{ "name": "Examiner",                    "locale": "en" }] },
+          { "path": ["org.geant.incubator.complex.1", "medical_certificate", "examiner", "name"],       "display": [{ "name": "Examiner name",               "locale": "en" }] },
+          { "path": ["org.geant.incubator.complex.1", "medical_certificate", "examiner", "licence_number"], "display": [{ "name": "Examiner licence number", "locale": "en" }] },
+          { "path": ["org.geant.incubator.complex.1", "endorsement_dates"],                             "display": [{ "name": "Endorsement dates",           "locale": "en" }] },
+          { "path": ["org.geant.incubator.complex.1", "last_updated"],                                  "display": [{ "name": "Last updated",                "locale": "en" }] }
+        ]
+      }
+    }
+  }
+}
+```
+
+</details>
+
+The rest of the settings are those of [The mdoc credential](#the-mdoc-credential) in Step 5. What
+is new here:
+
+- A `claim_types` path is a Claims Path Pointer as the paths of the metadata. It types the value
+  it points to, and nothing else of the element. JSON has no date or byte string, a JSON string
+  without an entry is a text string in CBOR:
+  - `["org.iso.18013.5.1", "birth_date"]` types the whole element. A string of the form
+    `YYYY-MM-DD` is treated as a `full-date`, CBOR tag 1004, the type of a date of birth in an mDL.
+  - `["org.geant.incubator.complex.1", "medical_certificate", "issue_date"]` types one member of
+    the map in the element. A string of the form `YYYY-MM-DD` is treated as a `full-date`, so
+    that the date is a date in our own name space too. `fit_to_drive`, `vision` and the rest of
+    `medical_certificate` keep their JSON types.
+  - `["org.iso.18013.5.1", "driving_privileges", null, "issue_date"]` types `issue_date` in every
+    object of the array, in the privileges of both `A` and `B`. A string of the form `YYYY-MM-DD`
+    is treated as a `full-date`, the type of the dates of a driving privilege in an mDL.
+    `vehicle_category_code` and `codes` stay text strings.
+  - `["org.geant.incubator.complex.1", "endorsement_dates"]` points to an array and types every
+    item of it. Each string of the form `YYYY-MM-DD` is treated as a `full-date`, so
+    that the array is an array of dates.
+
+### Resolving complex values
+
+**File(s):** *conf/attribute-resolver.xml*, *conf/oidc.properties*, *conf/attribute-filter.xml*
+**Format:** Native Spring, Property files
+
+The attributes are as in [Authorization code flow](#authorization-code-flow) of Step 1. The values
+here are static, the data of the specifications. Your own come from the attributes of the user.
+
+#### SD-JWT VC values
+
+<details>
+<summary>conf/attribute-resolver.xml</summary>
+
+```xml
+<AttributeDefinition id="ComplexCredential" xsi:type="ScriptedAttribute">
+  <Script><![CDATA[
+      var claims = [
+          {"path": ["verified_claims", "verification", "trust_framework"], "value": "de_aml"},
+          {"path": ["verified_claims", "verification", "time"], "value": "2012-04-23T18:25Z"},
+          {"path": ["verified_claims", "verification", "verification_process"], "value": "f24c6f-6d3f-4ec5-973e-b0d8506f3bc7"},
+          {"path": ["verified_claims", "verification", "evidence"], "value": [
+              {
+                  "type": "document",
+                  "method": "pipp",
+                  "time": "2012-04-22T11:30Z",
+                  "document": {
+                      "type": "idcard",
+                      "issuer": {"name": "Stadt Augsburg", "country": "DE"},
+                      "number": "53554554",
+                      "date_of_issuance": "2010-03-23",
+                      "date_of_expiry": "2020-03-22"
+                  }
+              }
+          ]},
+          {"path": ["verified_claims", "claims", "given_name"], "value": "Max"},
+          {"path": ["verified_claims", "claims", "family_name"], "value": "Müller"},
+          {"path": ["verified_claims", "claims", "nationalities"], "value": ["DE"]},
+          {"path": ["verified_claims", "claims", "birthdate"], "value": "1956-01-28"},
+          {"path": ["verified_claims", "claims", "place_of_birth"], "value": {"country": "IS", "locality": "Þykkvabæjarklaustur"}},
+          {"path": ["verified_claims", "claims", "address", "street_address"], "value": "Weidenstraße 22"},
+          {"path": ["verified_claims", "claims", "address", "locality"], "value": "Maxstadt"},
+          {"path": ["verified_claims", "claims", "address", "postal_code"], "value": "12344"},
+          {"path": ["verified_claims", "claims", "address", "country"], "value": "DE"},
+          {"path": ["birth_middle_name"], "value": "Timotheus"},
+          {"path": ["salutation"], "value": "Dr."},
+          {"path": ["msisdn"], "value": "49123456789"},
+          {"path": ["age_equal_or_over", "18"], "value": true},
+          {"path": ["age_equal_or_over", "21"], "value": true},
+          {"path": ["age_equal_or_over", "65"], "value": true},
+          {"path": ["age_equal_or_over", "75"], "value": false},
+          {"path": ["age_in_years"], "value": 70},
+          {"path": ["age_birth_year"], "value": 1956},
+          {"path": ["height"], "value": 1.82},
+          {"path": ["picture"], "value": "data:image/jpeg;base64,/9j/4AAQSkZJRgABAgAAAQABAAD..."}
+      ];
+      ComplexCredential.addValue(JSON.stringify(claims));
+  ]]></Script>
+  <AttributeEncoder xsi:type="oidc:OIDCString" name="ComplexCredential_SDJWT" />
+</AttributeDefinition>
+```
+
+The picture is shortened here.
+
+</details>
+
+The type of a value in the credential is the JSON type you give it here, `"70"` is a string and
+`70` a number. The script builds the claims as objects and serializes them with `JSON.stringify`,
+instead of joining strings as in Step 1. That keeps `true`, `70` and `1.82` as a boolean and numbers. In a
+joined string you would have to leave the quotes out yourself.
+
+The values of IdP attributes are strings. Convert them before they go to the list:
+
+```javascript
+{"path": ["age_in_years"], "value": Number(ageInYears.getValues().get(0))},
+{"path": ["age_equal_or_over", "18"], "value": String(over18.getValues().get(0)) == "true"},
+{"path": ["verified_claims", "claims", "given_name"], "value": String(givenName.getValues().get(0))}
+```
+
+`ageInYears` and `over18` stand for attributes of your own. `String()` makes a JavaScript string of
+the Java one, so that `JSON.stringify` serializes it as a string.
+
+How deep a path goes decides what a wallet can disclose:
+
+| Claim | Given as | Disclosed |
+|---|---|---|
+| `address` | four paths, one per member | each member on its own, `_sd` in `address` |
+| `place_of_birth` | one path, an object as the value | the object as a whole |
+| `nationalities`, `evidence` | one path, an array as the value | the array as a whole |
+| `age_equal_or_over` | one path per age | each age on its own |
+
+An object that holds values you give gets an `_sd` of its own. The objects on the way to it,
+`verified_claims` here, are not selectively disclosable, a wallet always shows that they are there.
+The elements of an array are never disclosed one by one. A path you give has names only, no
+`null` and no index, an array is always given as a whole value.
+
+#### mdoc values
+
+<details>
+<summary>conf/attribute-resolver.xml</summary>
+
+```xml
+<AttributeDefinition id="ComplexCredentialMdoc" xsi:type="ScriptedAttribute">
+  <Script><![CDATA[
+      var iso = "org.iso.18013.5.1";
+      var ext = "org.geant.incubator.complex.1";
+      var claims = [
+          {"path": [iso, "family_name"], "value": "Doe"},
+          {"path": [iso, "given_name"], "value": "John"},
+          {"path": [iso, "birth_date"], "value": "1980-06-15"},
+          {"path": [iso, "issue_date"], "value": "2019-10-20"},
+          {"path": [iso, "expiry_date"], "value": "2024-10-20"},
+          {"path": [iso, "issuing_country"], "value": "FI"},
+          {"path": [iso, "issuing_authority"], "value": "Utopia Department of Motor Vehicles"},
+          {"path": [iso, "document_number"], "value": "123456789"},
+          {"path": [iso, "portrait"], "value": "_9j_4AAQSkZJRgABAgAAAQABAAD..."},
+          {"path": [iso, "portrait_capture_date"], "value": "2019-10-20T10:00:00Z"},
+          {"path": [iso, "driving_privileges"], "value": [
+              {"vehicle_category_code": "A", "issue_date": "2018-08-09", "expiry_date": "2024-10-20"},
+              {"vehicle_category_code": "B", "issue_date": "2017-02-23", "expiry_date": "2024-10-20",
+               "codes": [{"code": "01"}]}
+          ]},
+          {"path": [iso, "un_distinguishing_sign"], "value": "FIN"},
+          {"path": [iso, "sex"], "value": 1},
+          {"path": [iso, "height"], "value": 180},
+          {"path": [iso, "weight"], "value": 75},
+          {"path": [iso, "age_in_years"], "value": 46},
+          {"path": [iso, "age_birth_year"], "value": 1980},
+          {"path": [iso, "age_over_18"], "value": true},
+          {"path": [iso, "age_over_21"], "value": true},
+          {"path": [iso, "age_over_65"], "value": false},
+          {"path": [ext, "medical_certificate", "issue_date"], "value": "2024-05-02"},
+          {"path": [ext, "medical_certificate", "expiry_date"], "value": "2029-05-02"},
+          {"path": [ext, "medical_certificate", "fit_to_drive"], "value": true},
+          {"path": [ext, "medical_certificate", "restrictions"], "value": ["01", "78"]},
+          {"path": [ext, "medical_certificate", "vision", "left"], "value": -2},
+          {"path": [ext, "medical_certificate", "vision", "right"], "value": -1},
+          {"path": [ext, "medical_certificate", "examiner", "name"], "value": "Dr. Jane Roe"},
+          {"path": [ext, "medical_certificate", "examiner", "licence_number"], "value": 4711},
+          {"path": [ext, "endorsement_dates"], "value": ["2020-03-01", "2023-07-15"]},
+          {"path": [ext, "last_updated"], "value": 1760000000}
+      ];
+      ComplexCredentialMdoc.addValue(JSON.stringify(claims));
+  ]]></Script>
+  <AttributeEncoder xsi:type="oidc:OIDCString" name="ComplexCredential_MDOC" />
+</AttributeDefinition>
+```
+
+The portrait is shortened here.
+
+</details>
+
+The dates and the portrait are strings here, the `claim_types` of the configuration make them a
+`full-date`, a `tdate` and a byte string. `last_updated` is given in epoch seconds, a `tdate`
+takes that too. The integers and booleans are JSON values and need no entry. Values of IdP
+attributes are strings, convert them in the script as above or give them an `int` or `bool` entry.
+
+The seven `medical_certificate` paths become one element, a map with two maps inside:
+
+```
+"medical_certificate": {
+  "issue_date": 1004("2024-05-02"),
+  "expiry_date": 1004("2029-05-02"),
+  "fit_to_drive": true,
+  "restrictions": ["01", "78"],
+  "vision": { "left": -2, "right": -1 },
+  "examiner": { "name": "Dr. Jane Roe", "licence_number": 4711 }
+}
+```
+
+#### Release
+
+Add both attributes to `idp.oidc.encodedAttributes` in *conf/oidc.properties* and release them to
+the wallets in *conf/attribute-filter.xml*, as in Step 1:
+
+```properties
+idp.oidc.encodedAttributes = ComplexCredential, ComplexCredentialMdoc
+```
+
+The configuration ids go to the `scope` of the wallets in *metadata/oidc-client.json*.
+
+#### Pre-authorized offer
+
+The offer takes the same list, the values keep their JSON types:
+
+```json
+{
+  "preAuthorizedCredentials": {
+    "ComplexCredential_SDJWT": [
+      { "path": ["verified_claims", "verification", "trust_framework"], "value": "de_aml" },
+      { "path": ["verified_claims", "claims", "given_name"], "value": "Max" },
+      { "path": ["verified_claims", "claims", "family_name"], "value": "Müller" },
+      { "path": ["verified_claims", "claims", "place_of_birth"], "value": { "country": "IS", "locality": "Þykkvabæjarklaustur" } },
+      { "path": ["age_equal_or_over", "18"], "value": true },
+      { "path": ["age_in_years"], "value": 70 },
+      { "path": ["height"], "value": 1.82 },
+      "..."
+    ]
+  }
+}
+```
+
+The offer is refused when a `mandatory` claim of the metadata is not in it, here `given_name` and
+`family_name`.
+
+### Getting the types right
+
+| In the credential | SD-JWT VC, give | mdoc, give |
+|---|---|---|
+| Text | a JSON string | a JSON string |
+| Integer, also negative | a JSON number | a JSON number, or text with `int` in `claim_types` |
+| Decimal number | a JSON number | not supported, refused |
+| Boolean | `true` or `false` | `true` or `false`, or text with `bool` in `claim_types` |
+| Date | a string, there is no date type | a string with `full-date` in `claim_types` |
+| Date and time | a string | a string or epoch seconds with `tdate` in `claim_types` |
+| Bytes | a string, a `data:` url for example | base64 or base64url with `bstr` in `claim_types` |
+| Object | a JSON object, disclosed as a whole, or a path per member, each disclosed | a JSON object or a path per member, both one element |
+| Array | a JSON array, disclosed as a whole | a JSON array, a `claim_types` entry on it types every item |
+| Typed value inside an array | | a `claim_types` path with `null` for the array |
+| Null | not supported, refused | not supported, refused |
+
+The places to configure, in order:
+
+1. The JSON type of the value, in the script of the attribute or in the offer. This is the type in
+   the credential for everything JSON has.
+2. `claim_types` in `_issuance_configuration`, mdoc only. Dates, byte strings, and numbers and
+   booleans that come as text.
+3. The depth of the paths. For an SD-JWT VC it decides what is disclosed on its own, for an mdoc
+   where an element ends.
+4. For a document type of a standard, the definitions of that standard. The types, the values and
+   for an mDL the country of the Document Signer certificate.
+
 ## Reference
 
 ### Profile configuration settings
@@ -4140,7 +4589,7 @@ Member `_issuance_configuration` of a credential configuration. It is not publis
 | `status_list` | Boolean | `openidvci.status-list.includeByDefault` | Whether the credential takes a Status List slot. |
 | `issuer` | String | `openidvci.issuer` | `did:jwk`, `did:web`, `url` or the identifier itself. |
 | `oidfed` | Boolean | `true` | Whether the credential states the Entity Identifier, when *conf/openid-vci-oidfed.xml* is imported. |
-| `claim_types` | Array | | `{"path": [...], "type": ...}` per claim of an mdoc. `full-date`, `tdate`, `bstr`, `int` or `bool`. |
+| `claim_types` | Array | | `{"path": [...], "type": ...}` per claim of an mdoc. `full-date`, `tdate`, `bstr`, `int` or `bool`. The path is a Claims Path Pointer, `null` for every element of an array, see [Complex claims](#complex-claims). |
 
 Members of a `vc+sd-jwt` credential, [W3C VCDM 2.0](https://www.w3.org/TR/vc-data-model-2.0/). The ones you set are members of
 `credential_definition`. Their content is purely yours, the plugin does not check it. Only
diff --git a/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/ClaimType.java b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/ClaimType.java
index e2dec95..06fe2a8 100644
--- a/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/ClaimType.java
+++ b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/ClaimType.java
@@ -15,6 +15,8 @@
  */
 package org.geant.shibboleth.plugin.openidvci.credential;
 
+import java.util.ArrayList;
+import java.util.Collections;
 import java.util.List;
 import java.util.Set;
 
@@ -55,9 +57,9 @@ public final class ClaimType {
     @Nonnull
     private static final Set<String> TYPES = Set.of(FULL_DATE, TDATE, BSTR, INT, BOOL);
 
-    /** Path of the claim. */
+    /** Path of the claim, a claims path pointer. */
     @Nonnull
-    private final List<String> path;
+    private final List<Object> path;
 
     /** Type of the claim value. */
     @Nonnull
@@ -66,11 +68,11 @@ public final class ClaimType {
     /**
      * Constructor.
      *
-     * @param claimPath path of the claim
+     * @param claimPath path of the claim, a claims path pointer
      * @param claimType type of the claim value
      */
     @JsonCreator
-    private ClaimType(@JsonProperty("path") final List<String> claimPath,
+    private ClaimType(@JsonProperty("path") final List<Object> claimPath,
             @JsonProperty("type") final String claimType) {
         if (claimPath == null || claimPath.isEmpty()) {
             throw new IllegalArgumentException("claim_types entry has no path");
@@ -79,20 +81,52 @@ public final class ClaimType {
             throw new IllegalArgumentException("claim_types entry " + claimPath + " has type '" + claimType
                     + "', not one of " + TYPES);
         }
-        path = List.copyOf(claimPath);
+        final List<Object> elements = new ArrayList<>(claimPath.size());
+        for (final Object element : claimPath) {
+            if (element == null || element instanceof String) {
+                elements.add(element);
+            } else if ((element instanceof Integer || element instanceof Long)
+                    && ((Number) element).longValue() >= 0 && ((Number) element).longValue() <= Integer.MAX_VALUE) {
+                elements.add(((Number) element).intValue());
+            } else {
+                throw new IllegalArgumentException("claim_types entry " + claimPath + " has path element '"
+                        + element + "', not a string, a non-negative integer or null");
+            }
+        }
+        path = Collections.unmodifiableList(elements);
         type = claimType;
     }
 
     /**
-     * Get the path of the claim.
+     * Get the path of the claim, a claims path pointer.
      *
      * @return path of the claim
      */
     @Nonnull
-    public List<String> getPath() {
+    public List<Object> getPath() {
         return path;
     }
 
+    /**
+     * Whether the path of the claim selects a value.
+     *
+     * @param valuePath path of the value, of element names and array indexes
+     * @return whether the path selects the value
+     */
+    public boolean matches(@Nonnull final List<?> valuePath) {
+        if (valuePath.size() != path.size()) {
+            return false;
+        }
+        for (int i = 0; i < path.size(); i++) {
+            final Object element = path.get(i);
+            final Object component = valuePath.get(i);
+            if (element == null ? !(component instanceof Integer) : !element.equals(component)) {
+                return false;
+            }
+        }
+        return true;
+    }
+
     /**
      * Get the type of the claim value.
      *
diff --git a/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/CredentialConfiguration.java b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/CredentialConfiguration.java
index 0be9dcd..ef1f93c 100644
--- a/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/CredentialConfiguration.java
+++ b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/CredentialConfiguration.java
@@ -282,25 +282,37 @@ public final class CredentialConfiguration {
     }
 
     /**
-     * Check that an mdoc has a document type and claim paths of a name space and a
-     * data element identifier.
+     * Check that an mdoc has a document type and claim paths and claim type paths of a
+     * name space and a data element identifier.
      */
     private void validateMdoc() {
         if (doctype == null) {
             throw new IllegalArgumentException("doctype is missing");
         }
-        if (credentialMetadata == null || credentialMetadata.getClaims() == null) {
-            return;
+        if (credentialMetadata != null && credentialMetadata.getClaims() != null) {
+            for (final ClaimDescription claim : credentialMetadata.getClaims()) {
+                validateMdocPath(claim.getPath());
+            }
         }
-        for (final ClaimDescription claim : credentialMetadata.getClaims()) {
-            final List<Object> path = claim.getPath();
-            if (path.size() < 2 || !(path.get(0) instanceof String) || !(path.get(1) instanceof String)) {
-                throw new IllegalArgumentException("mso_mdoc claim path " + path
-                        + " does not start with a name space and a data element identifier");
+        if (issuanceConfiguration != null) {
+            for (final ClaimType type : issuanceConfiguration.getClaimTypes()) {
+                validateMdocPath(type.getPath());
             }
         }
     }
 
+    /**
+     * Check that an mdoc claim path starts with a name space and a data element identifier.
+     *
+     * @param path claim path
+     */
+    private static void validateMdocPath(@Nonnull final List<Object> path) {
+        if (path.size() < 2 || !(path.get(0) instanceof String) || !(path.get(1) instanceof String)) {
+            throw new IllegalArgumentException("mso_mdoc claim path " + path
+                    + " does not start with a name space and a data element identifier");
+        }
+    }
+
     /**
      * Get Verifiable Credential Type identifier (SD-JWT VC formats).
      *
diff --git a/openid-vci-api/src/test/java/org/geant/shibboleth/plugin/openidvci/credential/MdocCredentialConfigurationTest.java b/openid-vci-api/src/test/java/org/geant/shibboleth/plugin/openidvci/credential/MdocCredentialConfigurationTest.java
index 5da6eca..3410643 100644
--- a/openid-vci-api/src/test/java/org/geant/shibboleth/plugin/openidvci/credential/MdocCredentialConfigurationTest.java
+++ b/openid-vci-api/src/test/java/org/geant/shibboleth/plugin/openidvci/credential/MdocCredentialConfigurationTest.java
@@ -15,6 +15,7 @@
  */
 package org.geant.shibboleth.plugin.openidvci.credential;
 
+import java.util.Arrays;
 import java.util.List;
 
 import org.testng.Assert;
@@ -67,6 +68,46 @@ public class MdocCredentialConfigurationTest {
                 + "{\"path\":[\"org.iso.18013.5.1\",\"birth_date\"],\"type\":\"date\"}]}}");
     }
 
+    @Test
+    public void claimTypePathPointer() throws JsonProcessingException {
+        final CredentialConfiguration configuration = CredentialConfiguration.parse("{\"format\":\"mso_mdoc\","
+                + "\"doctype\":\"org.iso.18013.5.1.mDL\",\"_issuance_configuration\":{\"claim_types\":["
+                + "{\"path\":[\"org.iso.18013.5.1\",\"driving_privileges\",null,\"issue_date\"],"
+                + "\"type\":\"full-date\"},"
+                + "{\"path\":[\"org.iso.18013.5.1\",\"driving_privileges\",1,\"expiry_date\"],"
+                + "\"type\":\"full-date\"}]}}");
+
+        final List<ClaimType> types = configuration.getIssuanceConfiguration().getClaimTypes();
+        Assert.assertEquals(types.get(0).getPath(),
+                Arrays.asList("org.iso.18013.5.1", "driving_privileges", null, "issue_date"));
+        Assert.assertTrue(types.get(0).matches(List.of("org.iso.18013.5.1", "driving_privileges", 3, "issue_date")));
+        Assert.assertFalse(types.get(0).matches(List.of("org.iso.18013.5.1", "driving_privileges", "3", "issue_date")));
+        Assert.assertFalse(types.get(0).matches(List.of("org.iso.18013.5.1", "driving_privileges")));
+        Assert.assertTrue(types.get(1).matches(List.of("org.iso.18013.5.1", "driving_privileges", 1, "expiry_date")));
+        Assert.assertFalse(types.get(1).matches(List.of("org.iso.18013.5.1", "driving_privileges", 0, "expiry_date")));
+    }
+
+    @Test(expectedExceptions = JsonProcessingException.class)
+    public void claimTypePathWithBoolean() throws JsonProcessingException {
+        CredentialConfiguration.parse("{\"format\":\"mso_mdoc\",\"doctype\":\"org.iso.18013.5.1.mDL\","
+                + "\"_issuance_configuration\":{\"claim_types\":["
+                + "{\"path\":[\"org.iso.18013.5.1\",\"driving_privileges\",true],\"type\":\"full-date\"}]}}");
+    }
+
+    @Test(expectedExceptions = JsonProcessingException.class)
+    public void claimTypePathWithNegativeIndex() throws JsonProcessingException {
+        CredentialConfiguration.parse("{\"format\":\"mso_mdoc\",\"doctype\":\"org.iso.18013.5.1.mDL\","
+                + "\"_issuance_configuration\":{\"claim_types\":["
+                + "{\"path\":[\"org.iso.18013.5.1\",\"driving_privileges\",-1],\"type\":\"full-date\"}]}}");
+    }
+
+    @Test(expectedExceptions = JsonProcessingException.class)
+    public void claimTypePathWithoutNameSpace() throws JsonProcessingException {
+        CredentialConfiguration.parse("{\"format\":\"mso_mdoc\",\"doctype\":\"org.iso.18013.5.1.mDL\","
+                + "\"_issuance_configuration\":{\"claim_types\":["
+                + "{\"path\":[null,\"birth_date\"],\"type\":\"full-date\"}]}}");
+    }
+
     @Test
     public void sdJwtIgnoresDoctype() throws JsonProcessingException {
         final CredentialConfiguration configuration = CredentialConfiguration
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/impl/MdocElements.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/impl/MdocElements.java
index 58605ca..f7a780b 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/impl/MdocElements.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/impl/MdocElements.java
@@ -48,21 +48,21 @@ public final class MdocElements {
      * Group claims into element values by element identifier by name space.
      *
      * @param claims claims as path and value
-     * @param types  type of a claim value by claim path
+     * @param types  types of claim values
      * @return element values by element identifier by name space
      * @throws EncodingException if a path is short, a value cannot take its type or two claims collide
      */
     @Nonnull
     public static Map<String, Map<String, Object>> collect(
             @Nonnull final List<? extends Map.Entry<List<String>, ?>> claims,
-            @Nonnull final Map<List<String>, String> types) throws EncodingException {
+            @Nonnull final List<ClaimType> types) throws EncodingException {
         final Map<String, Map<String, Object>> nameSpaces = new LinkedHashMap<>();
         for (final Map.Entry<List<String>, ?> claim : claims) {
             final List<String> path = claim.getKey();
             if (path.size() < 2) {
                 throw new EncodingException("Claim path " + path + " names no name space and data element");
             }
-            final Object value = toCbor(claim.getValue(), types.get(path));
+            final Object value = toCbor(claim.getValue(), new ArrayList<Object>(path), types, null);
             final Map<String, Object> elements = nameSpaces.computeIfAbsent(path.get(0), ns -> new LinkedHashMap<>());
             if (path.size() == 2) {
                 if (elements.containsKey(path.get(1))) {
@@ -113,21 +113,89 @@ public final class MdocElements {
      */
     @Nonnull
     public static Object toCbor(@Nullable final Object value, @Nullable final String type) throws EncodingException {
+        return toCbor(value, new ArrayList<>(), List.of(), type);
+    }
+
+    /**
+     * Convert a JSON value to a CBOR data item, typing the value and the values inside it by their paths.
+     *
+     * @param value     JSON value
+     * @param path      path of the value, of element names and array indexes
+     * @param types     types of claim values
+     * @param inherited type of the array the value is an item of, or null
+     * @return CBOR data item
+     * @throws EncodingException if a value cannot take its type or two types select it
+     */
+    @Nonnull
+    private static Object toCbor(@Nullable final Object value, @Nonnull final List<Object> path,
+            @Nonnull final List<ClaimType> types, @Nullable final String inherited) throws EncodingException {
         if (value == null) {
-            throw new EncodingException("Claim value is null");
+            throw new EncodingException("Claim value of " + path + " is null");
         }
+        final String selected = typeOf(path, types);
+        final String type = selected != null ? selected : inherited;
         if (value instanceof List<?> list) {
             final List<Object> items = new ArrayList<>(list.size());
-            for (final Object item : list) {
-                items.add(toCbor(item, type));
+            for (int i = 0; i < list.size(); i++) {
+                items.add(toCbor(list.get(i), append(path, i), types, type));
             }
             return items;
         }
-        return type != null ? typed(value, type) : untyped(value);
+        if (type != null) {
+            return typed(value, type);
+        }
+        if (value instanceof Map<?, ?> map) {
+            final Map<Object, Object> entries = new LinkedHashMap<>();
+            for (final Map.Entry<?, ?> entry : map.entrySet()) {
+                final String name = String.valueOf(entry.getKey());
+                entries.put(name, toCbor(entry.getValue(), append(path, name), types, null));
+            }
+            return entries;
+        }
+        return untyped(value);
+    }
+
+    /**
+     * Get the type of a value by its path.
+     *
+     * @param path  path of the value
+     * @param types types of claim values
+     * @return type of the value, or null
+     * @throws EncodingException if two types select the value
+     */
+    @Nullable
+    private static String typeOf(@Nonnull final List<Object> path, @Nonnull final List<ClaimType> types)
+            throws EncodingException {
+        ClaimType selected = null;
+        for (final ClaimType type : types) {
+            if (type.matches(path)) {
+                if (selected != null && !selected.getType().equals(type.getType())) {
+                    throw new EncodingException("Claim value of " + path + " is selected by claim types "
+                            + selected.getPath() + " and " + type.getPath() + " of different type");
+                }
+                selected = type;
+            }
+        }
+        return selected != null ? selected.getType() : null;
     }
 
     /**
-     * Convert a JSON value to a CBOR data item of the type of the JSON value.
+     * Get a path extended by a component.
+     *
+     * @param path      path
+     * @param component element name or array index
+     * @return extended path
+     */
+    @Nonnull
+    private static List<Object> append(@Nonnull final List<Object> path, @Nonnull final Object component) {
+        final List<Object> extended = new ArrayList<>(path.size() + 1);
+        extended.addAll(path);
+        extended.add(component);
+        return extended;
+    }
+
+    /**
+     * Convert a JSON scalar value to a CBOR data item of the type of the JSON value.
      *
      * @param value JSON value
      * @return CBOR data item
@@ -135,13 +203,6 @@ public final class MdocElements {
      */
     @Nonnull
     private static Object untyped(@Nonnull final Object value) throws EncodingException {
-        if (value instanceof Map<?, ?> map) {
-            final Map<Object, Object> entries = new LinkedHashMap<>();
-            for (final Map.Entry<?, ?> entry : map.entrySet()) {
-                entries.put(String.valueOf(entry.getKey()), toCbor(entry.getValue(), null));
-            }
-            return entries;
-        }
         if (value instanceof String || value instanceof Boolean || value instanceof Integer
                 || value instanceof Long || value instanceof BigInteger) {
             return value;
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/AddClaimsToMobileSecurityObject.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/AddClaimsToMobileSecurityObject.java
index 4814c23..0ceadf6 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/AddClaimsToMobileSecurityObject.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/AddClaimsToMobileSecurityObject.java
@@ -16,7 +16,6 @@
 package org.geant.shibboleth.plugin.openidvci.profile.impl;
 
 import java.util.ArrayList;
-import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 
@@ -104,20 +103,14 @@ public class AddClaimsToMobileSecurityObject extends AbstractProfileAction {
     }
 
     /**
-     * Get the types of claim values by claim path.
+     * Get the types of claim values.
      *
-     * @return types by claim path
+     * @return types of claim values
      */
     @Nonnull
-    private Map<List<String>, String> claimTypes() {
-        final Map<List<String>, String> types = new HashMap<>();
+    private List<ClaimType> claimTypes() {
         final CredentialIssuanceConfiguration settings = ctx.getCredentialConfiguration().getIssuanceConfiguration();
-        if (settings != null) {
-            for (final ClaimType type : settings.getClaimTypes()) {
-                types.put(type.getPath(), type.getType());
-            }
-        }
-        return types;
+        return settings != null ? settings.getClaimTypes() : List.of();
     }
 
 }
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/ValidateCredentialOffering.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/ValidateCredentialOffering.java
index e9adc6b..10e9d36 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/ValidateCredentialOffering.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/ValidateCredentialOffering.java
@@ -20,6 +20,7 @@ import java.util.List;
 import java.util.Map;
 
 import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
 
 import org.geant.shibboleth.plugin.openidvci.credential.ClaimDescription;
 import org.geant.shibboleth.plugin.openidvci.credential.CredentialConfiguration;
@@ -47,6 +48,14 @@ import net.shibboleth.shared.annotation.constraint.NonnullBeforeExec;
  */
 public class ValidateCredentialOffering extends AbstractCredentialValidationAction {
 
+    /** Format whose requested claims are placed inside 'credentialSubject'. */
+    @Nonnull
+    private static final String VC_SD_JWT = "vc+sd-jwt";
+
+    /** Claim the requested claims of a vc+sd-jwt credential are placed inside. */
+    @Nonnull
+    private static final String CREDENTIAL_SUBJECT = "credentialSubject";
+
     /** Class logger. */
     @Nonnull
     private Logger log = LoggerFactory.getLogger(ValidateCredentialOffering.class);
@@ -123,7 +132,12 @@ public class ValidateCredentialOffering extends AbstractCredentialValidationActi
             if (!Boolean.TRUE.equals(claim.getMandatory())) {
                 continue;
             }
-            if (!requestContainsPath(requestedCredential, claim.getPath())) {
+            List<Object> path = claim.getPath();
+            if (VC_SD_JWT.equals(configuration.getFormat()) && !path.isEmpty()
+                    && CREDENTIAL_SUBJECT.equals(path.get(0))) {
+                path = path.subList(1, path.size());
+            }
+            if (path.isEmpty() || !requestContainsPath(requestedCredential, path)) {
                 return false;
             }
         }
@@ -131,33 +145,57 @@ public class ValidateCredentialOffering extends AbstractCredentialValidationActi
     }
 
     /**
-     * Test whether any requested claim's path equals the given configuration path.
-     * Configuration paths are typed as {@link Object} per spec (element may be
-     * string, non-negative integer, or null); this comparison only matches string
-     * elements against the request's string-only path.
+     * Test whether a configuration path selects a value in the requested claims.
      *
      * @param requestedCredential Requested claims from the pre-authorized offer
-     * @param configPath          Path from a configuration claim
-     * @return true if a matching requested claim path exists
+     * @param configPath          Path from a configuration claim, a claims path pointer
+     * @return true if the path selects a value
      */
     private boolean requestContainsPath(@Nonnull final CredentialOfferRequestedCredential requestedCredential,
             @Nonnull final List<Object> configPath) {
         for (final CredentialOfferRequestedClaim requestedClaim : requestedCredential.getRequestedCredential()) {
             final List<String> requestPath = requestedClaim.getPath();
-            if (configPath.size() != requestPath.size()) {
-                continue;
+            if (requestPath.size() <= configPath.size()
+                    && configPath.subList(0, requestPath.size()).equals(requestPath)
+                    && selects(requestedClaim.getValue(), configPath.subList(requestPath.size(), configPath.size()))) {
+                return true;
             }
-            boolean match = true;
-            for (int i = 0; i < configPath.size(); i++) {
-                final Object configElement = configPath.get(i);
-                if (!(configElement instanceof String) || !configElement.equals(requestPath.get(i))) {
-                    match = false;
-                    break;
+        }
+        return false;
+    }
+
+    /**
+     * Test whether a claims path pointer selects a value inside a claim value.
+     *
+     * @param value claim value
+     * @param path  rest of the claims path pointer
+     * @return true if the path selects a value
+     */
+    private static boolean selects(@Nullable final Object value, @Nonnull final List<Object> path) {
+        if (value == null) {
+            return false;
+        }
+        if (path.isEmpty()) {
+            return true;
+        }
+        final Object element = path.get(0);
+        final List<Object> rest = path.subList(1, path.size());
+        if (element instanceof String name) {
+            return value instanceof Map<?, ?> map && selects(map.get(name), rest);
+        }
+        if (!(value instanceof List<?> list)) {
+            return false;
+        }
+        if (element == null) {
+            for (final Object item : list) {
+                if (selects(item, rest)) {
+                    return true;
                 }
             }
-            if (match) {
-                return true;
-            }
+            return false;
+        }
+        if (element instanceof Number index && index.longValue() >= 0 && index.longValue() < list.size()) {
+            return selects(list.get(index.intValue()), rest);
         }
         return false;
     }
diff --git a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/credential/impl/MdocElementsTest.java b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/credential/impl/MdocElementsTest.java
index f4383ff..6c5fd8c 100644
--- a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/credential/impl/MdocElementsTest.java
+++ b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/credential/impl/MdocElementsTest.java
@@ -21,9 +21,13 @@ import java.util.List;
 import java.util.Map;
 
 import org.geant.shibboleth.plugin.openidvci.cose.impl.CborTag;
+import org.geant.shibboleth.plugin.openidvci.credential.ClaimType;
 import org.testng.Assert;
 import org.testng.annotations.Test;
 
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
 import net.shibboleth.shared.codec.EncodingException;
 
 /**
@@ -33,14 +37,18 @@ public class MdocElementsTest {
 
     private static final String NS = "org.iso.18013.5.1";
 
+    private static ClaimType claimType(final String path, final String type) throws JsonProcessingException {
+        return new ObjectMapper().readValue("{\"path\":" + path + ",\"type\":\"" + type + "\"}", ClaimType.class);
+    }
+
     @Test
-    public void collect() throws EncodingException {
+    public void collect() throws EncodingException, JsonProcessingException {
         final Map<String, Map<String, Object>> nameSpaces = MdocElements.collect(List.of(
                 Map.entry(List.of(NS, "given_name"), "Gemma"),
                 Map.entry(List.of(NS, "address", "street"), "Main 1"),
                 Map.entry(List.of(NS, "address", "postal"), "00100"),
                 Map.entry(List.of(NS + ".aamva", "organ_donor"), 1)),
-                Map.of(List.of(NS, "address", "postal"), "int"));
+                List.of(claimType("[\"" + NS + "\",\"address\",\"postal\"]", "int")));
 
         Assert.assertEquals(new ArrayList<>(nameSpaces.keySet()), List.of(NS, NS + ".aamva"));
         Assert.assertEquals(nameSpaces.get(NS).get("given_name"), "Gemma");
@@ -64,9 +72,38 @@ public class MdocElementsTest {
         Assert.assertEquals(MdocElements.toCbor(Map.of("a", true), null), Map.of("a", true));
     }
 
+    @Test
+    public void typesInArrayOfMaps() throws EncodingException, JsonProcessingException {
+        final Map<String, Map<String, Object>> nameSpaces = MdocElements.collect(List.of(
+                Map.entry(List.of(NS, "driving_privileges"), List.of(
+                        Map.of("vehicle_category_code", "A", "issue_date", "2018-08-09"),
+                        Map.of("vehicle_category_code", "B", "issue_date", "2017-02-23",
+                                "codes", List.of(Map.of("code", "01"))))),
+                Map.entry(List.of(NS, "endorsement_dates"), List.of("2020-03-01"))),
+                List.of(claimType("[\"" + NS + "\",\"driving_privileges\",null,\"issue_date\"]", "full-date"),
+                        claimType("[\"" + NS + "\",\"driving_privileges\",1,\"codes\",0,\"code\"]", "int"),
+                        claimType("[\"" + NS + "\",\"endorsement_dates\"]", "full-date")));
+
+        final List<?> privileges = (List<?>) nameSpaces.get(NS).get("driving_privileges");
+        Assert.assertEquals(privileges.get(0), Map.of("vehicle_category_code", "A",
+                "issue_date", new CborTag(CborTag.FULL_DATE, "2018-08-09")));
+        Assert.assertEquals(privileges.get(1), Map.of("vehicle_category_code", "B",
+                "issue_date", new CborTag(CborTag.FULL_DATE, "2017-02-23"),
+                "codes", List.of(Map.of("code", BigInteger.ONE))));
+        Assert.assertEquals(nameSpaces.get(NS).get("endorsement_dates"),
+                List.of(new CborTag(CborTag.FULL_DATE, "2020-03-01")));
+    }
+
+    @Test(expectedExceptions = EncodingException.class)
+    public void conflictingTypes() throws EncodingException, JsonProcessingException {
+        MdocElements.collect(List.of(Map.entry(List.of(NS, "dates"), List.of("2020-03-01"))),
+                List.of(claimType("[\"" + NS + "\",\"dates\",null]", "full-date"),
+                        claimType("[\"" + NS + "\",\"dates\",0]", "tdate")));
+    }
+
     @Test(expectedExceptions = EncodingException.class)
     public void shortPath() throws EncodingException {
-        MdocElements.collect(List.of(Map.entry(List.of("given_name"), "Gemma")), Map.of());
+        MdocElements.collect(List.of(Map.entry(List.of("given_name"), "Gemma")), List.of());
     }
 
     @Test(expectedExceptions = EncodingException.class)
@@ -82,6 +119,6 @@ public class MdocElementsTest {
     @Test(expectedExceptions = EncodingException.class)
     public void collision() throws EncodingException {
         MdocElements.collect(List.of(Map.entry(List.of(NS, "address"), "Main 1"),
-                Map.entry(List.of(NS, "address", "street"), "Main 1")), Map.of());
+                Map.entry(List.of(NS, "address", "street"), "Main 1")), List.of());
     }
 }
diff --git a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/profile/impl/ValidateCredentialOfferingTest.java b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/profile/impl/ValidateCredentialOfferingTest.java
index 853118a..aec351f 100644
--- a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/profile/impl/ValidateCredentialOfferingTest.java
+++ b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/profile/impl/ValidateCredentialOfferingTest.java
@@ -30,6 +30,7 @@ import org.opensaml.messaging.context.MessageContext;
 import org.opensaml.profile.context.ProfileRequestContext;
 import org.springframework.core.io.ClassPathResource;
 import org.springframework.util.Assert;
+import org.springframework.webflow.execution.Event;
 import org.springframework.webflow.execution.RequestContext;
 import org.testng.annotations.BeforeMethod;
 import org.testng.annotations.Test;
@@ -180,7 +181,7 @@ public class ValidateCredentialOfferingTest {
         httpRequest.setAuthorization("Basic dGVzdDp0ZXN0");
         httpRequest.setContentType("application/json");
         httpRequest.setBody(
-                "{\"preAuthorizedCredentials\":{\"GeantIncubatorDiploma2\":[{\"path\":[\"credentialSubject\",\"mail\"],\"value\":\"incubatorUser at example.org\"},{\"path\":[\"credentialSubject\",\"givenName\"],\"value\":\"Gemma\"},{\"path\":[\"credentialSubject\",\"familyName\"],\"value\":\"Erasmus\"}]}}");
+                "{\"preAuthorizedCredentials\":{\"GeantIncubatorDiploma2\":[{\"path\":[\"mail\"],\"value\":\"incubatorUser at example.org\"},{\"path\":[\"givenName\"],\"value\":\"Gemma\"},{\"path\":[\"familyName\"],\"value\":\"Erasmus\"}]}}");
         profileRequestCtx.getInboundMessageContext().setMessage(CredentialOfferRequest.parse(httpRequest));
         ActionTestingSupport.assertProceedEvent(action.execute(requestCtx));
         CredentialOfferContext ctx = profileRequestCtx.getInboundMessageContext()
@@ -196,7 +197,7 @@ public class ValidateCredentialOfferingTest {
         httpRequest.setAuthorization("Basic dGVzdDp0ZXN0");
         httpRequest.setContentType("application/json");
         httpRequest.setBody(
-                "{\"preAuthorizedCredentials\":{\"GeantIncubatorDiploma2\":[{\"path\":[\"credentialSubject\",\"givenName\"],\"value\":\"Gemma\"},{\"path\":[\"credentialSubject\",\"familyName\"],\"value\":\"Erasmus\"}]}}");
+                "{\"preAuthorizedCredentials\":{\"GeantIncubatorDiploma2\":[{\"path\":[\"givenName\"],\"value\":\"Gemma\"},{\"path\":[\"familyName\"],\"value\":\"Erasmus\"}]}}");
         profileRequestCtx.getInboundMessageContext().setMessage(CredentialOfferRequest.parse(httpRequest));
         ActionTestingSupport.assertEvent(action.execute(requestCtx), OpenIDVCIEventIds.NO_CREDENTIALS_REQUEST);
     }
@@ -233,4 +234,70 @@ public class ValidateCredentialOfferingTest {
 
     }
 
+    private Event executeMandatory(final String claims) throws Exception {
+        HTTPRequest httpRequest = new HTTPRequest(HTTPRequest.Method.POST, new URI("http://example.com"));
+        httpRequest.setAuthorization("Basic dGVzdDp0ZXN0");
+        httpRequest.setContentType("application/json");
+        httpRequest.setBody("{\"preAuthorizedCredentials\":{\"Mandatory\":" + claims + "}}");
+        action = new ValidateCredentialOffering();
+        action.setCredentialConfigurationsResolver(resolverOf("conf/verifiable-credentials-mandatory.json"));
+        action.initialize();
+        profileRequestCtx.getInboundMessageContext().setMessage(CredentialOfferRequest.parse(httpRequest));
+        return action.execute(requestCtx);
+    }
+
+    @Test
+    public void testMandatoryPathPointers() throws Exception {
+        ActionTestingSupport.assertProceedEvent(executeMandatory("["
+                + "{\"path\":[\"nationalities\"],\"value\":[\"DE\"]},"
+                + "{\"path\":[\"place_of_birth\"],\"value\":{\"country\":\"IS\",\"locality\":\"Reykjavik\"}},"
+                + "{\"path\":[\"evidence\"],\"value\":[{\"document\":{\"number\":\"53554554\"}}]}]"));
+    }
+
+    @Test
+    public void testMandatoryLeafPaths() throws Exception {
+        ActionTestingSupport.assertProceedEvent(executeMandatory("["
+                + "{\"path\":[\"nationalities\"],\"value\":[\"DE\"]},"
+                + "{\"path\":[\"place_of_birth\",\"locality\"],\"value\":\"Reykjavik\"},"
+                + "{\"path\":[\"evidence\"],\"value\":[{\"document\":{\"number\":\"53554554\"}}]}]"));
+    }
+
+    @Test
+    public void testMandatoryEmptyArray() throws Exception {
+        ActionTestingSupport.assertEvent(executeMandatory("["
+                + "{\"path\":[\"nationalities\"],\"value\":[]},"
+                + "{\"path\":[\"place_of_birth\",\"locality\"],\"value\":\"Reykjavik\"},"
+                + "{\"path\":[\"evidence\"],\"value\":[{\"document\":{\"number\":\"53554554\"}}]}]"),
+                OpenIDVCIEventIds.NO_CREDENTIALS_REQUEST);
+    }
+
+    @Test
+    public void testMandatoryMissingInObject() throws Exception {
+        ActionTestingSupport.assertEvent(executeMandatory("["
+                + "{\"path\":[\"nationalities\"],\"value\":[\"DE\"]},"
+                + "{\"path\":[\"place_of_birth\"],\"value\":{\"country\":\"IS\"}},"
+                + "{\"path\":[\"evidence\"],\"value\":[{\"document\":{\"number\":\"53554554\"}}]}]"),
+                OpenIDVCIEventIds.NO_CREDENTIALS_REQUEST);
+    }
+
+    @Test
+    public void testMandatoryMissingAtIndex() throws Exception {
+        ActionTestingSupport.assertEvent(executeMandatory("["
+                + "{\"path\":[\"nationalities\"],\"value\":[\"DE\"]},"
+                + "{\"path\":[\"place_of_birth\",\"locality\"],\"value\":\"Reykjavik\"},"
+                + "{\"path\":[\"evidence\"],\"value\":[{\"document\":{\"type\":\"idcard\"}},"
+                + "{\"document\":{\"number\":\"53554554\"}}]}]"),
+                OpenIDVCIEventIds.NO_CREDENTIALS_REQUEST);
+    }
+
+    @Test
+    public void testMandatoryVcSdJwtPrefixedPathRejected() throws Exception {
+        HTTPRequest httpRequest = new HTTPRequest(HTTPRequest.Method.POST, new URI("http://example.com"));
+        httpRequest.setAuthorization("Basic dGVzdDp0ZXN0");
+        httpRequest.setContentType("application/json");
+        httpRequest.setBody(
+                "{\"preAuthorizedCredentials\":{\"GeantIncubatorDiploma2\":[{\"path\":[\"credentialSubject\",\"mail\"],\"value\":\"incubatorUser at example.org\"}]}}");
+        profileRequestCtx.getInboundMessageContext().setMessage(CredentialOfferRequest.parse(httpRequest));
+        ActionTestingSupport.assertEvent(action.execute(requestCtx), OpenIDVCIEventIds.NO_CREDENTIALS_REQUEST);
+    }
 }
diff --git a/openid-vci-impl/src/test/resources/conf/verifiable-credentials-mandatory.json b/openid-vci-impl/src/test/resources/conf/verifiable-credentials-mandatory.json
new file mode 100644
index 0000000..be0df41
--- /dev/null
+++ b/openid-vci-impl/src/test/resources/conf/verifiable-credentials-mandatory.json
@@ -0,0 +1,15 @@
+{
+  "credential_configurations_supported": {
+    "Mandatory": {
+      "format": "dc+sd-jwt",
+      "vct": "Mandatory",
+      "credential_metadata": {
+        "claims": [
+          {"path": ["nationalities", null], "mandatory": true},
+          {"path": ["place_of_birth", "locality"], "mandatory": true},
+          {"path": ["evidence", 0, "document", "number"], "mandatory": true}
+        ]
+      }
+    }
+  }
+}

-- 
To stop receiving notification emails like this one, please contact
the administrator of this repository.


More information about the commits mailing list