Added endorsement inspector for OB 2.0

This commit is contained in:
Xavi Aracil 2022-12-13 14:30:55 +01:00
parent 55ef973efe
commit cb60fd11ed
13 changed files with 319 additions and 43 deletions

View File

@ -0,0 +1,138 @@
package org.oneedtech.inspect.vc;
import static org.oneedtech.inspect.core.probe.RunContext.Key.GENERATED_OBJECT_BUILDER;
import static org.oneedtech.inspect.core.probe.RunContext.Key.JACKSON_OBJECTMAPPER;
import static org.oneedtech.inspect.core.probe.RunContext.Key.JSONPATH_EVALUATOR;
import static org.oneedtech.inspect.core.probe.RunContext.Key.JSON_DOCUMENT_LOADER;
import static org.oneedtech.inspect.core.probe.RunContext.Key.JWT_CREDENTIAL_NODE_NAME;
import static org.oneedtech.inspect.core.probe.RunContext.Key.PNG_CREDENTIAL_KEY;
import static org.oneedtech.inspect.core.probe.RunContext.Key.SVG_CREDENTIAL_QNAME;
import static org.oneedtech.inspect.core.report.ReportUtil.onProbeException;
import static org.oneedtech.inspect.util.code.Defensives.checkNotNull;
import static org.oneedtech.inspect.util.json.ObjectMapperCache.Config.DEFAULT;
import static org.oneedtech.inspect.vc.Credential.CREDENTIAL_KEY;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import org.oneedtech.inspect.core.SubInspector;
import org.oneedtech.inspect.core.probe.GeneratedObject;
import org.oneedtech.inspect.core.probe.Probe;
import org.oneedtech.inspect.core.probe.RunContext;
import org.oneedtech.inspect.core.probe.json.JsonPathEvaluator;
import org.oneedtech.inspect.core.report.Report;
import org.oneedtech.inspect.core.report.ReportItems;
import org.oneedtech.inspect.util.json.ObjectMapperCache;
import org.oneedtech.inspect.util.resource.Resource;
import org.oneedtech.inspect.vc.Assertion.Type;
import org.oneedtech.inspect.vc.jsonld.JsonLdGeneratedObject;
import org.oneedtech.inspect.vc.payload.PngParser;
import org.oneedtech.inspect.vc.payload.SvgParser;
import org.oneedtech.inspect.vc.probe.AssertionRevocationListProbe;
import org.oneedtech.inspect.vc.probe.ExpirationProbe;
import org.oneedtech.inspect.vc.probe.IssuanceProbe;
import org.oneedtech.inspect.vc.probe.VerificationDependenciesProbe;
import com.apicatalog.jsonld.loader.DocumentLoader;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
/**
* An inspector for EndorsementCredential objects.
* @author mgylling
*/
public class OB20EndorsementInspector extends VCInspector implements SubInspector {
private DocumentLoader documentLoader;
protected OB20EndorsementInspector(OB20EndorsementInspector.Builder builder) {
super(builder);
this.documentLoader = builder.documentLoader;
}
@Override
public Report run(Resource resource, Map<String, GeneratedObject> parentObjects) {
/*
* The resource param is the top-level credential that embeds the endorsement, we
* expect parentObjects to provide a pointer to the JsonNode we should check.
*
* The parent inspector is responsible to decode away possible jwt-ness, so that
* what we get here is a verbatim json node.
*
*/
Assertion endorsement = (Assertion) checkNotNull(parentObjects.get(CREDENTIAL_KEY));
ObjectMapper mapper = ObjectMapperCache.get(DEFAULT);
JsonPathEvaluator jsonPath = new JsonPathEvaluator(mapper);
RunContext ctx = new RunContext.Builder()
.put(this)
.put(resource)
.put(JACKSON_OBJECTMAPPER, mapper)
.put(JSONPATH_EVALUATOR, jsonPath)
.put(GENERATED_OBJECT_BUILDER, new Assertion.Builder())
.put(PNG_CREDENTIAL_KEY, PngParser.Keys.OB20)
.put(SVG_CREDENTIAL_QNAME, SvgParser.QNames.OB20)
.put(JSON_DOCUMENT_LOADER, documentLoader)
.put(JWT_CREDENTIAL_NODE_NAME, Assertion.JWT_NODE_NAME)
.build();
parentObjects.entrySet().stream().forEach(entry -> {
if (!entry.getKey().equals(CREDENTIAL_KEY)) {
ctx.addGeneratedObject(entry.getValue());
}
});
List<ReportItems> accumulator = new ArrayList<>();
int probeCount = 0;
try {
JsonNode endorsementNode = endorsement.getJson();
// verification and revocation
if (endorsement.getCredentialType() == Type.Endorsement) {
for(Probe<JsonLdGeneratedObject> probe : List.of(new VerificationDependenciesProbe(endorsementNode.get("id").asText(), "claim"),
new AssertionRevocationListProbe(endorsementNode.get("id").asText(), "claim"))) {
probeCount++;
accumulator.add(probe.run(new JsonLdGeneratedObject(endorsementNode.toString()), ctx));
if(broken(accumulator)) return abort(ctx, accumulator, probeCount);
}
}
// expiration and issuance
for(Probe<Credential> probe : List.of(
new ExpirationProbe(), new IssuanceProbe())) {
probeCount++;
accumulator.add(probe.run(endorsement, ctx));
if(broken(accumulator)) return abort(ctx, accumulator, probeCount);
}
} catch (Exception e) {
accumulator.add(onProbeException(Probe.ID.NO_UNCAUGHT_EXCEPTIONS, resource, e));
}
return new Report(ctx, new ReportItems(accumulator), probeCount);
}
@Override
public <R extends Resource> Report run(R resource) {
throw new IllegalStateException("must use #run(resource, map)");
}
public static class Builder extends VCInspector.Builder<OB20EndorsementInspector.Builder> {
private DocumentLoader documentLoader;
@SuppressWarnings("unchecked")
@Override
public OB20EndorsementInspector build() {
return new OB20EndorsementInspector(this);
}
public Builder documentLoader(DocumentLoader documentLoader) {
this.documentLoader = documentLoader;
return this;
}
}
}

