[java-idp-plugin-vci] 01/01: Issue credentials not only by configuratin but also by identifier. Limiting issued credentials per scope in code flow. Fix to bind DPoP access tokens also in pre-auth. Replace deprecated Nimbus library calls.
Codeberg
noreply at shibboleth.net
Mon Sep 28 11:00:42 UTC 2026
This is an automated email from the git hooks/post-receive script.
codeberg pushed a commit to branch dev/CredentialConfsAndIdentifiers
in repository java-idp-plugin-vci.
View the commit online:
https://codeberg.org/Shibboleth/java-idp-plugin-vci/commit/99ac562ac720ad2e9ffeb5c15c880a608f05078e
commit 99ac562ac720ad2e9ffeb5c15c880a608f05078e
Author: Janne Lauros <janne.lauros at csc.fi>
AuthorDate: Mon Sep 28 13:59:48 2026 +0300
Issue credentials not only by configuratin but also by identifier. Limiting issued credentials per scope in code flow. Fix to bind DPoP access tokens also in pre-auth. Replace deprecated Nimbus library calls.
---
README.md | 246 ++++++++++++++++++---
...eth-idp-plugin-openid-vci-0.9.0-SNAPSHOT.tar.gz | Bin 368363 -> 395645 bytes
...idp-plugin-openid-vci-0.9.0-SNAPSHOT.tar.gz.asc | 22 +-
.../openidvci/profile/OpenIDVCIEventIds.java | 7 +
.../profile/config/OpenIDVCIConfiguration.java | 16 ++
.../impl/CredentialOfferRequestDecoder.java | 5 +-
.../messaging/context/CredentialOfferContext.java | 12 +-
.../messaging/context/CredentialsContext.java | 46 ++++
.../openidvci/messaging/context/TokenContext.java | 24 ++
.../AuthorizationDetailsLookupFunction.java | 59 ++++-
.../messaging/impl/CredentialDataset.java | 244 ++++++++++++++++++++
.../messaging/impl/CredentialOfferRequest.java | 45 ++--
.../impl/CredentialOfferSuccessResponse.java | 7 +-
.../impl/OpenIDVCIAuthorizationDetail.java | 9 +
.../impl/OpenIDVCICredentialsRequest.java | 14 +-
.../impl/AbstractOpenIDVCIConfiguration.java | 36 +++
.../profile/impl/FilterPotentialCredentials.java | 146 ++++++++++++
.../SetAuthorizationDetailsToResponseContext.java | 168 ++++++++------
.../SetDPoPProofThumbprintToResponseContext.java | 76 +++++++
.../plugin/openidvci/profile/impl/UnwrapGrant.java | 3 +-
.../profile/impl/ValidateCredentialOffering.java | 32 +--
.../profile/impl/ValidateRequestedCredential.java | 189 +++++++++++-----
.../logic/CredentialDetailsLookupFunction.java | 5 +-
.../logic/OpenIDVCITokenManipulationStrategy.java | 12 +-
.../logic/PotentialCredentialLookupFunction.java | 99 +++++++++
.../impl/StatusListAssignmentsRequest.java | 2 +-
.../messaging/impl/StatusListRevokeRequest.java | 2 +-
.../openidvci/storage/CredentialOfferObject.java | 8 +-
.../openid/vci/credentials/credentials-beans.xml | 2 +
.../idp/flows/openid/vci/token/token-beans.xml | 8 +
.../idp/flows/openid/vci/token/token-flow.xml | 5 +
.../AuthorizationDetailsLookupFunctionTest.java | 117 ++++++++++
.../messaging/impl/CredentialDatasetTest.java | 129 +++++++++++
.../messaging/impl/CredentialOfferRequestTest.java | 14 +-
.../impl/OpenIDVCICredentialsRequestTest.java | 6 +-
.../messaging/impl/OpenIDVCITokenRequestTest.java | 4 +-
.../impl/FilterPotentialCredentialsTest.java | 116 ++++++++++
.../openidvci/profile/impl/ParseProofTest.java | 16 +-
...tAuthorizationDetailsToResponseContextTest.java | 126 +++++++++++
...etDPoPProofThumbprintToResponseContextTest.java | 96 ++++++++
.../impl/ValidateCredentialOfferingTest.java | 45 +++-
.../impl/ValidateRequestedCredentialTest.java | 226 +++++++++++++++++++
.../storage/CredentialOfferCacheTest.java | 2 +-
.../storage/CredentialOfferObjectTest.java | 4 +-
.../conf/verifiable-credentials-scoped.json | 24 ++
45 files changed, 2212 insertions(+), 262 deletions(-)
diff --git a/README.md b/README.md
index 3d12a8a..30ee58e 100644
--- a/README.md
+++ b/README.md
@@ -40,6 +40,9 @@ limitations, **No** is not built and **No, by design** is left out on purpose.
### [OpenID for Verifiable Credential Issuance 1.0](https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html)
+This is the base. The profile tables below list only what the profile requires in addition to it.
+Endpoints, metadata and error responses the specification mandates are not listed.
+
| Feature | Status |
|---|---|
| Pre-Authorized Code Flow | Yes |
@@ -52,25 +55,21 @@ limitations, **No** is not built and **No, by design** is left out on purpose.
| Authorization request with `scope` | Yes |
| Authorization request with `authorization_details` | Partial |
| PAR and PKCE | Yes |
-| Token endpoint, both grants | Yes |
| Refresh tokens | No |
-| Client authentication | Yes |
| DPoP | Yes |
+| Wallet Attestation, `attest_jwt_client_auth` | Yes |
| Nonce endpoint | Yes |
| Credential request by `credential_configuration_id` or `credential_identifier` | Yes |
| Proof type `jwt` | Yes |
-| Key attestation in a `jwt` proof | Yes |
+| Key attestation in a `jwt` proof, `key_attestations_required` | Yes |
| Proof types `di_vp` and `attestation` | No |
| Batch issuance | Yes |
| Credential Response encryption | No |
| Credential Request encryption | No |
| Deferred Credential Endpoint | No |
| Notification Endpoint | No |
-| Credential and token error responses | Yes |
-| Credential Issuer metadata | Yes |
| Signed Credential Issuer metadata | Yes |
| Localised metadata by `Accept-Language` | No |
-| Wallet Attestation, `attest_jwt_client_auth` | Yes |
| Format `dc+sd-jwt` | Yes |
| Format `vc+sd-jwt` | Yes |
| Formats `jwt_vc_json`, `jwt_vc_json-ld`, `ldp_vc`, `mso_mdoc` | No |
@@ -79,23 +78,16 @@ limitations, **No** is not built and **No, by design** is left out on purpose.
### [Decentralized Identity Interop Profile 5](https://fidescommunity.github.io/DIIP/)
+Requirements in addition to OpenID for Verifiable Credential Issuance 1.0 above.
+
| Requirement | Status |
|---|---|
-| SD-JWT VC | Yes |
| W3C VCDM 2.0 secured with SD-JWT | Partial |
| ES256 | Yes |
| Issuer identifier `did:jwk` | Yes |
| Issuer identifier `did:web` | Yes |
-| Holder identifier `did:jwk` | Yes |
-| Holder identifier `did:web` | No |
-| Pre-Authorized Code Flow and Authorization Code Flow | Yes |
-| `tx_code` | Yes |
-| PKCE `S256` and PAR | Yes |
| Issuer initiated, same device and cross device | Yes |
-| Immediate issuance | Yes |
-| `scope` | Yes |
-| `authorization_details` | Partial |
-| Proof type `jwt` | Yes |
+| PKCE `S256` | Yes |
| `cnf` as DID URL | Yes |
| Validity, `nbf` `exp` `validFrom` `validUntil` | Yes |
| Token Status List | Yes |
@@ -103,25 +95,22 @@ limitations, **No** is not built and **No, by design** is left out on purpose.
### [OpenID4VC High Assurance Interoperability Profile 1.0](https://openid.net/specs/openid4vc-high-assurance-interoperability-profile-1_0-final.html)
-| Feature | Status |
+Requirements in addition to OpenID for Verifiable Credential Issuance 1.0 above.
+
+| Requirement | Status |
|---|---|
-| `dc+sd-jwt` | Yes |
| Issuer key as `x5c` chain in the credential | Yes |
| `x5c` in Status List Token | Yes |
| `x5c` in signed Credential Issuer metadata | Yes |
| ES256 | Yes |
-| Authorization Code Flow with PAR and PKCE `S256` | Yes |
+| PAR and PKCE `S256` required | Yes |
| `iss` in authorization response, RFC 9207 | Yes |
-| DPoP | Yes |
-| Wallet Attestation at PAR and token endpoint | Yes |
-| Key attestation, `key_attestations_required` | Yes |
-| Proof type `attestation` | No |
+| DPoP required | Yes |
+| Wallet Attestation required at PAR and token endpoint | Yes |
| Holder key in `cnf` `jwk` member | Partial |
-| Pre-Authorized Code Flow | Yes |
| Same device and cross device offers | Partial |
| Batch issuance advertised by `batch_credential_issuance` | Yes |
| Unique, unpredictable status list index | Yes |
-| Refresh tokens, RECOMMENDED | No |
## Plugin Installation
@@ -828,6 +817,14 @@ And you release it to the wallet in *conf/attribute-filter.xml*. This flow reads
</details>
+The token endpoint of the code flow keeps only the credentials the wallet was authorized for: a
+configuration whose `scope` in the
+[Credential Issuer metadata](https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-issuer-metadata-p) is in the granted scope, see
+[Using scope Parameter](https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-using-scope-parameter-to-re), or one the wallet asked with
+[`authorization_details`](https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-using-authorization-details). Everything else released by the filter is dropped from
+the access token. The filter can be narrower than that, for example per scope or per client, but it
+cannot widen it.
+
#### Status List
**File(s):** *conf/relying-party.xml*, *conf/openid-vci.properties*
@@ -1679,6 +1676,202 @@ is another key than the one of the credential.
The whole *conf/relying-party.xml* is in [Relying party configuration](#relying-party-configuration)
and the whole *etc/jetty-rewrite-rules.xml* in [Discovery documents](#discovery-documents).
+## Requesting a credential by configuration or by identifier
+
+**File(s):** *metadata/verifiable-credentials.json*, *metadata/oidc-client.json*, *conf/relying-party.xml*
+**Format:** JSON, Native Spring
+
+A wallet names the credential it wants in the
+[Credential Request](https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-request) in one of two ways, see
+[Section 3.3.4](https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-identifying-credentials-bei) and the [Terminology](https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#terminology):
+
+- `credential_configuration_id`, a key of `credential_configurations_supported` in your
+ [Credential Issuer metadata](https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-issuer-metadata-p). It names the kind of
+ credential.
+- `credential_identifier`, one of the `credential_identifiers` this issuer returned in the
+ [token response](https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-successful-token-response). It names one Credential Dataset of a
+ configuration.
+
+The wallet uses `credential_identifier` when the token response has `credential_identifiers`, and
+`credential_configuration_id` otherwise. By default nothing needs configuring, the simple path of
+[First installation](#first-installation-by-example) uses `credential_configuration_id` only.
+
+What you control:
+
+- **What a code flow wallet may ask for.** The wallet asks a credential by
+ [`scope`](https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-using-scope-parameter-to-re) or by
+ [`authorization_details`](https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-using-authorization-details). For `scope`, give the
+ configuration a `scope` in *metadata/verifiable-credentials.json* and allow it for the client in
+ *metadata/oidc-client.json*. The access token covers only the configurations whose `scope` was
+ granted and those the wallet named in `authorization_details`, whatever else the attribute filter
+ released. A configuration without `scope` can be asked only with `authorization_details`.
+- **When identifiers are issued.** The token response has `credential_identifiers` for a
+ configuration the wallet asked with `authorization_details`, in the authorization request, also a
+ pushed one, or in the token request, and for an authorized configuration that has several
+ credentials for the user, see
+ [Several credentials of one configuration](#several-credentials-of-one-configuration). Otherwise
+ it has none, and most wallets, which use `credential_configuration_id` only, work as they are.
+- **Wallets that ignore the identifiers.** Once identifiers were issued, the credential endpoint
+ accepts only those and refuses `credential_configuration_id`, as
+ [Section 8.2](https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-request) requires. Some wallets ask with
+ `authorization_details` and still send `credential_configuration_id`. To accept that, set
+ `allowCredentialConfigurationIdWithIdentifiers` on the credentials profile of those wallets. It
+ does not help with a configuration of several credentials, there the configuration id cannot tell
+ which one.
+
+```xml
+<bean parent="RelyingPartyByName" c:relyingPartyIds="offer-caller/wallet">
+ <property name="profileConfigurations">
+ <list>
+ <bean parent="OpenID.VCI.Token"
+ p:accessTokenClaimsSetManipulationStrategy-ref="openidvci.TokenManipulationStrategy"
+ p:tokenEndpointAuthMethods="#{{'none'}}" />
+ <bean parent="OpenID.VCI.Credentials" p:allowCredentialConfigurationIdWithIdentifiers="true" />
+ </list>
+ </property>
+</bean>
+```
+
+## Several credentials of one configuration
+
+**File(s):** *metadata/verifiable-credentials.json*, *conf/attribute-resolver.xml*
+**Format:** Native Spring, JSON
+
+Check that your wallets support this before you configure several datasets. The wallet has to read
+`credential_identifiers` from the token response and request each credential by
+`credential_identifier`. A wallet that requests by `credential_configuration_id` only gets
+`invalid_credential_request` and no credential. Support for this varies between wallets and
+wallet versions. In our own tests the EUDI reference wallet libraries handled it and some other
+wallets did not.
+
+One Credential Configuration may be issued more than once for the same user. A user with a
+Master of Science in Computer Science and another in Mathematics gets two credentials of one
+`UniversityDegree` configuration:
+
+<details>
+<summary>Example snippet of metadata/verifiable-credentials.json</summary>
+
+```json
+{
+ "credential_configurations_supported": {
+ "UniversityDegree": {
+ "format": "dc+sd-jwt",
+ "vct": "UniversityDegree",
+ "scope": "UniversityDegree",
+ "cryptographic_binding_methods_supported": ["did:jwk"],
+ "proof_types_supported": {
+ "jwt": {
+ "proof_signing_alg_values_supported": ["ES256"]
+ }
+ },
+ "credential_metadata": {
+ "display": [
+ { "name": "University Degree", "locale": "en" }
+ ],
+ "claims": [
+ { "path": ["degree"], "display": [{ "name": "Degree", "locale": "en" }] },
+ { "path": ["field"], "display": [{ "name": "Field", "locale": "en" }] },
+ { "path": ["givenName"], "display": [{ "name": "Given names", "locale": "en" }] },
+ { "path": ["familyName"], "display": [{ "name": "Family name", "locale": "en" }] }
+ ]
+ }
+ }
+ }
+}
+```
+
+</details>
+
+The metadata has `UniversityDegree` once, the plugin issues it twice with different claim values.
+
+For a single credential you give the claims as one list of `path` and `value`. For two credentials
+of one configuration you give two lists and put an id next to each, the `dataset_id`, so the
+plugin can tell them apart. You choose the ids, here `cs` and `math`. The specification calls the
+claims of one credential a Credential Dataset, see
+[Terminology](https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#terminology).
+How the datasets reach the plugin is shown below for the pre-authorized code flow and the
+authorization code flow.
+
+The token response has one identifier per dataset in `credential_identifiers`, see
+[Successful Token Response](https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-successful-token-response).
+The identifier is the configuration id and the `dataset_id` joined with `#`:
+
+```json
+"authorization_details": [{
+ "type": "openid_credential",
+ "credential_configuration_id": "UniversityDegree",
+ "credential_identifiers": ["UniversityDegree#cs", "UniversityDegree#math"]
+}]
+```
+
+The wallet then asks for each with `credential_identifier` in the
+[Credential Request](https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-request)
+and ends up with two `UniversityDegree` credentials, Computer Science and Mathematics. The token
+response has the identifiers also when the wallet did not ask with `authorization_details`, as
+they are the only way to address one of the datasets.
+
+### Pre-authorized code flow
+
+The value of a credential configuration id in the offer request is a list of datasets instead of
+the claims:
+
+```json
+{
+ "preAuthorizedCredentials": {
+ "UniversityDegree": [
+ {
+ "dataset_id": "cs",
+ "claims": [
+ { "path": ["degree"], "value": "Master of Science" },
+ { "path": ["field"], "value": "Computer Science" },
+ { "path": ["givenName"], "value": "Gemma" },
+ { "path": ["familyName"], "value": "Erasmus" }
+ ]
+ },
+ {
+ "dataset_id": "math",
+ "claims": [
+ { "path": ["degree"], "value": "Master of Science" },
+ { "path": ["field"], "value": "Mathematics" },
+ { "path": ["givenName"], "value": "Gemma" },
+ { "path": ["familyName"], "value": "Erasmus" }
+ ]
+ }
+ ]
+ }
+}
+```
+
+Each dataset needs a `dataset_id` of its own. The offer lists `UniversityDegree` once in
+`credential_configuration_ids`.
+
+### Authorization code flow
+
+The attribute has one value per dataset, and the encoder puts them in an array:
+
+```xml
+<AttributeDefinition id="UniversityDegrees" xsi:type="ScriptedAttribute">
+ <InputAttributeDefinition ref="givenName" />
+ <InputAttributeDefinition ref="sn" />
+ <Script><![CDATA[
+ var degree = function(id, field) {
+ return '{"dataset_id":"' + id + '","claims":['
+ + '{"path":["degree"],"value":"Master of Science"},'
+ + '{"path":["field"],"value":"' + field + '"},'
+ + '{"path":["givenName"],"value":"' + givenName.getValues().get(0) + '"},'
+ + '{"path":["familyName"],"value":"' + sn.getValues().get(0) + '"}'
+ + ']}';
+ };
+ UniversityDegrees.addValue(degree('cs', 'Computer Science'));
+ UniversityDegrees.addValue(degree('math', 'Mathematics'));
+ ]]></Script>
+ <AttributeEncoder xsi:type="oidc:OIDCString" name="UniversityDegree" asArray="true" />
+</AttributeDefinition>
+```
+
+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`.
+
## Reference
### Profile configuration settings
@@ -1719,7 +1912,7 @@ Request, a `POST` of `application/json`:
| Name | Type | Description |
|---|---|---|
-| `preAuthorizedCredentials` | Object | Required. Keyed with credential configuration id, each an array of `{"path": [...], "value": ...}`. |
+| `preAuthorizedCredentials` | Object | Required. Keyed with credential configuration id, each an array of `{"path": [...], "value": ...}`, or a list of datasets `{"dataset_id": ..., "claims": [...]}`. See [Several credentials of one configuration](#several-credentials-of-one-configuration). |
| `txCode` | Object | `value`, and hints `input_mode` (`numeric` or `text`), `length` and `description`. `value` is not in the offer. |
| `client_id` | String | Only where unauthenticated callers are accepted. |
@@ -1752,6 +1945,7 @@ Endpoint `/idp/profile/openid/vci/credentials`, profile ID `http://geant.org/ns/
| `proofNonceGenerator` | Function<ProfileRequestContext,String> | **openidvci.DefaultOpenIDVCINonceGenerator** | Nonce a key proof has to carry. Same generator as in **OpenID.VCI.Nonce**. `#{null}` in both for no nonce. |
| `batchSize` | Integer | `2` | Largest number of key proofs in one request. Not read when the Credential Issuer metadata has `batch_credential_issuance`, its `batch_size` is used. Property `openidvci.batchSize`. |
| `keyAttestationClaimsValidator` | ClaimsValidator | bean **openidvci.KeyAttestationClaimsValidator** if defined | Validation of key attestation claims, `nonce` among them. |
+| `allowCredentialConfigurationIdWithIdentifiers` | Boolean | `false` | Accept `credential_configuration_id` in a Credential Request although credential identifiers were issued. See [Requesting a credential by configuration or by identifier](#requesting-a-credential-by-configuration-or-by-identifier). |
| `dpopProofNonceGenerator` | Function | | Of the OP plugin. `#{null}` for no DPoP nonce. |
| `inboundInterceptorFlows` | List<String> | | Interceptor flows run after the request is decoded, before authentication. |
| `outboundInterceptorFlows` | List<String> | | Interceptor flows run before the response is sent, also for an error response. |
diff --git a/download/shibboleth-idp-plugin-openid-vci-0.9.0-SNAPSHOT.tar.gz b/download/shibboleth-idp-plugin-openid-vci-0.9.0-SNAPSHOT.tar.gz
index 4b265b8..859407b 100644
Binary files a/download/shibboleth-idp-plugin-openid-vci-0.9.0-SNAPSHOT.tar.gz and b/download/shibboleth-idp-plugin-openid-vci-0.9.0-SNAPSHOT.tar.gz differ
diff --git a/download/shibboleth-idp-plugin-openid-vci-0.9.0-SNAPSHOT.tar.gz.asc b/download/shibboleth-idp-plugin-openid-vci-0.9.0-SNAPSHOT.tar.gz.asc
index c731386..cd90f78 100644
--- a/download/shibboleth-idp-plugin-openid-vci-0.9.0-SNAPSHOT.tar.gz.asc
+++ b/download/shibboleth-idp-plugin-openid-vci-0.9.0-SNAPSHOT.tar.gz.asc
@@ -1,14 +1,14 @@
-----BEGIN PGP SIGNATURE-----
-iQHPBAABCAA5FiEEoCV/y8Vr4Y+djZGw+mRLGOJQ2wsFAmqzzxcbFIAAAAAABAAO
-bWFudTIsMi41KzEuMTIsMCwzAAoJEPpkSxjiUNsLsiEL/16tF0mVQuOOdoua1KN4
-EVWAnY/k2drQb0mRogNqcKWeWbiof81wjjTFBjonlXxH8dAMyUf2M1AQKLYWpkNC
-lSYLZ0yNSyOuCw3bPC2dFmMlxbMWQKuiaRqg2WiYpQngNlMObz8+mIOHSbqrM3OH
-NEU7RizpMP9XgHIDHQ6+E6vwp2BE2sIFlwwFyvxCP9sQcp24vUhD/VGbPhFL9Llq
-SMRByIRpz+TIsUWlZsnVb+1IqSIIVbmi6FzbRM7Jkx7NLjXWkPOchw7cVx7zeLYl
-XGh8MS/675nk31qBBFt5658sAjfGisuJNrN7CfZ17/egR/LkjzTkpsoR2RPGmSrl
-RsJfuMCuwP0phBSFAQ1O8aLNA4qlwfWqzYd+zNc6AUJTWdb4jZgFqn0Wf5l9gOav
-bay/S1bPdfO6GnJjhB4kE6qz4i/uVVs5Lv+XU7sSFKwHFiW6J8GNe5mJz0/ffkWN
-fqCsA83DDKnYXACmMW+l1cc/KmUr0Aban+z5LPj9uFSUOQ==
-=8Vwt
+iQHPBAABCAA5FiEEoCV/y8Vr4Y+djZGw+mRLGOJQ2wsFAmq6R+wbFIAAAAAABAAO
+bWFudTIsMi41KzEuMTIsMCwzAAoJEPpkSxjiUNsLw4IL/1X7bEzE4Bps1vJeSvnP
+6y1HM/timTodH2jgad1lfnqo4ybbvwUs1z0AJKyvNB13SddCf9rBD37dLW5jaGuf
+e+1/3/0Vbg3fx9JwsVjBpXNwuwGMpbNWXM05hwNE0celXewsqW3bP9q75HTKUQSK
+Hia4S6B5v5CzWalC2WTCSMofrTNlDc4L028rzh8kUd1WTp7C18f9j5sRryNbHbRy
+B+2WaAMPiU4H1sCK2Z2mVIdyI7xfh92TRDpISN8BBDyM/a93rb2VSNvtllzXyacP
+GwVRZvl8R+d/en27K42MYmchhFeVn4cOKPowBcwRsplqfyD7b7cMrpTVLlSEtFAX
+SjFCsHzDW/a95eG5h2zfL/KVW8JKCZkTTZE4yco10grINyiJyKc4V9Du8fnryTSV
+P/+ahTmBt/Jo1fbye7goI2mZJMx2bNvc9+EjTaTeQ8ee4hrOiRPLYpbfy/V/WpO1
+OjvJWVqEnSHcBGnCCdy4tQsNATfaV/GofOg02R/L4fxg+g==
+=2bbK
-----END PGP SIGNATURE-----
diff --git a/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/OpenIDVCIEventIds.java b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/OpenIDVCIEventIds.java
index 41db26f..828094d 100644
--- a/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/OpenIDVCIEventIds.java
+++ b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/OpenIDVCIEventIds.java
@@ -64,6 +64,13 @@ public final class OpenIDVCIEventIds {
@NotEmpty
public static final String NO_CREDENTIALS_FOR_REQUEST = "NoCredentialsForRequest";
+ /**
+ * ID of event returned if the credential identifier of the request was not issued.
+ */
+ @Nonnull
+ @NotEmpty
+ public static final String UNKNOWN_CREDENTIAL_IDENTIFIER = "UnknownCredentialIdentifier";
+
/**
* Parsing of credential failed.
*/
diff --git a/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/config/OpenIDVCIConfiguration.java b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/config/OpenIDVCIConfiguration.java
index e0ebaac..bc8b482 100644
--- a/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/config/OpenIDVCIConfiguration.java
+++ b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/config/OpenIDVCIConfiguration.java
@@ -202,4 +202,20 @@ public interface OpenIDVCIConfiguration extends ConditionalProfileConfiguration
Function<ProfileRequestContext, String> getProofNonceGenerator(
@Nullable final ProfileRequestContext profileRequestContext);
+ /**
+ * Get whether a Credential Request may name a credential configuration id when credential identifiers were
+ * issued.
+ *
+ * <p>
+ * Defaults to false.
+ * </p>
+ *
+ * @param profileRequestContext profile request context
+ *
+ * @return whether a credential configuration id is accepted when credential identifiers were issued
+ */
+ @ConfigurationSetting(name = "allowCredentialConfigurationIdWithIdentifiers")
+ boolean isAllowCredentialConfigurationIdWithIdentifiers(
+ @Nullable final ProfileRequestContext profileRequestContext);
+
}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/decoding/impl/CredentialOfferRequestDecoder.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/decoding/impl/CredentialOfferRequestDecoder.java
index ef28f28..713e35c 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/decoding/impl/CredentialOfferRequestDecoder.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/decoding/impl/CredentialOfferRequestDecoder.java
@@ -17,12 +17,13 @@
package org.geant.shibboleth.plugin.openidvci.decoding.impl;
import java.io.IOException;
+import java.util.List;
import java.util.Map;
import javax.annotation.Nonnull;
import org.geant.shibboleth.plugin.openidvci.messaging.impl.CredentialOfferRequest;
-import org.geant.shibboleth.plugin.openidvci.messaging.impl.CredentialOfferRequestedCredential;
+import org.geant.shibboleth.plugin.openidvci.messaging.impl.CredentialDataset;
import org.opensaml.messaging.decoder.MessageDecodingException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -61,7 +62,7 @@ public class CredentialOfferRequestDecoder extends BaseOpenIDVCIRequestDecoder<C
@Override
protected String getMessageToLog(final CredentialOfferRequest message) {
- final Map<String, CredentialOfferRequestedCredential> request = message.getPreAuthorizedCredentials();
+ final Map<String, List<CredentialDataset>> request = message.getPreAuthorizedCredentials();
String requestAsString = null;
if (request != null) {
try {
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/messaging/context/CredentialOfferContext.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/messaging/context/CredentialOfferContext.java
index 1717da6..2307d72 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/messaging/context/CredentialOfferContext.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/messaging/context/CredentialOfferContext.java
@@ -16,12 +16,13 @@
package org.geant.shibboleth.plugin.openidvci.messaging.context;
+import java.util.List;
import java.util.Map;
import javax.annotation.Nullable;
import org.geant.shibboleth.plugin.openidvci.credential.CredentialConfiguration;
-import org.geant.shibboleth.plugin.openidvci.messaging.impl.CredentialOfferRequestedCredential;
+import org.geant.shibboleth.plugin.openidvci.messaging.impl.CredentialDataset;
import org.geant.shibboleth.plugin.openidvci.messaging.impl.CredentialOfferTxCode;
import org.opensaml.messaging.context.BaseContext;
@@ -37,7 +38,7 @@ public class CredentialOfferContext extends BaseContext {
/** Credential offer request content in pre-auth flow. */
@Nullable
- private Map<String, CredentialOfferRequestedCredential> preAuthorizedCredentials;
+ private Map<String, List<CredentialDataset>> preAuthorizedCredentials;
/** Transaction Code. */
@Nullable
@@ -76,8 +77,7 @@ public class CredentialOfferContext extends BaseContext {
* @param credentials offer request content in pre-auth flow
*/
@Nullable
- public void setValidatedPreAuthorizedCredentials(
- @Nullable final Map<String, CredentialOfferRequestedCredential> credentials) {
+ public void setValidatedPreAuthorizedCredentials(@Nullable final Map<String, List<CredentialDataset>> credentials) {
this.preAuthorizedCredentials = credentials;
}
@@ -87,12 +87,10 @@ public class CredentialOfferContext extends BaseContext {
* @return Credential offer request content in pre-auth flow
*/
@Nullable
- public Map<String, CredentialOfferRequestedCredential> getValidatedPreAuthorizedCredentials() {
+ public Map<String, List<CredentialDataset>> getValidatedPreAuthorizedCredentials() {
return preAuthorizedCredentials;
}
-
-
/**
* Get Transaction Code.
*
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/messaging/context/CredentialsContext.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/messaging/context/CredentialsContext.java
index 3e5777e..548c4fa 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/messaging/context/CredentialsContext.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/messaging/context/CredentialsContext.java
@@ -46,6 +46,14 @@ public class CredentialsContext extends BaseContext {
@Nullable
private String credentialIdentifier;
+ /** Credential configuration id of the final credential. */
+ @Nullable
+ private String credentialConfigurationId;
+
+ /** Dataset id of the final credential. */
+ @Nullable
+ private String datasetId;
+
/** Validated proofs of wallet. */
@Nullable
private List<SignedJWT> proofs;
@@ -219,6 +227,44 @@ public class CredentialsContext extends BaseContext {
this.credentialIdentifier = credentialIdentifier;
}
+ /**
+ * Get credential configuration id of the final credential.
+ *
+ * @return credential configuration id of the final credential
+ */
+ @Nullable
+ public String getCredentialConfigurationId() {
+ return credentialConfigurationId;
+ }
+
+ /**
+ * Set credential configuration id of the final credential.
+ *
+ * @param id credential configuration id of the final credential
+ */
+ public void setCredentialConfigurationId(@Nullable final String id) {
+ credentialConfigurationId = id;
+ }
+
+ /**
+ * Get dataset id of the final credential.
+ *
+ * @return dataset id of the final credential, or null
+ */
+ @Nullable
+ public String getDatasetId() {
+ return datasetId;
+ }
+
+ /**
+ * Set dataset id of the final credential.
+ *
+ * @param id dataset id of the final credential
+ */
+ public void setDatasetId(@Nullable final String id) {
+ datasetId = id;
+ }
+
/**
* Get credential shell(s) per proof.
*
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/messaging/context/TokenContext.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/messaging/context/TokenContext.java
index c81d2f5..cf85d79 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/messaging/context/TokenContext.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/messaging/context/TokenContext.java
@@ -17,6 +17,7 @@
package org.geant.shibboleth.plugin.openidvci.messaging.context;
import java.util.List;
+import java.util.Map;
import javax.annotation.Nullable;
@@ -50,6 +51,10 @@ public class TokenContext extends BaseContext {
@Nullable
private List<OpenIDVCIAuthorizationDetail> responseAuthorizationDetails;
+ /** Credential identifiers of the token response keyed with the credential dataset they identify. */
+ @Nullable
+ private Map<String, Object> issuedCredentialIdentifiers;
+
/**
* Get the Credential Offer being redeemed.
*
@@ -125,5 +130,24 @@ public class TokenContext extends BaseContext {
this.responseAuthorizationDetails = responseAuthorizationDetails;
}
+ /**
+ * Get the credential identifiers of the token response keyed with the credential dataset they identify.
+ *
+ * @return issued credential identifiers, or null
+ */
+ @Nullable
+ public Map<String, Object> getIssuedCredentialIdentifiers() {
+ return issuedCredentialIdentifiers;
+ }
+
+ /**
+ * Set the credential identifiers of the token response keyed with the credential dataset they identify.
+ *
+ * @param identifiers issued credential identifiers
+ */
+ public void setIssuedCredentialIdentifiers(@Nullable final Map<String, Object> identifiers) {
+ issuedCredentialIdentifiers = identifiers;
+ }
+
}
\ No newline at end of file
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/messaging/context/navigate/AuthorizationDetailsLookupFunction.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/messaging/context/navigate/AuthorizationDetailsLookupFunction.java
index 6f8ad27..d859b63 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/messaging/context/navigate/AuthorizationDetailsLookupFunction.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/messaging/context/navigate/AuthorizationDetailsLookupFunction.java
@@ -16,9 +16,11 @@
package org.geant.shibboleth.plugin.openidvci.messaging.context.navigate;
+import java.text.ParseException;
import java.util.List;
import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
import org.geant.shibboleth.plugin.openidvci.messaging.impl.OpenIDVCIAuthorizationDetail;
import org.geant.shibboleth.plugin.openidvci.messaging.impl.OpenIDVCITokenRequest;
@@ -30,20 +32,25 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.nimbusds.jwt.JWT;
import com.nimbusds.oauth2.sdk.AuthorizationRequest;
import net.shibboleth.idp.plugin.oidc.op.messaging.context.OIDCAuthenticationResponseContext;
/**
* A function that returns list of {@link OpenIDVCIAuthorizationDetail} parsed
- * from from 1. {@link AuthorizationRequest}, 2. {@link OpenIDVCITokenRequest}
- * or from claim encoded to 3. authorization code grant.
- *
- * <p>TODO Locate details also in PAR and or Request Object case.</p>
+ * from from 1. {@link AuthorizationRequest} or its request object, 2.
+ * {@link OpenIDVCITokenRequest} or from claim encoded to 3. authorization code
+ * grant.
*/
public class AuthorizationDetailsLookupFunction
implements ContextDataLookupFunction<ProfileRequestContext, List<OpenIDVCIAuthorizationDetail>> {
+ /** Name of the authorization details parameter. */
+ @Nonnull
+ private static final String AUTHORIZATION_DETAILS = "authorization_details";
+
/** Class logger. */
@Nonnull
private final Logger log = LoggerFactory.getLogger(AuthorizationDetailsLookupFunction.class);
@@ -57,6 +64,10 @@ public class AuthorizationDetailsLookupFunction
}
if (input.getInboundMessageContext().getMessage() instanceof AuthorizationRequest request) {
try {
+ final JWT requestObject = lookupRequestObject(input);
+ if (requestObject != null) {
+ return parse(requestObject);
+ }
return OpenIDVCIAuthorizationDetail.parse(request.toHTTPRequest());
} catch (final JsonProcessingException e) {
log.warn("Failed parsing authorization_details", e);
@@ -91,4 +102,44 @@ public class AuthorizationDetailsLookupFunction
return null;
}
// Checkstyle: CyclomaticComplexity|ReturnCount ON
+
+ /**
+ * Get the request object of the authorization request, pushed or by value.
+ *
+ * @param input profile request context
+ * @return request object, or null if there is none
+ */
+ @Nullable
+ private JWT lookupRequestObject(@Nonnull final ProfileRequestContext input) {
+ final MessageContext msgCtx = input.getOutboundMessageContext();
+ if (msgCtx == null) {
+ return null;
+ }
+ final OIDCAuthenticationResponseContext oidcCtx = msgCtx.getSubcontext(OIDCAuthenticationResponseContext.class);
+ return oidcCtx == null ? null : oidcCtx.getRequestObject();
+ }
+
+ /**
+ * Parse authorization details from a request object.
+ *
+ * @param requestObject request object
+ * @return authorization details, or null if the request object has none
+ * @throws JsonProcessingException if parsing fails
+ */
+ @Nullable
+ private List<OpenIDVCIAuthorizationDetail> parse(@Nonnull final JWT requestObject) throws JsonProcessingException {
+ final Object details;
+ try {
+ details = requestObject.getJWTClaimsSet().getClaim(AUTHORIZATION_DETAILS);
+ } catch (final ParseException e) {
+ log.warn("Failed parsing request object", e);
+ return null;
+ }
+ if (details == null) {
+ return null;
+ }
+ final String serialized = details instanceof String value ? value
+ : new ObjectMapper().writeValueAsString(details);
+ return serialized.isEmpty() ? null : OpenIDVCIAuthorizationDetail.parse(serialized);
+ }
}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/messaging/impl/CredentialDataset.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/messaging/impl/CredentialDataset.java
new file mode 100644
index 0000000..bd97344
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/messaging/impl/CredentialDataset.java
@@ -0,0 +1,244 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.geant.shibboleth.plugin.openidvci.messaging.impl;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonInclude.Include;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.nimbusds.oauth2.sdk.ParseException;
+
+/**
+ * Credential Dataset, the claims of one credential of a credential
+ * configuration.
+ */
+ at JsonInclude(Include.NON_NULL)
+public class CredentialDataset {
+
+ /** Member name of the dataset id. */
+ @Nonnull
+ public static final String DATASET_ID = "dataset_id";
+
+ /** Member name of the claims. */
+ @Nonnull
+ public static final String CLAIMS = "claims";
+
+ /** Member name of the requested claims of a requested credential. */
+ @Nonnull
+ private static final String REQUESTED_CREDENTIAL = "requestedCredential";
+
+ /** Dataset id. */
+ @Nullable
+ private final String datasetId;
+
+ /** Claims of the dataset. */
+ @Nonnull
+ private final CredentialOfferRequestedCredential claims;
+
+ /**
+ * Constructor.
+ *
+ * @param id dataset id, may be null for the only dataset of a credential
+ * configuration
+ * @param content claims of the dataset
+ */
+ public CredentialDataset(@Nullable final String id, @Nonnull final CredentialOfferRequestedCredential content) {
+ datasetId = id;
+ claims = content;
+ }
+
+ /**
+ * Constructor.
+ *
+ * @param id dataset id
+ * @param requestedClaims claims of the dataset
+ */
+ @JsonCreator
+ private CredentialDataset(@JsonProperty(DATASET_ID) @Nullable final String id,
+ @JsonProperty(CLAIMS) @Nonnull final List<CredentialOfferRequestedClaim> requestedClaims) {
+ this(id, new CredentialOfferRequestedCredential(requestedClaims));
+ }
+
+ /**
+ * Get dataset id.
+ *
+ * @return dataset id, or null
+ */
+ @JsonProperty(DATASET_ID)
+ @Nullable
+ public String getDatasetId() {
+ return datasetId;
+ }
+
+ /**
+ * Get content of the dataset.
+ *
+ * @return content of the dataset
+ */
+ @JsonIgnore
+ @Nonnull
+ public CredentialOfferRequestedCredential getContent() {
+ return claims;
+ }
+
+ /**
+ * Get requested claims of the dataset.
+ *
+ * @return requested claims of the dataset
+ */
+ @JsonProperty(CLAIMS)
+ @Nonnull
+ public List<CredentialOfferRequestedClaim> getRequestedClaims() {
+ return claims.getRequestedCredential();
+ }
+
+ /**
+ * Get the credential identifier of a dataset of a credential configuration.
+ *
+ * @param configurationId credential configuration id
+ * @param dataset dataset
+ * @return credential identifier
+ */
+ @Nonnull
+ public static String credentialIdentifier(@Nonnull final String configurationId,
+ @Nonnull final CredentialDataset dataset) {
+ return dataset.getDatasetId() == null ? configurationId : configurationId + "#" + dataset.getDatasetId();
+ }
+
+ /**
+ * Serialize datasets to json.
+ *
+ * @param datasets datasets to serialize
+ * @return datasets as a json string
+ * @throws JsonProcessingException if serialization fails
+ */
+ @Nonnull
+ public static String serialize(@Nonnull final List<CredentialDataset> datasets) throws JsonProcessingException {
+ return new ObjectMapper().writeValueAsString(datasets);
+ }
+
+ /**
+ * Parse the datasets of a credential configuration.
+ *
+ * @param value claims of one dataset, or a list of datasets, as parsed or as
+ * json
+ * @return datasets
+ * @throws ParseException if the value cannot be parsed or datasets are not
+ * uniquely identified
+ */
+ @Nonnull
+ public static List<CredentialDataset> parse(@Nullable final Object value) throws ParseException {
+ final Object parsed = value instanceof String json ? readJson(json) : value;
+ final List<CredentialDataset> datasets = new ArrayList<>();
+ try {
+ if (parsed instanceof List<?> values && !values.isEmpty()
+ && values.stream().allMatch(CredentialDataset::isDataset)) {
+ for (final Object dataset : values) {
+ datasets.add(parseDataset(dataset));
+ }
+ } else {
+ datasets.add(parseDataset(parsed));
+ }
+ } catch (final IllegalArgumentException e) {
+ throw new ParseException("Invalid credential dataset: " + e.getMessage());
+ }
+ validate(datasets);
+ return datasets;
+ }
+
+ /**
+ * Get whether a value is a dataset of a list of datasets.
+ *
+ * @param value value to test
+ * @return whether the value is a dataset
+ */
+ private static boolean isDataset(@Nullable final Object value) {
+ return value instanceof String || value instanceof Map<?, ?> map && map.containsKey(CLAIMS);
+ }
+
+ /**
+ * Parse one dataset.
+ *
+ * @param value dataset, or the claims of a dataset, as parsed or as json
+ * @return dataset
+ * @throws ParseException if the value cannot be parsed
+ */
+ @Nonnull
+ private static CredentialDataset parseDataset(@Nullable final Object value) throws ParseException {
+ final Object parsed = value instanceof String json ? readJson(json) : value;
+ if (parsed instanceof Map<?, ?> map && map.containsKey(CLAIMS)) {
+ final Object id = map.get(DATASET_ID);
+ if (id != null && !(id instanceof String)) {
+ throw new ParseException("Invalid credential dataset: " + DATASET_ID + " must be a string");
+ }
+ return new CredentialDataset((String) id, CredentialOfferRequestedCredential.parse(map.get(CLAIMS)));
+ }
+ if (parsed instanceof Map<?, ?> map && map.containsKey(REQUESTED_CREDENTIAL)) {
+ return new CredentialDataset(null, CredentialOfferRequestedCredential.parse(map.get(REQUESTED_CREDENTIAL)));
+ }
+ return new CredentialDataset(null, CredentialOfferRequestedCredential.parse(parsed));
+ }
+
+ /**
+ * Validate datasets of a credential configuration are uniquely identified.
+ *
+ * @param datasets datasets to validate
+ * @throws ParseException if datasets are not uniquely identified
+ */
+ private static void validate(@Nonnull final List<CredentialDataset> datasets) throws ParseException {
+ if (datasets.size() < 2) {
+ return;
+ }
+ final Set<String> ids = new HashSet<>();
+ for (final CredentialDataset dataset : datasets) {
+ if (dataset.getDatasetId() == null || dataset.getDatasetId().isEmpty()
+ || !ids.add(dataset.getDatasetId())) {
+ throw new ParseException(
+ "Invalid credential datasets: each of several datasets must have an unique " + DATASET_ID);
+ }
+ }
+ }
+
+ /**
+ * Read json.
+ *
+ * @param json json to read
+ * @return parsed json
+ * @throws ParseException if the json cannot be parsed
+ */
+ @Nullable
+ private static Object readJson(@Nonnull final String json) throws ParseException {
+ try {
+ return new ObjectMapper().readValue(json, Object.class);
+ } catch (final JsonProcessingException e) {
+ throw new ParseException("Invalid credential dataset: " + e.getMessage());
+ }
+ }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/messaging/impl/CredentialOfferRequest.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/messaging/impl/CredentialOfferRequest.java
index 257fbb8..6364cb3 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/messaging/impl/CredentialOfferRequest.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/messaging/impl/CredentialOfferRequest.java
@@ -19,6 +19,7 @@ package org.geant.shibboleth.plugin.openidvci.messaging.impl;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.HashMap;
+import java.util.List;
import java.util.Map;
import javax.annotation.Nonnull;
@@ -50,7 +51,7 @@ public class CredentialOfferRequest extends AbstractOptionallyIdentifiedRequest
/** Credential offer request content in pre-auth flow. */
@Nullable
- private final Map<String, CredentialOfferRequestedCredential> preAuthorizedCredentials;
+ private final Map<String, List<CredentialDataset>> preAuthorizedCredentials;
/** Transaction Code. */
@Nullable
@@ -59,16 +60,19 @@ public class CredentialOfferRequest extends AbstractOptionallyIdentifiedRequest
/**
* Constructor.
*
- * @param uri The URI of the endpoint (HTTP or HTTPS) for which the
- * request is intended, {@code null} if not specified (if, for
- * example, the {@link #toHTTPRequest()} method will not be
- * used).
- * @param clientAuth The client authentication, {@code null} if none.
- * @param preAuthorizedCredentials Credentials offered in the pre-authorized code flow.
+ * @param uri The URI of the endpoint (HTTP or HTTPS) for
+ * which the request is intended, {@code null}
+ * if not specified (if, for example, the
+ * {@link #toHTTPRequest()} method will not be
+ * used).
+ * @param clientAuth The client authentication, {@code null} if
+ * none.
+ * @param preAuthorizedCredentials Credentials offered in the pre-authorized
+ * code flow.
* @param credentialOfferTxCode Transaction Code the wallet must present.
*/
public CredentialOfferRequest(@Nullable final URI uri, @Nullable final ClientAuthentication clientAuth,
- @Nullable final Map<String, CredentialOfferRequestedCredential> preAuthorizedCredentials,
+ @Nullable final Map<String, List<CredentialDataset>> preAuthorizedCredentials,
@Nullable final CredentialOfferTxCode credentialOfferTxCode) {
super(uri, clientAuth);
this.preAuthorizedCredentials = preAuthorizedCredentials;
@@ -78,15 +82,19 @@ public class CredentialOfferRequest extends AbstractOptionallyIdentifiedRequest
/**
* Constructor.
*
- * @param uri The URI of the endpoint (HTTP or HTTPS) for which the request
- * is intended, {@code null} if not specified (if, for example,
- * the {@link #toHTTPRequest()} method will not be used).
- * @param clientID The client identifier, {@code null} if not specified.
- * @param preAuthorizedCredentials Credentials offered in the pre-authorized code flow.
+ * @param uri The URI of the endpoint (HTTP or HTTPS) for
+ * which the request is intended, {@code null}
+ * if not specified (if, for example, the
+ * {@link #toHTTPRequest()} method will not be
+ * used).
+ * @param clientID The client identifier, {@code null} if not
+ * specified.
+ * @param preAuthorizedCredentials Credentials offered in the pre-authorized
+ * code flow.
* @param credentialOfferTxCode Transaction Code the wallet must present.
*/
public CredentialOfferRequest(@Nullable final URI uri, @Nullable final ClientID clientID,
- @Nullable final Map<String, CredentialOfferRequestedCredential> preAuthorizedCredentials,
+ @Nullable final Map<String, List<CredentialDataset>> preAuthorizedCredentials,
@Nullable final CredentialOfferTxCode credentialOfferTxCode) {
super(uri, clientID);
this.preAuthorizedCredentials = preAuthorizedCredentials;
@@ -98,7 +106,7 @@ public class CredentialOfferRequest extends AbstractOptionallyIdentifiedRequest
*
* @return credential offer request content in pre-auth flow.
*/
- public Map<String, CredentialOfferRequestedCredential> getPreAuthorizedCredentials() {
+ public Map<String, List<CredentialDataset>> getPreAuthorizedCredentials() {
return preAuthorizedCredentials;
}
@@ -136,7 +144,7 @@ public class CredentialOfferRequest extends AbstractOptionallyIdentifiedRequest
final Map<String, Object> content;
try {
- content = new ObjectMapper().readValue(httpRequest.getQuery(), new TypeReference<Map<String, Object>>() {
+ content = new ObjectMapper().readValue(httpRequest.getBody(), new TypeReference<Map<String, Object>>() {
});
} catch (final IllegalArgumentException | JsonProcessingException e1) {
throw new ParseException(e1.getMessage());
@@ -154,10 +162,9 @@ public class CredentialOfferRequest extends AbstractOptionallyIdentifiedRequest
final Map<String, Object> preAuthorizedCredentialsInRequest = (Map<String, Object>) content
.get("preAuthorizedCredentials");
- final Map<String, CredentialOfferRequestedCredential> preAuthorizedCredentials = new HashMap<>();
+ final Map<String, List<CredentialDataset>> preAuthorizedCredentials = new HashMap<>();
for (final String key : preAuthorizedCredentialsInRequest.keySet()) {
- preAuthorizedCredentials.put(key,
- CredentialOfferRequestedCredential.parse(preAuthorizedCredentialsInRequest.get(key)));
+ preAuthorizedCredentials.put(key, CredentialDataset.parse(preAuthorizedCredentialsInRequest.get(key)));
}
CredentialOfferTxCode credentialOfferTxCode = null;
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/messaging/impl/CredentialOfferSuccessResponse.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/messaging/impl/CredentialOfferSuccessResponse.java
index 73ce78a..2c98ccd 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/messaging/impl/CredentialOfferSuccessResponse.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/messaging/impl/CredentialOfferSuccessResponse.java
@@ -69,11 +69,12 @@ public class CredentialOfferSuccessResponse implements SuccessResponse {
*
* @param issuer Credential Issuer identifier.
* @param preAuthorizedCode Pre-authorized code.
- * @param preAuthCredentials Credentials offered in the pre-authorized code flow.
+ * @param preAuthCredentials Credentials offered in the pre-authorized code
+ * flow.
* @param credentialOfferTxCode Transaction Code the wallet must present.
*/
public CredentialOfferSuccessResponse(@Nonnull final String issuer, @Nonnull final String preAuthorizedCode,
- @Nonnull final Map<String, CredentialOfferRequestedCredential> preAuthCredentials,
+ @Nonnull final Map<String, List<CredentialDataset>> preAuthCredentials,
@Nullable final CredentialOfferTxCode credentialOfferTxCode) {
credentialIssuer = issuer;
@@ -146,7 +147,7 @@ public class CredentialOfferSuccessResponse implements SuccessResponse {
* Build the pre-authorized code grant of a Credential Offer.
*
* @param preauthCode pre-authorized code
- * @param txCode Transaction Code the wallet must present, or null
+ * @param txCode Transaction Code the wallet must present, or null
* @return grant as a map
*/
@SuppressWarnings("unchecked")
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/messaging/impl/OpenIDVCIAuthorizationDetail.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/messaging/impl/OpenIDVCIAuthorizationDetail.java
index c9c97fa..36f7bf9 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/messaging/impl/OpenIDVCIAuthorizationDetail.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/messaging/impl/OpenIDVCIAuthorizationDetail.java
@@ -117,6 +117,15 @@ public class OpenIDVCIAuthorizationDetail {
return claims;
}
+ /**
+ * Get credential identifiers.
+ *
+ * @return credential identifiers
+ */
+ public List<String> getCredentialIdentifiers() {
+ return credentialIdentifiers;
+ }
+
/**
* Serialize object to json.
*
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/messaging/impl/OpenIDVCICredentialsRequest.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/messaging/impl/OpenIDVCICredentialsRequest.java
index 229975c..fa550ee 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/messaging/impl/OpenIDVCICredentialsRequest.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/messaging/impl/OpenIDVCICredentialsRequest.java
@@ -159,17 +159,6 @@ public class OpenIDVCICredentialsRequest extends ProtectedResourceRequest {
credentialResponseEncryption = responseEncryption;
}
- /**
- * Validate request conforms to
- * https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-request.
- */
- private void validate() {
- if (credentialIdentifier != null && credentialConfigurationId != null) {
- throw new IllegalArgumentException(
- "Request must not have both credential_identifier and credential_configuration_id fields");
- }
- }
-
/**
* {@inheritDoc}
*/
@@ -192,7 +181,7 @@ public class OpenIDVCICredentialsRequest extends ProtectedResourceRequest {
final AccessToken accessToken = AccessToken.parse(httpRequest);
final Map<String, Object> payload;
try {
- payload = new ObjectMapper().readValue(httpRequest.getQuery(), new TypeReference<Map<String, Object>>() {
+ payload = new ObjectMapper().readValue(httpRequest.getBody(), new TypeReference<Map<String, Object>>() {
});
} catch (final JsonProcessingException e1) {
throw new ParseException(e1.getMessage(),
@@ -211,7 +200,6 @@ public class OpenIDVCICredentialsRequest extends ProtectedResourceRequest {
(payload.get(CREDENTIAL_CONFIGURATION_ID_KEY) instanceof String value) ? value : null);
request.setProofs((Map<String, Object>) payload.get(PROOFS_KEY));
request.setCredentialResponseEncryption((Map<String, Object>) payload.get(CREDENTIAL_RESPONSE_ENCRYPTION_KEY));
- request.validate();
return request;
}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/config/impl/AbstractOpenIDVCIConfiguration.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/config/impl/AbstractOpenIDVCIConfiguration.java
index 0d6eafd..705181a 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/config/impl/AbstractOpenIDVCIConfiguration.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/config/impl/AbstractOpenIDVCIConfiguration.java
@@ -18,6 +18,7 @@ package org.geant.shibboleth.plugin.openidvci.profile.config.impl;
import java.time.Duration;
import java.util.function.Function;
+import java.util.function.Predicate;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
@@ -33,6 +34,7 @@ import net.shibboleth.shared.annotation.constraint.NotEmpty;
import net.shibboleth.shared.annotation.constraint.Positive;
import net.shibboleth.shared.logic.Constraint;
import net.shibboleth.shared.logic.FunctionSupport;
+import net.shibboleth.shared.logic.PredicateSupport;
/** Base class for the OpenID4VCI profile configurations. */
public abstract class AbstractOpenIDVCIConfiguration extends AbstractOIDCSSOConfiguration
@@ -76,6 +78,10 @@ public abstract class AbstractOpenIDVCIConfiguration extends AbstractOIDCSSOConf
@Nonnull
private Function<ProfileRequestContext, Function<ProfileRequestContext, String>> proofNonceGeneratorLookupStrategy;
+ /** Whether a credential configuration id is accepted when credential identifiers were issued. */
+ @Nonnull
+ private Predicate<ProfileRequestContext> allowCredentialConfigurationIdWithIdentifiersPredicate;
+
/**
* Constructor.
*
@@ -92,6 +98,7 @@ public abstract class AbstractOpenIDVCIConfiguration extends AbstractOIDCSSOConf
proofClaimsValidatorLookupStrategy = FunctionSupport.constant(null);
keyAttestationClaimsValidatorLookupStrategy = FunctionSupport.constant(null);
proofNonceGeneratorLookupStrategy = FunctionSupport.constant(null);
+ allowCredentialConfigurationIdWithIdentifiersPredicate = PredicateSupport.alwaysFalse();
}
/** {@inheritDoc} */
@@ -348,4 +355,33 @@ public abstract class AbstractOpenIDVCIConfiguration extends AbstractOIDCSSOConf
@Nonnull final Function<ProfileRequestContext, Function<ProfileRequestContext, String>> strategy) {
proofNonceGeneratorLookupStrategy = Constraint.isNotNull(strategy, "Lookup strategy cannot be null");
}
+
+ /** {@inheritDoc} */
+ @Override
+ public boolean isAllowCredentialConfigurationIdWithIdentifiers(
+ @Nullable final ProfileRequestContext profileRequestContext) {
+ return allowCredentialConfigurationIdWithIdentifiersPredicate.test(profileRequestContext);
+ }
+
+ /**
+ * Set whether a credential configuration id is accepted when credential identifiers were issued.
+ *
+ * @param flag flag to set
+ */
+ public void setAllowCredentialConfigurationIdWithIdentifiers(final boolean flag) {
+ allowCredentialConfigurationIdWithIdentifiersPredicate =
+ flag ? PredicateSupport.alwaysTrue() : PredicateSupport.alwaysFalse();
+ }
+
+ /**
+ * Set condition for whether a credential configuration id is accepted when credential identifiers were
+ * issued.
+ *
+ * @param condition condition to set
+ */
+ public void setAllowCredentialConfigurationIdWithIdentifiersPredicate(
+ @Nonnull final Predicate<ProfileRequestContext> condition) {
+ allowCredentialConfigurationIdWithIdentifiersPredicate =
+ Constraint.isNotNull(condition, "Condition cannot be null");
+ }
}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/FilterPotentialCredentials.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/FilterPotentialCredentials.java
new file mode 100644
index 0000000..262545e
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/FilterPotentialCredentials.java
@@ -0,0 +1,146 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.geant.shibboleth.plugin.openidvci.profile.impl;
+
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Function;
+
+import javax.annotation.Nonnull;
+
+import org.geant.shibboleth.plugin.openidvci.credential.CredentialConfiguration;
+import org.geant.shibboleth.plugin.openidvci.messaging.context.TokenContext;
+import org.geant.shibboleth.plugin.openidvci.messaging.context.navigate.AuthorizationDetailsLookupFunction;
+import org.geant.shibboleth.plugin.openidvci.messaging.impl.OpenIDVCIAuthorizationDetail;
+import org.opensaml.messaging.context.MessageContext;
+import org.opensaml.profile.action.ActionSupport;
+import org.opensaml.profile.action.EventIds;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.slf4j.Logger;
+
+import com.nimbusds.oauth2.sdk.Scope;
+import com.nimbusds.openid.connect.sdk.claims.ClaimsSet;
+
+import net.shibboleth.idp.plugin.oidc.op.messaging.context.OIDCAuthenticationResponseContext;
+import net.shibboleth.shared.annotation.constraint.NonnullBeforeExec;
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.primitive.LoggerFactory;
+
+/**
+ * Action that limits {@link TokenContext#getPotentialCredentials()} to the credential configurations authorized
+ * for the grant, those whose 'scope' is in the granted scope of {@link OIDCAuthenticationResponseContext#getScope()}
+ * and those requested with 'authorization_details'.
+ *
+ * @event {@link EventIds#PROCEED_EVENT_ID}
+ * @event {@link EventIds#INVALID_PROFILE_CTX}
+ * @event {@link EventIds#INVALID_MSG_CTX}
+ */
+public class FilterPotentialCredentials extends AbstractCredentialValidationAction {
+
+ /** Class logger. */
+ @Nonnull
+ private Logger log = LoggerFactory.getLogger(FilterPotentialCredentials.class);
+
+ /** Strategy used to locate the {@link OpenIDVCIAuthorizationDetail} to use. */
+ @Nonnull
+ private Function<ProfileRequestContext, List<OpenIDVCIAuthorizationDetail>> authorizationDetailsStrategy;
+
+ /** Token context. */
+ @NonnullBeforeExec
+ private TokenContext tokenContext;
+
+ /** OIDC response context. */
+ @NonnullBeforeExec
+ private OIDCAuthenticationResponseContext oidcResponseContext;
+
+ /**
+ * Constructor.
+ */
+ public FilterPotentialCredentials() {
+ authorizationDetailsStrategy = new AuthorizationDetailsLookupFunction();
+ }
+
+ /**
+ * Set the strategy used to locate the {@link OpenIDVCIAuthorizationDetail} to use.
+ *
+ * @param strategy lookup strategy
+ */
+ public void setAuthorizationDetailsLookupStrategy(
+ @Nonnull final Function<ProfileRequestContext, List<OpenIDVCIAuthorizationDetail>> strategy) {
+ checkSetterPreconditions();
+ authorizationDetailsStrategy = Constraint.isNotNull(strategy,
+ "AuthorizationDetailsLookupStrategy lookup strategy cannot be null");
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ protected boolean doPreExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+ if (!super.doPreExecute(profileRequestContext)) {
+ return false;
+ }
+ if (profileRequestContext.getInboundMessageContext() == null) {
+ log.error("{} No inbound message context", getLogPrefix());
+ ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
+ return false;
+ }
+ tokenContext = profileRequestContext.getInboundMessageContext().getSubcontext(TokenContext.class);
+ if (tokenContext == null || tokenContext.getPotentialCredentials() == null) {
+ log.debug("{} No potential credentials, nothing to do", getLogPrefix());
+ return false;
+ }
+ final MessageContext outboundMessageCtx = profileRequestContext.getOutboundMessageContext();
+ oidcResponseContext = outboundMessageCtx != null
+ ? outboundMessageCtx.getSubcontext(OIDCAuthenticationResponseContext.class) : null;
+ if (oidcResponseContext == null) {
+ log.error("{} No oidc response context", getLogPrefix());
+ ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_MSG_CTX);
+ return false;
+ }
+ return true;
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+ final Set<String> authorized = new HashSet<>();
+ final List<OpenIDVCIAuthorizationDetail> details = authorizationDetailsStrategy.apply(profileRequestContext);
+ if (details != null) {
+ details.forEach(detail -> authorized.add(detail.getCredentialConfigurationId()));
+ }
+ final Scope scope = oidcResponseContext.getScope();
+ if (scope != null) {
+ for (final Map.Entry<String, CredentialConfiguration> entry : getCredentialConfigurations().entrySet()) {
+ final String configurationScope = entry.getValue().getScope();
+ if (configurationScope != null && scope.contains(configurationScope)) {
+ authorized.add(entry.getKey());
+ }
+ }
+ }
+ final ClaimsSet filtered = new ClaimsSet();
+ tokenContext.getPotentialCredentials().toJSONObject().forEach((name, value) -> {
+ if (authorized.contains(name)) {
+ filtered.setClaim(name, value);
+ } else {
+ log.debug("{} Credential configuration {} is not authorized, removed", getLogPrefix(), name);
+ }
+ });
+ tokenContext.setPotentialCredentials(filtered);
+ }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/SetAuthorizationDetailsToResponseContext.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/SetAuthorizationDetailsToResponseContext.java
index 957b2ea..4a4a78c 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/SetAuthorizationDetailsToResponseContext.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/SetAuthorizationDetailsToResponseContext.java
@@ -17,15 +17,22 @@
package org.geant.shibboleth.plugin.openidvci.profile.impl;
import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
import java.util.List;
+import java.util.Map;
+import java.util.Set;
import java.util.function.Function;
import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
import org.geant.shibboleth.plugin.openidvci.messaging.context.TokenContext;
import org.geant.shibboleth.plugin.openidvci.messaging.context.navigate.AuthorizationDetailsLookupFunction;
+import org.geant.shibboleth.plugin.openidvci.messaging.impl.CredentialDataset;
import org.geant.shibboleth.plugin.openidvci.messaging.impl.OpenIDVCIAuthorizationDetail;
import org.geant.shibboleth.plugin.openidvci.profile.OpenIDVCIEventIds;
+import org.geant.shibboleth.plugin.openidvci.profile.logic.OpenIDVCITokenManipulationStrategy;
import org.opensaml.profile.action.ActionSupport;
import org.opensaml.profile.action.EventIds;
import org.opensaml.profile.context.ProfileRequestContext;
@@ -39,15 +46,10 @@ import net.shibboleth.shared.logic.Constraint;
import net.shibboleth.shared.primitive.LoggerFactory;
/**
- * Action seeks for claims in {@link TokenContext#getPotentialCredentials()}
- * that match requested 'authorization_details' as described in
- * https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-using-authorization-details.
- * Claim is considered a match if the claim name equals to requested
- * credential_configuration_id or is of format
- * [credential_configuration_id]_[postfix]. From matches we form
- * 'authorization_details' for response as described in
- * https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-successful-token-response
- * now containing also 'credential_identifiers'.
+ * Action that forms the 'authorization_details' of the token response, as described in
+ * https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-successful-token-response,
+ * from the credential datasets of {@link TokenContext#getPotentialCredentials()}. Credential identifiers are issued
+ * for the credential configurations requested with 'authorization_details' and for those having several datasets.
*/
public class SetAuthorizationDetailsToResponseContext extends AbstractProfileAction {
@@ -60,7 +62,7 @@ public class SetAuthorizationDetailsToResponseContext extends AbstractProfileAct
private Function<ProfileRequestContext, List<OpenIDVCIAuthorizationDetail>> authorizationDetailsStrategy;
/** Authorization details of the request. */
- @NonnullBeforeExec
+ @Nullable
private List<OpenIDVCIAuthorizationDetail> authorizationDetails;
/** Token context. */
@@ -92,85 +94,115 @@ public class SetAuthorizationDetailsToResponseContext extends AbstractProfileAct
if (!super.doPreExecute(profileRequestContext)) {
return false;
}
+ if (profileRequestContext.getInboundMessageContext() == null) {
+ log.error("{} No inbound message context", getLogPrefix());
+ ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
+ return false;
+ }
tokenContext = profileRequestContext.getInboundMessageContext().getSubcontext(TokenContext.class);
if (tokenContext == null) {
log.error("{} No token context", getLogPrefix());
ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
return false;
}
+ if (tokenContext.getPotentialCredentials() == null) {
+ log.debug("{} No potential credentials, nothing to do", getLogPrefix());
+ return false;
+ }
authorizationDetails = authorizationDetailsStrategy.apply(profileRequestContext);
- if (authorizationDetails == null) {
- setDetailsForPreAuthzAsIfItWasRequested();
+ return true;
+ }
+
+// Checkstyle: CyclomaticComplexity OFF
+ /** {@inheritDoc} */
+ @Override
+ protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+ final Map<String, Object> potentialCredentials;
+ try {
+ potentialCredentials = tokenContext.getPotentialCredentials().toJWTClaimsSet().toJSONObject();
+ } catch (final ParseException e) {
+ log.error("{} Parsing exception. Not able to match request to offering", getLogPrefix(), e);
+ ActionSupport.buildEvent(profileRequestContext, OpenIDVCIEventIds.NO_CREDENTIALS_FOR_REQUEST);
+ return;
}
- if (authorizationDetails == null) {
- log.debug("{} No authorization details, nothing to do", getLogPrefix());
- return false;
+ final Set<String> requested = new LinkedHashSet<>();
+ if (authorizationDetails != null) {
+ authorizationDetails.forEach(detail -> requested.add(detail.getCredentialConfigurationId()));
}
- if (profileRequestContext.getInboundMessageContext() == null) {
- log.error("{} No inbound message context", getLogPrefix());
- ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
- return false;
+ final List<OpenIDVCIAuthorizationDetail> responseDetails = new ArrayList<>();
+ final Map<String, Object> issued = new LinkedHashMap<>();
+ for (final String configurationId : requested) {
+ addDetail(configurationId, datasetsOf(potentialCredentials, configurationId), responseDetails, issued);
+ }
+ if (!requested.isEmpty() && responseDetails.isEmpty()) {
+ log.error("{} No credentials available for request", getLogPrefix());
+ ActionSupport.buildEvent(profileRequestContext, OpenIDVCIEventIds.NO_CREDENTIALS_FOR_REQUEST);
+ return;
+ }
+ for (final String configurationId : potentialCredentials.keySet()) {
+ if (requested.contains(configurationId)) {
+ continue;
+ }
+ final List<CredentialDataset> datasets = datasetsOf(potentialCredentials, configurationId);
+ if (datasets != null && datasets.size() > 1) {
+ addDetail(configurationId, datasets, responseDetails, issued);
+ }
+ }
+ if (!responseDetails.isEmpty()) {
+ tokenContext.setResponseAuthorizationDetails(responseDetails);
+ tokenContext.setIssuedCredentialIdentifiers(issued);
}
- return true;
}
+// Checkstyle: CyclomaticComplexity ON
/**
- * Most likely temporary method. My understanding of the latest version of the
- * specification is that authorization_details needs not to be in Token Response
- * if it was never in any request. This method is now only for development phase
- * inter-operability challenges. Please add a proper switch for this option if
- * it remains.
+ * Add the authorization detail of a credential configuration and record its credential identifiers.
+ *
+ * @param configurationId credential configuration id
+ * @param datasets datasets of the credential configuration
+ * @param responseDetails authorization details of the response
+ * @param issued issued credential identifiers
*/
- private void setDetailsForPreAuthzAsIfItWasRequested() {
- if (tokenContext.getCredentialOfferObject() == null
- || tokenContext.getCredentialOfferObject().getPreAuthorizedCredentials() == null) {
+ private void addDetail(@Nonnull final String configurationId, @Nullable final List<CredentialDataset> datasets,
+ @Nonnull final List<OpenIDVCIAuthorizationDetail> responseDetails,
+ @Nonnull final Map<String, Object> issued) {
+ if (datasets == null || datasets.isEmpty()) {
return;
}
- final List<OpenIDVCIAuthorizationDetail> details = new ArrayList<>();
- tokenContext.getCredentialOfferObject().getPreAuthorizedCredentials().keySet()
- .forEach(key -> details.add(new OpenIDVCIAuthorizationDetail("openid_credential", key, null, null)));
-
- if (details.size() != 0) {
- authorizationDetails = details;
+ final List<String> identifiers = new ArrayList<>();
+ for (final CredentialDataset dataset : datasets) {
+ final String identifier = CredentialDataset.credentialIdentifier(configurationId, dataset);
+ identifiers.add(identifier);
+ final Map<String, Object> issuedRecord = new LinkedHashMap<>();
+ issuedRecord.put(OpenIDVCITokenManipulationStrategy.CREDENTIAL_CONFIGURATION_ID, configurationId);
+ if (dataset.getDatasetId() != null) {
+ issuedRecord.put(CredentialDataset.DATASET_ID, dataset.getDatasetId());
+ }
+ issued.put(identifier, issuedRecord);
}
+ responseDetails.add(new OpenIDVCIAuthorizationDetail("openid_credential", configurationId, null, identifiers));
}
- /** {@inheritDoc} */
- @Override
- protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
- // tokenContext.getCredentialOfferObject().
- final List<OpenIDVCIAuthorizationDetail> matchedDetails = new ArrayList<>();
- for (final OpenIDVCIAuthorizationDetail detail : authorizationDetails) {
- final List<String> configurationsIds = new ArrayList<>();
- final String configurationId = detail.getCredentialConfigurationId();
- try {
- for (final String credentialName : tokenContext.getPotentialCredentials().toJWTClaimsSet()
- .toJSONObject().keySet()) {
- if (!credentialName.startsWith(configurationId)) {
- continue;
- }
- final String potentialIdPart = credentialName.replaceFirst(configurationId, "");
- if (potentialIdPart.isEmpty()
- || potentialIdPart.startsWith("_") && potentialIdPart.lastIndexOf("_") == 0) {
- configurationsIds.add(credentialName);
- }
- }
- if (configurationsIds.size() > 0) {
- matchedDetails.add(new OpenIDVCIAuthorizationDetail(detail.getType(),
- detail.getCredentialConfigurationId(), null, configurationsIds));
- }
- } catch (final ParseException e) {
- log.error("{} Parsing exception. Not able to match request to offering", getLogPrefix(), e);
- ActionSupport.buildEvent(profileRequestContext, OpenIDVCIEventIds.NO_CREDENTIALS_FOR_REQUEST);
- return;
- }
+ /**
+ * Get the datasets of a credential configuration.
+ *
+ * @param potentialCredentials potential credentials
+ * @param configurationId credential configuration id
+ * @return datasets, or null if there are none
+ */
+ @Nullable
+ private List<CredentialDataset> datasetsOf(@Nonnull final Map<String, Object> potentialCredentials,
+ @Nonnull final String configurationId) {
+ final Object value = potentialCredentials.get(configurationId);
+ if (value == null) {
+ return null;
}
- if (matchedDetails.isEmpty()) {
- log.error("{} No credentials available for request", getLogPrefix());
- ActionSupport.buildEvent(profileRequestContext, OpenIDVCIEventIds.NO_CREDENTIALS_FOR_REQUEST);
- return;
+ try {
+ return CredentialDataset.parse(value);
+ } catch (final ParseException e) {
+ log.debug("{} Claim {} is not a credential", getLogPrefix(), configurationId);
+ return null;
}
- tokenContext.setResponseAuthorizationDetails(matchedDetails);
}
}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/SetDPoPProofThumbprintToResponseContext.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/SetDPoPProofThumbprintToResponseContext.java
new file mode 100644
index 0000000..1b187aa
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/SetDPoPProofThumbprintToResponseContext.java
@@ -0,0 +1,76 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.geant.shibboleth.plugin.openidvci.profile.impl;
+
+import java.util.function.Function;
+
+import javax.annotation.Nonnull;
+
+import org.geant.shibboleth.plugin.openidvci.messaging.impl.AbstractOpenIDVCITokenResponseAction;
+import org.opensaml.profile.action.EventIds;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.slf4j.Logger;
+
+import net.shibboleth.idp.plugin.oidc.op.messaging.context.OIDCAuthenticationResponseContext;
+import net.shibboleth.idp.plugin.oidc.op.profile.context.navigate.DefaultDPoPProofThumbprintLookupFunction;
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.primitive.LoggerFactory;
+
+/**
+ * Action that sets the thumbprint of the validated DPoP proof key to {@link OIDCAuthenticationResponseContext}, the
+ * key the access token of a pre-authorized code grant is bound to.
+ *
+ * @event {@link EventIds#PROCEED_EVENT_ID}
+ * @event {@link EventIds#INVALID_MSG_CTX}
+ */
+public class SetDPoPProofThumbprintToResponseContext extends AbstractOpenIDVCITokenResponseAction {
+
+ /** Class logger. */
+ @Nonnull
+ private Logger log = LoggerFactory.getLogger(SetDPoPProofThumbprintToResponseContext.class);
+
+ /** Strategy used to read the thumbprint of the validated DPoP proof. */
+ @Nonnull
+ private Function<ProfileRequestContext, String> dpopProofThumbprintLookupStrategy =
+ new DefaultDPoPProofThumbprintLookupFunction();
+
+ /**
+ * Set the strategy used to read the thumbprint of the validated DPoP proof.
+ *
+ * @param strategy lookup strategy
+ */
+ public void setDpopProofThumbprintLookupStrategy(
+ @Nonnull final Function<ProfileRequestContext, String> strategy) {
+ checkSetterPreconditions();
+ dpopProofThumbprintLookupStrategy = Constraint.isNotNull(strategy, "Lookup strategy cannot be null");
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+ final OIDCAuthenticationResponseContext oidcResponseContext = getOidcResponseContext();
+ assert oidcResponseContext != null;
+ final String proofThumbprint = dpopProofThumbprintLookupStrategy.apply(profileRequestContext);
+ if (proofThumbprint == null) {
+ log.debug("{} No DPoP proof, access token is not bound", getLogPrefix());
+ return;
+ }
+ oidcResponseContext.setDpopProofJwkThumbprint(proofThumbprint);
+ log.debug("{} Access token is bound to DPoP proof key {}", getLogPrefix(), proofThumbprint);
+ }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/UnwrapGrant.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/UnwrapGrant.java
index 91e8c76..e1a65dd 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/UnwrapGrant.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/UnwrapGrant.java
@@ -25,6 +25,7 @@ import javax.annotation.Nonnull;
import org.geant.shibboleth.plugin.openidvci.messaging.context.TokenContext;
import org.geant.shibboleth.plugin.openidvci.messaging.impl.AbstractOpenIDVCITokenResponseAction;
+import org.geant.shibboleth.plugin.openidvci.messaging.impl.CredentialDataset;
import org.geant.shibboleth.plugin.openidvci.messaging.impl.PreAuthorizedCodeGrant;
import org.geant.shibboleth.plugin.openidvci.profile.context.navigate.APIRequestClientIDLookupFunction;
import org.geant.shibboleth.plugin.openidvci.storage.CredentialOfferCache;
@@ -284,7 +285,7 @@ public class UnwrapGrant extends AbstractOpenIDVCITokenResponseAction {
final ClaimsSet credentialOfferClaims = new ClaimsSet();
ctx.getCredentialOfferObject().getPreAuthorizedCredentials().forEach((key, value) -> {
try {
- credentialOfferClaims.setClaim(key, value.serialize());
+ credentialOfferClaims.setClaim(key, CredentialDataset.serialize(value));
} catch (final JsonProcessingException e) {
log.warn("{} Error occurred while handling CredentialOfferObject {}", getLogPrefix(), e);
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 6d401d7..e9adc6b 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
@@ -18,7 +18,6 @@ package org.geant.shibboleth.plugin.openidvci.profile.impl;
import java.util.List;
import java.util.Map;
-import java.util.Set;
import javax.annotation.Nonnull;
@@ -26,6 +25,7 @@ import org.geant.shibboleth.plugin.openidvci.credential.ClaimDescription;
import org.geant.shibboleth.plugin.openidvci.credential.CredentialConfiguration;
import org.geant.shibboleth.plugin.openidvci.credential.CredentialMetadata;
import org.geant.shibboleth.plugin.openidvci.messaging.context.CredentialOfferContext;
+import org.geant.shibboleth.plugin.openidvci.messaging.impl.CredentialDataset;
import org.geant.shibboleth.plugin.openidvci.messaging.impl.CredentialOfferRequest;
import org.geant.shibboleth.plugin.openidvci.messaging.impl.CredentialOfferRequestedClaim;
import org.geant.shibboleth.plugin.openidvci.messaging.impl.CredentialOfferRequestedCredential;
@@ -51,10 +51,6 @@ public class ValidateCredentialOffering extends AbstractCredentialValidationActi
@Nonnull
private Logger log = LoggerFactory.getLogger(ValidateCredentialOffering.class);
- /** The requested credential configuration identifiers . */
- @NonnullBeforeExec
- private Set<String> credentialConfIds;
-
/**
* Inbound {@link CredentialOfferRequest}.
*/
@@ -76,33 +72,29 @@ public class ValidateCredentialOffering extends AbstractCredentialValidationActi
ActionSupport.buildEvent(profileRequestContext, OpenIDVCIEventIds.NO_CREDENTIALS_REQUEST);
return false;
}
- credentialConfIds = request.getPreAuthorizedCredentials().keySet();
return super.doPreExecute(profileRequestContext);
}
/** {@inheritDoc} */
@Override
protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
- credentialConfIds.forEach(id -> {
- if (!getCredentialConfigurations().keySet().contains(id)) {
- log.error("{} Requested credential configuration id {} is not supported", getLogPrefix(), id);
- ActionSupport.buildEvent(profileRequestContext, OpenIDVCIEventIds.NO_CREDENTIALS_REQUEST);
- return;
- }
- });
-
- for (final Map.Entry<String, CredentialOfferRequestedCredential> entry
- : request.getPreAuthorizedCredentials().entrySet()) {
+ for (final Map.Entry<String, List<CredentialDataset>> entry : request.getPreAuthorizedCredentials()
+ .entrySet()) {
final CredentialConfiguration configuration = getCredentialConfigurations().get(entry.getKey());
if (configuration == null) {
- continue;
- }
- if (!hasAllMandatoryClaims(configuration, entry.getValue())) {
- log.error("{} Requested credential {} is missing one or more mandatory claims", getLogPrefix(),
+ log.error("{} Requested credential configuration id {} is not supported", getLogPrefix(),
entry.getKey());
ActionSupport.buildEvent(profileRequestContext, OpenIDVCIEventIds.NO_CREDENTIALS_REQUEST);
return;
}
+ for (final CredentialDataset dataset : entry.getValue()) {
+ if (!hasAllMandatoryClaims(configuration, dataset.getContent())) {
+ log.error("{} Requested credential {} is missing one or more mandatory claims", getLogPrefix(),
+ entry.getKey());
+ ActionSupport.buildEvent(profileRequestContext, OpenIDVCIEventIds.NO_CREDENTIALS_REQUEST);
+ return;
+ }
+ }
}
final CredentialOfferContext ctx = new CredentialOfferContext();
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/ValidateRequestedCredential.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/ValidateRequestedCredential.java
index 934c648..42a6b44 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/ValidateRequestedCredential.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/ValidateRequestedCredential.java
@@ -17,35 +17,39 @@
package org.geant.shibboleth.plugin.openidvci.profile.impl;
import java.text.ParseException;
+import java.util.List;
import java.util.Map;
-import java.util.Set;
+import java.util.function.BiFunction;
import java.util.function.Function;
-import java.util.stream.Collectors;
import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
import org.geant.shibboleth.plugin.openidvci.credential.CredentialConfiguration;
import org.geant.shibboleth.plugin.openidvci.messaging.context.CredentialsContext;
import org.geant.shibboleth.plugin.openidvci.messaging.context.navigate.RequestCredentialConfigurationIdLookupFunction;
import org.geant.shibboleth.plugin.openidvci.messaging.context.navigate.RequestCredentialIdentifierLookupFunction;
-import org.geant.shibboleth.plugin.openidvci.messaging.impl.CredentialOfferRequestedCredential;
+import org.geant.shibboleth.plugin.openidvci.messaging.impl.CredentialDataset;
import org.geant.shibboleth.plugin.openidvci.profile.OpenIDVCIEventIds;
+import org.geant.shibboleth.plugin.openidvci.profile.config.OpenIDVCIConfiguration;
import org.geant.shibboleth.plugin.openidvci.profile.logic.OpenIDVCITokenManipulationStrategy;
+import org.geant.shibboleth.plugin.openidvci.profile.logic.PotentialCredentialLookupFunction;
import org.opensaml.messaging.context.MessageContext;
+import org.opensaml.messaging.context.navigate.ChildContextLookup;
import org.opensaml.profile.action.ActionSupport;
import org.opensaml.profile.action.EventIds;
import org.opensaml.profile.context.ProfileRequestContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
-import com.fasterxml.jackson.core.JsonProcessingException;
-
import net.shibboleth.idp.plugin.oidc.op.messaging.context.OIDCAuthenticationResponseContext;
+import net.shibboleth.profile.context.RelyingPartyContext;
import net.shibboleth.shared.logic.Constraint;
/**
- * Validates requested credential has a credential configuration. Both requested
- * credential and the configuration is stored to {@link CredentialsContext}
+ * Validates the requested credential against the credential identifiers issued in the token response and the
+ * credential configurations. The requested credential and its configuration are stored to
+ * {@link CredentialsContext}.
*/
public class ValidateRequestedCredential extends AbstractCredentialValidationAction {
@@ -67,15 +71,28 @@ public class ValidateRequestedCredential extends AbstractCredentialValidationAct
@Nonnull
private Function<ProfileRequestContext, String> requestCredentialIdentifierLookupStrategy;
+ /** Strategy used to obtain the datasets of a credential configuration. */
+ @Nonnull
+ private BiFunction<ProfileRequestContext, String, List<CredentialDataset>> credentialContentLookupStrategy;
+
+ /** Strategy used to locate the {@link RelyingPartyContext}. */
+ @Nonnull
+ private Function<ProfileRequestContext, RelyingPartyContext> relyingPartyContextLookupStrategy;
+
/** OIDC response context. */
private OIDCAuthenticationResponseContext oidcResponseContext;
+ /** Whether a credential configuration id is accepted when credential identifiers were issued. */
+ private boolean allowCredentialConfigurationIdWithIdentifiers;
+
/**
* Constructor.
*/
public ValidateRequestedCredential() {
requestCredentialConfigurationIdLookupStrategy = new RequestCredentialConfigurationIdLookupFunction();
requestCredentialIdentifierLookupStrategy = new RequestCredentialIdentifierLookupFunction();
+ credentialContentLookupStrategy = new PotentialCredentialLookupFunction();
+ relyingPartyContextLookupStrategy = new ChildContextLookup<>(RelyingPartyContext.class);
}
/**
@@ -92,7 +109,7 @@ public class ValidateRequestedCredential extends AbstractCredentialValidationAct
}
/**
- * Set the strategy used to obtain the credential configuration id value from
+ * Set the strategy used to obtain the credential identifier value from
* credentials request.
*
* @param strategy lookup strategy
@@ -104,6 +121,31 @@ public class ValidateRequestedCredential extends AbstractCredentialValidationAct
"RequestCredentialIdentifierLookupStrategy lookup strategy cannot be null");
}
+ /**
+ * Set the strategy used to obtain the datasets of a credential configuration.
+ *
+ * @param strategy lookup strategy
+ */
+ public void setCredentialContentLookupStrategy(
+ @Nonnull final BiFunction<ProfileRequestContext, String, List<CredentialDataset>> strategy) {
+ ifInitializedThrowUnmodifiabledComponentException();
+ credentialContentLookupStrategy = Constraint.isNotNull(strategy,
+ "CredentialContentLookupStrategy lookup strategy cannot be null");
+ }
+
+ /**
+ * Set the strategy used to locate the {@link RelyingPartyContext} associated with a given
+ * {@link ProfileRequestContext}.
+ *
+ * @param strategy lookup strategy
+ */
+ public void setRelyingPartyContextLookupStrategy(
+ @Nonnull final Function<ProfileRequestContext, RelyingPartyContext> strategy) {
+ ifInitializedThrowUnmodifiabledComponentException();
+ relyingPartyContextLookupStrategy = Constraint.isNotNull(strategy,
+ "RelyingPartyContext lookup strategy cannot be null");
+ }
+
/** {@inheritDoc} */
@Override
protected boolean doPreExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
@@ -117,73 +159,118 @@ public class ValidateRequestedCredential extends AbstractCredentialValidationAct
return false;
}
oidcResponseContext = outboundMessageCtx.getSubcontext(OIDCAuthenticationResponseContext.class);
- if (oidcResponseContext == null) {
+ if (oidcResponseContext == null || oidcResponseContext.getAuthorizationGrantClaimsSet() == null) {
log.error("{} No oidc response context", getLogPrefix());
ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_MSG_CTX);
return false;
}
+ final RelyingPartyContext rpCtx = relyingPartyContextLookupStrategy.apply(profileRequestContext);
+ allowCredentialConfigurationIdWithIdentifiers = rpCtx != null
+ && rpCtx.getProfileConfig() instanceof OpenIDVCIConfiguration configuration
+ && configuration.isAllowCredentialConfigurationIdWithIdentifiers(profileRequestContext);
return true;
}
-// Checkstyle: CyclomaticComplexity OFF
+// Checkstyle: CyclomaticComplexity|ReturnCount OFF
/** {@inheritDoc} */
@Override
protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
- String idInRequest = requestCredentialConfigurationIdLookupStrategy.apply(profileRequestContext);
- if (idInRequest == null) {
- // TOOD: Not sure if we should treat both values with same logic.
- idInRequest = requestCredentialIdentifierLookupStrategy.apply(profileRequestContext);
+ final String configurationId = requestCredentialConfigurationIdLookupStrategy.apply(profileRequestContext);
+ final String identifier = requestCredentialIdentifierLookupStrategy.apply(profileRequestContext);
+ if ((configurationId == null) == (identifier == null)) {
+ log.error("{} Request must have either credential_identifier or credential_configuration_id",
+ getLogPrefix());
+ ActionSupport.buildEvent(profileRequestContext, OpenIDVCIEventIds.NO_CREDENTIAL_REQUEST);
+ return;
}
- CredentialConfiguration configuration = null;
- if (idInRequest != null && getCredentialConfigurations().containsKey(idInRequest)) {
- configuration = getCredentialConfigurations().get(idInRequest);
+ final Map<String, Object> issuedIdentifiers;
+ try {
+ issuedIdentifiers = oidcResponseContext.getAuthorizationGrantClaimsSet().getClaimsSet()
+ .getJSONObjectClaim(OpenIDVCITokenManipulationStrategy.CREDENTIAL_IDENTIFIERS);
+ } catch (final ParseException e) {
+ log.error("{} Parsing issued credential identifiers failed", getLogPrefix(), e);
+ ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
+ return;
}
- if (configuration == null) {
- if (idInRequest != null) {
- final String newId = idInRequest;
- // Look for configurations that start as "id_"
- final Set<String> set = getCredentialConfigurations().keySet().stream()
- .map(s -> newId.replaceFirst(s, ""))
- .filter(s -> s.startsWith("_") && s.lastIndexOf("_") == 0).collect(Collectors.toSet());
- if (set.size() == 1) {
- configuration = getCredentialConfigurations().get(set.iterator().next());
- }
- }
+ final String issuedConfigurationId;
+ final String issuedDatasetId;
+ if (identifier != null) {
+ final Map<?, ?> issued = issuedIdentifiers != null
+ && issuedIdentifiers.get(identifier) instanceof Map<?, ?> issuedRecord ? issuedRecord : null;
+ issuedConfigurationId = issued != null
+ && issued.get(OpenIDVCITokenManipulationStrategy.CREDENTIAL_CONFIGURATION_ID) instanceof String id
+ ? id : null;
+ issuedDatasetId = issued != null && issued.get(CredentialDataset.DATASET_ID) instanceof String id ? id
+ : null;
+ } else {
+ issuedConfigurationId = null;
+ issuedDatasetId = null;
+ }
+ final String selectedConfigurationId = identifier != null ? issuedConfigurationId : configurationId;
+ if (identifier != null && issuedConfigurationId == null) {
+ log.error("{} Credential identifier {} was not issued", getLogPrefix(), identifier);
+ ActionSupport.buildEvent(profileRequestContext, OpenIDVCIEventIds.UNKNOWN_CREDENTIAL_IDENTIFIER);
+ return;
+ }
+ if (identifier == null && issuedIdentifiers != null && !issuedIdentifiers.isEmpty()
+ && !allowCredentialConfigurationIdWithIdentifiers) {
+ log.error("{} Request has credential_configuration_id {} although credential identifiers were issued",
+ getLogPrefix(), configurationId);
+ ActionSupport.buildEvent(profileRequestContext, OpenIDVCIEventIds.NO_CREDENTIAL_REQUEST);
+ return;
}
+ final CredentialConfiguration configuration = getCredentialConfigurations().get(selectedConfigurationId);
if (configuration == null) {
- log.error("{} No matching configuration found for requested credential {}", getLogPrefix(), idInRequest);
- ActionSupport.buildEvent(profileRequestContext, OpenIDVCIEventIds.NO_CREDENTIALS_FOR_REQUEST);
+ log.error("{} Credential configuration {} is unknown", getLogPrefix(), selectedConfigurationId);
+ ActionSupport.buildEvent(profileRequestContext, identifier != null
+ ? OpenIDVCIEventIds.UNKNOWN_CREDENTIAL_IDENTIFIER : OpenIDVCIEventIds.NO_CREDENTIALS_FOR_REQUEST);
return;
}
- // look for claim that would match id and store it.
- CredentialOfferRequestedCredential credential = null;
- try {
- final Map<String, Object> potentialClaims =
- oidcResponseContext.getAuthorizationGrantClaimsSet().getClaimsSet()
- .getJSONObjectClaim(OpenIDVCITokenManipulationStrategy.POTENTIAL_CLAIMS);
- if (potentialClaims != null && potentialClaims.containsKey(idInRequest)) {
- credential = CredentialOfferRequestedCredential.parse((String)potentialClaims.get(idInRequest));
- }
- } catch (final ParseException | com.nimbusds.oauth2.sdk.ParseException | JsonProcessingException e) {
- log.error("{} parsing failed", getLogPrefix(), e);
- ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
+ final List<CredentialDataset> datasets =
+ credentialContentLookupStrategy.apply(profileRequestContext, selectedConfigurationId);
+ if (identifier == null && datasets != null && datasets.size() > 1) {
+ log.error("{} Credential configuration {} has several datasets, credential_identifier is required",
+ getLogPrefix(), configurationId);
+ ActionSupport.buildEvent(profileRequestContext, OpenIDVCIEventIds.NO_CREDENTIAL_REQUEST);
return;
}
- if (credential == null) {
+ final CredentialDataset dataset = selectDataset(datasets, issuedDatasetId);
+ if (dataset == null) {
log.error("{} No matching credential content found for requested credential {}", getLogPrefix(),
- idInRequest);
- ActionSupport.buildEvent(profileRequestContext, OpenIDVCIEventIds.NO_CREDENTIALS_FOR_REQUEST);
+ identifier != null ? identifier : configurationId);
+ ActionSupport.buildEvent(profileRequestContext, identifier != null
+ ? OpenIDVCIEventIds.UNKNOWN_CREDENTIAL_IDENTIFIER : OpenIDVCIEventIds.NO_CREDENTIALS_FOR_REQUEST);
return;
}
- // TODO: Move context creation to initialization phase
final CredentialsContext ctx = profileRequestContext.getInboundMessageContext()
.ensureSubcontext(CredentialsContext.class);
- ctx.setCredentialContent(credential);
+ ctx.setCredentialContent(dataset.getContent());
ctx.setCredentialConfiguration(configuration);
- // TODO: This should be value in configuration. Fix.
- ctx.setCredentialIdentifier(idInRequest);
+ ctx.setCredentialIdentifier(identifier != null ? identifier : configurationId);
+ ctx.setCredentialConfigurationId(selectedConfigurationId);
+ ctx.setDatasetId(dataset.getDatasetId());
+ }
+// Checkstyle: CyclomaticComplexity|ReturnCount ON
+
+ /**
+ * Select the dataset of the request.
+ *
+ * @param datasets datasets of the credential configuration
+ * @param datasetId dataset id the credential identifier was issued for, or null
+ * @return dataset, or null if there is no such dataset
+ */
+ @Nullable
+ private CredentialDataset selectDataset(@Nullable final List<CredentialDataset> datasets,
+ @Nullable final String datasetId) {
+ if (datasets == null || datasets.isEmpty()) {
+ return null;
+ }
+ if (datasetId == null) {
+ return datasets.size() == 1 ? datasets.get(0) : null;
+ }
+ return datasets.stream().filter(dataset -> datasetId.equals(dataset.getDatasetId())).findFirst()
+ .orElse(null);
}
-// Checkstyle: CyclomaticComplexity ON
-}
\ No newline at end of file
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/logic/CredentialDetailsLookupFunction.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/logic/CredentialDetailsLookupFunction.java
index d069dac..8c735b9 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/logic/CredentialDetailsLookupFunction.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/logic/CredentialDetailsLookupFunction.java
@@ -55,7 +55,10 @@ public class CredentialDetailsLookupFunction implements Function<ProfileRequestC
return details;
}
- details.put("credential_configuration_id", ctx.getCredentialIdentifier());
+ details.put("credential_configuration_id", ctx.getCredentialConfigurationId());
+ if (ctx.getDatasetId() != null) {
+ details.put("dataset_id", ctx.getDatasetId());
+ }
if (ctx.getCredentialConfiguration() != null) {
details.put("format", ctx.getCredentialConfiguration().getFormat());
}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/logic/OpenIDVCITokenManipulationStrategy.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/logic/OpenIDVCITokenManipulationStrategy.java
index b0dab50..5926a17 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/logic/OpenIDVCITokenManipulationStrategy.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/logic/OpenIDVCITokenManipulationStrategy.java
@@ -42,7 +42,7 @@ import net.shibboleth.shared.primitive.LoggerFactory;
*
* 1) authorization_details and stores it to token, usually either authorization
* code or access token, 2) potential verifiable credentials, stores them to
- * access token.
+ * access token, 3) issued credential identifiers, stores them to access token.
*
*/
public class OpenIDVCITokenManipulationStrategy
@@ -54,6 +54,12 @@ public class OpenIDVCITokenManipulationStrategy
/** Claim name carrying the potential claims. */
public static final String POTENTIAL_CLAIMS = "vci.int.potentialClaims";
+ /** Claim name carrying the issued credential identifiers. */
+ public static final String CREDENTIAL_IDENTIFIERS = "vci.int.credentialIdentifiers";
+
+ /** Member name of the credential configuration id of an issued credential identifier. */
+ public static final String CREDENTIAL_CONFIGURATION_ID = "credential_configuration_id";
+
/** Class logger. */
@Nonnull
private Logger log = LoggerFactory.getLogger(OpenIDVCITokenManipulationStrategy.class);
@@ -101,6 +107,10 @@ public class OpenIDVCITokenManipulationStrategy
profileRequestContext.getInboundMessageContext().getSubcontext(TokenContext.class);
if (tokenContext != null) {
claims.put(POTENTIAL_CLAIMS, tokenContext.getPotentialCredentials().toJSONObject());
+ if (tokenContext.getIssuedCredentialIdentifiers() != null
+ && !tokenContext.getIssuedCredentialIdentifiers().isEmpty()) {
+ claims.put(CREDENTIAL_IDENTIFIERS, tokenContext.getIssuedCredentialIdentifiers());
+ }
}
return claims;
}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/logic/PotentialCredentialLookupFunction.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/logic/PotentialCredentialLookupFunction.java
new file mode 100644
index 0000000..bf83292
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/logic/PotentialCredentialLookupFunction.java
@@ -0,0 +1,99 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.geant.shibboleth.plugin.openidvci.profile.logic;
+
+import java.text.ParseException;
+import java.util.List;
+import java.util.Map;
+import java.util.function.BiFunction;
+import java.util.function.Function;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.geant.shibboleth.plugin.openidvci.messaging.impl.CredentialDataset;
+import org.opensaml.messaging.context.navigate.ChildContextLookup;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.opensaml.profile.context.navigate.OutboundMessageContextLookup;
+import org.slf4j.Logger;
+
+import net.shibboleth.idp.plugin.oidc.op.messaging.context.OIDCAuthenticationResponseContext;
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.primitive.LoggerFactory;
+
+/**
+ * Function that returns the datasets of a credential configuration from the
+ * potential claims of the access token.
+ */
+public class PotentialCredentialLookupFunction
+ implements BiFunction<ProfileRequestContext, String, List<CredentialDataset>> {
+
+ /** Class logger. */
+ @Nonnull
+ private final Logger log = LoggerFactory.getLogger(PotentialCredentialLookupFunction.class);
+
+ /** Strategy used to locate the {@link OIDCAuthenticationResponseContext}. */
+ @Nonnull
+ private Function<ProfileRequestContext, OIDCAuthenticationResponseContext> oidcResponseContextLookupStrategy;
+
+ /** Constructor. */
+ public PotentialCredentialLookupFunction() {
+ final Function<ProfileRequestContext, OIDCAuthenticationResponseContext> strategy = new ChildContextLookup<>(
+ OIDCAuthenticationResponseContext.class).compose(new OutboundMessageContextLookup());
+ assert strategy != null;
+ oidcResponseContextLookupStrategy = strategy;
+ }
+
+ /**
+ * Set the strategy used to locate the
+ * {@link OIDCAuthenticationResponseContext}.
+ *
+ * @param strategy lookup strategy
+ */
+ public void setOIDCResponseContextLookupStrategy(
+ @Nonnull final Function<ProfileRequestContext, OIDCAuthenticationResponseContext> strategy) {
+ oidcResponseContextLookupStrategy = Constraint.isNotNull(strategy,
+ "OIDCAuthenticationResponseContext lookup strategy cannot be null");
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ @Nullable
+ public List<CredentialDataset> apply(@Nullable final ProfileRequestContext profileRequestContext,
+ @Nullable final String configurationId) {
+ if (profileRequestContext == null || configurationId == null) {
+ return null;
+ }
+ final OIDCAuthenticationResponseContext oidcResponseContext = oidcResponseContextLookupStrategy
+ .apply(profileRequestContext);
+ if (oidcResponseContext == null || oidcResponseContext.getAuthorizationGrantClaimsSet() == null) {
+ log.debug("No authorization grant to read potential credentials from");
+ return null;
+ }
+ try {
+ final Map<String, Object> potentialClaims = oidcResponseContext.getAuthorizationGrantClaimsSet()
+ .getClaimsSet().getJSONObjectClaim(OpenIDVCITokenManipulationStrategy.POTENTIAL_CLAIMS);
+ if (potentialClaims != null && potentialClaims.get(configurationId) != null) {
+ return CredentialDataset.parse(potentialClaims.get(configurationId));
+ }
+ } catch (final ParseException | com.nimbusds.oauth2.sdk.ParseException e) {
+ log.error("Parsing potential credential {} failed", configurationId, e);
+ }
+ return null;
+ }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/statuslist/messaging/impl/StatusListAssignmentsRequest.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/statuslist/messaging/impl/StatusListAssignmentsRequest.java
index fb42df4..631fd0c 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/statuslist/messaging/impl/StatusListAssignmentsRequest.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/statuslist/messaging/impl/StatusListAssignmentsRequest.java
@@ -144,7 +144,7 @@ public class StatusListAssignmentsRequest extends AbstractOptionallyIdentifiedRe
final Map<String, Object> payload;
try {
- payload = new ObjectMapper().readValue(httpRequest.getQuery(),
+ payload = new ObjectMapper().readValue(httpRequest.getBody(),
new TypeReference<Map<String, Object>>() {
});
} catch (final JsonProcessingException e) {
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/statuslist/messaging/impl/StatusListRevokeRequest.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/statuslist/messaging/impl/StatusListRevokeRequest.java
index ac1e579..eb86372 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/statuslist/messaging/impl/StatusListRevokeRequest.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/statuslist/messaging/impl/StatusListRevokeRequest.java
@@ -136,7 +136,7 @@ public class StatusListRevokeRequest extends AbstractOptionallyIdentifiedRequest
final Map<String, Object> payload;
try {
- payload = new ObjectMapper().readValue(httpRequest.getQuery(), new TypeReference<Map<String, Object>>() {
+ payload = new ObjectMapper().readValue(httpRequest.getBody(), new TypeReference<Map<String, Object>>() {
});
} catch (final JsonProcessingException e) {
throw new ParseException(e.getMessage(),
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/storage/CredentialOfferObject.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/storage/CredentialOfferObject.java
index d224f1d..147af54 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/storage/CredentialOfferObject.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/storage/CredentialOfferObject.java
@@ -22,7 +22,7 @@ import java.util.Map;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
-import org.geant.shibboleth.plugin.openidvci.messaging.impl.CredentialOfferRequestedCredential;
+import org.geant.shibboleth.plugin.openidvci.messaging.impl.CredentialDataset;
import org.geant.shibboleth.plugin.openidvci.messaging.impl.CredentialOfferTxCode;
import com.fasterxml.jackson.annotation.JsonCreator;
@@ -42,7 +42,7 @@ public class CredentialOfferObject {
/** Credential offer request content in pre-auth flow. */
@Nullable
- private final Map<String, CredentialOfferRequestedCredential> preAuthorizedCredentials;
+ private final Map<String, List<CredentialDataset>> preAuthorizedCredentials;
/** List of credentials for Credential offer in code flow. */
@Nullable
@@ -64,7 +64,7 @@ public class CredentialOfferObject {
public CredentialOfferObject(
@JsonProperty("clientID") @Nullable final ClientID clientID,
@JsonProperty("preAuthorizedCredentials") @Nullable
- final Map<String, CredentialOfferRequestedCredential> preAuthorizedCredentials,
+ final Map<String, List<CredentialDataset>> preAuthorizedCredentials,
@JsonProperty("codeCredentials") @Nullable final List<String> codeCredentials,
@JsonProperty("credentialOfferTxCode") @Nullable final CredentialOfferTxCode credentialOfferTxCode) {
this.clientID = clientID;
@@ -89,7 +89,7 @@ public class CredentialOfferObject {
*
* @return credential offer request content in pre-auth flow.
*/
- public Map<String, CredentialOfferRequestedCredential> getPreAuthorizedCredentials() {
+ public Map<String, List<CredentialDataset>> getPreAuthorizedCredentials() {
return preAuthorizedCredentials;
}
diff --git a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/credentials/credentials-beans.xml b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/credentials/credentials-beans.xml
index 226ae40..af9b300 100644
--- a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/credentials/credentials-beans.xml
+++ b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/credentials/credentials-beans.xml
@@ -185,6 +185,8 @@
value="#{T(org.geant.shibboleth.plugin.openidvci.messaging.error.OpenIDVCIError).UNKNOWN_CREDENTIAL_CONFIGURATION}" />
<entry key="#{T(org.geant.shibboleth.plugin.openidvci.profile.OpenIDVCIEventIds).NO_CREDENTIALS_FOR_REQUEST}"
value="#{T(org.geant.shibboleth.plugin.openidvci.messaging.error.OpenIDVCIError).UNKNOWN_CREDENTIAL_CONFIGURATION}" />
+ <entry key="#{T(org.geant.shibboleth.plugin.openidvci.profile.OpenIDVCIEventIds).UNKNOWN_CREDENTIAL_IDENTIFIER}"
+ value="#{T(org.geant.shibboleth.plugin.openidvci.messaging.error.OpenIDVCIError).UNKNOWN_CREDENTIAL_IDENTIFIER}" />
<entry key="#{T(org.geant.shibboleth.plugin.openidvci.profile.OpenIDVCIEventIds).PROOF_TYPE_UNSUPPORTED}"
value="#{T(org.geant.shibboleth.plugin.openidvci.messaging.error.OpenIDVCIError).INVALID_PROOF}" />
<entry key="#{T(org.geant.shibboleth.plugin.openidvci.profile.OpenIDVCIEventIds).INVALID_PROOF}"
diff --git a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/token/token-beans.xml b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/token/token-beans.xml
index fe12a93..bf57af6 100644
--- a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/token/token-beans.xml
+++ b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/token/token-beans.xml
@@ -844,9 +844,17 @@
<bean id="OpenIDVCISetAuthorizationDetailsToResponseContext" class="org.geant.shibboleth.plugin.openidvci.profile.impl.SetAuthorizationDetailsToResponseContext"
scope="prototype"/>
+ <bean id="OpenIDVCIFilterPotentialCredentials"
+ class="org.geant.shibboleth.plugin.openidvci.profile.impl.FilterPotentialCredentials" scope="prototype"
+ p:credentialConfigurationsResolver-ref="openidvci.CredentialConfigurationsResolver" />
+
<bean id="OpenIDVCIConsumeCredentialOffer" class="org.geant.shibboleth.plugin.openidvci.profile.impl.ConsumeCredentialOffer"
scope="prototype" p:credentialOfferCache-ref="openidvci.CredentialOfferCache" />
+ <bean id="OpenIDVCISetDPoPProofThumbprintToResponseContext"
+ class="org.geant.shibboleth.plugin.openidvci.profile.impl.SetDPoPProofThumbprintToResponseContext"
+ scope="prototype" />
+
<!-- REPLACED InitializeAuthenticationContext of oidc/abstract-api. -->
<bean id="InitializeAuthenticationContext" class="org.geant.shibboleth.plugin.openidvci.profile.impl.InitializeAuthenticationContext"
scope="prototype" />
diff --git a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/token/token-flow.xml b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/token/token-flow.xml
index 765eb7b..3db0498 100644
--- a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/token/token-flow.xml
+++ b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/token/token-flow.xml
@@ -8,6 +8,8 @@
OpenIDVCIValidatePreAuthorizedCode one time use of a pre-authorized code
OpenIDVCISetAuthorizationDetailsToResponseContext credential authorization details
OpenIDVCIConsumeCredentialOffer retires a stored offer before the transaction code is read
+ OpenIDVCISetDPoPProofThumbprintToResponseContext DPoP key the pre-authorized access token is bound to
+ OpenIDVCIFilterPotentialCredentials credentials of the code grant limited to the granted scope and authorization_details
OpenIDVCIBranchOnPreAuthorizedCodeGrant state takes that grant aside
OpenIDVCIPreAuthzGrantProcessing state the pre-authorized code grant branch
OpenIDVCIBuildTokensForCredentialsAccess state the opaque access token of both grants
@@ -99,6 +101,8 @@
<evaluate expression="ValidateScope" />
<evaluate expression="ValidateAudience" />
<evaluate expression="SetSessionIdFromAuthzCodeToResponseContext" />
+ <!-- ADDED. -->
+ <evaluate expression="OpenIDVCIFilterPotentialCredentials" />
<evaluate expression="'proceed'" />
<!-- REPLACED the transition to CheckTraditionalGrantForAudience. -->
@@ -117,6 +121,7 @@
<evaluate expression="OpenIDVCIValidatePreAuthorizedCode" />
<evaluate expression="OpenIDVCIConsumeCredentialOffer" />
<evaluate expression="OpenIDVCIValidateTxCode" />
+ <evaluate expression="OpenIDVCISetDPoPProofThumbprintToResponseContext" />
<evaluate expression="'proceed'" />
<transition on="proceed" to="OpenIDVCIBuildTokensForCredentialsAccess" />
diff --git a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/messaging/context/navigate/AuthorizationDetailsLookupFunctionTest.java b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/messaging/context/navigate/AuthorizationDetailsLookupFunctionTest.java
new file mode 100644
index 0000000..cb0b7b4
--- /dev/null
+++ b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/messaging/context/navigate/AuthorizationDetailsLookupFunctionTest.java
@@ -0,0 +1,117 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.geant.shibboleth.plugin.openidvci.messaging.context.navigate;
+
+import java.net.URI;
+import java.util.List;
+import java.util.Map;
+
+import org.geant.shibboleth.plugin.openidvci.messaging.impl.OpenIDVCIAuthorizationDetail;
+import org.opensaml.messaging.context.MessageContext;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.testng.Assert;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.PlainJWT;
+import com.nimbusds.oauth2.sdk.AuthorizationRequest;
+import com.nimbusds.oauth2.sdk.ResponseType;
+import com.nimbusds.oauth2.sdk.id.ClientID;
+
+import net.shibboleth.idp.plugin.oidc.op.messaging.context.OIDCAuthenticationResponseContext;
+
+/**
+ * Unit tests for {@link AuthorizationDetailsLookupFunction}.
+ */
+public class AuthorizationDetailsLookupFunctionTest {
+
+ private static final String DETAILS = "[{\"type\":\"openid_credential\",\"credential_configuration_id\":\"GeantIncubatorDiploma\"}]";
+
+ private ProfileRequestContext profileRequestCtx;
+
+ private AuthorizationDetailsLookupFunction lookup;
+
+ @BeforeMethod
+ protected void setUp() {
+ profileRequestCtx = new ProfileRequestContext();
+ profileRequestCtx.setInboundMessageContext(new MessageContext());
+ profileRequestCtx.setOutboundMessageContext(new MessageContext());
+ lookup = new AuthorizationDetailsLookupFunction();
+ }
+
+ private void setQueryRequest(final String details) {
+ final AuthorizationRequest.Builder builder = new AuthorizationRequest.Builder(
+ new ResponseType(ResponseType.Value.CODE), new ClientID("client"))
+ .endpointURI(URI.create("https://issuer.example.org/idp/profile/oidc/authorize"));
+ if (details != null) {
+ builder.customParameter("authorization_details", details);
+ }
+ profileRequestCtx.getInboundMessageContext().setMessage(builder.build());
+ }
+
+ private void setPushedRequest(final Object details) {
+ profileRequestCtx.getInboundMessageContext()
+ .setMessage(new AuthorizationRequest.Builder(URI.create("urn:ietf:params:oauth:request_uri:abc"),
+ new ClientID("client"))
+ .endpointURI(URI.create("https://issuer.example.org/idp/profile/oidc/authorize")).build());
+ final JWTClaimsSet.Builder claims = new JWTClaimsSet.Builder().claim("client_id", "client");
+ if (details != null) {
+ claims.claim("authorization_details", details);
+ }
+ profileRequestCtx.getOutboundMessageContext().ensureSubcontext(OIDCAuthenticationResponseContext.class)
+ .setRequestObject(new PlainJWT(claims.build()));
+ }
+
+ @Test
+ public void testQueryParameter() {
+ setQueryRequest(DETAILS);
+ final List<OpenIDVCIAuthorizationDetail> details = lookup.apply(profileRequestCtx);
+ Assert.assertNotNull(details);
+ Assert.assertEquals(details.get(0).getCredentialConfigurationId(), "GeantIncubatorDiploma");
+ }
+
+ @Test
+ public void testNone() {
+ setQueryRequest(null);
+ Assert.assertNull(lookup.apply(profileRequestCtx));
+ }
+
+ @Test
+ public void testPushedAsString() {
+ setPushedRequest(DETAILS);
+ final List<OpenIDVCIAuthorizationDetail> details = lookup.apply(profileRequestCtx);
+ Assert.assertNotNull(details);
+ Assert.assertEquals(details.get(0).getCredentialConfigurationId(), "GeantIncubatorDiploma");
+ }
+
+ @Test
+ public void testPushedAsList() {
+ setPushedRequest(
+ List.of(Map.of("type", "openid_credential", "credential_configuration_id", "GeantIncubatorDiploma")));
+ final List<OpenIDVCIAuthorizationDetail> details = lookup.apply(profileRequestCtx);
+ Assert.assertNotNull(details);
+ Assert.assertEquals(details.get(0).getCredentialConfigurationId(), "GeantIncubatorDiploma");
+ }
+
+ @Test
+ public void testPushedWithoutDetails() {
+ setPushedRequest(null);
+ Assert.assertNull(lookup.apply(profileRequestCtx));
+ }
+
+}
diff --git a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/messaging/impl/CredentialDatasetTest.java b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/messaging/impl/CredentialDatasetTest.java
new file mode 100644
index 0000000..c32cda2
--- /dev/null
+++ b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/messaging/impl/CredentialDatasetTest.java
@@ -0,0 +1,129 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.geant.shibboleth.plugin.openidvci.messaging.impl;
+
+import java.util.List;
+import java.util.Map;
+
+import org.testng.Assert;
+import org.testng.annotations.Test;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.nimbusds.oauth2.sdk.ParseException;
+
+/**
+ * Unit tests for {@link CredentialDataset}.
+ */
+public class CredentialDatasetTest {
+
+ private static final String CLAIMS = "[{\"path\":[\"degree\"],\"value\":\"BSc\"}]";
+
+ private static final String BSC = "{\"dataset_id\":\"bsc\",\"claims\":" + CLAIMS + "}";
+
+ private static final String MSC = "{\"dataset_id\":\"msc\",\"claims\":[{\"path\":[\"degree\"],\"value\":\"MSc\"}]}";
+
+ @Test
+ public void testClaims() throws Exception {
+ final List<CredentialDataset> datasets = CredentialDataset.parse(CLAIMS);
+ Assert.assertEquals(datasets.size(), 1);
+ Assert.assertNull(datasets.get(0).getDatasetId());
+ Assert.assertEquals(datasets.get(0).getContent().getRequestedCredential().get(0).getValue(), "BSc");
+ }
+
+ @Test
+ public void testRequestedCredentialWrapper() throws Exception {
+ final List<CredentialDataset> datasets = CredentialDataset.parse("{\"requestedCredential\":" + CLAIMS + "}");
+ Assert.assertEquals(datasets.size(), 1);
+ Assert.assertNull(datasets.get(0).getDatasetId());
+ }
+
+ @Test
+ public void testParsedClaims() throws Exception {
+ final List<CredentialDataset> datasets = CredentialDataset.parse(new ObjectMapper().readValue(CLAIMS,
+ Object.class));
+ Assert.assertEquals(datasets.size(), 1);
+ }
+
+ @Test
+ public void testDatasets() throws Exception {
+ final List<CredentialDataset> datasets = CredentialDataset.parse("[" + BSC + "," + MSC + "]");
+ Assert.assertEquals(datasets.size(), 2);
+ Assert.assertEquals(datasets.get(0).getDatasetId(), "bsc");
+ Assert.assertEquals(datasets.get(1).getContent().getRequestedCredential().get(0).getValue(), "MSc");
+ }
+
+ @Test
+ public void testDatasetsAsArrayOfStrings() throws Exception {
+ final List<CredentialDataset> datasets = CredentialDataset.parse(List.of(BSC, MSC));
+ Assert.assertEquals(datasets.size(), 2);
+ Assert.assertEquals(datasets.get(1).getDatasetId(), "msc");
+ }
+
+ @Test
+ public void testDatasetAsObject() throws Exception {
+ final List<CredentialDataset> datasets = CredentialDataset.parse(new ObjectMapper().readValue(BSC,
+ Map.class));
+ Assert.assertEquals(datasets.size(), 1);
+ Assert.assertEquals(datasets.get(0).getDatasetId(), "bsc");
+ }
+
+ @Test
+ public void testSerializeRoundTrip() throws Exception {
+ final List<CredentialDataset> datasets = CredentialDataset.parse("[" + BSC + "," + MSC + "]");
+ final List<CredentialDataset> parsed = CredentialDataset.parse(CredentialDataset.serialize(datasets));
+ Assert.assertEquals(parsed.size(), 2);
+ Assert.assertEquals(parsed.get(0).getDatasetId(), "bsc");
+ Assert.assertEquals(parsed.get(0).getContent().getRequestedCredential().get(0).getValue(), "BSc");
+ }
+
+ @Test
+ public void testSerializeSingle() throws Exception {
+ final List<CredentialDataset> parsed = CredentialDataset.parse(
+ CredentialDataset.serialize(CredentialDataset.parse(CLAIMS)));
+ Assert.assertEquals(parsed.size(), 1);
+ Assert.assertNull(parsed.get(0).getDatasetId());
+ }
+
+ @Test
+ public void testCredentialIdentifier() throws Exception {
+ Assert.assertEquals(CredentialDataset.credentialIdentifier("UniversityDegree",
+ CredentialDataset.parse(BSC).get(0)), "UniversityDegree#bsc");
+ Assert.assertEquals(CredentialDataset.credentialIdentifier("UniversityDegree",
+ CredentialDataset.parse(CLAIMS).get(0)), "UniversityDegree");
+ }
+
+ @Test(expectedExceptions = ParseException.class)
+ public void testSeveralWithoutId() throws Exception {
+ CredentialDataset.parse("[" + BSC + ",{\"claims\":" + CLAIMS + "}]");
+ }
+
+ @Test(expectedExceptions = ParseException.class)
+ public void testDuplicateId() throws Exception {
+ CredentialDataset.parse("[" + BSC + "," + BSC + "]");
+ }
+
+ @Test(expectedExceptions = ParseException.class)
+ public void testEmpty() throws Exception {
+ CredentialDataset.parse("[]");
+ }
+
+ @Test(expectedExceptions = ParseException.class)
+ public void testNotJson() throws Exception {
+ CredentialDataset.parse("member");
+ }
+
+}
diff --git a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/messaging/impl/CredentialOfferRequestTest.java b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/messaging/impl/CredentialOfferRequestTest.java
index d8297db..1d6cfc5 100644
--- a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/messaging/impl/CredentialOfferRequestTest.java
+++ b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/messaging/impl/CredentialOfferRequestTest.java
@@ -39,7 +39,7 @@ public class CredentialOfferRequestTest {
httpRequest = new HTTPRequest(HTTPRequest.Method.POST, new URI("http://example.com"));
httpRequest.setAuthorization("Basic dGVzdDp0ZXN0");
httpRequest.setContentType("application/json");
- httpRequest.setQuery(
+ httpRequest.setBody(
"{\"txCode\":{\"value\":\"12345\"}, \"preAuthorizedCredentials\":{\"GeantIncubatorSdJwt2\":[{\"path\":[\"mail\"],\"value\":\"incubatorUser at example.org\"},{\"path\":[\"edu\",\"eppn\"],\"value\":\"incubatorUser at example.org\"},{\"path\":[\"givenName\"],\"value\":\"Gemma Gemina Erasmus\"},{\"path\":[\"familyName\"],\"value\":\"Erasmus\"}]}}");
}
@@ -52,8 +52,8 @@ public class CredentialOfferRequestTest {
Assert.assertTrue(request.getPreAuthorizedCredentials().containsKey("GeantIncubatorSdJwt2"));
Assert.assertTrue(request.getPreAuthorizedCredentials().containsKey("GeantIncubatorSdJwt2"));
Assert.assertEquals(
- request.getPreAuthorizedCredentials().get("GeantIncubatorSdJwt2").getRequestedCredential().size(), 4);
- Assert.assertEquals(request.getPreAuthorizedCredentials().get("GeantIncubatorSdJwt2").getRequestedCredential()
+ request.getPreAuthorizedCredentials().get("GeantIncubatorSdJwt2").get(0).getContent().getRequestedCredential().size(), 4);
+ Assert.assertEquals(request.getPreAuthorizedCredentials().get("GeantIncubatorSdJwt2").get(0).getContent().getRequestedCredential()
.get(1).getPath().get(1), "eppn");
Assert.assertEquals(request.getCredentialOfferTxCode().getValue(), "12345");
}
@@ -61,7 +61,7 @@ public class CredentialOfferRequestTest {
@Test
public void testNonAuthSuccess() throws ParseException {
httpRequest.setAuthorization(null);
- httpRequest.setQuery(
+ httpRequest.setBody(
"{\"client_id\":\"test\",\"preAuthorizedCredentials\":{\"GeantIncubatorSdJwt2\":[{\"path\":[\"mail\"],\"value\":\"incubatorUser at example.org\"},{\"path\":[\"edu\",\"eppn\"],\"value\":\"incubatorUser at example.org\"},{\"path\":[\"givenName\"],\"value\":\"Gemma Gemina Erasmus\"},{\"path\":[\"familyName\"],\"value\":\"Erasmus\"}]}}");
request = CredentialOfferRequest.parse(httpRequest);
Assert.assertEquals(request.getClientID().toString(), "test");
@@ -77,20 +77,20 @@ public class CredentialOfferRequestTest {
@Test(expectedExceptions = ParseException.class)
public void testNoCredentialOfferRequestFailure() throws ParseException {
- httpRequest.setQuery(null);
+ httpRequest.setBody(null);
request = CredentialOfferRequest.parse(httpRequest);
}
@Test(expectedExceptions = ParseException.class)
public void testBothCredentialOfferRequestFailure() throws ParseException {
- httpRequest.setQuery(
+ httpRequest.setBody(
"{\"codeCredentials\":[\"GeantIncubatorSdJwt1\",\"GeantIncubatorSdJwt2\"], \"preAuthorizedCredentials\":{\"GeantIncubatorSdJwt2\":[{\"path\":[\"mail\"],\"value\":\"incubatorUser at example.org\"},{\"path\":[\"edu\",\"eppn\"],\"value\":\"incubatorUser at example.org\"},{\"path\":[\"givenName\"],\"value\":\"Gemma Gemina Erasmus\"},{\"path\":[\"familyName\"],\"value\":\"Erasmus\"}]}}");
request = CredentialOfferRequest.parse(httpRequest);
}
@Test(expectedExceptions = ParseException.class)
public void testCodeFlowRejected() throws ParseException {
- httpRequest.setQuery("{\"codeCredentials\":[\"GeantIncubatorSdJwt1\",\"GeantIncubatorSdJwt2\"]}");
+ httpRequest.setBody("{\"codeCredentials\":[\"GeantIncubatorSdJwt1\",\"GeantIncubatorSdJwt2\"]}");
request = CredentialOfferRequest.parse(httpRequest);
}
diff --git a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/messaging/impl/OpenIDVCICredentialsRequestTest.java b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/messaging/impl/OpenIDVCICredentialsRequestTest.java
index 309a184..589eb00 100644
--- a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/messaging/impl/OpenIDVCICredentialsRequestTest.java
+++ b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/messaging/impl/OpenIDVCICredentialsRequestTest.java
@@ -36,7 +36,7 @@ public class OpenIDVCICredentialsRequestTest {
httpRequest = new HTTPRequest(HTTPRequest.Method.POST, new URI("http://example.com"));
httpRequest.setAuthorization("Bearer dGVzdDp0ZXN0");
httpRequest.setContentType("application/json");
- httpRequest.setQuery("{\n" + " \"credential_configuration_id\": \"org.iso.18013.5.1.mDL\",\n"
+ httpRequest.setBody("{\n" + " \"credential_configuration_id\": \"org.iso.18013.5.1.mDL\",\n"
+ " \"proof\": {\n" + " \"proof_type\": \"jwt\",\n"
+ " \"jwt\": \"eyJraWQiOiJkaWQ6ZXhhbXBsZTplYmZlYjFmNzEyZWJjNmYxYzI3NmUxMmVjMjEva2V5cy8xIiwiYWxnIjoiRVMyNTYiLCJ0eXAiOiJKV1QifQ\"\n"
+ " }\n" + "}\n" + "");
@@ -53,7 +53,7 @@ public class OpenIDVCICredentialsRequestTest {
@Test
public void success2() throws ParseException {
- httpRequest.setQuery("{\n" + " \"credential_identifier\": \"CivilEngineeringDegree-2023\",\n"
+ httpRequest.setBody("{\n" + " \"credential_identifier\": \"CivilEngineeringDegree-2023\",\n"
+ " \"proofs\": {\n" + " \"jwt\": [\n"
+ " \"eyJ0eXAiOiJvcGVuaWQ0dmNpLXByb29mK2p3dCIsImFsZyI6IkVTMjU2IiwiandrIjp7Imt0eSI6IkVDIiwiY3J2IjoiUC0yNTYiLCJ4IjoiblVXQW9BdjNYWml0aDhFN2kxOU9kYXhPTFlGT3dNLVoyRXVNMDJUaXJUNCIsInkiOiJIc2tIVThCalVpMVU5WHFpN1N3bWo4Z3dBS18weGtjRGpFV183MVNvc0VZIn19\",\n"
+ " \"eyJraWQiOiJkaWQ6ZXhhbXBsZTplYmZlYjFmNzEyZWJjNmYxYzI3NmUxMmVjMjEva2V5cy8xIiwiYWxnIjoiRVMyNTYiLCJ0eXAiOiJKV1QifQ\"\n"
@@ -67,7 +67,7 @@ public class OpenIDVCICredentialsRequestTest {
@Test(expectedExceptions = IllegalArgumentException.class)
public void failProofAndProofs() throws ParseException {
- httpRequest.setQuery("{\n" + " \"credential_configuration_id\": \"org.iso.18013.5.1.mDL\",\n"
+ httpRequest.setBody("{\n" + " \"credential_configuration_id\": \"org.iso.18013.5.1.mDL\",\n"
+ " \"proof\": {\n" + " \"proof_type\": \"jwt\",\n"
+ " \"jwt\": \"eyJraWQiOiJkaWQ6ZXhhbXBsZTplYmZlYjFmNzEyZWJjNmYxYzI3NmUxMmVjMjEva2V5cy8xIiwiYWxnIjoiRVMyNTYiLCJ0eXAiOiJKV1QifQ\"\n"
+ " }\n" + " \"proofs\": {\n" + " \"jwt\": [\n"
diff --git a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/messaging/impl/OpenIDVCITokenRequestTest.java b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/messaging/impl/OpenIDVCITokenRequestTest.java
index be5cdb8..18f3d8e 100644
--- a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/messaging/impl/OpenIDVCITokenRequestTest.java
+++ b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/messaging/impl/OpenIDVCITokenRequestTest.java
@@ -47,7 +47,7 @@ public class OpenIDVCITokenRequestTest {
protected void setUp() throws Exception {
httpRequest = new HTTPRequest(HTTPRequest.Method.POST, new URI("http://example.com"));
httpRequest.setContentType(ContentType.APPLICATION_URLENCODED.toString());
- httpRequest.setQuery(
+ httpRequest.setBody(
"client_id=clientID&grant_type=urn:ietf:params:oauth:grant-type:pre-authorized_code&pre-authorized_code=123456&tx_code=txCodeValue&code_verifier=wzYAXaFZTuabNSWQUDPZuvAzmwVETLFyNJmtJGDWheU&authorization_details=[{\"type\":\"openid_credential\",\"credential_configuration_id\":\"UniversityDegreeCredential\",\"claims\":[{\"path\":[\"credentialSubject\",\"given_name\"],\"mandatory\":true},{\"path\":[\"credentialSubject\",\"family_name\"]},{\"path\":[\"credentialSubject\",\"de [...]
message = OpenIDVCITokenRequest.parse(httpRequest);
@@ -116,7 +116,7 @@ public class OpenIDVCITokenRequestTest {
public void parseCodeGrant() throws ParseException, JsonProcessingException, URISyntaxException {
httpRequest = new HTTPRequest(HTTPRequest.Method.POST, new URI("http://example.com"));
httpRequest.setContentType(ContentType.APPLICATION_URLENCODED.toString());
- httpRequest.setQuery(
+ httpRequest.setBody(
"client_id=clientID&client_secret=12234&grant_type=authorization_code&code=123456&code_verifier=wzYAXaFZTuabNSWQUDPZuvAzmwVETLFyNJmtJGDWheU&authorization_details=[{\"type\":\"openid_credential\",\"credential_configuration_id\":\"UniversityDegreeCredential\",\"claims\":[{\"path\":[\"credentialSubject\",\"given_name\"],\"mandatory\":true},{\"path\":[\"credentialSubject\",\"family_name\"]},{\"path\":[\"credentialSubject\",\"degree\"]}]}]");
message = OpenIDVCITokenRequest.parse(httpRequest);
Assert.assertTrue(message.getAuthorizationGrant() instanceof AuthorizationCodeGrant);
diff --git a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/profile/impl/FilterPotentialCredentialsTest.java b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/profile/impl/FilterPotentialCredentialsTest.java
new file mode 100644
index 0000000..da75128
--- /dev/null
+++ b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/profile/impl/FilterPotentialCredentialsTest.java
@@ -0,0 +1,116 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.geant.shibboleth.plugin.openidvci.profile.impl;
+
+import java.util.List;
+
+import org.geant.shibboleth.plugin.openidvci.credential.impl.FilesystemCredentialConfigurationsResolver;
+import org.geant.shibboleth.plugin.openidvci.messaging.context.TokenContext;
+import org.geant.shibboleth.plugin.openidvci.messaging.impl.OpenIDVCIAuthorizationDetail;
+import org.opensaml.messaging.context.MessageContext;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.springframework.core.io.ClassPathResource;
+import org.springframework.webflow.execution.RequestContext;
+import org.testng.Assert;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+import com.nimbusds.oauth2.sdk.Scope;
+import com.nimbusds.openid.connect.sdk.claims.ClaimsSet;
+
+import net.shibboleth.idp.plugin.oidc.op.messaging.context.OIDCAuthenticationResponseContext;
+import net.shibboleth.idp.profile.context.navigate.WebflowRequestContextProfileRequestContextLookup;
+import net.shibboleth.idp.profile.testing.ActionTestingSupport;
+import net.shibboleth.idp.profile.testing.RequestContextBuilder;
+
+/**
+ * Unit tests for {@link FilterPotentialCredentials}.
+ */
+public class FilterPotentialCredentialsTest {
+
+ private static final String CONTENT = "[{\"path\":[\"mail\"],\"value\":\"incubatorUser at example.org\"}]";
+
+ private RequestContext requestCtx;
+
+ private TokenContext tokenContext;
+
+ private OIDCAuthenticationResponseContext responseCtx;
+
+ private FilesystemCredentialConfigurationsResolver resolver;
+
+ @BeforeMethod
+ protected void setUp() throws Exception {
+ requestCtx = new RequestContextBuilder().buildRequestContext();
+ final ProfileRequestContext profileRequestCtx =
+ new WebflowRequestContextProfileRequestContextLookup().apply(requestCtx);
+ profileRequestCtx.setInboundMessageContext(new MessageContext());
+ profileRequestCtx.setOutboundMessageContext(new MessageContext());
+ responseCtx = profileRequestCtx.getOutboundMessageContext()
+ .ensureSubcontext(OIDCAuthenticationResponseContext.class);
+ tokenContext = profileRequestCtx.getInboundMessageContext().ensureSubcontext(TokenContext.class);
+ final ClaimsSet potentialCredentials = new ClaimsSet();
+ potentialCredentials.setClaim("ScopedCredential", CONTENT);
+ potentialCredentials.setClaim("OtherCredential", CONTENT);
+ potentialCredentials.setClaim("UnscopedCredential", CONTENT);
+ tokenContext.setPotentialCredentials(potentialCredentials);
+ resolver = new FilesystemCredentialConfigurationsResolver(
+ new ClassPathResource("conf/verifiable-credentials-scoped.json"));
+ resolver.setId("test");
+ resolver.initialize();
+ }
+
+ private FilterPotentialCredentials actionOf(final List<OpenIDVCIAuthorizationDetail> details) throws Exception {
+ final FilterPotentialCredentials action = new FilterPotentialCredentials();
+ action.setCredentialConfigurationsResolver(resolver);
+ action.setAuthorizationDetailsLookupStrategy(prc -> details);
+ action.initialize();
+ return action;
+ }
+
+ @Test
+ public void testScope() throws Exception {
+ responseCtx.setScope(new Scope("openid", "ScopedCredential"));
+ ActionTestingSupport.assertProceedEvent(actionOf(null).execute(requestCtx));
+ Assert.assertEquals(tokenContext.getPotentialCredentials().toJSONObject().keySet(),
+ java.util.Set.of("ScopedCredential"));
+ }
+
+ @Test
+ public void testAuthorizationDetails() throws Exception {
+ ActionTestingSupport.assertProceedEvent(actionOf(List.of(new OpenIDVCIAuthorizationDetail(
+ "openid_credential", "UnscopedCredential", null, null))).execute(requestCtx));
+ Assert.assertEquals(tokenContext.getPotentialCredentials().toJSONObject().keySet(),
+ java.util.Set.of("UnscopedCredential"));
+ }
+
+ @Test
+ public void testScopeAndAuthorizationDetails() throws Exception {
+ responseCtx.setScope(new Scope("OtherCredential"));
+ ActionTestingSupport.assertProceedEvent(actionOf(List.of(new OpenIDVCIAuthorizationDetail(
+ "openid_credential", "ScopedCredential", null, null))).execute(requestCtx));
+ Assert.assertEquals(tokenContext.getPotentialCredentials().toJSONObject().keySet(),
+ java.util.Set.of("ScopedCredential", "OtherCredential"));
+ }
+
+ @Test
+ public void testNothingAuthorized() throws Exception {
+ responseCtx.setScope(new Scope("openid"));
+ ActionTestingSupport.assertProceedEvent(actionOf(null).execute(requestCtx));
+ Assert.assertTrue(tokenContext.getPotentialCredentials().toJSONObject().isEmpty());
+ }
+
+}
diff --git a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/profile/impl/ParseProofTest.java b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/profile/impl/ParseProofTest.java
index 90b9cc3..aa76d34 100644
--- a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/profile/impl/ParseProofTest.java
+++ b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/profile/impl/ParseProofTest.java
@@ -61,7 +61,7 @@ public class ParseProofTest {
httpRequest = new HTTPRequest(HTTPRequest.Method.POST, new URI("http://example.com"));
httpRequest.setAuthorization("Bearer dGVzdDp0ZXN0");
httpRequest.setContentType("application/json");
- httpRequest.setQuery("{\n" + " \"credential_configuration_id\": \"org.iso.18013.5.1.mDL\",\n"
+ httpRequest.setBody("{\n" + " \"credential_configuration_id\": \"org.iso.18013.5.1.mDL\",\n"
+ " \"proofs\": {\n"
+ " \"jwt\": [\"eyJ0eXAiOiJvcGVuaWQ0dmNpLXByb29mK2p3dCIsImFsZyI6IkVTMjU2Iiwia2lkIjoiZGlkOmp3azpleUpoYkdjaU9pSkZVekkxTmlJc0luVnpaU0k2SW5OcFp5SXNJbXQwZVNJNklrVkRJaXdpWTNKMklqb2lVQzB5TlRZaUxDSjRJam9pVWtGQ2RVaHNTR05IYkdwMlVWVkpkVTlyYTNKQlJEaHJYM2RUVFZSaGNuQnJOR0p5TnpaQldXUTJheUlzSW5raU9pSldYMEZNVERKamFEaEJWMTlHV0hKMVMxWnVZazV0U3poUmVFUnpNblE0UmpSdU5USlFhRWwxVjFObkluMCMwIn0.eyJhdWQiOiJodHRwczovL2dlYW50LXZjaS4yLnJhaHRpYXBwLmZpL29pZDR2Y2kiLCJpYXQiOjE3NDQyNzU1OTEsImV4cCI6MTc0N [...]
+ " \"eyJ0eXAiOiJvcGVuaWQ0dmNpLXByb29mK2p3dCIsImFsZyI6IkVTMjU2Iiwia2lkIjoiZGlkOmp3azpleUpoYkdjaU9pSkZVekkxTmlJc0luVnpaU0k2SW5OcFp5SXNJbXQwZVNJNklrVkRJaXdpWTNKMklqb2lVQzB5TlRZaUxDSjRJam9pVWtGQ2RVaHNTR05IYkdwMlVWVkpkVTlyYTNKQlJEaHJYM2RUVFZSaGNuQnJOR0p5TnpaQldXUTJheUlzSW5raU9pSldYMEZNVERKamFEaEJWMTlHV0hKMVMxWnVZazV0U3poUmVFUnpNblE0UmpSdU5USlFhRWwxVjFObkluMCMwIn0.eyJhdWQiOiJodHRwczovL2dlYW50LXZjaS4yLnJhaHRpYXBwLmZpL29pZDR2Y2kiLCJpYXQiOjE3NDQyNzU1OTEsImV4cCI6MTc0N [...]
@@ -96,7 +96,7 @@ public class ParseProofTest {
@Test
public void testUnsupportedProofs() throws ParseException {
- httpRequest.setQuery("{\n" + " \"credential_configuration_id\": \"org.iso.18013.5.1.mDL\",\n"
+ httpRequest.setBody("{\n" + " \"credential_configuration_id\": \"org.iso.18013.5.1.mDL\",\n"
+ " \"proofs\": {\n"
+ " \"ldp_vp\": [\"eyJraWQiOiJkaWQ6ZXhhbXBsZTplYmZlYjFmNzEyZWJjNmYxYzI3NmUxMmVjMjEva2V5cy8xIiwiYWxnIjoiRVMyNTYiLCJ0eXAiOiJKV1QifQ\"]\n"
+ " }\n" + "}\n" + "");
@@ -106,7 +106,7 @@ public class ParseProofTest {
@Test
public void testAlgNone() throws ParseException {
- httpRequest.setQuery("{\n" + " \"credential_configuration_id\": \"org.iso.18013.5.1.mDL\",\n"
+ httpRequest.setBody("{\n" + " \"credential_configuration_id\": \"org.iso.18013.5.1.mDL\",\n"
+ " \"proofs\": {\n"
+ " \"jwt\": [\"eyJ0eXAiOiJvcGVuaWQ0dmNpLXByb29mK2p3dCIsImFsZyI6Im5vbmUiLCJraWQiOiJkaWQ6andrOmV5SmhiR2NpT2lKRlV6STFOaUlzSW5WelpTSTZJbk5wWnlJc0ltdDBlU0k2SWtWRElpd2lZM0oySWpvaVVDMHlOVFlpTENKNElqb2lVa0ZDZFVoc1NHTkhiR3AyVVZWSmRVOXJhM0pCUkRoclgzZFRUVlJoY25Cck5HSnlOelpCV1dRMmF5SXNJbmtpT2lKV1gwRk1UREpqYURoQlYxOUdXSEoxUzFadVlrNXRTemhSZUVSek1uUTRSalJ1TlRKUWFFbDFWMU5uSW4wIzAifQ.eyJhdWQiOiJodHRwczovL2dlYW50LXZjaS4yLnJhaHRpYXBwLmZpL29pZDR2Y2kiLCJpYXQiOjE3NDQyNzU1OTEsImV4cCI6MTc0ND [...]
+ " }\n" + "}\n" + "");
@@ -117,7 +117,7 @@ public class ParseProofTest {
@Test
public void testAlgHMAC() throws ParseException {
- httpRequest.setQuery("{\n" + " \"credential_configuration_id\": \"org.iso.18013.5.1.mDL\",\n"
+ httpRequest.setBody("{\n" + " \"credential_configuration_id\": \"org.iso.18013.5.1.mDL\",\n"
+ " \"proofs\": {\n"
+ " \"jwt\": [\"eyJ0eXAiOiJvcGVuaWQ0dmNpLXByb29mK2p3dCIsImFsZyI6IkhTMjU2Iiwia2lkIjoiZGlkOmp3azpleUpoYkdjaU9pSkZVekkxTmlJc0luVnpaU0k2SW5OcFp5SXNJbXQwZVNJNklrVkRJaXdpWTNKMklqb2lVQzB5TlRZaUxDSjRJam9pVWtGQ2RVaHNTR05IYkdwMlVWVkpkVTlyYTNKQlJEaHJYM2RUVFZSaGNuQnJOR0p5TnpaQldXUTJheUlzSW5raU9pSldYMEZNVERKamFEaEJWMTlHV0hKMVMxWnVZazV0U3poUmVFUnpNblE0UmpSdU5USlFhRWwxVjFObkluMCMwIn0.eyJhdWQiOiJodHRwczovL2dlYW50LXZjaS4yLnJhaHRpYXBwLmZpL29pZDR2Y2kiLCJpYXQiOjE3NDQyNzU1OTEsImV4cCI6MTc0N [...]
+ " }\n" + "}\n" + "");
@@ -128,7 +128,7 @@ public class ParseProofTest {
@Test
public void testWrongType() throws ParseException {
- httpRequest.setQuery("{\n" + " \"credential_configuration_id\": \"org.iso.18013.5.1.mDL\",\n"
+ httpRequest.setBody("{\n" + " \"credential_configuration_id\": \"org.iso.18013.5.1.mDL\",\n"
+ " \"proofs\": {\n"
+ " \"jwt\": [\"eyJ0eXAiOiJzb21lZDR2Y2ktcHJvb2Yrand0IiwiYWxnIjoiRVMyNTYiLCJraWQiOiJkaWQ6andrOmV5SmhiR2NpT2lKRlV6STFOaUlzSW5WelpTSTZJbk5wWnlJc0ltdDBlU0k2SWtWRElpd2lZM0oySWpvaVVDMHlOVFlpTENKNElqb2lVa0ZDZFVoc1NHTkhiR3AyVVZWSmRVOXJhM0pCUkRoclgzZFRUVlJoY25Cck5HSnlOelpCV1dRMmF5SXNJbmtpT2lKV1gwRk1UREpqYURoQlYxOUdXSEoxUzFadVlrNXRTemhSZUVSek1uUTRSalJ1TlRKUWFFbDFWMU5uSW4wIzAifQ.eyJhdWQiOiJodHRwczovL2dlYW50LXZjaS4yLnJhaHRpYXBwLmZpL29pZDR2Y2kiLCJpYXQiOjE3NDQyNzU1OTEsImV4cCI6MTc0ND [...]
+ " }\n" + "}\n" + "");
@@ -138,7 +138,7 @@ public class ParseProofTest {
@Test
public void testMissingProofsWhenConfigurationRequiresThem() throws Exception {
- httpRequest.setQuery("{\n" + " \"credential_configuration_id\": \"org.iso.18013.5.1.mDL\"\n" + "}\n" + "");
+ httpRequest.setBody("{\n" + " \"credential_configuration_id\": \"org.iso.18013.5.1.mDL\"\n" + "}\n" + "");
profileRequestCtx.getInboundMessageContext().setMessage(OpenIDVCICredentialsRequest.parse(httpRequest));
profileRequestCtx.getInboundMessageContext().ensureSubcontext(CredentialsContext.class)
.setCredentialConfiguration(new ObjectMapper()
@@ -150,7 +150,7 @@ public class ParseProofTest {
@Test
public void testMissingProofsWhenConfigurationDoesNotRequireThem() throws Exception {
- httpRequest.setQuery("{\n" + " \"credential_configuration_id\": \"org.iso.18013.5.1.mDL\"\n" + "}\n" + "");
+ httpRequest.setBody("{\n" + " \"credential_configuration_id\": \"org.iso.18013.5.1.mDL\"\n" + "}\n" + "");
profileRequestCtx.getInboundMessageContext().setMessage(OpenIDVCICredentialsRequest.parse(httpRequest));
profileRequestCtx.getInboundMessageContext().ensureSubcontext(CredentialsContext.class)
.setCredentialConfiguration(
@@ -182,7 +182,7 @@ public class ParseProofTest {
@Test
public void testEmptyProofs() throws ParseException {
- httpRequest.setQuery("{\n" + " \"credential_configuration_id\": \"org.iso.18013.5.1.mDL\",\n"
+ httpRequest.setBody("{\n" + " \"credential_configuration_id\": \"org.iso.18013.5.1.mDL\",\n"
+ " \"proofs\": {\n" + " \"jwt\": []\n" + " }\n" + "}\n" + "");
profileRequestCtx.getInboundMessageContext().setMessage(OpenIDVCICredentialsRequest.parse(httpRequest));
ActionTestingSupport.assertEvent(action.execute(requestCtx), OpenIDVCIEventIds.INVALID_PROOF);
diff --git a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/profile/impl/SetAuthorizationDetailsToResponseContextTest.java b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/profile/impl/SetAuthorizationDetailsToResponseContextTest.java
new file mode 100644
index 0000000..c468ef4
--- /dev/null
+++ b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/profile/impl/SetAuthorizationDetailsToResponseContextTest.java
@@ -0,0 +1,126 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.geant.shibboleth.plugin.openidvci.profile.impl;
+
+import java.util.List;
+import java.util.Map;
+
+import org.geant.shibboleth.plugin.openidvci.messaging.context.TokenContext;
+import org.geant.shibboleth.plugin.openidvci.messaging.impl.CredentialDataset;
+import org.geant.shibboleth.plugin.openidvci.messaging.impl.OpenIDVCIAuthorizationDetail;
+import org.geant.shibboleth.plugin.openidvci.profile.OpenIDVCIEventIds;
+import org.geant.shibboleth.plugin.openidvci.profile.logic.OpenIDVCITokenManipulationStrategy;
+import org.opensaml.messaging.context.MessageContext;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.springframework.webflow.execution.RequestContext;
+import org.testng.Assert;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+import com.nimbusds.openid.connect.sdk.claims.ClaimsSet;
+
+import net.shibboleth.idp.profile.context.navigate.WebflowRequestContextProfileRequestContextLookup;
+import net.shibboleth.idp.profile.testing.ActionTestingSupport;
+import net.shibboleth.idp.profile.testing.RequestContextBuilder;
+
+/**
+ * Unit tests for {@link SetAuthorizationDetailsToResponseContext}.
+ */
+public class SetAuthorizationDetailsToResponseContextTest {
+
+ private static final String CONTENT = "[{\"path\":[\"mail\"],\"value\":\"incubatorUser at example.org\"}]";
+
+ private static final String DATASETS = "[{\"dataset_id\":\"bsc\",\"claims\":" + CONTENT + "},"
+ + "{\"dataset_id\":\"msc\",\"claims\":" + CONTENT + "}]";
+
+ private RequestContext requestCtx;
+
+ private TokenContext tokenContext;
+
+ private ClaimsSet potentialCredentials;
+
+ @BeforeMethod
+ protected void setUp() throws Exception {
+ requestCtx = new RequestContextBuilder().buildRequestContext();
+ final ProfileRequestContext profileRequestCtx =
+ new WebflowRequestContextProfileRequestContextLookup().apply(requestCtx);
+ profileRequestCtx.setInboundMessageContext(new MessageContext());
+ tokenContext = profileRequestCtx.getInboundMessageContext().ensureSubcontext(TokenContext.class);
+ potentialCredentials = new ClaimsSet();
+ tokenContext.setPotentialCredentials(potentialCredentials);
+ }
+
+ private SetAuthorizationDetailsToResponseContext actionOf(final List<OpenIDVCIAuthorizationDetail> details)
+ throws Exception {
+ final SetAuthorizationDetailsToResponseContext action = new SetAuthorizationDetailsToResponseContext();
+ action.setAuthorizationDetailsLookupStrategy(prc -> details);
+ action.initialize();
+ return action;
+ }
+
+ private static List<OpenIDVCIAuthorizationDetail> requested(final String configurationId) {
+ return List.of(new OpenIDVCIAuthorizationDetail("openid_credential", configurationId, null, null));
+ }
+
+ @Test
+ public void testNotRequested() throws Exception {
+ potentialCredentials.setClaim("GeantIncubatorDiploma", CONTENT);
+ ActionTestingSupport.assertProceedEvent(actionOf(null).execute(requestCtx));
+ Assert.assertNull(tokenContext.getResponseAuthorizationDetails());
+ }
+
+ @Test
+ public void testRequested() throws Exception {
+ potentialCredentials.setClaim("GeantIncubatorDiploma", CONTENT);
+ ActionTestingSupport.assertProceedEvent(actionOf(requested("GeantIncubatorDiploma")).execute(requestCtx));
+ final List<OpenIDVCIAuthorizationDetail> details = tokenContext.getResponseAuthorizationDetails();
+ Assert.assertNotNull(details);
+ Assert.assertEquals(details.get(0).getCredentialIdentifiers(), List.of("GeantIncubatorDiploma"));
+ }
+
+ @Test
+ public void testRequestedSeveral() throws Exception {
+ potentialCredentials.setClaim("GeantIncubatorDiploma", DATASETS);
+ ActionTestingSupport.assertProceedEvent(actionOf(requested("GeantIncubatorDiploma")).execute(requestCtx));
+ final List<OpenIDVCIAuthorizationDetail> details = tokenContext.getResponseAuthorizationDetails();
+ Assert.assertNotNull(details);
+ Assert.assertEquals(details.get(0).getCredentialIdentifiers(),
+ List.of("GeantIncubatorDiploma#bsc", "GeantIncubatorDiploma#msc"));
+ Assert.assertEquals(tokenContext.getIssuedCredentialIdentifiers().get("GeantIncubatorDiploma#msc"),
+ Map.of(OpenIDVCITokenManipulationStrategy.CREDENTIAL_CONFIGURATION_ID, "GeantIncubatorDiploma",
+ CredentialDataset.DATASET_ID, "msc"));
+ }
+
+ @Test
+ public void testSeveralNotRequested() throws Exception {
+ potentialCredentials.setClaim("GeantIncubatorDiploma", DATASETS);
+ potentialCredentials.setClaim("GeantIncubatorDiploma2", CONTENT);
+ ActionTestingSupport.assertProceedEvent(actionOf(null).execute(requestCtx));
+ final List<OpenIDVCIAuthorizationDetail> details = tokenContext.getResponseAuthorizationDetails();
+ Assert.assertNotNull(details);
+ Assert.assertEquals(details.size(), 1);
+ Assert.assertEquals(details.get(0).getCredentialConfigurationId(), "GeantIncubatorDiploma");
+ }
+
+ @Test
+ public void testRequestedNotAvailable() throws Exception {
+ potentialCredentials.setClaim("GeantIncubatorDiploma", CONTENT);
+ ActionTestingSupport.assertEvent(actionOf(requested("GeantIncubatorDiploma2")).execute(requestCtx),
+ OpenIDVCIEventIds.NO_CREDENTIALS_FOR_REQUEST);
+ }
+
+}
diff --git a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/profile/impl/SetDPoPProofThumbprintToResponseContextTest.java b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/profile/impl/SetDPoPProofThumbprintToResponseContextTest.java
new file mode 100644
index 0000000..14f1a7b
--- /dev/null
+++ b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/profile/impl/SetDPoPProofThumbprintToResponseContextTest.java
@@ -0,0 +1,96 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.geant.shibboleth.plugin.openidvci.profile.impl;
+
+import org.geant.shibboleth.plugin.openidvci.decoding.impl.OpenIDVCITokenRequestDecoder;
+import org.opensaml.messaging.context.MessageContext;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.webflow.execution.RequestContext;
+import org.testng.Assert;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+import jakarta.servlet.http.HttpServletRequest;
+import net.shibboleth.idp.plugin.oidc.op.messaging.context.OIDCAuthenticationResponseContext;
+import net.shibboleth.idp.profile.context.navigate.WebflowRequestContextProfileRequestContextLookup;
+import net.shibboleth.idp.profile.testing.ActionTestingSupport;
+import net.shibboleth.idp.profile.testing.RequestContextBuilder;
+import net.shibboleth.shared.primitive.NonnullSupplier;
+
+/**
+ * Unit tests for {@link SetDPoPProofThumbprintToResponseContext}.
+ */
+public class SetDPoPProofThumbprintToResponseContextTest {
+
+ private RequestContext requestCtx;
+
+ private OIDCAuthenticationResponseContext responseCtx;
+
+ @BeforeMethod
+ protected void setUp() throws Exception {
+ final MockHttpServletRequest httpRequest = new MockHttpServletRequest();
+ httpRequest.setMethod("POST");
+ httpRequest.addHeader("Content-Type", "application/x-www-form-urlencoded");
+ httpRequest.addParameter("client_id", "123456");
+ httpRequest.addParameter("pre-authorized_code", "123456");
+ httpRequest.addParameter("grant_type", "urn:ietf:params:oauth:grant-type:pre-authorized_code");
+ final OpenIDVCITokenRequestDecoder decoder = new OpenIDVCITokenRequestDecoder();
+ decoder.setHttpServletRequestSupplier(new NonnullSupplier<>() {
+ public HttpServletRequest get() {
+ return httpRequest;
+ }
+ });
+ decoder.initialize();
+ decoder.decode();
+
+ requestCtx = new RequestContextBuilder().buildRequestContext();
+ final ProfileRequestContext profileRequestCtx =
+ new WebflowRequestContextProfileRequestContextLookup().apply(requestCtx);
+ profileRequestCtx.setInboundMessageContext(decoder.getMessageContext());
+ profileRequestCtx.setOutboundMessageContext(new MessageContext());
+ responseCtx = profileRequestCtx.getOutboundMessageContext()
+ .ensureSubcontext(OIDCAuthenticationResponseContext.class);
+ }
+
+ @Test
+ public void testBound() throws Exception {
+ final SetDPoPProofThumbprintToResponseContext action = new SetDPoPProofThumbprintToResponseContext();
+ action.setDpopProofThumbprintLookupStrategy(prc -> "thumbprint");
+ action.initialize();
+ ActionTestingSupport.assertProceedEvent(action.execute(requestCtx));
+ Assert.assertEquals(responseCtx.getDpopProofJwkThumbprint(), "thumbprint");
+ }
+
+ @Test
+ public void testNoProof() throws Exception {
+ final SetDPoPProofThumbprintToResponseContext action = new SetDPoPProofThumbprintToResponseContext();
+ action.setDpopProofThumbprintLookupStrategy(prc -> null);
+ action.initialize();
+ ActionTestingSupport.assertProceedEvent(action.execute(requestCtx));
+ Assert.assertNull(responseCtx.getDpopProofJwkThumbprint());
+ }
+
+ @Test
+ public void testDefaultLookupNoProof() throws Exception {
+ final SetDPoPProofThumbprintToResponseContext action = new SetDPoPProofThumbprintToResponseContext();
+ action.initialize();
+ ActionTestingSupport.assertProceedEvent(action.execute(requestCtx));
+ Assert.assertNull(responseCtx.getDpopProofJwkThumbprint());
+ }
+
+}
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 0c1f411..853118a 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
@@ -58,7 +58,7 @@ public class ValidateCredentialOfferingTest {
HTTPRequest httpRequest = new HTTPRequest(HTTPRequest.Method.POST, new URI("http://example.com"));
httpRequest.setAuthorization("Basic dGVzdDp0ZXN0");
httpRequest.setContentType("application/json");
- httpRequest.setQuery(
+ httpRequest.setBody(
"{\"preAuthorizedCredentials\":{\"GeantIncubatorDiploma\":[{\"path\":[\"mail\"],\"value\":\"incubatorUser at example.org\"},{\"path\":[\"edu\",\"eppn\"],\"value\":\"incubatorUser at example.org\"},{\"path\":[\"givenName\"],\"value\":\"Gemma Gemina Erasmus\"},{\"path\":[\"familyName\"],\"value\":\"Erasmus\"}]}}");
requestCtx = new RequestContextBuilder().buildRequestContext();
@@ -126,18 +126,51 @@ public class ValidateCredentialOfferingTest {
httpRequest.setAuthorization("Basic dGVzdDp0ZXN0");
httpRequest.setContentType("application/json");
// Request omits the mandatory "mail" claim declared in the configuration.
- httpRequest.setQuery(
+ httpRequest.setBody(
"{\"preAuthorizedCredentials\":{\"GeantIncubatorDiploma\":[{\"path\":[\"givenName\"],\"value\":\"Gemma\"},{\"path\":[\"familyName\"],\"value\":\"Erasmus\"}]}}");
profileRequestCtx.getInboundMessageContext().setMessage(CredentialOfferRequest.parse(httpRequest));
ActionTestingSupport.assertEvent(action.execute(requestCtx), OpenIDVCIEventIds.NO_CREDENTIALS_REQUEST);
}
+ @Test
+ public void testSuccessDatasets() throws Exception {
+ final String claims = "[{\"path\":[\"mail\"],\"value\":\"incubatorUser at example.org\"},"
+ + "{\"path\":[\"givenName\"],\"value\":\"Gemma\"},{\"path\":[\"familyName\"],\"value\":\"Erasmus\"}]";
+ HTTPRequest httpRequest = new HTTPRequest(HTTPRequest.Method.POST, new URI("http://example.com"));
+ httpRequest.setAuthorization("Basic dGVzdDp0ZXN0");
+ httpRequest.setContentType("application/json");
+ httpRequest.setBody("{\"preAuthorizedCredentials\":{\"GeantIncubatorDiploma\":["
+ + "{\"dataset_id\":\"a\",\"claims\":" + claims + "},"
+ + "{\"dataset_id\":\"b\",\"claims\":" + claims + "}]}}");
+ profileRequestCtx.getInboundMessageContext().setMessage(CredentialOfferRequest.parse(httpRequest));
+ ActionTestingSupport.assertProceedEvent(action.execute(requestCtx));
+ CredentialOfferContext ctx = profileRequestCtx.getInboundMessageContext()
+ .getSubcontext(CredentialOfferContext.class);
+ Assert.notNull(ctx, "Must not be null");
+ org.testng.Assert.assertEquals(ctx.getValidatedPreAuthorizedCredentials().get("GeantIncubatorDiploma").size(),
+ 2);
+ }
+
+ @Test
+ public void testDatasetMissingMandatoryClaim() throws Exception {
+ final String claims = "[{\"path\":[\"mail\"],\"value\":\"incubatorUser at example.org\"},"
+ + "{\"path\":[\"givenName\"],\"value\":\"Gemma\"},{\"path\":[\"familyName\"],\"value\":\"Erasmus\"}]";
+ HTTPRequest httpRequest = new HTTPRequest(HTTPRequest.Method.POST, new URI("http://example.com"));
+ httpRequest.setAuthorization("Basic dGVzdDp0ZXN0");
+ httpRequest.setContentType("application/json");
+ httpRequest.setBody("{\"preAuthorizedCredentials\":{\"GeantIncubatorDiploma\":["
+ + "{\"dataset_id\":\"a\",\"claims\":" + claims + "},"
+ + "{\"dataset_id\":\"b\",\"claims\":[{\"path\":[\"givenName\"],\"value\":\"Gemma\"}]}]}}");
+ profileRequestCtx.getInboundMessageContext().setMessage(CredentialOfferRequest.parse(httpRequest));
+ ActionTestingSupport.assertEvent(action.execute(requestCtx), OpenIDVCIEventIds.NO_CREDENTIALS_REQUEST);
+ }
+
@Test(expectedExceptions = ParseException.class)
public void testCodeFlowRejected() throws Exception {
HTTPRequest httpRequest = new HTTPRequest(HTTPRequest.Method.POST, new URI("http://example.com"));
httpRequest.setAuthorization("Basic dGVzdDp0ZXN0");
httpRequest.setContentType("application/json");
- httpRequest.setQuery("{\"codeCredentials\":[\"GeantIncubatorDiploma\"]}");
+ httpRequest.setBody("{\"codeCredentials\":[\"GeantIncubatorDiploma\"]}");
CredentialOfferRequest.parse(httpRequest);
}
@@ -146,7 +179,7 @@ public class ValidateCredentialOfferingTest {
HTTPRequest httpRequest = new HTTPRequest(HTTPRequest.Method.POST, new URI("http://example.com"));
httpRequest.setAuthorization("Basic dGVzdDp0ZXN0");
httpRequest.setContentType("application/json");
- httpRequest.setQuery(
+ httpRequest.setBody(
"{\"preAuthorizedCredentials\":{\"GeantIncubatorDiploma2\":[{\"path\":[\"credentialSubject\",\"mail\"],\"value\":\"incubatorUser at example.org\"},{\"path\":[\"credentialSubject\",\"givenName\"],\"value\":\"Gemma\"},{\"path\":[\"credentialSubject\",\"familyName\"],\"value\":\"Erasmus\"}]}}");
profileRequestCtx.getInboundMessageContext().setMessage(CredentialOfferRequest.parse(httpRequest));
ActionTestingSupport.assertProceedEvent(action.execute(requestCtx));
@@ -162,7 +195,7 @@ public class ValidateCredentialOfferingTest {
HTTPRequest httpRequest = new HTTPRequest(HTTPRequest.Method.POST, new URI("http://example.com"));
httpRequest.setAuthorization("Basic dGVzdDp0ZXN0");
httpRequest.setContentType("application/json");
- httpRequest.setQuery(
+ httpRequest.setBody(
"{\"preAuthorizedCredentials\":{\"GeantIncubatorDiploma2\":[{\"path\":[\"credentialSubject\",\"givenName\"],\"value\":\"Gemma\"},{\"path\":[\"credentialSubject\",\"familyName\"],\"value\":\"Erasmus\"}]}}");
profileRequestCtx.getInboundMessageContext().setMessage(CredentialOfferRequest.parse(httpRequest));
ActionTestingSupport.assertEvent(action.execute(requestCtx), OpenIDVCIEventIds.NO_CREDENTIALS_REQUEST);
@@ -173,7 +206,7 @@ public class ValidateCredentialOfferingTest {
HTTPRequest httpRequest = new HTTPRequest(HTTPRequest.Method.POST, new URI("http://example.com"));
httpRequest.setAuthorization("Basic dGVzdDp0ZXN0");
httpRequest.setContentType("application/json");
- httpRequest.setQuery("{\"codeCredentials\":[\"GeantIncubatorDiploma2\"]}");
+ httpRequest.setBody("{\"codeCredentials\":[\"GeantIncubatorDiploma2\"]}");
CredentialOfferRequest.parse(httpRequest);
}
diff --git a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/profile/impl/ValidateRequestedCredentialTest.java b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/profile/impl/ValidateRequestedCredentialTest.java
new file mode 100644
index 0000000..a2d34d1
--- /dev/null
+++ b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/profile/impl/ValidateRequestedCredentialTest.java
@@ -0,0 +1,226 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.geant.shibboleth.plugin.openidvci.profile.impl;
+
+import java.net.URI;
+import java.time.Instant;
+import java.util.Map;
+
+import org.geant.shibboleth.plugin.openidvci.credential.impl.FilesystemCredentialConfigurationsResolver;
+import org.geant.shibboleth.plugin.openidvci.messaging.context.CredentialsContext;
+import org.geant.shibboleth.plugin.openidvci.messaging.impl.CredentialDataset;
+import org.geant.shibboleth.plugin.openidvci.messaging.impl.OpenIDVCICredentialsRequest;
+import org.geant.shibboleth.plugin.openidvci.profile.OpenIDVCIEventIds;
+import org.geant.shibboleth.plugin.openidvci.profile.config.impl.DefaultOpenIDVCICredentialsConfiguration;
+import org.geant.shibboleth.plugin.openidvci.profile.logic.OpenIDVCITokenManipulationStrategy;
+import org.opensaml.messaging.context.MessageContext;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.springframework.core.io.ClassPathResource;
+import org.springframework.webflow.execution.RequestContext;
+import org.testng.Assert;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+import com.nimbusds.oauth2.sdk.http.HTTPRequest;
+import com.nimbusds.oauth2.sdk.id.ClientID;
+
+import net.shibboleth.idp.plugin.oidc.op.messaging.context.OIDCAuthenticationResponseContext;
+import net.shibboleth.idp.plugin.oidc.op.token.support.AccessTokenClaimsSet;
+import net.shibboleth.idp.profile.context.navigate.WebflowRequestContextProfileRequestContextLookup;
+import net.shibboleth.idp.profile.testing.ActionTestingSupport;
+import net.shibboleth.idp.profile.testing.RequestContextBuilder;
+import net.shibboleth.profile.context.RelyingPartyContext;
+
+/**
+ * Unit tests for {@link ValidateRequestedCredential}.
+ */
+public class ValidateRequestedCredentialTest {
+
+ private static final String CONTENT = "[{\"path\":[\"mail\"],\"value\":\"incubatorUser at example.org\"}]";
+
+ private static final String DATASETS = "[{\"dataset_id\":\"bsc\",\"claims\":" + CONTENT + "},"
+ + "{\"dataset_id\":\"msc\",\"claims\":" + CONTENT + "}]";
+
+ private RequestContext requestCtx;
+
+ private ProfileRequestContext profileRequestCtx;
+
+ private DefaultOpenIDVCICredentialsConfiguration configuration;
+
+ private ValidateRequestedCredential action;
+
+ @BeforeMethod
+ protected void setUp() throws Exception {
+ requestCtx = new RequestContextBuilder().buildRequestContext();
+ profileRequestCtx = new WebflowRequestContextProfileRequestContextLookup().apply(requestCtx);
+ profileRequestCtx.setInboundMessageContext(new MessageContext());
+ profileRequestCtx.setOutboundMessageContext(new MessageContext());
+ configuration = new DefaultOpenIDVCICredentialsConfiguration();
+ profileRequestCtx.ensureSubcontext(RelyingPartyContext.class).setProfileConfig(configuration);
+ final FilesystemCredentialConfigurationsResolver resolver = new FilesystemCredentialConfigurationsResolver(
+ new ClassPathResource("conf/verifiable-credentials.json"));
+ resolver.setId("test");
+ resolver.initialize();
+ action = new ValidateRequestedCredential();
+ action.setCredentialConfigurationsResolver(resolver);
+ action.initialize();
+ }
+
+ private void setRequest(final String body) throws Exception {
+ final HTTPRequest httpRequest = new HTTPRequest(HTTPRequest.Method.POST, new URI("http://example.com"));
+ httpRequest.setAuthorization("Bearer dGVzdDp0ZXN0");
+ httpRequest.setContentType("application/json");
+ httpRequest.setBody(body);
+ profileRequestCtx.getInboundMessageContext().setMessage(OpenIDVCICredentialsRequest.parse(httpRequest));
+ }
+
+ private void setGrant(final Map<String, Object> potentialClaims, final Map<String, Object> identifiers) {
+ final AccessTokenClaimsSet.Builder builder = new AccessTokenClaimsSet.Builder();
+ builder.setJWTID("jti").setClientID(new ClientID("client")).setIssuer("issuer").setPrincipal("principal")
+ .setSubject("subject").setIssuedAt(Instant.now()).setExpiresAt(Instant.now().plusSeconds(60))
+ .setAuthenticationTime(Instant.now());
+ builder.addCustomClaim(OpenIDVCITokenManipulationStrategy.POTENTIAL_CLAIMS, potentialClaims);
+ if (identifiers != null) {
+ builder.addCustomClaim(OpenIDVCITokenManipulationStrategy.CREDENTIAL_IDENTIFIERS, identifiers);
+ }
+ profileRequestCtx.getOutboundMessageContext().ensureSubcontext(OIDCAuthenticationResponseContext.class)
+ .setAuthorizationGrantClaimsSet(builder.build());
+ }
+
+ private static Map<String, Object> issued(final String configurationId) {
+ return Map.of(OpenIDVCITokenManipulationStrategy.CREDENTIAL_CONFIGURATION_ID, configurationId);
+ }
+
+ private static Map<String, Object> issued(final String configurationId, final String datasetId) {
+ return Map.of(OpenIDVCITokenManipulationStrategy.CREDENTIAL_CONFIGURATION_ID, configurationId,
+ CredentialDataset.DATASET_ID, datasetId);
+ }
+
+ @Test
+ public void testConfigurationId() throws Exception {
+ setRequest("{\"credential_configuration_id\":\"GeantIncubatorDiploma\"}");
+ setGrant(Map.of("GeantIncubatorDiploma", CONTENT), null);
+ ActionTestingSupport.assertProceedEvent(action.execute(requestCtx));
+ final CredentialsContext ctx = profileRequestCtx.getInboundMessageContext()
+ .getSubcontext(CredentialsContext.class);
+ Assert.assertNotNull(ctx);
+ Assert.assertEquals(ctx.getCredentialIdentifier(), "GeantIncubatorDiploma");
+ Assert.assertEquals(ctx.getCredentialConfigurationId(), "GeantIncubatorDiploma");
+ Assert.assertNull(ctx.getDatasetId());
+ Assert.assertNotNull(ctx.getCredentialConfiguration());
+ Assert.assertNotNull(ctx.getCredentialContent());
+ }
+
+ @Test
+ public void testUnknownConfigurationId() throws Exception {
+ setRequest("{\"credential_configuration_id\":\"Unknown\"}");
+ setGrant(Map.of("GeantIncubatorDiploma", CONTENT), null);
+ ActionTestingSupport.assertEvent(action.execute(requestCtx), OpenIDVCIEventIds.NO_CREDENTIALS_FOR_REQUEST);
+ }
+
+ @Test
+ public void testConfigurationIdNotAuthorized() throws Exception {
+ setRequest("{\"credential_configuration_id\":\"GeantIncubatorDiploma2\"}");
+ setGrant(Map.of("GeantIncubatorDiploma", CONTENT), null);
+ ActionTestingSupport.assertEvent(action.execute(requestCtx), OpenIDVCIEventIds.NO_CREDENTIALS_FOR_REQUEST);
+ }
+
+ @Test
+ public void testConfigurationIdWithIssuedIdentifiers() throws Exception {
+ setRequest("{\"credential_configuration_id\":\"GeantIncubatorDiploma\"}");
+ setGrant(Map.of("GeantIncubatorDiploma", CONTENT),
+ Map.of("GeantIncubatorDiploma", issued("GeantIncubatorDiploma")));
+ ActionTestingSupport.assertEvent(action.execute(requestCtx), OpenIDVCIEventIds.NO_CREDENTIAL_REQUEST);
+ }
+
+ @Test
+ public void testConfigurationIdWithIssuedIdentifiersAllowed() throws Exception {
+ configuration.setAllowCredentialConfigurationIdWithIdentifiers(true);
+ setRequest("{\"credential_configuration_id\":\"GeantIncubatorDiploma\"}");
+ setGrant(Map.of("GeantIncubatorDiploma", CONTENT),
+ Map.of("GeantIncubatorDiploma", issued("GeantIncubatorDiploma")));
+ ActionTestingSupport.assertProceedEvent(action.execute(requestCtx));
+ }
+
+ @Test
+ public void testIdentifier() throws Exception {
+ setRequest("{\"credential_identifier\":\"GeantIncubatorDiploma#bsc\"}");
+ setGrant(Map.of("GeantIncubatorDiploma", DATASETS),
+ Map.of("GeantIncubatorDiploma#bsc", issued("GeantIncubatorDiploma", "bsc"),
+ "GeantIncubatorDiploma#msc", issued("GeantIncubatorDiploma", "msc")));
+ ActionTestingSupport.assertProceedEvent(action.execute(requestCtx));
+ final CredentialsContext ctx = profileRequestCtx.getInboundMessageContext()
+ .getSubcontext(CredentialsContext.class);
+ Assert.assertNotNull(ctx);
+ Assert.assertEquals(ctx.getCredentialIdentifier(), "GeantIncubatorDiploma#bsc");
+ Assert.assertEquals(ctx.getCredentialConfigurationId(), "GeantIncubatorDiploma");
+ Assert.assertEquals(ctx.getDatasetId(), "bsc");
+ Assert.assertNotNull(ctx.getCredentialConfiguration());
+ }
+
+ @Test
+ public void testConfigurationIdSeveralDatasets() throws Exception {
+ configuration.setAllowCredentialConfigurationIdWithIdentifiers(true);
+ setRequest("{\"credential_configuration_id\":\"GeantIncubatorDiploma\"}");
+ setGrant(Map.of("GeantIncubatorDiploma", DATASETS),
+ Map.of("GeantIncubatorDiploma#bsc", issued("GeantIncubatorDiploma", "bsc"),
+ "GeantIncubatorDiploma#msc", issued("GeantIncubatorDiploma", "msc")));
+ ActionTestingSupport.assertEvent(action.execute(requestCtx), OpenIDVCIEventIds.NO_CREDENTIAL_REQUEST);
+ }
+
+ @Test
+ public void testUnknownIdentifier() throws Exception {
+ setRequest("{\"credential_identifier\":\"unknown:e6d200f6\"}");
+ setGrant(Map.of("GeantIncubatorDiploma", CONTENT),
+ Map.of("GeantIncubatorDiploma", issued("GeantIncubatorDiploma")));
+ ActionTestingSupport.assertEvent(action.execute(requestCtx),
+ OpenIDVCIEventIds.UNKNOWN_CREDENTIAL_IDENTIFIER);
+ }
+
+ @Test
+ public void testIdentifierNoneIssued() throws Exception {
+ setRequest("{\"credential_identifier\":\"GeantIncubatorDiploma\"}");
+ setGrant(Map.of("GeantIncubatorDiploma", CONTENT), null);
+ ActionTestingSupport.assertEvent(action.execute(requestCtx),
+ OpenIDVCIEventIds.UNKNOWN_CREDENTIAL_IDENTIFIER);
+ }
+
+ @Test
+ public void testIdentifierWithoutContent() throws Exception {
+ setRequest("{\"credential_identifier\":\"GeantIncubatorDiploma#phd\"}");
+ setGrant(Map.of("GeantIncubatorDiploma", DATASETS),
+ Map.of("GeantIncubatorDiploma#phd", issued("GeantIncubatorDiploma", "phd")));
+ ActionTestingSupport.assertEvent(action.execute(requestCtx),
+ OpenIDVCIEventIds.UNKNOWN_CREDENTIAL_IDENTIFIER);
+ }
+
+ @Test
+ public void testBoth() throws Exception {
+ setRequest("{\"credential_identifier\":\"GeantIncubatorDiploma\","
+ + "\"credential_configuration_id\":\"GeantIncubatorDiploma\"}");
+ setGrant(Map.of("GeantIncubatorDiploma", CONTENT), null);
+ ActionTestingSupport.assertEvent(action.execute(requestCtx), OpenIDVCIEventIds.NO_CREDENTIAL_REQUEST);
+ }
+
+ @Test
+ public void testNeither() throws Exception {
+ setRequest("{}");
+ setGrant(Map.of("GeantIncubatorDiploma", CONTENT), null);
+ ActionTestingSupport.assertEvent(action.execute(requestCtx), OpenIDVCIEventIds.NO_CREDENTIAL_REQUEST);
+ }
+
+}
diff --git a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/storage/CredentialOfferCacheTest.java b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/storage/CredentialOfferCacheTest.java
index 234c61b..0422d52 100644
--- a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/storage/CredentialOfferCacheTest.java
+++ b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/storage/CredentialOfferCacheTest.java
@@ -55,7 +55,7 @@ public class CredentialOfferCacheTest {
HTTPRequest httpRequest = new HTTPRequest(HTTPRequest.Method.POST, new URI("http://example.com"));
httpRequest.setAuthorization("Basic dGVzdDp0ZXN0");
httpRequest.setContentType("application/json");
- httpRequest.setQuery(
+ httpRequest.setBody(
"{\"preAuthorizedCredentials\":{\"GeantIncubatorSdJwt2\":[{\"path\":[\"mail\"],\"value\":\"incubatorUser at example.org\"},{\"path\":[\"edu\",\"eppn\"],\"value\":\"incubatorUser at example.org\"},{\"path\":[\"givenName\"],\"value\":\"Gemma Gemina Erasmus\"},{\"path\":[\"familyName\"],\"value\":\"Erasmus\"}]}}");
credentialOfferObject = new CredentialOfferObject(new ClientID("clientID_XYZ"),
CredentialOfferRequest.parse(httpRequest).getPreAuthorizedCredentials(),
diff --git a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/storage/CredentialOfferObjectTest.java b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/storage/CredentialOfferObjectTest.java
index 85266d1..7b8fdd5 100644
--- a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/storage/CredentialOfferObjectTest.java
+++ b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/storage/CredentialOfferObjectTest.java
@@ -43,7 +43,7 @@ public class CredentialOfferObjectTest {
HTTPRequest httpRequest = new HTTPRequest(HTTPRequest.Method.POST, new URI("http://example.com"));
httpRequest.setAuthorization("Basic dGVzdDp0ZXN0");
httpRequest.setContentType("application/json");
- httpRequest.setQuery(
+ httpRequest.setBody(
"{\"preAuthorizedCredentials\":{\"GeantIncubatorSdJwt2\":[{\"path\":[\"mail\"],\"value\":\"incubatorUser at example.org\"},{\"path\":[\"edu\",\"eppn\"],\"value\":\"incubatorUser at example.org\"},{\"path\":[\"givenName\"],\"value\":\"Gemma Gemina Erasmus\"},{\"path\":[\"familyName\"],\"value\":\"Erasmus\"}]}}");
object = new CredentialOfferObject(new ClientID("clientID_XYZ"),
CredentialOfferRequest.parse(httpRequest).getPreAuthorizedCredentials(),
@@ -53,7 +53,7 @@ public class CredentialOfferObjectTest {
Assert.assertEquals(parsedObject.getCodeCredentials().size(), 3);
Assert.assertEquals(parsedObject.getCodeCredentials().get(2), "GeantIncubatorSdJwt3");
Assert.assertTrue(parsedObject.getPreAuthorizedCredentials().containsKey("GeantIncubatorSdJwt2"));
- Assert.assertEquals(parsedObject.getPreAuthorizedCredentials().get("GeantIncubatorSdJwt2").getRequestedCredential()
+ Assert.assertEquals(parsedObject.getPreAuthorizedCredentials().get("GeantIncubatorSdJwt2").get(0).getContent().getRequestedCredential()
.get(0).getValue(), "incubatorUser at example.org");
Assert.assertEquals(parsedObject.getCredentialOfferTxCode().getValue(), "12345");
diff --git a/openid-vci-impl/src/test/resources/conf/verifiable-credentials-scoped.json b/openid-vci-impl/src/test/resources/conf/verifiable-credentials-scoped.json
new file mode 100644
index 0000000..35b1114
--- /dev/null
+++ b/openid-vci-impl/src/test/resources/conf/verifiable-credentials-scoped.json
@@ -0,0 +1,24 @@
+{
+ "credential_configurations_supported": {
+ "ScopedCredential": {
+ "format": "dc+sd-jwt",
+ "vct": "ScopedCredential",
+ "scope": "ScopedCredential",
+ "cryptographic_binding_methods_supported": [ "did:jwk" ],
+ "proof_types_supported": { "jwt": { "proof_signing_alg_values_supported": [ "ES256" ] } }
+ },
+ "OtherCredential": {
+ "format": "dc+sd-jwt",
+ "vct": "OtherCredential",
+ "scope": "OtherCredential",
+ "cryptographic_binding_methods_supported": [ "did:jwk" ],
+ "proof_types_supported": { "jwt": { "proof_signing_alg_values_supported": [ "ES256" ] } }
+ },
+ "UnscopedCredential": {
+ "format": "dc+sd-jwt",
+ "vct": "UnscopedCredential",
+ "cryptographic_binding_methods_supported": [ "did:jwk" ],
+ "proof_types_supported": { "jwt": { "proof_signing_alg_values_supported": [ "ES256" ] } }
+ }
+ }
+}
--
To stop receiving notification emails like this one, please contact
the administrator of this repository.
More information about the commits
mailing list