Skip to content

Commit 5467558

Browse files
add claim extraction to package
Signed-off-by: F-Node-Karlsruhe <christian.fries@eecc.de>
1 parent 3fcb0cf commit 5467558

11 files changed

Lines changed: 171 additions & 13 deletions

File tree

.cursor/notes/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ See [module-layout.md](module-layout.md) for embedding patterns, pluggable depen
3232
- **Wallet URL building** with inline or `request_uri` transport
3333
- **Direct post handling** with optional `response_code` (`DirectPostResult`)
3434
- **Pluggable** repository and verifier; `Oid4Vp.builder()` for tests and host wiring
35-
- **DCQL query models**, GS1 template, `PresentationParser`, sealed `Oid4VpError`
35+
- **DCQL query models**, GS1 template, `PresentationParser`, `PresentationClaims` extraction via `PresentationRequestDefinition`, sealed `Oid4VpError`
3636

3737
## Development & Release
3838

.cursor/notes/module-layout.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
- `GenerateRequestOptions.builderSupplier` + `beforeSave` — set app fields before `save()`
1919
- `PollStatusResolver` — override poll UX; default handles `response_code`, `completed`, verification errors
2020
- `Oid4VpError` sealed hierarchy — map errors without parsing HTTP status from messages
21+
- `PresentationRequestDefinition.extractPresentationClaims` + `Oid4Vp.extractPresentationClaims` — template-driven claim extraction from stored `vp_token`
2122

2223
## Spring Boot (optional)
2324

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- `PresentationRequestDefinition.extractPresentationClaims(JsonNode)` for template-specific claim extraction from verified `vp_token` responses
13+
- `Oid4Vp.extractPresentationClaims(...)` helpers to parse stored tokens and validate primary claim values
14+
- `EmptyPresentationClaims` error (HTTP 401) when extracted `PresentationClaims.values()` is empty
15+
1016
## [0.4.3] - 2026-06-15
1117

1218
- merge ttl into one property

README.md

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -85,25 +85,25 @@ PresentationRequestDefinition myDefinition = new PresentationRequestDefinition()
8585
PresentationRequest request = oid4Vp.generatePresentationRequest(myDefinition);
8686
```
8787

88-
After verification, add your own logic to read claims from the `vp_token` (see `PresentationParser` or the GS1 template below).
89-
90-
#### Built-in template: GS1 License Presentation
91-
92-
The library also ships a ready-made definition for GS1 Company Prefix and Prefix License credentials:
88+
After verification, extract claims via the same definition used to generate the request:
9389

9490
```java
9591
import de.eecc.oid4vc.oid4vp.PresentationClaims;
9692
import de.eecc.oid4vc.oid4vp.request.PresentationRequest;
9793
import de.eecc.oid4vc.oid4vp.request.template.gs1.Gs1LicenseRequest;
9894

