Skip to content

feat: download status list credential with EdcHttpClient #5030

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@

import java.net.URISyntaxException;
import java.time.Clock;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledFuture;
Expand All @@ -80,6 +82,7 @@
import static org.eclipse.edc.iam.identitytrust.spi.DcpConstants.DSPACE_DCP_NAMESPACE_V_1_0;
import static org.eclipse.edc.iam.identitytrust.spi.DcpConstants.DSPACE_DCP_V_1_0_CONTEXT;
import static org.eclipse.edc.iam.verifiablecredentials.spi.VcConstants.STATUSLIST_2021_URL;
import static org.eclipse.edc.iam.verifiablecredentials.spi.validation.TrustedIssuerRegistry.WILDCARD;
import static org.eclipse.edc.spi.constants.CoreConstants.JSON_LD;
import static org.eclipse.edc.verifiablecredentials.jwt.JwtPresentationVerifier.JWT_VC_TOKEN_CONTEXT;

Expand Down Expand Up @@ -157,6 +160,8 @@ public class IdentityAndTrustExtension implements ServiceExtension {
private PresentationVerifier presentationVerifier;
private CredentialServiceClient credentialServiceClient;
private ScheduledFuture<?> jtiEntryReaperThread;
@Setting(key = "edc.iam.credential.revocation.mimetype", description = "A comma-separated list of accepted content types of the revocation list credential.", defaultValue = WILDCARD)
private String contentTypes;

@Override
public void initialize(ServiceExtensionContext context) {
Expand Down Expand Up @@ -185,15 +190,20 @@ public void initialize(ServiceExtensionContext context) {
participantAgentService.register(participantAgentServiceExtension);

// register revocation services
revocationServiceRegistry.addService(StatusList2021Status.TYPE, new StatusList2021RevocationService(typeManager.getMapper(), revocationCacheValidity));
revocationServiceRegistry.addService(BitstringStatusListStatus.TYPE, new BitstringStatusListRevocationService(typeManager.getMapper(), revocationCacheValidity));
var acceptedContentTypes = parseAcceptedContentTypes(contentTypes);
revocationServiceRegistry.addService(StatusList2021Status.TYPE, new StatusList2021RevocationService(typeManager.getMapper(), revocationCacheValidity, acceptedContentTypes, httpClient));
revocationServiceRegistry.addService(BitstringStatusListStatus.TYPE, new BitstringStatusListRevocationService(typeManager.getMapper(), revocationCacheValidity, acceptedContentTypes, httpClient));
}

private Collection<String> parseAcceptedContentTypes(String contentTypes) {
return List.of(contentTypes.split(","));
}

@Override
public void start() {
if (activateJtiValidation) {
jtiEntryReaperThread = executorInstrumentation.instrument(Executors.newSingleThreadScheduledExecutor(), "JTI Validation Entry Reaper Thread")
.scheduleAtFixedRate(jtiValidationStore::deleteExpired, reaperCleanupPeriod, reaperCleanupPeriod, TimeUnit.SECONDS);
.scheduleAtFixedRate(jtiValidationStore::deleteExpired, reaperCleanupPeriod, reaperCleanupPeriod, TimeUnit.SECONDS);
}
}

Expand Down Expand Up @@ -243,12 +253,12 @@ public PresentationVerifier createPresentationVerifier(ServiceExtensionContext c

var jwtVerifier = new JwtPresentationVerifier(typeManager, JSON_LD, tokenValidationService, rulesRegistry, didPublicKeyResolver);
var ldpVerifier = LdpVerifier.Builder.newInstance()
.signatureSuites(signatureSuiteRegistry)
.jsonLd(jsonLd)
.typeManager(typeManager)
.typeContext(JSON_LD)
.methodResolver(new DidMethodResolver(didResolverRegistry))
.build();
.signatureSuites(signatureSuiteRegistry)
.jsonLd(jsonLd)
.typeManager(typeManager)
.typeContext(JSON_LD)
.methodResolver(new DidMethodResolver(didResolverRegistry))
.build();

presentationVerifier = new MultiFormatPresentationVerifier(issuerId, jwtVerifier, ldpVerifier);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,7 @@ void setUp(ServiceExtensionContext context) {

@Test
void verifyCorrectService(ServiceExtensionContext context, ObjectFactory objectFactory) {


var is = objectFactory.constructInstance(IdentityAndTrustExtension.class).createIdentityService(context);

assertThat(is).isInstanceOf(IdentityAndTrustService.class);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ dependencies {
testImplementation(testFixtures(project(":spi:common:verifiable-credentials-spi")))
testImplementation(libs.mockserver.netty)
testImplementation(project(":tests:junit-base"))
testImplementation(project(":core:common:lib:http-lib"))
testImplementation(project(":core:common:lib:util-lib"))
testImplementation(testFixtures(project(":spi:common:identity-trust-spi"))) //test functions
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@

import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import okhttp3.Request;
import org.eclipse.edc.http.spi.EdcHttpClient;
import org.eclipse.edc.iam.verifiablecredentials.spi.RevocationListService;
import org.eclipse.edc.iam.verifiablecredentials.spi.model.CredentialStatus;
import org.eclipse.edc.iam.verifiablecredentials.spi.model.VerifiableCredential;
Expand All @@ -25,8 +27,8 @@
import org.eclipse.edc.util.collection.Cache;

import java.io.IOException;
import java.net.URI;
import java.time.Instant;
import java.util.Collection;
import java.util.Map;
import java.util.stream.Collectors;

Expand All @@ -44,14 +46,18 @@
*/
public abstract class BaseRevocationListService<C extends VerifiableCredential, S> implements RevocationListService {
private final Cache<String, C> statusListCredentialCache;
private final Collection<String> acceptedContentTypes;
private final EdcHttpClient httpClient;
private final Class<C> credentialClass;
private final ObjectMapper objectMapper;

protected BaseRevocationListService(ObjectMapper mapper, long cacheValidity, Class<C> credentialClass) {
protected BaseRevocationListService(ObjectMapper mapper, long cacheValidity, Collection<String> acceptedContentTypes, EdcHttpClient httpClient, Class<C> credentialClass) {
this.objectMapper = mapper.copy()
.enable(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY) // technically, credential subjects and credential status can be objects AND Arrays
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); // let's make sure this is disabled, because the "@context" would cause problems
.enable(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY) // technically, credential subjects and credential status can be objects AND Arrays
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); // let's make sure this is disabled, because the "@context" would cause problems
statusListCredentialCache = new Cache<>(this::downloadStatusListCredential, cacheValidity);
this.acceptedContentTypes = acceptedContentTypes;
this.httpClient = httpClient;
this.credentialClass = credentialClass;
}

Expand All @@ -60,11 +66,11 @@ public Result<Void> checkValidity(CredentialStatus credential) {
var credentialStatus = getCredentialStatus(credential);
var credentialIndex = getStatusIndex(credentialStatus);
return preliminaryChecks(credentialStatus)
.compose(v -> validateStatusPurpose(credentialStatus))
.compose(v -> getStatusEntryValue(credentialStatus))
.compose(status -> status != null ?
Result.failure("Credential status is '%s', status at index %d is '1'".formatted(status, credentialIndex)) :
Result.success());
.compose(v -> validateStatusPurpose(credentialStatus))
.compose(v -> getStatusEntryValue(credentialStatus))
.compose(status -> status != null ?
Result.failure("Credential status is '%s', status at index %d is '1'".formatted(status, credentialIndex)) :
Result.success());
}

@Override
Expand All @@ -74,17 +80,17 @@ public Result<String> getStatusPurpose(VerifiableCredential credential) {
}

var res = credential.getCredentialStatus().stream()
.map(this::getCredentialStatus)
.map(this::getStatusEntryValue)
.collect(Collectors.groupingBy(AbstractResult::succeeded));
.map(this::getCredentialStatus)
.map(this::getStatusEntryValue)
.collect(Collectors.groupingBy(AbstractResult::succeeded));

if (res.containsKey(false)) { //if any failed
return Result.failure(res.get(false).stream().map(AbstractResult::getFailureDetail).toList());
}

var list = res.get(true).stream()
.filter(r -> r.getContent() != null)
.map(AbstractResult::getContent).toList();
.filter(r -> r.getContent() != null)
.map(AbstractResult::getContent).toList();

return list.isEmpty() ? success(null) : success(String.join(", ", list));
}
Expand All @@ -107,13 +113,17 @@ protected Result<Void> preliminaryChecks(S credentialStatus) {
* @return the VerifiableCredential
* @throws EdcException if it could not be downloaded
*/
protected C getCredential(String credentialUrl) {
var credential = statusListCredentialCache.get(credentialUrl);
// credential is cached, but expired -> download again
if (credential != null && credential.getExpirationDate() != null && credential.getExpirationDate().isBefore(Instant.now())) {
statusListCredentialCache.evict(credentialUrl);
protected Result<C> getCredential(String credentialUrl) {
try {
var credential = statusListCredentialCache.get(credentialUrl);
// credential is cached, but expired -> download again
if (credential != null && credential.getExpirationDate() != null && credential.getExpirationDate().isBefore(Instant.now())) {
statusListCredentialCache.evict(credentialUrl);
}
return success(statusListCredentialCache.get(credentialUrl));
} catch (IllegalArgumentException ex) {
return Result.failure(ex.getMessage());
}
return statusListCredentialCache.get(credentialUrl);
}

/**
Expand Down Expand Up @@ -150,8 +160,19 @@ protected C getCredential(String credentialUrl) {
protected abstract S getCredentialStatus(CredentialStatus credentialStatus);

private C downloadStatusListCredential(String credentialUrl) {
try {
return objectMapper.readValue(URI.create(credentialUrl).toURL(), credentialClass);
var request = new Request.Builder()
.url(credentialUrl)
.header("Accept", String.join(",", acceptedContentTypes))
.get()
.build();
try (var response = httpClient.execute(request)) {
if (response.isSuccessful()) {
if (response.body() == null) {
throw new EdcException("Response body is null for URL: " + credentialUrl);
}
return objectMapper.readValue(response.body().byteStream(), credentialClass);
}
throw new IllegalArgumentException("Failed to download status list credential from " + credentialUrl + ": " + response.code() + " " + response.message());
} catch (IOException e) {
throw new EdcException(e);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
package org.eclipse.edc.iam.verifiablecredentials.revocation.bitstring;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.eclipse.edc.http.spi.EdcHttpClient;
import org.eclipse.edc.iam.verifiablecredentials.revocation.BaseRevocationListService;
import org.eclipse.edc.iam.verifiablecredentials.spi.model.CredentialStatus;
import org.eclipse.edc.iam.verifiablecredentials.spi.model.revocation.BitString;
Expand All @@ -24,6 +25,7 @@
import org.eclipse.edc.spi.result.Result;

import java.util.Base64;
import java.util.Collection;

import static org.eclipse.edc.spi.result.Result.success;

Expand All @@ -33,8 +35,8 @@
*/
public class BitstringStatusListRevocationService extends BaseRevocationListService<BitstringStatusListCredential, BitstringStatusListStatus> {

public BitstringStatusListRevocationService(ObjectMapper mapper, long cacheValidity) {
super(mapper, cacheValidity, BitstringStatusListCredential.class);
public BitstringStatusListRevocationService(ObjectMapper mapper, long cacheValidity, Collection<String> acceptedContentTypes, EdcHttpClient httpClient) {
super(mapper, cacheValidity, acceptedContentTypes, httpClient, BitstringStatusListCredential.class);
}

@Override
Expand All @@ -48,7 +50,11 @@ protected Result<Void> preliminaryChecks(BitstringStatusListStatus credentialSta

@Override
protected Result<String> getStatusEntryValue(BitstringStatusListStatus credentialStatus) {
var bitStringCredential = getCredential(credentialStatus.getStatusListCredential());
var bitStringCredentialResult = getCredential(credentialStatus.getStatusListCredential());
if (bitStringCredentialResult.failed()) {
return bitStringCredentialResult.mapEmpty();
}
var bitStringCredential = bitStringCredentialResult.getContent();

var bitString = bitStringCredential.encodedList();
var decoder = Base64.getDecoder();
Expand Down Expand Up @@ -92,7 +98,10 @@ protected Result<Void> validateStatusPurpose(BitstringStatusListStatus credentia

var credentialUrl = credentialStatus.getStatusListCredential();
var statusListCredential = getCredential(credentialUrl);
var credentialStatusPurpose = statusListCredential.statusPurpose();
if (statusListCredential.failed()) {
return statusListCredential.mapEmpty();
}
var credentialStatusPurpose = statusListCredential.getContent().statusPurpose();

if (!statusPurpose.equalsIgnoreCase(credentialStatusPurpose)) {
return Result.failure("Credential's statusPurpose value must match the statusPurpose of the Bitstring Credential: '%s' != '%s'".formatted(statusPurpose, credentialStatusPurpose));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,16 @@
package org.eclipse.edc.iam.verifiablecredentials.revocation.statuslist2021;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.eclipse.edc.http.spi.EdcHttpClient;
import org.eclipse.edc.iam.verifiablecredentials.revocation.BaseRevocationListService;
import org.eclipse.edc.iam.verifiablecredentials.spi.model.CredentialStatus;
import org.eclipse.edc.iam.verifiablecredentials.spi.model.revocation.BitString;
import org.eclipse.edc.iam.verifiablecredentials.spi.model.revocation.statuslist2021.StatusList2021Credential;
import org.eclipse.edc.iam.verifiablecredentials.spi.model.revocation.statuslist2021.StatusList2021Status;
import org.eclipse.edc.spi.result.Result;

import java.util.Collection;

import static org.eclipse.edc.spi.result.Result.success;


Expand All @@ -31,8 +34,8 @@
*/
public class StatusList2021RevocationService extends BaseRevocationListService<StatusList2021Credential, StatusList2021Status> {

public StatusList2021RevocationService(ObjectMapper objectMapper, long cacheValidity) {
super(objectMapper, cacheValidity, StatusList2021Credential.class);
public StatusList2021RevocationService(ObjectMapper objectMapper, long cacheValidity, Collection<String> acceptedContentTypes, EdcHttpClient httpClient) {
super(objectMapper, cacheValidity, acceptedContentTypes, httpClient, StatusList2021Credential.class);
}

@Override
Expand All @@ -45,9 +48,11 @@ protected Result<String> getStatusEntryValue(StatusList2021Status credentialStat
var index = credentialStatus.getStatusListIndex();
var slCredUrl = credentialStatus.getStatusListCredential();
var credential = getCredential(slCredUrl);
if (credential.failed()) {
return credential.mapEmpty();
}


var bitStringResult = BitString.Parser.newInstance().parse(credential.encodedList());
var bitStringResult = BitString.Parser.newInstance().parse(credential.getContent().encodedList());

if (bitStringResult.failed()) {
return bitStringResult.mapEmpty();
Expand All @@ -64,10 +69,13 @@ protected Result<String> getStatusEntryValue(StatusList2021Status credentialStat
@Override
protected Result<Void> validateStatusPurpose(StatusList2021Status credentialStatus) {
var slCred = getCredential(credentialStatus.getStatusListCredential());
if (slCred.failed()) {
return slCred.mapEmpty();
}

// check that the "statusPurpose" values match
var purpose = credentialStatus.getStatusListPurpose();
var slCredPurpose = slCred.statusPurpose();
var slCredPurpose = slCred.getContent().statusPurpose();
if (!purpose.equalsIgnoreCase(slCredPurpose)) {
return Result.failure("Credential's statusPurpose value must match the status list's purpose: '%s' != '%s'".formatted(purpose, slCredPurpose));
}
Expand Down
Loading
Loading