Skip to content

Commit b3126e6

Browse files
feat: holder-side Credential Offer API (#685)
* add controller, validator + tests * update license headers, api version file
1 parent 2d5ce6b commit b3126e6

File tree

17 files changed

+789
-4
lines changed

17 files changed

+789
-4
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/*
2+
* Copyright (c) 2025 Metaform Systems Inc.
3+
*
4+
* This program and the accompanying materials are made available under the
5+
* terms of the Apache License, Version 2.0 which is available at
6+
* https://www.apache.org/licenses/LICENSE-2.0
7+
*
8+
* SPDX-License-Identifier: Apache-2.0
9+
*
10+
* Contributors:
11+
* Metaform Systems Inc. - initial API and implementation
12+
*
13+
*/
14+
15+
plugins {
16+
`java-library`
17+
`maven-publish`
18+
id("io.swagger.core.v3.swagger-gradle-plugin")
19+
}
20+
21+
dependencies {
22+
api(project(":spi:identity-hub-spi"))
23+
api(project(":protocols:dcp:dcp-spi"))
24+
25+
api(libs.edc.spi.jsonld)
26+
api(libs.edc.spi.jwt)
27+
api(libs.edc.spi.core)
28+
29+
implementation(project(":protocols:dcp:dcp-identityhub:credentials-api-configuration"))
30+
implementation(project(":protocols:dcp:dcp-transform-lib"))
31+
implementation(libs.edc.spi.validator)
32+
implementation(libs.edc.spi.web)
33+
implementation(libs.edc.spi.dcp)
34+
implementation(libs.edc.lib.jerseyproviders)
35+
implementation(libs.edc.lib.transform)
36+
implementation(libs.edc.dcp.transform)
37+
implementation(libs.jakarta.rsApi)
38+
testImplementation(libs.edc.junit)
39+
testImplementation(libs.edc.jsonld)
40+
testImplementation(testFixtures(libs.edc.core.jersey))
41+
testImplementation(testFixtures(project(":spi:verifiable-credential-spi")))
42+
testImplementation(libs.nimbus.jwt)
43+
testImplementation(libs.restAssured)
44+
}
45+
46+
edcBuild {
47+
swagger {
48+
apiGroup.set("credentials-api")
49+
}
50+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/*
2+
* Copyright (c) 2025 Metaform Systems Inc.
3+
*
4+
* This program and the accompanying materials are made available under the
5+
* terms of the Apache License, Version 2.0 which is available at
6+
* https://www.apache.org/licenses/LICENSE-2.0
7+
*
8+
* SPDX-License-Identifier: Apache-2.0
9+
*
10+
* Contributors:
11+
* Metaform Systems Inc. - initial API and implementation
12+
*
13+
*/
14+
15+
package org.eclipse.edc.identityhub.api;
16+
17+
import org.eclipse.edc.identityhub.api.credentialoffer.CredentialOfferApiController;
18+
import org.eclipse.edc.identityhub.api.validation.CredentialOfferMessageValidator;
19+
import org.eclipse.edc.identityhub.protocols.dcp.spi.DcpIssuerTokenVerifier;
20+
import org.eclipse.edc.identityhub.protocols.dcp.transform.to.JsonObjectToCredentialObjectTransformer;
21+
import org.eclipse.edc.identityhub.protocols.dcp.transform.to.JsonObjectToCredentialOfferMessageTransformer;
22+
import org.eclipse.edc.identityhub.spi.participantcontext.ParticipantContextService;
23+
import org.eclipse.edc.identityhub.spi.verifiablecredentials.generator.CredentialWriter;
24+
import org.eclipse.edc.jsonld.spi.JsonLd;
25+
import org.eclipse.edc.runtime.metamodel.annotation.Extension;
26+
import org.eclipse.edc.runtime.metamodel.annotation.Inject;
27+
import org.eclipse.edc.spi.monitor.Monitor;
28+
import org.eclipse.edc.spi.system.ServiceExtension;
29+
import org.eclipse.edc.spi.system.ServiceExtensionContext;
30+
import org.eclipse.edc.spi.types.TypeManager;
31+
import org.eclipse.edc.transform.spi.TypeTransformerRegistry;
32+
import org.eclipse.edc.validator.spi.JsonObjectValidatorRegistry;
33+
import org.eclipse.edc.web.jersey.providers.jsonld.ObjectMapperProvider;
34+
import org.eclipse.edc.web.spi.WebService;
35+
36+
import static org.eclipse.edc.iam.identitytrust.spi.DcpConstants.DSPACE_DCP_NAMESPACE_V_1_0;
37+
import static org.eclipse.edc.identityhub.api.CredentialOfferApiExtension.NAME;
38+
import static org.eclipse.edc.identityhub.protocols.dcp.spi.DcpConstants.DCP_SCOPE_V_1_0;
39+
import static org.eclipse.edc.identityhub.protocols.dcp.spi.model.CredentialMessage.CREDENTIAL_MESSAGE_TERM;
40+
import static org.eclipse.edc.identityhub.spi.webcontext.IdentityHubApiContext.CREDENTIALS;
41+
import static org.eclipse.edc.spi.constants.CoreConstants.JSON_LD;
42+
43+
@Extension(value = NAME)
44+
public class CredentialOfferApiExtension implements ServiceExtension {
45+
46+
public static final String NAME = "Storage API Extension";
47+
48+
@Inject
49+
private TypeTransformerRegistry typeTransformer;
50+
@Inject
51+
private JsonObjectValidatorRegistry validatorRegistry;
52+
@Inject
53+
private WebService webService;
54+
@Inject
55+
private JsonLd jsonLd;
56+
@Inject
57+
private TypeManager typeManager;
58+
@Inject
59+
private CredentialWriter writer;
60+
@Inject
61+
private DcpIssuerTokenVerifier issuerTokenVerifier;
62+
@Inject
63+
private Monitor monitor;
64+
65+
@Inject
66+
private ParticipantContextService participantContextService;
67+
68+
@Override
69+
public String name() {
70+
return NAME;
71+
}
72+
73+
@Override
74+
public void initialize(ServiceExtensionContext context) {
75+
76+
validatorRegistry.register(DSPACE_DCP_NAMESPACE_V_1_0.toIri(CREDENTIAL_MESSAGE_TERM), new CredentialOfferMessageValidator());
77+
78+
var controller = new CredentialOfferApiController(validatorRegistry, typeTransformer, context.getMonitor().withPrefix("CredentialOfferApi"), issuerTokenVerifier, participantContextService);
79+
webService.registerResource(CREDENTIALS, new ObjectMapperProvider(typeManager, JSON_LD));
80+
webService.registerResource(CREDENTIALS, controller);
81+
82+
registerTransformers();
83+
}
84+
85+
void registerTransformers() {
86+
var scopedTransformerRegistry = typeTransformer.forContext(DCP_SCOPE_V_1_0);
87+
88+
// inbound
89+
scopedTransformerRegistry.register(new JsonObjectToCredentialOfferMessageTransformer(DSPACE_DCP_NAMESPACE_V_1_0));
90+
scopedTransformerRegistry.register(new JsonObjectToCredentialObjectTransformer(typeManager, JSON_LD, DSPACE_DCP_NAMESPACE_V_1_0));
91+
}
92+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/*
2+
* Copyright (c) 2025 Metaform Systems Inc.
3+
*
4+
* This program and the accompanying materials are made available under the
5+
* terms of the Apache License, Version 2.0 which is available at
6+
* https://www.apache.org/licenses/LICENSE-2.0
7+
*
8+
* SPDX-License-Identifier: Apache-2.0
9+
*
10+
* Contributors:
11+
* Metaform Systems Inc. - initial API and implementation
12+
*
13+
*/
14+
15+
package org.eclipse.edc.identityhub.api.credentialoffer;
16+
17+
import io.swagger.v3.oas.annotations.media.Schema;
18+
19+
public interface ApiSchema {
20+
@Schema(name = "ApiErrorDetail", example = ApiErrorDetailSchema.API_ERROR_EXAMPLE)
21+
record ApiErrorDetailSchema(
22+
String message,
23+
String type,
24+
String path,
25+
String invalidValue
26+
) {
27+
public static final String API_ERROR_EXAMPLE = """
28+
{
29+
"message": "error message",
30+
"type": "ErrorType",
31+
"path": "object.error.path",
32+
"invalidValue": "this value is not valid"
33+
}
34+
""";
35+
}
36+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/*
2+
* Copyright (c) 2025 Metaform Systems Inc.
3+
*
4+
* This program and the accompanying materials are made available under the
5+
* terms of the Apache License, Version 2.0 which is available at
6+
* https://www.apache.org/licenses/LICENSE-2.0
7+
*
8+
* SPDX-License-Identifier: Apache-2.0
9+
*
10+
* Contributors:
11+
* Metaform Systems Inc. - initial API and implementation
12+
*
13+
*/
14+
15+
package org.eclipse.edc.identityhub.api.credentialoffer;
16+
17+
18+
import io.swagger.v3.oas.annotations.ExternalDocumentation;
19+
import io.swagger.v3.oas.annotations.OpenAPIDefinition;
20+
import io.swagger.v3.oas.annotations.Operation;
21+
import io.swagger.v3.oas.annotations.enums.SecuritySchemeType;
22+
import io.swagger.v3.oas.annotations.info.Info;
23+
import io.swagger.v3.oas.annotations.media.ArraySchema;
24+
import io.swagger.v3.oas.annotations.media.Content;
25+
import io.swagger.v3.oas.annotations.media.Schema;
26+
import io.swagger.v3.oas.annotations.parameters.RequestBody;
27+
import io.swagger.v3.oas.annotations.responses.ApiResponse;
28+
import io.swagger.v3.oas.annotations.security.SecurityScheme;
29+
import io.swagger.v3.oas.annotations.tags.Tag;
30+
import jakarta.json.JsonObject;
31+
import jakarta.ws.rs.core.Response;
32+
33+
import static org.eclipse.edc.identityhub.protocols.dcp.spi.DcpConstants.DCP_SPECIFICATION_URL;
34+
35+
@OpenAPIDefinition(
36+
info = @Info(description = "This implements the Credential Offer API as per DCP specification. It serves as notification facility for a holder.", title = "Credential Offer API",
37+
version = "1"))
38+
@SecurityScheme(name = "Authentication",
39+
description = "Self-Issued ID",
40+
type = SecuritySchemeType.HTTP,
41+
scheme = "bearer",
42+
bearerFormat = "JWT")
43+
public interface CredentialOfferApi {
44+
45+
@Tag(name = "Credential Offer API")
46+
@Operation(description = "Notifies the holder about the availability of a particular credential for issuance",
47+
requestBody = @RequestBody(content = @Content(schema = @Schema(externalDocs = @ExternalDocumentation(description = "DCP Credential Offer API", url = DCP_SPECIFICATION_URL)))),
48+
responses = {
49+
@ApiResponse(responseCode = "200", description = "The offer notification was successfully received."),
50+
@ApiResponse(responseCode = "400", description = "Request body was malformed",
51+
content = @Content(array = @ArraySchema(schema = @Schema(implementation = ApiSchema.ApiErrorDetailSchema.class)))),
52+
@ApiResponse(responseCode = "401", description = "No Authorization header was given.",
53+
content = @Content(array = @ArraySchema(schema = @Schema(implementation = ApiSchema.ApiErrorDetailSchema.class)))),
54+
@ApiResponse(responseCode = "403", description = "The given authentication token could not be validated.",
55+
content = @Content(array = @ArraySchema(schema = @Schema(implementation = ApiSchema.ApiErrorDetailSchema.class))))
56+
57+
}
58+
)
59+
Response offerCredential(String participantContextId, JsonObject credentialOfferMessage, String authHeader);
60+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
/*
2+
* Copyright (c) 2025 Metaform Systems Inc.
3+
*
4+
* This program and the accompanying materials are made available under the
5+
* terms of the Apache License, Version 2.0 which is available at
6+
* https://www.apache.org/licenses/LICENSE-2.0
7+
*
8+
* SPDX-License-Identifier: Apache-2.0
9+
*
10+
* Contributors:
11+
* Metaform Systems Inc. - initial API and implementation
12+
*
13+
*/
14+
15+
package org.eclipse.edc.identityhub.api.credentialoffer;
16+
17+
import jakarta.json.JsonObject;
18+
import jakarta.ws.rs.Consumes;
19+
import jakarta.ws.rs.HeaderParam;
20+
import jakarta.ws.rs.POST;
21+
import jakarta.ws.rs.Path;
22+
import jakarta.ws.rs.PathParam;
23+
import jakarta.ws.rs.Produces;
24+
import jakarta.ws.rs.core.Response;
25+
import org.eclipse.edc.identityhub.protocols.dcp.spi.DcpIssuerTokenVerifier;
26+
import org.eclipse.edc.identityhub.protocols.dcp.spi.model.CredentialOfferMessage;
27+
import org.eclipse.edc.identityhub.spi.participantcontext.ParticipantContextService;
28+
import org.eclipse.edc.spi.monitor.Monitor;
29+
import org.eclipse.edc.transform.spi.TypeTransformerRegistry;
30+
import org.eclipse.edc.validator.spi.JsonObjectValidatorRegistry;
31+
import org.eclipse.edc.web.spi.exception.AuthenticationFailedException;
32+
import org.eclipse.edc.web.spi.exception.InvalidRequestException;
33+
import org.eclipse.edc.web.spi.exception.NotAuthorizedException;
34+
import org.eclipse.edc.web.spi.exception.ValidationFailureException;
35+
36+
import static jakarta.ws.rs.core.HttpHeaders.AUTHORIZATION;
37+
import static jakarta.ws.rs.core.MediaType.APPLICATION_JSON;
38+
import static org.eclipse.edc.iam.identitytrust.spi.DcpConstants.DSPACE_DCP_NAMESPACE_V_1_0;
39+
import static org.eclipse.edc.identityhub.protocols.dcp.spi.DcpConstants.DCP_SCOPE_V_1_0;
40+
import static org.eclipse.edc.identityhub.spi.participantcontext.ParticipantContextId.onEncoded;
41+
42+
@Consumes(APPLICATION_JSON)
43+
@Produces(APPLICATION_JSON)
44+
@Path("/v1/participants/{participantContextId}/offers")
45+
public class CredentialOfferApiController implements CredentialOfferApi {
46+
47+
private final JsonObjectValidatorRegistry validatorRegistry;
48+
private final TypeTransformerRegistry transformerRegistry;
49+
private final Monitor monitor;
50+
private final DcpIssuerTokenVerifier issuerTokenVerifier;
51+
private final ParticipantContextService participantContextService;
52+
53+
public CredentialOfferApiController(JsonObjectValidatorRegistry validatorRegistry,
54+
TypeTransformerRegistry transformerRegistry,
55+
Monitor monitor,
56+
DcpIssuerTokenVerifier issuerTokenVerifier,
57+
ParticipantContextService participantContextService) {
58+
this.validatorRegistry = validatorRegistry;
59+
this.transformerRegistry = transformerRegistry;
60+
this.monitor = monitor;
61+
this.issuerTokenVerifier = issuerTokenVerifier;
62+
this.participantContextService = participantContextService;
63+
}
64+
65+
66+
@POST
67+
@Override
68+
public Response offerCredential(@PathParam("participantContextId") String participantContextId, JsonObject credentialOfferMessage, @HeaderParam(AUTHORIZATION) String authHeader) {
69+
if (credentialOfferMessage == null) {
70+
throw new InvalidRequestException("Request body is null");
71+
}
72+
if (authHeader == null) {
73+
throw new AuthenticationFailedException("Authorization header missing");
74+
}
75+
var authToken = authHeader.replace("Bearer", "").trim();
76+
validatorRegistry.validate(DSPACE_DCP_NAMESPACE_V_1_0.toIri(CredentialOfferMessage.CREDENTIAL_OFFER_MESSAGE_TERM), credentialOfferMessage).orElseThrow(ValidationFailureException::new);
77+
var protocolRegistry = transformerRegistry.forContext(DCP_SCOPE_V_1_0);
78+
79+
participantContextId = onEncoded(participantContextId).orElseThrow(InvalidRequestException::new);
80+
81+
var offerMessage = protocolRegistry.forContext(DCP_SCOPE_V_1_0).transform(credentialOfferMessage, CredentialOfferMessage.class).orElseThrow(InvalidRequestException::new);
82+
83+
var participantContext = participantContextService.getParticipantContext(participantContextId)
84+
.orElseThrow((f) -> new AuthenticationFailedException("Invalid participant"));
85+
86+
// validate Issuer's SI token
87+
issuerTokenVerifier.verify(participantContext, authToken)
88+
.orElseThrow(f -> new NotAuthorizedException("ID token verification failed: %s".formatted(f.getFailureDetail())));
89+
90+
91+
//todo: process credential offer message
92+
monitor.warning("Credential offer was received but processing it is not yet implemented");
93+
return Response.ok().build();
94+
}
95+
96+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/*
2+
* Copyright (c) 2025 Metaform Systems Inc.
3+
*
4+
* This program and the accompanying materials are made available under the
5+
* terms of the Apache License, Version 2.0 which is available at
6+
* https://www.apache.org/licenses/LICENSE-2.0
7+
*
8+
* SPDX-License-Identifier: Apache-2.0
9+
*
10+
* Contributors:
11+
* Metaform Systems Inc. - initial API and implementation
12+
*
13+
*/
14+
15+
package org.eclipse.edc.identityhub.api.validation;
16+
17+
import jakarta.json.JsonObject;
18+
import org.eclipse.edc.jsonld.spi.JsonLdNamespace;
19+
import org.eclipse.edc.validator.spi.ValidationResult;
20+
21+
import static org.eclipse.edc.iam.identitytrust.spi.DcpConstants.DSPACE_DCP_NAMESPACE_V_1_0;
22+
import static org.eclipse.edc.identityhub.protocols.dcp.spi.model.CredentialObject.CREDENTIAL_OBJECT_CREDENTIAL_TYPE_TERM;
23+
import static org.eclipse.edc.validator.spi.ValidationResult.failure;
24+
import static org.eclipse.edc.validator.spi.ValidationResult.success;
25+
import static org.eclipse.edc.validator.spi.Violation.violation;
26+
27+
public class CredentialObjectValidator extends JsonValidator {
28+
private final JsonLdNamespace namespace = DSPACE_DCP_NAMESPACE_V_1_0;
29+
30+
@Override
31+
public ValidationResult validate(JsonObject input) {
32+
if (input == null) {
33+
return failure(violation("CredentialObject was null", "."));
34+
}
35+
36+
var credentialType = input.get(namespace.toIri(CREDENTIAL_OBJECT_CREDENTIAL_TYPE_TERM));
37+
if (isNullObject(credentialType)) {
38+
return failure(violation("Must contain a '%s' property.".formatted(CREDENTIAL_OBJECT_CREDENTIAL_TYPE_TERM), null));
39+
}
40+
41+
return success();
42+
}
43+
44+
}

0 commit comments

Comments
 (0)