diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/OID4VCLoginProtocolFactory.java b/services/src/main/java/org/keycloak/protocol/oid4vc/OID4VCLoginProtocolFactory.java index e7797f1c7d..7f86e79416 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/OID4VCLoginProtocolFactory.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/OID4VCLoginProtocolFactory.java @@ -39,12 +39,10 @@ import org.keycloak.protocol.oid4vc.issuance.mappers.OID4VCTargetRoleMapper; import org.keycloak.protocol.oid4vc.issuance.mappers.OID4VCUserAttributeMapper; import org.keycloak.protocol.oid4vc.issuance.signing.VCSigningServiceProviderFactory; import org.keycloak.protocol.oid4vc.issuance.signing.VerifiableCredentialsSigningService; -import org.keycloak.protocol.oid4vc.model.Format; import org.keycloak.provider.ProviderFactory; import org.keycloak.representations.idm.ClientRepresentation; import org.keycloak.services.managers.AppAuthManager; -import java.util.EnumMap; import java.util.HashMap; import java.util.Map; import java.util.Optional; @@ -104,7 +102,8 @@ public class OID4VCLoginProtocolFactory implements LoginProtocolFactory, OID4VCE .getKeycloakSessionFactory() .getProviderFactory(VerifiableCredentialsSigningService.class, componentModel.getProviderId()); if (factory instanceof VCSigningServiceProviderFactory sspf) { - signingServices.put(sspf.supportedFormat(), sspf.create(keycloakSession, componentModel)); + VerifiableCredentialsSigningService verifiableCredentialsSigningService = sspf.create(keycloakSession, componentModel); + signingServices.put(verifiableCredentialsSigningService.locator(), verifiableCredentialsSigningService); } else { throw new IllegalArgumentException(String.format("The component %s is not a VerifiableCredentialsSigningServiceProviderFactory", componentModel.getProviderId())); } diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerEndpoint.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerEndpoint.java index 4190f8f8f1..7fd3aa67e2 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerEndpoint.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerEndpoint.java @@ -24,8 +24,6 @@ import com.google.zxing.WriterException; import com.google.zxing.client.j2se.MatrixToImageWriter; import com.google.zxing.common.BitMatrix; import com.google.zxing.qrcode.QRCodeWriter; -import com.google.zxing.qrcode.encoder.QRCode; -import jakarta.annotation.Nullable; import jakarta.ws.rs.BadRequestException; import jakarta.ws.rs.Consumes; import jakarta.ws.rs.DefaultValue; @@ -37,9 +35,7 @@ import jakarta.ws.rs.Produces; import jakarta.ws.rs.QueryParam; import jakarta.ws.rs.WebApplicationException; import jakarta.ws.rs.core.Response; -import org.apache.http.HttpStatus; import org.jboss.logging.Logger; -import org.keycloak.OAuthErrorException; import org.keycloak.common.util.SecretGenerator; import org.keycloak.models.AuthenticatedClientSessionModel; import org.keycloak.models.ClientModel; @@ -74,7 +70,6 @@ import org.keycloak.services.managers.AppAuthManager; import org.keycloak.services.managers.AuthenticationManager; import org.keycloak.utils.MediaType; -import java.awt.image.BufferedImage; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.net.URI; @@ -82,17 +77,17 @@ import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.time.Instant; import java.util.Arrays; -import java.util.Base64; -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.stream.Collectors; import static org.keycloak.protocol.oid4vc.model.Format.JWT_VC; import static org.keycloak.protocol.oid4vc.model.Format.LDP_VC; import static org.keycloak.protocol.oid4vc.model.Format.SD_JWT_VC; +import static org.keycloak.protocol.oid4vc.model.Format.SUPPORTED_FORMATS; /** * Provides the (REST-)endpoints required for the OID4VCI protocol. @@ -119,6 +114,18 @@ public class OID4VCIssuerEndpoint { // lifespan of the preAuthorizedCodes in seconds private final int preAuthorizedCodeLifeSpan; + /** + * Key shall be strings, as configured credential of the same format can + * have different configs. Like decoy, visible claims, + * time requirements (iat, exp, nbf, ...). + * + * Credentials with same configs can share a default entry with locator= format. + * + * Credentials in need of special configuration can provide another signer with specific + * locator=format::type::vc_config_id + * + * The providerId of the signing service factory is still the format. + */ private final Map signingServices; private final boolean isIgnoreScopeCheck; @@ -175,7 +182,7 @@ public class OID4VCIssuerEndpoint { String format = supportedCredentialConfiguration.getFormat(); // check that the user is allowed to get such credential - if (getClientsOfType(supportedCredentialConfiguration.getScope(), format).isEmpty()) { + if (getClientsOfScope(supportedCredentialConfiguration.getScope(), format).isEmpty()) { LOGGER.debugf("No OID4VP-Client supporting type %s registered.", supportedCredentialConfiguration.getScope()); throw new BadRequestException(getErrorResponse(ErrorType.UNSUPPORTED_CREDENTIAL_TYPE)); } @@ -301,39 +308,94 @@ public class OID4VCIssuerEndpoint { cors = Cors.builder().auth().allowedMethods("POST").auth().exposedHeaders(Cors.ACCESS_CONTROL_ALLOW_METHODS); // do first to fail fast on auth - UserSessionModel userSessionModel = getUserSessionModel(); + AuthenticationManager.AuthResult authResult = getAuthResult(); if (!isIgnoreScopeCheck) { checkScope(credentialRequestVO); } + // Both Format and identifier are optional. + // If the credential_identifier is present, Format can't be present. But this implementation will + // tolerate the presence of both, waiting for clarity in specifications. + // This implementation will privilege the presence of the credential config identifier. + String requestedCredentialId = credentialRequestVO.getCredentialIdentifier(); String requestedFormat = credentialRequestVO.getFormat(); - String requestedCredential = credentialRequestVO.getCredentialIdentifier(); - SupportedCredentialConfiguration supportedCredentialConfiguration = Optional - .ofNullable(OID4VCIssuerWellKnownProvider.getSupportedCredentials(this.session) - .get(requestedCredential)) - .orElseThrow( - () -> { - LOGGER.debugf("Unsupported credential %s was requested.", requestedCredential); - return new BadRequestException(getErrorResponse(ErrorType.UNSUPPORTED_CREDENTIAL_TYPE)); - }); + // Check if at least one of both is available. + if(requestedCredentialId == null && requestedFormat == null){ + LOGGER.debugf("Missing both configuration id and requested format. At least one shall be specified."); + throw new BadRequestException(getErrorResponse(ErrorType.MISSING_CREDENTIAL_CONFIG_AND_FORMAT)); + } - if (!supportedCredentialConfiguration.getFormat().equals(requestedFormat)) { - LOGGER.debugf("Format %s is not supported for credential %s.", requestedFormat, requestedCredential); - throw new BadRequestException(getErrorResponse(ErrorType.UNSUPPORTED_CREDENTIAL_FORMAT)); + Map supportedCredentials = OID4VCIssuerWellKnownProvider.getSupportedCredentials(this.session); + + // resolve from identifier first + SupportedCredentialConfiguration supportedCredentialConfiguration = null; + if (requestedCredentialId != null) { + supportedCredentialConfiguration = supportedCredentials.get(requestedCredentialId); + if(supportedCredentialConfiguration == null){ + LOGGER.debugf("Credential with configuration id %s not found.", requestedCredentialId); + throw new BadRequestException(getErrorResponse(ErrorType.UNSUPPORTED_CREDENTIAL_TYPE)); + } + // Then for format. We know spec does not allow both parameter. But we are tolerant if you send both + // Was found by id, check that the format matches. + if (requestedFormat != null && !requestedFormat.equals(supportedCredentialConfiguration.getFormat())){ + LOGGER.debugf("Credential with configuration id %s does not support requested format %s, but supports %s.", requestedCredentialId, requestedFormat, supportedCredentialConfiguration.getFormat()); + throw new BadRequestException(getErrorResponse(ErrorType.UNSUPPORTED_CREDENTIAL_FORMAT)); + } + } + + if(supportedCredentialConfiguration == null && requestedFormat != null) { + // Search by format + supportedCredentialConfiguration = getSupportedCredentialConfiguration(credentialRequestVO, supportedCredentials, requestedFormat); + if(supportedCredentialConfiguration == null) { + LOGGER.debugf("Credential with requested format %s, not supported.", requestedFormat); + throw new BadRequestException(getErrorResponse(ErrorType.UNSUPPORTED_CREDENTIAL_FORMAT)); + } } CredentialResponse responseVO = new CredentialResponse(); - Object theCredential = getCredential(userSessionModel, supportedCredentialConfiguration.getScope(), credentialRequestVO.getFormat()); - switch (requestedFormat) { - case LDP_VC, JWT_VC, SD_JWT_VC -> responseVO.setCredential(theCredential); - default -> throw new BadRequestException( - getErrorResponse(ErrorType.UNSUPPORTED_CREDENTIAL_TYPE)); + Object theCredential = getCredential(authResult, supportedCredentialConfiguration, credentialRequestVO); + if(SUPPORTED_FORMATS.contains(requestedFormat)) { + responseVO.setCredential(theCredential); + } else { + throw new BadRequestException(getErrorResponse(ErrorType.UNSUPPORTED_CREDENTIAL_TYPE)); } - return Response.ok().entity(responseVO) - .build(); + return Response.ok().entity(responseVO).build(); + } + + private SupportedCredentialConfiguration getSupportedCredentialConfiguration(CredentialRequest credentialRequestVO, Map supportedCredentials, String requestedFormat) { + // 1. Format resolver + List configs = supportedCredentials.values().stream() + .filter(supportedCredential -> Objects.equals(supportedCredential.getFormat(), requestedFormat)) + .collect(Collectors.toList()); + + List matchingConfigs; + + switch (requestedFormat) { + case SD_JWT_VC: + // Resolve from vct for sd-jwt + matchingConfigs = configs.stream() + .filter(supportedCredential -> Objects.equals(supportedCredential.getVct(), credentialRequestVO.getVct())) + .collect(Collectors.toList()); + break; + case JWT_VC: + case LDP_VC: + // Will detach this when each format provides logic on how to resolve from definition. + matchingConfigs = configs.stream() + .filter(supportedCredential -> Objects.equals(supportedCredential.getCredentialDefinition(), credentialRequestVO.getCredentialDefinition())) + .collect(Collectors.toList()); + break; + default: + throw new BadRequestException(getErrorResponse(ErrorType.UNSUPPORTED_CREDENTIAL_FORMAT)); + } + + if (matchingConfigs.isEmpty()) { + throw new BadRequestException(getErrorResponse(ErrorType.MISSING_CREDENTIAL_CONFIG)); + } + + return matchingConfigs.iterator().next(); } private AuthenticatedClientSessionModel getAuthenticatedClientSession() { @@ -349,12 +411,6 @@ public class OID4VCIssuerEndpoint { return clientSession; } - // return the current UserSessionModel - private UserSessionModel getUserSessionModel() { - return getAuthResult( - new BadRequestException(getErrorResponse(ErrorType.INVALID_TOKEN))).getSession(); - } - private AuthenticationManager.AuthResult getAuthResult() { return getAuthResult(new BadRequestException(getErrorResponse(ErrorType.INVALID_TOKEN))); } @@ -371,14 +427,14 @@ public class OID4VCIssuerEndpoint { /** * Get a signed credential * - * @param userSessionModel userSession to create the credential for - * @param vcType type of the credential to be created - * @param format format of the credential to be created + * @param authResult authResult containing the userSession to create the credential for + * @param credentialConfig the supported credential configuration + * @param credentialRequestVO the credential request * @return the signed credential */ - private Object getCredential(UserSessionModel userSessionModel, String vcType, String format) { + private Object getCredential(AuthenticationManager.AuthResult authResult, SupportedCredentialConfiguration credentialConfig, CredentialRequest credentialRequestVO) { - List clients = getClientsOfType(vcType, format); + List clients = getClientsOfScope(credentialConfig.getScope(), credentialConfig.getFormat()); List protocolMappers = getProtocolMappers(clients) .stream() @@ -396,11 +452,25 @@ public class OID4VCIssuerEndpoint { .filter(Objects::nonNull) .toList(); - VerifiableCredential credentialToSign = getVCToSign(protocolMappers, vcType, userSessionModel); + VCIssuanceContext vcIssuanceContext = getVCToSign(protocolMappers, credentialConfig, authResult, credentialRequestVO); - return Optional.ofNullable(signingServices.get(format)) - .map(verifiableCredentialsSigningService -> verifiableCredentialsSigningService.signCredential(credentialToSign)) - .orElseThrow(() -> new IllegalArgumentException(String.format("Requested format %s is not supported.", format))); + String fullyQualifiedConfigKey = VerifiableCredentialsSigningService.locator(credentialConfig.getFormat(), credentialConfig.deriveType(), credentialConfig.deriveConfiId()); + String formatAndTypeKey = VerifiableCredentialsSigningService.locator(credentialConfig.getFormat(), credentialConfig.deriveType(), null); + String formatOnlyKey = VerifiableCredentialsSigningService.locator(credentialConfig.getFormat(), null, null); + + // Search from specific to general config. + VerifiableCredentialsSigningService signingService = signingServices.getOrDefault( + fullyQualifiedConfigKey, + signingServices.getOrDefault( + formatAndTypeKey, + signingServices.get(formatOnlyKey)) + ); + + return Optional.ofNullable(signingService) + .map(service -> service.signCredential(vcIssuanceContext)) + .orElseThrow(() -> new BadRequestException( + String.format("No signer found for specific config '%s' or '%s' or format '%s'.", fullyQualifiedConfigKey, formatAndTypeKey, formatOnlyKey) + )); } private List getProtocolMappers(List oid4VCClients) { @@ -427,19 +497,20 @@ public class OID4VCIssuerEndpoint { return Response.status(Response.Status.BAD_REQUEST).entity(errorResponse).build(); } - // Return all {@link OID4VCClient}s that support the given type and format - private List getClientsOfType(String vcType, String format) { - LOGGER.debugf("Retrieve all clients of type %s, supporting format %s", vcType, format.toString()); + // Return all {@link OID4VCClient}s that support the given scope and format + // Scope might be different from vct. In the case of sd-jwt for example + private List getClientsOfScope(String vcScope, String format) { + LOGGER.debugf("Retrieve all clients of scope %s, supporting format %s", vcScope, format); - if (Optional.ofNullable(vcType).filter(type -> !type.isEmpty()).isEmpty()) { - throw new BadRequestException("No VerifiableCredential-Type was provided in the request."); + if (Optional.ofNullable(vcScope).filter(scope -> !scope.isEmpty()).isEmpty()) { + throw new BadRequestException("No VerifiableCredential-Scope was provided in the request."); } return getOID4VCClientsFromSession() .stream() .filter(oid4VCClient -> oid4VCClient.getSupportedVCTypes() .stream() - .anyMatch(supportedCredential -> supportedCredential.getScope().equals(vcType))) + .anyMatch(supportedCredential -> supportedCredential.getScope().equals(vcScope))) .toList(); } @@ -457,28 +528,32 @@ public class OID4VCIssuerEndpoint { } // builds the unsigned credential by applying all protocol mappers. - private VerifiableCredential getVCToSign(List protocolMappers, String vcType, - UserSessionModel userSessionModel) { + private VCIssuanceContext getVCToSign(List protocolMappers, SupportedCredentialConfiguration credentialConfig, + AuthenticationManager.AuthResult authResult, CredentialRequest credentialRequestVO) { // set the required claims VerifiableCredential vc = new VerifiableCredential() .setIssuer(URI.create(issuerDid)) .setIssuanceDate(Instant.ofEpochMilli(timeProvider.currentTimeMillis())) - .setType(List.of(vcType)); + .setType(List.of(credentialConfig.getScope())); Map subjectClaims = new HashMap<>(); protocolMappers .stream() - .filter(mapper -> mapper.isTypeSupported(vcType)) - .forEach(mapper -> mapper.setClaimsForSubject(subjectClaims, userSessionModel)); + .filter(mapper -> mapper.isScopeSupported(credentialConfig.getScope())) + .forEach(mapper -> mapper.setClaimsForSubject(subjectClaims, authResult.getSession())); subjectClaims.forEach((key, value) -> vc.getCredentialSubject().setClaims(key, value)); protocolMappers .stream() - .filter(mapper -> mapper.isTypeSupported(vcType)) - .forEach(mapper -> mapper.setClaimsForCredential(vc, userSessionModel)); + .filter(mapper -> mapper.isScopeSupported(credentialConfig.getScope())) + .forEach(mapper -> mapper.setClaimsForCredential(vc, authResult.getSession())); LOGGER.debugf("The credential to sign is: %s", vc); - return vc; + + return new VCIssuanceContext().setAuthResult(authResult) + .setVerifiableCredential(vc) + .setCredentialConfig(credentialConfig) + .setCredentialRequest(credentialRequestVO); } } diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/VCIssuanceContext.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/VCIssuanceContext.java new file mode 100644 index 0000000000..0f470d3031 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/VCIssuanceContext.java @@ -0,0 +1,74 @@ +/* + * Copyright 2024 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.protocol.oid4vc.issuance; + +import org.keycloak.protocol.oid4vc.model.CredentialRequest; +import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration; +import org.keycloak.protocol.oid4vc.model.VerifiableCredential; +import org.keycloak.services.managers.AuthenticationManager; + +/** + * Holds the verifiable credential to sign and additional context information. + * + * Helps keeps the {@link VerifiableCredential} as clean pojo. Without any risk to + * mistakenly serialize unwanted information. + * + * @author Francis Pouatcha + */ +public class VCIssuanceContext { + private VerifiableCredential verifiableCredential; + + private SupportedCredentialConfiguration credentialConfig; + private CredentialRequest credentialRequest; + private AuthenticationManager.AuthResult authResult; + + public VerifiableCredential getVerifiableCredential() { + return verifiableCredential; + } + + public VCIssuanceContext setVerifiableCredential(VerifiableCredential verifiableCredential) { + this.verifiableCredential = verifiableCredential; + return this; + } + + public SupportedCredentialConfiguration getCredentialConfig() { + return credentialConfig; + } + + public VCIssuanceContext setCredentialConfig(SupportedCredentialConfiguration credentialConfig) { + this.credentialConfig = credentialConfig; + return this; + } + + public CredentialRequest getCredentialRequest() { + return credentialRequest; + } + + public VCIssuanceContext setCredentialRequest(CredentialRequest credentialRequest) { + this.credentialRequest = credentialRequest; + return this; + } + + public AuthenticationManager.AuthResult getAuthResult() { + return authResult; + } + + public VCIssuanceContext setAuthResult(AuthenticationManager.AuthResult authResult) { + this.authResult = authResult; + return this; + } +} diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCMapper.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCMapper.java index 9cc4f3745a..67ff39da5e 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCMapper.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCMapper.java @@ -97,15 +97,15 @@ public abstract class OID4VCMapper implements ProtocolMapper, OID4VCEnvironmentP /** * Checks if the mapper supports the given credential type. Allows to configure them not only per client, but also per VC Type. * - * @param credentialType type of the VerifiableCredential that should be checked + * @param credentialScope type of the VerifiableCredential that should be checked * @return true if it is supported */ - public boolean isTypeSupported(String credentialType) { - var optionalTypes = Optional.ofNullable(mapperModel.getConfig().get(SUPPORTED_CREDENTIALS_KEY)); - if (optionalTypes.isEmpty()) { + public boolean isScopeSupported(String credentialScope) { + var optionalScopes = Optional.ofNullable(mapperModel.getConfig().get(SUPPORTED_CREDENTIALS_KEY)); + if (optionalScopes.isEmpty()) { return false; } - return Arrays.asList(optionalTypes.get().split(",")).contains(credentialType); + return Arrays.asList(optionalScopes.get().split(",")).contains(credentialScope); } /** @@ -120,4 +120,4 @@ public abstract class OID4VCMapper implements ProtocolMapper, OID4VCEnvironmentP public abstract void setClaimsForSubject(Map claims, UserSessionModel userSessionModel); -} \ No newline at end of file +} diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/JwtProofBasedSigningService.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/JwtProofBasedSigningService.java new file mode 100644 index 0000000000..47f9fa27f2 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/JwtProofBasedSigningService.java @@ -0,0 +1,207 @@ +/* + * Copyright 2024 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.protocol.oid4vc.issuance.signing; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Objects; +import java.util.Optional; + +import org.jboss.logging.Logger; +import org.keycloak.common.VerificationException; +import org.keycloak.crypto.SignatureVerifierContext; +import org.keycloak.jose.jwk.JWK; +import org.keycloak.jose.jws.JWSHeader; +import org.keycloak.jose.jws.JWSInput; +import org.keycloak.jose.jws.JWSInputException; +import org.keycloak.models.KeycloakSession; +import org.keycloak.protocol.oid4vc.issuance.VCIssuanceContext; +import org.keycloak.protocol.oid4vc.issuance.VCIssuerException; +import org.keycloak.protocol.oid4vc.model.Proof; +import org.keycloak.protocol.oid4vc.model.ProofType; +import org.keycloak.protocol.oid4vc.model.ProofTypeJWT; +import org.keycloak.protocol.oid4vc.model.ProofTypesSupported; +import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration; +import org.keycloak.representations.AccessToken; +import org.keycloak.util.JsonSerialization; + +/** + * Common signing service logic to handle proofs. + * + * @author Francis Pouatcha + */ +public abstract class JwtProofBasedSigningService extends SigningService { + + private static final Logger LOGGER = Logger.getLogger(JwtProofBasedSigningService.class); + private static final String CRYPTOGRAPHIC_BINDING_METHOD_JWK = "jwk"; + public static final String PROOF_JWT_TYP="openid4vci-proof+jwt"; + + protected JwtProofBasedSigningService(KeycloakSession keycloakSession, String keyId, String format, String type) { + super(keycloakSession, keyId, format, type); + } + + /* + * Validates a proof provided by the client if any. + * + * Returns null if there is no need to include a key binding in the credential + * + * Return the JWK to be included as key binding in the JWK if the provided proof was correctly validated + * + * @param vcIssuanceContext + * @return + * @throws VCIssuerException + * @throws JWSInputException + * @throws VerificationException + * @throws IllegalStateException: is credential type badly configured + * @throws IOException + */ + protected JWK validateProof(VCIssuanceContext vcIssuanceContext) throws VCIssuerException, JWSInputException, VerificationException, IOException { + + Optional optionalProof = getProofFromContext(vcIssuanceContext); + + if (optionalProof.isEmpty()) { + return null; // No proof support + } + + // Check key binding config for jwt. Only type supported. + checkCryptographicKeyBinding(vcIssuanceContext); + + JWSInput jwsInput = getJwsInput(optionalProof.get()); + JWSHeader jwsHeader = jwsInput.getHeader(); + validateJwsHeader(vcIssuanceContext, jwsHeader); + + JWK jwk = Optional.ofNullable(jwsHeader.getKey()) + .orElseThrow(() -> new VCIssuerException("Missing binding key. Make sure provided JWT contains the jwk jwsHeader claim.")); + + // Parsing the Proof as an access token shall work, as a proof is a strict subset of an access token. + AccessToken proofPayload = JsonSerialization.readValue(jwsInput.getContent(), AccessToken.class); + validateProofPayload(vcIssuanceContext, proofPayload); + + SignatureVerifierContext signatureVerifierContext = getVerifier(jwk, jwsHeader.getAlgorithm().name()); + if (signatureVerifierContext == null) { + throw new VCIssuerException("No verifier configured for " + jwsHeader.getAlgorithm()); + } + if (!signatureVerifierContext.verify(jwsInput.getEncodedSignatureInput().getBytes(StandardCharsets.UTF_8), jwsInput.getSignature())) { + throw new VCIssuerException("Could not verify provided proof"); + } + + return jwk; + } + + private void checkCryptographicKeyBinding(VCIssuanceContext vcIssuanceContext){ + // Make sure we are dealing with a jwk proof. + if (vcIssuanceContext.getCredentialConfig().getCryptographicBindingMethodsSupported() == null || + !vcIssuanceContext.getCredentialConfig().getCryptographicBindingMethodsSupported().contains(CRYPTOGRAPHIC_BINDING_METHOD_JWK)) { + throw new IllegalStateException("This SD-JWT implementation only supports jwk as cryptographic binding method"); + } + } + + private Optional getProofFromContext(VCIssuanceContext vcIssuanceContext) throws VCIssuerException { + return Optional.ofNullable(vcIssuanceContext.getCredentialConfig()) + .map(SupportedCredentialConfiguration::getProofTypesSupported) + .flatMap(proofTypesSupported -> { + Optional.ofNullable(proofTypesSupported.getJwt()) + .orElseThrow(() -> new VCIssuerException("SD-JWT supports only jwt proof type.")); + + Proof proof = Optional.ofNullable(vcIssuanceContext.getCredentialRequest().getProof()) + .orElseThrow(() -> new VCIssuerException("Credential configuration requires a proof of type: " + ProofType.JWT)); + + if (!Objects.equals(proof.getProofType(), ProofType.JWT)) { + throw new VCIssuerException("Wrong proof type"); + } + + return Optional.of(proof); + }); + } + + private JWSInput getJwsInput(Proof proof) throws JWSInputException { + return new JWSInput(proof.getJwt()); + } + + /** + * As we limit accepted algorithm to the ones listed by the issuer, we can omit checking for "none" + * The Algorithm enum class does not list the none value anyway. + * + * @param vcIssuanceContext + * @param jwsHeader + * @throws VCIssuerException + */ + private void validateJwsHeader(VCIssuanceContext vcIssuanceContext, JWSHeader jwsHeader) throws VCIssuerException { + Optional.ofNullable(jwsHeader.getAlgorithm()) + .orElseThrow(() -> new VCIssuerException("Missing jwsHeader claim alg")); + + // As we limit accepted algorithm to the ones listed by the server, we can omit checking for "none" + // The Algorithm enum class does not list the none value anyway. + Optional.ofNullable(vcIssuanceContext.getCredentialConfig()) + .map(SupportedCredentialConfiguration::getProofTypesSupported) + .map(ProofTypesSupported::getJwt) + .map(ProofTypeJWT::getProofSigningAlgValuesSupported) + .filter(supportedAlgs -> supportedAlgs.contains(jwsHeader.getAlgorithm().name())) + .orElseThrow(() -> new VCIssuerException("Proof signature algorithm not supported: " + jwsHeader.getAlgorithm().name())); + + Optional.ofNullable(jwsHeader.getType()) + .filter(type -> Objects.equals(PROOF_JWT_TYP, type)) + .orElseThrow(() -> new VCIssuerException("JWT type must be: " + PROOF_JWT_TYP)); + + // KeyId shall not be present alongside the jwk. + Optional.ofNullable(jwsHeader.getKeyId()) + .ifPresent(keyId -> { + throw new VCIssuerException("KeyId not expected in this JWT. Use the jwk claim instead."); + }); + } + + private void validateProofPayload(VCIssuanceContext vcIssuanceContext, AccessToken proofPayload) throws VCIssuerException { + // azp is the id of the client, as mentioned in the access token used to request the credential. + // Token provided from user is obtained with a clientId that support the oidc login protocol. + // oid4vci client doesn't. But it is the client needed at the credential endpoint. + // String azp = vcIssuanceContext.getAuthResult().getToken().getIssuedFor(); + // Optional.ofNullable(proofPayload.getIssuer()) + // .filter(proofIssuer -> Objects.equals(azp, proofIssuer)) + // .orElseThrow(() -> new VCIssuerException("Issuer claim must be null for preauthorized code else the clientId of the client making the request: " + azp)); + + // The issuer is the token / credential is the audience of the proof + String credentialIssuer = vcIssuanceContext.getVerifiableCredential().getIssuer().toString(); + Optional.ofNullable(proofPayload.getAudience()) // Ensure null-safety with Optional + .map(Arrays::asList) // Convert to List + .filter(audiences -> audiences.contains(credentialIssuer)) // Check if the issuer is in the audience list + .orElseThrow(() -> new VCIssuerException( + "Proof not produced for this audience. Audience claim must be: " + credentialIssuer + " but are " + Arrays.asList(proofPayload.getAudience()))); + + // Validate mandatory iat. + // I do not understand the rationale behind requiring an issue time if we are not checking expiration. + Optional.ofNullable(proofPayload.getIat()) + .orElseThrow(() -> new VCIssuerException("Missing proof issuing time. iat claim must be provided.")); + + // Check cNonce matches. + // If the token endpoint provides a c_nonce, we would like this: + // - stored in the access token + // - having the same validity as the access token. + Optional.ofNullable(vcIssuanceContext.getAuthResult().getToken().getNonce()) + .ifPresent( + cNonce -> { + Optional.ofNullable(proofPayload.getNonce()) + .filter(nonce -> Objects.equals(cNonce, nonce)) + .orElseThrow(() -> new VCIssuerException("Missing or wrong nonce value. Please provide nonce returned by the issuer if any.")); + + // We expect the expiration to be identical to the token expiration. We assume token expiration has been checked by AuthManager, + // So no_op + } + ); + + } +} diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/JwtSigningService.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/JwtSigningService.java index 6424c0c595..63f81cc050 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/JwtSigningService.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/JwtSigningService.java @@ -24,6 +24,8 @@ import org.keycloak.crypto.SignatureSignerContext; import org.keycloak.jose.jws.JWSBuilder; import org.keycloak.models.KeycloakSession; import org.keycloak.protocol.oid4vc.issuance.TimeProvider; +import org.keycloak.protocol.oid4vc.issuance.VCIssuanceContext; +import org.keycloak.protocol.oid4vc.model.Format; import org.keycloak.protocol.oid4vc.model.VerifiableCredential; import org.keycloak.representations.JsonWebToken; @@ -54,7 +56,7 @@ public class JwtSigningService extends SigningService { protected final String issuerDid; public JwtSigningService(KeycloakSession keycloakSession, String keyId, String algorithmType, String tokenType, String issuerDid, TimeProvider timeProvider) { - super(keycloakSession, keyId, algorithmType); + super(keycloakSession, keyId, Format.JWT_VC, algorithmType); this.issuerDid = issuerDid; this.timeProvider = timeProvider; this.tokenType = tokenType; @@ -69,9 +71,11 @@ public class JwtSigningService extends SigningService { } @Override - public String signCredential(VerifiableCredential verifiableCredential) { + public String signCredential(VCIssuanceContext vcIssuanceContext) { LOGGER.debugf("Sign credentials to jwt-vc format."); + VerifiableCredential verifiableCredential = vcIssuanceContext.getVerifiableCredential(); + // Get the issuance date from the credential. Since nbf is mandatory, we set it to the current time if not // provided long iat = Optional.ofNullable(verifiableCredential.getIssuanceDate()) diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/LDSigningService.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/LDSigningService.java index 548d03434a..d3e08c5f2e 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/LDSigningService.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/LDSigningService.java @@ -23,8 +23,10 @@ import org.keycloak.crypto.KeyWrapper; import org.keycloak.crypto.SignatureProvider; import org.keycloak.models.KeycloakSession; import org.keycloak.protocol.oid4vc.issuance.TimeProvider; +import org.keycloak.protocol.oid4vc.issuance.VCIssuanceContext; import org.keycloak.protocol.oid4vc.issuance.signing.vcdm.Ed255192018Suite; import org.keycloak.protocol.oid4vc.issuance.signing.vcdm.LinkedDataCryptographicSuite; +import org.keycloak.protocol.oid4vc.model.Format; import org.keycloak.protocol.oid4vc.model.VerifiableCredential; import org.keycloak.protocol.oid4vc.model.vcdm.LdProof; @@ -48,7 +50,7 @@ public class LDSigningService extends SigningService { private final String keyId; public LDSigningService(KeycloakSession keycloakSession, String keyId, String algorithmType, String ldpType, TimeProvider timeProvider, Optional kid) { - super(keycloakSession, keyId, algorithmType); + super(keycloakSession, keyId, Format.LDP_VC, algorithmType); this.timeProvider = timeProvider; this.keyId = kid.orElse(keyId); KeyWrapper signingKey = getKey(keyId, algorithmType); @@ -71,8 +73,8 @@ public class LDSigningService extends SigningService { } @Override - public VerifiableCredential signCredential(VerifiableCredential verifiableCredential) { - return addProof(verifiableCredential); + public VerifiableCredential signCredential(VCIssuanceContext vcIssuanceContext) { + return addProof(vcIssuanceContext.getVerifiableCredential()); } // add the signed proof to the credential. diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SdJwtSigningService.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SdJwtSigningService.java index 761e70fb6f..9e282034d6 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SdJwtSigningService.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SdJwtSigningService.java @@ -21,19 +21,27 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; import org.jboss.logging.Logger; +import org.keycloak.common.VerificationException; import org.keycloak.crypto.KeyWrapper; import org.keycloak.crypto.SignatureProvider; import org.keycloak.crypto.SignatureSignerContext; +import org.keycloak.jose.jwk.JWK; +import org.keycloak.jose.jws.JWSInputException; import org.keycloak.models.KeycloakSession; -import org.keycloak.protocol.oid4vc.issuance.TimeProvider; +import org.keycloak.protocol.oid4vc.issuance.VCIssuanceContext; +import org.keycloak.protocol.oid4vc.issuance.VCIssuerException; +import org.keycloak.protocol.oid4vc.model.CredentialConfigId; import org.keycloak.protocol.oid4vc.model.CredentialSubject; +import org.keycloak.protocol.oid4vc.model.Format; import org.keycloak.protocol.oid4vc.model.VerifiableCredential; +import org.keycloak.protocol.oid4vc.model.VerifiableCredentialType; import org.keycloak.sdjwt.DisclosureSpec; import org.keycloak.sdjwt.SdJwt; import org.keycloak.sdjwt.SdJwtUtils; -import java.time.Instant; +import java.io.IOException; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.stream.IntStream; @@ -46,44 +54,61 @@ import java.util.stream.IntStream; * * @author Stefan Wiedemann */ -public class SdJwtSigningService extends SigningService { +public class SdJwtSigningService extends JwtProofBasedSigningService { private static final Logger LOGGER = Logger.getLogger(SdJwtSigningService.class); - private static final String ISSUER_CLAIM ="iss"; - private static final String NOT_BEFORE_CLAIM ="nbf"; + private static final String ISSUER_CLAIM = "iss"; private static final String VERIFIABLE_CREDENTIAL_TYPE_CLAIM = "vct"; private static final String CREDENTIAL_ID_CLAIM = "jti"; + private static final String CNF_CLAIM = "cnf"; + private static final String JWK_CLAIM = "jwk"; private final ObjectMapper objectMapper; private final SignatureSignerContext signatureSignerContext; - private final TimeProvider timeProvider; private final String tokenType; private final String hashAlgorithm; private final int decoys; private final List visibleClaims; protected final String issuerDid; - public SdJwtSigningService(KeycloakSession keycloakSession, ObjectMapper objectMapper, String keyId, String algorithmType, String tokenType, String hashAlgorithm, String issuerDid, int decoys, List visibleClaims, TimeProvider timeProvider, Optional kid) { - super(keycloakSession, keyId, algorithmType); + private final CredentialConfigId vcConfigId; + + // See: https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-request-6 + // vct sort of additional category for sd-jwt. + private final VerifiableCredentialType vct; + + public SdJwtSigningService(KeycloakSession keycloakSession, ObjectMapper objectMapper, String keyId, String algorithmType, String tokenType, String hashAlgorithm, String issuerDid, int decoys, List visibleClaims, Optional kid, VerifiableCredentialType credentialType, CredentialConfigId vcConfigId) { + super(keycloakSession, keyId, Format.SD_JWT_VC, algorithmType); this.objectMapper = objectMapper; this.issuerDid = issuerDid; - this.timeProvider = timeProvider; this.tokenType = tokenType; this.hashAlgorithm = hashAlgorithm; this.decoys = decoys; this.visibleClaims = visibleClaims; + this.vcConfigId = vcConfigId; + this.vct = credentialType; + + // If a config id is defined, a vct must be defined. + // Also validated in: org.keycloak.protocol.oid4vc.issuance.signing.SdJwtSigningServiceProviderFactory.validateSpecificConfiguration + if (this.vcConfigId != null && this.vct == null) { + throw new SigningServiceException(String.format("Missing vct for credential config id %s.", vcConfigId)); + } + + // Will return the active key if key id is null. KeyWrapper signingKey = getKey(keyId, algorithmType); if (signingKey == null) { throw new SigningServiceException(String.format("No key for id %s and algorithm %s available.", keyId, algorithmType)); } + // keyId header can be confusing if there is any key rotation, as key ids have to be immutable. It can lead + // to different keys being exposed under the same id. // set the configured kid if present. if (kid.isPresent()) { // we need to clone the key first, to not change the kid of the original key so that the next request still can find it. signingKey = signingKey.cloneKey(); signingKey.setKid(keyId); } - kid.ifPresent(signingKey::setKid); + SignatureProvider signatureProvider = keycloakSession.getProvider(SignatureProvider.class, algorithmType); signatureSignerContext = signatureProvider.signer(signingKey); @@ -91,8 +116,17 @@ public class SdJwtSigningService extends SigningService { } @Override - public String signCredential(VerifiableCredential verifiableCredential) { + public String signCredential(VCIssuanceContext vcIssuanceContext) throws VCIssuerException { + JWK jwk = null; + try { + // null returned is a valid result. Means no key binding will be included. + jwk = validateProof(vcIssuanceContext); + } catch (JWSInputException | VerificationException | IOException e) { + throw new VCIssuerException("Can not verify proof", e); + } + + VerifiableCredential verifiableCredential = vcIssuanceContext.getVerifiableCredential(); DisclosureSpec.Builder disclosureSpecBuilder = DisclosureSpec.builder(); CredentialSubject credentialSubject = verifiableCredential.getCredentialSubject(); JsonNode claimSet = objectMapper.valueToTree(credentialSubject); @@ -119,18 +153,18 @@ public class SdJwtSigningService extends SigningService { ObjectNode rootNode = claimSet.withObject(""); rootNode.put(ISSUER_CLAIM, issuerDid); - // Get the issuance date from the credential. Since nbf is mandatory, we set it to the current time if not - // provided - long iat = Optional.ofNullable(verifiableCredential.getIssuanceDate()) - .map(Instant::getEpochSecond) - .orElse((long) timeProvider.currentTimeSeconds()); - rootNode.put(NOT_BEFORE_CLAIM, iat); - if (verifiableCredential.getType() == null || verifiableCredential.getType().size() != 1) { - throw new SigningServiceException("SD-JWT only supports single type credentials."); - } - rootNode.put(VERIFIABLE_CREDENTIAL_TYPE_CLAIM, verifiableCredential.getType().get(0)); + // nbf, iat and exp are all optional. So need to be set by a protocol mapper if needed + // see: https://www.ietf.org/archive/id/draft-ietf-oauth-sd-jwt-vc-03.html#name-registered-jwt-claims + + // Use vct as type for sd-jwt. + rootNode.put(VERIFIABLE_CREDENTIAL_TYPE_CLAIM, vct.getValue()); rootNode.put(CREDENTIAL_ID_CLAIM, JwtSigningService.createCredentialId(verifiableCredential)); + // add the key binding if any + if (jwk != null) { + rootNode.putPOJO(CNF_CLAIM, Map.of(JWK_CLAIM, jwk)); + } + SdJwt sdJwt = SdJwt.builder() .withDisclosureSpec(disclosureSpecBuilder.build()) .withClaimSet(claimSet) @@ -142,4 +176,8 @@ public class SdJwtSigningService extends SigningService { return sdJwt.toSdJwtString(); } + @Override + public String locator() { + return VerifiableCredentialsSigningService.locator(format, vct, vcConfigId); + } } diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SdJwtSigningServiceProviderFactory.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SdJwtSigningServiceProviderFactory.java index 395ebc3b04..689795e372 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SdJwtSigningServiceProviderFactory.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SdJwtSigningServiceProviderFactory.java @@ -22,9 +22,10 @@ import org.keycloak.component.ComponentModel; import org.keycloak.component.ComponentValidationException; import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; -import org.keycloak.protocol.oid4vc.issuance.OffsetTimeProvider; import org.keycloak.protocol.oid4vc.issuance.VCIssuerException; +import org.keycloak.protocol.oid4vc.model.CredentialConfigId; import org.keycloak.protocol.oid4vc.model.Format; +import org.keycloak.protocol.oid4vc.model.VerifiableCredentialType; import org.keycloak.provider.ConfigurationValidationHelper; import org.keycloak.provider.ProviderConfigProperty; @@ -50,9 +51,13 @@ public class SdJwtSigningServiceProviderFactory implements VCSigningServiceProvi String hashAlgorithm = model.get(SigningProperties.HASH_ALGORITHM.getKey()); Optional kid = Optional.ofNullable(model.get(SigningProperties.KID_HEADER.getKey())); int decoys = Integer.parseInt(model.get(SigningProperties.DECOYS.getKey())); + // Store vct as a conditional attribute of the signing service. + // But is vcConfigId is provided, vct must be provided as well. + String vct = model.get(SigningProperties.VC_VCT.getKey()); + String vcConfigId = model.get(SigningProperties.VC_CONFIG_ID.getKey()); List visibleClaims = Optional.ofNullable(model.get(SigningProperties.VISIBLE_CLAIMS.getKey())) - .map(visibileClaims -> visibileClaims.split(",")) + .map(vsbleClaims -> vsbleClaims.split(",")) .map(Arrays::asList) .orElse(List.of()); @@ -63,7 +68,8 @@ public class SdJwtSigningServiceProviderFactory implements VCSigningServiceProvi .getAttribute(ISSUER_DID_REALM_ATTRIBUTE_KEY)) .orElseThrow(() -> new VCIssuerException("No issuerDid configured.")); - return new SdJwtSigningService(session, new ObjectMapper(), keyId, algorithmType, tokenType, hashAlgorithm, issuerDid, decoys, visibleClaims, new OffsetTimeProvider(), kid); + return new SdJwtSigningService(session, new ObjectMapper(), keyId, algorithmType, tokenType, hashAlgorithm, + issuerDid, decoys, visibleClaims, kid, VerifiableCredentialType.from(vct), CredentialConfigId.from(vcConfigId)); } @Override @@ -79,21 +85,27 @@ public class SdJwtSigningServiceProviderFactory implements VCSigningServiceProvi .property(SigningProperties.DECOYS.asConfigProperty()) .property(SigningProperties.KID_HEADER.asConfigProperty()) .property(SigningProperties.HASH_ALGORITHM.asConfigProperty()) + .property(SigningProperties.VC_VCT.asConfigProperty()) + .property(SigningProperties.VC_CONFIG_ID.asConfigProperty()) .build(); } @Override public String getId() { - return SUPPORTED_FORMAT.toString(); + return SUPPORTED_FORMAT; } @Override public void validateSpecificConfiguration(KeycloakSession session, RealmModel realm, ComponentModel model) throws ComponentValidationException { - ConfigurationValidationHelper.check(model) + ConfigurationValidationHelper helper = ConfigurationValidationHelper.check(model) .checkRequired(SigningProperties.HASH_ALGORITHM.asConfigProperty()) .checkRequired(SigningProperties.ALGORITHM_TYPE.asConfigProperty()) .checkRequired(SigningProperties.TOKEN_TYPE.asConfigProperty()) .checkInt(SigningProperties.DECOYS.asConfigProperty(), true); + // Make sure VCT is set if vc config id is set. + if (model.get(SigningProperties.VC_CONFIG_ID.getKey()) != null) { + helper.checkRequired(SigningProperties.VC_VCT.asConfigProperty()); + } } @Override diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SigningProperties.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SigningProperties.java index 6a6c863fe6..49a2793cbd 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SigningProperties.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SigningProperties.java @@ -29,13 +29,17 @@ public enum SigningProperties { ISSUER_DID("issuerDid", "Did of the issuer.", "Provide the DID of the issuer. Needs to match the provided key material.", ProviderConfigProperty.STRING_TYPE, null), KEY_ID("keyId", "Id of the signing key.", "The id of the key to be used for signing credentials. The key needs to be provided as a realm key.", ProviderConfigProperty.STRING_TYPE, null), + // keyId header can be confusing if there is any key rotation, as key ids have to be immutable. It can lead + // to different keys being exposed under the same id. KID_HEADER("kidHeader", "Kid to be set for the JWT.", "The kid to be set in the jwt-header. Depending on the did-schema, the pure key-id might not be enough and can be overwritten here.", ProviderConfigProperty.STRING_TYPE, null), PROOF_TYPE("proofType", "Type of the LD-Proof.", "The type of LD-Proofs to be created. Needs to fit the provided signing key.", ProviderConfigProperty.STRING_TYPE, null), ALGORITHM_TYPE("algorithmType", "Type of the signing algorithm.", "The type of the algorithm to be used for signing. Needs to fit the provided signing key.", ProviderConfigProperty.STRING_TYPE, Algorithm.RS256), TOKEN_TYPE("tokenType", "Type of the token.", "The type of the token to be created. Will be used as `typ` claim in the JWT-Header.", ProviderConfigProperty.STRING_TYPE, "JWT"), DECOYS("decoys", "Number of decoys to be added.", "The number of decoys to be added to the SD-JWT.", ProviderConfigProperty.STRING_TYPE, 0), HASH_ALGORITHM("hashAlgorithm", "Hash algorithm for SD-JWTs.", "The hash algorithm to be used for the SD-JWTs.", ProviderConfigProperty.STRING_TYPE, "sha-256"), - VISIBLE_CLAIMS("visibleClaims", "Visible claims of the SD-JWT.", "List of claims to stay disclosed in the SD-JWT.", ProviderConfigProperty.MULTIVALUED_STRING_TYPE, null); + VISIBLE_CLAIMS("visibleClaims", "Visible claims of the SD-JWT.", "List of claims to stay disclosed in the SD-JWT.", ProviderConfigProperty.MULTIVALUED_STRING_TYPE, null), + VC_CONFIG_ID("vcConfigId", "Credential configuration identifier", "The identifier of this credential configuration", ProviderConfigProperty.STRING_TYPE, null), + VC_VCT("vct", "Credential Type", "The type of this credential", ProviderConfigProperty.STRING_TYPE, null); private final String key; private final String label; @@ -59,4 +63,4 @@ public enum SigningProperties { public String getKey() { return key; } -} \ No newline at end of file +} diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SigningService.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SigningService.java index 7a1b5da3c1..ab18be852c 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SigningService.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SigningService.java @@ -17,8 +17,14 @@ package org.keycloak.protocol.oid4vc.issuance.signing; +import org.keycloak.common.VerificationException; import org.keycloak.crypto.KeyUse; import org.keycloak.crypto.KeyWrapper; +import org.keycloak.crypto.SignatureProvider; +import org.keycloak.crypto.SignatureVerifierContext; +import org.keycloak.jose.jwk.JWK; +import org.keycloak.jose.jwk.JWKParser; +import org.keycloak.jose.jwk.OKPPublicJWK; import org.keycloak.models.KeycloakSession; /** @@ -34,18 +40,66 @@ public abstract class SigningService implements VerifiableCredentialsSigningS // 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(KeycloakSession keycloakSession, String keyId, String type) { + // As the type is not identical to the format, we use the format as a factory to + // instantiate provider. + protected final String format; + + protected SigningService(KeycloakSession keycloakSession, String keyId, String format, String type) { this.keycloakSession = keycloakSession; this.keyId = keyId; + this.format = format; this.type = type; } + @Override + public String locator() { + // Future implementation might consider credential type or even configuration specific signers. + // See: org.keycloak.protocol.oid4vc.issuance.signing.SdJwtSigningService.locator + return VerifiableCredentialsSigningService.locator(format, null, null); + } + + /** + * Returns the key stored under kid, or the active key for the given jws algorithm, + * + * @param kid + * @param algorithm + * @return + */ protected KeyWrapper getKey(String kid, String algorithm) { + // Allow the service to work with the active key if keyId is null + // And we still have to figure out how to proceed with key rotation + if (keyId == null) { + return keycloakSession.keys().getActiveKey(keycloakSession.getContext().getRealm(), KeyUse.SIG, algorithm); + } return keycloakSession.keys().getKey(keycloakSession.getContext().getRealm(), kid, KeyUse.SIG, algorithm); } + protected SignatureVerifierContext getVerifier(JWK jwk, String jwsAlgorithm) throws VerificationException { + SignatureProvider signatureProvider = keycloakSession.getProvider(SignatureProvider.class, jwsAlgorithm); + return signatureProvider.verifier(getKeyWrapper(jwk, jwsAlgorithm, KeyUse.SIG)); + } + + private KeyWrapper getKeyWrapper(JWK jwk, String algorithm, KeyUse keyUse) { + KeyWrapper keyWrapper = new KeyWrapper(); + keyWrapper.setType(jwk.getKeyType()); + + // Use the algorithm provided by the caller, and not the one inside the jwk (if any) + // As jws validation will also check that one against the value "none" + keyWrapper.setAlgorithm(algorithm); + + // Set the curve if any + if (jwk.getOtherClaims().get(OKPPublicJWK.CRV) != null) { + keyWrapper.setCurve((String) jwk.getOtherClaims().get(OKPPublicJWK.CRV)); + } + + keyWrapper.setUse(keyUse); + JWKParser parser = JWKParser.create(jwk); + keyWrapper.setPublicKey(parser.toPublicKey()); + return keyWrapper; + } + @Override public void close() { // no-op } -} \ No newline at end of file +} diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/VCSigningServiceProviderFactory.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/VCSigningServiceProviderFactory.java index cd3ae0d9bf..c6e3a7c19f 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/VCSigningServiceProviderFactory.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/VCSigningServiceProviderFactory.java @@ -26,7 +26,6 @@ import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSessionFactory; import org.keycloak.models.RealmModel; import org.keycloak.protocol.oid4vc.OID4VCEnvironmentProviderFactory; -import org.keycloak.protocol.oid4vc.model.Format; import org.keycloak.provider.ConfigurationValidationHelper; import org.keycloak.provider.ProviderConfigurationBuilder; @@ -42,15 +41,17 @@ public interface VCSigningServiceProviderFactory extends ComponentFactory extends Provider { * Takes a verifiable credential and signs it according to the implementation. * Depending on the type of the SigningService, it will return a signed representation of the credential * - * @param verifiableCredential the credential to sign + * @param vcIssuanceContext verifiable credential to sign and context info * @return a signed representation */ - T signCredential(VerifiableCredential verifiableCredential); -} \ No newline at end of file + T signCredential(VCIssuanceContext vcIssuanceContext) throws VCIssuerException; + + /** + * Returns the identifier of this service instance, can be either the format alone, + * or the combination between format, credential type and credential configuration id. + * @return + */ + String locator(); + + String LOCATION_SEPARATOR = "::"; + + /** + * We are forcing a structure with 3 components. format::type::configId. We assume format is always set, as + * implementation of this interface always know their format. + * + * @param format + * @param credentialType + * @param vcConfigId + * @return + */ + static String locator(String format, VerifiableCredentialType credentialType, CredentialConfigId vcConfigId){ + return (format == null ? "" : format) + LOCATION_SEPARATOR + + (credentialType == null ? "" : credentialType.getValue()) + LOCATION_SEPARATOR + + (vcConfigId == null ? "" : vcConfigId.getValue()); + } +} diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialConfigId.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialConfigId.java new file mode 100644 index 0000000000..6bb1ac8335 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialConfigId.java @@ -0,0 +1,36 @@ +/* + * Copyright 2024 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.protocol.oid4vc.model; + +/** + * @author Francis Pouatcha + */ +public class CredentialConfigId { + private final String value; + + public static CredentialConfigId from(String value) { + return value == null ? null : new CredentialConfigId(value); + } + + public CredentialConfigId(String value) { + this.value = value; + } + + public String getValue() { + return value; + } +} diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialDefinition.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialDefinition.java new file mode 100644 index 0000000000..05383eb564 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialDefinition.java @@ -0,0 +1,82 @@ +/* + * Copyright 2024 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.protocol.oid4vc.model; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import org.keycloak.util.JsonSerialization; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +/** + * Pojo to represent a CredentialDefinition for internal handling + * + * @author Francis Pouatcha + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class CredentialDefinition { + + @JsonProperty("@context") + private List context; + private List type = new ArrayList<>(); + private CredentialSubject credentialSubject = new CredentialSubject(); + + public List getContext() { + return context; + } + + public CredentialDefinition setContext(List context) { + this.context = context; + return this; + } + + public List getType() { + return type; + } + + public CredentialDefinition setType(List type) { + this.type = type; + return this; + } + + public CredentialSubject getCredentialSubject() { + return credentialSubject; + } + + public CredentialDefinition setCredentialSubject(CredentialSubject credentialSubject) { + this.credentialSubject = credentialSubject; + return this; + } + + public String toJsonString() { + try { + return JsonSerialization.writeValueAsString(this); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + public static CredentialDefinition fromJsonString(String jsonString) { + try { + return JsonSerialization.readValue(jsonString, CredentialDefinition.class); + } catch (IOException e) { + throw new RuntimeException(e); + } + } +} diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialRequest.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialRequest.java index 4cdd735f3d..492e6d7260 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialRequest.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialRequest.java @@ -36,6 +36,14 @@ public class CredentialRequest { private Proof proof; + // I have the choice of either defining format specific fields here, or adding a generic structure, + // opening room for spamming the server. I will prefer having format specific fields. + private String vct; + + // See: https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-format-identifier-3 + @JsonProperty("credential_definition") + private CredentialDefinition credentialDefinition; + public String getFormat() { return format; } @@ -62,4 +70,22 @@ public class CredentialRequest { this.proof = proof; return this; } + + public String getVct() { + return vct; + } + + public CredentialRequest setVct(String vct) { + this.vct = vct; + return this; + } + + public CredentialDefinition getCredentialDefinition() { + return credentialDefinition; + } + + public CredentialRequest setCredentialDefinition(CredentialDefinition credentialDefinition) { + this.credentialDefinition = credentialDefinition; + return this; + } } diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/ErrorType.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/ErrorType.java index 4bb5cf4b56..5cb13c3644 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/model/ErrorType.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/ErrorType.java @@ -31,7 +31,9 @@ public enum ErrorType { UNSUPPORTED_CREDENTIAL_TYPE("unsupported_credential_type"), UNSUPPORTED_CREDENTIAL_FORMAT("unsupported_credential_format"), INVALID_PROOF("invalid_proof"), - INVALID_ENCRYPTION_PARAMETER("invalid_encryption_parameters"); + INVALID_ENCRYPTION_PARAMETER("invalid_encryption_parameters"), + MISSING_CREDENTIAL_CONFIG("missing_credential_config"), + MISSING_CREDENTIAL_CONFIG_AND_FORMAT("missing_credential_config_format"); private final String value; @@ -42,4 +44,4 @@ public enum ErrorType { public String getValue() { return value; } -} \ No newline at end of file +} diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/Format.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/Format.java index 5e801b6058..02ec709c35 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/model/Format.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/Format.java @@ -17,6 +17,9 @@ package org.keycloak.protocol.oid4vc.model; +import java.util.Collections; +import java.util.Set; + /** * Enum of supported credential formats * @@ -39,4 +42,5 @@ public class Format { */ public static final String SD_JWT_VC = "vc+sd-jwt"; + public static final Set SUPPORTED_FORMATS = Collections.unmodifiableSet(Set.of(JWT_VC, LDP_VC, SD_JWT_VC)); } diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/Proof.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/Proof.java index 7ec02e334f..93228c771a 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/model/Proof.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/Proof.java @@ -32,7 +32,14 @@ public class Proof { @JsonProperty("proof_type") private String proofType; - private Object proofObject; + @JsonProperty("jwt") + private String jwt; + + @JsonProperty("cwt") + private String cwt; + + @JsonProperty("ldp_vp") + private Object ldpVp; public String getProofType() { return proofType; @@ -43,12 +50,30 @@ public class Proof { return this; } - public Object getProofObject() { - return proofObject; + public String getJwt() { + return jwt; } - public Proof setProofObject(Object proofObject) { - this.proofObject = proofObject; + public Proof setJwt(String jwt) { + this.jwt = jwt; + return this; + } + + public String getCwt() { + return cwt; + } + + public Proof setCwt(String cwt) { + this.cwt = cwt; + return this; + } + + public Object getLdpVp() { + return ldpVp; + } + + public Proof setLdpVp(Object ldpVp) { + this.ldpVp = ldpVp; return this; } } diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/SupportedCredentialConfiguration.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/SupportedCredentialConfiguration.java index a00fc53dfb..39423ec182 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/model/SupportedCredentialConfiguration.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/SupportedCredentialConfiguration.java @@ -56,6 +56,8 @@ public class SupportedCredentialConfiguration { private static final String CLAIMS_KEY = "claims"; @JsonIgnore private static final String VERIFIABLE_CREDENTIAL_TYPE_KEY = "vct"; + @JsonIgnore + private static final String CREDENTIAL_DEFINITION_KEY = "credential_definition"; private String id; @JsonProperty(FORMAT_KEY) @@ -79,6 +81,9 @@ public class SupportedCredentialConfiguration { @JsonProperty(VERIFIABLE_CREDENTIAL_TYPE_KEY) private String vct; + @JsonProperty(CREDENTIAL_DEFINITION_KEY) + private CredentialDefinition credentialDefinition; + @JsonProperty(PROOF_TYPES_SUPPORTED_KEY) private ProofTypesSupported proofTypesSupported; @@ -89,6 +94,30 @@ public class SupportedCredentialConfiguration { return format; } + /** + * Return the verifiable credential type. Sort of confusing in the specification. + * For sdjwt, we have a "vct" claim. + * See: https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-request-6 + * + * For iso mdl (not yet supported) we have a "doctype" + * See: https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-request-5 + * + * For jwt_vc and ldp_vc, we will be inferring from the "credential_definition" + * See: https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-request-3 + * + * @return + */ + public VerifiableCredentialType deriveType() { + if (Objects.equals(format, Format.SD_JWT_VC)) { + return VerifiableCredentialType.from(vct); + } + return null; + } + + public CredentialConfigId deriveConfiId() { + return CredentialConfigId.from(id); + } + public SupportedCredentialConfiguration setFormat(String format) { this.format = format; return this; @@ -169,6 +198,15 @@ public class SupportedCredentialConfiguration { return this; } + public CredentialDefinition getCredentialDefinition() { + return credentialDefinition; + } + + public SupportedCredentialConfiguration setCredentialDefinition(CredentialDefinition credentialDefinition) { + this.credentialDefinition = credentialDefinition; + return this; + } + public ProofTypesSupported getProofTypesSupported() { return proofTypesSupported; } @@ -180,7 +218,7 @@ public class SupportedCredentialConfiguration { public Map toDotNotation() { Map dotNotation = new HashMap<>(); - Optional.ofNullable(format).ifPresent(format -> dotNotation.put(id + DOT_SEPARATOR + FORMAT_KEY, format.toString())); + Optional.ofNullable(format).ifPresent(format -> dotNotation.put(id + DOT_SEPARATOR + FORMAT_KEY, format)); Optional.ofNullable(vct).ifPresent(vct -> dotNotation.put(id + DOT_SEPARATOR + VERIFIABLE_CREDENTIAL_TYPE_KEY, vct)); Optional.ofNullable(scope).ifPresent(scope -> dotNotation.put(id + DOT_SEPARATOR + SCOPE_KEY, scope)); Optional.ofNullable(cryptographicBindingMethodsSupported).ifPresent(types -> @@ -190,6 +228,7 @@ public class SupportedCredentialConfiguration { Optional.ofNullable(cryptographicSuitesSupported).ifPresent(types -> dotNotation.put(id + DOT_SEPARATOR + CREDENTIAL_SIGNING_ALG_VALUES_SUPPORTED_KEY, String.join(",", credentialSigningAlgValuesSupported))); Optional.ofNullable(claims).ifPresent(c -> dotNotation.put(id + DOT_SEPARATOR + CLAIMS_KEY, c.toJsonString())); + Optional.ofNullable(credentialDefinition).ifPresent(cdef -> dotNotation.put(id + DOT_SEPARATOR + CREDENTIAL_DEFINITION_KEY, cdef.toJsonString())); Optional.ofNullable(display) .ifPresent(d -> d.stream() @@ -223,6 +262,9 @@ public class SupportedCredentialConfiguration { Optional.ofNullable(dotNotated.get(credentialId + DOT_SEPARATOR + CLAIMS_KEY)) .map(Claims::fromJsonString) .ifPresent(supportedCredentialConfiguration::setClaims); + Optional.ofNullable(dotNotated.get(credentialId + DOT_SEPARATOR + CREDENTIAL_DEFINITION_KEY)) + .map(CredentialDefinition::fromJsonString) + .ifPresent(supportedCredentialConfiguration::setCredentialDefinition); String displayKeyPrefix = credentialId + DOT_SEPARATOR + DISPLAY_KEY + DOT_SEPARATOR; List displayList = dotNotated.entrySet().stream() @@ -231,7 +273,7 @@ public class SupportedCredentialConfiguration { .map(entry -> DisplayObject.fromJsonString(entry.getValue())) .collect(Collectors.toList()); - if(!displayList.isEmpty()){ + if (!displayList.isEmpty()){ supportedCredentialConfiguration.setDisplay(displayList); } @@ -247,11 +289,11 @@ public class SupportedCredentialConfiguration { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; SupportedCredentialConfiguration that = (SupportedCredentialConfiguration) o; - return Objects.equals(id, that.id) && format == that.format && Objects.equals(scope, that.scope) && Objects.equals(cryptographicBindingMethodsSupported, that.cryptographicBindingMethodsSupported) && Objects.equals(cryptographicSuitesSupported, that.cryptographicSuitesSupported) && Objects.equals(credentialSigningAlgValuesSupported, that.credentialSigningAlgValuesSupported) && Objects.equals(display, that.display) && Objects.equals(vct, that.vct) && Objects.equals(proofTypesSupported, that.proofTypesSupported) && Objects.equals(claims, that.claims); + return Objects.equals(id, that.id) && Objects.equals(format, that.format) && Objects.equals(scope, that.scope) && Objects.equals(cryptographicBindingMethodsSupported, that.cryptographicBindingMethodsSupported) && Objects.equals(cryptographicSuitesSupported, that.cryptographicSuitesSupported) && Objects.equals(credentialSigningAlgValuesSupported, that.credentialSigningAlgValuesSupported) && Objects.equals(display, that.display) && Objects.equals(vct, that.vct) && Objects.equals(credentialDefinition, that.credentialDefinition) && Objects.equals(proofTypesSupported, that.proofTypesSupported) && Objects.equals(claims, that.claims); } @Override public int hashCode() { - return Objects.hash(id, format, scope, cryptographicBindingMethodsSupported, cryptographicSuitesSupported, credentialSigningAlgValuesSupported, display, vct, proofTypesSupported, claims); + return Objects.hash(id, format, scope, cryptographicBindingMethodsSupported, cryptographicSuitesSupported, credentialSigningAlgValuesSupported, display, vct, credentialDefinition, proofTypesSupported, claims); } } diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/VerifiableCredential.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/VerifiableCredential.java index 36c4ddf332..2e9fd7bf93 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/model/VerifiableCredential.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/VerifiableCredential.java @@ -26,7 +26,6 @@ import com.fasterxml.jackson.annotation.JsonProperty; import java.net.URI; import java.time.Instant; import java.util.ArrayList; -import java.util.Date; import java.util.HashMap; import java.util.List; import java.util.Map; diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/VerifiableCredentialType.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/VerifiableCredentialType.java new file mode 100644 index 0000000000..c0ab26beeb --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/VerifiableCredentialType.java @@ -0,0 +1,35 @@ +/* + * Copyright 2024 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.protocol.oid4vc.model; + +/** + * @author Francis Pouatcha + */ +public class VerifiableCredentialType { + private final String value; + + public static VerifiableCredentialType from(String value){ + return value == null? null : new VerifiableCredentialType(value); + } + public VerifiableCredentialType(String value) { + this.value = value; + } + + public String getValue() { + return value; + } +} diff --git a/services/src/main/java/org/keycloak/protocol/oidc/grants/PreAuthorizedCodeGrantType.java b/services/src/main/java/org/keycloak/protocol/oidc/grants/PreAuthorizedCodeGrantType.java index d0ff1ff11a..82b5f77a1a 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/grants/PreAuthorizedCodeGrantType.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/grants/PreAuthorizedCodeGrantType.java @@ -52,10 +52,12 @@ public class PreAuthorizedCodeGrantType extends OAuth2GrantTypeBase { LOGGER.debug("Process grant request for preauthorized."); setContext(context); - String code = formParams.getFirst(OAuth2Constants.CODE); + // See: https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-token-request + String code = formParams.getFirst(PreAuthorizedCodeGrantTypeFactory.CODE_REQUEST_PARAM); if (code == null) { - String errorMessage = "Missing parameter: " + OAuth2Constants.CODE; + // See: https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-token-request + String errorMessage = "Missing parameter: " + PreAuthorizedCodeGrantTypeFactory.CODE_REQUEST_PARAM; event.detail(Details.REASON, errorMessage); event.error(Errors.INVALID_CODE); throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, diff --git a/services/src/main/java/org/keycloak/protocol/oidc/grants/PreAuthorizedCodeGrantTypeFactory.java b/services/src/main/java/org/keycloak/protocol/oidc/grants/PreAuthorizedCodeGrantTypeFactory.java index 72425fa8f0..be13b00bd4 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/grants/PreAuthorizedCodeGrantTypeFactory.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/grants/PreAuthorizedCodeGrantTypeFactory.java @@ -31,6 +31,7 @@ import org.keycloak.provider.EnvironmentDependentProviderFactory; public class PreAuthorizedCodeGrantTypeFactory implements OAuth2GrantTypeFactory, EnvironmentDependentProviderFactory { public static final String GRANT_TYPE = "urn:ietf:params:oauth:grant-type:pre-authorized_code"; + public static final String CODE_REQUEST_PARAM = "pre-authorized_code"; @Override public OAuth2GrantType create(KeycloakSession session) { diff --git a/services/src/test/java/org/keycloak/protocol/oid4vc/OID4VCClientRegistrationProviderTest.java b/services/src/test/java/org/keycloak/protocol/oid4vc/OID4VCClientRegistrationProviderTest.java index b6caeff739..2afc66c483 100644 --- a/services/src/test/java/org/keycloak/protocol/oid4vc/OID4VCClientRegistrationProviderTest.java +++ b/services/src/test/java/org/keycloak/protocol/oid4vc/OID4VCClientRegistrationProviderTest.java @@ -42,7 +42,7 @@ public class OID4VCClientRegistrationProviderTest { { "Single Supported Credential with format and single-type.", Map.of( - "vc.credential-id.format", Format.JWT_VC.toString(), + "vc.credential-id.format", Format.JWT_VC, "vc.credential-id.scope", "VerifiableCredential"), new OID4VCClient(null, "did:web:test.org", List.of(new SupportedCredentialConfiguration() @@ -54,7 +54,7 @@ public class OID4VCClientRegistrationProviderTest { { "Single Supported Credential with format and multi-type.", Map.of( - "vc.credential-id.format", Format.JWT_VC.toString(), + "vc.credential-id.format", Format.JWT_VC, "vc.credential-id.scope", "AnotherCredential"), new OID4VCClient(null, "did:web:test.org", List.of(new SupportedCredentialConfiguration() @@ -66,7 +66,7 @@ public class OID4VCClientRegistrationProviderTest { { "Single Supported Credential with format, multi-type and a display object.", Map.of( - "vc.credential-id.format", Format.JWT_VC.toString(), + "vc.credential-id.format", Format.JWT_VC, "vc.credential-id.scope", "AnotherCredential", "vc.credential-id.display.0", "{\"name\":\"Another\",\"locale\":\"en\"}"), new OID4VCClient(null, "did:web:test.org", @@ -80,10 +80,10 @@ public class OID4VCClientRegistrationProviderTest { { "Multiple Supported Credentials.", Map.of( - "vc.first-id.format", Format.JWT_VC.toString(), + "vc.first-id.format", Format.JWT_VC, "vc.first-id.scope", "AnotherCredential", "vc.first-id.display.0", "{\"name\":\"First\",\"locale\":\"en\"}", - "vc.second-id.format", Format.SD_JWT_VC.toString(), + "vc.second-id.format", Format.SD_JWT_VC, "vc.second-id.scope", "MyType", "vc.second-id.display.0", "{\"name\":\"Second Credential\",\"locale\":\"de\"}", "vc.second-id.proof_types_supported","{\"jwt\":{\"proof_signing_alg_values_supported\":[\"ES256\"]}}"), diff --git a/services/src/test/java/org/keycloak/protocol/oid4vc/model/ProofSerializationTest.java b/services/src/test/java/org/keycloak/protocol/oid4vc/model/ProofSerializationTest.java new file mode 100644 index 0000000000..857a2f7136 --- /dev/null +++ b/services/src/test/java/org/keycloak/protocol/oid4vc/model/ProofSerializationTest.java @@ -0,0 +1,36 @@ +/* + * Copyright 2024 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.protocol.oid4vc.model; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.Test; +import static org.junit.Assert.assertEquals; + +/** + * + * @author Francis Pouatcha + */ +public class ProofSerializationTest { + @Test + public void testSerializeProof() throws JsonProcessingException { + ObjectMapper objectMapper = new ObjectMapper(); + String proofStr = " { \"proof_type\": \"jwt\", \"jwt\": \"ewogICJhbGciOiAiRVMyNTYiLAogICJ0eXAiOiAib3BlbmlkNHZjaS1wcm9vZitqd3QiLAogICJqd2siOiB7CiAgICAia3R5IjogIkVDIiwKICAgICJjcnYiOiAiUC0yNTYiLAogICAgIngiOiAiWEdkNU9GU1pwc080VkRRTUZrR3Z0TDVHU2FXWWE3SzBrNGhxUUdLbFBjWSIsCiAgICAieSI6ICJiSXFDaGhoVDdfdnYtYmhuRmVuREljVzVSUjRKTS1nME5sUi1qZGlHemNFIgogIH0KfQo.ewogICJpc3MiOiAib2lkNHZjaS1jbGllbnQiLAogICJhdWQiOiAiaHR0cDovL2xvY2FsaG9zdDo4MDgwL3JlYWxtcy9tYXN0ZXIiLAogICJpYXQiOiAxNzE4OTU5MzY5LAogICJub25jZSI6ICJOODAxTEpVam1qQ1FDMUpzTm5lTllXWFpqZHQ2UEZSd01pNkpoTTU1OF9JIgp9Cg.mKKrkRkG1BfOzgsKwcZhop74EHflzHslO_NFOloKPnZ-ms6t0SnsTNDQjM_o4FBQAgtv_fnFEWRgnkNIa34gvQ\" } "; + Proof proof = objectMapper.readValue(proofStr, Proof.class); + assertEquals(ProofType.JWT, proof.getProofType()); + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/PreAuthorizedGrantTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/PreAuthorizedGrantTest.java index 7ae67c7f96..a67b56f3cd 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/PreAuthorizedGrantTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/PreAuthorizedGrantTest.java @@ -98,7 +98,7 @@ public class PreAuthorizedGrantTest extends AbstractTestRealmKeycloakTest { HttpPost post = new HttpPost(getTokenEndpoint()); List parameters = new LinkedList<>(); parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, PreAuthorizedCodeGrantTypeFactory.GRANT_TYPE)); - parameters.add(new BasicNameValuePair("code", preAuthorizedCode)); + parameters.add(new BasicNameValuePair("pre-authorized_code", preAuthorizedCode)); UrlEncodedFormEntity formEntity = new UrlEncodedFormEntity(parameters, "UTF-8"); post.setEntity(formEntity); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/JwtSigningServiceTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/JwtSigningServiceTest.java index e20f57f78e..bdfeb1650c 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/JwtSigningServiceTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/JwtSigningServiceTest.java @@ -30,6 +30,7 @@ import org.keycloak.crypto.KeyWrapper; import org.keycloak.crypto.ServerECDSASignatureVerifierContext; import org.keycloak.crypto.SignatureVerifierContext; import org.keycloak.models.KeycloakSession; +import org.keycloak.protocol.oid4vc.issuance.VCIssuanceContext; import org.keycloak.protocol.oid4vc.issuance.signing.JwtSigningService; import org.keycloak.protocol.oid4vc.issuance.signing.SigningServiceException; import org.keycloak.protocol.oid4vc.model.CredentialSubject; @@ -153,7 +154,7 @@ public class JwtSigningServiceTest extends OID4VCTest { VerifiableCredential testCredential = getTestCredential(claims); - String jwtCredential = jwtSigningService.signCredential(testCredential); + String jwtCredential = jwtSigningService.signCredential(new VCIssuanceContext().setVerifiableCredential(testCredential)); SignatureVerifierContext verifierContext = null; switch (algorithm) { diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/LDSigningServiceTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/LDSigningServiceTest.java index 57d3850d8f..913d62129c 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/LDSigningServiceTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/LDSigningServiceTest.java @@ -23,6 +23,7 @@ import org.keycloak.common.crypto.CryptoIntegration; import org.keycloak.common.util.MultivaluedHashMap; import org.keycloak.crypto.KeyWrapper; import org.keycloak.models.KeycloakSession; +import org.keycloak.protocol.oid4vc.issuance.VCIssuanceContext; import org.keycloak.protocol.oid4vc.issuance.signing.LDSigningService; import org.keycloak.protocol.oid4vc.issuance.signing.SigningServiceException; import org.keycloak.protocol.oid4vc.model.CredentialSubject; @@ -151,7 +152,7 @@ public class LDSigningServiceTest extends OID4VCTest { VerifiableCredential testCredential = getTestCredential(claims); - VerifiableCredential verifiableCredential = ldSigningService.signCredential(testCredential); + VerifiableCredential verifiableCredential = ldSigningService.signCredential(new VCIssuanceContext().setVerifiableCredential(testCredential)); assertEquals("The types should be included", TEST_TYPES, verifiableCredential.getType()); assertEquals("The issuer should be included", TEST_DID, verifiableCredential.getIssuer()); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerEndpointTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerEndpointTest.java index a7a9d1e81e..6c1995e02e 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerEndpointTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerEndpointTest.java @@ -17,29 +17,20 @@ package org.keycloak.testsuite.oid4vc.issuance.signing; -import com.fasterxml.jackson.databind.ObjectMapper; -import jakarta.ws.rs.BadRequestException; -import jakarta.ws.rs.core.Response; -import jakarta.ws.rs.core.HttpHeaders; -import jakarta.ws.rs.core.UriBuilder; import jakarta.ws.rs.client.Client; -import jakarta.ws.rs.client.Entity; import jakarta.ws.rs.client.WebTarget; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.UriBuilder; import org.apache.commons.io.IOUtils; import org.apache.http.HttpStatus; -import org.apache.http.NameValuePair; -import org.apache.http.client.entity.UrlEncodedFormEntity; import org.apache.http.client.methods.CloseableHttpResponse; -import org.apache.http.client.methods.HttpGet; import org.apache.http.client.methods.HttpPost; import org.apache.http.entity.ContentType; import org.apache.http.entity.StringEntity; import org.apache.http.impl.client.CloseableHttpClient; import org.apache.http.impl.client.HttpClientBuilder; -import org.apache.http.message.BasicNameValuePair; import org.junit.Before; -import org.junit.Test; -import org.keycloak.OAuth2Constants; import org.keycloak.TokenVerifier; import org.keycloak.admin.client.resource.ClientResource; import org.keycloak.admin.client.resource.RealmResource; @@ -56,21 +47,16 @@ import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerWellKnownProviderFactor import org.keycloak.protocol.oid4vc.issuance.TimeProvider; import org.keycloak.protocol.oid4vc.issuance.signing.JwtSigningService; import org.keycloak.protocol.oid4vc.model.CredentialIssuer; -import org.keycloak.protocol.oid4vc.model.CredentialOfferURI; import org.keycloak.protocol.oid4vc.model.CredentialRequest; import org.keycloak.protocol.oid4vc.model.CredentialResponse; -import org.keycloak.protocol.oid4vc.model.CredentialsOffer; import org.keycloak.protocol.oid4vc.model.Format; -import org.keycloak.protocol.oid4vc.model.OfferUriType; -import org.keycloak.protocol.oid4vc.model.PreAuthorizedGrant; import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration; import org.keycloak.protocol.oid4vc.model.VerifiableCredential; import org.keycloak.protocol.oidc.OIDCLoginProtocol; -import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantTypeFactory; -import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation; import org.keycloak.representations.JsonWebToken; import org.keycloak.representations.idm.ClientRepresentation; import org.keycloak.representations.idm.ClientScopeRepresentation; +import org.keycloak.representations.idm.ComponentExportRepresentation; import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.services.managers.AppAuthManager; import org.keycloak.services.managers.AuthenticationManager; @@ -85,7 +71,6 @@ import org.keycloak.util.JsonSerialization; import java.io.IOException; import java.net.URI; import java.nio.charset.StandardCharsets; -import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.function.BiFunction; @@ -95,13 +80,14 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; -import static org.junit.Assert.fail; -public class OID4VCIssuerEndpointTest extends OID4VCTest { +/** + * Moved test to subclass. so we can reuse initialization code. + */ +public abstract class OID4VCIssuerEndpointTest extends OID4VCTest { - private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - private static final TimeProvider TIME_PROVIDER = new OID4VCTest.StaticTimeProvider(1000); - private CloseableHttpClient httpClient; + protected static final TimeProvider TIME_PROVIDER = new OID4VCTest.StaticTimeProvider(1000); + protected CloseableHttpClient httpClient; @Before @@ -111,349 +97,11 @@ public class OID4VCIssuerEndpointTest extends OID4VCTest { } - // ----- getCredentialOfferUri - - @Test(expected = BadRequestException.class) - public void testGetCredentialOfferUriUnsupportedCredential() throws Throwable { - String token = getBearerToken(oauth); - withCausePropagation(() -> testingClient.server(TEST_REALM_NAME) - .run((session -> { - AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); - authenticator.setTokenString(token); - - OID4VCIssuerEndpoint oid4VCIssuerEndpoint = prepareIssuerEndpoint(session, authenticator); - oid4VCIssuerEndpoint.getCredentialOfferURI("inexistent-id", OfferUriType.URI, 0, 0); - }))); - - } - - @Test(expected = BadRequestException.class) - public void testGetCredentialOfferUriUnauthorized() throws Throwable { - withCausePropagation(() -> testingClient.server(TEST_REALM_NAME) - .run((session -> { - AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); - authenticator.setTokenString(null); - OID4VCIssuerEndpoint oid4VCIssuerEndpoint = prepareIssuerEndpoint(session, authenticator); - oid4VCIssuerEndpoint.getCredentialOfferURI("test-credential", OfferUriType.URI, 0, 0); - }))); - } - - @Test(expected = BadRequestException.class) - public void testGetCredentialOfferUriInvalidToken() throws Throwable { - withCausePropagation(() -> testingClient.server(TEST_REALM_NAME) - .run((session -> { - AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); - authenticator.setTokenString("invalid-token"); - OID4VCIssuerEndpoint oid4VCIssuerEndpoint = prepareIssuerEndpoint(session, authenticator); - oid4VCIssuerEndpoint.getCredentialOfferURI("test-credential", OfferUriType.URI, 0, 0); - }))); - } - - @Test - public void testGetCredentialOfferURI() { - String token = getBearerToken(oauth); - testingClient - .server(TEST_REALM_NAME) - .run((session) -> { - try { - AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); - authenticator.setTokenString(token); - OID4VCIssuerEndpoint oid4VCIssuerEndpoint = prepareIssuerEndpoint(session, authenticator); - - Response response = oid4VCIssuerEndpoint.getCredentialOfferURI("test-credential", OfferUriType.URI, 0, 0); - - assertEquals("An offer uri should have been returned.", HttpStatus.SC_OK, response.getStatus()); - CredentialOfferURI credentialOfferURI = new ObjectMapper().convertValue(response.getEntity(), CredentialOfferURI.class); - assertNotNull("A nonce should be included.", credentialOfferURI.getNonce()); - assertNotNull("The issuer uri should be provided.", credentialOfferURI.getIssuer()); - } catch (Exception e) { - throw new RuntimeException(e); - } - }); - - } - - private static String getBearerToken(OAuthClient oAuthClient) { + protected String getBearerToken(OAuthClient oAuthClient) { OAuthClient.AuthorizationEndpointResponse authorizationEndpointResponse = oAuthClient.doLogin("john", "password"); return oAuthClient.doAccessTokenRequest(authorizationEndpointResponse.getCode(), "password").getAccessToken(); } - // ----- getCredentialOffer - - @Test(expected = BadRequestException.class) - public void testGetCredentialOfferUnauthorized() throws Throwable { - withCausePropagation(() -> { - testingClient - .server(TEST_REALM_NAME) - .run((session) -> { - AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); - authenticator.setTokenString(null); - OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); - issuerEndpoint.getCredentialOffer("nonce"); - }); - }); - } - - @Test(expected = BadRequestException.class) - public void testGetCredentialOfferWithoutNonce() throws Throwable { - String token = getBearerToken(oauth); - withCausePropagation(() -> { - testingClient - .server(TEST_REALM_NAME) - .run((session -> { - AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); - authenticator.setTokenString(token); - OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); - issuerEndpoint.getCredentialOffer(null); - })); - }); - } - - @Test(expected = BadRequestException.class) - public void testGetCredentialOfferWithoutAPreparedOffer() throws Throwable { - String token = getBearerToken(oauth); - withCausePropagation(() -> { - testingClient - .server(TEST_REALM_NAME) - .run((session -> { - AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); - authenticator.setTokenString(token); - OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); - issuerEndpoint.getCredentialOffer("unpreparedNonce"); - })); - }); - } - - @Test(expected = BadRequestException.class) - public void testGetCredentialOfferWithABrokenNote() throws Throwable { - String token = getBearerToken(oauth); - withCausePropagation(() -> { - testingClient - .server(TEST_REALM_NAME) - .run((session -> { - AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); - authenticator.setTokenString(token); - String nonce = prepareNonce(authenticator, "invalidNote"); - OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); - issuerEndpoint.getCredentialOffer(nonce); - })); - }); - } - - @Test - public void testGetCredentialOffer() { - String token = getBearerToken(oauth); - String rootURL = suiteContext.getAuthServerInfo().getContextRoot().toString(); - testingClient - .server(TEST_REALM_NAME) - .run((session) -> { - AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); - authenticator.setTokenString(token); - - SupportedCredentialConfiguration supportedCredentialConfiguration = new SupportedCredentialConfiguration() - .setId("test-credential") - .setScope("VerifiableCredential") - .setFormat(Format.JWT_VC); - String nonce = prepareNonce(authenticator, OBJECT_MAPPER.writeValueAsString(supportedCredentialConfiguration)); - - OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); - Response credentialOfferResponse = issuerEndpoint.getCredentialOffer(nonce); - assertEquals("The offer should have been returned.", HttpStatus.SC_OK, credentialOfferResponse.getStatus()); - Object credentialOfferEntity = credentialOfferResponse.getEntity(); - assertNotNull("An actual offer should be in the response.", credentialOfferEntity); - - CredentialsOffer credentialsOffer = OBJECT_MAPPER.convertValue(credentialOfferEntity, CredentialsOffer.class); - assertNotNull("Credentials should have been offered.", credentialsOffer.getCredentialConfigurationIds()); - assertFalse("Credentials should have been offered.", credentialsOffer.getCredentialConfigurationIds().isEmpty()); - List supportedCredentials = credentialsOffer.getCredentialConfigurationIds(); - assertEquals("Exactly one credential should have been returned.", 1, supportedCredentials.size()); - String offeredCredentialId = supportedCredentials.get(0); - assertEquals("The credential should be as defined in the note.", supportedCredentialConfiguration.getId(), offeredCredentialId); - - PreAuthorizedGrant grant = credentialsOffer.getGrants(); - assertNotNull("The grant should be included.", grant); - assertNotNull("The grant should contain the pre-authorized code.", grant.getPreAuthorizedCode()); - assertNotNull("The actual pre-authorized code should be included.", grant - .getPreAuthorizedCode() - .getPreAuthorizedCode()); - - assertEquals("The correct issuer should be included.", rootURL + "/auth/realms/" + TEST_REALM_NAME, credentialsOffer.getCredentialIssuer()); - }); - } - - // ----- requestCredential - - @Test(expected = BadRequestException.class) - public void testRequestCredentialUnauthorized() throws Throwable { - withCausePropagation(() -> { - testingClient - .server(TEST_REALM_NAME) - .run((session -> { - AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); - authenticator.setTokenString(null); - OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); - issuerEndpoint.requestCredential(new CredentialRequest() - .setFormat(Format.JWT_VC) - .setCredentialIdentifier("test-credential")); - })); - }); - } - - @Test(expected = BadRequestException.class) - public void testRequestCredentialInvalidToken() throws Throwable { - withCausePropagation(() -> { - testingClient - .server(TEST_REALM_NAME) - .run((session -> { - AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); - authenticator.setTokenString("token"); - OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); - issuerEndpoint.requestCredential(new CredentialRequest() - .setFormat(Format.JWT_VC) - .setCredentialIdentifier("test-credential")); - })); - }); - } - - @Test(expected = BadRequestException.class) - public void testRequestCredentialUnsupportedFormat() throws Throwable { - String token = getBearerToken(oauth); - withCausePropagation(() -> { - testingClient - .server(TEST_REALM_NAME) - .run((session -> { - AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); - authenticator.setTokenString(token); - OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); - issuerEndpoint.requestCredential(new CredentialRequest() - .setFormat(Format.SD_JWT_VC) - .setCredentialIdentifier("test-credential")); - })); - }); - } - - @Test(expected = BadRequestException.class) - public void testRequestCredentialUnsupportedCredential() throws Throwable { - String token = getBearerToken(oauth); - withCausePropagation(() -> { - testingClient - .server(TEST_REALM_NAME) - .run((session -> { - AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); - authenticator.setTokenString(token); - OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); - issuerEndpoint.requestCredential(new CredentialRequest() - .setFormat(Format.JWT_VC) - .setCredentialIdentifier("no-such-credential")); - })); - }); - } - - @Test - public void testRequestCredential() { - String token = getBearerToken(oauth); - testingClient - .server(TEST_REALM_NAME) - .run((session -> { - AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); - authenticator.setTokenString(token); - OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); - CredentialRequest credentialRequest = new CredentialRequest() - .setFormat(Format.JWT_VC) - .setCredentialIdentifier("test-credential"); - Response credentialResponse = issuerEndpoint.requestCredential(credentialRequest); - assertEquals("The credential request should be answered successfully.", HttpStatus.SC_OK, credentialResponse.getStatus()); - assertNotNull("A credential should be responded.", credentialResponse.getEntity()); - CredentialResponse credentialResponseVO = OBJECT_MAPPER.convertValue(credentialResponse.getEntity(), CredentialResponse.class); - JsonWebToken jsonWebToken = TokenVerifier.create((String) credentialResponseVO.getCredential(), JsonWebToken.class).getToken(); - - assertNotNull("A valid credential string should have been responded", jsonWebToken); - assertNotNull("The credentials should be included at the vc-claim.", jsonWebToken.getOtherClaims().get("vc")); - VerifiableCredential credential = // - JsonSerialization.mapper.convertValue(jsonWebToken.getOtherClaims().get( - "vc"), VerifiableCredential.class); - assertNotNull("@context is a required VC property", credential.getContext()); - assertEquals(1, credential.getContext().size()); - assertEquals(VerifiableCredential.VC_CONTEXT_V1, credential.getContext().get(0)); - assertTrue("The static claim should be set.", credential.getCredentialSubject().getClaims().containsKey("VerifiableCredential")); - assertFalse("Only mappers supported for the requested type should have been evaluated.", credential.getCredentialSubject().getClaims().containsKey("AnotherCredentialType")); - })); - } - - // Tests the complete flow from - // 1. Retrieving the credential-offer-uri - // 2. Using the uri to get the actual credential offer - // 3. Get the issuer metadata - // 4. Get the openid-configuration - // 5. Get an access token for the pre-authorized code - // 6. Get the credential - @Test - public void testCredentialIssuance() throws Exception { - - String token = getBearerToken(oauth); - - // 1. Retrieving the credential-offer-uri - HttpGet getCredentialOfferURI = new HttpGet(getBasePath(TEST_REALM_NAME) + "credential-offer-uri?credential_configuration_id=test-credential"); - getCredentialOfferURI.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token); - CloseableHttpResponse credentialOfferURIResponse = httpClient.execute(getCredentialOfferURI); - - assertEquals("A valid offer uri should be returned", HttpStatus.SC_OK, credentialOfferURIResponse.getStatusLine().getStatusCode()); - String s = IOUtils.toString(credentialOfferURIResponse.getEntity().getContent(), StandardCharsets.UTF_8); - CredentialOfferURI credentialOfferURI = JsonSerialization.readValue(s, CredentialOfferURI.class); - - // 2. Using the uri to get the actual credential offer - HttpGet getCredentialOffer = new HttpGet(credentialOfferURI.getIssuer() + "/" + credentialOfferURI.getNonce()); - getCredentialOffer.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token); - CloseableHttpResponse credentialOfferResponse = httpClient.execute(getCredentialOffer); - - assertEquals("A valid offer should be returned", HttpStatus.SC_OK, credentialOfferResponse.getStatusLine().getStatusCode()); - s = IOUtils.toString(credentialOfferResponse.getEntity().getContent(), StandardCharsets.UTF_8); - CredentialsOffer credentialsOffer = JsonSerialization.readValue(s, CredentialsOffer.class); - - // 3. Get the issuer metadata - HttpGet getIssuerMetadata = new HttpGet(credentialsOffer.getCredentialIssuer() + "/.well-known/openid-credential-issuer"); - CloseableHttpResponse issuerMetadataResponse = httpClient.execute(getIssuerMetadata); - assertEquals(HttpStatus.SC_OK, issuerMetadataResponse.getStatusLine().getStatusCode()); - s = IOUtils.toString(issuerMetadataResponse.getEntity().getContent(), StandardCharsets.UTF_8); - CredentialIssuer credentialIssuer = JsonSerialization.readValue(s, CredentialIssuer.class); - - assertEquals("We only expect one authorization server.", 1, credentialIssuer.getAuthorizationServers().size()); - - // 4. Get the openid-configuration - HttpGet getOpenidConfiguration = new HttpGet(credentialIssuer.getAuthorizationServers().get(0) + "/.well-known/openid-configuration"); - CloseableHttpResponse openidConfigResponse = httpClient.execute(getOpenidConfiguration); - assertEquals(HttpStatus.SC_OK, openidConfigResponse.getStatusLine().getStatusCode()); - s = IOUtils.toString(openidConfigResponse.getEntity().getContent(), StandardCharsets.UTF_8); - OIDCConfigurationRepresentation openidConfig = JsonSerialization.readValue(s, OIDCConfigurationRepresentation.class); - - assertNotNull("A token endpoint should be included.", openidConfig.getTokenEndpoint()); - assertTrue("The pre-authorized code should be supported.", openidConfig.getGrantTypesSupported().contains(PreAuthorizedCodeGrantTypeFactory.GRANT_TYPE)); - - // 5. Get an access token for the pre-authorized code - HttpPost postPreAuthorizedCode = new HttpPost(openidConfig.getTokenEndpoint()); - List parameters = new LinkedList<>(); - parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, PreAuthorizedCodeGrantTypeFactory.GRANT_TYPE)); - parameters.add(new BasicNameValuePair("code", credentialsOffer.getGrants().getPreAuthorizedCode().getPreAuthorizedCode())); - UrlEncodedFormEntity formEntity = new UrlEncodedFormEntity(parameters, "UTF-8"); - postPreAuthorizedCode.setEntity(formEntity); - OAuthClient.AccessTokenResponse accessTokenResponse = new OAuthClient.AccessTokenResponse(httpClient.execute(postPreAuthorizedCode)); - assertEquals(HttpStatus.SC_OK, accessTokenResponse.getStatusCode()); - String theToken = accessTokenResponse.getAccessToken(); - - // 6. Get the credential - credentialsOffer.getCredentialConfigurationIds().stream() - .map(offeredCredentialId -> credentialIssuer.getCredentialsSupported().get(offeredCredentialId)) - .forEach(supportedCredential -> { - try { - requestOffer(theToken, credentialIssuer.getCredentialEndpoint(), supportedCredential); - } catch (IOException e) { - fail("Was not able to get the credential."); - } catch (VerificationException e) { - throw new RuntimeException(e); - } - }); - } - private ClientResource findClientByClientId(RealmResource realm, String clientId) { for (ClientRepresentation c : realm.clients().findAll()) { if (clientId.equals(c.getClientId())) { @@ -488,11 +136,11 @@ public class OID4VCIssuerEndpointTest extends OID4VCTest { "vc." + credentialConfigurationId + ".scope", scope)); clientRepresentation.setProtocolMappers( List.of( - getRoleMapper(clientId), - getEmailMapper(), - getIdMapper(), - getStaticClaimMapper(scope), - getStaticClaimMapper("AnotherCredentialType") + getRoleMapper(clientId, "VerifiableCredential"), + getUserAttributeMapper("email", "email", "VerifiableCredential"), + getIdMapper("VerifiableCredential"), + getStaticClaimMapper(scope, "VerifiableCredential"), + getStaticClaimMapper("AnotherCredentialType", "VerifiableCredential") ) ); @@ -518,7 +166,7 @@ public class OID4VCIssuerEndpointTest extends OID4VCTest { // use supported values by Credential Issuer Metadata String testCredentialConfigurationId = "test-credential"; String testScope = "VerifiableCredential"; - String testFormat = Format.JWT_VC.toString(); + String testFormat = Format.JWT_VC; // register optional client scope String scopeId = registerOptionalClientScope(testScope); @@ -546,8 +194,8 @@ public class OID4VCIssuerEndpointTest extends OID4VCTest { // 2. Using the code to get access token // 3. Get the credential configuration id from issuer metadata at .wellKnown // 4. With the access token, get the credential - private void testCredentialIssuanceWithAuthZCodeFlow(BiFunction f, Consumer> c) throws Exception { - testCredentialIssuanceWithAuthZCodeFlow(m->{ + protected void testCredentialIssuanceWithAuthZCodeFlow(BiFunction f, Consumer> c) throws Exception { + testCredentialIssuanceWithAuthZCodeFlow(m -> { String testClientId = m.get("clientId"); String testScope = m.get("scope"); String testFormat = m.get("format"); @@ -586,70 +234,17 @@ public class OID4VCIssuerEndpointTest extends OID4VCTest { "accessToken", token, "credentialTarget", credentialTarget, "credentialRequest", request - )); + )); } } - } catch (IOException e) { + } catch (IOException e) { Assert.fail(); } }); } - @Test - public void testCredentialIssuanceWithAuthZCodeWithScopeMatched() throws Exception { - testCredentialIssuanceWithAuthZCodeFlow((testClientId, testScope) -> getBearerToken(oauth.clientId(testClientId).openid(false).scope(testScope)), - m -> { - String accessToken = (String)m.get("accessToken"); - WebTarget credentialTarget = (WebTarget)m.get("credentialTarget"); - CredentialRequest credentialRequest = (CredentialRequest)m.get("credentialRequest"); - - try (Response response = credentialTarget.request().header(HttpHeaders.AUTHORIZATION, "bearer " + accessToken).post(Entity.json(credentialRequest))) { - CredentialResponse credentialResponse = JsonSerialization.readValue(response.readEntity(String.class),CredentialResponse.class); - - assertEquals(200, response.getStatus()); - JsonWebToken jsonWebToken = TokenVerifier.create((String) credentialResponse.getCredential(), JsonWebToken.class).getToken(); - assertEquals("did:web:test.org", jsonWebToken.getIssuer()); - - VerifiableCredential credential = JsonSerialization.mapper.convertValue(jsonWebToken.getOtherClaims().get("vc"), VerifiableCredential.class); - assertEquals(TEST_TYPES, credential.getType()); - assertEquals(TEST_DID, credential.getIssuer()); - assertEquals("john@email.cz", credential.getCredentialSubject().getClaims().get("email")); - } catch (IOException | VerificationException e) { - Assert.fail(); - } - }); - } - - @Test - public void testCredentialIssuanceWithAuthZCodeWithScopeUnmatched() throws Exception { - testCredentialIssuanceWithAuthZCodeFlow((testClientId, testScope) -> getBearerToken(oauth.clientId(testClientId).openid(false).scope("email")), // set registered different scope - m -> { - String accessToken = (String)m.get("accessToken"); - WebTarget credentialTarget = (WebTarget)m.get("credentialTarget"); - CredentialRequest credentialRequest = (CredentialRequest)m.get("credentialRequest"); - - try (Response response = credentialTarget.request().header(HttpHeaders.AUTHORIZATION, "bearer " + accessToken).post(Entity.json(credentialRequest))) { - assertEquals(400, response.getStatus()); - } - }); - } - - @Test - public void testCredentialIssuanceWithAuthZCodeSWithoutScope() throws Exception { - testCredentialIssuanceWithAuthZCodeFlow((testClientId, testScope) -> getBearerToken(oauth.clientId(testClientId).openid(false).scope(null)), // no scope - m -> { - String accessToken = (String)m.get("accessToken"); - WebTarget credentialTarget = (WebTarget)m.get("credentialTarget"); - CredentialRequest credentialRequest = (CredentialRequest)m.get("credentialRequest"); - - try (Response response = credentialTarget.request().header(HttpHeaders.AUTHORIZATION, "bearer " + accessToken).post(Entity.json(credentialRequest))) { - assertEquals(400, response.getStatus()); - } - }); - } - - private static String prepareNonce(AppAuthManager.BearerTokenAuthenticator authenticator, String note) { + protected static String prepareNonce(AppAuthManager.BearerTokenAuthenticator authenticator, String note) { String nonce = SecretGenerator.getInstance().randomString(); AuthenticationManager.AuthResult authResult = authenticator.authenticate(); UserSessionModel userSessionModel = authResult.getSession(); @@ -657,7 +252,7 @@ public class OID4VCIssuerEndpointTest extends OID4VCTest { return nonce; } - private static OID4VCIssuerEndpoint prepareIssuerEndpoint(KeycloakSession session, AppAuthManager.BearerTokenAuthenticator authenticator) { + protected static OID4VCIssuerEndpoint prepareIssuerEndpoint(KeycloakSession session, AppAuthManager.BearerTokenAuthenticator authenticator) { JwtSigningService jwtSigningService = new JwtSigningService( session, getKeyFromSession(session).getKid(), @@ -668,28 +263,28 @@ public class OID4VCIssuerEndpointTest extends OID4VCTest { return new OID4VCIssuerEndpoint( session, "did:web:issuer.org", - Map.of(Format.JWT_VC, jwtSigningService), + Map.of(jwtSigningService.locator(), jwtSigningService), authenticator, - new ObjectMapper(), + JsonSerialization.mapper, TIME_PROVIDER, 30, true); } - private String getBasePath(String realm) { + protected String getBasePath(String realm) { return getRealmPath(realm) + "/protocol/oid4vc/"; } - private String getRealmPath(String realm){ + private String getRealmPath(String realm) { return suiteContext.getAuthServerInfo().getContextRoot().toString() + "/auth/realms/" + realm; } - private void requestOffer(String token, String credentialEndpoint, SupportedCredentialConfiguration offeredCredential) throws IOException, VerificationException { + protected void requestOffer(String token, String credentialEndpoint, SupportedCredentialConfiguration offeredCredential, CredentialResponseHandler responseHandler) throws IOException, VerificationException { CredentialRequest request = new CredentialRequest(); request.setFormat(offeredCredential.getFormat()); request.setCredentialIdentifier(offeredCredential.getId()); - StringEntity stringEntity = new StringEntity(OBJECT_MAPPER.writeValueAsString(request), ContentType.APPLICATION_JSON); + StringEntity stringEntity = new StringEntity(JsonSerialization.writeValueAsString(request), ContentType.APPLICATION_JSON); HttpPost postCredential = new HttpPost(credentialEndpoint); postCredential.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token); @@ -699,26 +294,19 @@ public class OID4VCIssuerEndpointTest extends OID4VCTest { String s = IOUtils.toString(credentialRequestResponse.getEntity().getContent(), StandardCharsets.UTF_8); CredentialResponse credentialResponse = JsonSerialization.readValue(s, CredentialResponse.class); - assertNotNull("The credential should have been responded.", credentialResponse.getCredential()); - JsonWebToken jsonWebToken = TokenVerifier.create((String) credentialResponse.getCredential(), JsonWebToken.class).getToken(); - assertEquals("did:web:test.org", jsonWebToken.getIssuer()); - VerifiableCredential credential = JsonSerialization.mapper.convertValue(jsonWebToken.getOtherClaims().get("vc"), VerifiableCredential.class); - assertEquals(List.of("VerifiableCredential"), credential.getType()); - assertEquals(URI.create("did:web:test.org"), credential.getIssuer()); - assertEquals("john@email.cz", credential.getCredentialSubject().getClaims().get("email")); - assertTrue("The static claim should be set.", credential.getCredentialSubject().getClaims().containsKey("VerifiableCredential")); - assertFalse("Only mappers supported for the requested type should have been evaluated.", credential.getCredentialSubject().getClaims().containsKey("AnotherCredentialType")); + // Use response handler to customize checks based on formats. + responseHandler.handleCredentialResponse(credentialResponse); } @Override public void configureTestRealm(RealmRepresentation testRealm) { if (testRealm.getComponents() != null) { - testRealm.getComponents().add("org.keycloak.keys.KeyProvider", getRsaKeyProvider(RSA_KEY)); - testRealm.getComponents().add("org.keycloak.protocol.oid4vc.issuance.signing.VerifiableCredentialsSigningService", getJwtSigningProvider(RSA_KEY)); + testRealm.getComponents().add("org.keycloak.keys.KeyProvider", getKeyProvider()); + testRealm.getComponents().addAll("org.keycloak.protocol.oid4vc.issuance.signing.VerifiableCredentialsSigningService", getSigningProviders()); } else { testRealm.setComponents(new MultivaluedHashMap<>( - Map.of("org.keycloak.keys.KeyProvider", List.of(getRsaKeyProvider(RSA_KEY)), - "org.keycloak.protocol.oid4vc.issuance.signing.VerifiableCredentialsSigningService", List.of(getJwtSigningProvider(RSA_KEY)) + Map.of("org.keycloak.keys.KeyProvider", List.of(getKeyProvider()), + "org.keycloak.protocol.oid4vc.issuance.signing.VerifiableCredentialsSigningService", getSigningProviders() ))); } ClientRepresentation clientRepresentation = getTestClient("did:web:test.org"); @@ -746,7 +334,7 @@ public class OID4VCIssuerEndpointTest extends OID4VCTest { } } - private void withCausePropagation(Runnable r) throws Throwable { + protected void withCausePropagation(Runnable r) throws Throwable { try { r.run(); } catch (Exception e) { @@ -757,5 +345,25 @@ public class OID4VCIssuerEndpointTest extends OID4VCTest { } } -} + protected ComponentExportRepresentation getKeyProvider() { + return getRsaKeyProvider(RSA_KEY); + } + protected List getSigningProviders() { + return List.of(getJwtSigningProvider(RSA_KEY)); + } + + protected static class CredentialResponseHandler { + protected void handleCredentialResponse(CredentialResponse credentialResponse) throws VerificationException { + assertNotNull("The credential should have been responded.", credentialResponse.getCredential()); + JsonWebToken jsonWebToken = TokenVerifier.create((String) credentialResponse.getCredential(), JsonWebToken.class).getToken(); + assertEquals("did:web:test.org", jsonWebToken.getIssuer()); + VerifiableCredential credential = JsonSerialization.mapper.convertValue(jsonWebToken.getOtherClaims().get("vc"), VerifiableCredential.class); + assertEquals(List.of("VerifiableCredential"), credential.getType()); + assertEquals(URI.create("did:web:test.org"), credential.getIssuer()); + assertEquals("john@email.cz", credential.getCredentialSubject().getClaims().get("email")); + assertTrue("The static claim should be set.", credential.getCredentialSubject().getClaims().containsKey("VerifiableCredential")); + assertFalse("Only mappers supported for the requested type should have been evaluated.", credential.getCredentialSubject().getClaims().containsKey("AnotherCredentialType")); + } + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerWellKnownProviderTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerWellKnownProviderTest.java index 73129a0d99..27766b2099 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerWellKnownProviderTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerWellKnownProviderTest.java @@ -58,12 +58,7 @@ public class OID4VCIssuerWellKnownProviderTest extends OID4VCTest { assertNotNull("The test-credential claim firstName is present.", credentialIssuer.getCredentialsSupported().get("test-credential").getClaims().get("firstName")); assertFalse("The test-credential claim firstName is not mandatory.", credentialIssuer.getCredentialsSupported().get("test-credential").getClaims().get("firstName").getMandatory()); assertEquals("The test-credential claim firstName shall be displayed as First Name", "First Name", credentialIssuer.getCredentialsSupported().get("test-credential").getClaims().get("firstName").getDisplay().get(0).getName()); - assertEquals("The test-credential should offer vct VerifiableCredential", "VerifiableCredential", credentialIssuer.getCredentialsSupported().get("test-credential").getVct()); - assertTrue("The test-credential should contain a cryptographic binding method supported named jwk", credentialIssuer.getCredentialsSupported().get("test-credential").getCryptographicBindingMethodsSupported().contains("jwk")); - assertTrue("The test-credential should contain a credential signing algorithm named ES256", credentialIssuer.getCredentialsSupported().get("test-credential").getCredentialSigningAlgValuesSupported().contains("ES256")); - assertTrue("The test-credential should contain a credential signing algorithm named ES384", credentialIssuer.getCredentialsSupported().get("test-credential").getCredentialSigningAlgValuesSupported().contains("ES384")); - assertEquals("The test-credential should display as Test Credential", "Test Credential", credentialIssuer.getCredentialsSupported().get("test-credential").getDisplay().get(0).getName()); - assertTrue("The test-credential should support a proof of type jwt with signing algorithm ES256", credentialIssuer.getCredentialsSupported().get("test-credential").getProofTypesSupported().getJwt().getProofSigningAlgValuesSupported().contains("ES256")); + // moved sd-jwt specific config to org.keycloak.testsuite.oid4vc.issuance.signing.OID4VCSdJwtIssuingEndpointTest.getConfig })); } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCJWTIssuerEndpointTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCJWTIssuerEndpointTest.java new file mode 100644 index 0000000000..31c8d5b056 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCJWTIssuerEndpointTest.java @@ -0,0 +1,457 @@ +/* + * Copyright 2024 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.testsuite.oid4vc.issuance.signing; + +import jakarta.ws.rs.BadRequestException; +import jakarta.ws.rs.client.Entity; +import jakarta.ws.rs.client.WebTarget; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.Response; +import org.apache.commons.io.IOUtils; +import org.apache.http.HttpStatus; +import org.apache.http.NameValuePair; +import org.apache.http.client.entity.UrlEncodedFormEntity; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.message.BasicNameValuePair; +import org.junit.Test; +import org.keycloak.OAuth2Constants; +import org.keycloak.TokenVerifier; +import org.keycloak.common.VerificationException; +import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint; +import org.keycloak.protocol.oid4vc.model.CredentialIssuer; +import org.keycloak.protocol.oid4vc.model.CredentialOfferURI; +import org.keycloak.protocol.oid4vc.model.CredentialRequest; +import org.keycloak.protocol.oid4vc.model.CredentialResponse; +import org.keycloak.protocol.oid4vc.model.CredentialsOffer; +import org.keycloak.protocol.oid4vc.model.Format; +import org.keycloak.protocol.oid4vc.model.OfferUriType; +import org.keycloak.protocol.oid4vc.model.PreAuthorizedGrant; +import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration; +import org.keycloak.protocol.oid4vc.model.VerifiableCredential; +import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantTypeFactory; +import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation; +import org.keycloak.representations.JsonWebToken; +import org.keycloak.services.managers.AppAuthManager; +import org.keycloak.testsuite.Assert; +import org.keycloak.testsuite.util.OAuthClient; +import org.keycloak.util.JsonSerialization; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.LinkedList; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +/** + * Test from org.keycloak.testsuite.oid4vc.issuance.signing.OID4VCIssuerEndpointTest + */ +public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest { + + // ----- getCredentialOfferUri + + @Test(expected = BadRequestException.class) + public void testGetCredentialOfferUriUnsupportedCredential() throws Throwable { + String token = getBearerToken(oauth); + withCausePropagation(() -> testingClient.server(TEST_REALM_NAME) + .run((session -> { + AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); + authenticator.setTokenString(token); + + OID4VCIssuerEndpoint oid4VCIssuerEndpoint = prepareIssuerEndpoint(session, authenticator); + oid4VCIssuerEndpoint.getCredentialOfferURI("inexistent-id", OfferUriType.URI, 0, 0); + }))); + + } + + @Test(expected = BadRequestException.class) + public void testGetCredentialOfferUriUnauthorized() throws Throwable { + withCausePropagation(() -> testingClient.server(TEST_REALM_NAME) + .run((session -> { + AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); + authenticator.setTokenString(null); + OID4VCIssuerEndpoint oid4VCIssuerEndpoint = prepareIssuerEndpoint(session, authenticator); + oid4VCIssuerEndpoint.getCredentialOfferURI("test-credential", OfferUriType.URI, 0, 0); + }))); + } + + @Test(expected = BadRequestException.class) + public void testGetCredentialOfferUriInvalidToken() throws Throwable { + withCausePropagation(() -> testingClient.server(TEST_REALM_NAME) + .run((session -> { + AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); + authenticator.setTokenString("invalid-token"); + OID4VCIssuerEndpoint oid4VCIssuerEndpoint = prepareIssuerEndpoint(session, authenticator); + oid4VCIssuerEndpoint.getCredentialOfferURI("test-credential", OfferUriType.URI, 0, 0); + }))); + } + + @Test + public void testGetCredentialOfferURI() { + String token = getBearerToken(oauth); + testingClient + .server(TEST_REALM_NAME) + .run((session) -> { + try { + AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); + authenticator.setTokenString(token); + OID4VCIssuerEndpoint oid4VCIssuerEndpoint = prepareIssuerEndpoint(session, authenticator); + + Response response = oid4VCIssuerEndpoint.getCredentialOfferURI("test-credential", OfferUriType.URI, 0, 0); + + assertEquals("An offer uri should have been returned.", HttpStatus.SC_OK, response.getStatus()); + CredentialOfferURI credentialOfferURI = JsonSerialization.mapper.convertValue(response.getEntity(), CredentialOfferURI.class); + assertNotNull("A nonce should be included.", credentialOfferURI.getNonce()); + assertNotNull("The issuer uri should be provided.", credentialOfferURI.getIssuer()); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + + } + + // ----- getCredentialOffer + + @Test(expected = BadRequestException.class) + public void testGetCredentialOfferUnauthorized() throws Throwable { + withCausePropagation(() -> { + testingClient + .server(TEST_REALM_NAME) + .run((session) -> { + AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); + authenticator.setTokenString(null); + OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); + issuerEndpoint.getCredentialOffer("nonce"); + }); + }); + } + + @Test(expected = BadRequestException.class) + public void testGetCredentialOfferWithoutNonce() throws Throwable { + String token = getBearerToken(oauth); + withCausePropagation(() -> { + testingClient + .server(TEST_REALM_NAME) + .run((session -> { + AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); + authenticator.setTokenString(token); + OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); + issuerEndpoint.getCredentialOffer(null); + })); + }); + } + + @Test(expected = BadRequestException.class) + public void testGetCredentialOfferWithoutAPreparedOffer() throws Throwable { + String token = getBearerToken(oauth); + withCausePropagation(() -> { + testingClient + .server(TEST_REALM_NAME) + .run((session -> { + AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); + authenticator.setTokenString(token); + OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); + issuerEndpoint.getCredentialOffer("unpreparedNonce"); + })); + }); + } + + @Test(expected = BadRequestException.class) + public void testGetCredentialOfferWithABrokenNote() throws Throwable { + String token = getBearerToken(oauth); + withCausePropagation(() -> { + testingClient + .server(TEST_REALM_NAME) + .run((session -> { + AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); + authenticator.setTokenString(token); + String nonce = prepareNonce(authenticator, "invalidNote"); + OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); + issuerEndpoint.getCredentialOffer(nonce); + })); + }); + } + + @Test + public void testGetCredentialOffer() { + String token = getBearerToken(oauth); + String rootURL = suiteContext.getAuthServerInfo().getContextRoot().toString(); + testingClient + .server(TEST_REALM_NAME) + .run((session) -> { + AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); + authenticator.setTokenString(token); + + SupportedCredentialConfiguration supportedCredentialConfiguration = new SupportedCredentialConfiguration() + .setId("test-credential") + .setScope("VerifiableCredential") + .setFormat(Format.JWT_VC); + String nonce = prepareNonce(authenticator, JsonSerialization.writeValueAsString(supportedCredentialConfiguration)); + + OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); + Response credentialOfferResponse = issuerEndpoint.getCredentialOffer(nonce); + assertEquals("The offer should have been returned.", HttpStatus.SC_OK, credentialOfferResponse.getStatus()); + Object credentialOfferEntity = credentialOfferResponse.getEntity(); + assertNotNull("An actual offer should be in the response.", credentialOfferEntity); + + CredentialsOffer credentialsOffer = JsonSerialization.mapper.convertValue(credentialOfferEntity, CredentialsOffer.class); + assertNotNull("Credentials should have been offered.", credentialsOffer.getCredentialConfigurationIds()); + assertFalse("Credentials should have been offered.", credentialsOffer.getCredentialConfigurationIds().isEmpty()); + List supportedCredentials = credentialsOffer.getCredentialConfigurationIds(); + assertEquals("Exactly one credential should have been returned.", 1, supportedCredentials.size()); + String offeredCredentialId = supportedCredentials.get(0); + assertEquals("The credential should be as defined in the note.", supportedCredentialConfiguration.getId(), offeredCredentialId); + + PreAuthorizedGrant grant = credentialsOffer.getGrants(); + assertNotNull("The grant should be included.", grant); + assertNotNull("The grant should contain the pre-authorized code.", grant.getPreAuthorizedCode()); + assertNotNull("The actual pre-authorized code should be included.", grant + .getPreAuthorizedCode() + .getPreAuthorizedCode()); + + assertEquals("The correct issuer should be included.", rootURL + "/auth/realms/" + TEST_REALM_NAME, credentialsOffer.getCredentialIssuer()); + }); + } + + // ----- requestCredential + + @Test(expected = BadRequestException.class) + public void testRequestCredentialUnauthorized() throws Throwable { + withCausePropagation(() -> { + testingClient + .server(TEST_REALM_NAME) + .run((session -> { + AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); + authenticator.setTokenString(null); + OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); + issuerEndpoint.requestCredential(new CredentialRequest() + .setFormat(Format.JWT_VC) + .setCredentialIdentifier("test-credential")); + })); + }); + } + + @Test(expected = BadRequestException.class) + public void testRequestCredentialInvalidToken() throws Throwable { + withCausePropagation(() -> { + testingClient + .server(TEST_REALM_NAME) + .run((session -> { + AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); + authenticator.setTokenString("token"); + OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); + issuerEndpoint.requestCredential(new CredentialRequest() + .setFormat(Format.JWT_VC) + .setCredentialIdentifier("test-credential")); + })); + }); + } + + @Test(expected = BadRequestException.class) + public void testRequestCredentialUnsupportedFormat() throws Throwable { + String token = getBearerToken(oauth); + withCausePropagation(() -> { + testingClient + .server(TEST_REALM_NAME) + .run((session -> { + AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); + authenticator.setTokenString(token); + OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); + issuerEndpoint.requestCredential(new CredentialRequest() + .setFormat(Format.SD_JWT_VC) + .setCredentialIdentifier("test-credential")); + })); + }); + } + + @Test(expected = BadRequestException.class) + public void testRequestCredentialUnsupportedCredential() throws Throwable { + String token = getBearerToken(oauth); + withCausePropagation(() -> { + testingClient + .server(TEST_REALM_NAME) + .run((session -> { + AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); + authenticator.setTokenString(token); + OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); + issuerEndpoint.requestCredential(new CredentialRequest() + .setFormat(Format.JWT_VC) + .setCredentialIdentifier("no-such-credential")); + })); + }); + } + + @Test + public void testRequestCredential() { + String token = getBearerToken(oauth); + testingClient + .server(TEST_REALM_NAME) + .run((session -> { + AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); + authenticator.setTokenString(token); + OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); + CredentialRequest credentialRequest = new CredentialRequest() + .setFormat(Format.JWT_VC) + .setCredentialIdentifier("test-credential"); + Response credentialResponse = issuerEndpoint.requestCredential(credentialRequest); + assertEquals("The credential request should be answered successfully.", HttpStatus.SC_OK, credentialResponse.getStatus()); + assertNotNull("A credential should be responded.", credentialResponse.getEntity()); + CredentialResponse credentialResponseVO = JsonSerialization.mapper.convertValue(credentialResponse.getEntity(), CredentialResponse.class); + JsonWebToken jsonWebToken = TokenVerifier.create((String) credentialResponseVO.getCredential(), JsonWebToken.class).getToken(); + + assertNotNull("A valid credential string should have been responded", jsonWebToken); + assertNotNull("The credentials should be included at the vc-claim.", jsonWebToken.getOtherClaims().get("vc")); + VerifiableCredential credential = JsonSerialization.mapper.convertValue(jsonWebToken.getOtherClaims().get("vc"), VerifiableCredential.class); + assertTrue("The static claim should be set.", credential.getCredentialSubject().getClaims().containsKey("VerifiableCredential")); + assertFalse("Only mappers supported for the requested type should have been evaluated.", credential.getCredentialSubject().getClaims().containsKey("AnotherCredentialType")); + })); + } + + // Tests the complete flow from + // 1. Retrieving the credential-offer-uri + // 2. Using the uri to get the actual credential offer + // 3. Get the issuer metadata + // 4. Get the openid-configuration + // 5. Get an access token for the pre-authorized code + // 6. Get the credential + @Test + public void testCredentialIssuance() throws Exception { + + String token = getBearerToken(oauth); + + // 1. Retrieving the credential-offer-uri + HttpGet getCredentialOfferURI = new HttpGet(getBasePath(TEST_REALM_NAME) + "credential-offer-uri?credential_configuration_id=test-credential"); + getCredentialOfferURI.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token); + CloseableHttpResponse credentialOfferURIResponse = httpClient.execute(getCredentialOfferURI); + + assertEquals("A valid offer uri should be returned", HttpStatus.SC_OK, credentialOfferURIResponse.getStatusLine().getStatusCode()); + String s = IOUtils.toString(credentialOfferURIResponse.getEntity().getContent(), StandardCharsets.UTF_8); + CredentialOfferURI credentialOfferURI = JsonSerialization.readValue(s, CredentialOfferURI.class); + + // 2. Using the uri to get the actual credential offer + HttpGet getCredentialOffer = new HttpGet(credentialOfferURI.getIssuer() + "/" + credentialOfferURI.getNonce()); + getCredentialOffer.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token); + CloseableHttpResponse credentialOfferResponse = httpClient.execute(getCredentialOffer); + + assertEquals("A valid offer should be returned", HttpStatus.SC_OK, credentialOfferResponse.getStatusLine().getStatusCode()); + s = IOUtils.toString(credentialOfferResponse.getEntity().getContent(), StandardCharsets.UTF_8); + CredentialsOffer credentialsOffer = JsonSerialization.readValue(s, CredentialsOffer.class); + + // 3. Get the issuer metadata + HttpGet getIssuerMetadata = new HttpGet(credentialsOffer.getCredentialIssuer() + "/.well-known/openid-credential-issuer"); + CloseableHttpResponse issuerMetadataResponse = httpClient.execute(getIssuerMetadata); + assertEquals(HttpStatus.SC_OK, issuerMetadataResponse.getStatusLine().getStatusCode()); + s = IOUtils.toString(issuerMetadataResponse.getEntity().getContent(), StandardCharsets.UTF_8); + CredentialIssuer credentialIssuer = JsonSerialization.readValue(s, CredentialIssuer.class); + + assertEquals("We only expect one authorization server.", 1, credentialIssuer.getAuthorizationServers().size()); + + // 4. Get the openid-configuration + HttpGet getOpenidConfiguration = new HttpGet(credentialIssuer.getAuthorizationServers().get(0) + "/.well-known/openid-configuration"); + CloseableHttpResponse openidConfigResponse = httpClient.execute(getOpenidConfiguration); + assertEquals(HttpStatus.SC_OK, openidConfigResponse.getStatusLine().getStatusCode()); + s = IOUtils.toString(openidConfigResponse.getEntity().getContent(), StandardCharsets.UTF_8); + OIDCConfigurationRepresentation openidConfig = JsonSerialization.readValue(s, OIDCConfigurationRepresentation.class); + + assertNotNull("A token endpoint should be included.", openidConfig.getTokenEndpoint()); + assertTrue("The pre-authorized code should be supported.", openidConfig.getGrantTypesSupported().contains(PreAuthorizedCodeGrantTypeFactory.GRANT_TYPE)); + + // 5. Get an access token for the pre-authorized code + HttpPost postPreAuthorizedCode = new HttpPost(openidConfig.getTokenEndpoint()); + List parameters = new LinkedList<>(); + parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, PreAuthorizedCodeGrantTypeFactory.GRANT_TYPE)); + parameters.add(new BasicNameValuePair(PreAuthorizedCodeGrantTypeFactory.CODE_REQUEST_PARAM, credentialsOffer.getGrants().getPreAuthorizedCode().getPreAuthorizedCode())); + UrlEncodedFormEntity formEntity = new UrlEncodedFormEntity(parameters, "UTF-8"); + postPreAuthorizedCode.setEntity(formEntity); + OAuthClient.AccessTokenResponse accessTokenResponse = new OAuthClient.AccessTokenResponse(httpClient.execute(postPreAuthorizedCode)); + assertEquals(HttpStatus.SC_OK, accessTokenResponse.getStatusCode()); + String theToken = accessTokenResponse.getAccessToken(); + + // 6. Get the credential + credentialsOffer.getCredentialConfigurationIds().stream() + .map(offeredCredentialId -> credentialIssuer.getCredentialsSupported().get(offeredCredentialId)) + .forEach(supportedCredential -> { + try { + requestOffer(theToken, credentialIssuer.getCredentialEndpoint(), supportedCredential, new CredentialResponseHandler()); + } catch (IOException e) { + fail("Was not able to get the credential."); + } catch (VerificationException e) { + throw new RuntimeException(e); + } + }); + } + + @Test + public void testCredentialIssuanceWithAuthZCodeWithScopeMatched() throws Exception { + testCredentialIssuanceWithAuthZCodeFlow((testClientId, testScope) -> getBearerToken(oauth.clientId(testClientId).openid(false).scope(testScope)), + m -> { + String accessToken = (String) m.get("accessToken"); + WebTarget credentialTarget = (WebTarget) m.get("credentialTarget"); + CredentialRequest credentialRequest = (CredentialRequest) m.get("credentialRequest"); + + try (Response response = credentialTarget.request().header(HttpHeaders.AUTHORIZATION, "bearer " + accessToken).post(Entity.json(credentialRequest))) { + CredentialResponse credentialResponse = JsonSerialization.readValue(response.readEntity(String.class), CredentialResponse.class); + + assertEquals(200, response.getStatus()); + JsonWebToken jsonWebToken = TokenVerifier.create((String) credentialResponse.getCredential(), JsonWebToken.class).getToken(); + assertEquals("did:web:test.org", jsonWebToken.getIssuer()); + + VerifiableCredential credential = JsonSerialization.mapper.convertValue(jsonWebToken.getOtherClaims().get("vc"), VerifiableCredential.class); + assertEquals(TEST_TYPES, credential.getType()); + assertEquals(TEST_DID, credential.getIssuer()); + assertEquals("john@email.cz", credential.getCredentialSubject().getClaims().get("email")); + } catch (IOException | VerificationException e) { + Assert.fail(); + } + }); + } + + @Test + public void testCredentialIssuanceWithAuthZCodeWithScopeUnmatched() throws Exception { + testCredentialIssuanceWithAuthZCodeFlow((testClientId, testScope) -> getBearerToken(oauth.clientId(testClientId).openid(false).scope("email")), // set registered different scope + m -> { + String accessToken = (String) m.get("accessToken"); + WebTarget credentialTarget = (WebTarget) m.get("credentialTarget"); + CredentialRequest credentialRequest = (CredentialRequest) m.get("credentialRequest"); + + try (Response response = credentialTarget.request().header(HttpHeaders.AUTHORIZATION, "bearer " + accessToken).post(Entity.json(credentialRequest))) { + assertEquals(400, response.getStatus()); + } + }); + } + + @Test + public void testCredentialIssuanceWithAuthZCodeSWithoutScope() throws Exception { + testCredentialIssuanceWithAuthZCodeFlow((testClientId, testScope) -> getBearerToken(oauth.clientId(testClientId).openid(false).scope(null)), // no scope + m -> { + String accessToken = (String) m.get("accessToken"); + WebTarget credentialTarget = (WebTarget) m.get("credentialTarget"); + CredentialRequest credentialRequest = (CredentialRequest) m.get("credentialRequest"); + + try (Response response = credentialTarget.request().header(HttpHeaders.AUTHORIZATION, "bearer " + accessToken).post(Entity.json(credentialRequest))) { + assertEquals(400, response.getStatus()); + } + }); + } + +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCSdJwtIssuingEndpointTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCSdJwtIssuingEndpointTest.java new file mode 100644 index 0000000000..0bf1e5aa7a --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCSdJwtIssuingEndpointTest.java @@ -0,0 +1,397 @@ +/* + * Copyright 2024 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.testsuite.oid4vc.issuance.signing; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.Response; +import org.apache.commons.collections4.map.HashedMap; +import org.apache.commons.io.IOUtils; +import org.apache.http.HttpStatus; +import org.apache.http.NameValuePair; +import org.apache.http.client.entity.UrlEncodedFormEntity; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.message.BasicNameValuePair; +import org.junit.Test; +import org.keycloak.OAuth2Constants; +import org.keycloak.TokenVerifier; +import org.keycloak.common.VerificationException; +import org.keycloak.common.util.Base64Url; +import org.keycloak.common.util.MultivaluedHashMap; +import org.keycloak.crypto.Algorithm; +import org.keycloak.models.KeycloakSession; +import org.keycloak.protocol.oid4vc.OID4VCLoginProtocolFactory; +import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint; +import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerWellKnownProvider; +import org.keycloak.protocol.oid4vc.issuance.signing.SdJwtSigningService; +import org.keycloak.protocol.oid4vc.model.CredentialConfigId; +import org.keycloak.protocol.oid4vc.model.CredentialIssuer; +import org.keycloak.protocol.oid4vc.model.CredentialOfferURI; +import org.keycloak.protocol.oid4vc.model.CredentialRequest; +import org.keycloak.protocol.oid4vc.model.CredentialResponse; +import org.keycloak.protocol.oid4vc.model.CredentialsOffer; +import org.keycloak.protocol.oid4vc.model.Format; +import org.keycloak.protocol.oid4vc.model.VerifiableCredentialType; +import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantTypeFactory; +import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation; +import org.keycloak.representations.JsonWebToken; +import org.keycloak.representations.idm.ClientRepresentation; +import org.keycloak.representations.idm.ComponentExportRepresentation; +import org.keycloak.sdjwt.vp.SdJwtVP; +import org.keycloak.services.managers.AppAuthManager; +import org.keycloak.testsuite.util.OAuthClient; +import org.keycloak.util.JsonSerialization; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.stream.Collectors; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +/** + * Endpoint test with sd-jwt specific config. + * + * @author Francis Pouatcha + */ +public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest { + + @Test + public void testRequestTestCredential() { + String token = getBearerToken(oauth); + String vct = "https://credentials.example.com/test-credential"; + testingClient + .server(TEST_REALM_NAME) + .run((session -> { + AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); + authenticator.setTokenString(token); + OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); + CredentialRequest credentialRequest = new CredentialRequest() + .setFormat(Format.SD_JWT_VC) + .setVct(vct); + Response credentialResponse = issuerEndpoint.requestCredential(credentialRequest); + assertEquals("The credential request should be answered successfully.", HttpStatus.SC_OK, credentialResponse.getStatus()); + assertNotNull("A credential should be responded.", credentialResponse.getEntity()); + CredentialResponse credentialResponseVO = JsonSerialization.mapper.convertValue(credentialResponse.getEntity(), CredentialResponse.class); + new TestCredentialResponseHandler(vct).handleCredentialResponse(credentialResponseVO); + })); + } + + // Tests the complete flow from + // 1. Retrieving the credential-offer-uri + // 2. Using the uri to get the actual credential offer + // 3. Get the issuer metadata + // 4. Get the openid-configuration + // 5. Get an access token for the pre-authorized code + // 6. Get the credential + @Test + public void testCredentialIssuance() throws Exception { + + String token = getBearerToken(oauth); + + // 1. Retrieving the credential-offer-uri + HttpGet getCredentialOfferURI = new HttpGet(getBasePath(TEST_REALM_NAME) + "credential-offer-uri?credential_configuration_id=test-credential"); + getCredentialOfferURI.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token); + CloseableHttpResponse credentialOfferURIResponse = httpClient.execute(getCredentialOfferURI); + + assertEquals("A valid offer uri should be returned", HttpStatus.SC_OK, credentialOfferURIResponse.getStatusLine().getStatusCode()); + String s = IOUtils.toString(credentialOfferURIResponse.getEntity().getContent(), StandardCharsets.UTF_8); + CredentialOfferURI credentialOfferURI = JsonSerialization.readValue(s, CredentialOfferURI.class); + + // 2. Using the uri to get the actual credential offer + HttpGet getCredentialOffer = new HttpGet(credentialOfferURI.getIssuer() + "/" + credentialOfferURI.getNonce()); + getCredentialOffer.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token); + CloseableHttpResponse credentialOfferResponse = httpClient.execute(getCredentialOffer); + + assertEquals("A valid offer should be returned", HttpStatus.SC_OK, credentialOfferResponse.getStatusLine().getStatusCode()); + s = IOUtils.toString(credentialOfferResponse.getEntity().getContent(), StandardCharsets.UTF_8); + CredentialsOffer credentialsOffer = JsonSerialization.readValue(s, CredentialsOffer.class); + + // 3. Get the issuer metadata + HttpGet getIssuerMetadata = new HttpGet(credentialsOffer.getCredentialIssuer() + "/.well-known/openid-credential-issuer"); + CloseableHttpResponse issuerMetadataResponse = httpClient.execute(getIssuerMetadata); + assertEquals(HttpStatus.SC_OK, issuerMetadataResponse.getStatusLine().getStatusCode()); + s = IOUtils.toString(issuerMetadataResponse.getEntity().getContent(), StandardCharsets.UTF_8); + CredentialIssuer credentialIssuer = JsonSerialization.readValue(s, CredentialIssuer.class); + + assertEquals("We only expect one authorization server.", 1, credentialIssuer.getAuthorizationServers().size()); + + // 4. Get the openid-configuration + HttpGet getOpenidConfiguration = new HttpGet(credentialIssuer.getAuthorizationServers().get(0) + "/.well-known/openid-configuration"); + CloseableHttpResponse openidConfigResponse = httpClient.execute(getOpenidConfiguration); + assertEquals(HttpStatus.SC_OK, openidConfigResponse.getStatusLine().getStatusCode()); + s = IOUtils.toString(openidConfigResponse.getEntity().getContent(), StandardCharsets.UTF_8); + OIDCConfigurationRepresentation openidConfig = JsonSerialization.readValue(s, OIDCConfigurationRepresentation.class); + + assertNotNull("A token endpoint should be included.", openidConfig.getTokenEndpoint()); + assertTrue("The pre-authorized code should be supported.", openidConfig.getGrantTypesSupported().contains(PreAuthorizedCodeGrantTypeFactory.GRANT_TYPE)); + + // 5. Get an access token for the pre-authorized code + HttpPost postPreAuthorizedCode = new HttpPost(openidConfig.getTokenEndpoint()); + List parameters = new LinkedList<>(); + parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, PreAuthorizedCodeGrantTypeFactory.GRANT_TYPE)); + parameters.add(new BasicNameValuePair(PreAuthorizedCodeGrantTypeFactory.CODE_REQUEST_PARAM, credentialsOffer.getGrants().getPreAuthorizedCode().getPreAuthorizedCode())); + UrlEncodedFormEntity formEntity = new UrlEncodedFormEntity(parameters, "UTF-8"); + postPreAuthorizedCode.setEntity(formEntity); + OAuthClient.AccessTokenResponse accessTokenResponse = new OAuthClient.AccessTokenResponse(httpClient.execute(postPreAuthorizedCode)); + assertEquals(HttpStatus.SC_OK, accessTokenResponse.getStatusCode()); + String theToken = accessTokenResponse.getAccessToken(); + + final String vct = "https://credentials.example.com/test-credential"; + + // 6. Get the credential + credentialsOffer.getCredentialConfigurationIds().stream() + .map(offeredCredentialId -> credentialIssuer.getCredentialsSupported().get(offeredCredentialId)) + .forEach(supportedCredential -> { + try { + requestOffer(theToken, credentialIssuer.getCredentialEndpoint(), supportedCredential, new TestCredentialResponseHandler(vct)); + } catch (IOException e) { + fail("Was not able to get the credential."); + } catch (VerificationException e) { + throw new RuntimeException(e); + } + }); + } + + /** + * This is testing the configuration exposed by OID4VCIssuerWellKnownProvider based on the client and signing config setup here. + */ + @Test + public void getConfig() { + String expectedIssuer = suiteContext.getAuthServerInfo().getContextRoot().toString() + "/auth/realms/" + TEST_REALM_NAME; + String expectedCredentialsEndpoint = expectedIssuer + "/protocol/oid4vc/credential"; + String expectedAuthorizationServer = expectedIssuer; + testingClient + .server(TEST_REALM_NAME) + .run((session -> { + OID4VCIssuerWellKnownProvider oid4VCIssuerWellKnownProvider = new OID4VCIssuerWellKnownProvider(session); + Object issuerConfig = oid4VCIssuerWellKnownProvider.getConfig(); + assertTrue("Valid credential-issuer metadata should be returned.", issuerConfig instanceof CredentialIssuer); + CredentialIssuer credentialIssuer = (CredentialIssuer) issuerConfig; + assertEquals("The correct issuer should be included.", expectedIssuer, credentialIssuer.getCredentialIssuer()); + assertEquals("The correct credentials endpoint should be included.", expectedCredentialsEndpoint, credentialIssuer.getCredentialEndpoint()); + assertEquals("Since the authorization server is equal to the issuer, just 1 should be returned.", 1, credentialIssuer.getAuthorizationServers().size()); + assertEquals("The expected server should have been returned.", expectedAuthorizationServer, credentialIssuer.getAuthorizationServers().get(0)); + assertTrue("The test-credential should be supported.", credentialIssuer.getCredentialsSupported().containsKey("test-credential")); + assertEquals("The test-credential should offer type test-credential", "test-credential", credentialIssuer.getCredentialsSupported().get("test-credential").getScope()); + assertEquals("The test-credential should be offered in the sd-jwt format.", Format.SD_JWT_VC, credentialIssuer.getCredentialsSupported().get("test-credential").getFormat()); + assertNotNull("The test-credential can optionally provide a claims claim.", credentialIssuer.getCredentialsSupported().get("test-credential").getClaims()); + assertNotNull("The test-credential claim firstName is present.", credentialIssuer.getCredentialsSupported().get("test-credential").getClaims().get("firstName")); + assertFalse("The test-credential claim firstName is not mandatory.", credentialIssuer.getCredentialsSupported().get("test-credential").getClaims().get("firstName").getMandatory()); + assertEquals("The test-credential claim firstName shall be displayed as First Name", "First Name", credentialIssuer.getCredentialsSupported().get("test-credential").getClaims().get("firstName").getDisplay().get(0).getName()); + assertEquals("The test-credential should offer vct VerifiableCredential", "https://credentials.example.com/test-credential", credentialIssuer.getCredentialsSupported().get("test-credential").getVct()); + + // We are offering key binding only for identity credential + assertTrue("The IdentityCredential should contain a cryptographic binding method supported named jwk", credentialIssuer.getCredentialsSupported().get("IdentityCredential").getCryptographicBindingMethodsSupported().contains("jwk")); + assertTrue("The IdentityCredential should contain a credential signing algorithm named ES256", credentialIssuer.getCredentialsSupported().get("IdentityCredential").getCredentialSigningAlgValuesSupported().contains("ES256")); + assertTrue("The IdentityCredential should contain a credential signing algorithm named ES384", credentialIssuer.getCredentialsSupported().get("IdentityCredential").getCredentialSigningAlgValuesSupported().contains("ES384")); + assertEquals("The IdentityCredential should display as Test Credential", "Identity Credential", credentialIssuer.getCredentialsSupported().get("IdentityCredential").getDisplay().get(0).getName()); + assertTrue("The IdentityCredential should support a proof of type jwt with signing algorithm ES256", credentialIssuer.getCredentialsSupported().get("IdentityCredential").getProofTypesSupported().getJwt().getProofSigningAlgValuesSupported().contains("ES256")); + })); + } + + + protected static OID4VCIssuerEndpoint prepareIssuerEndpoint(KeycloakSession session, AppAuthManager.BearerTokenAuthenticator authenticator) { + SdJwtSigningService testCredentialSigningService = new SdJwtSigningService( + session, + JsonSerialization.mapper, + getKeyFromSession(session).getKid(), + Algorithm.ES256, + Format.SD_JWT_VC, + "sha-256", + "did:web:issuer.org", + 2, + List.of(), + Optional.empty(), + VerifiableCredentialType.from("https://credentials.example.com/test-credential"), + CredentialConfigId.from("test-credential")); + + SdJwtSigningService identityCredentialSigningService = new SdJwtSigningService( + session, + JsonSerialization.mapper, + getKeyFromSession(session).getKid(), + Algorithm.ES256, + Format.SD_JWT_VC, + "sha-256", + "did:web:issuer.org", + 0, + List.of("given_name"), + Optional.empty(), + VerifiableCredentialType.from("https://credentials.example.com/identity_credential"), + CredentialConfigId.from("IdentityCredential")); + + return new OID4VCIssuerEndpoint( + session, + "did:web:issuer.org", + Map.of( + testCredentialSigningService.locator(), testCredentialSigningService, + identityCredentialSigningService.locator(), identityCredentialSigningService + ), + authenticator, + new ObjectMapper(), + TIME_PROVIDER, + 30, + true); + } + + private ComponentExportRepresentation getIdCredentialSigningProvider() { + ComponentExportRepresentation componentExportRepresentation = new ComponentExportRepresentation(); + componentExportRepresentation.setName("sd-jwt-signing_identity_credential"); + componentExportRepresentation.setId(UUID.randomUUID().toString()); + componentExportRepresentation.setProviderId(Format.SD_JWT_VC); + + componentExportRepresentation.setConfig(new MultivaluedHashMap<>( + Map.of( + "algorithmType", List.of("ES256"), + "tokenType", List.of(Format.SD_JWT_VC), + "issuerDid", List.of(TEST_DID.toString()), + "hashAlgorithm", List.of("sha-256"), + "decoys", List.of("0"), + "vct", List.of("https://credentials.example.com/identity_credential"), + "vcConfigId", List.of("IdentityCredential") + ) + )); + return componentExportRepresentation; + } + + private ComponentExportRepresentation getTestCredentialSigningProvider() { + ComponentExportRepresentation componentExportRepresentation = new ComponentExportRepresentation(); + componentExportRepresentation.setName("sd-jwt-signing_test-credential"); + componentExportRepresentation.setId(UUID.randomUUID().toString()); + componentExportRepresentation.setProviderId(Format.SD_JWT_VC); + + componentExportRepresentation.setConfig(new MultivaluedHashMap<>( + Map.of( + "algorithmType", List.of("ES256"), + "tokenType", List.of(Format.SD_JWT_VC), + "issuerDid", List.of(TEST_DID.toString()), + "hashAlgorithm", List.of("sha-256"), + "decoys", List.of("2"), + "vct", List.of("https://credentials.example.com/test-credential"), + "vcConfigId", List.of("test-credential") + ) + )); + return componentExportRepresentation; + } + + @Override + protected ClientRepresentation getTestClient(String clientId) { + ClientRepresentation clientRepresentation = new ClientRepresentation(); + clientRepresentation.setClientId(clientId); + clientRepresentation.setProtocol(OID4VCLoginProtocolFactory.PROTOCOL_ID); + clientRepresentation.setEnabled(true); + Map testCredentialAttributes = Map.of( + "vc.test-credential.expiry_in_s", "1800", + "vc.test-credential.format", Format.SD_JWT_VC, + "vc.test-credential.scope", "test-credential", + "vc.test-credential.claims", "{ \"firstName\": {\"mandatory\": false, \"display\": [{\"name\": \"First Name\", \"locale\": \"en-US\"}, {\"name\": \"名前\", \"locale\": \"ja-JP\"}]}, \"lastName\": {\"mandatory\": false}, \"email\": {\"mandatory\": false} }", + "vc.test-credential.vct", "https://credentials.example.com/test-credential", + "vc.test-credential.credential_signing_alg_values_supported", "ES256,ES384", + "vc.test-credential.display.0", "{\n \"name\": \"Test Credential\"\n}" + ); + Map identityCredentialAttributes = Map.of( + "vc.IdentityCredential.expiry_in_s", "31536000", + "vc.IdentityCredential.format", Format.SD_JWT_VC, + "vc.IdentityCredential.scope", "identity_credential", + "vc.IdentityCredential.vct", "https://credentials.example.com/identity_credential", + "vc.IdentityCredential.cryptographic_binding_methods_supported", "jwk", + "vc.IdentityCredential.credential_signing_alg_values_supported", "ES256,ES384", + "vc.IdentityCredential.claims", "{\"given_name\":{\"display\":[{\"name\":\"الاسم الشخصي\",\"locale\":\"ar\"},{\"name\":\"Vorname\",\"locale\":\"de\"},{\"name\":\"Given Name\",\"locale\":\"en\"},{\"name\":\"Nombre\",\"locale\":\"es\"},{\"name\":\"نام\",\"locale\":\"fa\"},{\"name\":\"Etunimi\",\"locale\":\"fi\"},{\"name\":\"Prénom\",\"locale\":\"fr\"},{\"name\":\"पहचानी गई नाम\",\"locale\":\"hi\"},{\"name\":\"Nome\",\"locale\":\"it\"},{\"name\":\"名\",\"locale\":\"ja\"},{\"name\":\"Овог нэр\",\"locale\":\"mn\"},{\"name\":\"Voornaam\",\"locale\":\"nl\"},{\"name\":\"Nome Próprio\",\"locale\":\"pt\"},{\"name\":\"Förnamn\",\"locale\":\"sv\"},{\"name\":\"مسلمان نام\",\"locale\":\"ur\"}]},\"family_name\":{\"display\":[{\"name\":\"اسم العائلة\",\"locale\":\"ar\"},{\"name\":\"Nachname\",\"locale\":\"de\"},{\"name\":\"Family Name\",\"locale\":\"en\"},{\"name\":\"Apellido\",\"locale\":\"es\"},{\"name\":\"نام خانوادگی\",\"locale\":\"fa\"},{\"name\":\"Sukunimi\",\"locale\":\"fi\"},{\"name\":\"Nom de famille\",\"locale\":\"fr\"},{\"name\":\"परिवार का नाम\",\"locale\":\"hi\"},{\"name\":\"Cognome\",\"locale\":\"it\"},{\"name\":\"姓\",\"locale\":\"ja\"},{\"name\":\"өөрийн нэр\",\"locale\":\"mn\"},{\"name\":\"Achternaam\",\"locale\":\"nl\"},{\"name\":\"Sobrenome\",\"locale\":\"pt\"},{\"name\":\"Efternamn\",\"locale\":\"sv\"},{\"name\":\"خاندانی نام\",\"locale\":\"ur\"}]},\"birthdate\":{\"display\":[{\"name\":\"تاريخ الميلاد\",\"locale\":\"ar\"},{\"name\":\"Geburtsdatum\",\"locale\":\"de\"},{\"name\":\"Date of Birth\",\"locale\":\"en\"},{\"name\":\"Fecha de Nacimiento\",\"locale\":\"es\"},{\"name\":\"تاریخ تولد\",\"locale\":\"fa\"},{\"name\":\"Syntymäaika\",\"locale\":\"fi\"},{\"name\":\"Date de naissance\",\"locale\":\"fr\"},{\"name\":\"जन्म की तारीख\",\"locale\":\"hi\"},{\"name\":\"Data di nascita\",\"locale\":\"it\"},{\"name\":\"生年月日\",\"locale\":\"ja\"},{\"name\":\"төрсөн өдөр\",\"locale\":\"mn\"},{\"name\":\"Geboortedatum\",\"locale\":\"nl\"},{\"name\":\"Data de Nascimento\",\"locale\":\"pt\"},{\"name\":\"Födelsedatum\",\"locale\":\"sv\"},{\"name\":\"تاریخ پیدائش\",\"locale\":\"ur\"}]}}", + "vc.IdentityCredential.display.0", "{\"name\": \"Identity Credential\"}", + "vc.IdentityCredential.proof_types_supported", "{\"jwt\":{\"proof_signing_alg_values_supported\":[\"ES256\"]}}" + ); + HashedMap allAttributes = new HashedMap<>(); + allAttributes.putAll(testCredentialAttributes); + allAttributes.putAll(identityCredentialAttributes); + clientRepresentation.setAttributes(allAttributes); + clientRepresentation.setProtocolMappers( + List.of( + getRoleMapper(clientId, "test-credential"), + getUserAttributeMapper("email", "email", "test-credential"), + getUserAttributeMapper("firstName", "firstName", "test-credential"), + getUserAttributeMapper("lastName", "lastName", "test-credential"), + getIdMapper("test-credential"), + getStaticClaimMapper("test-credential", "test-credential"), + + getUserAttributeMapper("given_name", "firstName", "identity_credential"), + getUserAttributeMapper("family_name", "lastName", "identity_credential") + ) + ); + return clientRepresentation; + } + + protected ComponentExportRepresentation getKeyProvider(){ + return getEcKeyProvider(); + } + + @Override + protected List getSigningProviders() { + return List.of(getIdCredentialSigningProvider(), getTestCredentialSigningProvider()); + } + + static class TestCredentialResponseHandler extends CredentialResponseHandler { + final String vct; + TestCredentialResponseHandler(String vct){ + this.vct = vct; + } + @Override + protected void handleCredentialResponse(CredentialResponse credentialResponse) throws VerificationException { + // SDJWT have a special format. + SdJwtVP sdJwtVP = SdJwtVP.of(credentialResponse.getCredential().toString()); + JsonWebToken jsonWebToken = TokenVerifier.create(sdJwtVP.getIssuerSignedJWT().getJwsString(), JsonWebToken.class).getToken(); + + assertNotNull("A valid credential string should have been responded", jsonWebToken); + assertNotNull("The credentials should be included at the vct-claim.", jsonWebToken.getOtherClaims().get("vct")); + assertEquals("The credentials should be included at the vct-claim.", vct, jsonWebToken.getOtherClaims().get("vct").toString()); + + Map disclosureMap = sdJwtVP.getDisclosures().values().stream() + .map(disclosure -> { + try { + JsonNode jsonNode = JsonSerialization.mapper.readTree(Base64Url.decode(disclosure)); + return Map.entry(jsonNode.get(1).asText(), jsonNode); // Create a Map.Entry + } catch (IOException e) { + throw new RuntimeException(e); // Re-throw as unchecked exception + } + }) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + assertFalse("Only mappers supported for the requested type should have been evaluated.", disclosureMap.containsKey("given_name")); + assertTrue("The credentials should include the firstName claim.", disclosureMap.containsKey("firstName")); + assertEquals("firstName claim incorrectly mapped.", disclosureMap.get("firstName").get(2).asText(), "John"); + assertTrue("The credentials should include the lastName claim.", disclosureMap.containsKey("lastName")); + assertEquals("lastName claim incorrectly mapped.", disclosureMap.get("lastName").get(2).asText(), "Doe"); + assertTrue("The credentials should include the roles claim.", disclosureMap.containsKey("roles")); + assertTrue("The credentials should include the id claim", disclosureMap.containsKey("id")); + assertTrue("The credentials should include the test-credential claim.", disclosureMap.containsKey("test-credential")); + assertTrue("lastName claim incorrectly mapped.", disclosureMap.get("test-credential").get(2).asBoolean()); + assertTrue("The credentials should include the email claim.", disclosureMap.containsKey("email")); + assertEquals("email claim incorrectly mapped.", disclosureMap.get("email").get(2).asText(), "john@email.cz"); + + } + } +} + diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCTest.java index a323e1c4ee..024e729554 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCTest.java @@ -27,6 +27,7 @@ import org.keycloak.common.util.PemUtils; import org.keycloak.crypto.KeyUse; import org.keycloak.crypto.KeyWrapper; import org.keycloak.models.KeycloakSession; +import org.keycloak.models.utils.DefaultKeyProviders; import org.keycloak.models.utils.KeycloakModelUtils; import org.keycloak.protocol.oid4vc.OID4VCLoginProtocolFactory; import org.keycloak.protocol.oid4vc.issuance.TimeProvider; @@ -104,7 +105,11 @@ public abstract class OID4VCTest extends AbstractTestRealmKeycloakTest { kw.setPrivateKey(keyPair.getPrivate()); kw.setPublicKey(keyPair.getPublic()); kw.setUse(KeyUse.SIG); - kw.setKid(keyId); + if (keyId != null) { + kw.setKid(keyId); + } else { + kw.setKid(KeyUtils.createKeyId(keyPair.getPublic())); + } kw.setType("EC"); kw.setAlgorithm("ES256"); return kw; @@ -173,29 +178,27 @@ public abstract class OID4VCTest extends AbstractTestRealmKeycloakTest { return componentExportRepresentation; } - public static ClientRepresentation getTestClient(String clientId) { + protected ClientRepresentation getTestClient(String clientId) { ClientRepresentation clientRepresentation = new ClientRepresentation(); clientRepresentation.setClientId(clientId); clientRepresentation.setProtocol(OID4VCLoginProtocolFactory.PROTOCOL_ID); clientRepresentation.setEnabled(true); clientRepresentation.setAttributes(Map.of( "vc.test-credential.expiry_in_s", "100", - "vc.test-credential.format", Format.JWT_VC.toString(), + "vc.test-credential.format", Format.JWT_VC, "vc.test-credential.scope", "VerifiableCredential", "vc.test-credential.claims", "{ \"firstName\": {\"mandatory\": false, \"display\": [{\"name\": \"First Name\", \"locale\": \"en-US\"}, {\"name\": \"名前\", \"locale\": \"ja-JP\"}]}, \"lastName\": {\"mandatory\": false}, \"email\": {\"mandatory\": false} }", - "vc.test-credential.vct", "VerifiableCredential", - "vc.test-credential.cryptographic_binding_methods_supported", "jwk", - "vc.test-credential.credential_signing_alg_values_supported", "ES256,ES384", - "vc.test-credential.display.0","{\n \"name\": \"Test Credential\"\n}", - "vc.test-credential.proof_types_supported","{\"jwt\":{\"proof_signing_alg_values_supported\":[\"ES256\"]}}" + "vc.test-credential.display.0","{\n \"name\": \"Test Credential\"\n}" + // Moved sd-jwt specific attributes to: org.keycloak.testsuite.oid4vc.issuance.signing.OID4VCSdJwtIssuingEndpointTest.getTestCredentialSigningProvider )); clientRepresentation.setProtocolMappers( List.of( - getRoleMapper(clientId), - getEmailMapper(), - getIdMapper(), - getStaticClaimMapper("VerifiableCredential"), - getStaticClaimMapper("AnotherCredentialType") + getRoleMapper(clientId, "VerifiableCredential"), + getUserAttributeMapper("email", "email", "VerifiableCredential"), + getIdMapper("VerifiableCredential"), + getStaticClaimMapper("VerifiableCredential", "VerifiableCredential"), + // This is used for negative test. Shall not land into the credential + getStaticClaimMapper("AnotherCredentialType", "AnotherCredentialType") ) ); return clientRepresentation; @@ -205,8 +208,6 @@ public abstract class OID4VCTest extends AbstractTestRealmKeycloakTest { ComponentExportRepresentation componentExportRepresentation = new ComponentExportRepresentation(); componentExportRepresentation.setName("eddsa-generated"); componentExportRepresentation.setId(UUID.randomUUID().toString()); - componentExportRepresentation.setName("eddsa-generated"); - componentExportRepresentation.setId(UUID.randomUUID().toString()); componentExportRepresentation.setProviderId("eddsa-generated"); componentExportRepresentation.setConfig(new MultivaluedHashMap<>( @@ -217,7 +218,20 @@ public abstract class OID4VCTest extends AbstractTestRealmKeycloakTest { return componentExportRepresentation; } - public static ProtocolMapperRepresentation getRoleMapper(String clientId) { + protected ComponentExportRepresentation getEcKeyProvider() { + ComponentExportRepresentation componentExportRepresentation = new ComponentExportRepresentation(); + componentExportRepresentation.setName("ecdsa-issuer-key"); + componentExportRepresentation.setId(UUID.randomUUID().toString()); + componentExportRepresentation.setProviderId("ecdsa-generated"); + componentExportRepresentation.setConfig(new MultivaluedHashMap<>( + Map.of( + "ecdsaEllipticCurveKey", List.of("P-256"), + "algorithm", List.of("ES256") )) + ); + return componentExportRepresentation; + } + + public static ProtocolMapperRepresentation getRoleMapper(String clientId, String supportedCredentialTypes) { ProtocolMapperRepresentation protocolMapperRepresentation = new ProtocolMapperRepresentation(); protocolMapperRepresentation.setName("role-mapper"); protocolMapperRepresentation.setId(UUID.randomUUID().toString()); @@ -227,27 +241,12 @@ public abstract class OID4VCTest extends AbstractTestRealmKeycloakTest { Map.of( "subjectProperty", "roles", "clientId", clientId, - "supportedCredentialTypes", "VerifiableCredential") + "supportedCredentialTypes", supportedCredentialTypes) ); return protocolMapperRepresentation; } - public static ProtocolMapperRepresentation getEmailMapper() { - ProtocolMapperRepresentation protocolMapperRepresentation = new ProtocolMapperRepresentation(); - protocolMapperRepresentation.setName("email-mapper"); - protocolMapperRepresentation.setProtocol("oid4vc"); - protocolMapperRepresentation.setId(UUID.randomUUID().toString()); - protocolMapperRepresentation.setProtocolMapper("oid4vc-user-attribute-mapper"); - protocolMapperRepresentation.setConfig( - Map.of( - "subjectProperty", "email", - "userAttribute", "email", - "supportedCredentialTypes", "VerifiableCredential") - ); - return protocolMapperRepresentation; - } - - public static ProtocolMapperRepresentation getIdMapper() { + public static ProtocolMapperRepresentation getIdMapper(String supportedCredentialTypes) { ProtocolMapperRepresentation protocolMapperRepresentation = new ProtocolMapperRepresentation(); protocolMapperRepresentation.setName("id-mapper"); protocolMapperRepresentation.setProtocol("oid4vc"); @@ -255,12 +254,12 @@ public abstract class OID4VCTest extends AbstractTestRealmKeycloakTest { protocolMapperRepresentation.setProtocolMapper("oid4vc-subject-id-mapper"); protocolMapperRepresentation.setConfig( Map.of( - "supportedCredentialTypes", "VerifiableCredential") + "supportedCredentialTypes", supportedCredentialTypes) ); return protocolMapperRepresentation; } - public static ProtocolMapperRepresentation getStaticClaimMapper(String supportedType) { + public static ProtocolMapperRepresentation getStaticClaimMapper(String scope, String supportedCredentialTypes) { ProtocolMapperRepresentation protocolMapperRepresentation = new ProtocolMapperRepresentation(); protocolMapperRepresentation.setName(UUID.randomUUID().toString()); protocolMapperRepresentation.setProtocol("oid4vc"); @@ -268,9 +267,9 @@ public abstract class OID4VCTest extends AbstractTestRealmKeycloakTest { protocolMapperRepresentation.setProtocolMapper("oid4vc-static-claim-mapper"); protocolMapperRepresentation.setConfig( Map.of( - "subjectProperty", supportedType, + "subjectProperty", scope, "staticValue", "true", - "supportedCredentialTypes", supportedType) + "supportedCredentialTypes", supportedCredentialTypes) ); return protocolMapperRepresentation; } @@ -353,4 +352,18 @@ public abstract class OID4VCTest extends AbstractTestRealmKeycloakTest { } } + protected ProtocolMapperRepresentation getUserAttributeMapper(String subjectProperty, String attributeName, String supportedCredentialTypes) { + ProtocolMapperRepresentation protocolMapperRepresentation = new ProtocolMapperRepresentation(); + protocolMapperRepresentation.setName(supportedCredentialTypes + "-" + attributeName + "-mapper"); + protocolMapperRepresentation.setProtocol("oid4vc"); + protocolMapperRepresentation.setId(UUID.randomUUID().toString()); + protocolMapperRepresentation.setProtocolMapper("oid4vc-user-attribute-mapper"); + protocolMapperRepresentation.setConfig( + Map.of( + "subjectProperty", subjectProperty, + "userAttribute", attributeName, + "supportedCredentialTypes", supportedCredentialTypes) + ); + return protocolMapperRepresentation; + } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/SdJwtSigningServiceTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/SdJwtSigningServiceTest.java index b4c889f1b5..ca231f21b0 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/SdJwtSigningServiceTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/SdJwtSigningServiceTest.java @@ -30,14 +30,19 @@ import org.keycloak.crypto.ServerECDSASignatureVerifierContext; import org.keycloak.crypto.SignatureVerifierContext; import org.keycloak.jose.jws.crypto.HashUtils; import org.keycloak.models.KeycloakSession; +import org.keycloak.protocol.oid4vc.issuance.VCIssuanceContext; import org.keycloak.protocol.oid4vc.issuance.signing.JwtSigningService; import org.keycloak.protocol.oid4vc.issuance.signing.SdJwtSigningService; import org.keycloak.protocol.oid4vc.issuance.signing.SigningServiceException; +import org.keycloak.protocol.oid4vc.model.CredentialConfigId; +import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration; import org.keycloak.protocol.oid4vc.model.VerifiableCredential; +import org.keycloak.protocol.oid4vc.model.VerifiableCredentialType; import org.keycloak.representations.JsonWebToken; import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.sdjwt.SdJwtUtils; import org.keycloak.testsuite.runonserver.RunOnServerException; +import org.keycloak.util.JsonSerialization; import java.security.PublicKey; import java.util.Arrays; @@ -54,10 +59,9 @@ import static org.junit.Assert.fail; public class SdJwtSigningServiceTest extends OID4VCTest { - private static ObjectMapper OBJECT_MAPPER = new ObjectMapper(); private static KeyWrapper rsaKey = getRsaKey(); - // If an unsupported algorithm is provided, the JWT Sigining Service should not be instantiated. + // If an unsupported algorithm is provided, the JWT Signing Service should not be instantiated. @Test(expected = SigningServiceException.class) public void testUnsupportedAlgorithm() throws Throwable { try { @@ -74,14 +78,15 @@ public class SdJwtSigningServiceTest extends OID4VCTest { "did:web:test.org", 0, List.of(), - new StaticTimeProvider(1000), - Optional.empty())); + Optional.empty(), + VerifiableCredentialType.from("https://credentials.example.com/test-credential"), + CredentialConfigId.from("test-credential"))); } catch (RunOnServerException ros) { throw ros.getCause(); } } - // If no key is provided, the JWT Sigining Service should not be instantiated. + // If no key is provided, the JWT Signing Service should not be instantiated. @Test(expected = SigningServiceException.class) public void testFailIfNoKey() throws Throwable { try { @@ -193,12 +198,15 @@ public class SdJwtSigningServiceTest extends OID4VCTest { "did:web:test.org", decoys, visibleClaims, - new StaticTimeProvider(1000), - keyId); + keyId, + VerifiableCredentialType.from("https://credentials.example.com/test-credential"), + CredentialConfigId.from("test-credential")); VerifiableCredential testCredential = getTestCredential(claims); - - String sdJwt = signingService.signCredential(testCredential); + VCIssuanceContext vcIssuanceContext = new VCIssuanceContext() + .setVerifiableCredential(testCredential) + .setCredentialConfig(new SupportedCredentialConfiguration()); + String sdJwt = signingService.signCredential(vcIssuanceContext); SignatureVerifierContext verifierContext = null; switch (algorithm) { case Algorithm.ES256: { @@ -241,10 +249,7 @@ public class SdJwtSigningServiceTest extends OID4VCTest { assertEquals("The issuer should be set in the token.", TEST_DID.toString(), theToken.getIssuer()); assertEquals("The credential ID should be set as the token ID.", testCredential.getId().toString(), theToken.getId()); - assertEquals("The type should be included", TEST_TYPES.get(0), theToken.getOtherClaims().get("vct")); - - assertEquals("The nbf date should be included", TEST_ISSUANCE_DATE.getEpochSecond(), theToken.getNbf().longValue()); - + assertEquals("The type should be included", "https://credentials.example.com/test-credential", theToken.getOtherClaims().get("vct")); List sds = (List) theToken.getOtherClaims().get("_sd"); if (sds != null && !sds.isEmpty()){ assertEquals("The algorithm should be included", "sha-256", theToken.getOtherClaims().get("_sd_alg")); @@ -266,7 +271,7 @@ public class SdJwtSigningServiceTest extends OID4VCTest { .map(disclosed -> new String(Base64.getUrlDecoder().decode(disclosed))) .map(disclosedString -> { try { - return OBJECT_MAPPER.readValue(disclosedString, List.class); + return JsonSerialization.mapper.readValue(disclosedString, List.class); } catch (JsonProcessingException e) { throw new RuntimeException(e); }