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:
parent
b4368b75e6
commit
30be268672
35 changed files with 1901 additions and 637 deletions
|
@ -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()));
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
);
|
||||
|
||||
}
|
||||
}
|
|
@ -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())
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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\"]}}"),
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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());
|
||||
|
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}));
|
||||
}
|
||||
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
|
@ -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");
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue