From 5ce9eca5bfba0a128280ebb7a88c73f4f2b0955e Mon Sep 17 00:00:00 2001 From: Stefan Wiedemann <wistefan@googlemail.com> Date: Wed, 13 Dec 2023 12:08:43 +0100 Subject: [PATCH] make it independent of the vc libs --- crypto/pom.xml | 2 +- distribution/pom.xml | 5 - pom.xml | 15 - quarkus/runtime/pom.xml | 4 - services/pom.xml | 28 +- ...C4VPClientRegistrationProviderFactory.java | 2 +- .../oidc4vp/OIDC4VPIssuerEndpoint.java | 1020 ++++++----- .../oidc4vp/OIDC4VPLoginProtocolFactory.java | 202 +-- .../oidc4vp/mappers/OIDC4VPMapper.java | 12 +- .../oidc4vp/mappers/OIDC4VPMapperFactory.java | 1 + .../mappers/OIDC4VPStaticClaimMapper.java | 6 +- .../mappers/OIDC4VPSubjectIdMapper.java | 6 +- .../mappers/OIDC4VPTargetRoleMapper.java | 13 +- .../oidc4vp/mappers/OIDC4VPTypeMapper.java | 75 + .../mappers/OIDC4VPUserAttributeMapper.java | 6 +- .../oidc4vp/model/CredentialSubject.java | 38 + .../protocol/oidc4vp/model/LdProof.java | 82 + .../oidc4vp/model/VerifiableCredential.java | 100 ++ .../oidc4vp/signing/AlgorithmType.java | 28 - .../oidc4vp/signing/FileBasedKeyLoader.java | 30 + .../oidc4vp/signing/JWTSigningService.java | 173 +- .../protocol/oidc4vp/signing/KeyLoader.java | 7 + .../oidc4vp/signing/LDSigningService.java | 145 +- .../oidc4vp/signing/SigningService.java | 96 +- .../oidc4vp/signing/VCSigningService.java | 5 +- .../signing/signatures/Ed255192018Suite.java | 133 ++ .../EdDSASignatureSignerContext.java | 48 + .../signatures/RsaSignature2018Suite.java | 48 + .../signing/signatures/SecuritySuite.java | 29 + .../oidc4vp/OIDC4VPIssuerEndpointTest.java | 1490 +++++++++-------- .../signing/JWTSigningServiceTest.java | 89 + .../oidc4vp/signing/LDSigningServiceTest.java | 109 ++ .../oidc4vp/signing/SigningServiceTest.java | 30 + services/src/test/resources/eckey.tls | 4 + 34 files changed, 2424 insertions(+), 1657 deletions(-) create mode 100644 services/src/main/java/org/keycloak/protocol/oidc4vp/mappers/OIDC4VPTypeMapper.java create mode 100644 services/src/main/java/org/keycloak/protocol/oidc4vp/model/CredentialSubject.java create mode 100644 services/src/main/java/org/keycloak/protocol/oidc4vp/model/LdProof.java create mode 100644 services/src/main/java/org/keycloak/protocol/oidc4vp/model/VerifiableCredential.java delete mode 100644 services/src/main/java/org/keycloak/protocol/oidc4vp/signing/AlgorithmType.java create mode 100644 services/src/main/java/org/keycloak/protocol/oidc4vp/signing/FileBasedKeyLoader.java create mode 100644 services/src/main/java/org/keycloak/protocol/oidc4vp/signing/KeyLoader.java create mode 100644 services/src/main/java/org/keycloak/protocol/oidc4vp/signing/signatures/Ed255192018Suite.java create mode 100644 services/src/main/java/org/keycloak/protocol/oidc4vp/signing/signatures/EdDSASignatureSignerContext.java create mode 100644 services/src/main/java/org/keycloak/protocol/oidc4vp/signing/signatures/RsaSignature2018Suite.java create mode 100644 services/src/main/java/org/keycloak/protocol/oidc4vp/signing/signatures/SecuritySuite.java create mode 100644 services/src/test/java/org/keycloak/protocol/oidc4vp/signing/JWTSigningServiceTest.java create mode 100644 services/src/test/java/org/keycloak/protocol/oidc4vp/signing/LDSigningServiceTest.java create mode 100644 services/src/test/java/org/keycloak/protocol/oidc4vp/signing/SigningServiceTest.java create mode 100644 services/src/test/resources/eckey.tls diff --git a/crypto/pom.xml b/crypto/pom.xml index de2f6d339653..f3f554480949 100644 --- a/crypto/pom.xml +++ b/crypto/pom.xml @@ -32,7 +32,7 @@ <modules> <module>default</module> - <module>fips1402</module> +<!-- <module>fips1402</module>--> <module>elytron</module> </modules> </project> \ No newline at end of file diff --git a/distribution/pom.xml b/distribution/pom.xml index 358724ef147a..d5ba3d6a1533 100755 --- a/distribution/pom.xml +++ b/distribution/pom.xml @@ -74,11 +74,6 @@ <enabled>false</enabled> </snapshots> </repository> - - <repository> - <id>danubetech-maven-public</id> - <url>https://repo.danubetech.com/repository/maven-public/</url> - </repository> </repositories> <profiles> diff --git a/pom.xml b/pom.xml index c00649034984..9436a31b8802 100644 --- a/pom.xml +++ b/pom.xml @@ -311,16 +311,6 @@ <module>js</module> <module>quarkus</module> </modules> - <repositories> - <repository> - <id>mavenCentral</id> - <url>https://repo1.maven.org/maven2/</url> - </repository> - <repository> - <id>danubetech-maven-public</id> - <url>https://repo.danubetech.com/repository/maven-public/</url> - </repository> - </repositories> <dependencyManagement> <dependencies> @@ -1737,11 +1727,6 @@ <artifactId>jboss-servlet-api_4.0_spec</artifactId> <version>${jboss-servlet-api_4.0_spec}</version> </dependency> - <dependency> - <groupId>com.danubetech</groupId> - <artifactId>verifiable-credentials-java</artifactId> - <version>1.7.0</version> - </dependency> </dependencies> </dependencyManagement> diff --git a/quarkus/runtime/pom.xml b/quarkus/runtime/pom.xml index 08db3e09bf31..68aa3768669a 100644 --- a/quarkus/runtime/pom.xml +++ b/quarkus/runtime/pom.xml @@ -153,10 +153,6 @@ </exclusion> </exclusions> </dependency> - <dependency> - <groupId>com.danubetech</groupId> - <artifactId>verifiable-credentials-java</artifactId> - </dependency> <dependency> <groupId>org.keycloak</groupId> <artifactId>keycloak-crypto-default</artifactId> diff --git a/services/pom.xml b/services/pom.xml index 10f0896f13c2..15fc3cdce6ad 100755 --- a/services/pom.xml +++ b/services/pom.xml @@ -34,13 +34,24 @@ <maven.compiler.release>17</maven.compiler.release> <maven.compiler.source>17</maven.compiler.source> <maven.compiler.target>17</maven.compiler.target> - <version.org.projectlombok>1.18.24</version.org.projectlombok> + <version.org.projectlombok>1.18.30</version.org.projectlombok> <mockito.version>1.9.5</mockito.version> <mockito.junit-jupiter.version>5.0.0</mockito.junit-jupiter.version> <junit.jupiter.version>5.9.2</junit.jupiter.version> </properties> <dependencies> + <dependency> + <groupId>com.apicatalog</groupId> + <artifactId>titanium-json-ld</artifactId> + <version>1.3.3</version> + </dependency> + <dependency> + <groupId>io.setl</groupId> + <artifactId>rdf-urdna</artifactId> + <version>1.1</version> + </dependency> + <dependency> <groupId>org.keycloak</groupId> <artifactId>keycloak-core</artifactId> @@ -87,21 +98,6 @@ <groupId>org.twitter4j</groupId> <artifactId>twitter4j-core</artifactId> </dependency> - <!-- VC Model --> - <dependency> - <groupId>com.danubetech</groupId> - <artifactId>verifiable-credentials-java</artifactId> - <exclusions> - <exclusion> - <groupId>org.glassfish</groupId> - <artifactId>jakarta.json</artifactId> - </exclusion> - <exclusion> - <groupId>org.bouncycastle</groupId> - <artifactId>bcprov-jdk18on</artifactId> - </exclusion> - </exclusions> - </dependency> <!-- lazy dev --> <dependency> <groupId>org.projectlombok</groupId> diff --git a/services/src/main/java/org/keycloak/protocol/oidc4vp/OIDC4VPClientRegistrationProviderFactory.java b/services/src/main/java/org/keycloak/protocol/oidc4vp/OIDC4VPClientRegistrationProviderFactory.java index 3d648ffa1f06..89ef818a2a92 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc4vp/OIDC4VPClientRegistrationProviderFactory.java +++ b/services/src/main/java/org/keycloak/protocol/oidc4vp/OIDC4VPClientRegistrationProviderFactory.java @@ -15,7 +15,7 @@ */ public class OIDC4VPClientRegistrationProviderFactory implements ClientRegistrationProviderFactory { - public static final String PROTOCOL_ID = "OIDC4VP"; + public static final String PROTOCOL_ID = "oidc4vp"; @Override public ClientRegistrationProvider create(KeycloakSession session) { return new OIDC4VPClientRegistrationProvider(session); diff --git a/services/src/main/java/org/keycloak/protocol/oidc4vp/OIDC4VPIssuerEndpoint.java b/services/src/main/java/org/keycloak/protocol/oidc4vp/OIDC4VPIssuerEndpoint.java index 327194dc904d..465d4f315f93 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc4vp/OIDC4VPIssuerEndpoint.java +++ b/services/src/main/java/org/keycloak/protocol/oidc4vp/OIDC4VPIssuerEndpoint.java @@ -1,41 +1,24 @@ package org.keycloak.protocol.oidc4vp; -import com.danubetech.verifiablecredentials.CredentialSubject; -import com.danubetech.verifiablecredentials.VerifiableCredential; -import com.danubetech.verifiablecredentials.jsonld.VerifiableCredentialContexts; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; -import info.weboftrust.ldsignatures.LdProof; import jakarta.validation.constraints.NotNull; -import jakarta.ws.rs.BadRequestException; -import jakarta.ws.rs.GET; -import jakarta.ws.rs.OPTIONS; -import jakarta.ws.rs.POST; -import jakarta.ws.rs.Path; -import jakarta.ws.rs.PathParam; -import jakarta.ws.rs.QueryParam; -import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.*; import jakarta.ws.rs.core.Response; import lombok.Getter; import lombok.RequiredArgsConstructor; import org.jboss.logging.Logger; import org.keycloak.TokenVerifier; -import org.keycloak.authentication.authenticators.client.JWTClientValidator; import org.keycloak.common.VerificationException; import org.keycloak.common.util.Time; import org.keycloak.events.EventBuilder; -import org.keycloak.models.AuthenticatedClientSessionModel; -import org.keycloak.models.ClientModel; -import org.keycloak.models.KeycloakSession; -import org.keycloak.models.ProtocolMapperContainerModel; -import org.keycloak.models.ProtocolMapperModel; -import org.keycloak.models.RoleModel; -import org.keycloak.models.UserSessionModel; +import org.keycloak.models.*; import org.keycloak.protocol.oidc.utils.OAuth2Code; import org.keycloak.protocol.oidc.utils.OAuth2CodeParser; import org.keycloak.protocol.oidc4vp.mappers.OIDC4VPMapper; import org.keycloak.protocol.oidc4vp.mappers.OIDC4VPMapperFactory; +import org.keycloak.protocol.oidc4vp.model.*; import org.keycloak.protocol.oidc4vp.model.CredentialOfferURI; import org.keycloak.protocol.oidc4vp.model.CredentialRequest; import org.keycloak.protocol.oidc4vp.model.CredentialResponse; @@ -44,9 +27,8 @@ import org.keycloak.protocol.oidc4vp.model.Format; import org.keycloak.protocol.oidc4vp.model.PreAuthorized; import org.keycloak.protocol.oidc4vp.model.PreAuthorizedGrant; -import org.keycloak.protocol.oidc4vp.model.Proof; -import org.keycloak.protocol.oidc4vp.model.Role; import org.keycloak.protocol.oidc4vp.model.SupportedCredential; +import org.keycloak.protocol.oidc4vp.signing.FileBasedKeyLoader; import org.keycloak.protocol.oidc4vp.signing.JWTSigningService; import org.keycloak.protocol.oidc4vp.signing.LDSigningService; import org.keycloak.protocol.oidc4vp.signing.SigningServiceException; @@ -56,23 +38,11 @@ import java.net.URI; import java.time.Clock; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Date; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import java.util.Set; -import java.util.UUID; +import java.util.*; import java.util.stream.Collectors; import static org.keycloak.protocol.oidc4vp.OIDC4VPClientRegistrationProvider.VC_TYPES_PREFIX; -import static org.keycloak.protocol.oidc4vp.model.Format.JWT_VC; -import static org.keycloak.protocol.oidc4vp.model.Format.JWT_VC_JSON; -import static org.keycloak.protocol.oidc4vp.model.Format.JWT_VC_JSON_LD; -import static org.keycloak.protocol.oidc4vp.model.Format.LDP_VC; +import static org.keycloak.protocol.oidc4vp.model.Format.*; /** * Realm-Resource to provide functionality for issuing VerifiableCredentials to users, depending on their roles in @@ -80,489 +50,499 @@ */ public class OIDC4VPIssuerEndpoint { - private static final Logger LOGGER = Logger.getLogger(OIDC4VPIssuerEndpoint.class); - - public static final String CREDENTIAL_PATH = "credential"; - public static final String TYPE_VERIFIABLE_CREDENTIAL = "VerifiableCredential"; - public static final String GRANT_TYPE_PRE_AUTHORIZED_CODE = "urn:ietf:params:oauth:grant-type:pre-authorized_code"; - private static final String ACCESS_CONTROL_HEADER = "Access-Control-Allow-Origin"; - - private final KeycloakSession session; - private final AppAuthManager.BearerTokenAuthenticator bearerTokenAuthenticator; - private final ObjectMapper objectMapper; - private final Clock clock; - - private final String issuerDid; - - private final boolean ldSigningEnabled; - private final boolean jwtSigningEnabled; - private LDSigningService ldSigningService; - private JWTSigningService jwtSigningService; - - public OIDC4VPIssuerEndpoint(KeycloakSession session, - String issuerDid, - String keyPath, - AppAuthManager.BearerTokenAuthenticator authenticator, - ObjectMapper objectMapper, Clock clock) { - this.session = session; - this.bearerTokenAuthenticator = authenticator; - this.objectMapper = objectMapper; - this.clock = clock; - this.issuerDid = issuerDid; - var tempJwtSigningEnabled = false; - try { - this.jwtSigningService = new JWTSigningService(keyPath, Optional.empty()); - tempJwtSigningEnabled = true; - } catch (SigningServiceException e) { - LOGGER.warn("Was not able to initialize JWT SigningService, jwt credentials are not supported.", e); - } - this.jwtSigningEnabled = tempJwtSigningEnabled; - - var tempLdSigningEnabled = false; - try { - this.ldSigningService = new LDSigningService(keyPath, Optional.empty(), clock); - tempLdSigningEnabled = true; - } catch (SigningServiceException e) { - LOGGER.warn("Was not able to initialize LD SigningService, ld credentials are not supported.", e); - } - this.ldSigningEnabled = tempLdSigningEnabled; - } - - /** - * Provides URI to the OIDC4VCI compliant credentials offer - */ - @GET - @Path("credential-offer-uri") - public Response getCredentialOfferURI(@QueryParam("credentialId") String vcId) { - - Map<String, SupportedCredential> credentialsMap = OIDC4VPAbstractWellKnownProvider - .getSupportedCredentials(session.getContext()).stream() - .collect(Collectors.toMap(SupportedCredential::getId, sc -> sc, (sc1, sc2) -> sc1)); - - LOGGER.debugf("Get an offer for %s", vcId); - if (!credentialsMap.containsKey(vcId)) { - LOGGER.warnf("No credential with id %s exists.", vcId); - LOGGER.debugf("Supported credentials are %s.", credentialsMap); - throw new BadRequestException(getErrorResponse(ErrorResponse.ErrorEnum.INVALID_REQUEST)); - } - SupportedCredential supportedCredential = credentialsMap.get(vcId); - var format = supportedCredential.getFormat(); - - // check that the user is allowed to get such credential - supportedCredential.getTypes() - .forEach(type -> getClientsOfType(type, format)); - - String nonce = generateAuthorizationCode(); - - AuthenticationManager.AuthResult authResult = getAuthResult(); - UserSessionModel userSessionModel = getUserSessionModel(); - - AuthenticatedClientSessionModel clientSession = userSessionModel. - getAuthenticatedClientSessionByClient( - authResult.getClient().getId()); - try { - clientSession.setNote(nonce, objectMapper.writeValueAsString(supportedCredential)); - } catch (JsonProcessingException e) { - LOGGER.errorf("Could not convert POJO to JSON: %s", e.getMessage()); - throw new BadRequestException(getErrorResponse(ErrorResponse.ErrorEnum.INVALID_REQUEST)); - } - - CredentialOfferURI credentialOfferURI = new CredentialOfferURI(); - credentialOfferURI.setIssuer(OIDC4VPAbstractWellKnownProvider.getIssuer(session.getContext())); - credentialOfferURI.setNonce(nonce); - - LOGGER.debugf("Responding with nonce: %s", nonce); - return Response.ok() - .entity(credentialOfferURI) - .header(ACCESS_CONTROL_HEADER, "*") - .build(); - - } - - /** - * Provides an OIDC4VCI compliant credentials offer - */ - @GET - @Path("credential-offer/{nonce}") - public Response getCredentialOffer(@PathParam("nonce") String nonce) { - - OAuth2CodeParser.ParseResult result = parseNonce(nonce); - - SupportedCredential offeredCredential; - try { - offeredCredential = objectMapper.readValue(result.getClientSession().getNote(nonce), - SupportedCredential.class); - LOGGER.debugf("Creating an offer for %s - %s", offeredCredential.getTypes(), - offeredCredential.getFormat()); - result.getClientSession().removeNote(nonce); - } catch (JsonProcessingException e) { - LOGGER.errorf("Could not convert JSON to POJO: %s", e); - throw new BadRequestException(getErrorResponse(ErrorResponse.ErrorEnum.INVALID_REQUEST)); - } - - String preAuthorizedCode = generateAuthorizationCodeForClientSession(result.getClientSession()); - CredentialsOffer theOffer = new CredentialsOffer() - .credentialIssuer(OIDC4VPAbstractWellKnownProvider.getIssuer(session.getContext())) - .credentials(List.of(offeredCredential)) - .grants(new PreAuthorizedGrant(). - urnColonIetfColonParamsColonOauthColonGrantTypeColonPreAuthorizedCode( - new PreAuthorized().preAuthorizedCode(preAuthorizedCode) - .userPinRequired(false))); - - LOGGER.debugf("Responding with offer: %s", theOffer); - return Response.ok() - .entity(theOffer) - .header(ACCESS_CONTROL_HEADER, "*") - .build(); - } - - /** - * Options endpoint to serve the cors-preflight requests. - * Since we cannot know the address of the requesting wallets in advance, we have to accept all origins. - */ - @OPTIONS - @Path("{any: .*}") - public Response optionCorsResponse() { - return Response.ok().header(ACCESS_CONTROL_HEADER, "*") - .header("Access-Control-Allow-Methods", "POST,GET,OPTIONS") - .header("Access-Control-Allow-Headers", "Content-Type,Authorization") - .build(); - } - - /** - * Returns a verifiable credential of the given type, containing the information and roles assigned to the - * authenticated user. - * In order to support the often used retrieval method by wallets, the token can also be provided as a - * query-parameter. If the parameter is empty, the token is taken from the authorization-header. - * - * @param vcType type of the VerifiableCredential to be returend. - * @param token optional JWT to be used instead of retrieving it from the header. - * @return the vc. - */ - @GET - @Path("/") - public Response issueVerifiableCredential(@QueryParam("type") String vcType, @QueryParam("token") String - token) { - LOGGER.debugf("Get a VC of type %s. Token parameter is %s.", vcType, token); - if (token != null) { - // authenticate with the token - bearerTokenAuthenticator.setTokenString(token); - } - return Response.ok() - .entity(getCredential(vcType, LDP_VC)) - .header(ACCESS_CONTROL_HEADER, "*") - .build(); - } - - /** - * Requests a credential from the issuer - */ - @POST - @Path(CREDENTIAL_PATH) - public Response requestCredential( - CredentialRequest credentialRequestVO) { - LOGGER.debugf("Received credentials request %s.", credentialRequestVO); - - List<String> types = new ArrayList<>(Objects.requireNonNull(Optional.ofNullable(credentialRequestVO.getTypes()) - .orElseGet(() -> { - try { - return objectMapper.readValue(credentialRequestVO.getType(), new TypeReference<>() { - }); - } catch (JsonProcessingException e) { - LOGGER.warnf("Was not able to read the type parameter: %s", credentialRequestVO.getType(), e); - return null; - } - }))); - - // remove the static type - types.remove(TYPE_VERIFIABLE_CREDENTIAL); - - if (types.size() != 1) { - LOGGER.infof("Credential request contained multiple types. Req: %s", credentialRequestVO); - throw new BadRequestException(getErrorResponse(ErrorResponse.ErrorEnum.INVALID_REQUEST)); - } - // verify the proof - Optional.ofNullable(credentialRequestVO.getProof()).ifPresent(this::verifyProof); - - Format requestedFormat = credentialRequestVO.getFormat(); - // workaround to support implementations not differentiating json & json-ld - if (requestedFormat == JWT_VC) { - requestedFormat = JWT_VC_JSON; - } - // TODO: check if there can be more - String vcType = types.get(0); - - var responseVO = new CredentialResponse(); - // keep the originally requested here. - responseVO.format(credentialRequestVO.getFormat()); - - Object theCredential = getCredential(vcType, credentialRequestVO.getFormat()); - switch (requestedFormat) { - case LDP_VC -> responseVO.setCredential(theCredential); - case JWT_VC_JSON -> responseVO.setCredential(theCredential); - default -> throw new BadRequestException( - getErrorResponse(ErrorResponse.ErrorEnum.UNSUPPORTED_CREDENTIAL_TYPE)); - } - return Response.ok().entity(responseVO) - .header(ACCESS_CONTROL_HEADER, "*").build(); - } - - // return the current usersession model - private UserSessionModel getUserSessionModel() { - return getAuthResult( - new BadRequestException(getErrorResponse(ErrorResponse.ErrorEnum.INVALID_TOKEN))).getSession(); - } - - private AuthenticationManager.AuthResult getAuthResult() { - return getAuthResult(new BadRequestException(getErrorResponse(ErrorResponse.ErrorEnum.INVALID_TOKEN))); - } - - // get the auth result from the authentication manager - private AuthenticationManager.AuthResult getAuthResult(WebApplicationException errorResponse) { - AuthenticationManager.AuthResult authResult = bearerTokenAuthenticator.authenticate(); - if (authResult == null) { - throw errorResponse; - } - return authResult; - } - - protected Object getCredential(String vcType, Format format) { - // do first to fail fast on auth - UserSessionModel userSessionModel = getUserSessionModel(); - List<ClientModel> clients = getClientsOfType(vcType, format); - List<OIDC4VPMapper> protocolMappers = getProtocolMappers(clients) - .stream() - .map(OIDC4VPMapperFactory::createOIDC4VPMapper) - .toList(); - - var credentialToSign = getVCToSign(protocolMappers, vcType, userSessionModel); - - return switch (format) { - case LDP_VC -> { - if (ldSigningEnabled) { - yield ldSigningService.signCredential(credentialToSign); - } - throw new IllegalArgumentException( - String.format("Requested format %s is not supported.", format)); - } - case JWT_VC, JWT_VC_JSON_LD, JWT_VC_JSON -> { - if (jwtSigningEnabled) { - yield jwtSigningService.signCredential(credentialToSign); - } - throw new IllegalArgumentException( - String.format("Requested format %s is not supported.", format)); - } - }; - } - - private List<ProtocolMapperModel> getProtocolMappers(List<ClientModel> clientModels) { - return clientModels.stream() - .flatMap(ProtocolMapperContainerModel::getProtocolMappersStream) - .toList(); - - } - - private OAuth2CodeParser.ParseResult parseNonce(String nonce) throws BadRequestException { - EventBuilder eventBuilder = new EventBuilder(session.getContext().getRealm(), session, - session.getContext().getConnection()); - OAuth2CodeParser.ParseResult result = OAuth2CodeParser.parseCode(session, nonce, - session.getContext().getRealm(), - eventBuilder); - if (result.isExpiredCode() || result.isIllegalCode()) { - throw new BadRequestException(getErrorResponse(ErrorResponse.ErrorEnum.INVALID_TOKEN)); - } - return result; - } - - private String generateAuthorizationCode() { - AuthenticationManager.AuthResult authResult = getAuthResult(); - UserSessionModel userSessionModel = getUserSessionModel(); - AuthenticatedClientSessionModel clientSessionModel = userSessionModel. - getAuthenticatedClientSessionByClient(authResult.getClient().getId()); - return generateAuthorizationCodeForClientSession(clientSessionModel); - } - - private String generateAuthorizationCodeForClientSession(AuthenticatedClientSessionModel clientSessionModel) { - int expiration = Time.currentTime() + clientSessionModel.getUserSession().getRealm().getAccessCodeLifespan(); - - String codeId = UUID.randomUUID().toString(); - String nonce = UUID.randomUUID().toString(); - OAuth2Code oAuth2Code = new OAuth2Code(codeId, expiration, nonce, null, null, null, null, - clientSessionModel.getUserSession().getId()); - - return OAuth2CodeParser.persistCode(session, clientSessionModel, oAuth2Code); - } - - private Response getErrorResponse(ErrorResponse.ErrorEnum errorType) { - var errorResponse = new ErrorResponse(); - errorResponse.setError(errorType); - return Response.status(Response.Status.BAD_REQUEST).entity(errorResponse).build(); - } - - @NotNull - private List<ClientModel> getClientsOfType(String vcType, Format format) { - LOGGER.debugf("Retrieve all clients of type %s, supporting format %s", vcType, format.toString()); - - List<String> formatStrings = switch (format) { - case LDP_VC -> List.of(LDP_VC.toString()); - case JWT_VC, JWT_VC_JSON -> List.of(JWT_VC.toString(), JWT_VC_JSON.toString()); - case JWT_VC_JSON_LD -> List.of(JWT_VC.toString(), JWT_VC_JSON_LD.toString()); - - }; - - Optional.ofNullable(vcType).filter(type -> !type.isEmpty()).orElseThrow(() -> { - LOGGER.info("No VC type was provided."); - return new BadRequestException("No VerifiableCredential-Type was provided in the request."); - }); - - String prefixedType = String.format("%s%s", VC_TYPES_PREFIX, vcType); - LOGGER.infof("Looking for client supporting %s with format %s", prefixedType, formatStrings); - List<ClientModel> vcClients = getClientModelsFromSession().stream() - .filter(clientModel -> Optional.ofNullable(clientModel.getAttributes()) - .filter(attributes -> attributes.containsKey(prefixedType)) - .filter(attributes -> formatStrings.stream() - .anyMatch(formatString -> Arrays.asList(attributes.get(prefixedType).split(",")) - .contains(formatString))) - .isPresent()) - .toList(); - - if (vcClients.isEmpty()) { - LOGGER.infof("No OIDC4VP-Client supporting type %s registered.", vcType); - throw new BadRequestException(getErrorResponse(ErrorResponse.ErrorEnum.UNSUPPORTED_CREDENTIAL_TYPE)); - } - return vcClients; - } - - @NotNull - private List<ClientModel> getClientModelsFromSession() { - return session.clients().getClientsStream(session.getContext().getRealm()) - .filter(clientModel -> clientModel.getProtocol() != null) - .filter(clientModel -> clientModel.getProtocol() - .equals(OIDC4VPClientRegistrationProviderFactory.PROTOCOL_ID)) - .toList(); - } - - @NotNull - private Role toRolesClaim(ClientRoleModel crm) { - Set<String> roleNames = crm - .getRoleModels() - .stream() - .map(RoleModel::getName) - .collect(Collectors.toSet()); - return new Role(roleNames, crm.getClientId()); - } - - @NotNull - private VerifiableCredential getVCToSign(List<OIDC4VPMapper> protocolMappers, String vcType, - UserSessionModel userSessionModel) { - - var subjectBuilder = CredentialSubject.builder(); - - Map<String, Object> subjectClaims = new HashMap<>(); - - protocolMappers - .forEach(mapper -> mapper.setClaimsForSubject(subjectClaims, userSessionModel)); - - LOGGER.infof("Will set %s", subjectClaims); - subjectBuilder.claims(subjectClaims); - - CredentialSubject subject = subjectBuilder.build(); - - var credentialBuilder = VerifiableCredential.builder() - .types(List.of(vcType)) - .context(VerifiableCredentialContexts.JSONLD_CONTEXT_W3C_2018_CREDENTIALS_V1) - .id(URI.create(String.format("urn:uuid:%s", UUID.randomUUID()))) - .issuer(URI.create(issuerDid)) - .issuanceDate(Date.from(clock.instant())) - .credentialSubject(subject); - - // use the mappers after the default - protocolMappers - .forEach(mapper -> mapper.setClaimsForCredential(credentialBuilder, userSessionModel)); - - return credentialBuilder.build(); - } - - private void verifyProof(Proof proof) { - switch (proof.getProofType()) { - case JWT -> verifyJWTProof(proof.getJwt()); - case LD_PROOF -> throw new IllegalArgumentException("LD Proofs on the request are not yet supported."); - } - } - - private void verifyJWTProof(String jwt) { - - var verifier = TokenVerifier.create(jwt, JsonWebToken.class) - .withChecks(jsonWebToken -> jsonWebToken.getType().equals("openid4vci-proof+jwt"), - jsonWebToken -> jsonWebToken.getAudience().length == 1, - jsonWebToken -> jsonWebToken.getAudience()[0].equals( - OIDC4VPAbstractWellKnownProvider.getIssuer(session.getContext())), - jsonWebToken -> jsonWebToken.getOtherClaims().containsKey("nonce")); - - try { - verifier.verify(); - } catch (VerificationException e) { - LOGGER.warnf("Was not able to verify the jwt proof.", e); - throw new BadRequestException(getErrorResponse(ErrorResponse.ErrorEnum.INVALID_OR_MISSING_PROOF)); - } - - } - - @NotNull - private List<String> getClaimsToSet(String credentialType, List<ClientModel> clients) { - String claims = clients.stream() - .map(ClientModel::getAttributes) - .filter(Objects::nonNull) - .map(Map::entrySet) - .flatMap(Set::stream) - // get the claims - .filter(entry -> entry.getKey().equals(String.format("%s_%s", credentialType, "claims"))) - .findFirst() - .map(Map.Entry::getValue) - .orElse(""); - LOGGER.infof("Should set %s for %s.", claims, credentialType); - return Arrays.asList(claims.split(",")); - - } - - @NotNull - private Optional<Map<String, String>> getAdditionalClaims(List<ClientModel> clients) { - Map<String, String> additionalClaims = clients.stream() - .map(ClientModel::getAttributes) - .filter(Objects::nonNull) - .map(Map::entrySet) - .flatMap(Set::stream) - // only include the claims explicitly intended for vc - .filter(entry -> entry.getKey().startsWith(OIDC4VPClientRegistrationProvider.VC_CLAIMS_PREFIX)) - .collect( - Collectors.toMap( - // remove the prefix before sending it - entry -> entry.getKey() - .replaceFirst(OIDC4VPClientRegistrationProvider.VC_CLAIMS_PREFIX, ""), - // value is taken untouched if its unique - Map.Entry::getValue, - // if multiple values for the same key exist, we add them comma separated. - // this needs to be improved, once more requirements are known. - (entry1, entry2) -> { - if (entry1.equals(entry2) || entry1.contains(entry2)) { - return entry1; - } else { - return String.format("%s,%s", entry1, entry2); - } - } - )); - if (additionalClaims.isEmpty()) { - return Optional.empty(); - } else { - return Optional.of(additionalClaims); - } - } - - @Getter - @RequiredArgsConstructor - private static class ClientRoleModel { - private final String clientId; - private final List<RoleModel> roleModels; - } + private static final Logger LOGGER = Logger.getLogger(OIDC4VPIssuerEndpoint.class); + + public static final String CREDENTIAL_PATH = "credential"; + public static final String TYPE_VERIFIABLE_CREDENTIAL = "VerifiableCredential"; + public static final String GRANT_TYPE_PRE_AUTHORIZED_CODE = "urn:ietf:params:oauth:grant-type:pre-authorized_code"; + private static final String ACCESS_CONTROL_HEADER = "Access-Control-Allow-Origin"; + + private final KeycloakSession session; + private final AppAuthManager.BearerTokenAuthenticator bearerTokenAuthenticator; + private final ObjectMapper objectMapper; + private final Clock clock; + + private final String issuerDid; + + private final boolean ldSigningEnabled; + private final boolean jwtSigningEnabled; + private LDSigningService ldSigningService; + private JWTSigningService jwtSigningService; + + public OIDC4VPIssuerEndpoint(KeycloakSession session, + String issuerDid, + String keyPath, + Optional<String> jwtType, + Optional<String> ldpType, + AppAuthManager.BearerTokenAuthenticator authenticator, + ObjectMapper objectMapper, Clock clock) { + this.session = session; + this.bearerTokenAuthenticator = authenticator; + this.objectMapper = objectMapper; + this.clock = clock; + this.issuerDid = issuerDid; + var tempJwtSigningEnabled = false; + if (jwtType.isPresent()) { + try { + this.jwtSigningService = new JWTSigningService(new FileBasedKeyLoader(keyPath), Optional.empty(), clock, jwtType.get()); + tempJwtSigningEnabled = true; + } catch (SigningServiceException e) { + LOGGER.warn("Was not able to initialize JWT SigningService, jwt credentials are not supported.", e); + throw new IllegalArgumentException("No valid jwt_vc signing configured.", e); + } + } + this.jwtSigningEnabled = tempJwtSigningEnabled; + + var tempLdSigningEnabled = false; + if (ldpType.isPresent()) { + try { + this.ldSigningService = new LDSigningService(new FileBasedKeyLoader(keyPath), Optional.empty(), clock, ldpType.get(), objectMapper); + tempLdSigningEnabled = true; + } catch (SigningServiceException e) { + LOGGER.warn("Was not able to initialize LD SigningService, ld credentials are not supported.", e); + throw new IllegalArgumentException("No valid ldp_vc signing configured.", e); + + } + } + this.ldSigningEnabled = tempLdSigningEnabled; + } + + /** + * Provides URI to the OIDC4VCI compliant credentials offer + */ + @GET + @Path("credential-offer-uri") + public Response getCredentialOfferURI(@QueryParam("credentialId") String vcId) { + + Map<String, SupportedCredential> credentialsMap = OIDC4VPAbstractWellKnownProvider + .getSupportedCredentials(session.getContext()).stream() + .collect(Collectors.toMap(SupportedCredential::getId, sc -> sc, (sc1, sc2) -> sc1)); + + LOGGER.debugf("Get an offer for %s", vcId); + if (!credentialsMap.containsKey(vcId)) { + LOGGER.warnf("No credential with id %s exists.", vcId); + LOGGER.debugf("Supported credentials are %s.", credentialsMap); + throw new BadRequestException(getErrorResponse(ErrorResponse.ErrorEnum.INVALID_REQUEST)); + } + SupportedCredential supportedCredential = credentialsMap.get(vcId); + var format = supportedCredential.getFormat(); + + // check that the user is allowed to get such credential + supportedCredential.getTypes() + .forEach(type -> getClientsOfType(type, format)); + + String nonce = generateAuthorizationCode(); + + AuthenticationManager.AuthResult authResult = getAuthResult(); + UserSessionModel userSessionModel = getUserSessionModel(); + + AuthenticatedClientSessionModel clientSession = userSessionModel. + getAuthenticatedClientSessionByClient( + authResult.getClient().getId()); + try { + clientSession.setNote(nonce, objectMapper.writeValueAsString(supportedCredential)); + } catch (JsonProcessingException e) { + LOGGER.errorf("Could not convert POJO to JSON: %s", e.getMessage()); + throw new BadRequestException(getErrorResponse(ErrorResponse.ErrorEnum.INVALID_REQUEST)); + } + + CredentialOfferURI credentialOfferURI = new CredentialOfferURI(); + credentialOfferURI.setIssuer(OIDC4VPAbstractWellKnownProvider.getIssuer(session.getContext())); + credentialOfferURI.setNonce(nonce); + + LOGGER.debugf("Responding with nonce: %s", nonce); + return Response.ok() + .entity(credentialOfferURI) + .header(ACCESS_CONTROL_HEADER, "*") + .build(); + + } + + /** + * Provides an OIDC4VCI compliant credentials offer + */ + @GET + @Path("credential-offer/{nonce}") + public Response getCredentialOffer(@PathParam("nonce") String nonce) { + + OAuth2CodeParser.ParseResult result = parseNonce(nonce); + + SupportedCredential offeredCredential; + try { + offeredCredential = objectMapper.readValue(result.getClientSession().getNote(nonce), + SupportedCredential.class); + LOGGER.debugf("Creating an offer for %s - %s", offeredCredential.getTypes(), + offeredCredential.getFormat()); + result.getClientSession().removeNote(nonce); + } catch (JsonProcessingException e) { + LOGGER.errorf("Could not convert JSON to POJO: %s", e); + throw new BadRequestException(getErrorResponse(ErrorResponse.ErrorEnum.INVALID_REQUEST)); + } + + String preAuthorizedCode = generateAuthorizationCodeForClientSession(result.getClientSession()); + CredentialsOffer theOffer = new CredentialsOffer() + .credentialIssuer(OIDC4VPAbstractWellKnownProvider.getIssuer(session.getContext())) + .credentials(List.of(offeredCredential)) + .grants(new PreAuthorizedGrant(). + urnColonIetfColonParamsColonOauthColonGrantTypeColonPreAuthorizedCode( + new PreAuthorized().preAuthorizedCode(preAuthorizedCode) + .userPinRequired(false))); + + LOGGER.debugf("Responding with offer: %s", theOffer); + return Response.ok() + .entity(theOffer) + .header(ACCESS_CONTROL_HEADER, "*") + .build(); + } + + /** + * Options endpoint to serve the cors-preflight requests. + * Since we cannot know the address of the requesting wallets in advance, we have to accept all origins. + */ + @OPTIONS + @Path("{any: .*}") + public Response optionCorsResponse() { + return Response.ok().header(ACCESS_CONTROL_HEADER, "*") + .header("Access-Control-Allow-Methods", "POST,GET,OPTIONS") + .header("Access-Control-Allow-Headers", "Content-Type,Authorization") + .build(); + } + + /** + * Returns a verifiable credential of the given type, containing the information and roles assigned to the + * authenticated user. + * In order to support the often used retrieval method by wallets, the token can also be provided as a + * query-parameter. If the parameter is empty, the token is taken from the authorization-header. + * + * @param vcType type of the VerifiableCredential to be returend. + * @param token optional JWT to be used instead of retrieving it from the header. + * @return the vc. + */ + @GET + @Path("/") + public Response issueVerifiableCredential(@QueryParam("type") String vcType, @QueryParam("token") String + token) { + LOGGER.debugf("Get a VC of type %s. Token parameter is %s.", vcType, token); + if (token != null) { + // authenticate with the token + bearerTokenAuthenticator.setTokenString(token); + } + return Response.ok() + .entity(getCredential(vcType, LDP_VC)) + .header(ACCESS_CONTROL_HEADER, "*") + .build(); + } + + /** + * Requests a credential from the issuer + */ + @POST + @Path(CREDENTIAL_PATH) + public Response requestCredential( + CredentialRequest credentialRequestVO) { + LOGGER.debugf("Received credentials request %s.", credentialRequestVO); + + List<String> types = new ArrayList<>(Objects.requireNonNull(Optional.ofNullable(credentialRequestVO.getTypes()) + .orElseGet(() -> { + try { + return objectMapper.readValue(credentialRequestVO.getType(), new TypeReference<>() { + }); + } catch (JsonProcessingException e) { + LOGGER.warnf("Was not able to read the type parameter: %s", credentialRequestVO.getType(), e); + return null; + } + }))); + + // remove the static type + types.remove(TYPE_VERIFIABLE_CREDENTIAL); + + if (types.size() != 1) { + LOGGER.infof("Credential request contained multiple types. Req: %s", credentialRequestVO); + throw new BadRequestException(getErrorResponse(ErrorResponse.ErrorEnum.INVALID_REQUEST)); + } + // verify the proof +// Optional.ofNullable(credentialRequestVO.getProof()).ifPresent(this::verifyProof); + + Format requestedFormat = credentialRequestVO.getFormat(); + // workaround to support implementations not differentiating json & json-ld + if (requestedFormat == JWT_VC) { + requestedFormat = JWT_VC_JSON; + } + // TODO: check if there can be more + String vcType = types.get(0); + + var responseVO = new CredentialResponse(); + // keep the originally requested here. + responseVO.format(credentialRequestVO.getFormat()); + + Object theCredential = getCredential(vcType, credentialRequestVO.getFormat()); + switch (requestedFormat) { + case LDP_VC -> responseVO.setCredential(theCredential); + case JWT_VC_JSON -> responseVO.setCredential(theCredential); + default -> throw new BadRequestException( + getErrorResponse(ErrorResponse.ErrorEnum.UNSUPPORTED_CREDENTIAL_TYPE)); + } + return Response.ok().entity(responseVO) + .header(ACCESS_CONTROL_HEADER, "*").build(); + } + + // return the current usersession model + private UserSessionModel getUserSessionModel() { + return getAuthResult( + new BadRequestException(getErrorResponse(ErrorResponse.ErrorEnum.INVALID_TOKEN))).getSession(); + } + + private AuthenticationManager.AuthResult getAuthResult() { + return getAuthResult(new BadRequestException(getErrorResponse(ErrorResponse.ErrorEnum.INVALID_TOKEN))); + } + + // get the auth result from the authentication manager + private AuthenticationManager.AuthResult getAuthResult(WebApplicationException errorResponse) { + AuthenticationManager.AuthResult authResult = bearerTokenAuthenticator.authenticate(); + if (authResult == null) { + throw errorResponse; + } + return authResult; + } + + protected Object getCredential(String vcType, Format format) { + // do first to fail fast on auth + UserSessionModel userSessionModel = getUserSessionModel(); + List<ClientModel> clients = getClientsOfType(vcType, format); + List<OIDC4VPMapper> protocolMappers = getProtocolMappers(clients) + .stream() + .map(OIDC4VPMapperFactory::createOIDC4VPMapper) + .toList(); + + var credentialToSign = getVCToSign(protocolMappers, vcType, userSessionModel); + + return switch (format) { + case LDP_VC -> { + if (ldSigningEnabled) { + yield ldSigningService.signCredential(credentialToSign); + } + throw new IllegalArgumentException( + String.format("Requested format %s is not supported.", format)); + } + case JWT_VC, JWT_VC_JSON_LD, JWT_VC_JSON -> { + if (jwtSigningEnabled) { + yield jwtSigningService.signCredential(credentialToSign); + } + throw new IllegalArgumentException( + String.format("Requested format %s is not supported.", format)); + } + }; + } + + private List<ProtocolMapperModel> getProtocolMappers(List<ClientModel> clientModels) { + return clientModels.stream() + .flatMap(ProtocolMapperContainerModel::getProtocolMappersStream) + .toList(); + + } + + private OAuth2CodeParser.ParseResult parseNonce(String nonce) throws BadRequestException { + EventBuilder eventBuilder = new EventBuilder(session.getContext().getRealm(), session, + session.getContext().getConnection()); + OAuth2CodeParser.ParseResult result = OAuth2CodeParser.parseCode(session, nonce, + session.getContext().getRealm(), + eventBuilder); + if (result.isExpiredCode() || result.isIllegalCode()) { + throw new BadRequestException(getErrorResponse(ErrorResponse.ErrorEnum.INVALID_TOKEN)); + } + return result; + } + + private String generateAuthorizationCode() { + AuthenticationManager.AuthResult authResult = getAuthResult(); + UserSessionModel userSessionModel = getUserSessionModel(); + AuthenticatedClientSessionModel clientSessionModel = userSessionModel. + getAuthenticatedClientSessionByClient(authResult.getClient().getId()); + return generateAuthorizationCodeForClientSession(clientSessionModel); + } + + private String generateAuthorizationCodeForClientSession(AuthenticatedClientSessionModel clientSessionModel) { + int expiration = Time.currentTime() + clientSessionModel.getUserSession().getRealm().getAccessCodeLifespan(); + + String codeId = UUID.randomUUID().toString(); + String nonce = UUID.randomUUID().toString(); + OAuth2Code oAuth2Code = new OAuth2Code(codeId, expiration, nonce, null, null, null, null, + clientSessionModel.getUserSession().getId()); + + return OAuth2CodeParser.persistCode(session, clientSessionModel, oAuth2Code); + } + + private Response getErrorResponse(ErrorResponse.ErrorEnum errorType) { + var errorResponse = new ErrorResponse(); + errorResponse.setError(errorType); + return Response.status(Response.Status.BAD_REQUEST).entity(errorResponse).build(); + } + + @NotNull + private List<ClientModel> getClientsOfType(String vcType, Format format) { + LOGGER.debugf("Retrieve all clients of type %s, supporting format %s", vcType, format.toString()); + + List<String> formatStrings = switch (format) { + case LDP_VC -> List.of(LDP_VC.toString()); + case JWT_VC, JWT_VC_JSON -> List.of(JWT_VC.toString(), JWT_VC_JSON.toString()); + case JWT_VC_JSON_LD -> List.of(JWT_VC.toString(), JWT_VC_JSON_LD.toString()); + + }; + + Optional.ofNullable(vcType).filter(type -> !type.isEmpty()).orElseThrow(() -> { + LOGGER.info("No VC type was provided."); + return new BadRequestException("No VerifiableCredential-Type was provided in the request."); + }); + + String prefixedType = String.format("%s%s", VC_TYPES_PREFIX, vcType); + LOGGER.infof("Looking for client supporting %s with format %s", prefixedType, formatStrings); + List<ClientModel> vcClients = getClientModelsFromSession().stream() + .filter(clientModel -> Optional.ofNullable(clientModel.getAttributes()) + .filter(attributes -> attributes.containsKey(prefixedType)) + .filter(attributes -> formatStrings.stream() + .anyMatch(formatString -> Arrays.asList(attributes.get(prefixedType).split(",")) + .contains(formatString))) + .isPresent()) + .toList(); + + if (vcClients.isEmpty()) { + LOGGER.infof("No OIDC4VP-Client supporting type %s registered.", vcType); + throw new BadRequestException(getErrorResponse(ErrorResponse.ErrorEnum.UNSUPPORTED_CREDENTIAL_TYPE)); + } + return vcClients; + } + + @NotNull + private List<ClientModel> getClientModelsFromSession() { + return session.clients().getClientsStream(session.getContext().getRealm()) + .filter(clientModel -> clientModel.getProtocol() != null) + .filter(clientModel -> clientModel.getProtocol() + .equals(OIDC4VPClientRegistrationProviderFactory.PROTOCOL_ID)) + .toList(); + } + + @NotNull + private Role toRolesClaim(ClientRoleModel crm) { + Set<String> roleNames = crm + .getRoleModels() + .stream() + .map(RoleModel::getName) + .collect(Collectors.toSet()); + return new Role(roleNames, crm.getClientId()); + } + + @NotNull + private VerifiableCredential getVCToSign(List<OIDC4VPMapper> protocolMappers, String vcType, + UserSessionModel userSessionModel) { + + var vc = new VerifiableCredential(); + + // set the required claims + vc.setIssuer(URI.create(issuerDid)); + vc.setIssuanceDate(Date.from(clock.instant())); + + Map<String, Object> subjectClaims = new HashMap<>(); + + protocolMappers + .forEach(mapper -> mapper.setClaimsForSubject(subjectClaims, userSessionModel)); + + LOGGER.infof("Will set %s", subjectClaims); + subjectClaims.entrySet().stream().forEach(entry -> vc.getCredentialSubject().setClaims(entry.getKey(), entry.getValue())); + // use the mappers after the default + protocolMappers + .forEach(mapper -> mapper.setClaimsForCredential(vc, userSessionModel)); + if (vc.getContext() == null || vc.getContext().isEmpty()) { + // add default + vc.setContext(List.of("https://www.w3.org/2018/credentials/v1")); + } + + if (vc.getId() == null) { + vc.setId(URI.create(String.format("uri:uuid:%s", UUID.randomUUID()))); + } + return vc; + } + + private void verifyProof(LdProof proof) { + switch (proof.getType()) { + // TODO: fix types + case "JWT" -> verifyJWTProof(proof.getJws()); + case "LD_PROOF" -> throw new IllegalArgumentException("LD Proofs on the request are not yet supported."); + } + } + + private void verifyJWTProof(String jwt) { + + var verifier = TokenVerifier.create(jwt, JsonWebToken.class) + .withChecks(jsonWebToken -> jsonWebToken.getType().equals("openid4vci-proof+jwt"), + jsonWebToken -> jsonWebToken.getAudience().length == 1, + jsonWebToken -> jsonWebToken.getAudience()[0].equals( + OIDC4VPAbstractWellKnownProvider.getIssuer(session.getContext())), + jsonWebToken -> jsonWebToken.getOtherClaims().containsKey("nonce")); + + try { + verifier.verify(); + } catch (VerificationException e) { + LOGGER.warnf("Was not able to verify the jwt proof.", e); + throw new BadRequestException(getErrorResponse(ErrorResponse.ErrorEnum.INVALID_OR_MISSING_PROOF)); + } + + } + + @NotNull + private List<String> getClaimsToSet(String credentialType, List<ClientModel> clients) { + String claims = clients.stream() + .map(ClientModel::getAttributes) + .filter(Objects::nonNull) + .map(Map::entrySet) + .flatMap(Set::stream) + // get the claims + .filter(entry -> entry.getKey().equals(String.format("%s_%s", credentialType, "claims"))) + .findFirst() + .map(Map.Entry::getValue) + .orElse(""); + LOGGER.infof("Should set %s for %s.", claims, credentialType); + return Arrays.asList(claims.split(",")); + + } + + @NotNull + private Optional<Map<String, String>> getAdditionalClaims(List<ClientModel> clients) { + Map<String, String> additionalClaims = clients.stream() + .map(ClientModel::getAttributes) + .filter(Objects::nonNull) + .map(Map::entrySet) + .flatMap(Set::stream) + // only include the claims explicitly intended for vc + .filter(entry -> entry.getKey().startsWith(OIDC4VPClientRegistrationProvider.VC_CLAIMS_PREFIX)) + .collect( + Collectors.toMap( + // remove the prefix before sending it + entry -> entry.getKey() + .replaceFirst(OIDC4VPClientRegistrationProvider.VC_CLAIMS_PREFIX, ""), + // value is taken untouched if its unique + Map.Entry::getValue, + // if multiple values for the same key exist, we add them comma separated. + // this needs to be improved, once more requirements are known. + (entry1, entry2) -> { + if (entry1.equals(entry2) || entry1.contains(entry2)) { + return entry1; + } else { + return String.format("%s,%s", entry1, entry2); + } + } + )); + if (additionalClaims.isEmpty()) { + return Optional.empty(); + } else { + return Optional.of(additionalClaims); + } + } + + @Getter + @RequiredArgsConstructor + private static class ClientRoleModel { + private final String clientId; + private final List<RoleModel> roleModels; + } } diff --git a/services/src/main/java/org/keycloak/protocol/oidc4vp/OIDC4VPLoginProtocolFactory.java b/services/src/main/java/org/keycloak/protocol/oidc4vp/OIDC4VPLoginProtocolFactory.java index cac2053cbfa3..74de718782d2 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc4vp/OIDC4VPLoginProtocolFactory.java +++ b/services/src/main/java/org/keycloak/protocol/oidc4vp/OIDC4VPLoginProtocolFactory.java @@ -31,101 +31,111 @@ */ public class OIDC4VPLoginProtocolFactory implements LoginProtocolFactory { - private static final Logger LOGGER = Logger.getLogger(OIDC4VPLoginProtocolFactory.class); - - public static final String PROTOCOL_ID = "oidc4vp"; - - private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - private static final String CLIENT_ROLES_MAPPER = "client-roles"; - private static final String SUBJECT_ID_MAPPER = "subject-id"; - private static final String USERNAME_MAPPER = "username"; - private static final String EMAIL_MAPPER = "email"; - private static final String LAST_NAME_MAPPER = "last-name"; - private static final String FIRST_NAME_MAPPER = "first-name"; - - private final Clock clock = Clock.systemUTC(); - - private Map<String, ProtocolMapperModel> builtins = new HashMap<>(); - - @Override public void init(Config.Scope config) { - LOGGER.info("Initiate the protocol factory"); - builtins.put(CLIENT_ROLES_MAPPER, - OIDC4VPTargetRoleMapper.create("id", "client roles")); - builtins.put(SUBJECT_ID_MAPPER, - OIDC4VPSubjectIdMapper.create("subject id", "id")); - builtins.put(USERNAME_MAPPER, - OIDC4VPUserAttributeMapper.create(USERNAME_MAPPER, "username", "username", false)); - builtins.put(EMAIL_MAPPER, - OIDC4VPUserAttributeMapper.create(EMAIL_MAPPER, "email", "email", false)); - builtins.put(FIRST_NAME_MAPPER, - OIDC4VPUserAttributeMapper.create(FIRST_NAME_MAPPER, "firstName", "firstName", false)); - builtins.put(LAST_NAME_MAPPER, - OIDC4VPUserAttributeMapper.create(LAST_NAME_MAPPER, "lastName", "familyName", false)); - } - - @Override public void postInit(KeycloakSessionFactory factory) { - // no-op - } - - @Override public void close() { - // no-op - } - - @Override - public Map<String, ProtocolMapperModel> getBuiltinMappers() { - return builtins; - } - - @Override - public Object createProtocolEndpoint(KeycloakSession keycloakSession, EventBuilder event) { - - LOGGER.info("Create vc-issuer protocol endpoint"); - - String issuerDid = Optional.ofNullable(keycloakSession.getContext().getRealm().getAttribute("issuerDid")) - .orElseThrow(() -> new VCIssuerException("No issuerDid configured.")); - String keyPath = Optional.ofNullable(keycloakSession.getContext().getRealm().getAttribute("keyPath")) - .orElseThrow(() -> new VCIssuerException("No keyPath configured.")); - return new OIDC4VPIssuerEndpoint( - keycloakSession, - issuerDid, keyPath, - new AppAuthManager.BearerTokenAuthenticator( - keycloakSession), - OBJECT_MAPPER, - clock - ); - } - - @Override public void createDefaultClientScopes(RealmModel newRealm, boolean addScopesToExistingClients) { - LOGGER.debugf("Create default scopes for realm %s", newRealm.getName()); - - ClientScopeModel naturalPersonScope = KeycloakModelUtils.getClientScopeByName(newRealm, "natural_person"); - if (naturalPersonScope == null) { - LOGGER.debug("Add natural person scope"); - naturalPersonScope = newRealm.addClientScope("natural_person"); - naturalPersonScope.setDescription( - "SIOP-2 Scope, that adds all properties required for a natural person."); - naturalPersonScope.setProtocol(OIDC4VPClientRegistrationProviderFactory.PROTOCOL_ID); - naturalPersonScope.addProtocolMapper(builtins.get(SUBJECT_ID_MAPPER)); - naturalPersonScope.addProtocolMapper(builtins.get(CLIENT_ROLES_MAPPER)); - naturalPersonScope.addProtocolMapper(builtins.get(EMAIL_MAPPER)); - naturalPersonScope.addProtocolMapper(builtins.get(FIRST_NAME_MAPPER)); - naturalPersonScope.addProtocolMapper(builtins.get(LAST_NAME_MAPPER)); - newRealm.addDefaultClientScope(naturalPersonScope, true); - } - } - - @Override - public void setupClientDefaults(ClientRepresentation rep, ClientModel newClient) { - // validate before setting the defaults - OIDC4VPClientRegistrationProvider.validate(rep); - } - - @Override public LoginProtocol create(KeycloakSession session) { - return new OIDC4VPLoginProtocol(session); - } - - @Override public String getId() { - return PROTOCOL_ID; - } + private static final Logger LOGGER = Logger.getLogger(OIDC4VPLoginProtocolFactory.class); + + public static final String PROTOCOL_ID = "oidc4vp"; + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final String CLIENT_ROLES_MAPPER = "client-roles"; + private static final String SUBJECT_ID_MAPPER = "subject-id"; + private static final String USERNAME_MAPPER = "username"; + private static final String EMAIL_MAPPER = "email"; + private static final String LAST_NAME_MAPPER = "last-name"; + private static final String FIRST_NAME_MAPPER = "first-name"; + + private final Clock clock = Clock.systemUTC(); + + private Map<String, ProtocolMapperModel> builtins = new HashMap<>(); + + @Override + public void init(Config.Scope config) { + LOGGER.info("Initiate the protocol factory"); + builtins.put(CLIENT_ROLES_MAPPER, + OIDC4VPTargetRoleMapper.create("id", "client roles")); + builtins.put(SUBJECT_ID_MAPPER, + OIDC4VPSubjectIdMapper.create("subject id", "id")); + builtins.put(USERNAME_MAPPER, + OIDC4VPUserAttributeMapper.create(USERNAME_MAPPER, "username", "username", false)); + builtins.put(EMAIL_MAPPER, + OIDC4VPUserAttributeMapper.create(EMAIL_MAPPER, "email", "email", false)); + builtins.put(FIRST_NAME_MAPPER, + OIDC4VPUserAttributeMapper.create(FIRST_NAME_MAPPER, "firstName", "firstName", false)); + builtins.put(LAST_NAME_MAPPER, + OIDC4VPUserAttributeMapper.create(LAST_NAME_MAPPER, "lastName", "familyName", false)); + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + // no-op + } + + @Override + public void close() { + // no-op + } + + @Override + public Map<String, ProtocolMapperModel> getBuiltinMappers() { + return builtins; + } + + @Override + public Object createProtocolEndpoint(KeycloakSession keycloakSession, EventBuilder event) { + + LOGGER.info("Create vc-issuer protocol endpoint"); + + String issuerDid = Optional.ofNullable(keycloakSession.getContext().getRealm().getAttribute("issuerDid")) + .orElseThrow(() -> new VCIssuerException("No issuerDid configured.")); + String keyPath = Optional.ofNullable(keycloakSession.getContext().getRealm().getAttribute("keyPath")) + .orElseThrow(() -> new VCIssuerException("No keyPath configured.")); + Optional<String> lpdType = Optional.ofNullable(keycloakSession.getContext().getRealm().getAttribute("ldpType")); + Optional<String> jwtType = Optional.ofNullable(keycloakSession.getContext().getRealm().getAttribute("jwtType")); + + return new OIDC4VPIssuerEndpoint( + keycloakSession, + issuerDid, keyPath, + jwtType, lpdType, + new AppAuthManager.BearerTokenAuthenticator( + keycloakSession), + OBJECT_MAPPER, + clock + ); + } + + @Override + public void createDefaultClientScopes(RealmModel newRealm, boolean addScopesToExistingClients) { + LOGGER.debugf("Create default scopes for realm %s", newRealm.getName()); + + ClientScopeModel naturalPersonScope = KeycloakModelUtils.getClientScopeByName(newRealm, "natural_person"); + if (naturalPersonScope == null) { + LOGGER.debug("Add natural person scope"); + naturalPersonScope = newRealm.addClientScope("natural_person"); + naturalPersonScope.setDescription( + "OIDC$VP Scope, that adds all properties required for a natural person."); + naturalPersonScope.setProtocol(PROTOCOL_ID); + naturalPersonScope.addProtocolMapper(builtins.get(SUBJECT_ID_MAPPER)); + naturalPersonScope.addProtocolMapper(builtins.get(CLIENT_ROLES_MAPPER)); + naturalPersonScope.addProtocolMapper(builtins.get(EMAIL_MAPPER)); + naturalPersonScope.addProtocolMapper(builtins.get(FIRST_NAME_MAPPER)); + naturalPersonScope.addProtocolMapper(builtins.get(LAST_NAME_MAPPER)); + newRealm.addDefaultClientScope(naturalPersonScope, true); + } + } + + @Override + public void setupClientDefaults(ClientRepresentation rep, ClientModel newClient) { + // validate before setting the defaults + OIDC4VPClientRegistrationProvider.validate(rep); + } + + @Override + public LoginProtocol create(KeycloakSession session) { + return new OIDC4VPLoginProtocol(session); + } + + @Override + public String getId() { + return PROTOCOL_ID; + } } diff --git a/services/src/main/java/org/keycloak/protocol/oidc4vp/mappers/OIDC4VPMapper.java b/services/src/main/java/org/keycloak/protocol/oidc4vp/mappers/OIDC4VPMapper.java index 11513919cdf6..f183170fba17 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc4vp/mappers/OIDC4VPMapper.java +++ b/services/src/main/java/org/keycloak/protocol/oidc4vp/mappers/OIDC4VPMapper.java @@ -1,6 +1,5 @@ package org.keycloak.protocol.oidc4vp.mappers; -import com.danubetech.verifiablecredentials.VerifiableCredential; import org.keycloak.Config; import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSessionFactory; @@ -8,13 +7,10 @@ import org.keycloak.models.UserSessionModel; import org.keycloak.protocol.ProtocolMapper; import org.keycloak.protocol.oidc4vp.OIDC4VPClientRegistrationProviderFactory; +import org.keycloak.protocol.oidc4vp.model.VerifiableCredential; import org.keycloak.provider.ProviderConfigProperty; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import java.util.Map; -import java.util.Optional; +import java.util.*; import java.util.stream.Stream; public abstract class OIDC4VPMapper implements ProtocolMapper { @@ -87,8 +83,8 @@ public boolean isTypeSupported(String credentialType) { /** * Set the claims to credential, like f.e. the context */ - public abstract void setClaimsForCredential(VerifiableCredential.Builder credentialBuilder, - UserSessionModel userSessionModel); + public abstract void setClaimsForCredential(VerifiableCredential verifiableCredential, + UserSessionModel userSessionModel); /** * Set the claims to the credential subject. diff --git a/services/src/main/java/org/keycloak/protocol/oidc4vp/mappers/OIDC4VPMapperFactory.java b/services/src/main/java/org/keycloak/protocol/oidc4vp/mappers/OIDC4VPMapperFactory.java index 43be5088027a..19e9f6fcc8e3 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc4vp/mappers/OIDC4VPMapperFactory.java +++ b/services/src/main/java/org/keycloak/protocol/oidc4vp/mappers/OIDC4VPMapperFactory.java @@ -14,6 +14,7 @@ public static OIDC4VPMapper createOIDC4VPMapper(ProtocolMapperModel mapperModel) case OIDC4VPSubjectIdMapper.MAPPER_ID -> new OIDC4VPSubjectIdMapper().setMapperModel(mapperModel); case OIDC4VPUserAttributeMapper.MAPPER_ID -> new OIDC4VPUserAttributeMapper().setMapperModel(mapperModel); case OIDC4VPStaticClaimMapper.MAPPER_ID -> new OIDC4VPStaticClaimMapper().setMapperModel(mapperModel); + case OIDC4VPTypeMapper.MAPPER_ID -> new OIDC4VPTypeMapper().setMapperModel(mapperModel); default -> throw new OIDC4VPMapperException( String.format("No mapper with id %s exists.", mapperModel.getProtocolMapper())); }; diff --git a/services/src/main/java/org/keycloak/protocol/oidc4vp/mappers/OIDC4VPStaticClaimMapper.java b/services/src/main/java/org/keycloak/protocol/oidc4vp/mappers/OIDC4VPStaticClaimMapper.java index f4e1eed41520..543f1c542789 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc4vp/mappers/OIDC4VPStaticClaimMapper.java +++ b/services/src/main/java/org/keycloak/protocol/oidc4vp/mappers/OIDC4VPStaticClaimMapper.java @@ -1,9 +1,9 @@ package org.keycloak.protocol.oidc4vp.mappers; -import com.danubetech.verifiablecredentials.VerifiableCredential; import org.keycloak.models.ProtocolMapperModel; import org.keycloak.models.UserSessionModel; import org.keycloak.protocol.oidc4vp.OIDC4VPClientRegistrationProviderFactory; +import org.keycloak.protocol.oidc4vp.model.VerifiableCredential; import org.keycloak.provider.ProviderConfigProperty; import java.util.ArrayList; @@ -54,8 +54,8 @@ public static ProtocolMapperModel create(String mapperName, String propertyName, return CONFIG_PROPERTIES; } - @Override public void setClaimsForCredential(VerifiableCredential.Builder credentialBuilder, - UserSessionModel userSessionModel) { + public void setClaimsForCredential(VerifiableCredential verifiableCredential, + UserSessionModel userSessionModel) { // nothing to do for the mapper. } diff --git a/services/src/main/java/org/keycloak/protocol/oidc4vp/mappers/OIDC4VPSubjectIdMapper.java b/services/src/main/java/org/keycloak/protocol/oidc4vp/mappers/OIDC4VPSubjectIdMapper.java index 416033d2c853..2771e5c0189a 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc4vp/mappers/OIDC4VPSubjectIdMapper.java +++ b/services/src/main/java/org/keycloak/protocol/oidc4vp/mappers/OIDC4VPSubjectIdMapper.java @@ -1,9 +1,9 @@ package org.keycloak.protocol.oidc4vp.mappers; -import com.danubetech.verifiablecredentials.VerifiableCredential; import org.keycloak.models.ProtocolMapperModel; import org.keycloak.models.UserSessionModel; import org.keycloak.protocol.oidc4vp.OIDC4VPClientRegistrationProviderFactory; +import org.keycloak.protocol.oidc4vp.model.VerifiableCredential; import org.keycloak.provider.ProviderConfigProperty; import java.util.ArrayList; @@ -47,8 +47,8 @@ public static ProtocolMapperModel create(String name, String subjectId) { return mapperModel; } - @Override public void setClaimsForCredential(VerifiableCredential.Builder credentialBuilder, - UserSessionModel userSessionModel) { + public void setClaimsForCredential(VerifiableCredential verifiableCredential, + UserSessionModel userSessionModel) { // nothing to do for the mapper. } diff --git a/services/src/main/java/org/keycloak/protocol/oidc4vp/mappers/OIDC4VPTargetRoleMapper.java b/services/src/main/java/org/keycloak/protocol/oidc4vp/mappers/OIDC4VPTargetRoleMapper.java index 76902c573960..ea6895f623f0 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc4vp/mappers/OIDC4VPTargetRoleMapper.java +++ b/services/src/main/java/org/keycloak/protocol/oidc4vp/mappers/OIDC4VPTargetRoleMapper.java @@ -1,6 +1,5 @@ package org.keycloak.protocol.oidc4vp.mappers; -import com.danubetech.verifiablecredentials.VerifiableCredential; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.validation.constraints.NotNull; import lombok.Getter; @@ -12,14 +11,10 @@ import org.keycloak.models.UserSessionModel; import org.keycloak.protocol.oidc4vp.OIDC4VPClientRegistrationProviderFactory; import org.keycloak.protocol.oidc4vp.model.Role; +import org.keycloak.protocol.oidc4vp.model.VerifiableCredential; import org.keycloak.provider.ProviderConfigProperty; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; +import java.util.*; import java.util.stream.Collectors; public class OIDC4VPTargetRoleMapper extends OIDC4VPMapper { @@ -72,8 +67,8 @@ public static ProtocolMapperModel create(String clientId, String name) { } @Override - public void setClaimsForCredential(VerifiableCredential.Builder credentialBuilder, - UserSessionModel userSessionModel) { + public void setClaimsForCredential(VerifiableCredential verifiableCredential, + UserSessionModel userSessionModel) { // nothing to do for the mapper. } diff --git a/services/src/main/java/org/keycloak/protocol/oidc4vp/mappers/OIDC4VPTypeMapper.java b/services/src/main/java/org/keycloak/protocol/oidc4vp/mappers/OIDC4VPTypeMapper.java new file mode 100644 index 000000000000..037b9a4eee30 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc4vp/mappers/OIDC4VPTypeMapper.java @@ -0,0 +1,75 @@ +package org.keycloak.protocol.oidc4vp.mappers; + +import org.keycloak.models.ProtocolMapperModel; +import org.keycloak.models.UserSessionModel; +import org.keycloak.protocol.oidc4vp.OIDC4VPClientRegistrationProviderFactory; +import org.keycloak.protocol.oidc4vp.model.VerifiableCredential; +import org.keycloak.provider.ProviderConfigProperty; + +import java.util.*; + +public class OIDC4VPTypeMapper extends OIDC4VPMapper { + + public static final String MAPPER_ID = "oidc4vp-vc-type-mapper"; + public static final String TYPE_KEY = "vcTypeProperty"; + + private static final List<ProviderConfigProperty> CONFIG_PROPERTIES = new ArrayList<>(); + + public OIDC4VPTypeMapper() { + super(); + ProviderConfigProperty vcTypePropertyNameConfig = new ProviderConfigProperty(); + vcTypePropertyNameConfig.setName(TYPE_KEY); + vcTypePropertyNameConfig.setLabel("Verifiable Credential Type"); + vcTypePropertyNameConfig.setHelpText("Type of the credential."); + vcTypePropertyNameConfig.setType(ProviderConfigProperty.STRING_TYPE); + CONFIG_PROPERTIES.add(vcTypePropertyNameConfig); + + } + + @Override + protected List<ProviderConfigProperty> getIndividualConfigProperties() { + return CONFIG_PROPERTIES; + } + + public static ProtocolMapperModel create(String name, String subjectId) { + var mapperModel = new ProtocolMapperModel(); + mapperModel.setName(name); + Map<String, String> configMap = new HashMap<>(); + configMap.put(SUPPORTED_CREDENTIALS_KEY, "VerifiableCredential"); + mapperModel.setConfig(configMap); + mapperModel.setProtocol(OIDC4VPClientRegistrationProviderFactory.PROTOCOL_ID); + mapperModel.setProtocolMapper(MAPPER_ID); + return mapperModel; + } + + public void setClaimsForCredential(VerifiableCredential verifiableCredential, + UserSessionModel userSessionModel) { + // remove duplicates + Set<String> types = new HashSet<>(); + if (verifiableCredential.getType() != null) { + types = new HashSet<>(verifiableCredential.getType()); + } + types.add(mapperModel.getConfig().get(TYPE_KEY)); + verifiableCredential.setType(new ArrayList<>(types)); + } + + @Override + public void setClaimsForSubject(Map<String, Object> claims, UserSessionModel userSessionModel) { + // nothing to do for the mapper. + } + + @Override + public String getDisplayType() { + return "CredentialSubject ID Mapper"; + } + + @Override + public String getHelpText() { + return "Assigns a subject ID to the credentials subject. If no specific id is configured, a randomly generated one is used."; + } + + @Override + public String getId() { + return MAPPER_ID; + } +} diff --git a/services/src/main/java/org/keycloak/protocol/oidc4vp/mappers/OIDC4VPUserAttributeMapper.java b/services/src/main/java/org/keycloak/protocol/oidc4vp/mappers/OIDC4VPUserAttributeMapper.java index 78cce6fc6724..77a205cb8ac7 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc4vp/mappers/OIDC4VPUserAttributeMapper.java +++ b/services/src/main/java/org/keycloak/protocol/oidc4vp/mappers/OIDC4VPUserAttributeMapper.java @@ -1,11 +1,11 @@ package org.keycloak.protocol.oidc4vp.mappers; -import com.danubetech.verifiablecredentials.VerifiableCredential; import org.keycloak.models.ProtocolMapperModel; import org.keycloak.models.UserModel; import org.keycloak.models.UserSessionModel; import org.keycloak.models.utils.KeycloakModelUtils; import org.keycloak.protocol.oidc4vp.OIDC4VPClientRegistrationProviderFactory; +import org.keycloak.protocol.oidc4vp.model.VerifiableCredential; import org.keycloak.provider.ProviderConfigProperty; import java.util.ArrayList; @@ -59,8 +59,8 @@ public OIDC4VPUserAttributeMapper() { return CONFIG_PROPERTIES; } - @Override public void setClaimsForCredential(VerifiableCredential.Builder credentialBuilder, - UserSessionModel userSessionModel) { + public void setClaimsForCredential(VerifiableCredential verifiableCredential, + UserSessionModel userSessionModel) { // nothing to do for the mapper. } diff --git a/services/src/main/java/org/keycloak/protocol/oidc4vp/model/CredentialSubject.java b/services/src/main/java/org/keycloak/protocol/oidc4vp/model/CredentialSubject.java new file mode 100644 index 000000000000..4d7eff4e2abd --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc4vp/model/CredentialSubject.java @@ -0,0 +1,38 @@ +package org.keycloak.protocol.oidc4vp.model; + +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; + +import java.util.HashMap; +import java.util.Map; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public class CredentialSubject { + + private String id; + + @JsonIgnore + private Map<String, Object> claims = new HashMap<>(); + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + @JsonAnyGetter + public Map<String, Object> getClaims() { + return claims; + } + + @JsonAnySetter + public void setClaims(String name, Object claim) { + claims.put(name, claim); + } + + +} diff --git a/services/src/main/java/org/keycloak/protocol/oidc4vp/model/LdProof.java b/services/src/main/java/org/keycloak/protocol/oidc4vp/model/LdProof.java new file mode 100644 index 000000000000..e3b1201c2f33 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc4vp/model/LdProof.java @@ -0,0 +1,82 @@ +package org.keycloak.protocol.oidc4vp.model; + +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; + +import java.util.Date; +import java.util.HashMap; +import java.util.Map; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public class LdProof { + + private String type; + private Date created; + private String proofPurpose; + private String verificationMethod; + private String proofValue; + private String jws; + + @JsonIgnore + private Map<String, Object> additionalProperties = new HashMap<>(); + + @JsonAnyGetter + public Map<String, Object> getAdditionalProperties() { + return additionalProperties; + } + + @JsonAnySetter + public void setAdditionalProperties(String name, Object property) { + additionalProperties.put(name, property); + } + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public Date getCreated() { + return created; + } + + public void setCreated(Date created) { + this.created = created; + } + + public String getProofPurpose() { + return proofPurpose; + } + + public void setProofPurpose(String proofPurpose) { + this.proofPurpose = proofPurpose; + } + + public String getVerificationMethod() { + return verificationMethod; + } + + public void setVerificationMethod(String verificationMethod) { + this.verificationMethod = verificationMethod; + } + + public String getProofValue() { + return proofValue; + } + + public void setProofValue(String proofValue) { + this.proofValue = proofValue; + } + + public String getJws() { + return jws; + } + + public void setJws(String jws) { + this.jws = jws; + } +} diff --git a/services/src/main/java/org/keycloak/protocol/oidc4vp/model/VerifiableCredential.java b/services/src/main/java/org/keycloak/protocol/oidc4vp/model/VerifiableCredential.java new file mode 100644 index 000000000000..f66492612ca7 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc4vp/model/VerifiableCredential.java @@ -0,0 +1,100 @@ +package org.keycloak.protocol.oidc4vp.model; + +import com.fasterxml.jackson.annotation.*; +import com.fasterxml.jackson.databind.DatabindException; + +import java.net.URI; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public class VerifiableCredential { + + @JsonProperty("@context") + private List<String> context; + private List<String> type; + private URI issuer; + private Date issuanceDate; + private URI id; + private Date expirationDate; + private CredentialSubject credentialSubject = new CredentialSubject(); + private LdProof proof; + @JsonIgnore + private Map<String, Object> additionalProperties = new HashMap<>(); + + @JsonAnyGetter + public Map<String, Object> getAdditionalProperties() { + return additionalProperties; + } + + @JsonAnySetter + public void setAdditionalProperties(String name, Object property) { + additionalProperties.put(name, property); + } + + public List<String> getContext() { + return context; + } + + public void setContext(List<String> context) { + this.context = context; + } + + public List<String> getType() { + return type; + } + + public void setType(List<String> type) { + this.type = type; + } + + public URI getIssuer() { + return issuer; + } + + public void setIssuer(URI issuer) { + this.issuer = issuer; + } + + public Date getIssuanceDate() { + return issuanceDate; + } + + public void setIssuanceDate(Date issuanceDate) { + this.issuanceDate = issuanceDate; + } + + public Date getExpirationDate() { + return expirationDate; + } + + public void setExpirationDate(Date expirationDate) { + this.expirationDate = expirationDate; + } + + public CredentialSubject getCredentialSubject() { + return credentialSubject; + } + + public void setCredentialSubject(CredentialSubject credentialSubject) { + this.credentialSubject = credentialSubject; + } + + public LdProof getProof() { + return proof; + } + + public void setProof(LdProof proof) { + this.proof = proof; + } + + public URI getId() { + return id; + } + + public void setId(URI id) { + this.id = id; + } +} diff --git a/services/src/main/java/org/keycloak/protocol/oidc4vp/signing/AlgorithmType.java b/services/src/main/java/org/keycloak/protocol/oidc4vp/signing/AlgorithmType.java deleted file mode 100644 index 13db35d32b17..000000000000 --- a/services/src/main/java/org/keycloak/protocol/oidc4vp/signing/AlgorithmType.java +++ /dev/null @@ -1,28 +0,0 @@ -package org.keycloak.protocol.oidc4vp.signing; - -import java.util.List; - -public enum AlgorithmType { - - ED_DSA_ED25519(List.of("eddsa", "ed25519", "eddsa_ed25519")), - ECDSA_SECP256K1(List.of("ecdsa", "secp256k1", "ecdsa_secp256k1")), - RSA(List.of("rsa", "ps256", "rs256")); - - private final List<String> values; - - AlgorithmType(List<String> values) { - this.values = values; - } - - public List<String> getValues() { - return values; - } - - public static AlgorithmType getByValue(String value) { - for (AlgorithmType algorithmType : values()) - if (algorithmType.values.stream().anyMatch(value::equalsIgnoreCase)) { - return algorithmType; - } - throw new IllegalArgumentException(String.format("No algorithm of type %s exists.", value)); - } -} diff --git a/services/src/main/java/org/keycloak/protocol/oidc4vp/signing/FileBasedKeyLoader.java b/services/src/main/java/org/keycloak/protocol/oidc4vp/signing/FileBasedKeyLoader.java new file mode 100644 index 000000000000..2954b6a52faa --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc4vp/signing/FileBasedKeyLoader.java @@ -0,0 +1,30 @@ +package org.keycloak.protocol.oidc4vp.signing; + +import org.jboss.logging.Logger; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +public class FileBasedKeyLoader implements KeyLoader { + private static final Logger LOGGER = Logger.getLogger(FileBasedKeyLoader.class); + private final String keyPath; + + public FileBasedKeyLoader(String keyPath) { + this.keyPath = keyPath; + } + + @Override + public String loadKey() { + Path keyFilePath = Paths.get(keyPath); + try { + return Files.readString(keyFilePath); + } catch (IOException e) { + LOGGER.errorf("Was not able to read the private key from %s", keyPath); + throw new SigningServiceException("Was not able to read private key. Cannot initiate the SigningService.", + e); + } + } + +} diff --git a/services/src/main/java/org/keycloak/protocol/oidc4vp/signing/JWTSigningService.java b/services/src/main/java/org/keycloak/protocol/oidc4vp/signing/JWTSigningService.java index 5c8f566debec..18d8dc05f47d 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc4vp/signing/JWTSigningService.java +++ b/services/src/main/java/org/keycloak/protocol/oidc4vp/signing/JWTSigningService.java @@ -1,48 +1,141 @@ package org.keycloak.protocol.oidc4vp.signing; -import com.danubetech.verifiablecredentials.VerifiableCredential; -import com.danubetech.verifiablecredentials.jwt.JwtVerifiableCredential; -import com.danubetech.verifiablecredentials.jwt.ToJwtConverter; -import com.nimbusds.jose.JOSEException; -import org.bitcoinj.core.ECKey; +import org.bouncycastle.asn1.pkcs.PrivateKeyInfo; +import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.openssl.PEMKeyPair; +import org.bouncycastle.openssl.PEMParser; +import org.keycloak.common.util.KeyUtils; +import org.keycloak.crypto.*; +import org.keycloak.jose.jws.JWSBuilder; +import org.keycloak.protocol.oidc4vp.model.VerifiableCredential; +import org.keycloak.protocol.oidc4vp.signing.signatures.EdDSASignatureSignerContext; +import org.keycloak.representations.JsonWebToken; + +import java.io.IOException; +import java.io.StringReader; +import java.net.URI; +import java.security.*; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.PKCS8EncodedKeySpec; +import java.security.spec.X509EncodedKeySpec; +import java.time.Clock; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.List; import java.util.Optional; +import java.util.UUID; + +import static org.keycloak.protocol.oidc4vp.signing.signatures.EdDSASignatureSignerContext.ED_25519; public class JWTSigningService extends SigningService<String> { - private final AlgorithmType algorithmType; - - public JWTSigningService(String keyPath, Optional<String> optionalKeyId){ - super(keyPath, optionalKeyId); - algorithmType = getAlgorithmType(); - } - - private AlgorithmType getAlgorithmType() { - return AlgorithmType.getByValue(signingKey.getPrivate().getAlgorithm()); - } - - @Override - public String signCredential(VerifiableCredential verifiableCredential) { - JwtVerifiableCredential jwtVerifiableCredential = ToJwtConverter.toJwtVerifiableCredential( - verifiableCredential); - try { - - return switch (algorithmType) { - case RSA -> { - String concreteAlgorithm = signingKey.getPrivate().getAlgorithm(); - if (concreteAlgorithm.equalsIgnoreCase("ps256")) { - yield jwtVerifiableCredential.sign_RSA_PS256(signingKey); - } else { - yield jwtVerifiableCredential.sign_RSA_RS256(signingKey); - } - } - case ECDSA_SECP256K1 -> jwtVerifiableCredential.sign_secp256k1_ES256K( - ECKey.fromPrivate(signingKey.getPrivate().getEncoded())); - case ED_DSA_ED25519 -> jwtVerifiableCredential.sign_Ed25519_EdDSA(signingKey.getPrivate().getEncoded()); - - }; - } catch (JOSEException e) { - throw new SigningServiceException("Was not able to sign the credential.", e); - } - } + private static final String ID_TEMPLATE = "urn:uuid:%s"; + + private SignatureSignerContext signatureSignerContext; + + public JWTSigningService(KeyLoader keyLoader, Optional<String> optionalKeyId, Clock clock, String algorithmType) { + super(keyLoader, optionalKeyId, clock, algorithmType); + + var signingKey = getKeyWrapper(algorithmType); + signatureSignerContext = switch (algorithmType) { + case ED_25519 -> new EdDSASignatureSignerContext(signingKey); + case Algorithm.RS256, Algorithm.RS384, Algorithm.RS512, Algorithm.PS256, Algorithm.PS384, Algorithm.PS512 -> + new AsymmetricSignatureSignerContext(signingKey); + case Algorithm.ES256, Algorithm.ES384, Algorithm.ES512 -> new ECDSASignatureSignerContext(signingKey); + default -> + throw new SigningServiceException(String.format("Algorithm %s is not supported by the JWTSigningService.", algorithmType)); + }; + } + + @Override + public String signCredential(VerifiableCredential verifiableCredential) { + + JsonWebToken jsonWebToken = new JsonWebToken(); + jsonWebToken.exp(clock.instant().plus(1, ChronoUnit.DAYS).getEpochSecond()); + jsonWebToken.issuer(verifiableCredential.getIssuer().toString()); + jsonWebToken.nbf(clock.instant().getEpochSecond()); + jsonWebToken.iat(clock.instant().getEpochSecond()); + var credentialId = Optional.ofNullable(verifiableCredential.getAdditionalProperties().get("id")).orElse(String.format(ID_TEMPLATE, UUID.randomUUID())); + if (credentialId instanceof String idString) { + jsonWebToken.id(idString); + } else if (credentialId instanceof URI idUri) { + jsonWebToken.id(idUri.toString()); + } else { + throw new SigningServiceException("The id needs to be a URI or a string."); + } + jsonWebToken.subject(verifiableCredential.getCredentialSubject().getId()); + jsonWebToken.setOtherClaims("vc", verifiableCredential); + + JWSBuilder jwsBuilder = new JWSBuilder(); + optionalKeyId.ifPresent(jwsBuilder::kid); + jwsBuilder.type("JWT"); + return jwsBuilder.jsonContent(jsonWebToken).sign(signatureSignerContext); + } + + private KeyWrapper getKeyWrapper(String algorithm) { + KeyPair keyPair = parsePem(keyLoader.loadKey()); + + KeyWrapper keyWrapper = new KeyWrapper(); + optionalKeyId.ifPresent(keyWrapper::setKid); + + keyWrapper.setAlgorithm(algorithm); + keyWrapper.setPrivateKey(keyPair.getPrivate()); + + if (keyPair.getPublic() != null) { + keyWrapper.setPublicKey(keyPair.getPublic()); + keyWrapper.setKid(KeyUtils.createKeyId(keyPair.getPublic())); + keyWrapper.setType(keyPair.getPublic().getAlgorithm()); + } + keyWrapper.setUse(KeyUse.SIG); + return keyWrapper; + } + + protected KeyPair parsePem(String keyString) { + PEMParser pemParser = new PEMParser(new StringReader(keyString)); + List<Object> parsedObjects = new ArrayList<>(); + try { + var currentObject = pemParser.readObject(); + while (currentObject != null) { + parsedObjects.add(currentObject); + currentObject = pemParser.readObject(); + } + } catch (IOException e) { + throw new SigningServiceException("Was not able to parse the key-pem", e); + } + SubjectPublicKeyInfo publicKeyInfo = null; + PrivateKeyInfo privateKeyInfo = null; + for (Object parsedObject : parsedObjects) { + if (parsedObject instanceof SubjectPublicKeyInfo spki) { + publicKeyInfo = spki; + } else if (parsedObject instanceof PrivateKeyInfo pki) { + privateKeyInfo = pki; + } else if (parsedObject instanceof PEMKeyPair pkp) { + publicKeyInfo = pkp.getPublicKeyInfo(); + privateKeyInfo = pkp.getPrivateKeyInfo(); + } + } + if (privateKeyInfo == null) { + throw new SigningServiceException("Was not able to read a private key."); + } + PublicKey publicKey = null; + if (publicKeyInfo != null) { + try { + KeyFactory keyFactory = KeyFactory.getInstance(publicKeyInfo.getAlgorithm().getAlgorithm().getId()); + publicKey = keyFactory.generatePublic(new X509EncodedKeySpec(publicKeyInfo.getEncoded())); + } catch (NoSuchAlgorithmException | InvalidKeySpecException | IOException e) { + throw new SigningServiceException("Was not able to get the public key.", e); + } + } + try { + KeyFactory privateKeyFactory = KeyFactory.getInstance( + privateKeyInfo.getPrivateKeyAlgorithm().getAlgorithm().getId()); + PrivateKey privateKey = privateKeyFactory.generatePrivate( + new PKCS8EncodedKeySpec(privateKeyInfo.getEncoded())); + return new KeyPair(publicKey, privateKey); + } catch (NoSuchAlgorithmException | InvalidKeySpecException | IOException e) { + throw new SigningServiceException("Was not able to get the public key.", e); + } + } } \ No newline at end of file diff --git a/services/src/main/java/org/keycloak/protocol/oidc4vp/signing/KeyLoader.java b/services/src/main/java/org/keycloak/protocol/oidc4vp/signing/KeyLoader.java new file mode 100644 index 000000000000..830c7e0f54f6 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc4vp/signing/KeyLoader.java @@ -0,0 +1,7 @@ +package org.keycloak.protocol.oidc4vp.signing; + +public interface KeyLoader { + + String loadKey(); + +} diff --git a/services/src/main/java/org/keycloak/protocol/oidc4vp/signing/LDSigningService.java b/services/src/main/java/org/keycloak/protocol/oidc4vp/signing/LDSigningService.java index 43ad89a4072b..c85a46b140a7 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc4vp/signing/LDSigningService.java +++ b/services/src/main/java/org/keycloak/protocol/oidc4vp/signing/LDSigningService.java @@ -1,123 +1,64 @@ package org.keycloak.protocol.oidc4vp.signing; -import com.danubetech.keyformats.crypto.ByteSigner; -import com.danubetech.keyformats.crypto.impl.Ed25519_EdDSA_PrivateKeySigner; -import com.danubetech.keyformats.crypto.impl.RSA_PS256_PrivateKeySigner; -import com.danubetech.keyformats.crypto.impl.RSA_RS256_PrivateKeySigner; -import com.danubetech.verifiablecredentials.VerifiableCredential; -import foundation.identity.jsonld.JsonLDException; -import info.weboftrust.ldsignatures.LdProof; -import info.weboftrust.ldsignatures.jsonld.LDSecurityKeywords; -import info.weboftrust.ldsignatures.signer.EcdsaSecp256k1Signature2019LdSigner; -import info.weboftrust.ldsignatures.signer.Ed25519Signature2018LdSigner; -import info.weboftrust.ldsignatures.signer.Ed25519Signature2020LdSigner; -import info.weboftrust.ldsignatures.signer.JcsEd25519Signature2020LdSigner; -import info.weboftrust.ldsignatures.signer.JsonWebSignature2020LdSigner; -import info.weboftrust.ldsignatures.signer.LdSigner; -import info.weboftrust.ldsignatures.signer.RsaSignature2018LdSigner; -import info.weboftrust.ldsignatures.suites.EcdsaSecp256k1Signature2019SignatureSuite; -import info.weboftrust.ldsignatures.suites.Ed25519Signature2018SignatureSuite; -import info.weboftrust.ldsignatures.suites.Ed25519Signature2020SignatureSuite; -import info.weboftrust.ldsignatures.suites.JcsEd25519Signature2020SignatureSuite; -import info.weboftrust.ldsignatures.suites.JsonWebSignature2020SignatureSuite; -import info.weboftrust.ldsignatures.suites.RsaSignature2018SignatureSuite; -import org.bitcoinj.core.ECKey; + +import com.fasterxml.jackson.databind.ObjectMapper; import org.jboss.logging.Logger; +import org.keycloak.common.util.Base64; +import org.keycloak.protocol.oidc4vp.model.LdProof; +import org.keycloak.protocol.oidc4vp.model.VerifiableCredential; +import org.keycloak.protocol.oidc4vp.signing.signatures.Ed255192018Suite; +import org.keycloak.protocol.oidc4vp.signing.signatures.RsaSignature2018Suite; +import org.keycloak.protocol.oidc4vp.signing.signatures.SecuritySuite; import java.io.IOException; -import java.security.GeneralSecurityException; import java.time.Clock; import java.util.Date; import java.util.Optional; public class LDSigningService extends SigningService<VerifiableCredential> { - private static final Logger LOGGER = Logger.getLogger(LDSigningService.class); - - private final Clock clock; - - public LDSigningService(String keyPath, Optional<String> keyId, - Clock clock) { - super(keyPath, keyId); - this.clock = clock; - } + private static final Logger LOGGER = Logger.getLogger(LDSigningService.class); - @Override - public VerifiableCredential signCredential(VerifiableCredential verifiableCredential) { - LOGGER.debug("Sign credential with an ld-proof."); - String proofType = Optional.ofNullable(verifiableCredential.getLdProof()).map(LdProof::getType) - // use a default - .orElse(LDSignatureType.RSA_SIGNATURE_2018.getValue()); - LDSignatureType signatureType = LDSignatureType.getByValue(proofType); - AlgorithmType algorithmType = AlgorithmType.getByValue(signingKey.getPrivate().getAlgorithm()); - var ldSigner = switch (signatureType) { - case RSA_SIGNATURE_2018 -> getRsaSigner(algorithmType); - case ED_25519_SIGNATURE_2018 -> getEd25519Signature2018Signer(algorithmType); - case ED_25519_SIGNATURE_2020 -> getEd25519Signature2020Signer(algorithmType); - case ECDSA_SECP_256K1_SIGNATURE_2019 -> getEcdsaSecp256k1Signature2019Signer(algorithmType); - case JSON_WEB_SIGNATURE_2020 -> getJsonWebSignature2020Signer(algorithmType); - case JCS_ED_25519_SIGNATURE_2020 -> getJcsEd25519Signature2020Signer(algorithmType); - }; - ldSigner.setProofPurpose(LDSecurityKeywords.JSONLD_TERM_ASSERTIONMETHOD); - ldSigner.setCreated(Date.from(clock.instant())); - try { - ldSigner.sign(verifiableCredential); - } catch (IOException | GeneralSecurityException | JsonLDException e) { - throw new SigningServiceException("Was not able to sign the credential.", e); - } - return verifiableCredential; - } + private SecuritySuite securitySuite; + private ObjectMapper objectMapper; - private LdSigner<JcsEd25519Signature2020SignatureSuite> getJcsEd25519Signature2020Signer(AlgorithmType algorithmType) { - if (algorithmType != AlgorithmType.ED_DSA_ED25519) { - throw new IllegalArgumentException("Signing key does not support JCS_ED_25519_SIGNATURE_2020."); - } - return new JcsEd25519Signature2020LdSigner(signingKey.getPrivate().getEncoded()); - } + public LDSigningService(KeyLoader keyLoader, Optional<String> keyId, + Clock clock, String ldpType, ObjectMapper objectMapper) { + super(keyLoader, keyId, clock, ldpType); + this.objectMapper = objectMapper; - private LdSigner<JsonWebSignature2020SignatureSuite> getJsonWebSignature2020Signer(AlgorithmType algorithmType) { + securitySuite = switch (ldpType) { + case Ed255192018Suite.PROOF_TYPE -> new Ed255192018Suite(objectMapper); + case RsaSignature2018Suite.PROOF_TYPE -> new RsaSignature2018Suite(); + default -> throw new SigningServiceException(String.format("Proof Type %s is not supported.", ldpType)); + }; - String concreteAlgorithm = signingKey.getPrivate().getAlgorithm(); + } - ByteSigner byteSigner = switch (algorithmType) { - case RSA -> { - if (concreteAlgorithm.equalsIgnoreCase("rs256")) { - yield new RSA_RS256_PrivateKeySigner(signingKey); - } else { - yield new RSA_PS256_PrivateKeySigner(signingKey); - } - } - case ECDSA_SECP256K1 -> getEcdsaSecp256k1Signature2019Signer(algorithmType).getSigner(); - case ED_DSA_ED25519 -> new Ed25519_EdDSA_PrivateKeySigner(signingKey.getPrivate().getEncoded()); - }; - return new JsonWebSignature2020LdSigner(byteSigner); - } + @Override + public VerifiableCredential signCredential(VerifiableCredential verifiableCredential) { + return addProof(verifiableCredential); + } - private LdSigner<EcdsaSecp256k1Signature2019SignatureSuite> getEcdsaSecp256k1Signature2019Signer(AlgorithmType algorithmType) { - if (algorithmType != AlgorithmType.ECDSA_SECP256K1) { - throw new IllegalArgumentException("Signing key does not support ECDSA_SECP_256K1_SIGNATURE_2019."); - } - return new EcdsaSecp256k1Signature2019LdSigner(ECKey.fromPrivate(signingKey.getPrivate().getEncoded())); - } - private LdSigner<Ed25519Signature2018SignatureSuite> getEd25519Signature2018Signer(AlgorithmType algorithmType) { - if (algorithmType != AlgorithmType.ED_DSA_ED25519) { - throw new IllegalArgumentException("Signing key does not support ED_25519_SIGNATURE_2018."); - } - return new Ed25519Signature2018LdSigner(signingKey.getPrivate().getEncoded()); - } + private VerifiableCredential addProof(VerifiableCredential verifiableCredential) { - private LdSigner<Ed25519Signature2020SignatureSuite> getEd25519Signature2020Signer(AlgorithmType algorithmType) { - if (algorithmType != AlgorithmType.ED_DSA_ED25519) { - throw new IllegalArgumentException("Signing key does not support ED_25519_SIGNATURE_2020."); - } - return new Ed25519Signature2020LdSigner(signingKey.getPrivate().getEncoded()); - } + byte[] transformedData = securitySuite.transform(verifiableCredential); + byte[] hashedData = securitySuite.digest(transformedData); + byte[] signature = securitySuite.sign(hashedData, keyLoader.loadKey()); + LdProof ldProof = new LdProof(); + ldProof.setProofPurpose("assertionMethod"); + ldProof.setType(securitySuite.getProofType()); + ldProof.setCreated(Date.from(clock.instant())); + optionalKeyId.ifPresent(ldProof::setVerificationMethod); - private LdSigner<RsaSignature2018SignatureSuite> getRsaSigner(AlgorithmType algorithmType) { - if (algorithmType != AlgorithmType.RSA) { - throw new IllegalArgumentException("Signing key does not support RSA_SIGNATURE_2018."); - } - return new RsaSignature2018LdSigner(signingKey); - } + try { + var proofValue = Base64.encodeBytes(signature, Base64.URL_SAFE); + ldProof.setProofValue(proofValue); + verifiableCredential.setProof(ldProof); + return verifiableCredential; + } catch (IOException e) { + throw new SigningServiceException("Was not able to encode the signature.", e); + } + } } diff --git a/services/src/main/java/org/keycloak/protocol/oidc4vp/signing/SigningService.java b/services/src/main/java/org/keycloak/protocol/oidc4vp/signing/SigningService.java index eb6e0c00251e..e47dcd6503d0 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc4vp/signing/SigningService.java +++ b/services/src/main/java/org/keycloak/protocol/oidc4vp/signing/SigningService.java @@ -1,96 +1,26 @@ package org.keycloak.protocol.oidc4vp.signing; -import org.bouncycastle.asn1.pkcs.PrivateKeyInfo; -import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo; -import org.bouncycastle.openssl.PEMKeyPair; -import org.bouncycastle.openssl.PEMParser; import org.jboss.logging.Logger; -import java.io.IOException; -import java.io.StringReader; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.security.KeyFactory; -import java.security.KeyPair; -import java.security.NoSuchAlgorithmException; -import java.security.PrivateKey; -import java.security.PublicKey; -import java.security.spec.InvalidKeySpecException; -import java.security.spec.PKCS8EncodedKeySpec; -import java.security.spec.X509EncodedKeySpec; -import java.util.ArrayList; -import java.util.List; +import java.time.Clock; import java.util.Optional; public abstract class SigningService<T> implements VCSigningService<T> { - private static final Logger LOGGER = Logger.getLogger(SigningService.class); + private static final Logger LOGGER = Logger.getLogger(SigningService.class); - protected final KeyPair signingKey; - protected final Optional<String> optionalKeyId; + protected final KeyLoader keyLoader; + protected final Optional<String> optionalKeyId; + protected final Clock clock; + // values of the type field are defined by the implementing service. Could f.e. the security suite for ldp_vc or the algorithm to be used for jwt_vc + protected final String type; - protected SigningService(String keyPath, Optional<String> optionalKeyId) { - this.signingKey = parsePem(loadPrivateKeyString(keyPath)); - this.optionalKeyId = optionalKeyId; - } + protected SigningService(KeyLoader keyLoader, Optional<String> optionalKeyId, Clock clock, String type) { + this.keyLoader = keyLoader; + this.optionalKeyId = optionalKeyId; + this.clock = clock; + this.type = type; + } - protected String loadPrivateKeyString(String keyPath) { - Path keyFilePath = Paths.get(keyPath); - try { - return Files.readString(keyFilePath); - } catch (IOException e) { - LOGGER.errorf("Was not able to read the private key from %s", keyPath); - throw new SigningServiceException("Was not able to read private key. Cannot initiate the SigningService.", - e); - } - } - - protected KeyPair parsePem(String keyString) { - PEMParser pemParser = new PEMParser(new StringReader(keyString)); - List<Object> parsedObjects = new ArrayList<>(); - try { - var currentObject = pemParser.readObject(); - while (currentObject != null) { - parsedObjects.add(currentObject); - currentObject = pemParser.readObject(); - } - } catch (IOException e) { - throw new SigningServiceException("Was not able to parse the key-pem"); - } - SubjectPublicKeyInfo publicKeyInfo = null; - PrivateKeyInfo privateKeyInfo = null; - for (Object parsedObject : parsedObjects) { - if (parsedObject instanceof SubjectPublicKeyInfo spki) { - publicKeyInfo = spki; - } else if (parsedObject instanceof PrivateKeyInfo pki) { - privateKeyInfo = pki; - } else if (parsedObject instanceof PEMKeyPair pkp) { - publicKeyInfo = pkp.getPublicKeyInfo(); - privateKeyInfo = pkp.getPrivateKeyInfo(); - } - } - if (privateKeyInfo == null) { - throw new SigningServiceException("Was not able to read a private key."); - } - PublicKey publicKey = null; - if (publicKeyInfo != null) { - try { - KeyFactory keyFactory = KeyFactory.getInstance(publicKeyInfo.getAlgorithm().getAlgorithm().getId()); - publicKey = keyFactory.generatePublic(new X509EncodedKeySpec(publicKeyInfo.getEncoded())); - } catch (NoSuchAlgorithmException | InvalidKeySpecException | IOException e) { - throw new SigningServiceException("Was not able to get the public key.", e); - } - } - try { - KeyFactory privateKeyFactory = KeyFactory.getInstance( - privateKeyInfo.getPrivateKeyAlgorithm().getAlgorithm().getId()); - PrivateKey privateKey = privateKeyFactory.generatePrivate( - new PKCS8EncodedKeySpec(privateKeyInfo.getEncoded())); - return new KeyPair(publicKey, privateKey); - } catch (NoSuchAlgorithmException | InvalidKeySpecException | IOException e) { - throw new SigningServiceException("Was not able to get the public key.", e); - } - } } diff --git a/services/src/main/java/org/keycloak/protocol/oidc4vp/signing/VCSigningService.java b/services/src/main/java/org/keycloak/protocol/oidc4vp/signing/VCSigningService.java index e3dc7d88ff9b..b7bb6259c6a8 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc4vp/signing/VCSigningService.java +++ b/services/src/main/java/org/keycloak/protocol/oidc4vp/signing/VCSigningService.java @@ -1,8 +1,9 @@ package org.keycloak.protocol.oidc4vp.signing; -import com.danubetech.verifiablecredentials.VerifiableCredential; + +import org.keycloak.protocol.oidc4vp.model.VerifiableCredential; public interface VCSigningService<T> { - T signCredential(VerifiableCredential verifiableCredential); + T signCredential(VerifiableCredential verifiableCredential); } diff --git a/services/src/main/java/org/keycloak/protocol/oidc4vp/signing/signatures/Ed255192018Suite.java b/services/src/main/java/org/keycloak/protocol/oidc4vp/signing/signatures/Ed255192018Suite.java new file mode 100644 index 000000000000..e5f26fc62d77 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc4vp/signing/signatures/Ed255192018Suite.java @@ -0,0 +1,133 @@ +package org.keycloak.protocol.oidc4vp.signing.signatures; + +import com.apicatalog.jsonld.JsonLd; +import com.apicatalog.jsonld.JsonLdError; +import com.apicatalog.jsonld.document.JsonDocument; +import com.apicatalog.jsonld.http.DefaultHttpClient; +import com.apicatalog.jsonld.http.media.MediaType; +import com.apicatalog.jsonld.json.JsonUtils; +import com.apicatalog.jsonld.loader.HttpLoader; +import com.apicatalog.rdf.Rdf; +import com.apicatalog.rdf.RdfDataset; +import com.apicatalog.rdf.io.RdfWriter; +import com.apicatalog.rdf.io.error.RdfWriterException; +import com.apicatalog.rdf.io.error.UnsupportedContentException; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.setl.rdf.normalization.RdfNormalize; +import jakarta.json.JsonObject; +import jakarta.json.JsonValue; +import org.bouncycastle.asn1.pkcs.PrivateKeyInfo; +import org.bouncycastle.crypto.params.AsymmetricKeyParameter; +import org.bouncycastle.crypto.signers.Ed25519Signer; +import org.bouncycastle.crypto.util.PrivateKeyFactory; +import org.bouncycastle.openssl.PEMKeyPair; +import org.bouncycastle.openssl.PEMParser; +import org.keycloak.protocol.oidc4vp.model.VerifiableCredential; +import org.keycloak.protocol.oidc4vp.signing.SigningServiceException; + +import java.io.IOException; +import java.io.StringReader; +import java.io.StringWriter; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Optional; + +public class Ed255192018Suite implements SecuritySuite { + + private final ObjectMapper objectMapper; + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private static final String CANONICALIZATION_ALGORITHM = "https://w3id.org/security#URDNA2015"; + private static final String DIGEST_ALGORITHM = "http://w3id.org/digests#sha256"; + private static final String SIGNATURE_ALGORITHM = "http://w3id.org/security#ed25519"; + + public static final String PROOF_TYPE = "Ed25519Signature2018"; + + public Ed255192018Suite(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + @Override + public byte[] transform(VerifiableCredential verifiableCredential) { + + try { + String credentialString = objectMapper.writeValueAsString(verifiableCredential); + + var credentialDocument = JsonDocument.of(new StringReader(credentialString)); + + var expandedDocument = JsonLd.expand(credentialDocument) + .loader(new HttpLoader(DefaultHttpClient.defaultInstance())) + .get(); + Optional<JsonObject> documentObject = Optional.empty(); + if (JsonUtils.isArray(expandedDocument)) { + documentObject = expandedDocument.asJsonArray().stream().filter(JsonUtils::isObject).map(JsonValue::asJsonObject).findFirst(); + } else if (JsonUtils.isObject(expandedDocument)) { + documentObject = Optional.of(expandedDocument.asJsonObject()); + } + if (documentObject.isPresent()) { + + RdfDataset rdfDataset = JsonLd.toRdf(JsonDocument.of(documentObject.get())).get(); + RdfDataset canonicalDataset = RdfNormalize.normalize(rdfDataset); + + StringWriter writer = new StringWriter(); + RdfWriter rdfWriter = Rdf.createWriter(MediaType.N_QUADS, writer); + rdfWriter.write(canonicalDataset); + + return writer.toString() + .getBytes(StandardCharsets.UTF_8); + } else { + throw new SigningServiceException("Was not able to get the expanded json."); + } + } catch (JsonProcessingException e) { + throw new SigningServiceException("Was not able to serialize the credential", e); + } catch (JsonLdError e) { + throw new SigningServiceException("Was not able to create a JsonLD Document from the serialized string.", e); + } catch (UnsupportedContentException | IOException | RdfWriterException e) { + throw new SigningServiceException("Was not able to canonicalize the json-ld.", e); + } + + } + + @Override + public byte[] digest(byte[] transformedData) { + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + return md.digest(transformedData); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException(e); + } + } + + @Override + public byte[] sign(byte[] hashData, String key) { + Ed25519Signer signer = new Ed25519Signer(); + signer.init(true, parseKey(key)); + signer.update(hashData, 0, hashData.length); + return signer.generateSignature(); + } + + + private static AsymmetricKeyParameter parseKey(String key) { + PEMParser pemReaderPrivate = new PEMParser(new StringReader(key)); + try { + var pemObject = pemReaderPrivate.readObject(); + if (pemObject instanceof PEMKeyPair pkp) { + return PrivateKeyFactory.createKey(pkp.getPrivateKeyInfo()); + } else if (pemObject instanceof PrivateKeyInfo pki) { + return PrivateKeyFactory.createKey(pki); + } else { + throw new RuntimeException(); + } + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + @Override + public String getProofType() { + return PROOF_TYPE; + } +} diff --git a/services/src/main/java/org/keycloak/protocol/oidc4vp/signing/signatures/EdDSASignatureSignerContext.java b/services/src/main/java/org/keycloak/protocol/oidc4vp/signing/signatures/EdDSASignatureSignerContext.java new file mode 100644 index 000000000000..b6dbe60d1641 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc4vp/signing/signatures/EdDSASignatureSignerContext.java @@ -0,0 +1,48 @@ +package org.keycloak.protocol.oidc4vp.signing.signatures; + +import org.bouncycastle.jcajce.interfaces.EdDSAPrivateKey; +import org.keycloak.crypto.KeyWrapper; +import org.keycloak.crypto.SignatureException; +import org.keycloak.crypto.SignatureSignerContext; + +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.Signature; + +public class EdDSASignatureSignerContext implements SignatureSignerContext { + + public static final String ED_25519 = "Ed25519"; + + private final KeyWrapper key; + + public EdDSASignatureSignerContext(KeyWrapper key) { + this.key = key; + } + + @Override + public String getKid() { + return key.getKid(); + } + + @Override + public String getAlgorithm() { + return key.getAlgorithm(); + } + + @Override + public String getHashAlgorithm() { + return key.getAlgorithm(); + } + + @Override + public byte[] sign(byte[] data) throws SignatureException { + try { + Signature signature = Signature.getInstance(key.getAlgorithm()); + signature.initSign((PrivateKey) key.getPrivateKey()); + signature.update(data); + return signature.sign(); + } catch (Exception e) { + throw new SignatureException("Signing failed", e); + } + } +} diff --git a/services/src/main/java/org/keycloak/protocol/oidc4vp/signing/signatures/RsaSignature2018Suite.java b/services/src/main/java/org/keycloak/protocol/oidc4vp/signing/signatures/RsaSignature2018Suite.java new file mode 100644 index 000000000000..2670be7913c8 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc4vp/signing/signatures/RsaSignature2018Suite.java @@ -0,0 +1,48 @@ +package org.keycloak.protocol.oidc4vp.signing.signatures; + +import org.bouncycastle.asn1.pkcs.PrivateKeyInfo; +import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo; +import org.bouncycastle.openssl.PEMKeyPair; +import org.bouncycastle.openssl.PEMParser; +import org.keycloak.protocol.oidc4vp.model.VerifiableCredential; +import org.keycloak.protocol.oidc4vp.signing.SigningServiceException; + +import java.io.IOException; +import java.io.StringReader; +import java.security.*; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.PKCS8EncodedKeySpec; +import java.security.spec.X509EncodedKeySpec; +import java.util.ArrayList; +import java.util.List; + +public class RsaSignature2018Suite implements SecuritySuite { + + private static final String CANONICALIZATION_ALGORITHM = "https://w3id.org/security#GCA2015"; + private static final String DIGEST_ALGORITHM = "https://www.ietf.org/assignments/jwa-parameters#SHA256"; + private static final String SIGNATURE_ALGORITHM = "https://www.ietf.org/assignments/jwa-parameters#RS256"; + + public static final String PROOF_TYPE = "RsaSignature2018"; + + @Override + public byte[] transform(VerifiableCredential verifiableCredential) { + return new byte[0]; + } + + @Override + public byte[] digest(byte[] transformedData) { + return new byte[0]; + } + + @Override + public byte[] sign(byte[] hashData, String key) { + return new byte[0]; + } + + @Override + public String getProofType() { + return PROOF_TYPE; + } +} + + diff --git a/services/src/main/java/org/keycloak/protocol/oidc4vp/signing/signatures/SecuritySuite.java b/services/src/main/java/org/keycloak/protocol/oidc4vp/signing/signatures/SecuritySuite.java new file mode 100644 index 000000000000..06bf48b5bae7 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc4vp/signing/signatures/SecuritySuite.java @@ -0,0 +1,29 @@ +package org.keycloak.protocol.oidc4vp.signing.signatures; + +import org.bouncycastle.asn1.pkcs.PrivateKeyInfo; +import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo; +import org.bouncycastle.openssl.PEMKeyPair; +import org.bouncycastle.openssl.PEMParser; +import org.keycloak.protocol.oidc4vp.model.VerifiableCredential; +import org.keycloak.protocol.oidc4vp.signing.SigningServiceException; + +import java.io.IOException; +import java.io.StringReader; +import java.security.*; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.PKCS8EncodedKeySpec; +import java.security.spec.X509EncodedKeySpec; +import java.util.ArrayList; +import java.util.List; + +public interface SecuritySuite { + + byte[] transform(VerifiableCredential verifiableCredential); + + byte[] digest(byte[] transformedData); + + byte[] sign(byte[] hashData, String key); + + String getProofType(); + +} diff --git a/services/src/test/java/org/keycloak/protocol/oidc4vp/OIDC4VPIssuerEndpointTest.java b/services/src/test/java/org/keycloak/protocol/oidc4vp/OIDC4VPIssuerEndpointTest.java index 572e0fd1e995..e4e8dce4f294 100644 --- a/services/src/test/java/org/keycloak/protocol/oidc4vp/OIDC4VPIssuerEndpointTest.java +++ b/services/src/test/java/org/keycloak/protocol/oidc4vp/OIDC4VPIssuerEndpointTest.java @@ -1,6 +1,5 @@ package org.keycloak.protocol.oidc4vp; -import com.danubetech.verifiablecredentials.CredentialSubject; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.MapperFeature; @@ -10,6 +9,7 @@ import jakarta.ws.rs.WebApplicationException; import jakarta.ws.rs.core.Response; import lombok.extern.slf4j.Slf4j; +import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -17,45 +17,26 @@ import org.junit.jupiter.params.provider.MethodSource; import org.keycloak.TokenVerifier; import org.keycloak.common.VerificationException; -import org.keycloak.models.ClientModel; -import org.keycloak.models.ClientProvider; -import org.keycloak.models.KeycloakContext; -import org.keycloak.models.KeycloakSession; -import org.keycloak.models.ProtocolMapperModel; -import org.keycloak.models.RealmModel; -import org.keycloak.models.RoleModel; -import org.keycloak.models.UserModel; -import org.keycloak.models.UserSessionModel; -import org.keycloak.protocol.oidc4vp.mappers.OIDC4VPStaticClaimMapper; -import org.keycloak.protocol.oidc4vp.mappers.OIDC4VPSubjectIdMapper; -import org.keycloak.protocol.oidc4vp.mappers.OIDC4VPTargetRoleMapper; -import org.keycloak.protocol.oidc4vp.mappers.OIDC4VPUserAttributeMapper; +import org.keycloak.models.*; +import org.keycloak.protocol.oidc4vp.mappers.*; +import org.keycloak.protocol.oidc4vp.model.*; import org.keycloak.protocol.oidc4vp.model.ErrorResponse; -import org.keycloak.protocol.oidc4vp.model.ErrorType; import org.keycloak.protocol.oidc4vp.model.Format; -import org.keycloak.protocol.oidc4vp.model.Role; import org.keycloak.protocol.oidc4vp.model.SupportedCredential; import org.keycloak.representations.JsonWebToken; import org.keycloak.services.managers.AppAuthManager; import org.keycloak.services.managers.AuthenticationManager; import java.net.URL; +import java.security.Security; import java.time.Clock; import java.time.Instant; import java.time.ZoneId; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Set; +import java.util.*; import java.util.stream.Collectors; import java.util.stream.Stream; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.junit.jupiter.api.Assertions.fail; +import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Matchers.any; import static org.mockito.Matchers.eq; import static org.mockito.Mockito.mock; @@ -64,696 +45,769 @@ @Slf4j public class OIDC4VPIssuerEndpointTest { - private static final String ISSUER_DID = "did:key:test"; - - private final ObjectMapper OBJECT_MAPPER = JsonMapper.builder() - .configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true) - .configure(MapperFeature.SORT_PROPERTIES_ALPHABETICALLY, true).build(); - - private KeycloakSession keycloakSession; - private AppAuthManager.BearerTokenAuthenticator bearerTokenAuthenticator; - - private OIDC4VPIssuerEndpoint testEndpoint; - - private Clock fixedClock = Clock.fixed(Instant.parse("2022-11-10T17:11:09.00Z"), - ZoneId.of("Europe/Paris")); - - @BeforeEach - public void setUp() throws NoSuchFieldException { - URL url = getClass().getClassLoader().getResource("key.tls"); - - this.keycloakSession = mock(KeycloakSession.class); - this.bearerTokenAuthenticator = mock(AppAuthManager.BearerTokenAuthenticator.class); - this.testEndpoint = new OIDC4VPIssuerEndpoint(keycloakSession, ISSUER_DID, url.getPath(), - bearerTokenAuthenticator, new ObjectMapper(), fixedClock); - } - - @Test - public void testGetVCUnauthorized() { - KeycloakContext context = mock(KeycloakContext.class); - RealmModel realmModel = mock(RealmModel.class); - when(keycloakSession.getContext()).thenReturn(context); - when(context.getRealm()).thenReturn(realmModel); - - when(bearerTokenAuthenticator.authenticate()).thenReturn(null); - - try { - testEndpoint.issueVerifiableCredential(ISSUER_DID, "MyVC"); - fail("VCs should only be accessible for authorized users."); - } catch (WebApplicationException e) { - assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), e.getResponse().getStatus(), - "The response should be a 400."); - ErrorResponse er = OBJECT_MAPPER.convertValue(e.getResponse().getEntity(), ErrorResponse.class); - assertEquals(ErrorType.INVALID_TOKEN.getValue(), er.getError().value(), - "The response should have been denied because of the invalid token."); - } - } - - @ParameterizedTest - @MethodSource("provideTypesAndClients") - public void testGetVCNoSuchType(Stream<ClientModel> clientModelStream, - ExpectedResult<Set<SupportedCredential>> ignored) { - AuthenticationManager.AuthResult authResult = mock(AuthenticationManager.AuthResult.class); - UserModel userModel = mock(UserModel.class); - KeycloakContext context = mock(KeycloakContext.class); - RealmModel realmModel = mock(RealmModel.class); - ClientProvider clientProvider = mock(ClientProvider.class); - - when(bearerTokenAuthenticator.authenticate()).thenReturn(authResult); - when(authResult.getUser()).thenReturn(userModel); - when(keycloakSession.getContext()).thenReturn(context); - when(context.getRealm()).thenReturn(realmModel); - when(keycloakSession.clients()).thenReturn(clientProvider); - when(clientProvider.getClientsStream(any())).thenReturn(clientModelStream); - - try { - testEndpoint.issueVerifiableCredential(ISSUER_DID, "MyNonExistentType"); - fail("Not found types should be a 400"); - } catch (WebApplicationException e) { - assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), e.getResponse().getStatus(), - "Not found types should be a 400"); - ErrorResponse er = OBJECT_MAPPER.convertValue(e.getResponse().getEntity(), ErrorResponse.class); - assertEquals(ErrorType.UNSUPPORTED_CREDENTIAL_TYPE.getValue(), er.getError().value(), - "The response should have been denied because of the unsupported type."); - } - } - - @ParameterizedTest - @MethodSource("provideUserAndClients") - public void testGetCredential(UserModel userModel, Stream<ClientModel> clientModelStream, - Map<ClientModel, Stream<RoleModel>> roleModelStreamMap, - ExpectedResult<Map> expectedResult, Format requestedFormat) - throws JsonProcessingException, VerificationException { - List<ClientModel> clientModels = clientModelStream.toList(); - - AuthenticationManager.AuthResult authResult = mock(AuthenticationManager.AuthResult.class); - KeycloakContext context = mock(KeycloakContext.class); - RealmModel realmModel = mock(RealmModel.class); - ClientProvider clientProvider = mock(ClientProvider.class); - - UserSessionModel userSessionModel = mock(UserSessionModel.class); - when(userSessionModel.getRealm()).thenReturn(realmModel); - when(userSessionModel.getUser()).thenReturn(userModel); - clientModels.forEach(cm -> when(realmModel.getClientByClientId(eq(cm.getClientId()))).thenReturn(cm)); - when(realmModel.getClientsStream()).thenReturn(clientModels.stream()); - - when(bearerTokenAuthenticator.authenticate()).thenReturn(authResult); - - when(authResult.getUser()).thenReturn(userModel); - when(authResult.getSession()).thenReturn(userSessionModel); - - when(keycloakSession.getContext()).thenReturn(context); - when(context.getRealm()).thenReturn(realmModel); - - when(keycloakSession.clients()).thenReturn(clientProvider); - when(clientProvider.getClientsStream(any())).thenReturn(clientModels.stream()); - - when(userModel.getClientRoleMappingsStream(any())).thenAnswer(i -> roleModelStreamMap.get(i.getArguments()[0])); - Object credential = testEndpoint.getCredential("MyType", requestedFormat); - switch (requestedFormat) { - case LDP_VC -> { - Map verifiableCredential = OBJECT_MAPPER.convertValue(credential, Map.class); - verifyLDCredential(expectedResult, verifiableCredential); - } - case JWT_VC_JSON_LD, JWT_VC, JWT_VC_JSON -> verifyJWTCredential(expectedResult, (String) credential); - } - } - - private void verifyJWTCredential(ExpectedResult<Map> expectedResult, String actualResult) - throws VerificationException, JsonProcessingException { - TokenVerifier<JsonWebToken> verifier = TokenVerifier.create(actualResult, JsonWebToken.class); - JsonWebToken theJWT = verifier.getToken(); - assertEquals(ISSUER_DID, theJWT.getIssuer(), "The issuer should be properly set."); - assertNotNull(theJWT.getSubject(), "A subject should be set."); - assertNotNull(theJWT.getId(), "The jwt should have an id."); - - Map theVC = (Map) theJWT.getOtherClaims().get("vc"); - assertNotNull(theVC, "The vc should be part of the jwt."); - List credentialType = (List) theVC.get("type"); - assertEquals(2, credentialType.size(), "Both types should be included."); - assertTrue(credentialType.contains("MyType") && credentialType.contains("VerifiableCredential"), - "The correct types should be included."); - - Map retrievedSubject = (Map) theVC.get("credentialSubject"); - Map expectedCredentialSubject = new HashMap(expectedResult.getExpectedResult()); - - verifySubject(expectedResult, expectedCredentialSubject, retrievedSubject); - - } - - @ParameterizedTest - @MethodSource("provideUserAndClientsLDP") - public void testGetVC(UserModel userModel, Stream<ClientModel> clientModelStream, - Map<ClientModel, Stream<RoleModel>> roleModelStreamMap, - ExpectedResult<Map> expectedResult) throws JsonProcessingException { - List<ClientModel> clientModels = clientModelStream.toList(); - - AuthenticationManager.AuthResult authResult = mock(AuthenticationManager.AuthResult.class); - KeycloakContext context = mock(KeycloakContext.class); - RealmModel realmModel = mock(RealmModel.class); - ClientProvider clientProvider = mock(ClientProvider.class); - UserSessionModel userSessionModel = mock(UserSessionModel.class); - when(userSessionModel.getRealm()).thenReturn(realmModel); - when(userSessionModel.getUser()).thenReturn(userModel); - clientModels.forEach(cm -> when(realmModel.getClientByClientId(eq(cm.getClientId()))).thenReturn(cm)); - - when(bearerTokenAuthenticator.authenticate()).thenReturn(authResult); - when(authResult.getUser()).thenReturn(userModel); - when(authResult.getSession()).thenReturn(userSessionModel); - when(keycloakSession.getContext()).thenReturn(context); - when(context.getRealm()).thenReturn(realmModel); - when(keycloakSession.clients()).thenReturn(clientProvider); - // use then to open a new stream on each invocation - when(clientProvider.getClientsStream(any())).then(f -> clientModels.stream()); - - when(userModel.getClientRoleMappingsStream(any())).thenAnswer(i -> roleModelStreamMap.get(i.getArguments()[0])); - - Map credentialVO = OBJECT_MAPPER.convertValue( - testEndpoint.issueVerifiableCredential("MyType", "myToken").getEntity(), - Map.class); - - verifyLDCredential(expectedResult, credentialVO); - } - - private void verifyLDCredential(ExpectedResult<Map> expectedResult, Map credentialVO) - throws JsonProcessingException { - assertEquals("2022-11-10T17:11:09Z", credentialVO.get("issuanceDate"), - "The issuance data should be correctly set."); - assertNotNull(credentialVO.get("@context"), "The context should be set on an ld-credential."); - assertNotNull(credentialVO.get("proof"), "The proof should be included."); - assertNotNull(credentialVO.get("id"), "The credential should have an id."); - List credentialType = (List) credentialVO.get("type"); - assertEquals(2, credentialType.size(), "Both types should be included."); - assertTrue(credentialType.contains("MyType") && credentialType.contains("VerifiableCredential"), - "The correct types should be included."); - - assertEquals(ISSUER_DID, credentialVO.get("issuer"), "The correct issuer should be set."); - - Map expectedCredentialSubject = new HashMap(expectedResult.getExpectedResult()); - Map retrievedSubject = (Map) credentialVO.get("credentialSubject"); - assertNotNull(retrievedSubject.get("id"), "The id should have been set."); - // remove the id, since its randomly generated. - retrievedSubject.remove("id"); - - verifySubject(expectedResult, expectedCredentialSubject, retrievedSubject); - } - - private void verifySubject(ExpectedResult<Map> expectedResult, Map expectedCredentialSubject, Map retrievedSubject) - throws JsonProcessingException { - verifyRoles(expectedResult.getMessage(), expectedCredentialSubject, retrievedSubject); - // roles are checked, can be removed to not interfer with next checks. - expectedCredentialSubject.remove("roles"); - retrievedSubject.remove("roles"); - - String expectedJson = OBJECT_MAPPER.writeValueAsString(expectedCredentialSubject); - String retrievedJson = OBJECT_MAPPER.writeValueAsString(retrievedSubject); - // we compare the json, to prevent order issues. - assertEquals(expectedJson, retrievedJson, expectedResult.getMessage()); - } - - private void verifyRoles(String message, Map expectedCredentialSubject, Map retrievedSubject) { - Set<Role> retrievedRoles = OBJECT_MAPPER.convertValue(retrievedSubject.get("roles"), - new TypeReference<Set<Role>>() { - }); - Set<Role> expectedRoles = OBJECT_MAPPER.convertValue(expectedCredentialSubject.get("roles"), - new TypeReference<Set<Role>>() { - }); - assertEquals(expectedRoles, retrievedRoles, message); - } - - private static Arguments getArguments(UserModel um, Map<ClientModel, List<RoleModel>> clients, - ExpectedResult expectedResult) { - return Arguments.of(um, - clients.keySet().stream(), - clients.entrySet() - .stream() - .filter(e -> e.getValue() != null) - .collect( - Collectors.toMap(Map.Entry::getKey, e -> ((List) e.getValue()).stream(), - (e1, e2) -> e1)), - expectedResult); - } - - private static Stream<Arguments> provideUserAndClients() { - return Stream.concat(provideUserAndClientsLDP().map(a -> { - var argObjects = new ArrayList<>(Arrays.asList(a.get())); - argObjects.add(Format.LDP_VC); - return Arguments.of(argObjects.toArray()); - }), - provideUserAndClientsJWT().map(a -> { - var argObjects = new ArrayList<>(Arrays.asList(a.get())); - argObjects.add(Format.JWT_VC); - return Arguments.of(argObjects.toArray()); - })); - } - - private static Stream<Arguments> provideUserAndClientsJWT() { - return Stream.of( - getArguments(getUserModel("e@mail.org", "Happy", "User"), - Map.of(getOidc4VpClient("did:key:1", - Map.of("vctypes_MyType", Format.JWT_VC.toString()), - List.of("MyRole")), - List.of(getRoleModel("MyRole"))), - new ExpectedResult<>( - Map.of("email", "e@mail.org", "familyName", "User", "firstName", "Happy", "roles", - Set.of(new Role(Set.of("MyRole"), "did:key:1"))), - "A valid Credential should have been returned.") - ), - getArguments(getUserModel("e@mail.org", null, "User"), - Map.of(getOidc4VpClient("did:key:1", - Map.of("vctypes_MyType", Format.JWT_VC.toString()), - List.of("MyRole")), - List.of(getRoleModel("MyRole"))), - new ExpectedResult<>( - Map.of("email", "e@mail.org", "familyName", "User", "roles", - Set.of(new Role(Set.of("MyRole"), "did:key:1"))), - "A valid Credential should have been returned.") - ), - getArguments( - getUserModel("e@mail.org", null, null), - Map.of(getOidc4VpClient("did:key:1", - Map.of("vctypes_MyType", Format.JWT_VC.toString()), - List.of("MyRole")), - List.of(getRoleModel("MyRole"))), - new ExpectedResult<>( - Map.of("email", "e@mail.org", "roles", - Set.of(new Role(Set.of("MyRole"), "did:key:1"))), - "A valid Credential should have been returned.") - ), - getArguments( - getUserModel(null, null, null), - Map.of(getOidc4VpClient("did:key:1", - Map.of("vctypes_MyType", Format.JWT_VC.toString()), - List.of("MyRole")), - List.of(getRoleModel("MyRole"))), - new ExpectedResult<>( - Map.of("roles", - Set.of(new Role(Set.of("MyRole"), "did:key:1"))), - "A valid Credential should have been returned.") - ), - getArguments( - getUserModel(null, null, null), - Map.of(getOidc4VpClient("did:key:1", - Map.of("vctypes_MyType", Format.JWT_VC.toString()), - List.of("MyRole", "MySecondRole")), - List.of(getRoleModel("MyRole"), getRoleModel("MySecondRole"))), - new ExpectedResult<>( - Map.of("roles", - Set.of(new Role(Set.of("MyRole", "MySecondRole"), "did:key:1"))), - "Multiple roles should be included") - ), - getArguments( - getUserModel(null, null, null), - Map.of(getOidc4VpClient("did:key:1", - Map.of("vctypes_MyType", Format.JWT_VC.toString()), - List.of("MyRole", "MySecondRole")), - List.of(getRoleModel("MyRole"))), - new ExpectedResult<>( - Map.of("roles", - Set.of(new Role(Set.of("MyRole"), "did:key:1"))), - "Only assigned roles should be included.") - ), - getArguments( - getUserModel(null, null, null), - Map.of(getOidc4VpClient("did:key:1", - Map.of("vctypes_MyType", Format.JWT_VC.toString()), - List.of("MyRole", "MySecondRole")), - List.of(getRoleModel("MyRole"), getRoleModel("MySecondRole")), - getOidc4VpClient("did:key:2", - Map.of("vctypes_MyType", Format.JWT_VC.toString()), - List.of("AnotherRole")), - List.of(getRoleModel("AnotherRole"))), - new ExpectedResult<>( - Map.of("roles", - Set.of(new Role(Set.of("MyRole", "MySecondRole"), "did:key:1"), - new Role(Set.of("AnotherRole"), "did:key:2"))), - "The request should contain roles from both clients") - ), - getArguments( - getUserModel(null, null, null), - Map.of(getOidc4VpClient("did:key:1", - Map.of("vctypes_MyType", Format.JWT_VC.toString()), - List.of("MyRole", "MySecondRole")), - List.of(getRoleModel("MyRole"), getRoleModel("MySecondRole")), - getOidc4VpClient("did:key:2", - Map.of("vctypes_AnotherType", Format.JWT_VC.toString()), - List.of("AnotherRole")), - List.of(getRoleModel("AnotherRole"))), - new ExpectedResult<>( - Map.of("roles", - Set.of(new Role(Set.of("MyRole", "MySecondRole"), "did:key:1"))), - "Only roles for supported clients should be included.") - ), - getArguments( - getUserModel(null, null, null), - Map.of(getOidc4VpClient("did:key:1", - Map.of("vctypes_MyType", Format.JWT_VC.toString()), - List.of("MyRole", "MySecondRole"), - Map.of("more", "claims")), - List.of(getRoleModel("MyRole"), getRoleModel("MySecondRole")), - getOidc4VpClient("did:key:2", - Map.of("vctypes_MyType", Format.JWT_VC.toString()), - List.of("AnotherRole"), - Map.of("additional", "claim")), - List.of(getRoleModel("AnotherRole"))), - new ExpectedResult<>( - Map.of("roles", - Set.of(new Role(Set.of("MyRole", "MySecondRole"), "did:key:1"), - new Role(Set.of("AnotherRole"), "did:key:2")), - "additional", "claim", "more", "claims"), - "Additional claims should be included.") - ), - getArguments( - getUserModel(null, null, null), - Map.of(getOidc4VpClient("did:key:1", - Map.of("vctypes_MyType", Format.JWT_VC.toString()), - List.of("MyRole", "MySecondRole")), - List.of(getRoleModel("MyRole"), getRoleModel("MySecondRole")), - getOidc4VpClient("did:key:2", - Map.of("vctypes_MyType", Format.JWT_VC.toString()), - List.of("AnotherRole"), - Map.of("additional", "claim")), - List.of(getRoleModel("AnotherRole"))), - new ExpectedResult<>( - Map.of("additional", "claim", "roles", - Set.of(new Role(Set.of("MyRole", "MySecondRole"), "did:key:1"), - new Role(Set.of("AnotherRole"), "did:key:2"))), - "Additional claims should be included.") - ) - ); - } - - private static Stream<Arguments> provideUserAndClientsLDP() { - return Stream.of( - getArguments(getUserModel("e@mail.org", "Happy", "User"), - Map.of(getOidc4VpClient("did:key:1", - Map.of("vctypes_MyType", Format.LDP_VC.toString()), - List.of("MyRole")), - List.of(getRoleModel("MyRole"))), - new ExpectedResult<>( - Map.of("email", "e@mail.org", "familyName", "User", "firstName", "Happy", "roles", - Set.of(new Role(Set.of("MyRole"), "did:key:1"))), - "A valid Credential should have been returned.") - ), - getArguments(getUserModel("e@mail.org", null, "User"), - Map.of(getOidc4VpClient("did:key:1", - Map.of("vctypes_MyType", Format.LDP_VC.toString()), - List.of("MyRole")), - List.of(getRoleModel("MyRole"))), - new ExpectedResult<>( - Map.of("email", "e@mail.org", "familyName", "User", "roles", - Set.of(new Role(Set.of("MyRole"), "did:key:1"))), - "A valid Credential should have been returned.") - ), - getArguments( - getUserModel("e@mail.org", null, null), - Map.of(getOidc4VpClient("did:key:1", - Map.of("vctypes_MyType", Format.LDP_VC.toString()), - List.of("MyRole")), - List.of(getRoleModel("MyRole"))), - new ExpectedResult<>( - Map.of("email", "e@mail.org", "roles", - Set.of(new Role(Set.of("MyRole"), "did:key:1"))), - "A valid Credential should have been returned.") - ), - getArguments( - getUserModel(null, null, null), - Map.of(getOidc4VpClient("did:key:1", - Map.of("vctypes_MyType", Format.LDP_VC.toString()), - List.of("MyRole")), - List.of(getRoleModel("MyRole"))), - new ExpectedResult<>( - Map.of("roles", - Set.of(new Role(Set.of("MyRole"), "did:key:1"))), - "A valid Credential should have been returned.") - ), - getArguments( - getUserModel(null, null, null), - Map.of(getOidc4VpClient("did:key:1", - Map.of("vctypes_MyType", Format.LDP_VC.toString()), - List.of("MyRole", "MySecondRole")), - List.of(getRoleModel("MyRole"), getRoleModel("MySecondRole"))), - new ExpectedResult<>( - Map.of("roles", - Set.of(new Role(Set.of("MyRole", "MySecondRole"), "did:key:1"))), - "Multiple roles should be included") - ), - getArguments( - getUserModel(null, null, null), - Map.of(getOidc4VpClient("did:key:1", - Map.of("vctypes_MyType", Format.LDP_VC.toString()), - List.of("MyRole", "MySecondRole")), - List.of(getRoleModel("MyRole"))), - new ExpectedResult<>( - Map.of("roles", - Set.of(new Role(Set.of("MyRole"), "did:key:1"))), - "Only assigned roles should be included.") - ), - getArguments( - getUserModel(null, null, null), - Map.of(getOidc4VpClient("did:key:1", - Map.of("vctypes_MyType", Format.LDP_VC.toString()), - List.of("MyRole", "MySecondRole")), - List.of(getRoleModel("MyRole"), getRoleModel("MySecondRole")), - getOidc4VpClient("did:key:2", - Map.of("vctypes_MyType", Format.LDP_VC.toString()), - List.of("AnotherRole")), - List.of(getRoleModel("AnotherRole"))), - new ExpectedResult<>( - Map.of("roles", - Set.of(new Role(Set.of("MyRole", "MySecondRole"), "did:key:1"), - new Role(Set.of("AnotherRole"), "did:key:2"))), - "The request should contain roles from both clients") - ), - getArguments( - getUserModel(null, null, null), - Map.of(getOidc4VpClient("did:key:1", - Map.of("vctypes_MyType", Format.LDP_VC.toString()), - List.of("MyRole", "MySecondRole")), - List.of(getRoleModel("MyRole"), getRoleModel("MySecondRole")), - getOidc4VpClient("did:key:2", - Map.of("vctypes_AnotherType", Format.LDP_VC.toString()), - List.of("AnotherRole")), - List.of(getRoleModel("AnotherRole"))), - new ExpectedResult<>( - Map.of("roles", - Set.of(new Role(Set.of("MyRole", "MySecondRole"), "did:key:1"))), - "Only roles for supported clients should be included.") - ), - getArguments( - getUserModel(null, null, null), - Map.of(getOidc4VpClient("did:key:1", - Map.of("vctypes_MyType", Format.LDP_VC.toString()), - List.of("MyRole", "MySecondRole"), - Map.of("more", "claims")), - List.of(getRoleModel("MyRole"), getRoleModel("MySecondRole")), - getOidc4VpClient("did:key:2", - Map.of("vctypes_MyType", Format.LDP_VC.toString()), - List.of("AnotherRole"), - Map.of("additional", "claim")), - List.of(getRoleModel("AnotherRole"))), - new ExpectedResult<>( - Map.of("roles", - Set.of(new Role(Set.of("MyRole", "MySecondRole"), "did:key:1"), - new Role(Set.of("AnotherRole"), "did:key:2")), - "additional", "claim", "more", "claims"), - "Additional claims should be included.") - ), - getArguments( - getUserModel(null, null, null), - Map.of(getOidc4VpClient("did:key:1", - Map.of("vctypes_MyType", Format.LDP_VC.toString()), - List.of("MyRole", "MySecondRole")), - List.of(getRoleModel("MyRole"), getRoleModel("MySecondRole")), - getOidc4VpClient("did:key:2", - Map.of("vctypes_MyType", Format.LDP_VC.toString()), - List.of("AnotherRole"), - Map.of("additional", "claim")), - List.of(getRoleModel("AnotherRole"))), - new ExpectedResult<>( - Map.of("additional", "claim", "roles", - Set.of(new Role(Set.of("MyRole", "MySecondRole"), "did:key:1"), - new Role(Set.of("AnotherRole"), "did:key:2"))), - "Additional claims should be included.") - ) - ); - } - - private static Stream<Arguments> provideTypesAndClients() { - return Stream.of( - Arguments.of(Stream.of(getOidcClient(), getNullClient(), getOidc4VpClient( - Map.of("vctypes_TestType", Format.LDP_VC.toString()))), - new ExpectedResult<>(Set.of(getCredential("TestType", Format.LDP_VC)), - "The list of configured types should be returned.")), - Arguments.of(Stream.of(getOidcClient(), getNullClient()), - new ExpectedResult<>(Set.of(), "An empty list should be returned if nothing is configured.")), - Arguments.of(Stream.of(), - new ExpectedResult<>(Set.of(), "An empty list should be returned if nothing is configured.")), - Arguments.of( - Stream.of(getOidc4VpClient(Map.of("vctypes_TestType", Format.LDP_VC.toString(), - "another", "attribute"))), - new ExpectedResult<>(Set.of(getCredential("TestType", Format.LDP_VC)), - "The list of configured types should be returned.")), - Arguments.of(Stream.of(getOidc4VpClient( - Map.of("vctypes_TestTypeA", Format.LDP_VC.toString(), "vctypes_TestTypeB", - Format.LDP_VC.toString()))), - new ExpectedResult<>( - Set.of(getCredential("TestTypeA", Format.LDP_VC), - getCredential("TestTypeB", Format.LDP_VC)), - "The list of configured types should be returned.")), - Arguments.of(Stream.of( - getOidc4VpClient(Map.of()), - getOidc4VpClient( - Map.of("vctypes_TestTypeA", Format.LDP_VC.toString(), "vctypes_TestTypeB", - Format.LDP_VC.toString()))), - new ExpectedResult<>( - Set.of(getCredential("TestTypeA", Format.LDP_VC), - getCredential("TestTypeB", Format.LDP_VC)), - "The list of configured types should be returned.")), - Arguments.of(Stream.of( - getOidc4VpClient(null), - getOidc4VpClient( - Map.of("vctypes_TestTypeA", Format.LDP_VC.toString(), "vctypes_TestTypeB", - Format.LDP_VC.toString()))), - new ExpectedResult<>( - Set.of(getCredential("TestTypeA", Format.LDP_VC), - getCredential("TestTypeB", Format.LDP_VC)), - "The list of configured types should be returned.")), - Arguments.of(Stream.of( - getOidc4VpClient(Map.of("vctypes_AnotherType", Format.LDP_VC.toString())), - getOidc4VpClient( - Map.of("vctypes_TestTypeA", Format.LDP_VC.toString(), "vctypes_TestTypeB", - Format.LDP_VC.toString()))), - new ExpectedResult<>( - Set.of(getCredential("TestTypeA", Format.LDP_VC), - getCredential("TestTypeB", Format.LDP_VC), - getCredential("AnotherType", Format.LDP_VC)), - "The list of configured types should be returned.")), - Arguments.of(Stream.of( - getOidc4VpClient( - Map.of("vctypes_AnotherType", Format.LDP_VC.toString(), "vctypes_AndAnother", - Format.LDP_VC.toString())), - getOidc4VpClient( - Map.of("vctypes_TestTypeA", Format.LDP_VC.toString(), "vctypes_TestTypeB", - Format.LDP_VC.toString()))), - new ExpectedResult<>( - Set.of(getCredential("TestTypeA", Format.LDP_VC), - getCredential("TestTypeB", Format.LDP_VC), - getCredential("AnotherType", Format.LDP_VC), - getCredential("AndAnother", Format.LDP_VC)), - "The list of configured types should be returned.")) - ); - } - - protected static SupportedCredential getCredential(String type, Format format) { - var cred = new SupportedCredential(); - cred.setTypes(List.of(type)); - cred.setFormat(format); - return cred; - } - - private static UserModel getUserModel(String email, String firstName, String lastName) { - UserModel userModel = mock(UserModel.class); - when(userModel.getEmail()).thenReturn(email); - when(userModel.getFirstName()).thenReturn(firstName); - when(userModel.getLastName()).thenReturn(lastName); - // use answer to allow multiple invocations - when(userModel.getAttributeStream(eq("firstName"))).then(f -> Stream.of(firstName)); - when(userModel.getAttributeStream(eq("familyName"))).then(f -> Stream.of(lastName)); - when(userModel.getAttributeStream(eq("email"))).then(f -> Stream.of(email)); - return userModel; - } - - private static RoleModel getRoleModel(String name) { - RoleModel roleModel = mock(RoleModel.class); - when(roleModel.getName()).thenReturn(name); - return roleModel; - } - - private static ClientModel getOidcClient() { - ClientModel clientA = mock(ClientModel.class); - when(clientA.getProtocol()).thenReturn("OIDC"); - return clientA; - } - - private static ClientModel getNullClient() { - ClientModel clientA = mock(ClientModel.class); - when(clientA.getProtocol()).thenReturn(null); - return clientA; - } - - private static ClientModel getOidc4VpClient(String clientId, Map<String, String> attributes, List<String> roles, - Map<String, String> additionalClaims) { - Stream<RoleModel> roleModelStream = roles.stream().map(role -> { - RoleModel roleModel = mock(RoleModel.class); - when(roleModel.getName()).thenReturn(role); - return roleModel; - }); - List<ProtocolMapperModel> mapperModels = new ArrayList<>(); - ProtocolMapperModel idMapperModel = mock(ProtocolMapperModel.class); - when(idMapperModel.getProtocolMapper()).thenReturn(OIDC4VPSubjectIdMapper.MAPPER_ID); - when(idMapperModel.getProtocol()).thenReturn(OIDC4VPClientRegistrationProviderFactory.PROTOCOL_ID); - when(idMapperModel.getConfig()).thenReturn(Map.of(OIDC4VPSubjectIdMapper.ID_KEY, "urn:uuid:dummy-id")); - mapperModels.add(idMapperModel); - - if (clientId != null) { - ProtocolMapperModel roleMapperModel = mock(ProtocolMapperModel.class); - when(roleMapperModel.getProtocol()).thenReturn(OIDC4VPClientRegistrationProviderFactory.PROTOCOL_ID); - when(roleMapperModel.getProtocolMapper()).thenReturn(OIDC4VPTargetRoleMapper.MAPPER_ID); - when(roleMapperModel.getConfig()).thenReturn( - Map.of(OIDC4VPTargetRoleMapper.SUBJECT_PROPERTY_CONFIG_KEY, "roles", - OIDC4VPTargetRoleMapper.CLIENT_CONFIG_KEY, clientId)); - mapperModels.add(roleMapperModel); - } - - ProtocolMapperModel familyNameMapper = mock(ProtocolMapperModel.class); - when(familyNameMapper.getProtocolMapper()).thenReturn(OIDC4VPUserAttributeMapper.MAPPER_ID); - when(familyNameMapper.getProtocol()).thenReturn(OIDC4VPClientRegistrationProviderFactory.PROTOCOL_ID); - when(familyNameMapper.getConfig()).thenReturn( - Map.of(OIDC4VPUserAttributeMapper.USER_ATTRIBUTE_KEY, "familyName", - OIDC4VPUserAttributeMapper.SUBJECT_PROPERTY_CONFIG_KEY, "familyName", - OIDC4VPUserAttributeMapper.AGGREGATE_ATTRIBUTES_KEY, "false")); - mapperModels.add(familyNameMapper); - - ProtocolMapperModel firstNameMapper = mock(ProtocolMapperModel.class); - when(firstNameMapper.getProtocolMapper()).thenReturn(OIDC4VPUserAttributeMapper.MAPPER_ID); - when(firstNameMapper.getProtocol()).thenReturn(OIDC4VPClientRegistrationProviderFactory.PROTOCOL_ID); - when(firstNameMapper.getConfig()).thenReturn(Map.of(OIDC4VPUserAttributeMapper.USER_ATTRIBUTE_KEY, "firstName", - OIDC4VPUserAttributeMapper.SUBJECT_PROPERTY_CONFIG_KEY, "firstName", - OIDC4VPUserAttributeMapper.AGGREGATE_ATTRIBUTES_KEY, "false")); - mapperModels.add(firstNameMapper); - - ProtocolMapperModel emailMapper = mock(ProtocolMapperModel.class); - when(emailMapper.getProtocolMapper()).thenReturn(OIDC4VPUserAttributeMapper.MAPPER_ID); - when(emailMapper.getProtocol()).thenReturn(OIDC4VPClientRegistrationProviderFactory.PROTOCOL_ID); - when(emailMapper.getConfig()).thenReturn(Map.of(OIDC4VPUserAttributeMapper.USER_ATTRIBUTE_KEY, "email", - OIDC4VPUserAttributeMapper.SUBJECT_PROPERTY_CONFIG_KEY, "email", - OIDC4VPUserAttributeMapper.AGGREGATE_ATTRIBUTES_KEY, "false")); - mapperModels.add(emailMapper); - - additionalClaims.entrySet().forEach(entry -> { - ProtocolMapperModel claimMapper = mock(ProtocolMapperModel.class); - when(claimMapper.getProtocolMapper()).thenReturn(OIDC4VPStaticClaimMapper.MAPPER_ID); - when(claimMapper.getProtocol()).thenReturn(OIDC4VPClientRegistrationProviderFactory.PROTOCOL_ID); - when(claimMapper.getConfig()).thenReturn(Map.of(OIDC4VPStaticClaimMapper.STATIC_CLAIM_KEY, entry.getValue(), - OIDC4VPStaticClaimMapper.SUBJECT_PROPERTY_CONFIG_KEY, entry.getKey())); - mapperModels.add(claimMapper); - }); - - ClientModel clientA = mock(ClientModel.class); - when(clientA.getProtocol()).thenReturn(OIDC4VPClientRegistrationProviderFactory.PROTOCOL_ID); - when(clientA.getClientId()).thenReturn(clientId); - when(clientA.getAttributes()).thenReturn(attributes); - when(clientA.getProtocolMappersStream()).thenReturn(mapperModels.stream()); - when(clientA.getRolesStream()).thenReturn(roleModelStream); - return clientA; - } - - private static ClientModel getOidc4VpClient(String clientId, Map<String, String> attributes, List<String> roles) { - return getOidc4VpClient(clientId, attributes, roles, Map.of()); - } - - private static ClientModel getOidc4VpClient(Map<String, String> attributes) { - return getOidc4VpClient(null, attributes, List.of()); - } + private static final String ISSUER_DID = "did:key:test"; + + private final ObjectMapper OBJECT_MAPPER = JsonMapper.builder() + .configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true) + .configure(MapperFeature.SORT_PROPERTIES_ALPHABETICALLY, true).build(); + + private KeycloakSession keycloakSession; + private AppAuthManager.BearerTokenAuthenticator bearerTokenAuthenticator; + + private OIDC4VPIssuerEndpoint testEndpoint; + + private Clock fixedClock = Clock.fixed(Instant.parse("2022-11-10T17:11:09.00Z"), + ZoneId.of("Europe/Paris")); + + @BeforeEach + public void setUp() throws NoSuchFieldException { + URL url = getClass().getClassLoader().getResource("eckey.tls"); + + Security.addProvider(new BouncyCastleProvider()); + this.keycloakSession = mock(KeycloakSession.class); + this.bearerTokenAuthenticator = mock(AppAuthManager.BearerTokenAuthenticator.class); + this.testEndpoint = new OIDC4VPIssuerEndpoint(keycloakSession, ISSUER_DID, url.getPath(), + Optional.of("Ed25519"), + Optional.of("Ed25519Signature2018"), + bearerTokenAuthenticator, new ObjectMapper(), fixedClock); + } + + @Test + public void testGetVCUnauthorized() { + KeycloakContext context = mock(KeycloakContext.class); + RealmModel realmModel = mock(RealmModel.class); + when(keycloakSession.getContext()).thenReturn(context); + when(context.getRealm()).thenReturn(realmModel); + + when(bearerTokenAuthenticator.authenticate()).thenReturn(null); + + try { + testEndpoint.issueVerifiableCredential(ISSUER_DID, "MyVC"); + fail("VCs should only be accessible for authorized users."); + } catch (WebApplicationException e) { + assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), e.getResponse().getStatus(), + "The response should be a 400."); + ErrorResponse er = OBJECT_MAPPER.convertValue(e.getResponse().getEntity(), ErrorResponse.class); + assertEquals(ErrorType.INVALID_TOKEN.getValue(), er.getError().value(), + "The response should have been denied because of the invalid token."); + } + } + + @ParameterizedTest + @MethodSource("provideTypesAndClients") + public void testGetVCNoSuchType(Stream<ClientModel> clientModelStream, + ExpectedResult<Set<SupportedCredential>> ignored) { + AuthenticationManager.AuthResult authResult = mock(AuthenticationManager.AuthResult.class); + UserModel userModel = mock(UserModel.class); + KeycloakContext context = mock(KeycloakContext.class); + RealmModel realmModel = mock(RealmModel.class); + ClientProvider clientProvider = mock(ClientProvider.class); + + when(bearerTokenAuthenticator.authenticate()).thenReturn(authResult); + when(authResult.getUser()).thenReturn(userModel); + when(keycloakSession.getContext()).thenReturn(context); + when(context.getRealm()).thenReturn(realmModel); + when(keycloakSession.clients()).thenReturn(clientProvider); + when(clientProvider.getClientsStream(any())).thenReturn(clientModelStream); + + try { + testEndpoint.issueVerifiableCredential(ISSUER_DID, "MyNonExistentType"); + fail("Not found types should be a 400"); + } catch (WebApplicationException e) { + assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), e.getResponse().getStatus(), + "Not found types should be a 400"); + ErrorResponse er = OBJECT_MAPPER.convertValue(e.getResponse().getEntity(), ErrorResponse.class); + assertEquals(ErrorType.UNSUPPORTED_CREDENTIAL_TYPE.getValue(), er.getError().value(), + "The response should have been denied because of the unsupported type."); + } + } + + @ParameterizedTest + @MethodSource("provideUserAndClients") + public void testGetCredential(UserModel userModel, Stream<ClientModel> clientModelStream, + Map<ClientModel, Stream<RoleModel>> roleModelStreamMap, + ExpectedResult<CredentialSubject> expectedResult, Format requestedFormat) + throws JsonProcessingException, VerificationException { + List<ClientModel> clientModels = clientModelStream.toList(); + + AuthenticationManager.AuthResult authResult = mock(AuthenticationManager.AuthResult.class); + KeycloakContext context = mock(KeycloakContext.class); + RealmModel realmModel = mock(RealmModel.class); + ClientProvider clientProvider = mock(ClientProvider.class); + + UserSessionModel userSessionModel = mock(UserSessionModel.class); + when(userSessionModel.getRealm()).thenReturn(realmModel); + when(userSessionModel.getUser()).thenReturn(userModel); + clientModels.forEach(cm -> when(realmModel.getClientByClientId(eq(cm.getClientId()))).thenReturn(cm)); + when(realmModel.getClientsStream()).thenReturn(clientModels.stream()); + + when(bearerTokenAuthenticator.authenticate()).thenReturn(authResult); + + when(authResult.getUser()).thenReturn(userModel); + when(authResult.getSession()).thenReturn(userSessionModel); + + when(keycloakSession.getContext()).thenReturn(context); + when(context.getRealm()).thenReturn(realmModel); + + when(keycloakSession.clients()).thenReturn(clientProvider); + when(clientProvider.getClientsStream(any())).thenReturn(clientModels.stream()); + + when(userModel.getClientRoleMappingsStream(any())).thenAnswer(i -> roleModelStreamMap.get(i.getArguments()[0])); + Object credential = testEndpoint.getCredential("MyType", requestedFormat); + switch (requestedFormat) { + case LDP_VC -> { + VerifiableCredential verifiableCredential = OBJECT_MAPPER.convertValue(credential, VerifiableCredential.class); + verifyLDCredential(expectedResult, verifiableCredential); + } + case JWT_VC_JSON_LD, JWT_VC, JWT_VC_JSON -> verifyJWTCredential(expectedResult, (String) credential); + } + } + + private void verifyJWTCredential(ExpectedResult<CredentialSubject> expectedResult, String actualResult) + throws VerificationException, JsonProcessingException { + TokenVerifier<JsonWebToken> verifier = TokenVerifier.create(actualResult, JsonWebToken.class); + JsonWebToken theJWT = verifier.getToken(); + assertEquals(ISSUER_DID, theJWT.getIssuer(), "The issuer should be properly set."); + assertNotNull(theJWT.getSubject(), "A subject should be set."); + assertNotNull(theJWT.getId(), "The jwt should have an id."); + + VerifiableCredential theVC = (VerifiableCredential) theJWT.getOtherClaims().get("vc"); + assertNotNull(theVC, "The vc should be part of the jwt."); + List credentialType = (List) theVC.getType(); + assertEquals(2, credentialType.size(), "Both types should be included."); + assertTrue(credentialType.contains("MyType") && credentialType.contains("VerifiableCredential"), + "The correct types should be included."); + + + verifySubject(expectedResult, expectedResult.getExpectedResult(), theVC.getCredentialSubject()); + + } + + @ParameterizedTest + @MethodSource("provideUserAndClientsLDP") + public void testGetVC(UserModel userModel, Stream<ClientModel> clientModelStream, + Map<ClientModel, Stream<RoleModel>> roleModelStreamMap, + ExpectedResult<CredentialSubject> expectedResult) throws JsonProcessingException { + List<ClientModel> clientModels = clientModelStream.toList(); + + AuthenticationManager.AuthResult authResult = mock(AuthenticationManager.AuthResult.class); + KeycloakContext context = mock(KeycloakContext.class); + RealmModel realmModel = mock(RealmModel.class); + ClientProvider clientProvider = mock(ClientProvider.class); + UserSessionModel userSessionModel = mock(UserSessionModel.class); + when(userSessionModel.getRealm()).thenReturn(realmModel); + when(userSessionModel.getUser()).thenReturn(userModel); + clientModels.forEach(cm -> when(realmModel.getClientByClientId(eq(cm.getClientId()))).thenReturn(cm)); + + when(bearerTokenAuthenticator.authenticate()).thenReturn(authResult); + when(authResult.getUser()).thenReturn(userModel); + when(authResult.getSession()).thenReturn(userSessionModel); + when(keycloakSession.getContext()).thenReturn(context); + when(context.getRealm()).thenReturn(realmModel); + when(keycloakSession.clients()).thenReturn(clientProvider); + // use then to open a new stream on each invocation + when(clientProvider.getClientsStream(any())).then(f -> clientModels.stream()); + + when(userModel.getClientRoleMappingsStream(any())).thenAnswer(i -> roleModelStreamMap.get(i.getArguments()[0])); + + VerifiableCredential credentialVO = OBJECT_MAPPER.convertValue( + testEndpoint.issueVerifiableCredential("MyType", "myToken").getEntity(), + VerifiableCredential.class); + + verifyLDCredential(expectedResult, credentialVO); + } + + private void verifyLDCredential(ExpectedResult<CredentialSubject> expectedResult, VerifiableCredential credentialVO) + throws JsonProcessingException { + assertEquals(Date.from(fixedClock.instant()), credentialVO.getIssuanceDate(), + "The issuance date should be correctly set."); + assertNotNull(credentialVO.getContext(), "The context should be set on an ld-credential."); + assertNotNull(credentialVO.getProof(), "The proof should be included."); + assertNotNull(credentialVO.getId(), "The credential should have an id."); + List credentialType = (List) credentialVO.getType(); + assertEquals(2, credentialType.size(), "Both types should be included."); + assertTrue(credentialType.contains("MyType") && credentialType.contains("VerifiableCredential"), + "The correct types should be included."); + + assertEquals(ISSUER_DID, credentialVO.getIssuer().toString(), "The correct issuer should be set."); + + CredentialSubject retrievedSubject = credentialVO.getCredentialSubject(); + assertNotNull(retrievedSubject.getId(), "The id should have been set."); + // remove the id, since its randomly generated. + retrievedSubject.setId(null); + + verifySubject(expectedResult, expectedResult.getExpectedResult(), retrievedSubject); + } + + private void verifySubject(ExpectedResult<CredentialSubject> expectedResult, CredentialSubject expectedCredentialSubject, CredentialSubject retrievedSubject) + throws JsonProcessingException { + verifyRoles(expectedResult.getMessage(), expectedCredentialSubject, retrievedSubject); + // roles are checked, can be removed to not interfer with next checks. + expectedCredentialSubject.setClaims("roles", null); + retrievedSubject.setClaims("roles", null); + + String expectedJson = OBJECT_MAPPER.writeValueAsString(expectedCredentialSubject); + String retrievedJson = OBJECT_MAPPER.writeValueAsString(retrievedSubject); + // we compare the json, to prevent order issues. + assertEquals(expectedJson, retrievedJson, expectedResult.getMessage()); + } + + private void verifyRoles(String message, CredentialSubject expectedCredentialSubject, CredentialSubject retrievedSubject) { + Set<Role> retrievedRoles = OBJECT_MAPPER.convertValue(retrievedSubject.getClaims().get("roles"), + new TypeReference<Set<Role>>() { + }); + Set<Role> expectedRoles = OBJECT_MAPPER.convertValue(expectedCredentialSubject.getClaims().get("roles"), + new TypeReference<Set<Role>>() { + }); + assertEquals(expectedRoles, retrievedRoles, message); + } + + private static Arguments getArguments(UserModel um, Map<ClientModel, List<RoleModel>> clients, + ExpectedResult expectedResult) { + return Arguments.of(um, + clients.keySet().stream(), + clients.entrySet() + .stream() + .filter(e -> e.getValue() != null) + .collect( + Collectors.toMap(Map.Entry::getKey, e -> ((List) e.getValue()).stream(), + (e1, e2) -> e1)), + expectedResult); + } + + private static Stream<Arguments> provideUserAndClients() { + return Stream.concat(provideUserAndClientsLDP().map(a -> { + var argObjects = new ArrayList<>(Arrays.asList(a.get())); + argObjects.add(Format.LDP_VC); + return Arguments.of(argObjects.toArray()); + }), + provideUserAndClientsJWT().map(a -> { + var argObjects = new ArrayList<>(Arrays.asList(a.get())); + argObjects.add(Format.JWT_VC); + return Arguments.of(argObjects.toArray()); + })); + } + + private static CredentialSubject getCredentialSubject(Map<String, Object> claims) { + CredentialSubject credentialSubject = new CredentialSubject(); + claims.entrySet().stream().forEach(e -> credentialSubject.setClaims(e.getKey(), e.getValue())); + return credentialSubject; + } + + private static Stream<Arguments> provideUserAndClientsJWT() { + return Stream.of( + getArguments(getUserModel("e@mail.org", "Happy", "User"), + Map.of(getOidc4VpClient("did:key:1", + Map.of("vctypes_MyType", Format.JWT_VC.toString()), + List.of("MyRole"), + List.of("MyType", "VerifiableCredential")), + List.of(getRoleModel("MyRole"))), + new ExpectedResult<>( + getCredentialSubject( + Map.of("email", "e@mail.org", "familyName", "User", "firstName", "Happy", "roles", + Set.of(new Role(Set.of("MyRole"), "did:key:1")))), + "A valid Credential should have been returned.") + ), + getArguments(getUserModel("e@mail.org", null, "User"), + Map.of(getOidc4VpClient("did:key:1", + Map.of("vctypes_MyType", Format.JWT_VC.toString()), + List.of("MyRole"), + List.of("MyType", "VerifiableCredential")), + List.of(getRoleModel("MyRole"))), + new ExpectedResult<>( + getCredentialSubject( + Map.of("email", "e@mail.org", "familyName", "User", "roles", + Set.of(new Role(Set.of("MyRole"), "did:key:1")))), + "A valid Credential should have been returned.") + ), + getArguments( + getUserModel("e@mail.org", null, null), + Map.of(getOidc4VpClient("did:key:1", + Map.of("vctypes_MyType", Format.JWT_VC.toString()), + List.of("MyRole"), + List.of("MyType", "VerifiableCredential")), + List.of(getRoleModel("MyRole"))), + new ExpectedResult<>( + getCredentialSubject( + Map.of("email", "e@mail.org", "roles", + Set.of(new Role(Set.of("MyRole"), "did:key:1")))), + "A valid Credential should have been returned.") + ), + getArguments( + getUserModel(null, null, null), + Map.of(getOidc4VpClient("did:key:1", + Map.of("vctypes_MyType", Format.JWT_VC.toString()), + List.of("MyRole"), + List.of("MyType", "VerifiableCredential")), + List.of(getRoleModel("MyRole"))), + new ExpectedResult<>( + getCredentialSubject( + Map.of("roles", + Set.of(new Role(Set.of("MyRole"), "did:key:1")))), + "A valid Credential should have been returned.") + ), + getArguments( + getUserModel(null, null, null), + Map.of(getOidc4VpClient("did:key:1", + Map.of("vctypes_MyType", Format.JWT_VC.toString()), + List.of("MyRole", "MySecondRole"), + List.of("MyType", "VerifiableCredential")), + List.of(getRoleModel("MyRole"), getRoleModel("MySecondRole"))), + new ExpectedResult<>( + getCredentialSubject( + Map.of("roles", + Set.of(new Role(Set.of("MyRole", "MySecondRole"), "did:key:1")))), + "Multiple roles should be included") + ), + getArguments( + getUserModel(null, null, null), + Map.of(getOidc4VpClient("did:key:1", + Map.of("vctypes_MyType", Format.JWT_VC.toString()), + List.of("MyRole", "MySecondRole"), + List.of("MyType", "VerifiableCredential")), + List.of(getRoleModel("MyRole"))), + new ExpectedResult<>( + getCredentialSubject( + Map.of("roles", + Set.of(new Role(Set.of("MyRole"), "did:key:1")))), + "Only assigned roles should be included.") + ), + getArguments( + getUserModel(null, null, null), + Map.of(getOidc4VpClient("did:key:1", + Map.of("vctypes_MyType", Format.JWT_VC.toString()), + List.of("MyRole", "MySecondRole"), + List.of("MyType", "VerifiableCredential")), + List.of(getRoleModel("MyRole"), getRoleModel("MySecondRole")), + getOidc4VpClient("did:key:2", + Map.of("vctypes_MyType", Format.JWT_VC.toString()), + List.of("AnotherRole"), + List.of("MyType", "VerifiableCredential")), + List.of(getRoleModel("AnotherRole"))), + new ExpectedResult<>( + getCredentialSubject( + Map.of("roles", + Set.of(new Role(Set.of("MyRole", "MySecondRole"), "did:key:1"), + new Role(Set.of("AnotherRole"), "did:key:2")))), + "The request should contain roles from both clients") + ), + getArguments( + getUserModel(null, null, null), + Map.of(getOidc4VpClient("did:key:1", + Map.of("vctypes_MyType", Format.JWT_VC.toString()), + List.of("MyRole", "MySecondRole"), + List.of("MyType", "VerifiableCredential")), + List.of(getRoleModel("MyRole"), getRoleModel("MySecondRole")), + getOidc4VpClient("did:key:2", + Map.of("vctypes_AnotherType", Format.JWT_VC.toString()), + List.of("AnotherRole"), + List.of("AnotherType", "VerifiableCredential")), + List.of(getRoleModel("AnotherRole"))), + new ExpectedResult<>( + getCredentialSubject( + Map.of("roles", + Set.of(new Role(Set.of("MyRole", "MySecondRole"), "did:key:1")))), + "Only roles for supported clients should be included.") + ), + getArguments( + getUserModel(null, null, null), + Map.of(getOidc4VpClient("did:key:1", + Map.of("vctypes_MyType", Format.JWT_VC.toString()), + List.of("MyRole", "MySecondRole"), + Map.of("more", "claims"), + List.of("MyType", "VerifiableCredential")), + List.of(getRoleModel("MyRole"), getRoleModel("MySecondRole")), + getOidc4VpClient("did:key:2", + Map.of("vctypes_MyType", Format.JWT_VC.toString()), + List.of("AnotherRole"), + Map.of("additional", "claim"), + List.of("MyType", "VerifiableCredential")), + List.of(getRoleModel("AnotherRole"))), + new ExpectedResult<>( + getCredentialSubject( + Map.of("roles", + Set.of(new Role(Set.of("MyRole", "MySecondRole"), "did:key:1"), + new Role(Set.of("AnotherRole"), "did:key:2")), + "additional", "claim", "more", "claims")), + "Additional claims should be included.") + ), + getArguments( + getUserModel(null, null, null), + Map.of(getOidc4VpClient("did:key:1", + Map.of("vctypes_MyType", Format.JWT_VC.toString()), + List.of("MyRole", "MySecondRole"), + List.of("MyType", "VerifiableCredential")), + List.of(getRoleModel("MyRole"), getRoleModel("MySecondRole")), + getOidc4VpClient("did:key:2", + Map.of("vctypes_MyType", Format.JWT_VC.toString()), + List.of("AnotherRole"), + Map.of("additional", "claim"), + List.of("MyType", "VerifiableCredential")), + List.of(getRoleModel("AnotherRole"))), + new ExpectedResult<>( + getCredentialSubject( + Map.of("additional", "claim", "roles", + Set.of(new Role(Set.of("MyRole", "MySecondRole"), "did:key:1"), + new Role(Set.of("AnotherRole"), "did:key:2")))), + "Additional claims should be included.") + ) + ); + } + + private static Stream<Arguments> provideUserAndClientsLDP() { + return Stream.of( + getArguments(getUserModel("e@mail.org", "Happy", "User"), + Map.of(getOidc4VpClient("did:key:1", + Map.of("vctypes_MyType", Format.LDP_VC.toString()), + List.of("MyRole"), + List.of("MyType", "VerifiableCredential")), + List.of(getRoleModel("MyRole"))), + new ExpectedResult<>( + getCredentialSubject( + Map.of("email", "e@mail.org", "familyName", "User", "firstName", "Happy", "roles", + Set.of(new Role(Set.of("MyRole"), "did:key:1")))), + "A valid Credential should have been returned.") + ), + getArguments(getUserModel("e@mail.org", null, "User"), + Map.of(getOidc4VpClient("did:key:1", + Map.of("vctypes_MyType", Format.LDP_VC.toString()), + List.of("MyRole"), + List.of("MyType", "VerifiableCredential")), + List.of(getRoleModel("MyRole"))), + new ExpectedResult<>( + getCredentialSubject( + Map.of("email", "e@mail.org", "familyName", "User", "roles", + Set.of(new Role(Set.of("MyRole"), "did:key:1")))), + "A valid Credential should have been returned.") + ), + getArguments( + getUserModel("e@mail.org", null, null), + Map.of(getOidc4VpClient("did:key:1", + Map.of("vctypes_MyType", Format.LDP_VC.toString()), + List.of("MyRole"), + List.of("MyType", "VerifiableCredential")), + List.of(getRoleModel("MyRole"))), + new ExpectedResult<>( + getCredentialSubject( + Map.of("email", "e@mail.org", "roles", + Set.of(new Role(Set.of("MyRole"), "did:key:1")))), + "A valid Credential should have been returned.") + ), + getArguments( + getUserModel(null, null, null), + Map.of(getOidc4VpClient("did:key:1", + Map.of("vctypes_MyType", Format.LDP_VC.toString()), + List.of("MyRole"), + List.of("MyType", "VerifiableCredential")), + List.of(getRoleModel("MyRole"))), + new ExpectedResult<>( + getCredentialSubject( + Map.of("roles", + Set.of(new Role(Set.of("MyRole"), "did:key:1")))), + "A valid Credential should have been returned.") + ), + getArguments( + getUserModel(null, null, null), + Map.of(getOidc4VpClient("did:key:1", + Map.of("vctypes_MyType", Format.LDP_VC.toString()), + List.of("MyRole", "MySecondRole"), + List.of("MyType", "VerifiableCredential")), + List.of(getRoleModel("MyRole"), getRoleModel("MySecondRole"))), + new ExpectedResult<>( + getCredentialSubject( + Map.of("roles", + Set.of(new Role(Set.of("MyRole", "MySecondRole"), "did:key:1")))), + "Multiple roles should be included") + ), + getArguments( + getUserModel(null, null, null), + Map.of(getOidc4VpClient("did:key:1", + Map.of("vctypes_MyType", Format.LDP_VC.toString()), + List.of("MyRole", "MySecondRole"), + List.of("MyType", "VerifiableCredential")), + List.of(getRoleModel("MyRole"))), + new ExpectedResult<>( + getCredentialSubject( + Map.of("roles", + Set.of(new Role(Set.of("MyRole"), "did:key:1")))), + "Only assigned roles should be included.") + ), + getArguments( + getUserModel(null, null, null), + Map.of(getOidc4VpClient("did:key:1", + Map.of("vctypes_MyType", Format.LDP_VC.toString()), + List.of("MyRole", "MySecondRole"), + List.of("MyType", "VerifiableCredential")), + List.of(getRoleModel("MyRole"), getRoleModel("MySecondRole")), + getOidc4VpClient("did:key:2", + Map.of("vctypes_MyType", Format.LDP_VC.toString()), + List.of("AnotherRole"), + List.of("MyType", "VerifiableCredential")), + List.of(getRoleModel("AnotherRole"))), + new ExpectedResult<>( + getCredentialSubject( + Map.of("roles", + Set.of(new Role(Set.of("MyRole", "MySecondRole"), "did:key:1"), + new Role(Set.of("AnotherRole"), "did:key:2")))), + "The request should contain roles from both clients") + ), + getArguments( + getUserModel(null, null, null), + Map.of(getOidc4VpClient("did:key:1", + Map.of("vctypes_MyType", Format.LDP_VC.toString()), + List.of("MyRole", "MySecondRole"), + List.of("MyType", "VerifiableCredential")), + List.of(getRoleModel("MyRole"), getRoleModel("MySecondRole")), + getOidc4VpClient("did:key:2", + Map.of("vctypes_AnotherType", Format.LDP_VC.toString()), + List.of("AnotherRole"), + List.of("MyType", "VerifiableCredential")), + List.of(getRoleModel("AnotherRole"))), + new ExpectedResult<>( + getCredentialSubject( + Map.of("roles", + Set.of(new Role(Set.of("MyRole", "MySecondRole"), "did:key:1")))), + "Only roles for supported clients should be included.") + ), + getArguments( + getUserModel(null, null, null), + Map.of(getOidc4VpClient("did:key:1", + Map.of("vctypes_MyType", Format.LDP_VC.toString()), + List.of("MyRole", "MySecondRole"), + Map.of("more", "claims"), + List.of("MyType", "VerifiableCredential")), + List.of(getRoleModel("MyRole"), getRoleModel("MySecondRole")), + getOidc4VpClient("did:key:2", + Map.of("vctypes_MyType", Format.LDP_VC.toString()), + List.of("AnotherRole"), + Map.of("additional", "claim"), + List.of("MyType", "VerifiableCredential")), + List.of(getRoleModel("AnotherRole"))), + new ExpectedResult<>( + getCredentialSubject( + Map.of("roles", + Set.of(new Role(Set.of("MyRole", "MySecondRole"), "did:key:1"), + new Role(Set.of("AnotherRole"), "did:key:2")), + "additional", "claim", "more", "claims")), + "Additional claims should be included.") + ), + getArguments( + getUserModel(null, null, null), + Map.of(getOidc4VpClient("did:key:1", + Map.of("vctypes_MyType", Format.LDP_VC.toString()), + List.of("MyRole", "MySecondRole"), + List.of("MyType", "VerifiableCredential")), + List.of(getRoleModel("MyRole"), getRoleModel("MySecondRole")), + getOidc4VpClient("did:key:2", + Map.of("vctypes_MyType", Format.LDP_VC.toString()), + List.of("AnotherRole"), + Map.of("additional", "claim"), + List.of("MyType", "VerifiableCredential")), + List.of(getRoleModel("AnotherRole"))), + new ExpectedResult<>( + getCredentialSubject( + Map.of("additional", "claim", "roles", + Set.of(new Role(Set.of("MyRole", "MySecondRole"), "did:key:1"), + new Role(Set.of("AnotherRole"), "did:key:2")))), + "Additional claims should be included.") + ) + ); + } + + private static Stream<Arguments> provideTypesAndClients() { + return Stream.of( + Arguments.of(Stream.of(getOidcClient(), getNullClient(), getOidc4VpClient( + Map.of("vctypes_TestType", Format.LDP_VC.toString()), + List.of("TestType", "VerifiableCredential"))), + new ExpectedResult<>(Set.of(getCredential("TestType", Format.LDP_VC)), + "The list of configured types should be returned.")), + Arguments.of(Stream.of(getOidcClient(), getNullClient()), + new ExpectedResult<>(Set.of(), "An empty list should be returned if nothing is configured.")), + Arguments.of(Stream.of(), + new ExpectedResult<>(Set.of(), "An empty list should be returned if nothing is configured.")), + Arguments.of( + Stream.of(getOidc4VpClient(Map.of("vctypes_TestType", Format.LDP_VC.toString(), + "another", "attribute"), + List.of("MyType", "VerifiableCredential"))), + new ExpectedResult<>(Set.of(getCredential("TestType", Format.LDP_VC)), + "The list of configured types should be returned.")), + Arguments.of(Stream.of(getOidc4VpClient( + Map.of("vctypes_TestTypeA", Format.LDP_VC.toString(), "vctypes_TestTypeB", + Format.LDP_VC.toString()), + List.of("MyType", "VerifiableCredential"))), + new ExpectedResult<>( + Set.of(getCredential("TestTypeA", Format.LDP_VC), + getCredential("TestTypeB", Format.LDP_VC)), + "The list of configured types should be returned.")), + Arguments.of(Stream.of( + getOidc4VpClient(Map.of(), null), + getOidc4VpClient( + Map.of("vctypes_TestTypeA", Format.LDP_VC.toString(), "vctypes_TestTypeB", + Format.LDP_VC.toString()), + List.of("TestTypeA", "TestTypeB", "VerifiableCredential"))), + new ExpectedResult<>( + Set.of(getCredential("TestTypeA", Format.LDP_VC), + getCredential("TestTypeB", Format.LDP_VC)), + "The list of configured types should be returned.")), + Arguments.of(Stream.of( + getOidc4VpClient(null, null), + getOidc4VpClient( + Map.of("vctypes_TestTypeA", Format.LDP_VC.toString(), "vctypes_TestTypeB", + Format.LDP_VC.toString()), + List.of("TestTypeA", "TestTypeB", "VerifiableCredential"))), + new ExpectedResult<>( + Set.of(getCredential("TestTypeA", Format.LDP_VC), + getCredential("TestTypeB", Format.LDP_VC)), + "The list of configured types should be returned.")), + Arguments.of(Stream.of( + getOidc4VpClient(Map.of("vctypes_AnotherType", Format.LDP_VC.toString()), + List.of("TestTypeA", "TestTypeB", "AnotherType")), + getOidc4VpClient( + Map.of("vctypes_TestTypeA", Format.LDP_VC.toString(), "vctypes_TestTypeB", + Format.LDP_VC.toString()), + List.of("TestTypeA", "TestTypeB", "VerifiableCredential"))), + new ExpectedResult<>( + Set.of(getCredential("TestTypeA", Format.LDP_VC), + getCredential("TestTypeB", Format.LDP_VC), + getCredential("AnotherType", Format.LDP_VC)), + "The list of configured types should be returned.")), + Arguments.of(Stream.of( + getOidc4VpClient( + Map.of("vctypes_AnotherType", Format.LDP_VC.toString(), "vctypes_AndAnother", + Format.LDP_VC.toString()), + List.of("AnotherType", "AndAnother", "VerfiableCredential")), + getOidc4VpClient( + Map.of("vctypes_TestTypeA", Format.LDP_VC.toString(), "vctypes_TestTypeB", + Format.LDP_VC.toString()), List.of("AnotherType", "AndAnother", "VerfiableCredential")) + ), + new ExpectedResult<>( + Set.of(getCredential("TestTypeA", Format.LDP_VC), + getCredential("TestTypeB", Format.LDP_VC), + getCredential("AnotherType", Format.LDP_VC), + getCredential("AndAnother", Format.LDP_VC)), + "The list of configured types should be returned.")) + ); + } + + protected static SupportedCredential getCredential(String type, Format format) { + var cred = new SupportedCredential(); + cred.setTypes(List.of(type)); + cred.setFormat(format); + return cred; + } + + private static UserModel getUserModel(String email, String firstName, String lastName) { + UserModel userModel = mock(UserModel.class); + when(userModel.getEmail()).thenReturn(email); + when(userModel.getFirstName()).thenReturn(firstName); + when(userModel.getLastName()).thenReturn(lastName); + // use answer to allow multiple invocations + when(userModel.getAttributeStream(eq("firstName"))).then(f -> Stream.of(firstName)); + when(userModel.getAttributeStream(eq("familyName"))).then(f -> Stream.of(lastName)); + when(userModel.getAttributeStream(eq("email"))).then(f -> Stream.of(email)); + return userModel; + } + + private static RoleModel getRoleModel(String name) { + RoleModel roleModel = mock(RoleModel.class); + when(roleModel.getName()).thenReturn(name); + return roleModel; + } + + private static ClientModel getOidcClient() { + ClientModel clientA = mock(ClientModel.class); + when(clientA.getProtocol()).thenReturn("OIDC"); + return clientA; + } + + private static ClientModel getNullClient() { + ClientModel clientA = mock(ClientModel.class); + when(clientA.getProtocol()).thenReturn(null); + return clientA; + } + + private static ClientModel getOidc4VpClient(String clientId, Map<String, String> attributes, List<String> roles, + Map<String, String> additionalClaims, List<String> types) { + Stream<RoleModel> roleModelStream = roles.stream().map(role -> { + RoleModel roleModel = mock(RoleModel.class); + when(roleModel.getName()).thenReturn(role); + return roleModel; + }); + List<ProtocolMapperModel> mapperModels = new ArrayList<>(); + ProtocolMapperModel idMapperModel = mock(ProtocolMapperModel.class); + when(idMapperModel.getProtocolMapper()).thenReturn(OIDC4VPSubjectIdMapper.MAPPER_ID); + when(idMapperModel.getProtocol()).thenReturn(OIDC4VPClientRegistrationProviderFactory.PROTOCOL_ID); + when(idMapperModel.getConfig()).thenReturn(Map.of(OIDC4VPSubjectIdMapper.ID_KEY, "urn:uuid:dummy-id")); + mapperModels.add(idMapperModel); + + if (clientId != null) { + ProtocolMapperModel roleMapperModel = mock(ProtocolMapperModel.class); + when(roleMapperModel.getProtocol()).thenReturn(OIDC4VPClientRegistrationProviderFactory.PROTOCOL_ID); + when(roleMapperModel.getProtocolMapper()).thenReturn(OIDC4VPTargetRoleMapper.MAPPER_ID); + when(roleMapperModel.getConfig()).thenReturn( + Map.of(OIDC4VPTargetRoleMapper.SUBJECT_PROPERTY_CONFIG_KEY, "roles", + OIDC4VPTargetRoleMapper.CLIENT_CONFIG_KEY, clientId)); + mapperModels.add(roleMapperModel); + } + + if (types != null) { + types.forEach(t -> { + ProtocolMapperModel typeMapper = mock(ProtocolMapperModel.class); + when(typeMapper.getProtocolMapper()).thenReturn(OIDC4VPTypeMapper.MAPPER_ID); + when(typeMapper.getProtocol()).thenReturn(OIDC4VPClientRegistrationProviderFactory.PROTOCOL_ID); + when(typeMapper.getConfig()).thenReturn( + Map.of(OIDC4VPTypeMapper.TYPE_KEY, t)); + mapperModels.add(typeMapper); + }); + } + ProtocolMapperModel familyNameMapper = mock(ProtocolMapperModel.class); + when(familyNameMapper.getProtocolMapper()).thenReturn(OIDC4VPUserAttributeMapper.MAPPER_ID); + when(familyNameMapper.getProtocol()).thenReturn(OIDC4VPClientRegistrationProviderFactory.PROTOCOL_ID); + when(familyNameMapper.getConfig()).thenReturn( + Map.of(OIDC4VPUserAttributeMapper.USER_ATTRIBUTE_KEY, "familyName", + OIDC4VPUserAttributeMapper.SUBJECT_PROPERTY_CONFIG_KEY, "familyName", + OIDC4VPUserAttributeMapper.AGGREGATE_ATTRIBUTES_KEY, "false")); + mapperModels.add(familyNameMapper); + + ProtocolMapperModel firstNameMapper = mock(ProtocolMapperModel.class); + when(firstNameMapper.getProtocolMapper()).thenReturn(OIDC4VPUserAttributeMapper.MAPPER_ID); + when(firstNameMapper.getProtocol()).thenReturn(OIDC4VPClientRegistrationProviderFactory.PROTOCOL_ID); + when(firstNameMapper.getConfig()).thenReturn(Map.of(OIDC4VPUserAttributeMapper.USER_ATTRIBUTE_KEY, "firstName", + OIDC4VPUserAttributeMapper.SUBJECT_PROPERTY_CONFIG_KEY, "firstName", + OIDC4VPUserAttributeMapper.AGGREGATE_ATTRIBUTES_KEY, "false")); + mapperModels.add(firstNameMapper); + + ProtocolMapperModel emailMapper = mock(ProtocolMapperModel.class); + when(emailMapper.getProtocolMapper()).thenReturn(OIDC4VPUserAttributeMapper.MAPPER_ID); + when(emailMapper.getProtocol()).thenReturn(OIDC4VPClientRegistrationProviderFactory.PROTOCOL_ID); + when(emailMapper.getConfig()).thenReturn(Map.of(OIDC4VPUserAttributeMapper.USER_ATTRIBUTE_KEY, "email", + OIDC4VPUserAttributeMapper.SUBJECT_PROPERTY_CONFIG_KEY, "email", + OIDC4VPUserAttributeMapper.AGGREGATE_ATTRIBUTES_KEY, "false")); + mapperModels.add(emailMapper); + + additionalClaims.entrySet().forEach(entry -> { + ProtocolMapperModel claimMapper = mock(ProtocolMapperModel.class); + when(claimMapper.getProtocolMapper()).thenReturn(OIDC4VPStaticClaimMapper.MAPPER_ID); + when(claimMapper.getProtocol()).thenReturn(OIDC4VPClientRegistrationProviderFactory.PROTOCOL_ID); + when(claimMapper.getConfig()).thenReturn(Map.of(OIDC4VPStaticClaimMapper.STATIC_CLAIM_KEY, entry.getValue(), + OIDC4VPStaticClaimMapper.SUBJECT_PROPERTY_CONFIG_KEY, entry.getKey())); + mapperModels.add(claimMapper); + }); + + ClientModel clientA = mock(ClientModel.class); + when(clientA.getProtocol()).thenReturn(OIDC4VPClientRegistrationProviderFactory.PROTOCOL_ID); + when(clientA.getClientId()).thenReturn(clientId); + when(clientA.getAttributes()).thenReturn(attributes); + when(clientA.getProtocolMappersStream()).thenReturn(mapperModels.stream()); + when(clientA.getRolesStream()).thenReturn(roleModelStream); + return clientA; + } + + private static ClientModel getOidc4VpClient(String clientId, Map<String, String> attributes, List<String> roles, List<String> types) { + return getOidc4VpClient(clientId, attributes, roles, Map.of(), types); + } + + private static ClientModel getOidc4VpClient(Map<String, String> attributes, List<String> types) { + return getOidc4VpClient(null, attributes, List.of(), types); + } } \ No newline at end of file diff --git a/services/src/test/java/org/keycloak/protocol/oidc4vp/signing/JWTSigningServiceTest.java b/services/src/test/java/org/keycloak/protocol/oidc4vp/signing/JWTSigningServiceTest.java new file mode 100644 index 000000000000..26b679b4a30a --- /dev/null +++ b/services/src/test/java/org/keycloak/protocol/oidc4vp/signing/JWTSigningServiceTest.java @@ -0,0 +1,89 @@ +package org.keycloak.protocol.oidc4vp.signing; + +import org.bouncycastle.crypto.util.PrivateKeyInfoFactory; +import org.bouncycastle.openssl.jcajce.JcaPEMWriter; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.BeforeAll; +import org.keycloak.TokenVerifier; +import org.keycloak.common.VerificationException; +import org.keycloak.crypto.Algorithm; +import org.keycloak.representations.JsonWebToken; +import org.keycloak.util.TokenUtil; + +import java.io.IOException; +import java.io.StringWriter; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneId; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; + +class JWTSigningServiceTest extends SigningServiceTest { + private static final String CONTEXT_URL = "https://www.w3.org/2018/credentials/v1"; + + @BeforeAll + public static void setup() { + + } + + @Test + public void test() { + RSAKeyLoader keyLoader = new RSAKeyLoader(); + JWTSigningService jwtSigningService = new JWTSigningService( + keyLoader, + Optional.of("my-key-id"), + Clock.fixed(Instant.ofEpochSecond(1000), ZoneId.of("UTC")), + Algorithm.RS256); + + var testCredential = getTestCredential(); + + String jwtCredential = jwtSigningService.signCredential(testCredential); + var verifier = TokenVerifier.create(jwtCredential, JsonWebToken.class); + verifier.publicKey(keyLoader.getKeyPair().getPublic()); + try { + verifier.verify(); + } catch (VerificationException e) { + fail("The credential should successfully be verified.", e); + } + } + + + class RSAKeyLoader implements KeyLoader { + + private KeyPair keyPair; + + public KeyPair getKeyPair() { + return keyPair; + } + + public RSAKeyLoader() { + try { + KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); + kpg.initialize(2048); + keyPair = kpg.generateKeyPair(); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException(e); + } + } + + @Override + public String loadKey() { + + StringWriter stringWriter = new StringWriter(); + JcaPEMWriter pemWriter = new JcaPEMWriter(stringWriter); + try { + pemWriter.writeObject(keyPair); + pemWriter.flush(); + pemWriter.close(); + return stringWriter.toString(); + } catch (IOException e) { + throw new RuntimeException(e); + } + + } + } +} \ No newline at end of file diff --git a/services/src/test/java/org/keycloak/protocol/oidc4vp/signing/LDSigningServiceTest.java b/services/src/test/java/org/keycloak/protocol/oidc4vp/signing/LDSigningServiceTest.java new file mode 100644 index 000000000000..22d944e52e58 --- /dev/null +++ b/services/src/test/java/org/keycloak/protocol/oidc4vp/signing/LDSigningServiceTest.java @@ -0,0 +1,109 @@ +package org.keycloak.protocol.oidc4vp.signing; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.util.StdDateFormat; +import org.bouncycastle.crypto.generators.Ed25519KeyPairGenerator; +import org.bouncycastle.crypto.params.AsymmetricKeyParameter; +import org.bouncycastle.crypto.params.Ed25519KeyGenerationParameters; +import org.bouncycastle.crypto.signers.Ed25519Signer; +import org.bouncycastle.crypto.util.PrivateKeyInfoFactory; +import org.bouncycastle.openssl.jcajce.JcaPEMWriter; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.keycloak.common.util.Base64; +import org.keycloak.protocol.oidc4vp.model.VerifiableCredential; +import org.keycloak.protocol.oidc4vp.signing.signatures.Ed255192018Suite; +import org.keycloak.protocol.oidc4vp.signing.signatures.SecuritySuite; + +import java.io.IOException; +import java.io.StringWriter; +import java.security.SecureRandom; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneId; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class LDSigningServiceTest extends SigningServiceTest { + private ObjectMapper objectMapper; + + @BeforeAll + public static void setup() { + + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + objectMapper.setDateFormat(new StdDateFormat().withColonInTimeZone(true)); + } + + @Test + public void testEd25519Signature() throws IOException { + Ed25519TestKeyLoader testKeyLoader = new Ed25519TestKeyLoader(); + LDSigningService ldSigningService = new LDSigningService( + testKeyLoader, + Optional.of("my-key-id"), + Clock.fixed(Instant.ofEpochSecond(1000), ZoneId.of("UTC")), + Ed255192018Suite.PROOF_TYPE, + objectMapper); + + + var testCredential = getTestCredential(); + VerifiableCredential signedCredential = ldSigningService.signCredential(testCredential); + + verify(testCredential, signedCredential.getProof().getProofValue(), testKeyLoader.getPublicKey()); + } + + private void verify(VerifiableCredential testCredential, String proof, AsymmetricKeyParameter publicKey) throws IOException { + Ed25519Signer signer = new Ed25519Signer(); + signer.init(false, publicKey); + SecuritySuite securitySuite = new Ed255192018Suite(objectMapper); + testCredential.setProof(null); + byte[] transformedData = securitySuite.transform(testCredential); + byte[] hashedData = securitySuite.digest(transformedData); + signer.update(hashedData, 0, hashedData.length); + + assertTrue(signer.verifySignature(Base64.decode(proof, Base64.URL_SAFE)), "The signature should be valid"); + } + + class Ed25519TestKeyLoader implements KeyLoader { + private AsymmetricKeyParameter publicKey; + private AsymmetricKeyParameter privateKey; + + public Ed25519TestKeyLoader() { + Ed25519KeyGenerationParameters keygenParams = new Ed25519KeyGenerationParameters(new SecureRandom()); + + Ed25519KeyPairGenerator generator = new Ed25519KeyPairGenerator(); + generator.init(keygenParams); + var keyPair = generator.generateKeyPair(); + + publicKey = keyPair.getPublic(); + privateKey = keyPair.getPrivate(); + } + + public AsymmetricKeyParameter getPublicKey() { + return publicKey; + } + + public AsymmetricKeyParameter getPrivateKey() { + return privateKey; + } + + @Override + public String loadKey() { + try { + var keyInfo = PrivateKeyInfoFactory.createPrivateKeyInfo(getPrivateKey()); + StringWriter stringWriter = new StringWriter(); + JcaPEMWriter pemWriter = new JcaPEMWriter(stringWriter); + pemWriter.writeObject(keyInfo); + pemWriter.flush(); + pemWriter.close(); + return stringWriter.toString(); + + } catch (IOException e) { + throw new RuntimeException(e); + } + } + } + +} \ No newline at end of file diff --git a/services/src/test/java/org/keycloak/protocol/oidc4vp/signing/SigningServiceTest.java b/services/src/test/java/org/keycloak/protocol/oidc4vp/signing/SigningServiceTest.java new file mode 100644 index 000000000000..9b0215306ba0 --- /dev/null +++ b/services/src/test/java/org/keycloak/protocol/oidc4vp/signing/SigningServiceTest.java @@ -0,0 +1,30 @@ +package org.keycloak.protocol.oidc4vp.signing; + +import org.keycloak.protocol.oidc4vp.model.CredentialSubject; +import org.keycloak.protocol.oidc4vp.model.VerifiableCredential; + +import java.net.URI; +import java.time.Instant; +import java.util.Date; +import java.util.List; +import java.util.UUID; + +public abstract class SigningServiceTest { + + + protected static final String CONTEXT_URL = "https://www.w3.org/2018/credentials/v1"; + + protected VerifiableCredential getTestCredential() { + CredentialSubject credentialSubject = new CredentialSubject(); + credentialSubject.setClaims("id", String.format("uri:uuid:%s", UUID.randomUUID())); + credentialSubject.setClaims("test", "test"); + VerifiableCredential testCredential = new VerifiableCredential(); + testCredential.setContext(List.of(CONTEXT_URL)); + testCredential.setType(List.of("VerifiableCredential")); + testCredential.setIssuer(URI.create("did:web:test.org")); + testCredential.setExpirationDate(Date.from(Instant.ofEpochSecond(2000))); + testCredential.setIssuanceDate(Date.from(Instant.ofEpochSecond(1000))); + testCredential.setCredentialSubject(credentialSubject); + return testCredential; + } +} diff --git a/services/src/test/resources/eckey.tls b/services/src/test/resources/eckey.tls new file mode 100644 index 000000000000..f19a5088a3a5 --- /dev/null +++ b/services/src/test/resources/eckey.tls @@ -0,0 +1,4 @@ +-----BEGIN PRIVATE KEY----- +MFECAQEwBQYDK2VwBCIEIIVDLxM1I39HfwfTNCNWKMyeqMYhewD8Jni3COdSE1+u +gSEABXqBACv6GT0LYpOM2JW3zcbHXMOGR9a2K0sNC+zXM6s= +-----END PRIVATE KEY-----