From c7666ad27b562ed13a05e2657ffc0b52708e1b1c Mon Sep 17 00:00:00 2001 From: Jarlath Holleran Date: Fri, 3 Oct 2025 17:36:45 +0100 Subject: [PATCH 1/3] Renamed field used for AG link to AR --- .../com/inrupt/client/accessgrant/AccessGrantClient.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/access-grant/src/main/java/com/inrupt/client/accessgrant/AccessGrantClient.java b/access-grant/src/main/java/com/inrupt/client/accessgrant/AccessGrantClient.java index 41fd98596a..ade99998ff 100644 --- a/access-grant/src/main/java/com/inrupt/client/accessgrant/AccessGrantClient.java +++ b/access-grant/src/main/java/com/inrupt/client/accessgrant/AccessGrantClient.java @@ -106,7 +106,7 @@ public class AccessGrantClient { private static final String IS_CONSENT_FOR_DATA_SUBJECT = "isConsentForDataSubject"; private static final String FOR_PERSONAL_DATA = "forPersonalData"; private static final String HAS_STATUS = "hasStatus"; - private static final String REQUEST = "request"; + private static final String VERIFIED_REQUEST = "verifiedRequest"; private static final String MODE = "mode"; private static final String PROVIDED_CONSENT = "providedConsent"; private static final String FOR_PURPOSE = "forPurpose"; @@ -724,7 +724,7 @@ static Map buildAccessDenialv1(final URI agent, final Set r consent.put(HAS_STATUS, "https://w3id.org/GConsent#ConsentStatusRefused"); consent.put(FOR_PERSONAL_DATA, resources); consent.put(IS_PROVIDED_TO, agent); - consent.put(REQUEST, accessRequest); + consent.put(VERIFIED_REQUEST, accessRequest); if (!purposes.isEmpty()) { consent.put(FOR_PURPOSE, purposes); } @@ -755,7 +755,7 @@ static Map buildAccessGrantv1(final URI agent, final Set re consent.put(HAS_STATUS, "https://w3id.org/GConsent#ConsentStatusExplicitlyGiven"); consent.put(FOR_PERSONAL_DATA, resources); consent.put(IS_PROVIDED_TO, agent); - consent.put(REQUEST, accessRequest); + consent.put(VERIFIED_REQUEST, accessRequest); if (!purposes.isEmpty()) { consent.put(FOR_PURPOSE, purposes); } From fa206fed476c592d3024609901845979b98040d2 Mon Sep 17 00:00:00 2001 From: Nicolas Ayral Seydoux Date: Sat, 4 Oct 2025 23:25:59 +0200 Subject: [PATCH 2/3] Make access request verification optional This makes request verification opt-in for backwards-compatibility --- .../client/accessgrant/AccessGrant.java | 4 +- .../client/accessgrant/AccessGrantClient.java | 65 ++++++++++++++++--- .../accessgrant/AccessGrantClientTest.java | 19 ++++-- .../accessgrant/MockAccessGrantServer.java | 11 ++++ .../src/test/resources/vc-4-verified.json | 34 ++++++++++ access-grant/src/test/resources/vc-4.json | 3 +- 6 files changed, 118 insertions(+), 18 deletions(-) create mode 100644 access-grant/src/test/resources/vc-4-verified.json diff --git a/access-grant/src/main/java/com/inrupt/client/accessgrant/AccessGrant.java b/access-grant/src/main/java/com/inrupt/client/accessgrant/AccessGrant.java index 101dea1c89..8dfef4d2b4 100644 --- a/access-grant/src/main/java/com/inrupt/client/accessgrant/AccessGrant.java +++ b/access-grant/src/main/java/com/inrupt/client/accessgrant/AccessGrant.java @@ -138,7 +138,9 @@ static AccessGrant parse(final String serialization) throws IOException { final Optional other = asUri(consent.get("isProvidedTo")); final URI recipient = person.orElseGet(() -> controller.orElseGet(() -> other.orElse(null))); - final URI accessRequest = asUri(consent.get("request")).orElse(null); + final URI accessRequest = asUri(consent.get("verifiedRequest")).orElse( + asUri(consent.get("request")).orElse(null) + ); final Set modes = asSet(consent.get("mode")).orElseGet(Collections::emptySet); final Set resources = asSet(consent.get("forPersonalData")).orElseGet(Collections::emptySet) .stream().map(URI::create).collect(Collectors.toSet()); diff --git a/access-grant/src/main/java/com/inrupt/client/accessgrant/AccessGrantClient.java b/access-grant/src/main/java/com/inrupt/client/accessgrant/AccessGrantClient.java index ade99998ff..fc96a626e2 100644 --- a/access-grant/src/main/java/com/inrupt/client/accessgrant/AccessGrantClient.java +++ b/access-grant/src/main/java/com/inrupt/client/accessgrant/AccessGrantClient.java @@ -106,6 +106,7 @@ public class AccessGrantClient { private static final String IS_CONSENT_FOR_DATA_SUBJECT = "isConsentForDataSubject"; private static final String FOR_PERSONAL_DATA = "forPersonalData"; private static final String HAS_STATUS = "hasStatus"; + private static final String REQUEST = "request"; private static final String VERIFIED_REQUEST = "verifiedRequest"; private static final String MODE = "mode"; private static final String PROVIDED_CONSENT = "providedConsent"; @@ -252,17 +253,28 @@ private CompletionStage requestAccess(final URI recipient, final } /** - * Issue an access grant based on an access request. + * Issue an access grant based on an access request. The access request is not verified. * * @param request the access request * @return the next stage of completion containing the issued access grant */ public CompletionStage grantAccess(final AccessRequest request) { + return grantAccess(request, false); + } + + /** + * Issue an access grant based on an access request. + * + * @param request the access request + * @param verifyRequest whether the request should be verified before issuing the access grant + * @return the next stage of completion containing the issued access grant + */ + public CompletionStage grantAccess(final AccessRequest request, final boolean verifyRequest) { Objects.requireNonNull(request, "Request may not be null!"); return v1Metadata().thenCompose(metadata -> { final Map data = buildAccessGrantv1(request.getCreator(), request.getResources(), request.getModes(), request.getPurposes(), request.getExpiration(), request.getIssuedAt(), - request.getIdentifier()); + request.getIdentifier(), verifyRequest); final Request req = Request.newBuilder(metadata.issueEndpoint) .header(CONTENT_TYPE, APPLICATION_JSON) .POST(Request.BodyPublishers.ofByteArray(serialize(data))).build(); @@ -285,17 +297,28 @@ public CompletionStage grantAccess(final AccessRequest request) { } /** - * Issue an access denial receipt based on an access request. + * Issue an access denial receipt based on an access request. The access request is not verified. * * @param request the access request * @return the next stage of completion containing the issued access denial */ public CompletionStage denyAccess(final AccessRequest request) { + return denyAccess(request, false); + } + + /** + * Issue an access denial receipt based on an access request. + * + * @param request the access request + * @param verifyRequest whether the request should be verified before issuing the access denial + * @return the next stage of completion containing the issued access denial + */ + public CompletionStage denyAccess(final AccessRequest request, final boolean verifyRequest) { Objects.requireNonNull(request, "Request may not be null!"); return v1Metadata().thenCompose(metadata -> { final Map data = buildAccessDenialv1(request.getCreator(), request.getResources(), request.getModes(), request.getPurposes(), request.getExpiration(), request.getIssuedAt(), - request.getIdentifier()); + request.getIdentifier(), verifyRequest); final Request req = Request.newBuilder(metadata.issueEndpoint) .header(CONTENT_TYPE, APPLICATION_JSON) .POST(Request.BodyPublishers.ofByteArray(serialize(data))).build(); @@ -716,15 +739,26 @@ static URI asUri(final Object value) { return null; } - static Map buildAccessDenialv1(final URI agent, final Set resources, final Set modes, - final Set purposes, final Instant expiration, final Instant issuance, final URI accessRequest) { + static Map buildAccessDenialv1( + final URI agent, + final Set resources, + final Set modes, + final Set purposes, + final Instant expiration, + final Instant issuance, + final URI accessRequest, + final boolean verifiedRequest) { Objects.requireNonNull(agent, "Access denial agent may not be null!"); final Map consent = new HashMap<>(); consent.put(MODE, modes); consent.put(HAS_STATUS, "https://w3id.org/GConsent#ConsentStatusRefused"); consent.put(FOR_PERSONAL_DATA, resources); consent.put(IS_PROVIDED_TO, agent); - consent.put(VERIFIED_REQUEST, accessRequest); + if (verifiedRequest) { + consent.put(VERIFIED_REQUEST, accessRequest); + } else { + consent.put(REQUEST, accessRequest); + } if (!purposes.isEmpty()) { consent.put(FOR_PURPOSE, purposes); } @@ -747,15 +781,26 @@ static Map buildAccessDenialv1(final URI agent, final Set r return data; } - static Map buildAccessGrantv1(final URI agent, final Set resources, final Set modes, - final Set purposes, final Instant expiration, final Instant issuance, final URI accessRequest) { + static Map buildAccessGrantv1( + final URI agent, + final Set resources, + final Set modes, + final Set purposes, + final Instant expiration, + final Instant issuance, + final URI accessRequest, + final boolean verifiedRequest) { Objects.requireNonNull(agent, "Access grant agent may not be null!"); final Map consent = new HashMap<>(); consent.put(MODE, modes); consent.put(HAS_STATUS, "https://w3id.org/GConsent#ConsentStatusExplicitlyGiven"); consent.put(FOR_PERSONAL_DATA, resources); consent.put(IS_PROVIDED_TO, agent); - consent.put(VERIFIED_REQUEST, accessRequest); + if (verifiedRequest) { + consent.put(VERIFIED_REQUEST, accessRequest); + } else { + consent.put(REQUEST, accessRequest); + } if (!purposes.isEmpty()) { consent.put(FOR_PURPOSE, purposes); } diff --git a/access-grant/src/test/java/com/inrupt/client/accessgrant/AccessGrantClientTest.java b/access-grant/src/test/java/com/inrupt/client/accessgrant/AccessGrantClientTest.java index e977a10e07..750847aaf1 100644 --- a/access-grant/src/test/java/com/inrupt/client/accessgrant/AccessGrantClientTest.java +++ b/access-grant/src/test/java/com/inrupt/client/accessgrant/AccessGrantClientTest.java @@ -53,6 +53,8 @@ import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; class AccessGrantClientTest { @@ -299,8 +301,9 @@ void testRequestAccessNoAuth() { assertInstanceOf(AccessGrantException.class, err.getCause()); } - @Test - void testGrantAccess() { + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void testGrantAccess(final boolean verifyRequest) { final Map claims = new HashMap<>(); claims.put("webid", WEBID); claims.put("sub", SUB); @@ -318,7 +321,7 @@ void testGrantAccess() { final AccessRequest request = client.requestAccess(recipient, resources, modes, purposes, expiration) .toCompletableFuture().join(); - final AccessGrant grant = client.grantAccess(request).toCompletableFuture().join(); + final AccessGrant grant = client.grantAccess(request, verifyRequest).toCompletableFuture().join(); assertTrue(grant.getTypes().contains("SolidAccessGrant")); assertEquals(Optional.of(recipient), grant.getRecipient()); @@ -327,10 +330,14 @@ void testGrantAccess() { assertEquals(baseUri, grant.getIssuer()); assertEquals(purposes, grant.getPurposes()); assertEquals(resources, grant.getResources()); + // The request URL is static in the mock response, but it is dynamic in the request, so they will mismatch + // if compared. + assertNotNull(grant.getAccessRequest()); } - @Test - void testDenyAccess() { + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void testDenyAccess(final boolean verifyRequest) { final Map claims = new HashMap<>(); claims.put("webid", WEBID); claims.put("sub", SUB); @@ -348,7 +355,7 @@ void testDenyAccess() { final AccessRequest request = client.requestAccess(recipient, resources, modes, purposes, expiration) .toCompletableFuture().join(); - final AccessDenial denial = client.denyAccess(request).toCompletableFuture().join(); + final AccessDenial denial = client.denyAccess(request, verifyRequest).toCompletableFuture().join(); assertTrue(denial.getTypes().contains("SolidAccessDenial")); assertEquals(Optional.of(recipient), denial.getRecipient()); diff --git a/access-grant/src/test/java/com/inrupt/client/accessgrant/MockAccessGrantServer.java b/access-grant/src/test/java/com/inrupt/client/accessgrant/MockAccessGrantServer.java index dda3e6423a..9a3eb57dec 100644 --- a/access-grant/src/test/java/com/inrupt/client/accessgrant/MockAccessGrantServer.java +++ b/access-grant/src/test/java/com/inrupt/client/accessgrant/MockAccessGrantServer.java @@ -277,6 +277,17 @@ private void setupMocks() { .withHeader("Content-Type", "application/json") .withBody(getResource("/vc-4.json", wireMockServer.baseUrl())))); + wireMockServer.stubFor(post(urlEqualTo("/issue")) + .atPriority(1) + .withHeader("Authorization", containing("Bearer eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.")) + .withRequestBody(containing("\"providedConsent\"")) + .withRequestBody(containing("\"2022-08-27T12:00:00Z\"")) + .withRequestBody(containing("\"verifiedRequest\"")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(getResource("/vc-4-verified.json", wireMockServer.baseUrl())))); + // Access Request wireMockServer.stubFor(post(urlEqualTo("/issue")) .atPriority(1) diff --git a/access-grant/src/test/resources/vc-4-verified.json b/access-grant/src/test/resources/vc-4-verified.json new file mode 100644 index 0000000000..ee74a4f5a3 --- /dev/null +++ b/access-grant/src/test/resources/vc-4-verified.json @@ -0,0 +1,34 @@ +{ + "@context":[ + "https://www.w3.org/2018/credentials/v1", + "https://w3id.org/security/suites/ed25519-2020/v1", + "https://w3id.org/vc-revocation-list-2020/v1", + "https://schema.inrupt.com/credentials/v2.jsonld"], + "id":"{{baseUrl}}/access-grant-4", + "type":["VerifiableCredential","SolidAccessGrant"], + "issuer":"{{baseUrl}}", + "expirationDate":"2022-08-27T12:00:00Z", + "issuanceDate":"2022-08-25T20:34:05.153Z", + "credentialStatus":{ + "id":"https://accessgrant.example/status/CVAM#2832", + "revocationListCredential":"https://accessgrant.example/status/CVAM", + "revocationListIndex":"2832", + "type":"RevocationList2020Status"}, + "credentialSubject":{ + "id":"https://id.test/username", + "providedConsent":{ + "mode":["Read","Append"], + "hasStatus":"https://w3id.org/GConsent#ConsentStatusExplicitlyGiven", + "isProvidedTo":"https://id.test/agent", + "forPurpose":["https://purpose.test/Purpose1"], + "forPersonalData":["https://storage.test/data/"], + "verifiedRequest": "http://localhost:33367/access-request-5"}}, + + "proof":{ + "created":"2022-08-25T20:34:05.236Z", + "proofPurpose":"assertionMethod", + "proofValue":"nIeQF44XVik7onnAbdkbp8xxJ2C8JoTw6-VtCkAzxuWYRFsSfYpft5MuAJaivyeKDmaK82Lj_YsME2xgL2WIBQ", + "type":"Ed25519Signature2020", + "verificationMethod":"https://accessgrant.example/key/1e332728-4af5-46e4-a5db-4f7b89e3f378"} +} + diff --git a/access-grant/src/test/resources/vc-4.json b/access-grant/src/test/resources/vc-4.json index dbb008e45f..e090339700 100644 --- a/access-grant/src/test/resources/vc-4.json +++ b/access-grant/src/test/resources/vc-4.json @@ -21,7 +21,8 @@ "hasStatus":"https://w3id.org/GConsent#ConsentStatusExplicitlyGiven", "isProvidedTo":"https://id.test/agent", "forPurpose":["https://purpose.test/Purpose1"], - "forPersonalData":["https://storage.test/data/"]}}, + "forPersonalData":["https://storage.test/data/"], + "request": "http://localhost:33367/access-request-5"}}, "proof":{ "created":"2022-08-25T20:34:05.236Z", "proofPurpose":"assertionMethod", From 49cc22fd0548c64bad3e32a23fa974d20571e4f2 Mon Sep 17 00:00:00 2001 From: Nicolas Ayral Seydoux Date: Mon, 6 Oct 2025 16:23:08 +0200 Subject: [PATCH 3/3] Reduce duplication --- .../client/accessgrant/AccessGrantClient.java | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/access-grant/src/main/java/com/inrupt/client/accessgrant/AccessGrantClient.java b/access-grant/src/main/java/com/inrupt/client/accessgrant/AccessGrantClient.java index fc96a626e2..89716d47a5 100644 --- a/access-grant/src/main/java/com/inrupt/client/accessgrant/AccessGrantClient.java +++ b/access-grant/src/main/java/com/inrupt/client/accessgrant/AccessGrantClient.java @@ -754,11 +754,7 @@ static Map buildAccessDenialv1( consent.put(HAS_STATUS, "https://w3id.org/GConsent#ConsentStatusRefused"); consent.put(FOR_PERSONAL_DATA, resources); consent.put(IS_PROVIDED_TO, agent); - if (verifiedRequest) { - consent.put(VERIFIED_REQUEST, accessRequest); - } else { - consent.put(REQUEST, accessRequest); - } + linkRequest(consent, accessRequest, verifiedRequest); if (!purposes.isEmpty()) { consent.put(FOR_PURPOSE, purposes); } @@ -796,11 +792,7 @@ static Map buildAccessGrantv1( consent.put(HAS_STATUS, "https://w3id.org/GConsent#ConsentStatusExplicitlyGiven"); consent.put(FOR_PERSONAL_DATA, resources); consent.put(IS_PROVIDED_TO, agent); - if (verifiedRequest) { - consent.put(VERIFIED_REQUEST, accessRequest); - } else { - consent.put(REQUEST, accessRequest); - } + linkRequest(consent, accessRequest, verifiedRequest); if (!purposes.isEmpty()) { consent.put(FOR_PURPOSE, purposes); } @@ -906,4 +898,12 @@ static boolean isAccessDenial(final URI type) { return SOLID_ACCESS_DENIAL.equals(type.toString()) || QN_ACCESS_DENIAL.equals(type) || FQ_ACCESS_DENIAL.equals(type); } + + private static void linkRequest(final Map consent, final URI request, final boolean verifiedLink) { + if (verifiedLink) { + consent.put(VERIFIED_REQUEST, request); + } else { + consent.put(REQUEST, request); + } + } }