[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