Enhance Verifiable Credential Signing Service Flexibility and Key Rotation(#30692)

closes #30525 

Signed-off-by: Francis Pouatcha <francis.pouatcha@adorsys.com>
This commit is contained in:
Francis Pouatcha 2024-07-24 07:45:39 -04:00 committed by GitHub
parent b4368b75e6
commit 30be268672
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 1901 additions and 637 deletions

View file

@ -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()));
}

View file

@ -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<String, VerifiableCredentialsSigningService> 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<String, SupportedCredentialConfiguration> 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<String, SupportedCredentialConfiguration> supportedCredentials, String requestedFormat) {
// 1. Format resolver
List<SupportedCredentialConfiguration> configs = supportedCredentials.values().stream()
.filter(supportedCredential -> Objects.equals(supportedCredential.getFormat(), requestedFormat))
.collect(Collectors.toList());
List<SupportedCredentialConfiguration> 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<OID4VCClient> clients = getClientsOfType(vcType, format);
List<OID4VCClient> clients = getClientsOfScope(credentialConfig.getScope(), credentialConfig.getFormat());
List<OID4VCMapper> 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<ProtocolMapperModel> getProtocolMappers(List<OID4VCClient> 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<OID4VCClient> 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<OID4VCClient> 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<OID4VCMapper> protocolMappers, String vcType,
UserSessionModel userSessionModel) {
private VCIssuanceContext getVCToSign(List<OID4VCMapper> 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<String, Object> 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);
}
}

View file

@ -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 <a href="mailto:francis.pouatcha@adorsys.com">Francis Pouatcha</a>
*/
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;
}
}

View file

@ -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<String, Object> claims,
UserSessionModel userSessionModel);
}
}

View file

@ -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 <a href="mailto:francis.pouatcha@adorsys.com">Francis Pouatcha</a>
*/
public abstract class JwtProofBasedSigningService<T> extends SigningService<T> {
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<Proof> 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<Proof> 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<String>
.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
}
);
}
}

View file

@ -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<String> {
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<String> {
}
@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())

View file

@ -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<VerifiableCredential> {
private final String keyId;
public LDSigningService(KeycloakSession keycloakSession, String keyId, String algorithmType, String ldpType, TimeProvider timeProvider, Optional<String> 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<VerifiableCredential> {
}
@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.

View file

@ -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 <a href="https://github.com/wistefan">Stefan Wiedemann</a>
*/
public class SdJwtSigningService extends SigningService<String> {
public class SdJwtSigningService extends JwtProofBasedSigningService<String> {
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<String> visibleClaims;
protected final String issuerDid;
public SdJwtSigningService(KeycloakSession keycloakSession, ObjectMapper objectMapper, String keyId, String algorithmType, String tokenType, String hashAlgorithm, String issuerDid, int decoys, List<String> visibleClaims, TimeProvider timeProvider, Optional<String> 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<String> visibleClaims, Optional<String> 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<String> {
}
@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<String> {
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<String> {
return sdJwt.toSdJwtString();
}
@Override
public String locator() {
return VerifiableCredentialsSigningService.locator(format, vct, vcConfigId);
}
}

View file

@ -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<String> 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<String> 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

View file

@ -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;
}
}
}

View file

@ -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<T> 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
}
}
}

View file

@ -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<Verifi
*/
String ISSUER_DID_REALM_ATTRIBUTE_KEY = "issuerDid";
public static ProviderConfigurationBuilder configurationBuilder() {
static ProviderConfigurationBuilder configurationBuilder() {
return ProviderConfigurationBuilder.create()
.property(SigningProperties.KEY_ID.asConfigProperty());
// I do believe the ALGORITHM_TYPE need to be mandatory instead. As the keyId might change with key rotation.
// If keyId is not set, service can always work with active key.
.property(SigningProperties.ALGORITHM_TYPE.asConfigProperty());
}
@Override
default void validateConfiguration(KeycloakSession session, RealmModel realm, ComponentModel model) throws ComponentValidationException {
ConfigurationValidationHelper.check(model)
.checkRequired(SigningProperties.KEY_ID.asConfigProperty());
.checkRequired(SigningProperties.ALGORITHM_TYPE.asConfigProperty());
validateSpecificConfiguration(session, realm, model);
}

View file

@ -17,7 +17,10 @@
package org.keycloak.protocol.oid4vc.issuance.signing;
import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
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.VerifiableCredentialType;
import org.keycloak.provider.Provider;
/**
@ -30,8 +33,32 @@ public interface VerifiableCredentialsSigningService<T> 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);
}
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());
}
}

View file

@ -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 <a href="mailto:francis.pouatcha@adorsys.com">Francis Pouatcha</a>
*/
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;
}
}

View file

@ -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 <a href="mailto:francis.pouatcha@adorsys.com">Francis Pouatcha</a>
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
public class CredentialDefinition {
@JsonProperty("@context")
private List<String> context;
private List<String> type = new ArrayList<>();
private CredentialSubject credentialSubject = new CredentialSubject();
public List<String> getContext() {
return context;
}
public CredentialDefinition setContext(List<String> context) {
this.context = context;
return this;
}
public List<String> getType() {
return type;
}
public CredentialDefinition setType(List<String> 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);
}
}
}

View file

@ -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;
}
}

View file

@ -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;
}
}
}

View file

@ -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<String> SUPPORTED_FORMATS = Collections.unmodifiableSet(Set.of(JWT_VC, LDP_VC, SD_JWT_VC));
}

View file

@ -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;
}
}

View file

@ -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<String, String> toDotNotation() {
Map<String, String> 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<DisplayObject> 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);
}
}

View file

@ -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;

View file

@ -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 <a href="mailto:francis.pouatcha@adorsys.com">Francis Pouatcha</a>
*/
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;
}
}

View file

@ -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,

View file

@ -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) {

View file

@ -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\"]}}"),

View file

@ -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 <a href="mailto:francis.pouatcha@adorsys.com">Francis Pouatcha</a>
*/
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());
}
}

View file

@ -98,7 +98,7 @@ public class PreAuthorizedGrantTest extends AbstractTestRealmKeycloakTest {
HttpPost post = new HttpPost(getTokenEndpoint());
List<NameValuePair> 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);

View file

@ -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) {

View file

@ -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());

View file

@ -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<String> 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<NameValuePair> 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<String, String, String> f, Consumer<Map<String, Object>> c) throws Exception {
testCredentialIssuanceWithAuthZCodeFlow(m->{
protected void testCredentialIssuanceWithAuthZCodeFlow(BiFunction<String, String, String> f, Consumer<Map<String, Object>> 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<ComponentExportRepresentation> 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"));
}
}
}

View file

@ -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
}));
}

View file

@ -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<String> 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<NameValuePair> 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());
}
});
}
}

View file

@ -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 <a href="mailto:francis.pouatcha@adorsys.com">Francis Pouatcha</a>
*/
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<NameValuePair> 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<String, String> 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<String, String> 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<String, String> 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<ComponentExportRepresentation> 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<String, JsonNode> 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");
}
}
}

View file

@ -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;
}
}

View file

@ -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<String> sds = (List<String>) 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);
}