View File

@ -4,12 +4,20 @@ import static java.lang.Boolean.TRUE;
import static org.oneedtech.inspect.core.Inspector.Behavior.RESET_CACHES_ON_RUN;
import static org.oneedtech.inspect.core.report.ReportUtil.onProbeException;
import static org.oneedtech.inspect.util.json.ObjectMapperCache.Config.DEFAULT;
import static org.oneedtech.inspect.vc.Credential.CREDENTIAL_KEY;
import static org.oneedtech.inspect.vc.util.JsonNodeUtil.asNodeList;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.oneedtech.inspect.core.Inspector;
import org.oneedtech.inspect.core.probe.Outcome;
import org.oneedtech.inspect.core.probe.GeneratedObject;
import org.oneedtech.inspect.core.probe.Probe;
import org.oneedtech.inspect.core.probe.RunContext;
import org.oneedtech.inspect.core.probe.RunContext.Key;
@ -20,6 +28,7 @@ import org.oneedtech.inspect.schema.JsonSchemaCache;
import org.oneedtech.inspect.util.json.ObjectMapperCache;
import org.oneedtech.inspect.util.resource.Resource;
import org.oneedtech.inspect.util.resource.ResourceType;
import org.oneedtech.inspect.util.resource.UriResource;
import org.oneedtech.inspect.util.spec.Specification;
import org.oneedtech.inspect.vc.Assertion.Type;
import org.oneedtech.inspect.vc.Credential.CredentialEnum;
@ -41,38 +50,25 @@ import org.oneedtech.inspect.vc.probe.validation.ValidationPropertyProbeFactory;
import org.oneedtech.inspect.vc.util.CachingDocumentLoader;
import com.apicatalog.jsonld.loader.DocumentLoader;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import foundation.identity.jsonld.ConfigurableDocumentLoader;
/**
* A verifier for Open Badges 2.0.
* @author xaracil
*/
public class OB20Inspector extends Inspector {
public class OB20Inspector extends VCInspector {
protected OB20Inspector(OB20Inspector.Builder builder) {
protected <B extends VCInspector.Builder<?>> OB20Inspector(B builder) {
super(builder);
}
protected Report abort(RunContext ctx, List<ReportItems> accumulator, int probeCount) {
return new Report(ctx, new ReportItems(accumulator), probeCount);
}
protected boolean broken(List<ReportItems> accumulator) {
return broken(accumulator, false);
}
protected boolean broken(List<ReportItems> accumulator, boolean force) {
if(!force && getBehavior(Inspector.Behavior.VALIDATOR_FAIL_FAST) == Boolean.FALSE) {
return false;
}
for(ReportItems items : accumulator) {
if(items.contains(Outcome.FATAL, Outcome.EXCEPTION)) return true;
}
return false;
}
/* (non-Javadoc)
* @see org.oneedtech.inspect.core.Inspector#run(org.oneedtech.inspect.util.resource.Resource)
*/
@Override
public Report run(Resource resource) {
super.check(resource);
@ -143,14 +139,6 @@ public class OB20Inspector extends Inspector {
if(broken(accumulator)) return abort(ctx, accumulator, probeCount);
}
// expiration and issuance
for(Probe<Credential> probe : List.of(
new ExpirationProbe(), new IssuanceProbe())) {
probeCount++;
accumulator.add(probe.run(assertion, ctx));
if(broken(accumulator)) return abort(ctx, accumulator, probeCount);
}
// verification and revocation
if (assertion.getCredentialType() == Type.Assertion) {
for(Probe<JsonLdGeneratedObject> probe : List.of(new VerificationDependenciesProbe(assertionNode.get("id").asText()),
@ -168,6 +156,47 @@ public class OB20Inspector extends Inspector {
}
}
// expiration and issuance
for(Probe<Credential> probe : List.of(
new ExpirationProbe(), new IssuanceProbe())) {
probeCount++;
accumulator.add(probe.run(assertion, ctx));
if(broken(accumulator)) return abort(ctx, accumulator, probeCount);
}
// Embedded endorsements. Pass document loader because it has already cached documents, and it has localdomains for testing
OB20EndorsementInspector endorsementInspector = new OB20EndorsementInspector.Builder().documentLoader(documentLoader).build();
// get endorsements for all JSON_LD objects in the graph
List<JsonNode> endorsements = ctx.getGeneratedObjects().values().stream()
.filter(generatedObject -> generatedObject instanceof JsonLdGeneratedObject)
.flatMap(obj -> {
JsonNode node;
try {
node = mapper.readTree(((JsonLdGeneratedObject) obj).getJson());
// return endorsement node, filtering out the on inside @context
return asNodeList(node, "$..endorsement", jsonPath).stream().filter(endorsementNode -> !endorsementNode.isObject());
} catch (JsonProcessingException e) {
throw new IllegalArgumentException("Couldn't not parse " + obj.getId() + ": contains invalid JSON");
}
})
.collect(Collectors.toList());
for(JsonNode node : endorsements) {
probeCount++;
// get endorsement json from context
UriResource uriResource = resolveUriResource(ctx, node.asText());
JsonLdGeneratedObject resolved = (JsonLdGeneratedObject) ctx.getGeneratedObject(JsonLDCompactionProve.getId(uriResource));
if (resolved == null) {
throw new IllegalArgumentException("endorsement " + node.toString() + " not found in graph");
}
Assertion endorsement = new Assertion.Builder().resource(resource).jsonData(mapper.readTree(resolved.getJson())).build();
// pass graph to subinspector
Map<String, GeneratedObject> parentObjects = new HashMap<>(ctx.getGeneratedObjects());
parentObjects.put(CREDENTIAL_KEY, endorsement);
accumulator.add(endorsementInspector.run(resource, parentObjects));
}
} catch (Exception e) {
accumulator.add(onProbeException(Probe.ID.NO_UNCAUGHT_EXCEPTIONS, resource, e));
@ -176,11 +205,7 @@ public class OB20Inspector extends Inspector {
return new Report(ctx, new ReportItems(accumulator), probeCount);
}
protected DocumentLoader getDocumentLoader() {
return new CachingDocumentLoader();
}
public static class Builder extends Inspector.Builder<OB20Inspector.Builder> {
public static class Builder extends VCInspector.Builder<OB20Inspector.Builder> {
public Builder() {
super();
@ -204,4 +229,19 @@ public class OB20Inspector extends Inspector {
public static final String ALLOW_LOCAL_REDIRECTION = "ALLOW_LOCAL_REDIRECTION";
}
protected UriResource resolveUriResource(RunContext ctx, String url) throws URISyntaxException {
URI uri = new URI(url);
UriResource initialUriResource = new UriResource(uri);
UriResource uriResource = initialUriResource;
// check if uri points to a local resource
if (ctx.get(Key.JSON_DOCUMENT_LOADER) instanceof ConfigurableDocumentLoader) {
if (ConfigurableDocumentLoader.getDefaultHttpLoader() instanceof CachingDocumentLoader.HttpLoader) {
URI resolvedUri = ((CachingDocumentLoader.HttpLoader) ConfigurableDocumentLoader.getDefaultHttpLoader()).resolve(uri);
uriResource = new UriResource(resolvedUri);
}
}
return uriResource;
}
}

View File

@ -10,7 +10,9 @@ import org.oneedtech.inspect.core.probe.Probe;
import org.oneedtech.inspect.core.probe.RunContext;
import org.oneedtech.inspect.core.report.Report;
import org.oneedtech.inspect.core.report.ReportItems;
import org.oneedtech.inspect.vc.util.CachingDocumentLoader;
import com.apicatalog.jsonld.loader.DocumentLoader;
import com.fasterxml.jackson.databind.JsonNode;
/**
@ -61,6 +63,14 @@ public abstract class VCInspector extends Inspector {
return Optional.empty();
}
/**
* Creates a caching document loader for loading json resources
* @return document loader for loading json resources
*/
protected DocumentLoader getDocumentLoader() {
return new CachingDocumentLoader();
}
protected static final String REFRESHED = "is.refreshed.credential";
public abstract static class Builder<B extends VCInspector.Builder<B>> extends Inspector.Builder<B> {

View File

@ -25,10 +25,16 @@ import foundation.identity.jsonld.ConfigurableDocumentLoader;
public class AssertionRevocationListProbe extends Probe<JsonLdGeneratedObject> {
private final String assertionId;
private final String propertyName;
public AssertionRevocationListProbe(String assertionId) {
this(assertionId, "badge");
}
public AssertionRevocationListProbe(String assertionId, String propertyName) {
super(ID);
this.assertionId = assertionId;
this.propertyName = propertyName;
}
@Override
@ -37,7 +43,7 @@ public class AssertionRevocationListProbe extends Probe<JsonLdGeneratedObject> {
JsonNode jsonNode = (mapper).readTree(jsonLdGeneratedObject.getJson());
// get badge
UriResource badgeUriResource = resolveUriResource(ctx, jsonNode.get("badge").asText().strip());
UriResource badgeUriResource = resolveUriResource(ctx, getBadgeClaimId(jsonNode));
JsonLdGeneratedObject badgeObject = (JsonLdGeneratedObject) ctx.getGeneratedObject(
JsonLDCompactionProve.getId(badgeUriResource));
@ -97,5 +103,18 @@ public class AssertionRevocationListProbe extends Probe<JsonLdGeneratedObject> {
return uriResource;
}
/**
* Return the ID of the node with name propertyName
* @param jsonNode node
* @return ID of the node. If node is textual, the text is returned. If node is an object, its "ID" attribute is returned
*/
protected String getBadgeClaimId(JsonNode jsonNode) {
JsonNode propertyNode = jsonNode.get(propertyName);
if (propertyNode.isTextual()) {
return propertyNode.asText().strip();
}
return propertyNode.get("id").asText().strip();
}
public static final String ID = AssertionRevocationListProbe.class.getSimpleName();
}

View File

@ -28,12 +28,19 @@ import foundation.identity.jsonld.ConfigurableDocumentLoader;
*/
public class VerificationDependenciesProbe extends Probe<JsonLdGeneratedObject> {
private final String assertionId;
private final String propertyName;
public VerificationDependenciesProbe(String assertionId) {
this(assertionId, "badge");
}
public VerificationDependenciesProbe(String assertionId, String propertyName) {
super(ID);
this.assertionId = assertionId;
this.propertyName = propertyName;
}
@Override
public ReportItems run(JsonLdGeneratedObject jsonLdGeneratedObject, RunContext ctx) throws Exception {
ObjectMapper mapper = (ObjectMapper) ctx.get(Key.JACKSON_OBJECTMAPPER);
@ -56,7 +63,7 @@ public class VerificationDependenciesProbe extends Probe<JsonLdGeneratedObject>
if ("HostedBadge".equals(type)) {
// get badge
UriResource badgeUriResource = resolveUriResource(ctx, jsonNode.get("badge").asText().strip());
UriResource badgeUriResource = resolveUriResource(ctx, getBadgeClaimId(jsonNode));
JsonLdGeneratedObject badgeObject = (JsonLdGeneratedObject) ctx.getGeneratedObject(
JsonLDCompactionProve.getId(badgeUriResource));
JsonNode badgeNode = ((ObjectMapper) ctx.get(Key.JACKSON_OBJECTMAPPER))
@ -104,7 +111,7 @@ public class VerificationDependenciesProbe extends Probe<JsonLdGeneratedObject>
allowedOrigins = List.of(defaultAllowedOrigins);
}
} else {
JsonNodeUtil.asStringList(allowedOriginsNode);
allowedOrigins = JsonNodeUtil.asStringList(allowedOriginsNode);
}
if (allowedOrigins == null || allowedOrigins.isEmpty() || !issuerId.startsWith("http")) {
@ -147,6 +154,20 @@ public class VerificationDependenciesProbe extends Probe<JsonLdGeneratedObject>
return uriResource;
}
/**
* Return the ID of the node with name propertyName
* @param jsonNode node
* @return ID of the node. If node is textual, the text is returned. If node is an object, its "ID" attribute is returned
*/
protected String getBadgeClaimId(JsonNode jsonNode) {
JsonNode propertyNode = jsonNode.get(propertyName);
if (propertyNode.isTextual()) {
return propertyNode.asText().strip();
}
return propertyNode.get("id").asText().strip();
}
public static final String ID = VerificationDependenciesProbe.class.getSimpleName();
}

View File

@ -11,7 +11,16 @@
"image": "https://example.org/beths-robot-badge.png",
"evidence": "https://example.org/beths-robot-work.html",
"issuedOn": "2016-12-31T23:59:59Z",
"badge": "https://example.org/robotics-badge.json",
"badge": {
"type": "BadgeClass",
"id": "https://example.org/badgeclass-with-endorsements.json",
"name": "Awesome Robotics Badge",
"description": "For doing awesome things with robots that people think is pretty great.",
"image": "https://example.org/robotics-badge.png",
"criteria": "https://example.org/badgecriteria.json",
"issuer": "https://example.org/organization.json",
"endorsement": ["https://example.org/endorsement-3.json", "https://example.org/endorsement-4.json"]
},
"verification": {
"type": "hosted"
},

View File

@ -0,0 +1,11 @@
{
"@context": "https://w3id.org/openbadges/v2",
"type": "BadgeClass",
"id": "https://example.org/badgeclass-with-endorsements.json",
"name": "Awesome Robotics Badge",
"description": "For doing awesome things with robots that people think is pretty great.",
"image": "https://example.org/robotics-badge.png",
"criteria": "https://example.org/badgecriteria.json",
"issuer": "https://example.org/organization.json",
"endorsement": ["https://example.org/endorsement-3.json", "https://example.org/endorsement-4.json"]
}

View File

@ -3,7 +3,7 @@
"id": "http://example.org/endorsement-1.json",
"type": "Endorsement",
"claim": {
"id": "https://example.org/robotics-badge.json",
"id": "https://example.org/badgeclass-with-endorsements.json",
"endorsementComment": "Pretty good"
},
"issuedOn": "2017-10-01T00:00Z",

View File

@ -3,7 +3,7 @@
"id": "http://example.org/endorsement-2.json",
"type": "Endorsement",
"claim": {
"id": "https://example.org/robotics-badge.json",
"id": "https://example.org/badgeclass-with-endorsements.json",
"endorsementComment": "Pretty good"
},
"issuedOn": "2017-10-01T00:00Z",

View File

@ -0,0 +1,14 @@
{
"@context": "https://w3id.org/openbadges/v2",
"id": "http://example.org/endorsement-3.json",
"type": "Endorsement",
"claim": {
"id": "https://example.org/badgeclass-with-endorsements.json",
"endorsementComment": "Pretty good"
},
"issuedOn": "2017-10-01T00:00Z",
"issuer": "http://example.org/issuer1.json",
"verification": {
"type": "HostedBadge"
}
}

View File

@ -0,0 +1,14 @@
{
"@context": "https://w3id.org/openbadges/v2",
"id": "http://example.org/endorsement-4.json",
"type": "Endorsement",
"claim": {
"id": "https://example.org/badgeclass-with-endorsements.json",
"endorsementComment": "Pretty good"
},
"issuedOn": "2017-10-01T00:00Z",
"issuer": "http://example.org/issuer1.json",
"verification": {
"type": "HostedBadge"
}
}

View File

@ -204,6 +204,6 @@
"email": "me@example.org",
"url": "http://example.org",
"verification": {
"allowedOrigins": ["example.com"]
"allowedOrigins": ["example.com", "example.org"]
}
}

View File

@ -198,7 +198,7 @@
},
"verify": "verification"
},
"id": "http://example.org/issuer1",
"id": "http://example.org/issuer1.json",
"type": "Issuer",
"name": "Example Issuer",
"email": "me@example.org",