9995
PresentationRequest request = oid4Vp.generatePresentationRequest(Gs1LicenseRequest.INSTANCE);
100-
String walletUrl = oid4Vp.toOpenId4VpUrl(request);
101-
102-
// After direct_post verification:
103-
PresentationClaims claims = Gs1LicenseRequest.extractPresentationClaims(vpTokenNode);
96+
// ... after direct_post verification ...
97+
PresentationClaims claims = oid4Vp.extractPresentationClaims(Gs1LicenseRequest.INSTANCE, request);
10498
List<String> gcps = claims.values();
10599
```
106100

101+
Lower-level access: `definition.extractPresentationClaims(vpTokenNode)` or `PresentationParser` for format-specific parsing.
102+
103+
#### Built-in template: GS1 License Presentation
104+
105+
The library ships a ready-made definition for GS1 Company Prefix and Prefix License credentials (`Gs1LicenseRequest.INSTANCE`).
106+
107107
Application-specific attributes (for example `purpose` or an organisation id) belong on a
108108
subclass of `PresentationRequest` and are never serialized to the wallet.
109109

oid4vp-java/oid4vp-core/src/main/java/de/eecc/oid4vc/oid4vp/api/Oid4Vp.java

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,14 @@
44
import com.fasterxml.jackson.databind.JsonNode;
55
import com.fasterxml.jackson.databind.ObjectMapper;
66
import de.eecc.oid4vc.oid4vp.Constants;
7+
import de.eecc.oid4vc.oid4vp.PresentationClaims;
78
import de.eecc.oid4vc.oid4vp.request.PresentationRequest;
89
import de.eecc.oid4vc.oid4vp.request.PresentationRequestDefinition;
910
import de.eecc.oid4vc.oid4vp.request.PublicPresentationRequest;
1011
import de.eecc.oid4vc.oid4vp.VerificationStatus;
1112
import de.eecc.oid4vc.oid4vp.VpTokenResponse;
1213
import de.eecc.oid4vc.oid4vp.exception.AlreadyConsumed;
14+
import de.eecc.oid4vc.oid4vp.exception.EmptyPresentationClaims;
1315
import de.eecc.oid4vc.oid4vp.exception.ExpiredState;
1416
import de.eecc.oid4vc.oid4vp.exception.InternalError;
1517
import de.eecc.oid4vc.oid4vp.exception.InvalidVpToken;
@@ -342,6 +344,46 @@ public java.util.List<Object> buildVerifierRequestBody(JsonNode presentationNode
342344
return verifier.buildVerifierRequestBody(presentationNode);
343345
}
344346

347+
/**
348+
* Extracts presentation claims from the {@code vp_token} stored on a verified request.
349+
*
350+
* @throws Oid4VpException with {@link InvalidVpToken} when the stored token is missing or invalid JSON
351+
* @throws Oid4VpException with {@link EmptyPresentationClaims} when {@link PresentationClaims#values()} is empty
352+
*/
353+
public PresentationClaims extractPresentationClaims(
354+
PresentationRequestDefinition definition, PresentationRequest request) {
355+
String vpToken = request.getVpToken();
356+
if (vpToken == null || vpToken.isBlank()) {
357+
throw new Oid4VpException(new InvalidVpToken("No vp_token on presentation request"));
358+
}
359+
return extractPresentationClaims(definition, vpToken);
360+
}
361+
362+
/**
363+
* Extracts presentation claims from a verified {@code vp_token} JSON string.
364+
*
365+
* @throws Oid4VpException with {@link InvalidVpToken} when JSON parsing fails
366+
* @throws Oid4VpException with {@link EmptyPresentationClaims} when {@link PresentationClaims#values()} is empty
367+
*/
368+
public PresentationClaims extractPresentationClaims(
369+
PresentationRequestDefinition definition, String vpToken) {
370+
return extractPresentationClaims(definition, parseVpToken(vpToken));
371+
}
372+
373+
/**
374+
* Extracts presentation claims from a parsed {@code vp_token}.
375+
*
376+
* @throws Oid4VpException with {@link EmptyPresentationClaims} when {@link PresentationClaims#values()} is empty
377+
*/
378+
public PresentationClaims extractPresentationClaims(
379+
PresentationRequestDefinition definition, JsonNode vpTokenNode) {
380+
PresentationClaims claims = definition.extractPresentationClaims(vpTokenNode);
381+
if (claims.values().isEmpty()) {
382+
throw new Oid4VpException(new EmptyPresentationClaims());
383+
}
384+
return claims;
385+
}
386+
345387
private JsonNode parseVpToken(String vpToken) {
346388
try {
347389
return objectMapper.readTree(vpToken);
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package de.eecc.oid4vc.oid4vp.exception;
2+
3+
public record EmptyPresentationClaims() implements Oid4VpError {
4+
5+
@Override
6+
public String message() {
7+
return "Verified presentation does not contain the requested primary claim values";
8+
}
9+
10+
@Override
11+
public int suggestedHttpStatus() {
12+
return 401;
13+
}
14+
}

oid4vp-java/oid4vp-core/src/main/java/de/eecc/oid4vc/oid4vp/exception/Oid4VpError.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ public sealed interface Oid4VpError permits
99
AlreadyConsumed,
1010
InvalidVpToken,
1111
VerificationFailed,
12+
EmptyPresentationClaims,
1213
InternalError {
1314

1415
String message();

oid4vp-java/oid4vp-core/src/main/java/de/eecc/oid4vc/oid4vp/request/PresentationRequestDefinition.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package de.eecc.oid4vc.oid4vp.request;
22

3+
import com.fasterxml.jackson.databind.JsonNode;
34
import de.eecc.oid4vc.oid4vp.ClientMetadata;
45
import de.eecc.oid4vc.oid4vp.DcqlQuery;
6+
import de.eecc.oid4vc.oid4vp.PresentationClaims;
57

68
/**
79
* Credential-specific parts of an OID4VP authorization request.
@@ -14,4 +16,9 @@ public interface PresentationRequestDefinition {
1416
default ClientMetadata clientMetadata() {
1517
return ClientMetadata.presentationDefault();
1618
}
19+
20+
/**
21+
* Extracts claims from a verified {@code vp_token} according to this definition's DCQL query.
22+
*/
23+
PresentationClaims extractPresentationClaims(JsonNode vpTokenNode);
1724
}

oid4vp-java/oid4vp-core/src/main/java/de/eecc/oid4vc/oid4vp/request/template/gs1/Gs1LicenseRequest.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,8 @@ public DcqlQuery.Query dcqlQuery() {
139139
* Extracts GCP and organisation metadata from a verified {@code vp_token}.
140140
* Organisation fields use the first GCP credential encountered.
141141
*/
142-
public static PresentationClaims extractPresentationClaims(JsonNode vpTokenNode) {
142+
@Override
143+
public PresentationClaims extractPresentationClaims(JsonNode vpTokenNode) {
143144
List<String> extractedGcps = new ArrayList<>();
144145
String firstOrganizationName = null;
145146
String firstPartyGln = null;
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package de.eecc.oid4vc.oid4vp.api;
2+
3+
import com.fasterxml.jackson.databind.ObjectMapper;
4+
import de.eecc.oid4vc.oid4vp.PresentationClaims;
5+
import de.eecc.oid4vc.oid4vp.exception.EmptyPresentationClaims;
6+
import de.eecc.oid4vc.oid4vp.exception.InvalidVpToken;
7+
import de.eecc.oid4vc.oid4vp.exception.Oid4VpException;
8+
import de.eecc.oid4vc.oid4vp.request.PresentationRequest;
9+
import de.eecc.oid4vc.oid4vp.request.template.gs1.Gs1LicenseRequest;
10+
import org.junit.jupiter.api.BeforeEach;
11+
import org.junit.jupiter.api.Test;
12+
13+
import static org.assertj.core.api.Assertions.assertThat;
14+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
15+
16+
class Oid4VpExtractPresentationClaimsTest {
17+
18+
private static final ObjectMapper MAPPER = new ObjectMapper();
19+
20+
private Oid4Vp oid4Vp;
21+
22+
@BeforeEach
23+
void setUp() {
24+
oid4Vp = Oid4Vp.builder()
25+
.options(Oid4VpOptions.builder()
26+
.responseUri("https://example.com/api/auth/oid4vp/response")
27+
.build())
28+
.objectMapper(MAPPER)
29+
.build();
30+
}
31+
32+
@Test
33+
void extractPresentationClaims_fromStoredRequest() throws Exception {
34+
PresentationRequest request = oid4Vp.generatePresentationRequest(Gs1LicenseRequest.INSTANCE);
35+
request.setVpToken("""
36+
{
37+
"gs1_license": [{
38+
"verifiableCredential": [{
39+
"type": ["VerifiableCredential", "GS1CompanyPrefixLicenseCredential"],
40+
"credentialSubject": {
41+
"licenseValue": "0614141",
42+
"organization": {
43+
"gs1:organizationName": "ACME",
44+
"gs1:partyGLN": "9501234567890"
45+
}
46+
}
47+
}]
48+
}]
49+
}
50+
""");
51+
52+
PresentationClaims claims = oid4Vp.extractPresentationClaims(Gs1LicenseRequest.INSTANCE, request);
53+
54+
assertThat(claims.values()).containsExactly("0614141");
55+
assertThat(claims.name()).isEqualTo("ACME");
56+
assertThat(claims.identifier()).isEqualTo("9501234567890");
57+
}
58+
59+
@Test
60+
void extractPresentationClaims_emptyValues_throwsEmptyPresentationClaims() {
61+
assertThatThrownBy(() -> oid4Vp.extractPresentationClaims(
62+
Gs1LicenseRequest.INSTANCE, "{\"gs1_license\": [{\"verifiableCredential\": []}]}"))
63+
.isInstanceOf(Oid4VpException.class)
64+
.satisfies(ex -> {
65+
Oid4VpException oid4VpException = (Oid4VpException) ex;
66+
assertThat(oid4VpException.error()).isInstanceOf(EmptyPresentationClaims.class);
67+
assertThat(oid4VpException.error().suggestedHttpStatus()).isEqualTo(401);
68+
});
69+
}
70+
71+
@Test
72+
void extractPresentationClaims_missingVpToken_throwsInvalidVpToken() {
73+
PresentationRequest request = oid4Vp.generatePresentationRequest(Gs1LicenseRequest.INSTANCE);
74+
75+
assertThatThrownBy(() -> oid4Vp.extractPresentationClaims(Gs1LicenseRequest.INSTANCE, request))
76+
.isInstanceOf(Oid4VpException.class)
77+
.satisfies(ex -> assertThat(((Oid4VpException) ex).error()).isInstanceOf(InvalidVpToken.class));
78+
}
79+
80+
@Test
81+
void extractPresentationClaims_invalidJson_throwsInvalidVpToken() {
82+
assertThatThrownBy(() -> oid4Vp.extractPresentationClaims(Gs1LicenseRequest.INSTANCE, "not-json"))
83+
.isInstanceOf(Oid4VpException.class)
84+
.satisfies(ex -> assertThat(((Oid4VpException) ex).error()).isInstanceOf(InvalidVpToken.class));
85+
}
86+
}

0 commit comments

Comments
 (0)