diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/OID4VCClientRegistrationProvider.java b/services/src/main/java/org/keycloak/protocol/oid4vc/OID4VCClientRegistrationProvider.java
new file mode 100644
index 0000000000..c4232fd48e
--- /dev/null
+++ b/services/src/main/java/org/keycloak/protocol/oid4vc/OID4VCClientRegistrationProvider.java
@@ -0,0 +1,169 @@
+/*
+ * 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;
+
+import jakarta.ws.rs.Consumes;
+import jakarta.ws.rs.DELETE;
+import jakarta.ws.rs.POST;
+import jakarta.ws.rs.PUT;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.PathParam;
+import jakarta.ws.rs.Produces;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.Response;
+import org.jboss.logging.Logger;
+import org.keycloak.models.KeycloakSession;
+import org.keycloak.protocol.oid4vc.model.OID4VCClient;
+import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration;
+import org.keycloak.representations.idm.ClientRepresentation;
+import org.keycloak.services.ErrorResponseException;
+import org.keycloak.services.clientregistration.AbstractClientRegistrationProvider;
+import org.keycloak.services.clientregistration.DefaultClientRegistrationContext;
+
+import java.net.URI;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.UUID;
+import java.util.stream.Collectors;
+
+/**
+ * Provides the client-registration functionality for OID4VC-clients.
+ *
+ * @author Stefan Wiedemann
+ */
+public class OID4VCClientRegistrationProvider extends AbstractClientRegistrationProvider {
+
+ private static final Logger LOGGER = Logger.getLogger(OID4VCClientRegistrationProvider.class);
+
+ private static final String VC_KEY = "vc";
+
+ public OID4VCClientRegistrationProvider(KeycloakSession session) {
+ super(session);
+ }
+
+ @POST
+ @Consumes(MediaType.APPLICATION_JSON)
+ @Produces(MediaType.APPLICATION_JSON)
+ public Response createOID4VCClient(OID4VCClient client) {
+ ClientRepresentation clientRepresentation = toClientRepresentation(client);
+ validate(clientRepresentation);
+
+ ClientRepresentation cr = create(
+ new DefaultClientRegistrationContext(session, clientRepresentation, this));
+ URI uri = session.getContext().getUri().getAbsolutePathBuilder().path(cr.getClientId()).build();
+ return Response.created(uri).entity(cr).build();
+ }
+
+ @PUT
+ @Path("{clientId}")
+ @Consumes(MediaType.APPLICATION_JSON)
+ @Produces(MediaType.APPLICATION_JSON)
+ public Response updateOID4VCClient(@PathParam("clientId") String clientDid, OID4VCClient client) {
+ client.setClientDid(clientDid);
+ ClientRepresentation clientRepresentation = toClientRepresentation(client);
+ validate(clientRepresentation);
+ clientRepresentation = update(clientDid,
+ new DefaultClientRegistrationContext(session, clientRepresentation, this));
+ return Response.ok(clientRepresentation).build();
+ }
+
+ @DELETE
+ @Path("{clientId}")
+ public Response deleteOID4VCClient(@PathParam("clientId") String clientDid) {
+ delete(clientDid);
+ return Response.noContent().build();
+ }
+
+ /**
+ * Validates the clientRepresentation to fulfill the requirement of an OID4VC client
+ */
+ public static void validate(ClientRepresentation client) {
+ String did = client.getClientId();
+ if (did == null) {
+ throw new ErrorResponseException("no_did", "A client did needs to be configured for OID4VC clients",
+ Response.Status.BAD_REQUEST);
+ }
+ if (!did.startsWith("did:")) {
+ throw new ErrorResponseException("invalid_did", "The client id is not a did.",
+ Response.Status.BAD_REQUEST);
+ }
+ }
+
+ /**
+ * Translate an incoming {@link OID4VCClient} into a keycloak native {@link ClientRepresentation}.
+ *
+ * @param oid4VCClient pojo, containing the oid4vc client parameters
+ * @return a clientRepresentation
+ */
+ protected static ClientRepresentation toClientRepresentation(OID4VCClient oid4VCClient) {
+ ClientRepresentation clientRepresentation = new ClientRepresentation();
+ clientRepresentation.setProtocol(OID4VCLoginProtocolFactory.PROTOCOL_ID);
+
+ clientRepresentation.setId(Optional.ofNullable(oid4VCClient.getId()).orElse(UUID.randomUUID().toString()));
+ clientRepresentation.setClientId(oid4VCClient.getClientDid());
+ // only add non-null parameters
+ Optional.ofNullable(oid4VCClient.getDescription()).ifPresent(clientRepresentation::setDescription);
+ Optional.ofNullable(oid4VCClient.getName()).ifPresent(clientRepresentation::setName);
+
+
+ Map clientAttributes = oid4VCClient.getSupportedVCTypes()
+ .stream()
+ .map(SupportedCredentialConfiguration::toDotNotation)
+ .flatMap(dotNotated -> dotNotated.entrySet().stream())
+ .collect(Collectors.toMap(entry -> VC_KEY + "." + entry.getKey(), Map.Entry::getValue, (e1, e2) -> e1));
+
+ if (!clientAttributes.isEmpty()) {
+ clientRepresentation.setAttributes(clientAttributes);
+ }
+
+
+ LOGGER.debugf("Generated client representation {}.", clientRepresentation);
+ return clientRepresentation;
+ }
+
+ public static OID4VCClient fromClientAttributes(String clientId, Map clientAttributes) {
+
+ OID4VCClient oid4VCClient = new OID4VCClient()
+ .setClientDid(clientId);
+
+ Set supportedCredentialIds = new HashSet<>();
+ Map attributes = new HashMap<>();
+ clientAttributes
+ .entrySet()
+ .forEach(entry -> {
+ if (!entry.getKey().startsWith(VC_KEY)) {
+ return;
+ }
+ String key = entry.getKey().substring((VC_KEY + ".").length());
+ supportedCredentialIds.add(key.split("\\.")[0]);
+ attributes.put(key, entry.getValue());
+ });
+
+
+ List supportedCredentialConfigurations = supportedCredentialIds
+ .stream()
+ .map(id -> SupportedCredentialConfiguration.fromDotNotation(id, attributes))
+ .toList();
+
+ return oid4VCClient.setSupportedVCTypes(supportedCredentialConfigurations);
+ }
+}
\ No newline at end of file
diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/OID4VCClientRegistrationProviderFactory.java b/services/src/main/java/org/keycloak/protocol/oid4vc/OID4VCClientRegistrationProviderFactory.java
new file mode 100644
index 0000000000..f63cb0fdd2
--- /dev/null
+++ b/services/src/main/java/org/keycloak/protocol/oid4vc/OID4VCClientRegistrationProviderFactory.java
@@ -0,0 +1,68 @@
+/*
+ * 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;
+
+import org.keycloak.Config;
+import org.keycloak.models.KeycloakSession;
+import org.keycloak.models.KeycloakSessionFactory;
+import org.keycloak.provider.ProviderConfigProperty;
+import org.keycloak.services.clientregistration.ClientRegistrationProvider;
+import org.keycloak.services.clientregistration.ClientRegistrationProviderFactory;
+
+import java.util.List;
+
+/**
+ * Implementation of the {@link ClientRegistrationProviderFactory} to integrate the OID4VC protocols with
+ * Keycloak's client-registration.
+ *
+ * {@see https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html}
+ *
+ * @author Stefan Wiedemann
+ */
+public class OID4VCClientRegistrationProviderFactory implements ClientRegistrationProviderFactory, OID4VCEnvironmentProviderFactory {
+
+ @Override
+ public ClientRegistrationProvider create(KeycloakSession session) {
+ return new OID4VCClientRegistrationProvider(session);
+ }
+
+ @Override
+ public void init(Config.Scope config) {
+ // no config required
+ }
+
+ @Override
+ public void postInit(KeycloakSessionFactory factory) {
+ // nothing to do post init
+ }
+
+ @Override
+ public void close() {
+ // no resources to close
+ }
+
+ @Override
+ public String getId() {
+ return OID4VCLoginProtocolFactory.PROTOCOL_ID;
+ }
+
+ @Override
+ public List getConfigMetadata() {
+ return List.of();
+ }
+}
\ No newline at end of file
diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/OID4VCEnvironmentProviderFactory.java b/services/src/main/java/org/keycloak/protocol/oid4vc/OID4VCEnvironmentProviderFactory.java
new file mode 100644
index 0000000000..1b7950cc4c
--- /dev/null
+++ b/services/src/main/java/org/keycloak/protocol/oid4vc/OID4VCEnvironmentProviderFactory.java
@@ -0,0 +1,38 @@
+/*
+ * 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;
+
+import org.keycloak.Config;
+import org.keycloak.common.Profile;
+import org.keycloak.provider.EnvironmentDependentProviderFactory;
+
+/**
+ * Interface for all OID4VC related provider factories, to ensure usage of the same feature flag.
+ */
+public interface OID4VCEnvironmentProviderFactory extends EnvironmentDependentProviderFactory {
+
+ @Override
+ default boolean isSupported(Config.Scope config) {
+ return Profile.isFeatureEnabled(Profile.Feature.OID4VC_VCI);
+ }
+
+ @Override
+ default boolean isSupported() {
+ return Profile.isFeatureEnabled(Profile.Feature.OID4VC_VCI);
+ }
+}
\ No newline at end of file
diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/OID4VCLoginProtocolFactory.java b/services/src/main/java/org/keycloak/protocol/oid4vc/OID4VCLoginProtocolFactory.java
new file mode 100644
index 0000000000..166675f1b7
--- /dev/null
+++ b/services/src/main/java/org/keycloak/protocol/oid4vc/OID4VCLoginProtocolFactory.java
@@ -0,0 +1,173 @@
+/*
+ * 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;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.jboss.logging.Logger;
+import org.keycloak.Config;
+import org.keycloak.component.ComponentModel;
+import org.keycloak.events.EventBuilder;
+import org.keycloak.models.ClientModel;
+import org.keycloak.models.ClientScopeModel;
+import org.keycloak.models.KeycloakSession;
+import org.keycloak.models.KeycloakSessionFactory;
+import org.keycloak.models.ProtocolMapperModel;
+import org.keycloak.models.RealmModel;
+import org.keycloak.models.utils.KeycloakModelUtils;
+import org.keycloak.protocol.LoginProtocol;
+import org.keycloak.protocol.LoginProtocolFactory;
+import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint;
+import org.keycloak.protocol.oid4vc.issuance.OffsetTimeProvider;
+import org.keycloak.protocol.oid4vc.issuance.VCIssuerException;
+import org.keycloak.protocol.oid4vc.issuance.mappers.OID4VCSubjectIdMapper;
+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;
+
+/**
+ * Factory for creating all OID4VC related endpoints and the default mappers.
+ *
+ * @author Stefan Wiedemann
+ */
+public class OID4VCLoginProtocolFactory implements LoginProtocolFactory, OID4VCEnvironmentProviderFactory {
+
+ private static final Logger LOGGER = Logger.getLogger(OID4VCLoginProtocolFactory.class);
+
+ public static final String PROTOCOL_ID = "oid4vc";
+
+ private static final String ISSUER_DID_REALM_ATTRIBUTE_KEY = "issuerDid";
+ private static final String CODE_LIFESPAN_REALM_ATTRIBUTE_KEY = "preAuthorizedCodeLifespanS";
+ private static final int DEFAULT_CODE_LIFESPAN_S = 30;
+
+ private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+ private static final String CLIENT_ROLES_MAPPER = "client-roles";
+ private static final String USERNAME_MAPPER = "username";
+ private static final String SUBJECT_ID_MAPPER = "subject-id";
+ private static final String EMAIL_MAPPER = "email";
+ private static final String LAST_NAME_MAPPER = "last-name";
+ private static final String FIRST_NAME_MAPPER = "first-name";
+
+ private Map builtins = new HashMap<>();
+
+ @Override
+ public void init(Config.Scope config) {
+ builtins.put(CLIENT_ROLES_MAPPER, OID4VCTargetRoleMapper.create("id", "client roles"));
+ builtins.put(SUBJECT_ID_MAPPER, OID4VCSubjectIdMapper.create("subject id", "id"));
+ builtins.put(USERNAME_MAPPER, OID4VCUserAttributeMapper.create(USERNAME_MAPPER, "username", "username", false));
+ builtins.put(EMAIL_MAPPER, OID4VCUserAttributeMapper.create(EMAIL_MAPPER, "email", "email", false));
+ builtins.put(FIRST_NAME_MAPPER, OID4VCUserAttributeMapper.create(FIRST_NAME_MAPPER, "firstName", "firstName", false));
+ builtins.put(LAST_NAME_MAPPER, OID4VCUserAttributeMapper.create(LAST_NAME_MAPPER, "lastName", "familyName", false));
+ }
+
+ @Override
+ public void postInit(KeycloakSessionFactory factory) {
+ // no-op
+ }
+
+ @Override
+ public void close() {
+ // no-op
+ }
+
+ @Override
+ public Map getBuiltinMappers() {
+ return builtins;
+ }
+
+ private void addServiceFromComponent(Map signingServices, KeycloakSession keycloakSession, ComponentModel componentModel) {
+ ProviderFactory factory = keycloakSession
+ .getKeycloakSessionFactory()
+ .getProviderFactory(VerifiableCredentialsSigningService.class, componentModel.getProviderId());
+ if (factory instanceof VCSigningServiceProviderFactory sspf) {
+ signingServices.put(sspf.supportedFormat(), sspf.create(keycloakSession, componentModel));
+ } else {
+ throw new IllegalArgumentException(String.format("The component %s is not a VerifiableCredentialsSigningServiceProviderFactory", componentModel.getProviderId()));
+ }
+
+ }
+
+ @Override
+ public Object createProtocolEndpoint(KeycloakSession keycloakSession, EventBuilder event) {
+
+ Map signingServices = new EnumMap<>(Format.class);
+ RealmModel realm = keycloakSession.getContext().getRealm();
+ realm.getComponentsStream(realm.getId(), VerifiableCredentialsSigningService.class.getName())
+ .forEach(cm -> addServiceFromComponent(signingServices, keycloakSession, cm));
+
+ RealmModel realmModel = keycloakSession.getContext().getRealm();
+ String issuerDid = Optional.ofNullable(realmModel.getAttribute(ISSUER_DID_REALM_ATTRIBUTE_KEY))
+ .orElseThrow(() -> new VCIssuerException("No issuer-did configured."));
+ int preAuthorizedCodeLifespan = Optional.ofNullable(realmModel.getAttribute(CODE_LIFESPAN_REALM_ATTRIBUTE_KEY))
+ .map(Integer::valueOf)
+ .orElse(DEFAULT_CODE_LIFESPAN_S);
+
+ return new OID4VCIssuerEndpoint(
+ keycloakSession,
+ issuerDid,
+ signingServices,
+ new AppAuthManager.BearerTokenAuthenticator(keycloakSession),
+ OBJECT_MAPPER,
+ new OffsetTimeProvider(),
+ preAuthorizedCodeLifespan);
+ }
+
+ @Override
+ public void createDefaultClientScopes(RealmModel newRealm, boolean addScopesToExistingClients) {
+ LOGGER.debugf("Create default scopes for realm %s", newRealm.getName());
+
+ ClientScopeModel naturalPersonScope = KeycloakModelUtils.getClientScopeByName(newRealm, "natural_person");
+ if (naturalPersonScope == null) {
+ LOGGER.debug("Add natural person scope");
+ naturalPersonScope = newRealm.addClientScope(String.format("%s_%s", PROTOCOL_ID, "natural_person"));
+ naturalPersonScope.setDescription("OIDC$VP Scope, that adds all properties required for a natural person.");
+ naturalPersonScope.setProtocol(PROTOCOL_ID);
+ naturalPersonScope.addProtocolMapper(builtins.get(SUBJECT_ID_MAPPER));
+ naturalPersonScope.addProtocolMapper(builtins.get(CLIENT_ROLES_MAPPER));
+ naturalPersonScope.addProtocolMapper(builtins.get(EMAIL_MAPPER));
+ naturalPersonScope.addProtocolMapper(builtins.get(FIRST_NAME_MAPPER));
+ naturalPersonScope.addProtocolMapper(builtins.get(LAST_NAME_MAPPER));
+ newRealm.addDefaultClientScope(naturalPersonScope, true);
+ }
+ }
+
+ @Override
+ public void setupClientDefaults(ClientRepresentation rep, ClientModel newClient) {
+ //no-op
+ }
+
+ @Override
+ public LoginProtocol create(KeycloakSession session) {
+ return null;
+ }
+
+ @Override
+ public String getId() {
+ return PROTOCOL_ID;
+ }
+
+}
\ No newline at end of file
diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerEndpoint.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerEndpoint.java
new file mode 100644
index 0000000000..4d42093fac
--- /dev/null
+++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerEndpoint.java
@@ -0,0 +1,386 @@
+/*
+ * 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 com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import jakarta.ws.rs.BadRequestException;
+import jakarta.ws.rs.Consumes;
+import jakarta.ws.rs.GET;
+import jakarta.ws.rs.POST;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.PathParam;
+import jakarta.ws.rs.Produces;
+import jakarta.ws.rs.QueryParam;
+import jakarta.ws.rs.WebApplicationException;
+import jakarta.ws.rs.core.Response;
+import org.jboss.logging.Logger;
+import org.keycloak.common.util.SecretGenerator;
+import org.keycloak.models.AuthenticatedClientSessionModel;
+import org.keycloak.models.ClientModel;
+import org.keycloak.models.KeycloakSession;
+import org.keycloak.models.ProtocolMapperContainerModel;
+import org.keycloak.models.ProtocolMapperModel;
+import org.keycloak.models.UserSessionModel;
+import org.keycloak.protocol.ProtocolMapper;
+import org.keycloak.protocol.oid4vc.OID4VCClientRegistrationProvider;
+import org.keycloak.protocol.oid4vc.OID4VCLoginProtocolFactory;
+import org.keycloak.protocol.oid4vc.issuance.mappers.OID4VCMapper;
+import org.keycloak.protocol.oid4vc.issuance.signing.VerifiableCredentialsSigningService;
+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.ErrorResponse;
+import org.keycloak.protocol.oid4vc.model.ErrorType;
+import org.keycloak.protocol.oid4vc.model.Format;
+import org.keycloak.protocol.oid4vc.model.OID4VCClient;
+import org.keycloak.protocol.oid4vc.model.PreAuthorizedCode;
+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.PreAuthorizedCodeGrantType;
+import org.keycloak.services.managers.AppAuthManager;
+import org.keycloak.services.managers.AuthenticationManager;
+import org.keycloak.utils.MediaType;
+
+import java.net.URI;
+import java.time.Instant;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+
+/**
+ * Provides the (REST-)endpoints required for the OID4VCI protocol.
+ *
+ * {@see https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html}
+ *
+ * @author Stefan Wiedemann
+ */
+public class OID4VCIssuerEndpoint {
+
+ private static final Logger LOGGER = Logger.getLogger(OID4VCIssuerEndpoint.class);
+
+ public static final String CREDENTIAL_PATH = "credential";
+ public static final String CREDENTIAL_OFFER_PATH = "credential-offer/";
+ private final KeycloakSession session;
+ private final AppAuthManager.BearerTokenAuthenticator bearerTokenAuthenticator;
+ private final ObjectMapper objectMapper;
+ private final TimeProvider timeProvider;
+
+ private final String issuerDid;
+ // lifespan of the preAuthorizedCodes in seconds
+ private final int preAuthorizedCodeLifeSpan;
+
+ private final Map signingServices;
+
+ public OID4VCIssuerEndpoint(KeycloakSession session,
+ String issuerDid,
+ Map signingServices,
+ AppAuthManager.BearerTokenAuthenticator authenticator,
+ ObjectMapper objectMapper, TimeProvider timeProvider, int preAuthorizedCodeLifeSpan) {
+ this.session = session;
+ this.bearerTokenAuthenticator = authenticator;
+ this.objectMapper = objectMapper;
+ this.timeProvider = timeProvider;
+ this.issuerDid = issuerDid;
+ this.signingServices = signingServices;
+ this.preAuthorizedCodeLifeSpan = preAuthorizedCodeLifeSpan;
+
+ }
+
+ /**
+ * Provides the URI to the OID4VCI compliant credentials offer
+ */
+ @GET
+ @Produces(MediaType.APPLICATION_JSON)
+ @Path("credential-offer-uri")
+ public Response getCredentialOfferURI(@QueryParam("credential_configuration_id") String vcId) {
+
+ AuthenticatedClientSessionModel clientSession = getAuthenticatedClientSession();
+
+ Map credentialsMap = OID4VCIssuerWellKnownProvider.getSupportedCredentials(session);
+
+ LOGGER.debugf("Get an offer for %s", vcId);
+ if (!credentialsMap.containsKey(vcId)) {
+ LOGGER.debugf("No credential with id %s exists.", vcId);
+ LOGGER.debugf("Supported credentials are %s.", credentialsMap);
+ throw new BadRequestException(getErrorResponse(ErrorType.INVALID_CREDENTIAL_REQUEST));
+ }
+ SupportedCredentialConfiguration supportedCredentialConfiguration = credentialsMap.get(vcId);
+ Format format = supportedCredentialConfiguration.getFormat();
+
+ // check that the user is allowed to get such credential
+ if (getClientsOfType(supportedCredentialConfiguration.getScope(), format).isEmpty()) {
+ LOGGER.debugf("No OID4VP-Client supporting type %s registered.", supportedCredentialConfiguration.getScope());
+ throw new BadRequestException(getErrorResponse(ErrorType.UNSUPPORTED_CREDENTIAL_TYPE));
+ }
+
+ String nonce = generateNonce();
+ try {
+ clientSession.setNote(nonce, objectMapper.writeValueAsString(supportedCredentialConfiguration));
+ } catch (JsonProcessingException e) {
+ LOGGER.errorf("Could not convert Supported Credential POJO to JSON: %s", e.getMessage());
+ throw new BadRequestException(getErrorResponse(ErrorType.INVALID_CREDENTIAL_REQUEST));
+ }
+
+ CredentialOfferURI credentialOfferURI = new CredentialOfferURI()
+ .setIssuer(OID4VCIssuerWellKnownProvider.getIssuer(session.getContext()) + "/protocol/" + OID4VCLoginProtocolFactory.PROTOCOL_ID + "/" + CREDENTIAL_OFFER_PATH)
+ .setNonce(nonce);
+
+ return Response.ok()
+ .entity(credentialOfferURI)
+ .build();
+
+ }
+
+ /**
+ * Provides an OID4VCI compliant credentials offer
+ */
+ @GET
+ @Produces(MediaType.APPLICATION_JSON)
+ @Path(CREDENTIAL_OFFER_PATH + "{nonce}")
+ public Response getCredentialOffer(@PathParam("nonce") String nonce) {
+ if (nonce == null) {
+ throw new BadRequestException(getErrorResponse(ErrorType.INVALID_CREDENTIAL_REQUEST));
+ }
+
+ AuthenticatedClientSessionModel clientSession = getAuthenticatedClientSession();
+
+ String note = clientSession.getNote(nonce);
+ if (note == null) {
+ throw new BadRequestException(getErrorResponse(ErrorType.INVALID_CREDENTIAL_REQUEST));
+ }
+
+ SupportedCredentialConfiguration offeredCredential;
+ try {
+ offeredCredential = objectMapper.readValue(note,
+ SupportedCredentialConfiguration.class);
+ LOGGER.debugf("Creating an offer for %s - %s", offeredCredential.getScope(),
+ offeredCredential.getFormat());
+ clientSession.removeNote(nonce);
+ } catch (JsonProcessingException e) {
+ LOGGER.errorf("Could not convert SupportedCredential JSON to POJO: %s", e);
+ throw new BadRequestException(getErrorResponse(ErrorType.INVALID_CREDENTIAL_REQUEST));
+ }
+
+ String preAuthorizedCode = generateAuthorizationCodeForClientSession(clientSession);
+
+ CredentialsOffer theOffer = new CredentialsOffer()
+ .setCredentialIssuer(OID4VCIssuerWellKnownProvider.getIssuer(session.getContext()))
+ .setCredentialConfigurationIds(List.of(offeredCredential.getId()))
+ .setGrants(
+ new PreAuthorizedGrant()
+ .setPreAuthorizedCode(
+ new PreAuthorizedCode()
+ .setPreAuthorizedCode(preAuthorizedCode)));
+
+ LOGGER.debugf("Responding with offer: %s", theOffer);
+ return Response.ok()
+ .entity(theOffer)
+ .build();
+ }
+
+ /**
+ * Returns a verifiable credential
+ */
+ @POST
+ @Consumes(MediaType.APPLICATION_JSON)
+ @Produces(MediaType.APPLICATION_JSON)
+ @Path(CREDENTIAL_PATH)
+ public Response requestCredential(
+ CredentialRequest credentialRequestVO) {
+ LOGGER.debugf("Received credentials request %s.", credentialRequestVO);
+
+ // do first to fail fast on auth
+ UserSessionModel userSessionModel = getUserSessionModel();
+
+ Format 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));
+ });
+
+ 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));
+ }
+
+ 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));
+ }
+ return Response.ok().entity(responseVO)
+ .build();
+ }
+
+ private AuthenticatedClientSessionModel getAuthenticatedClientSession() {
+ AuthenticationManager.AuthResult authResult = getAuthResult();
+ UserSessionModel userSessionModel = authResult.getSession();
+
+ AuthenticatedClientSessionModel clientSession = userSessionModel.
+ getAuthenticatedClientSessionByClient(
+ authResult.getClient().getId());
+ if (clientSession == null) {
+ throw new BadRequestException(getErrorResponse(ErrorType.INVALID_TOKEN));
+ }
+ 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)));
+ }
+
+ // get the auth result from the authentication manager
+ private AuthenticationManager.AuthResult getAuthResult(WebApplicationException errorResponse) {
+ AuthenticationManager.AuthResult authResult = bearerTokenAuthenticator.authenticate();
+ if (authResult == null) {
+ throw errorResponse;
+ }
+ return authResult;
+ }
+
+ /**
+ * 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
+ * @return the signed credential
+ */
+ private Object getCredential(UserSessionModel userSessionModel, String vcType, Format format) {
+
+ List clients = getClientsOfType(vcType, format);
+
+ List protocolMappers = getProtocolMappers(clients)
+ .stream()
+ .map(pm -> {
+ if (session.getProvider(ProtocolMapper.class, pm.getProtocolMapper()) instanceof OID4VCMapper mapperFactory) {
+ ProtocolMapper protocolMapper = mapperFactory.create(session);
+ if (protocolMapper instanceof OID4VCMapper oid4VCMapper) {
+ oid4VCMapper.setMapperModel(pm);
+ return oid4VCMapper;
+ }
+ }
+ LOGGER.warnf("The protocol mapper %s is not an instance of OID4VCMapper.", pm.getId());
+ return null;
+ })
+ .filter(Objects::nonNull)
+ .toList();
+
+ VerifiableCredential credentialToSign = getVCToSign(protocolMappers, vcType, userSessionModel);
+
+ return Optional.ofNullable(signingServices.get(format))
+ .map(verifiableCredentialsSigningService -> verifiableCredentialsSigningService.signCredential(credentialToSign))
+ .orElseThrow(() -> new IllegalArgumentException(String.format("Requested format %s is not supported.", format)));
+ }
+
+ private List getProtocolMappers(List oid4VCClients) {
+
+ return oid4VCClients.stream()
+ .map(OID4VCClient::getClientDid)
+ .map(this::getClient)
+ .flatMap(ProtocolMapperContainerModel::getProtocolMappersStream)
+ .toList();
+ }
+
+ private String generateNonce() {
+ return SecretGenerator.getInstance().randomString();
+ }
+
+ private String generateAuthorizationCodeForClientSession(AuthenticatedClientSessionModel clientSessionModel) {
+ int expiration = timeProvider.currentTimeSeconds() + preAuthorizedCodeLifeSpan;
+ return PreAuthorizedCodeGrantType.getPreAuthorizedCode(session, clientSessionModel, expiration);
+ }
+
+ private Response getErrorResponse(ErrorType errorType) {
+ var errorResponse = new ErrorResponse();
+ errorResponse.setError(errorType);
+ return Response.status(Response.Status.BAD_REQUEST).entity(errorResponse).build();
+ }
+
+ // Return all {@link OID4VCClient}s that support the given type and format
+ private List getClientsOfType(String vcType, Format format) {
+ LOGGER.debugf("Retrieve all clients of type %s, supporting format %s", vcType, format.toString());
+
+ if (Optional.ofNullable(vcType).filter(type -> !type.isEmpty()).isEmpty()) {
+ throw new BadRequestException("No VerifiableCredential-Type was provided in the request.");
+ }
+
+ return getOID4VCClientsFromSession()
+ .stream()
+ .filter(oid4VCClient -> oid4VCClient.getSupportedVCTypes()
+ .stream()
+ .anyMatch(supportedCredential -> supportedCredential.getScope().equals(vcType)))
+ .toList();
+ }
+
+ private ClientModel getClient(String clientId) {
+ return session.clients().getClientByClientId(session.getContext().getRealm(), clientId);
+ }
+
+ private List getOID4VCClientsFromSession() {
+ return session.clients().getClientsStream(session.getContext().getRealm())
+ .filter(clientModel -> clientModel.getProtocol() != null)
+ .filter(clientModel -> clientModel.getProtocol()
+ .equals(OID4VCLoginProtocolFactory.PROTOCOL_ID))
+ .map(clientModel -> OID4VCClientRegistrationProvider.fromClientAttributes(clientModel.getClientId(), clientModel.getAttributes()))
+ .toList();
+ }
+
+ // builds the unsigned credential by applying all protocol mappers.
+ private VerifiableCredential getVCToSign(List protocolMappers, String vcType,
+ UserSessionModel userSessionModel) {
+ // set the required claims
+ VerifiableCredential vc = new VerifiableCredential()
+ .setIssuer(URI.create(issuerDid))
+ .setIssuanceDate(Date.from(Instant.ofEpochMilli(timeProvider.currentTimeMillis())))
+ .setType(List.of(vcType));
+
+ Map subjectClaims = new HashMap<>();
+ protocolMappers
+ .forEach(mapper -> mapper.setClaimsForSubject(subjectClaims, userSessionModel));
+
+ subjectClaims.forEach((key, value) -> vc.getCredentialSubject().setClaims(key, value));
+
+ protocolMappers
+ .forEach(mapper -> mapper.setClaimsForCredential(vc, userSessionModel));
+
+ LOGGER.debugf("The credential to sign is: %s", vc);
+ return vc;
+ }
+}
\ No newline at end of file
diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerWellKnownProvider.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerWellKnownProvider.java
new file mode 100644
index 0000000000..82a270ab2a
--- /dev/null
+++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerWellKnownProvider.java
@@ -0,0 +1,118 @@
+/*
+ * 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 jakarta.ws.rs.core.UriInfo;
+import org.keycloak.models.KeycloakContext;
+import org.keycloak.models.KeycloakSession;
+import org.keycloak.models.RealmModel;
+import org.keycloak.protocol.oid4vc.OID4VCClientRegistrationProvider;
+import org.keycloak.protocol.oid4vc.OID4VCLoginProtocolFactory;
+import org.keycloak.protocol.oid4vc.issuance.signing.VCSigningServiceProviderFactory;
+import org.keycloak.protocol.oid4vc.issuance.signing.VerifiableCredentialsSigningService;
+import org.keycloak.protocol.oid4vc.model.CredentialIssuer;
+import org.keycloak.protocol.oid4vc.model.Format;
+import org.keycloak.protocol.oid4vc.model.OID4VCClient;
+import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration;
+import org.keycloak.services.Urls;
+import org.keycloak.urls.UrlType;
+import org.keycloak.wellknown.WellKnownProvider;
+
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * {@link WellKnownProvider} implementation to provide the .well-known/openid-credential-issuer endpoint, offering
+ * the Credential Issuer Metadata as defined by the OID4VCI protocol
+ * {@see https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#section-11.2.2}
+ *
+ * @author Stefan Wiedemann
+ */
+public class OID4VCIssuerWellKnownProvider implements WellKnownProvider {
+
+ private final KeycloakSession keycloakSession;
+
+ public OID4VCIssuerWellKnownProvider(KeycloakSession keycloakSession) {
+ this.keycloakSession = keycloakSession;
+ }
+
+ @Override
+ public void close() {
+ // no-op
+ }
+
+ @Override
+ public Object getConfig() {
+ return new CredentialIssuer()
+ .setCredentialIssuer(getIssuer(keycloakSession.getContext()))
+ .setCredentialEndpoint(getCredentialsEndpoint(keycloakSession.getContext()))
+ .setCredentialsSupported(getSupportedCredentials(keycloakSession))
+ .setAuthorizationServers(List.of(getIssuer(keycloakSession.getContext())));
+ }
+
+ /**
+ * Return the supported credentials from the current session.
+ * It will take into account the configured {@link VerifiableCredentialsSigningService}'s and there supported format
+ * and the credentials supported by the clients available in the session.
+ */
+ public static Map getSupportedCredentials(KeycloakSession keycloakSession) {
+
+ RealmModel realm = keycloakSession.getContext().getRealm();
+ List supportedFormats = realm.getComponentsStream(realm.getId(), VerifiableCredentialsSigningService.class.getName())
+ .map(cm ->
+ keycloakSession
+ .getKeycloakSessionFactory()
+ .getProviderFactory(VerifiableCredentialsSigningService.class, cm.getProviderId())
+ )
+ .filter(VCSigningServiceProviderFactory.class::isInstance)
+ .map(VCSigningServiceProviderFactory.class::cast)
+ .map(VCSigningServiceProviderFactory::supportedFormat)
+ .toList();
+
+ return keycloakSession.getContext()
+ .getRealm()
+ .getClientsStream()
+ .filter(cm -> cm.getProtocol() != null)
+ .filter(cm -> cm.getProtocol().equals(OID4VCLoginProtocolFactory.PROTOCOL_ID))
+ .map(cm -> OID4VCClientRegistrationProvider.fromClientAttributes(cm.getClientId(), cm.getAttributes()))
+ .map(OID4VCClient::getSupportedVCTypes)
+ .flatMap(List::stream)
+ .filter(sc -> supportedFormats.contains(sc.getFormat()))
+ .distinct()
+ .collect(Collectors.toMap(SupportedCredentialConfiguration::getId, sc -> sc, (sc1, sc2) -> sc1));
+
+ }
+
+ /**
+ * Return the url of the issuer.
+ */
+ public static String getIssuer(KeycloakContext context) {
+ UriInfo frontendUriInfo = context.getUri(UrlType.FRONTEND);
+ return Urls.realmIssuer(frontendUriInfo.getBaseUri(),
+ context.getRealm().getName());
+
+ }
+
+ /**
+ * Return the credentials endpoint address
+ */
+ public static String getCredentialsEndpoint(KeycloakContext context) {
+ return getIssuer(context) + "/protocol/" + OID4VCLoginProtocolFactory.PROTOCOL_ID + "/" + OID4VCIssuerEndpoint.CREDENTIAL_PATH;
+ }
+}
\ No newline at end of file
diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerWellKnownProviderFactory.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerWellKnownProviderFactory.java
new file mode 100644
index 0000000000..fcae160d34
--- /dev/null
+++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerWellKnownProviderFactory.java
@@ -0,0 +1,62 @@
+/*
+ * 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.Config;
+import org.keycloak.models.KeycloakSession;
+import org.keycloak.models.KeycloakSessionFactory;
+import org.keycloak.protocol.oid4vc.OID4VCEnvironmentProviderFactory;
+import org.keycloak.wellknown.WellKnownProvider;
+import org.keycloak.wellknown.WellKnownProviderFactory;
+
+/**
+ * {@link WellKnownProviderFactory} implementation for the OID4VCI metadata
+ *
+ * {@see https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#section-11.2.2}
+ *
+ * @author Stefan Wiedemann
+ */
+public class OID4VCIssuerWellKnownProviderFactory implements WellKnownProviderFactory, OID4VCEnvironmentProviderFactory {
+
+ public static final String PROVIDER_ID = "openid-credential-issuer";
+
+ @Override
+ public WellKnownProvider create(KeycloakSession session) {
+ return new OID4VCIssuerWellKnownProvider(session);
+ }
+
+ @Override
+ public void init(Config.Scope config) {
+ // no-op
+ }
+
+ @Override
+ public void postInit(KeycloakSessionFactory factory) {
+ // no-op
+ }
+
+ @Override
+ public void close() {
+ // no-op
+ }
+
+ @Override
+ public String getId() {
+ return PROVIDER_ID;
+ }
+}
\ No newline at end of file
diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCContextMapper.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCContextMapper.java
new file mode 100644
index 0000000000..dc6dcccea3
--- /dev/null
+++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCContextMapper.java
@@ -0,0 +1,95 @@
+/*
+ * 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.mappers;
+
+import org.keycloak.models.KeycloakSession;
+import org.keycloak.models.UserSessionModel;
+import org.keycloak.protocol.ProtocolMapper;
+import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
+import org.keycloak.provider.ProviderConfigProperty;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Allows to add the context to the credential subject
+ *
+ * @author Stefan Wiedemann
+ */
+public class OID4VCContextMapper extends OID4VCMapper {
+
+ public static final String MAPPER_ID = "oid4vc-context-mapper";
+ public static final String TYPE_KEY = "context";
+
+ private static final List CONFIG_PROPERTIES = new ArrayList<>();
+
+ static {
+ ProviderConfigProperty contextPropertyNameConfig = new ProviderConfigProperty();
+ contextPropertyNameConfig.setName(TYPE_KEY);
+ contextPropertyNameConfig.setLabel("Verifiable Credentials Context");
+ contextPropertyNameConfig.setHelpText("Context of the credential.");
+ contextPropertyNameConfig.setType(ProviderConfigProperty.STRING_TYPE);
+ contextPropertyNameConfig.setDefaultValue("https://www.w3.org/2018/credentials/v1");
+ CONFIG_PROPERTIES.add(contextPropertyNameConfig);
+ }
+
+
+ @Override
+ protected List getIndividualConfigProperties() {
+ return CONFIG_PROPERTIES;
+ }
+
+ public void setClaimsForCredential(VerifiableCredential verifiableCredential,
+ UserSessionModel userSessionModel) {
+ // remove duplicates
+ Set contexts = new HashSet<>();
+ if (verifiableCredential.getContext() != null) {
+ contexts = new HashSet<>(verifiableCredential.getContext());
+ }
+ contexts.add(mapperModel.getConfig().get(TYPE_KEY));
+ verifiableCredential.setContext(new ArrayList<>(contexts));
+ }
+
+ @Override
+ public void setClaimsForSubject(Map claims, UserSessionModel userSessionModel) {
+ // nothing to do for the mapper.
+ }
+
+ @Override
+ public String getDisplayType() {
+ return "Credential Context Mapper";
+ }
+
+ @Override
+ public String getHelpText() {
+ return "Assigns a context to the credential.";
+ }
+
+ @Override
+ public ProtocolMapper create(KeycloakSession session) {
+ return new OID4VCContextMapper();
+ }
+
+ @Override
+ public String getId() {
+ return MAPPER_ID;
+ }
+}
\ No newline at end of file
diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCMapper.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCMapper.java
new file mode 100644
index 0000000000..9cc4f3745a
--- /dev/null
+++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCMapper.java
@@ -0,0 +1,123 @@
+/*
+ * 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.mappers;
+
+import org.keycloak.Config;
+import org.keycloak.models.KeycloakSessionFactory;
+import org.keycloak.models.ProtocolMapperModel;
+import org.keycloak.models.UserSessionModel;
+import org.keycloak.protocol.ProtocolMapper;
+import org.keycloak.protocol.oid4vc.OID4VCEnvironmentProviderFactory;
+import org.keycloak.protocol.oid4vc.OID4VCLoginProtocolFactory;
+import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
+import org.keycloak.provider.ProviderConfigProperty;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.Stream;
+
+/**
+ * Base class for OID4VC Mappers, to provide common configuration and functionality for all of them
+ *
+ * @author Stefan Wiedemann
+ */
+public abstract class OID4VCMapper implements ProtocolMapper, OID4VCEnvironmentProviderFactory {
+
+ protected static final String SUPPORTED_CREDENTIALS_KEY = "supportedCredentialTypes";
+
+ protected ProtocolMapperModel mapperModel;
+
+ private static final List OID4VC_CONFIG_PROPERTIES = new ArrayList<>();
+
+ static {
+ ProviderConfigProperty supportedCredentialsConfig = new ProviderConfigProperty();
+ supportedCredentialsConfig.setType(ProviderConfigProperty.STRING_TYPE);
+ supportedCredentialsConfig.setLabel("Supported Credential Types");
+ supportedCredentialsConfig.setDefaultValue("VerifiableCredential");
+ supportedCredentialsConfig.setHelpText(
+ "Types of Credentials to apply the mapper. Needs to be a comma-separated list.");
+ supportedCredentialsConfig.setName(SUPPORTED_CREDENTIALS_KEY);
+ OID4VC_CONFIG_PROPERTIES.clear();
+ OID4VC_CONFIG_PROPERTIES.add(supportedCredentialsConfig);
+ }
+
+ protected abstract List getIndividualConfigProperties();
+
+ @Override
+ public List getConfigProperties() {
+ return Stream.concat(OID4VC_CONFIG_PROPERTIES.stream(), getIndividualConfigProperties().stream()).toList();
+ }
+
+ public OID4VCMapper setMapperModel(ProtocolMapperModel mapperModel) {
+ this.mapperModel = mapperModel;
+ return this;
+ }
+
+ @Override
+ public String getProtocol() {
+ return OID4VCLoginProtocolFactory.PROTOCOL_ID;
+ }
+
+ @Override
+ public String getDisplayCategory() {
+ return "OID4VC Mapper";
+ }
+
+ @Override
+ public void init(Config.Scope scope) {
+ }
+
+ @Override
+ public void postInit(KeycloakSessionFactory keycloakSessionFactory) {
+ // try to get the credentials
+ }
+
+ @Override
+ public void close() {
+ }
+
+ /**
+ * 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
+ * @return true if it is supported
+ */
+ public boolean isTypeSupported(String credentialType) {
+ var optionalTypes = Optional.ofNullable(mapperModel.getConfig().get(SUPPORTED_CREDENTIALS_KEY));
+ if (optionalTypes.isEmpty()) {
+ return false;
+ }
+ return Arrays.asList(optionalTypes.get().split(",")).contains(credentialType);
+ }
+
+ /**
+ * Set the claims to credential, like f.e. the context
+ */
+ public abstract void setClaimsForCredential(VerifiableCredential verifiableCredential,
+ UserSessionModel userSessionModel);
+
+ /**
+ * Set the claims to the credential subject.
+ */
+ public abstract void setClaimsForSubject(Map claims,
+ UserSessionModel userSessionModel);
+
+}
\ No newline at end of file
diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCStaticClaimMapper.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCStaticClaimMapper.java
new file mode 100644
index 0000000000..49250da8db
--- /dev/null
+++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCStaticClaimMapper.java
@@ -0,0 +1,96 @@
+/*
+ * 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.mappers;
+
+import org.keycloak.models.KeycloakSession;
+import org.keycloak.models.UserSessionModel;
+import org.keycloak.protocol.ProtocolMapper;
+import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
+import org.keycloak.provider.ProviderConfigProperty;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Allows to add statically configured claims to the credential subject
+ *
+ * @author Stefan Wiedemann
+ */
+public class OID4VCStaticClaimMapper extends OID4VCMapper {
+
+ public static final String MAPPER_ID = "oid4vc-static-claim-mapper";
+
+ public static final String SUBJECT_PROPERTY_CONFIG_KEY = "subjectProperty";
+ public static final String STATIC_CLAIM_KEY = "staticValue";
+
+ private static final List CONFIG_PROPERTIES = new ArrayList<>();
+
+ static {
+ ProviderConfigProperty subjectPropertyNameConfig = new ProviderConfigProperty();
+ subjectPropertyNameConfig.setName(SUBJECT_PROPERTY_CONFIG_KEY);
+ subjectPropertyNameConfig.setLabel("Static Claim Property Name");
+ subjectPropertyNameConfig.setHelpText("Name of the property to contain the static value.");
+ subjectPropertyNameConfig.setType(ProviderConfigProperty.STRING_TYPE);
+ CONFIG_PROPERTIES.add(subjectPropertyNameConfig);
+
+ ProviderConfigProperty claimValueConfig = new ProviderConfigProperty();
+ claimValueConfig.setName(STATIC_CLAIM_KEY);
+ claimValueConfig.setLabel("Static Claim Value");
+ claimValueConfig.setHelpText("Value to be set for the property.");
+ claimValueConfig.setType(ProviderConfigProperty.STRING_TYPE);
+ CONFIG_PROPERTIES.add(claimValueConfig);
+ }
+
+ @Override
+ protected List getIndividualConfigProperties() {
+ return CONFIG_PROPERTIES;
+ }
+
+ public void setClaimsForCredential(VerifiableCredential verifiableCredential,
+ UserSessionModel userSessionModel) {
+ // nothing to do for the mapper.
+ }
+
+ @Override
+ public void setClaimsForSubject(Map claims, UserSessionModel userSessionModel) {
+ String propertyName = mapperModel.getConfig().get(SUBJECT_PROPERTY_CONFIG_KEY);
+ String staticValue = mapperModel.getConfig().get(STATIC_CLAIM_KEY);
+ claims.put(propertyName, staticValue);
+ }
+
+ @Override
+ public String getDisplayType() {
+ return "Static Claim Mapper";
+ }
+
+ @Override
+ public String getHelpText() {
+ return "Allows to set static values for the credential subject.";
+ }
+
+ @Override
+ public ProtocolMapper create(KeycloakSession session) {
+ return new OID4VCStaticClaimMapper();
+ }
+
+ @Override
+ public String getId() {
+ return MAPPER_ID;
+ }
+}
\ No newline at end of file
diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCSubjectIdMapper.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCSubjectIdMapper.java
new file mode 100644
index 0000000000..9fffe45184
--- /dev/null
+++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCSubjectIdMapper.java
@@ -0,0 +1,102 @@
+/*
+ * 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.mappers;
+
+import org.keycloak.models.KeycloakSession;
+import org.keycloak.models.ProtocolMapperModel;
+import org.keycloak.models.UserSessionModel;
+import org.keycloak.protocol.ProtocolMapper;
+import org.keycloak.protocol.oid4vc.OID4VCLoginProtocolFactory;
+import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
+import org.keycloak.provider.ProviderConfigProperty;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+
+/**
+ * Sets an ID for the credential, either randomly generated or statically configured
+ *
+ * @author Stefan Wiedemann
+ */
+public class OID4VCSubjectIdMapper extends OID4VCMapper {
+
+ public static final String MAPPER_ID = "oid4vc-subject-id-mapper";
+ public static final String ID_KEY = "subjectIdProperty";
+
+ private static final List CONFIG_PROPERTIES = new ArrayList<>();
+
+ static {
+ ProviderConfigProperty idPropertyNameConfig = new ProviderConfigProperty();
+ idPropertyNameConfig.setName(ID_KEY);
+ idPropertyNameConfig.setLabel("ID Property Name");
+ idPropertyNameConfig.setHelpText("Name of the property to contain the id.");
+ idPropertyNameConfig.setDefaultValue("id");
+ idPropertyNameConfig.setType(ProviderConfigProperty.STRING_TYPE);
+ CONFIG_PROPERTIES.add(idPropertyNameConfig);
+ }
+
+ @Override
+ protected List getIndividualConfigProperties() {
+ return CONFIG_PROPERTIES;
+ }
+
+ public static ProtocolMapperModel create(String name, String subjectId) {
+ var mapperModel = new ProtocolMapperModel();
+ mapperModel.setName(name);
+ Map configMap = new HashMap<>();
+ configMap.put(ID_KEY, subjectId);
+ configMap.put(SUPPORTED_CREDENTIALS_KEY, "VerifiableCredential");
+ mapperModel.setConfig(configMap);
+ mapperModel.setProtocol(OID4VCLoginProtocolFactory.PROTOCOL_ID);
+ mapperModel.setProtocolMapper(MAPPER_ID);
+ return mapperModel;
+ }
+
+ public void setClaimsForCredential(VerifiableCredential verifiableCredential,
+ UserSessionModel userSessionModel) {
+ // nothing to do for the mapper.
+ }
+
+ @Override
+ public void setClaimsForSubject(Map claims, UserSessionModel userSessionModel) {
+ claims.put("id", mapperModel.getConfig().getOrDefault(ID_KEY, String.format("urn:uuid:%s", UUID.randomUUID())));
+ }
+
+ @Override
+ public String getDisplayType() {
+ return "CredentialSubject ID Mapper";
+ }
+
+ @Override
+ public String getHelpText() {
+ return "Assigns a subject ID to the credentials subject. If no specific id is configured, a randomly generated one is used.";
+ }
+
+ @Override
+ public ProtocolMapper create(KeycloakSession session) {
+ return new OID4VCSubjectIdMapper();
+ }
+
+ @Override
+ public String getId() {
+ return MAPPER_ID;
+ }
+}
\ No newline at end of file
diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCTargetRoleMapper.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCTargetRoleMapper.java
new file mode 100644
index 0000000000..2ba10c1ec7
--- /dev/null
+++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCTargetRoleMapper.java
@@ -0,0 +1,170 @@
+/*
+ * 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.mappers;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.jboss.logging.Logger;
+import org.keycloak.models.ClientModel;
+import org.keycloak.models.KeycloakSession;
+import org.keycloak.models.ProtocolMapperModel;
+import org.keycloak.models.RoleModel;
+import org.keycloak.models.UserSessionModel;
+import org.keycloak.protocol.ProtocolMapper;
+import org.keycloak.protocol.oid4vc.OID4VCLoginProtocolFactory;
+import org.keycloak.protocol.oid4vc.model.Role;
+import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
+import org.keycloak.provider.ProviderConfigProperty;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * Adds the users roles to the credential subject
+ *
+ * @author Stefan Wiedemann
+ */
+public class OID4VCTargetRoleMapper extends OID4VCMapper {
+
+ private static final Logger LOGGER = Logger.getLogger(OID4VCTargetRoleMapper.class);
+ private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+
+ public static final String MAPPER_ID = "oid4vc-target-role-mapper";
+ public static final String SUBJECT_PROPERTY_CONFIG_KEY = "subjectProperty";
+ public static final String CLIENT_CONFIG_KEY = "clientId";
+
+ private static final List CONFIG_PROPERTIES = new ArrayList<>();
+
+ static {
+ ProviderConfigProperty subjectPropertyNameConfig = new ProviderConfigProperty();
+ subjectPropertyNameConfig.setName(SUBJECT_PROPERTY_CONFIG_KEY);
+ subjectPropertyNameConfig.setLabel("Roles Property Name");
+ subjectPropertyNameConfig.setHelpText("Property to add the roles to in the credential subject.");
+ subjectPropertyNameConfig.setDefaultValue("roles");
+ subjectPropertyNameConfig.setType(ProviderConfigProperty.STRING_TYPE);
+ CONFIG_PROPERTIES.add(subjectPropertyNameConfig);
+ }
+
+ @Override
+ protected List getIndividualConfigProperties() {
+ return CONFIG_PROPERTIES;
+ }
+
+ @Override
+ public String getDisplayType() {
+ return "Target-Role Mapper";
+ }
+
+ @Override
+ public String getHelpText() {
+ return "Map the assigned role to the credential subject, providing the client id as the target.";
+ }
+
+ public static ProtocolMapperModel create(String clientId, String name) {
+ var mapperModel = new ProtocolMapperModel();
+ mapperModel.setName(name);
+ Map configMap = new HashMap<>();
+ configMap.put(SUBJECT_PROPERTY_CONFIG_KEY, "roles");
+ configMap.put(CLIENT_CONFIG_KEY, clientId);
+ mapperModel.setConfig(configMap);
+ mapperModel.setProtocol(OID4VCLoginProtocolFactory.PROTOCOL_ID);
+ mapperModel.setProtocolMapper(MAPPER_ID);
+ return mapperModel;
+ }
+
+ @Override
+ public ProtocolMapper create(KeycloakSession session) {
+ return new OID4VCTargetRoleMapper();
+ }
+
+ @Override
+ public String getId() {
+ return MAPPER_ID;
+ }
+
+ @Override
+ public void setClaimsForCredential(VerifiableCredential verifiableCredential,
+ UserSessionModel userSessionModel) {
+ // nothing to do for the mapper.
+ }
+
+ @Override
+ public void setClaimsForSubject(Map claims,
+ UserSessionModel userSessionModel) {
+ String client = mapperModel.getConfig().get(CLIENT_CONFIG_KEY);
+ String propertyName = mapperModel.getConfig().get(SUBJECT_PROPERTY_CONFIG_KEY);
+ ClientModel clientModel = userSessionModel.getRealm().getClientByClientId(client);
+ if (clientModel == null || !clientModel.getProtocol().equals(OID4VCLoginProtocolFactory.PROTOCOL_ID)) {
+ return;
+ }
+
+ ClientRoleModel clientRoleModel = new ClientRoleModel(clientModel.getClientId(),
+ userSessionModel.getUser().getClientRoleMappingsStream(clientModel).toList());
+ Role rolesClaim = toRolesClaim(clientRoleModel);
+ if (rolesClaim.getNames().isEmpty()) {
+ return;
+ }
+ var modelMap = OBJECT_MAPPER.convertValue(toRolesClaim(clientRoleModel), Map.class);
+
+ if (claims.containsKey(propertyName)) {
+ if (claims.get(propertyName) instanceof Set rolesProperty) {
+ rolesProperty.add(modelMap);
+ claims.put(propertyName, rolesProperty);
+ } else {
+ LOGGER.warnf("Incompatible types for property %s. The mapper will not set the roles for client %s",
+ propertyName, client);
+ }
+ } else {
+ // needs to be mutable
+ Set roles = new HashSet();
+ roles.add(modelMap);
+ claims.put(propertyName, roles);
+ }
+ }
+
+ private Role toRolesClaim(ClientRoleModel crm) {
+ Set roleNames = crm
+ .getRoleModels()
+ .stream()
+ .map(RoleModel::getName)
+ .collect(Collectors.toSet());
+ return new Role(roleNames, crm.getClientId());
+ }
+
+ private static class ClientRoleModel {
+ private final String clientId;
+ private final List roleModels;
+
+ public ClientRoleModel(String clientId, List roleModels) {
+ this.clientId = clientId;
+ this.roleModels = roleModels;
+ }
+
+ public String getClientId() {
+ return clientId;
+ }
+
+ public List getRoleModels() {
+ return roleModels;
+ }
+ }
+}
\ No newline at end of file
diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCTypeMapper.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCTypeMapper.java
new file mode 100644
index 0000000000..e1c66ddf07
--- /dev/null
+++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCTypeMapper.java
@@ -0,0 +1,95 @@
+/*
+ * 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.mappers;
+
+import org.keycloak.models.KeycloakSession;
+import org.keycloak.models.UserSessionModel;
+import org.keycloak.protocol.ProtocolMapper;
+import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
+import org.keycloak.provider.ProviderConfigProperty;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+
+/**
+ * Allows to add types to the credential subject
+ *
+ * @author Stefan Wiedemann
+ */
+public class OID4VCTypeMapper extends OID4VCMapper {
+
+ public static final String MAPPER_ID = "oid4vc-vc-type-mapper";
+ public static final String TYPE_KEY = "vcTypeProperty";
+ public static final String DEFAULT_VC_TYPE = "VerifiableCredential";
+
+ private static final List CONFIG_PROPERTIES = new ArrayList<>();
+
+ static {
+ ProviderConfigProperty vcTypePropertyNameConfig = new ProviderConfigProperty();
+ vcTypePropertyNameConfig.setName(TYPE_KEY);
+ vcTypePropertyNameConfig.setLabel("Verifiable Credential Type");
+ vcTypePropertyNameConfig.setHelpText("Type of the credential.");
+ vcTypePropertyNameConfig.setType(ProviderConfigProperty.STRING_TYPE);
+ CONFIG_PROPERTIES.add(vcTypePropertyNameConfig);
+ }
+
+ @Override
+ protected List getIndividualConfigProperties() {
+ return CONFIG_PROPERTIES;
+ }
+
+ public void setClaimsForCredential(VerifiableCredential verifiableCredential,
+ UserSessionModel userSessionModel) {
+ // remove duplicates
+ Set types = new HashSet<>();
+ if (verifiableCredential.getType() != null) {
+ types = new HashSet<>(verifiableCredential.getType());
+ }
+ types.add(Optional.ofNullable(mapperModel.getConfig().get(TYPE_KEY)).orElse(DEFAULT_VC_TYPE));
+ verifiableCredential.setType(new ArrayList<>(types));
+ }
+
+ @Override
+ public void setClaimsForSubject(Map claims, UserSessionModel userSessionModel) {
+ // nothing to do for the mapper.
+ }
+
+ @Override
+ public String getDisplayType() {
+ return "Credential Type Mapper";
+ }
+
+ @Override
+ public String getHelpText() {
+ return "Assigns a type to the credential.";
+ }
+
+ @Override
+ public ProtocolMapper create(KeycloakSession session) {
+ return new OID4VCTypeMapper();
+ }
+
+ @Override
+ public String getId() {
+ return MAPPER_ID;
+ }
+}
\ No newline at end of file
diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCUserAttributeMapper.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCUserAttributeMapper.java
new file mode 100644
index 0000000000..f796d27cae
--- /dev/null
+++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCUserAttributeMapper.java
@@ -0,0 +1,136 @@
+/*
+ * 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.mappers;
+
+import org.keycloak.models.KeycloakSession;
+import org.keycloak.models.ProtocolMapperModel;
+import org.keycloak.models.UserModel;
+import org.keycloak.models.UserSessionModel;
+import org.keycloak.models.utils.KeycloakModelUtils;
+import org.keycloak.protocol.ProtocolMapper;
+import org.keycloak.protocol.oid4vc.OID4VCLoginProtocolFactory;
+import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
+import org.keycloak.provider.ProviderConfigProperty;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+/**
+ * Allows to add user attributes to the credential subject
+ *
+ * @author Stefan Wiedemann
+ */
+public class OID4VCUserAttributeMapper extends OID4VCMapper {
+
+ public static final String MAPPER_ID = "oid4vc-user-attribute-mapper";
+ public static final String SUBJECT_PROPERTY_CONFIG_KEY = "subjectProperty";
+ public static final String USER_ATTRIBUTE_KEY = "userAttribute";
+ public static final String AGGREGATE_ATTRIBUTES_KEY = "aggregateAttributes";
+
+ private static final List CONFIG_PROPERTIES = new ArrayList<>();
+
+ static {
+ ProviderConfigProperty subjectPropertyNameConfig = new ProviderConfigProperty();
+ subjectPropertyNameConfig.setName(SUBJECT_PROPERTY_CONFIG_KEY);
+ subjectPropertyNameConfig.setLabel("Attribute Property Name");
+ subjectPropertyNameConfig.setHelpText("Property to add the user attribute to in the credential subject.");
+ subjectPropertyNameConfig.setType(ProviderConfigProperty.STRING_TYPE);
+ CONFIG_PROPERTIES.add(subjectPropertyNameConfig);
+
+ ProviderConfigProperty userAttributeConfig = new ProviderConfigProperty();
+ userAttributeConfig.setName(USER_ATTRIBUTE_KEY);
+ userAttributeConfig.setLabel("User attribute");
+ userAttributeConfig.setHelpText("The user attribute to be added to the credential subject.");
+ userAttributeConfig.setType(ProviderConfigProperty.LIST_TYPE);
+ userAttributeConfig.setOptions(
+ List.of(UserModel.USERNAME, UserModel.LOCALE, UserModel.FIRST_NAME, UserModel.LAST_NAME,
+ UserModel.DISABLED_REASON, UserModel.EMAIL, UserModel.EMAIL_VERIFIED));
+ CONFIG_PROPERTIES.add(userAttributeConfig);
+
+ ProviderConfigProperty aggregateAttributesConfig = new ProviderConfigProperty();
+ aggregateAttributesConfig.setName(AGGREGATE_ATTRIBUTES_KEY);
+ aggregateAttributesConfig.setLabel("Aggregate attributes");
+ aggregateAttributesConfig.setHelpText("Should the mapper aggregate user attributes.");
+ aggregateAttributesConfig.setType(ProviderConfigProperty.BOOLEAN_TYPE);
+ CONFIG_PROPERTIES.add(aggregateAttributesConfig);
+ }
+
+ @Override
+ protected List getIndividualConfigProperties() {
+ return CONFIG_PROPERTIES;
+ }
+
+ public void setClaimsForCredential(VerifiableCredential verifiableCredential,
+ UserSessionModel userSessionModel) {
+ // nothing to do for the mapper.
+ }
+
+ @Override
+ public void setClaimsForSubject(Map claims, UserSessionModel userSessionModel) {
+ String propertyName = mapperModel.getConfig().get(SUBJECT_PROPERTY_CONFIG_KEY);
+ String userAttribute = mapperModel.getConfig().get(USER_ATTRIBUTE_KEY);
+ boolean aggregateAttributes = Optional.ofNullable(mapperModel.getConfig().get(AGGREGATE_ATTRIBUTES_KEY))
+ .map(Boolean::parseBoolean).orElse(false);
+ Collection attributes =
+ KeycloakModelUtils.resolveAttribute(userSessionModel.getUser(), userAttribute,
+ aggregateAttributes);
+ attributes.removeAll(Collections.singleton(null));
+ if (!attributes.isEmpty()) {
+ claims.put(propertyName, String.join(",", attributes));
+ }
+ }
+
+ public static ProtocolMapperModel create(String mapperName, String userAttribute, String propertyName,
+ boolean aggregateAttributes) {
+ var mapperModel = new ProtocolMapperModel();
+ mapperModel.setName(mapperName);
+ Map configMap = new HashMap<>();
+ configMap.put(SUBJECT_PROPERTY_CONFIG_KEY, propertyName);
+ configMap.put(USER_ATTRIBUTE_KEY, userAttribute);
+ configMap.put(AGGREGATE_ATTRIBUTES_KEY, Boolean.toString(aggregateAttributes));
+ mapperModel.setConfig(configMap);
+ mapperModel.setProtocol(OID4VCLoginProtocolFactory.PROTOCOL_ID);
+ mapperModel.setProtocolMapper(MAPPER_ID);
+ return mapperModel;
+ }
+
+ @Override
+ public String getDisplayType() {
+ return "User Attribute Mapper";
+ }
+
+ @Override
+ public String getHelpText() {
+ return "Maps user attributes to credential subject properties.";
+ }
+
+ @Override
+ public ProtocolMapper create(KeycloakSession session) {
+ return new OID4VCUserAttributeMapper();
+ }
+
+ @Override
+ public String getId() {
+ return MAPPER_ID;
+ }
+}
\ No newline at end of file
diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/JwtSigningService.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/JwtSigningService.java
index 2cb91bbbbf..fd076417ee 100644
--- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/JwtSigningService.java
+++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/JwtSigningService.java
@@ -43,7 +43,6 @@ public class JwtSigningService extends SigningService {
private static final Logger LOGGER = Logger.getLogger(JwtSigningService.class);
private static final String ID_TEMPLATE = "urn:uuid:%s";
- private static final String TOKEN_TYPE = "JWT";
private static final String VC_CLAIM_KEY = "vc";
private static final String ID_CLAIM_KEY = "id";
diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SdJwtSigningService.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SdJwtSigningService.java
index 3d9ceeec64..e461bc0338 100644
--- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SdJwtSigningService.java
+++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SdJwtSigningService.java
@@ -34,7 +34,6 @@ import org.keycloak.sdjwt.SdJwtUtils;
import java.util.List;
import java.util.Optional;
-import java.util.StringJoiner;
import java.util.stream.IntStream;
/**
diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SdJwtSigningServiceProviderFactory.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SdJwtSigningServiceProviderFactory.java
index bcd545711e..c1685443df 100644
--- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SdJwtSigningServiceProviderFactory.java
+++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SdJwtSigningServiceProviderFactory.java
@@ -18,7 +18,6 @@
package org.keycloak.protocol.oid4vc.issuance.signing;
import com.fasterxml.jackson.databind.ObjectMapper;
-import org.keycloak.common.util.Time;
import org.keycloak.component.ComponentModel;
import org.keycloak.component.ComponentValidationException;
import org.keycloak.models.KeycloakSession;
@@ -50,7 +49,7 @@ public class SdJwtSigningServiceProviderFactory implements VCSigningServiceProvi
String tokenType = model.get(SigningProperties.TOKEN_TYPE.getKey());
String hashAlgorithm = model.get(SigningProperties.HASH_ALGORITHM.getKey());
Optional kid = Optional.ofNullable(model.get(SigningProperties.KID_HEADER.getKey()));
- int decoys = Integer.valueOf(model.get(SigningProperties.DECOYS.getKey()));
+ int decoys = Integer.parseInt(model.get(SigningProperties.DECOYS.getKey()));
List visibleClaims = Optional.ofNullable(model.get(SigningProperties.VISIBLE_CLAIMS.getKey()))
.map(visibileClaims -> visibileClaims.split(","))
diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/VCSigningServiceProviderFactory.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/VCSigningServiceProviderFactory.java
index bcbde00580..73b9d5312c 100644
--- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/VCSigningServiceProviderFactory.java
+++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/VCSigningServiceProviderFactory.java
@@ -25,25 +25,23 @@ import org.keycloak.component.ComponentValidationException;
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.EnvironmentDependentProviderFactory;
import org.keycloak.provider.ProviderConfigurationBuilder;
-import java.time.Clock;
-
/**
* Provider Factory to create {@link VerifiableCredentialsSigningService}s
*
* @author Stefan Wiedemann
*/
-public interface VCSigningServiceProviderFactory extends ComponentFactory, EnvironmentDependentProviderFactory {
+public interface VCSigningServiceProviderFactory extends ComponentFactory, OID4VCEnvironmentProviderFactory {
/**
* Key for the realm attribute providing the issuerDidy.
*/
String ISSUER_DID_REALM_ATTRIBUTE_KEY = "issuerDid";
-
+
public static ProviderConfigurationBuilder configurationBuilder() {
return ProviderConfigurationBuilder.create()
.property(SigningProperties.KEY_ID.asConfigProperty());
diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialIssuer.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialIssuer.java
new file mode 100644
index 0000000000..f6d6952b7e
--- /dev/null
+++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialIssuer.java
@@ -0,0 +1,119 @@
+/*
+ * 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 com.google.common.collect.ImmutableMap;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Represents a credentials issuer according to the OID4VCI Credentials Issuer Metadata
+ * {@see https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-issuer-metadata}
+ *
+ * @author Stefan Wiedemann
+ */
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class CredentialIssuer {
+
+ @JsonProperty("credential_issuer")
+ private String credentialIssuer;
+
+ @JsonProperty("credential_endpoint")
+ private String credentialEndpoint;
+
+ @JsonProperty("authorization_servers")
+ private List authorizationServers;
+
+ @JsonProperty("batch_credential_endpoint")
+ private String batchCredentialEndpoint;
+
+ @JsonProperty("notification_endpoint")
+ private String notificationEndpoint;
+
+ @JsonProperty("credential_configurations_supported")
+ private Map credentialsSupported;
+
+ private DisplayObject display;
+
+ public String getCredentialIssuer() {
+ return credentialIssuer;
+ }
+
+ public CredentialIssuer setCredentialIssuer(String credentialIssuer) {
+ this.credentialIssuer = credentialIssuer;
+ return this;
+ }
+
+ public String getCredentialEndpoint() {
+ return credentialEndpoint;
+ }
+
+ public CredentialIssuer setCredentialEndpoint(String credentialEndpoint) {
+ this.credentialEndpoint = credentialEndpoint;
+ return this;
+ }
+
+ public String getBatchCredentialEndpoint() {
+ return batchCredentialEndpoint;
+ }
+
+ public CredentialIssuer setBatchCredentialEndpoint(String batchCredentialEndpoint) {
+ this.batchCredentialEndpoint = batchCredentialEndpoint;
+ return this;
+ }
+
+ public Map getCredentialsSupported() {
+ return credentialsSupported;
+ }
+
+ public CredentialIssuer setCredentialsSupported(Map credentialsSupported) {
+ this.credentialsSupported = ImmutableMap.copyOf(credentialsSupported);
+ return this;
+ }
+
+ public DisplayObject getDisplay() {
+ return display;
+ }
+
+ public CredentialIssuer setDisplay(DisplayObject display) {
+ this.display = display;
+ return this;
+ }
+
+ public List getAuthorizationServers() {
+ return authorizationServers;
+ }
+
+ public CredentialIssuer setAuthorizationServers(List authorizationServers) {
+ this.authorizationServers = authorizationServers;
+ return this;
+ }
+
+ public String getNotificationEndpoint() {
+ return notificationEndpoint;
+ }
+
+ public CredentialIssuer setNotificationEndpoint(String notificationEndpoint) {
+ this.notificationEndpoint = notificationEndpoint;
+ return this;
+ }
+}
+
diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialOfferURI.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialOfferURI.java
new file mode 100644
index 0000000000..0375d42fb6
--- /dev/null
+++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialOfferURI.java
@@ -0,0 +1,49 @@
+/*
+ * 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;
+
+/**
+ * Holds all information required to build a uri to a credentials offer.
+ *
+ * @author Stefan Wiedemann
+ */
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class CredentialOfferURI {
+ private String issuer;
+ private String nonce;
+
+ public String getIssuer() {
+ return issuer;
+ }
+
+ public CredentialOfferURI setIssuer(String issuer) {
+ this.issuer = issuer;
+ return this;
+ }
+
+ public String getNonce() {
+ return nonce;
+ }
+
+ public CredentialOfferURI setNonce(String nonce) {
+ this.nonce = nonce;
+ return this;
+ }
+}
\ No newline at end of file
diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialRequest.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialRequest.java
new file mode 100644
index 0000000000..c0db0c18a3
--- /dev/null
+++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialRequest.java
@@ -0,0 +1,65 @@
+/*
+ * 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;
+
+/**
+ * Represents a CredentialRequest according to OID4VCI
+ * {@see https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-request}
+ *
+ * @author Stefan Wiedemann
+ */
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class CredentialRequest {
+
+ private Format format;
+
+ @JsonProperty("credential_identifier")
+ private String credentialIdentifier;
+
+ private Proof proof;
+
+ public Format getFormat() {
+ return format;
+ }
+
+ public CredentialRequest setFormat(Format format) {
+ this.format = format;
+ return this;
+ }
+
+ public String getCredentialIdentifier() {
+ return credentialIdentifier;
+ }
+
+ public CredentialRequest setCredentialIdentifier(String credentialIdentifier) {
+ this.credentialIdentifier = credentialIdentifier;
+ return this;
+ }
+
+ public Proof getProof() {
+ return proof;
+ }
+
+ public CredentialRequest setProof(Proof proof) {
+ this.proof = proof;
+ return this;
+ }
+}
\ No newline at end of file
diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialResponse.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialResponse.java
new file mode 100644
index 0000000000..d5d78973f0
--- /dev/null
+++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialResponse.java
@@ -0,0 +1,79 @@
+/*
+ * 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;
+
+/**
+ * Represents a CredentialResponse according to the OID4VCI Spec
+ * {@see https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-response}
+ *
+ * @author Stefan Wiedemann
+ */
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class CredentialResponse {
+
+ // concrete type depends on the format
+ private Object credential;
+
+ @JsonProperty("c_nonce")
+ private String cNonce;
+
+ @JsonProperty("c_nonce_expires_in")
+ private String cNonceExpiresIn;
+
+ @JsonProperty("notification_id")
+ private String notificationId;
+
+ public Object getCredential() {
+ return credential;
+ }
+
+ public CredentialResponse setCredential(Object credential) {
+ this.credential = credential;
+ return this;
+ }
+
+ public String getcNonce() {
+ return cNonce;
+ }
+
+ public CredentialResponse setcNonce(String cNonce) {
+ this.cNonce = cNonce;
+ return this;
+ }
+
+ public String getcNonceExpiresIn() {
+ return cNonceExpiresIn;
+ }
+
+ public CredentialResponse setcNonceExpiresIn(String cNonceExpiresIn) {
+ this.cNonceExpiresIn = cNonceExpiresIn;
+ return this;
+ }
+
+ public String getNotificationId() {
+ return notificationId;
+ }
+
+ public CredentialResponse setNotificationId(String notificationId) {
+ this.notificationId = notificationId;
+ return this;
+ }
+}
\ No newline at end of file
diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialSubject.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialSubject.java
index a6049bf586..c0eae569e9 100644
--- a/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialSubject.java
+++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialSubject.java
@@ -33,7 +33,6 @@ import java.util.Map;
@JsonInclude(JsonInclude.Include.NON_NULL)
public class CredentialSubject {
-
@JsonIgnore
private Map claims = new HashMap<>();
@@ -46,4 +45,9 @@ public class CredentialSubject {
public void setClaims(String name, Object claim) {
claims.put(name, claim);
}
+
+ public CredentialSubject setClaims(Map claims) {
+ this.claims = claims;
+ return this;
+ }
}
\ No newline at end of file
diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialsOffer.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialsOffer.java
new file mode 100644
index 0000000000..00ebd84e7e
--- /dev/null
+++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialsOffer.java
@@ -0,0 +1,71 @@
+/*
+ * 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 com.google.common.collect.ImmutableList;
+
+import java.util.List;
+
+/**
+ * Represents a CredentialsOffer according to the OID4VCI Spec
+ * {@see https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-offer}
+ *
+ * @author Stefan Wiedemann
+ */
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class CredentialsOffer {
+
+ @JsonProperty("credential_issuer")
+ private String credentialIssuer;
+
+ //ids of credentials as offered in the issuer metadata
+ @JsonProperty("credential_configuration_ids")
+ private List credentialConfigurationIds;
+
+ // current implementation only supports pre-authorized codes.
+ private PreAuthorizedGrant grants;
+
+ public String getCredentialIssuer() {
+ return credentialIssuer;
+ }
+
+ public CredentialsOffer setCredentialIssuer(String credentialIssuer) {
+ this.credentialIssuer = credentialIssuer;
+ return this;
+ }
+
+ public List getCredentialConfigurationIds() {
+ return credentialConfigurationIds;
+ }
+
+ public CredentialsOffer setCredentialConfigurationIds(List credentialConfigurationIds) {
+ this.credentialConfigurationIds = ImmutableList.copyOf(credentialConfigurationIds);
+ return this;
+ }
+
+ public PreAuthorizedGrant getGrants() {
+ return grants;
+ }
+
+ public CredentialsOffer setGrants(PreAuthorizedGrant grants) {
+ this.grants = grants;
+ return this;
+ }
+}
\ No newline at end of file
diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/DisplayObject.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/DisplayObject.java
new file mode 100644
index 0000000000..a25d8155d7
--- /dev/null
+++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/DisplayObject.java
@@ -0,0 +1,176 @@
+/*
+ * 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.JsonAutoDetect;
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+
+/**
+ * Represents a DisplayObject, as used in the OID4VCI Credentials Issuer Metadata
+ * {@see https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-issuer-metadata}
+ *
+ * @author Stefan Wiedemann
+ */
+@JsonInclude(JsonInclude.Include.NON_NULL)
+@JsonAutoDetect(
+ getterVisibility = JsonAutoDetect.Visibility.NONE,
+ isGetterVisibility = JsonAutoDetect.Visibility.NONE,
+ setterVisibility = JsonAutoDetect.Visibility.NONE
+)
+public class DisplayObject {
+
+ @JsonIgnore
+ private static final String NAME_KEY = "name";
+ @JsonIgnore
+ private static final String LOCALE_KEY = "locale";
+ @JsonIgnore
+ private static final String LOGO_KEY = "logo";
+ @JsonIgnore
+ private static final String DESCRIPTION_KEY = "description";
+ @JsonIgnore
+ private static final String BG_COLOR_KEY = "background_color";
+ @JsonIgnore
+ private static final String TEXT_COLOR_KEY = "text_color";
+
+ @JsonProperty(DisplayObject.NAME_KEY)
+ private String name;
+
+ @JsonProperty(DisplayObject.LOCALE_KEY)
+ private String locale;
+
+ @JsonProperty(DisplayObject.LOGO_KEY)
+ private String logo;
+
+ @JsonProperty(DisplayObject.DESCRIPTION_KEY)
+ private String description;
+
+ @JsonProperty(DisplayObject.BG_COLOR_KEY)
+ private String backgroundColor;
+
+ @JsonProperty(DisplayObject.TEXT_COLOR_KEY)
+ private String textColor;
+
+
+ public String getName() {
+ return name;
+ }
+
+ public DisplayObject setName(String name) {
+ this.name = name;
+ return this;
+ }
+
+ public String getLocale() {
+ return locale;
+ }
+
+ public DisplayObject setLocale(String locale) {
+ this.locale = locale;
+ return this;
+ }
+
+ public String getLogo() {
+ return logo;
+ }
+
+ public DisplayObject setLogo(String logo) {
+ this.logo = logo;
+ return this;
+ }
+
+ public String getDescription() {
+ return description;
+ }
+
+ public DisplayObject setDescription(String description) {
+ this.description = description;
+ return this;
+ }
+
+ public String getBackgroundColor() {
+ return backgroundColor;
+ }
+
+ public DisplayObject setBackgroundColor(String backgroundColor) {
+ this.backgroundColor = backgroundColor;
+ return this;
+ }
+
+ public String getTextColor() {
+ return textColor;
+ }
+
+ public DisplayObject setTextColor(String textColor) {
+ this.textColor = textColor;
+ return this;
+ }
+
+ public Map toDotNotation() {
+ Map dotNotation = new HashMap<>();
+ dotNotation.put(NAME_KEY, name);
+ dotNotation.put(LOCALE_KEY, locale);
+ dotNotation.put(LOGO_KEY, logo);
+ dotNotation.put(DESCRIPTION_KEY, description);
+ dotNotation.put(BG_COLOR_KEY, backgroundColor);
+ dotNotation.put(TEXT_COLOR_KEY, textColor);
+ return dotNotation;
+ }
+
+ public static DisplayObject fromDotNotation(Map dotNotated) {
+ DisplayObject displayObject = new DisplayObject();
+ Optional.ofNullable(dotNotated.get(NAME_KEY)).ifPresent(displayObject::setName);
+ Optional.ofNullable(dotNotated.get(LOCALE_KEY)).ifPresent(displayObject::setLocale);
+ Optional.ofNullable(dotNotated.get(LOGO_KEY)).ifPresent(displayObject::setLogo);
+ Optional.ofNullable(dotNotated.get(DESCRIPTION_KEY)).ifPresent(displayObject::setDescription);
+ Optional.ofNullable(dotNotated.get(BG_COLOR_KEY)).ifPresent(displayObject::setBackgroundColor);
+ Optional.ofNullable(dotNotated.get(TEXT_COLOR_KEY)).ifPresent(displayObject::setTextColor);
+ return displayObject;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (!(o instanceof DisplayObject that)) return false;
+
+ if (getName() != null ? !getName().equals(that.getName()) : that.getName() != null) return false;
+ if (getLocale() != null ? !getLocale().equals(that.getLocale()) : that.getLocale() != null) return false;
+ if (getLogo() != null ? !getLogo().equals(that.getLogo()) : that.getLogo() != null) return false;
+ if (getDescription() != null ? !getDescription().equals(that.getDescription()) : that.getDescription() != null)
+ return false;
+ if (getBackgroundColor() != null ? !getBackgroundColor().equals(that.getBackgroundColor()) : that.getBackgroundColor() != null)
+ return false;
+ return getTextColor() != null ? getTextColor().equals(that.getTextColor()) : that.getTextColor() == null;
+ }
+
+ @Override
+ public int hashCode() {
+ int result = getName() != null ? getName().hashCode() : 0;
+ result = 31 * result + (getLocale() != null ? getLocale().hashCode() : 0);
+ result = 31 * result + (getLogo() != null ? getLogo().hashCode() : 0);
+ result = 31 * result + (getDescription() != null ? getDescription().hashCode() : 0);
+ result = 31 * result + (getBackgroundColor() != null ? getBackgroundColor().hashCode() : 0);
+ result = 31 * result + (getTextColor() != null ? getTextColor().hashCode() : 0);
+ return result;
+ }
+}
\ No newline at end of file
diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/ErrorResponse.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/ErrorResponse.java
new file mode 100644
index 0000000000..5e336ac00f
--- /dev/null
+++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/ErrorResponse.java
@@ -0,0 +1,77 @@
+/*
+ * 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;
+
+/**
+ * Represents an error response, containing the error type as defined by OID4VCI
+ *
+ * @author Stefan Wiedemann
+ */
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class ErrorResponse {
+
+ private ErrorType error;
+
+ @JsonProperty("error_description")
+ private String errorDescription;
+
+ @JsonProperty("c_nonce")
+ private String cNonce;
+
+ @JsonProperty("c_nonce_expires_in")
+ private long cNonceExpiresIn;
+
+ public ErrorType getError() {
+ return error;
+ }
+
+ public ErrorResponse setError(ErrorType error) {
+ this.error = error;
+ return this;
+ }
+
+ public String getErrorDescription() {
+ return errorDescription;
+ }
+
+ public ErrorResponse setErrorDescription(String errorDescription) {
+ this.errorDescription = errorDescription;
+ return this;
+ }
+
+ public String getcNonce() {
+ return cNonce;
+ }
+
+ public ErrorResponse setcNonce(String cNonce) {
+ this.cNonce = cNonce;
+ return this;
+ }
+
+ public long getcNonceExpiresIn() {
+ return cNonceExpiresIn;
+ }
+
+ public ErrorResponse setcNonceExpiresIn(long cNonceExpiresIn) {
+ this.cNonceExpiresIn = cNonceExpiresIn;
+ return this;
+ }
+}
\ No newline at end of file
diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/ErrorType.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/ErrorType.java
new file mode 100644
index 0000000000..4bb5cf4b56
--- /dev/null
+++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/ErrorType.java
@@ -0,0 +1,45 @@
+/*
+ * 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;
+
+
+/**
+ * Enum to handle potential errors in issuing credentials with the error types defined in OID4VCI
+ * {@see https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html}
+ *
+ * @author Stefan Wiedemann
+ */
+public enum ErrorType {
+
+ INVALID_CREDENTIAL_REQUEST("invalid_credential_request"),
+ INVALID_TOKEN("invalid_token"),
+ UNSUPPORTED_CREDENTIAL_TYPE("unsupported_credential_type"),
+ UNSUPPORTED_CREDENTIAL_FORMAT("unsupported_credential_format"),
+ INVALID_PROOF("invalid_proof"),
+ INVALID_ENCRYPTION_PARAMETER("invalid_encryption_parameters");
+
+ private final String value;
+
+ ErrorType(String value) {
+ this.value = value;
+ }
+
+ public String getValue() {
+ return value;
+ }
+}
\ No newline at end of file
diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/OID4VCClient.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/OID4VCClient.java
new file mode 100644
index 0000000000..ac2e99e69d
--- /dev/null
+++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/OID4VCClient.java
@@ -0,0 +1,133 @@
+/*
+ * 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.google.common.collect.ImmutableList;
+
+import java.util.List;
+
+/**
+ * Pojo, containing all information required to create a VCClient.
+ *
+ * @author Stefan Wiedemann
+ */
+public class OID4VCClient {
+
+ /**
+ * Id of the client.
+ */
+ private String id;
+
+ /**
+ * Did of the target/client, will be used as client-id
+ */
+ private String clientDid;
+ /**
+ * Comma-separated list of supported credentials types
+ */
+ private List supportedVCTypes;
+ /**
+ * Description of the client, will f.e. be displayed in the admin-console
+ */
+ private String description;
+ /**
+ * Human-readable name of the client
+ */
+ private String name;
+
+ public OID4VCClient() {
+ }
+
+ public OID4VCClient(String id, String clientDid, List supportedVCTypes, String description, String name) {
+ this.id = id;
+ this.clientDid = clientDid;
+ this.supportedVCTypes = supportedVCTypes;
+ this.description = description;
+ this.name = name;
+ }
+
+ public String getId() {
+ return id;
+ }
+
+ public OID4VCClient setId(String id) {
+ this.id = id;
+ return this;
+ }
+
+ public String getClientDid() {
+ return clientDid;
+ }
+
+ public OID4VCClient setClientDid(String clientDid) {
+ this.clientDid = clientDid;
+ return this;
+ }
+
+ public List getSupportedVCTypes() {
+ return supportedVCTypes;
+ }
+
+ public OID4VCClient setSupportedVCTypes(List supportedVCTypes) {
+ this.supportedVCTypes = ImmutableList.copyOf(supportedVCTypes);
+ return this;
+ }
+
+ public String getDescription() {
+ return description;
+ }
+
+ public OID4VCClient setDescription(String description) {
+ this.description = description;
+ return this;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public OID4VCClient setName(String name) {
+ this.name = name;
+ return this;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (!(o instanceof OID4VCClient that)) return false;
+
+ if (getId() != null ? !getId().equals(that.getId()) : that.getId() != null) return false;
+ if (getClientDid() != null ? !getClientDid().equals(that.getClientDid()) : that.getClientDid() != null)
+ return false;
+ if (getSupportedVCTypes() != null ? !getSupportedVCTypes().equals(that.getSupportedVCTypes()) : that.getSupportedVCTypes() != null)
+ return false;
+ if (getDescription() != null ? !getDescription().equals(that.getDescription()) : that.getDescription() != null)
+ return false;
+ return getName() != null ? getName().equals(that.getName()) : that.getName() == null;
+ }
+
+ @Override
+ public int hashCode() {
+ int result = getId() != null ? getId().hashCode() : 0;
+ result = 31 * result + (getClientDid() != null ? getClientDid().hashCode() : 0);
+ result = 31 * result + (getSupportedVCTypes() != null ? getSupportedVCTypes().hashCode() : 0);
+ result = 31 * result + (getDescription() != null ? getDescription().hashCode() : 0);
+ result = 31 * result + (getName() != null ? getName().hashCode() : 0);
+ return result;
+ }
+}
\ No newline at end of file
diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/PreAuthorizedCode.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/PreAuthorizedCode.java
new file mode 100644
index 0000000000..fe69c93e73
--- /dev/null
+++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/PreAuthorizedCode.java
@@ -0,0 +1,79 @@
+/*
+ * 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;
+
+/**
+ * Represents a pre-authorized grant, as used by the Credential Offer in OID4VCI
+ * {@see https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-offer}
+ *
+ * @author Stefan Wiedemann
+ */
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class PreAuthorizedCode {
+
+ @JsonProperty("pre-authorized_code")
+ private String preAuthorizedCode;
+
+ @JsonProperty("tx_code")
+ private TxCode txCode;
+
+ @JsonProperty("interval")
+ private long interval;
+
+ @JsonProperty("authorization_server")
+ private String authorizationServer;
+
+ public String getPreAuthorizedCode() {
+ return preAuthorizedCode;
+ }
+
+ public PreAuthorizedCode setPreAuthorizedCode(String preAuthorizedCode) {
+ this.preAuthorizedCode = preAuthorizedCode;
+ return this;
+ }
+
+ public TxCode getTxCode() {
+ return txCode;
+ }
+
+ public PreAuthorizedCode setTxCode(TxCode txCode) {
+ this.txCode = txCode;
+ return this;
+ }
+
+ public long getInterval() {
+ return interval;
+ }
+
+ public PreAuthorizedCode setInterval(long interval) {
+ this.interval = interval;
+ return this;
+ }
+
+ public String getAuthorizationServer() {
+ return authorizationServer;
+ }
+
+ public PreAuthorizedCode setAuthorizationServer(String authorizationServer) {
+ this.authorizationServer = authorizationServer;
+ return this;
+ }
+}
\ No newline at end of file
diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/PreAuthorizedGrant.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/PreAuthorizedGrant.java
new file mode 100644
index 0000000000..2ea7233811
--- /dev/null
+++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/PreAuthorizedGrant.java
@@ -0,0 +1,46 @@
+/*
+ * 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.protocol.oidc.grants.PreAuthorizedCodeGrantType;
+import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantTypeFactory;
+
+/**
+ * Container for the pre-authorized code to be used in a Credential Offer
+ *
+ * {@see https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-offer}
+ *
+ * @author Stefan Wiedemann
+ */
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class PreAuthorizedGrant {
+
+ @JsonProperty(PreAuthorizedCodeGrantTypeFactory.GRANT_TYPE)
+ private PreAuthorizedCode preAuthorizedCode;
+
+ public PreAuthorizedCode getPreAuthorizedCode() {
+ return preAuthorizedCode;
+ }
+
+ public PreAuthorizedGrant setPreAuthorizedCode(PreAuthorizedCode preAuthorizedCode) {
+ this.preAuthorizedCode = preAuthorizedCode;
+ return this;
+ }
+}
\ No newline at end of file
diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/Proof.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/Proof.java
new file mode 100644
index 0000000000..984432e45c
--- /dev/null
+++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/Proof.java
@@ -0,0 +1,54 @@
+/*
+ * 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;
+
+/**
+ * Proof to be used in the Credential Request(to allow holder binding) according to OID4VCI
+ * {@see https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-request}
+ *
+ * @author Stefan Wiedemann
+ */
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class Proof {
+
+ @JsonProperty("proof_type")
+ private ProofType proofType;
+
+ private Object proofObject;
+
+ public ProofType getProofType() {
+ return proofType;
+ }
+
+ public Proof setProofType(ProofType proofType) {
+ this.proofType = proofType;
+ return this;
+ }
+
+ public Object getProofObject() {
+ return proofObject;
+ }
+
+ public Proof setProofObject(Object proofObject) {
+ this.proofObject = proofObject;
+ return this;
+ }
+}
\ No newline at end of file
diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/ProofType.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/ProofType.java
new file mode 100644
index 0000000000..5306744a15
--- /dev/null
+++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/ProofType.java
@@ -0,0 +1,41 @@
+/*
+ * 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;
+
+
+/**
+ * Enum to provide potential proof types for holder-binding
+ *
+ * @author Stefan Wiedemann
+ */
+public enum ProofType {
+
+ JWT("jwt"),
+ LD_PROOF("ldp_vp"),
+ CWT("cwt");
+
+ private final String value;
+
+ ProofType(String value) {
+ this.value = value;
+ }
+
+ public String getValue() {
+ return value;
+ }
+}
\ No newline at end of file
diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/Role.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/Role.java
new file mode 100644
index 0000000000..81ee2cbbd3
--- /dev/null
+++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/Role.java
@@ -0,0 +1,72 @@
+/*
+ * 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.google.common.collect.ImmutableSet;
+import org.keycloak.protocol.oid4vc.issuance.mappers.OID4VCTargetRoleMapper;
+
+import java.util.Objects;
+import java.util.Set;
+
+/**
+ * Pojo representation of a role to be added by the {@link OID4VCTargetRoleMapper}
+ *
+ * @author Stefan Wiedemann
+ */
+public class Role {
+
+ private Set names;
+ private String target;
+
+ public Role() {
+ }
+
+ public Role(Set names, String target) {
+ this.names = ImmutableSet.copyOf(names);
+ this.target = target;
+ }
+
+ public Set getNames() {
+ return names;
+ }
+
+ public void setNames(Set names) {
+ this.names = names;
+ }
+
+ public String getTarget() {
+ return target;
+ }
+
+ public void setTarget(String target) {
+ this.target = target;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ Role role = (Role) o;
+ return Objects.equals(names, role.names) && Objects.equals(target, role.target);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(names, target);
+ }
+}
\ No newline at end of file
diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/SupportedCredentialConfiguration.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/SupportedCredentialConfiguration.java
new file mode 100644
index 0000000000..12d78cc222
--- /dev/null
+++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/SupportedCredentialConfiguration.java
@@ -0,0 +1,216 @@
+/*
+ * 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.JsonIgnore;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.google.common.collect.ImmutableList;
+
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+/**
+ * A supported credential, as used in the Credentials Issuer Metadata in OID4VCI
+ * {@see https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-issuer-metadata}
+ */
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class SupportedCredentialConfiguration {
+
+ private static final String DOT_SEPARATOR = ".";
+
+ @JsonIgnore
+ private static final String FORMAT_KEY = "format";
+ @JsonIgnore
+ private static final String SCOPE_KEY = "scope";
+ @JsonIgnore
+ private static final String CRYPTOGRAPHIC_BINDING_METHODS_SUPPORTED_KEY = " credential_signing_alg_values_supported";
+ @JsonIgnore
+ private static final String CRYPTOGRAPHIC_SUITES_SUPPORTED_KEY = "cryptographic_suites_supported";
+ @JsonIgnore
+ private static final String CREDENTIAL_SIGNING_ALG_VALUES_SUPPORTED_KEY = "credential_signing_alg_values_supported";
+ @JsonIgnore
+ private static final String DISPLAY_KEY = "display";
+ private String id;
+
+ @JsonProperty(FORMAT_KEY)
+ private Format format;
+
+ @JsonProperty(SCOPE_KEY)
+ private String scope;
+
+ @JsonProperty(CRYPTOGRAPHIC_BINDING_METHODS_SUPPORTED_KEY)
+ private List cryptographicBindingMethodsSupported;
+
+ @JsonProperty(CRYPTOGRAPHIC_SUITES_SUPPORTED_KEY)
+ private List cryptographicSuitesSupported;
+
+ @JsonProperty(CREDENTIAL_SIGNING_ALG_VALUES_SUPPORTED_KEY)
+ private List credentialSigningAlgValuesSupported;
+
+ @JsonProperty(DISPLAY_KEY)
+ private DisplayObject display;
+
+ public Format getFormat() {
+ return format;
+ }
+
+ public SupportedCredentialConfiguration setFormat(Format format) {
+ this.format = format;
+ return this;
+ }
+
+ public String getScope() {
+ return scope;
+ }
+
+ public SupportedCredentialConfiguration setScope(String scope) {
+ this.scope = scope;
+ return this;
+ }
+
+ public List getCryptographicBindingMethodsSupported() {
+ return cryptographicBindingMethodsSupported;
+ }
+
+ public SupportedCredentialConfiguration setCryptographicBindingMethodsSupported(List cryptographicBindingMethodsSupported) {
+ this.cryptographicBindingMethodsSupported = ImmutableList.copyOf(cryptographicBindingMethodsSupported);
+ return this;
+ }
+
+ public List getCryptographicSuitesSupported() {
+ return cryptographicSuitesSupported;
+ }
+
+ public SupportedCredentialConfiguration setCryptographicSuitesSupported(List cryptographicSuitesSupported) {
+ this.cryptographicSuitesSupported = ImmutableList.copyOf(cryptographicSuitesSupported);
+ return this;
+ }
+
+ public DisplayObject getDisplay() {
+ return display;
+ }
+
+ public SupportedCredentialConfiguration setDisplay(DisplayObject display) {
+ this.display = display;
+ return this;
+ }
+
+ public String getId() {
+ return id;
+ }
+
+ public SupportedCredentialConfiguration setId(String id) {
+ if (id.contains(".")) {
+ throw new IllegalArgumentException("dots are not supported as part of the supported credentials id.");
+ }
+ this.id = id;
+ return this;
+ }
+
+ public List getCredentialSigningAlgValuesSupported() {
+ return credentialSigningAlgValuesSupported;
+ }
+
+ public SupportedCredentialConfiguration setCredentialSigningAlgValuesSupported(List credentialSigningAlgValuesSupported) {
+ this.credentialSigningAlgValuesSupported = ImmutableList.copyOf(credentialSigningAlgValuesSupported);
+ return this;
+ }
+
+ public Map toDotNotation() {
+ Map dotNotation = new HashMap<>();
+ Optional.ofNullable(format).ifPresent(format -> dotNotation.put(id + DOT_SEPARATOR + FORMAT_KEY, format.toString()));
+ Optional.ofNullable(scope).ifPresent(scope -> dotNotation.put(id + DOT_SEPARATOR + SCOPE_KEY, scope));
+ Optional.ofNullable(cryptographicBindingMethodsSupported).ifPresent(types ->
+ dotNotation.put(id + DOT_SEPARATOR + CRYPTOGRAPHIC_BINDING_METHODS_SUPPORTED_KEY, String.join(",", cryptographicBindingMethodsSupported)));
+ Optional.ofNullable(cryptographicSuitesSupported).ifPresent(types ->
+ dotNotation.put(id + DOT_SEPARATOR + CRYPTOGRAPHIC_SUITES_SUPPORTED_KEY, String.join(",", cryptographicSuitesSupported)));
+ Optional.ofNullable(cryptographicSuitesSupported).ifPresent(types ->
+ dotNotation.put(id + DOT_SEPARATOR + CREDENTIAL_SIGNING_ALG_VALUES_SUPPORTED_KEY, String.join(",", credentialSigningAlgValuesSupported)));
+
+ Map dotNotatedDisplay = Optional.ofNullable(display)
+ .map(DisplayObject::toDotNotation)
+ .orElse(Map.of());
+ dotNotatedDisplay.entrySet().stream()
+ .filter(entry -> entry.getValue() != null)
+ .forEach(entry -> dotNotation.put(id + DOT_SEPARATOR + DISPLAY_KEY + "." + entry.getKey(), entry.getValue()));
+ return dotNotation;
+ }
+
+ public static SupportedCredentialConfiguration fromDotNotation(String credentialId, Map dotNotated) {
+
+ SupportedCredentialConfiguration supportedCredentialConfiguration = new SupportedCredentialConfiguration().setId(credentialId);
+ Optional.ofNullable(dotNotated.get(credentialId + DOT_SEPARATOR + FORMAT_KEY)).map(Format::fromString).ifPresent(supportedCredentialConfiguration::setFormat);
+ Optional.ofNullable(dotNotated.get(credentialId + DOT_SEPARATOR + SCOPE_KEY)).ifPresent(supportedCredentialConfiguration::setScope);
+ Optional.ofNullable(dotNotated.get(credentialId + DOT_SEPARATOR + CRYPTOGRAPHIC_BINDING_METHODS_SUPPORTED_KEY))
+ .map(cbms -> cbms.split(","))
+ .map(Arrays::asList)
+ .ifPresent(supportedCredentialConfiguration::setCryptographicBindingMethodsSupported);
+ Optional.ofNullable(dotNotated.get(credentialId + DOT_SEPARATOR + CRYPTOGRAPHIC_SUITES_SUPPORTED_KEY))
+ .map(css -> css.split(","))
+ .map(Arrays::asList)
+ .ifPresent(supportedCredentialConfiguration::setCryptographicSuitesSupported);
+ Optional.ofNullable(dotNotated.get(credentialId + DOT_SEPARATOR + CREDENTIAL_SIGNING_ALG_VALUES_SUPPORTED_KEY))
+ .map(css -> css.split(","))
+ .map(Arrays::asList)
+ .ifPresent(supportedCredentialConfiguration::setCredentialSigningAlgValuesSupported);
+ Map displayMap = new HashMap<>();
+ dotNotated.entrySet().forEach(entry -> {
+ String key = entry.getKey();
+ if (key.startsWith(credentialId + DOT_SEPARATOR + DISPLAY_KEY)) {
+ displayMap.put(key.substring((credentialId + DOT_SEPARATOR + DISPLAY_KEY).length() + 1), entry.getValue());
+ }
+ });
+ if (!displayMap.isEmpty()) {
+ supportedCredentialConfiguration.setDisplay(DisplayObject.fromDotNotation(displayMap));
+ }
+ return supportedCredentialConfiguration;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (!(o instanceof SupportedCredentialConfiguration that)) return false;
+
+ if (getId() != null ? !getId().equals(that.getId()) : that.getId() != null) return false;
+ if (getFormat() != that.getFormat()) return false;
+ if (getScope() != null ? !getScope().equals(that.getScope()) : that.getScope() != null) return false;
+ if (getCryptographicBindingMethodsSupported() != null ? !getCryptographicBindingMethodsSupported().equals(that.getCryptographicBindingMethodsSupported()) : that.getCryptographicBindingMethodsSupported() != null)
+ return false;
+ if (getCryptographicSuitesSupported() != null ? !getCryptographicSuitesSupported().equals(that.getCryptographicSuitesSupported()) : that.getCryptographicSuitesSupported() != null)
+ return false;
+ if (getCredentialSigningAlgValuesSupported() != null ? !getCredentialSigningAlgValuesSupported().equals(that.getCredentialSigningAlgValuesSupported()) : that.getCredentialSigningAlgValuesSupported() != null)
+ return false;
+ return getDisplay() != null ? getDisplay().equals(that.getDisplay()) : that.getDisplay() == null;
+ }
+
+ @Override
+ public int hashCode() {
+ int result = getId() != null ? getId().hashCode() : 0;
+ result = 31 * result + (getFormat() != null ? getFormat().hashCode() : 0);
+ result = 31 * result + (getScope() != null ? getScope().hashCode() : 0);
+ result = 31 * result + (getCryptographicBindingMethodsSupported() != null ? getCryptographicBindingMethodsSupported().hashCode() : 0);
+ result = 31 * result + (getCryptographicSuitesSupported() != null ? getCryptographicSuitesSupported().hashCode() : 0);
+ result = 31 * result + (getCredentialSigningAlgValuesSupported() != null ? getCredentialSigningAlgValuesSupported().hashCode() : 0);
+ result = 31 * result + (getDisplay() != null ? getDisplay().hashCode() : 0);
+ return result;
+ }
+}
\ No newline at end of file
diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/TxCode.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/TxCode.java
new file mode 100644
index 0000000000..d4b4b6b4b6
--- /dev/null
+++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/TxCode.java
@@ -0,0 +1,67 @@
+/*
+ * 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;
+
+/**
+ * Represents a transaction code as used in the pre-authorized grant in the Credential Offer in OID4VCI
+ * {@see https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-offer}
+ *
+ * @author Stefan Wiedemann
+ */
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class TxCode {
+
+ @JsonProperty("input_mode")
+ private String inputMode;
+
+ @JsonProperty("length")
+ private int length;
+
+ @JsonProperty("description")
+ private String description;
+
+ public String getInputMode() {
+ return inputMode;
+ }
+
+ public TxCode setInputMode(String inputMode) {
+ this.inputMode = inputMode;
+ return this;
+ }
+
+ public int getLength() {
+ return length;
+ }
+
+ public TxCode setLength(int length) {
+ this.length = length;
+ return this;
+ }
+
+ public String getDescription() {
+ return description;
+ }
+
+ public TxCode setDescription(String description) {
+ this.description = description;
+ return this;
+ }
+}
diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/VerifiableCredential.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/VerifiableCredential.java
index aac078ecc1..22507b7c80 100644
--- a/services/src/main/java/org/keycloak/protocol/oid4vc/model/VerifiableCredential.java
+++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/VerifiableCredential.java
@@ -55,67 +55,76 @@ public class VerifiableCredential {
}
@JsonAnySetter
- public void setAdditionalProperties(String name, Object property) {
+ public VerifiableCredential setAdditionalProperties(String name, Object property) {
additionalProperties.put(name, property);
+ return this;
}
public List getContext() {
return context;
}
- public void setContext(List context) {
+ public VerifiableCredential setContext(List context) {
this.context = context;
+ return this;
}
public List getType() {
return type;
}
- public void setType(List type) {
+ public VerifiableCredential setType(List type) {
this.type = type;
- }
-
- public void addType(String type) {
- this.type.add(type);
+ return this;
}
public URI getIssuer() {
return issuer;
}
- public void setIssuer(URI issuer) {
+ public VerifiableCredential setIssuer(URI issuer) {
this.issuer = issuer;
+ return this;
}
public Date getIssuanceDate() {
return issuanceDate;
}
- public void setIssuanceDate(Date issuanceDate) {
+ public VerifiableCredential setIssuanceDate(Date issuanceDate) {
this.issuanceDate = issuanceDate;
- }
-
- public Date getExpirationDate() {
- return expirationDate;
- }
-
- public void setExpirationDate(Date expirationDate) {
- this.expirationDate = expirationDate;
- }
-
- public CredentialSubject getCredentialSubject() {
- return credentialSubject;
- }
-
- public void setCredentialSubject(CredentialSubject credentialSubject) {
- this.credentialSubject = credentialSubject;
+ return this;
}
public URI getId() {
return id;
}
- public void setId(URI id) {
+ public VerifiableCredential setId(URI id) {
this.id = id;
+ return this;
+ }
+
+ public Date getExpirationDate() {
+ return expirationDate;
+ }
+
+ public VerifiableCredential setExpirationDate(Date expirationDate) {
+ this.expirationDate = expirationDate;
+ return this;
+ }
+
+ public CredentialSubject getCredentialSubject() {
+ return credentialSubject;
+ }
+
+ public VerifiableCredential setCredentialSubject(CredentialSubject credentialSubject) {
+ this.credentialSubject = credentialSubject;
+ return this;
+ }
+
+ public VerifiableCredential setAdditionalProperties(Map additionalProperties) {
+ this.additionalProperties = additionalProperties;
+ return this;
}
}
\ No newline at end of file
diff --git a/services/src/main/java/org/keycloak/protocol/oidc/OIDCWellKnownProvider.java b/services/src/main/java/org/keycloak/protocol/oidc/OIDCWellKnownProvider.java
index 4fb2d45d78..82ddcc02bd 100755
--- a/services/src/main/java/org/keycloak/protocol/oidc/OIDCWellKnownProvider.java
+++ b/services/src/main/java/org/keycloak/protocol/oidc/OIDCWellKnownProvider.java
@@ -33,6 +33,8 @@ import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.protocol.oidc.endpoints.AuthorizationEndpoint;
import org.keycloak.protocol.oidc.endpoints.TokenEndpoint;
+import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantType;
+import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantTypeFactory;
import org.keycloak.protocol.oidc.grants.ciba.CibaGrantType;
import org.keycloak.protocol.oidc.grants.device.endpoints.DeviceEndpoint;
import org.keycloak.protocol.oidc.par.endpoints.ParEndpoint;
@@ -82,9 +84,9 @@ public class OIDCWellKnownProvider implements WellKnownProvider {
public static final List DEFAULT_CLIENT_AUTH_SIGNING_ALG_VALUES_SUPPORTED = list(Algorithm.RS256.toString());
// The exact list depends on protocolMappers
- public static final List DEFAULT_CLAIMS_SUPPORTED= list("aud", "sub", "iss", IDToken.AUTH_TIME, IDToken.NAME, IDToken.GIVEN_NAME, IDToken.FAMILY_NAME, IDToken.PREFERRED_USERNAME, IDToken.EMAIL, IDToken.ACR);
+ public static final List DEFAULT_CLAIMS_SUPPORTED = list("aud", "sub", "iss", IDToken.AUTH_TIME, IDToken.NAME, IDToken.GIVEN_NAME, IDToken.FAMILY_NAME, IDToken.PREFERRED_USERNAME, IDToken.EMAIL, IDToken.ACR);
- public static final List DEFAULT_CLAIM_TYPES_SUPPORTED= list("normal");
+ public static final List DEFAULT_CLAIM_TYPES_SUPPORTED = list("normal");
// KEYCLOAK-7451 OAuth Authorization Server Metadata for Proof Key for Code Exchange
public static final List DEFAULT_CODE_CHALLENGE_METHODS_SUPPORTED = list(OAuth2Constants.PKCE_METHOD_PLAIN, OAuth2Constants.PKCE_METHOD_S256);
@@ -108,6 +110,9 @@ public class OIDCWellKnownProvider implements WellKnownProvider {
if (Profile.isFeatureEnabled(Profile.Feature.DEVICE_FLOW)) {
DEFAULT_GRANT_TYPES_SUPPORTED.add(OAuth2Constants.DEVICE_CODE_GRANT_TYPE);
}
+ if (Profile.isFeatureEnabled(Profile.Feature.OID4VC_VCI)) {
+ DEFAULT_GRANT_TYPES_SUPPORTED.add(PreAuthorizedCodeGrantTypeFactory.GRANT_TYPE);
+ }
this.session = session;
this.openidConfigOverride = openidConfigOverride;
@@ -137,7 +142,7 @@ public class OIDCWellKnownProvider implements WellKnownProvider {
.build(realm.getName(), OIDCLoginProtocol.LOGIN_PROTOCOL).toString());
}
URI jwksUri = backendUriBuilder.clone().path(OIDCLoginProtocolService.class, "certs").build(realm.getName(),
- OIDCLoginProtocol.LOGIN_PROTOCOL);
+ OIDCLoginProtocol.LOGIN_PROTOCOL);
// NOTE: Don't hardcode HTTPS checks here. JWKS URI is exposed just in the development/testing environment. For the production environment, the OIDCWellKnownProvider
// is not exposed over "http" at all.
@@ -183,7 +188,7 @@ public class OIDCWellKnownProvider implements WellKnownProvider {
.collect(Collectors.toList());
if (!scopeNames.contains(OAuth2Constants.SCOPE_OPENID)) {
scopeNames.add(0, OAuth2Constants.SCOPE_OPENID);
- }
+ }
config.setScopesSupported(scopeNames);
}
@@ -203,7 +208,7 @@ public class OIDCWellKnownProvider implements WellKnownProvider {
}
URI revocationEndpoint = frontendUriBuilder.clone().path(OIDCLoginProtocolService.class, "revoke")
- .build(realm.getName(), OIDCLoginProtocol.LOGIN_PROTOCOL);
+ .build(realm.getName(), OIDCLoginProtocol.LOGIN_PROTOCOL);
// NOTE: Don't hardcode HTTPS checks here. JWKS URI is exposed just in the development/testing environment. For the production environment, the OIDCWellKnownProvider
// is not exposed over "http" at all.
diff --git a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java
index 7ff420da8d..2a36487b27 100644
--- a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java
+++ b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java
@@ -17,14 +17,24 @@
package org.keycloak.protocol.oidc.endpoints;
+import jakarta.ws.rs.Consumes;
+import jakarta.ws.rs.OPTIONS;
+import jakarta.ws.rs.POST;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.core.HttpHeaders;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.MultivaluedHashMap;
+import jakarta.ws.rs.core.MultivaluedMap;
+import jakarta.ws.rs.core.Response;
+import jakarta.ws.rs.core.Response.Status;
import org.jboss.logging.Logger;
-import org.keycloak.http.HttpRequest;
-import org.keycloak.http.HttpResponse;
import org.keycloak.OAuth2Constants;
import org.keycloak.OAuthErrorException;
import org.keycloak.common.ClientConnection;
import org.keycloak.events.Details;
import org.keycloak.events.EventBuilder;
+import org.keycloak.http.HttpRequest;
+import org.keycloak.http.HttpResponse;
import org.keycloak.models.AuthenticatedClientSessionModel;
import org.keycloak.models.ClientModel;
import org.keycloak.models.KeycloakSession;
@@ -33,6 +43,7 @@ import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.TokenManager;
import org.keycloak.protocol.oidc.grants.OAuth2GrantType;
+import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantTypeFactory;
import org.keycloak.protocol.oidc.utils.AuthorizeClientUtil;
import org.keycloak.protocol.saml.JaxrsSAML2BindingBuilder;
import org.keycloak.protocol.saml.SamlClient;
@@ -48,20 +59,8 @@ import org.keycloak.services.cors.Cors;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
-import jakarta.ws.rs.Consumes;
-import jakarta.ws.rs.OPTIONS;
-import jakarta.ws.rs.POST;
-import jakarta.ws.rs.Path;
-import jakarta.ws.rs.core.HttpHeaders;
-import jakarta.ws.rs.core.MediaType;
-import jakarta.ws.rs.core.MultivaluedHashMap;
-import jakarta.ws.rs.core.MultivaluedMap;
-import jakarta.ws.rs.core.Response;
-import jakarta.ws.rs.core.Response.Status;
import javax.xml.namespace.QName;
-
import java.io.IOException;
-import java.util.List;
import java.util.Map;
/**
@@ -130,7 +129,9 @@ public class TokenEndpoint {
checkRealm();
checkGrantType();
- if (!grantType.equals(OAuth2Constants.UMA_GRANT_TYPE)) {
+ if (!grantType.equals(OAuth2Constants.UMA_GRANT_TYPE)
+ // pre-authorized grants are not necessarily used by known clients.
+ && !grantType.equals(PreAuthorizedCodeGrantTypeFactory.GRANT_TYPE)) {
checkClient();
checkParameterDuplicated();
}
@@ -202,7 +203,7 @@ public class TokenEndpoint {
for (String key : formParams.keySet()) {
if (formParams.get(key).size() != 1) {
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, "duplicated parameter",
- Response.Status.BAD_REQUEST);
+ Response.Status.BAD_REQUEST);
}
}
}
diff --git a/services/src/main/java/org/keycloak/protocol/oidc/grants/PreAuthorizedCodeGrantType.java b/services/src/main/java/org/keycloak/protocol/oidc/grants/PreAuthorizedCodeGrantType.java
new file mode 100644
index 0000000000..486fe184cb
--- /dev/null
+++ b/services/src/main/java/org/keycloak/protocol/oidc/grants/PreAuthorizedCodeGrantType.java
@@ -0,0 +1,130 @@
+/*
+ * 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.oidc.grants;
+
+import jakarta.ws.rs.core.Response;
+import org.jboss.logging.Logger;
+import org.keycloak.Config;
+import org.keycloak.OAuth2Constants;
+import org.keycloak.OAuthErrorException;
+import org.keycloak.common.Profile;
+import org.keycloak.common.util.SecretGenerator;
+import org.keycloak.events.Errors;
+import org.keycloak.events.EventType;
+import org.keycloak.models.AuthenticatedClientSessionModel;
+import org.keycloak.models.ClientSessionContext;
+import org.keycloak.models.KeycloakSession;
+import org.keycloak.protocol.oidc.utils.OAuth2Code;
+import org.keycloak.protocol.oidc.utils.OAuth2CodeParser;
+import org.keycloak.provider.EnvironmentDependentProviderFactory;
+import org.keycloak.representations.AccessToken;
+import org.keycloak.representations.AccessTokenResponse;
+import org.keycloak.services.CorsErrorResponseException;
+import org.keycloak.services.util.DefaultClientSessionContext;
+import org.keycloak.utils.MediaType;
+
+import java.util.UUID;
+
+public class PreAuthorizedCodeGrantType extends OAuth2GrantTypeBase implements EnvironmentDependentProviderFactory {
+
+ private static final Logger LOGGER = Logger.getLogger(PreAuthorizedCodeGrantType.class);
+
+ @Override
+ public Response process(Context context) {
+ LOGGER.debug("Process grant request for preauthorized.");
+ setContext(context);
+
+ String code = formParams.getFirst(OAuth2Constants.CODE);
+
+ if (code == null) {
+ event.error(Errors.INVALID_CODE);
+ throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST,
+ "Missing parameter: " + OAuth2Constants.CODE, Response.Status.BAD_REQUEST);
+ }
+ OAuth2CodeParser.ParseResult result = OAuth2CodeParser.parseCode(session, code, realm, event);
+ if (result.isIllegalCode()) {
+ event.error(Errors.INVALID_CODE);
+ throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_GRANT, "Code not valid",
+ Response.Status.BAD_REQUEST);
+ }
+ if (result.isExpiredCode()) {
+ event.error(Errors.EXPIRED_CODE);
+ throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_GRANT, "Code is expired",
+ Response.Status.BAD_REQUEST);
+ }
+ AuthenticatedClientSessionModel clientSession = result.getClientSession();
+ ClientSessionContext sessionContext = DefaultClientSessionContext.fromClientSessionAndScopeParameter(clientSession,
+ OAuth2Constants.SCOPE_OPENID, session);
+
+
+ // set the client as retrieved from the pre-authorized session
+ session.getContext().setClient(result.getClientSession().getClient());
+
+ AccessToken accessToken = tokenManager.createClientAccessToken(session,
+ clientSession.getRealm(),
+ clientSession.getClient(),
+ clientSession.getUserSession().getUser(),
+ clientSession.getUserSession(),
+ sessionContext);
+
+ AccessTokenResponse tokenResponse = tokenManager.responseBuilder(
+ clientSession.getRealm(),
+ clientSession.getClient(),
+ event,
+ session,
+ clientSession.getUserSession(),
+ sessionContext)
+ .accessToken(accessToken).build();
+
+ event.success();
+
+ return cors.allowAllOrigins().builder(Response.ok(tokenResponse).type(MediaType.APPLICATION_JSON_TYPE)).build();
+ }
+
+
+ @Override
+ public boolean isSupported(Config.Scope config) {
+ return Profile.isFeatureEnabled(Profile.Feature.OID4VC_VCI);
+ }
+
+ @Override
+ public boolean isSupported() {
+ return Profile.isFeatureEnabled(Profile.Feature.OID4VC_VCI);
+ }
+
+ @Override
+ public EventType getEventType() {
+ return EventType.CODE_TO_TOKEN;
+ }
+
+ /**
+ * Create a pre-authorized Code for the given client session.
+ *
+ * @param session - keycloak session to be used
+ * @param authenticatedClientSession - client session to be persisted
+ * @param expirationTime - expiration time of the code, the code should be short-lived
+ * @return the pre-authorized code
+ */
+ public static String getPreAuthorizedCode(KeycloakSession session, AuthenticatedClientSessionModel authenticatedClientSession, int expirationTime) {
+ String codeId = UUID.randomUUID().toString();
+ String nonce = SecretGenerator.getInstance().randomString();
+ OAuth2Code oAuth2Code = new OAuth2Code(codeId, expirationTime, nonce, null, null, null, null,
+ authenticatedClientSession.getUserSession().getId());
+ return OAuth2CodeParser.persistCode(session, authenticatedClientSession, oAuth2Code);
+ }
+}
\ No newline at end of file
diff --git a/services/src/main/java/org/keycloak/protocol/oidc/grants/PreAuthorizedCodeGrantTypeFactory.java b/services/src/main/java/org/keycloak/protocol/oidc/grants/PreAuthorizedCodeGrantTypeFactory.java
new file mode 100644
index 0000000000..ec742ad3df
--- /dev/null
+++ b/services/src/main/java/org/keycloak/protocol/oidc/grants/PreAuthorizedCodeGrantTypeFactory.java
@@ -0,0 +1,55 @@
+/*
+ * 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.oidc.grants;
+
+import org.keycloak.Config;
+import org.keycloak.models.KeycloakSession;
+import org.keycloak.models.KeycloakSessionFactory;
+
+/**
+ * Factory for Pre-Authorized Code Grant
+ *
+ * @author Stefan Wiedemann
+ */
+public class PreAuthorizedCodeGrantTypeFactory implements OAuth2GrantTypeFactory {
+
+ public static final String GRANT_TYPE = "urn:ietf:params:oauth:grant-type:pre-authorized_code";
+
+ @Override
+ public OAuth2GrantType create(KeycloakSession session) {
+ return new PreAuthorizedCodeGrantType();
+ }
+
+ @Override
+ public void init(Config.Scope config) {
+ }
+
+ @Override
+ public void postInit(KeycloakSessionFactory factory) {
+ }
+
+ @Override
+ public void close() {
+ }
+
+ @Override
+ public String getId() {
+ return GRANT_TYPE;
+ }
+
+}
diff --git a/services/src/main/java/org/keycloak/protocol/oidc/mappers/AbstractOIDCProtocolMapper.java b/services/src/main/java/org/keycloak/protocol/oidc/mappers/AbstractOIDCProtocolMapper.java
index 6dfb2ffdbe..98bb964e20 100755
--- a/services/src/main/java/org/keycloak/protocol/oidc/mappers/AbstractOIDCProtocolMapper.java
+++ b/services/src/main/java/org/keycloak/protocol/oidc/mappers/AbstractOIDCProtocolMapper.java
@@ -89,8 +89,8 @@ public abstract class AbstractOIDCProtocolMapper implements ProtocolMapper {
public AccessToken transformAccessToken(AccessToken token, ProtocolMapperModel mappingModel, KeycloakSession session,
UserSessionModel userSession, ClientSessionContext clientSessionCtx) {
boolean shouldUseLightweightToken = getShouldUseLightweightToken(session);
- boolean includeInAccessToken = shouldUseLightweightToken ? OIDCAttributeMapperHelper.includeInLightweightAccessToken(mappingModel) : OIDCAttributeMapperHelper.includeInAccessToken(mappingModel);
- if (!includeInAccessToken){
+ boolean includeInAccessToken = shouldUseLightweightToken ? OIDCAttributeMapperHelper.includeInLightweightAccessToken(mappingModel) : OIDCAttributeMapperHelper.includeInAccessToken(mappingModel);
+ if (!includeInAccessToken) {
return token;
}
@@ -101,7 +101,7 @@ public abstract class AbstractOIDCProtocolMapper implements ProtocolMapper {
public IDToken transformIDToken(IDToken token, ProtocolMapperModel mappingModel, KeycloakSession session,
UserSessionModel userSession, ClientSessionContext clientSessionCtx) {
- if (!OIDCAttributeMapperHelper.includeInIDToken(mappingModel)){
+ if (!OIDCAttributeMapperHelper.includeInIDToken(mappingModel)) {
return token;
}
@@ -122,9 +122,9 @@ public abstract class AbstractOIDCProtocolMapper implements ProtocolMapper {
}
public AccessToken transformIntrospectionToken(AccessToken token, ProtocolMapperModel mappingModel, KeycloakSession session,
- UserSessionModel userSession, ClientSessionContext clientSessionCtx) {
+ UserSessionModel userSession, ClientSessionContext clientSessionCtx) {
- if (!OIDCAttributeMapperHelper.includeInIntrospection(mappingModel)){
+ if (!OIDCAttributeMapperHelper.includeInIntrospection(mappingModel)) {
return token;
}
@@ -134,10 +134,10 @@ public abstract class AbstractOIDCProtocolMapper implements ProtocolMapper {
/**
* Intended to be overridden in {@link ProtocolMapper} implementations to add claims to an token.
+ *
* @param token
* @param mappingModel
* @param userSession
- *
* @deprecated override {@link #setClaim(IDToken, ProtocolMapperModel, UserSessionModel, KeycloakSession, ClientSessionContext)} instead.
*/
@Deprecated
@@ -146,6 +146,7 @@ public abstract class AbstractOIDCProtocolMapper implements ProtocolMapper {
/**
* Intended to be overridden in {@link ProtocolMapper} implementations to add claims to an token.
+ *
* @param token
* @param mappingModel
* @param userSession
@@ -160,6 +161,7 @@ public abstract class AbstractOIDCProtocolMapper implements ProtocolMapper {
/**
* Intended to be overridden in {@link ProtocolMapper} implementations to add claims to an token.
+ *
* @param accessTokenResponse
* @param mappingModel
* @param userSession
diff --git a/services/src/main/resources/META-INF/services/org.keycloak.protocol.LoginProtocolFactory b/services/src/main/resources/META-INF/services/org.keycloak.protocol.LoginProtocolFactory
index e954f2ee7a..ef616476c0 100755
--- a/services/src/main/resources/META-INF/services/org.keycloak.protocol.LoginProtocolFactory
+++ b/services/src/main/resources/META-INF/services/org.keycloak.protocol.LoginProtocolFactory
@@ -17,4 +17,5 @@
org.keycloak.protocol.oidc.OIDCLoginProtocolFactory
org.keycloak.protocol.saml.SamlProtocolFactory
-org.keycloak.protocol.docker.DockerAuthV2ProtocolFactory
\ No newline at end of file
+org.keycloak.protocol.docker.DockerAuthV2ProtocolFactory
+org.keycloak.protocol.oid4vc.OID4VCLoginProtocolFactory
\ No newline at end of file
diff --git a/services/src/main/resources/META-INF/services/org.keycloak.protocol.ProtocolMapper b/services/src/main/resources/META-INF/services/org.keycloak.protocol.ProtocolMapper
index 716fc14990..1adfde55d4 100755
--- a/services/src/main/resources/META-INF/services/org.keycloak.protocol.ProtocolMapper
+++ b/services/src/main/resources/META-INF/services/org.keycloak.protocol.ProtocolMapper
@@ -48,5 +48,11 @@ org.keycloak.protocol.oidc.mappers.ClaimsParameterTokenMapper
org.keycloak.protocol.saml.mappers.UserAttributeNameIdMapper
org.keycloak.protocol.oidc.mappers.ClaimsParameterWithValueIdTokenMapper
org.keycloak.protocol.oidc.mappers.NonceBackwardsCompatibleMapper
+org.keycloak.protocol.oid4vc.issuance.mappers.OID4VCSubjectIdMapper
+org.keycloak.protocol.oid4vc.issuance.mappers.OID4VCStaticClaimMapper
+org.keycloak.protocol.oid4vc.issuance.mappers.OID4VCTargetRoleMapper
+org.keycloak.protocol.oid4vc.issuance.mappers.OID4VCUserAttributeMapper
+org.keycloak.protocol.oid4vc.issuance.mappers.OID4VCTypeMapper
+org.keycloak.protocol.oid4vc.issuance.mappers.OID4VCContextMapper
org.keycloak.protocol.oidc.mappers.SessionStateMapper
org.keycloak.protocol.oidc.mappers.SubMapper
diff --git a/services/src/main/resources/META-INF/services/org.keycloak.protocol.oid4vc.issuance.signing.VCSigningServiceProviderFactory b/services/src/main/resources/META-INF/services/org.keycloak.protocol.oid4vc.issuance.signing.VCSigningServiceProviderFactory
new file mode 100644
index 0000000000..731691c883
--- /dev/null
+++ b/services/src/main/resources/META-INF/services/org.keycloak.protocol.oid4vc.issuance.signing.VCSigningServiceProviderFactory
@@ -0,0 +1,19 @@
+#
+# 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.
+#
+
+org.keycloak.protocol.oid4vc.issuance.signing.JwtSigningServiceProviderFactory
+org.keycloak.protocol.oid4vc.issuance.signing.SdJwtSigningServiceProviderFactory
\ No newline at end of file
diff --git a/services/src/main/resources/META-INF/services/org.keycloak.protocol.oidc.grants.OAuth2GrantTypeFactory b/services/src/main/resources/META-INF/services/org.keycloak.protocol.oidc.grants.OAuth2GrantTypeFactory
index 129a145a0f..8897da06c3 100644
--- a/services/src/main/resources/META-INF/services/org.keycloak.protocol.oidc.grants.OAuth2GrantTypeFactory
+++ b/services/src/main/resources/META-INF/services/org.keycloak.protocol.oidc.grants.OAuth2GrantTypeFactory
@@ -6,3 +6,4 @@ org.keycloak.protocol.oidc.grants.ResourceOwnerPasswordCredentialsGrantTypeFacto
org.keycloak.protocol.oidc.grants.TokenExchangeGrantTypeFactory
org.keycloak.protocol.oidc.grants.ciba.CibaGrantTypeFactory
org.keycloak.protocol.oidc.grants.device.DeviceGrantTypeFactory
+org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantTypeFactory
diff --git a/services/src/main/resources/META-INF/services/org.keycloak.services.clientregistration.ClientRegistrationProviderFactory b/services/src/main/resources/META-INF/services/org.keycloak.services.clientregistration.ClientRegistrationProviderFactory
index b9377cd9c9..eb55687c23 100755
--- a/services/src/main/resources/META-INF/services/org.keycloak.services.clientregistration.ClientRegistrationProviderFactory
+++ b/services/src/main/resources/META-INF/services/org.keycloak.services.clientregistration.ClientRegistrationProviderFactory
@@ -18,4 +18,5 @@
org.keycloak.services.clientregistration.DefaultClientRegistrationProviderFactory
org.keycloak.services.clientregistration.oidc.OIDCClientRegistrationProviderFactory
org.keycloak.services.clientregistration.AdapterInstallationClientRegistrationProviderFactory
-org.keycloak.protocol.saml.clientregistration.EntityDescriptorClientRegistrationProviderFactory
\ No newline at end of file
+org.keycloak.protocol.saml.clientregistration.EntityDescriptorClientRegistrationProviderFactory
+org.keycloak.protocol.oid4vc.OID4VCClientRegistrationProviderFactory
\ No newline at end of file
diff --git a/services/src/main/resources/META-INF/services/org.keycloak.wellknown.WellKnownProviderFactory b/services/src/main/resources/META-INF/services/org.keycloak.wellknown.WellKnownProviderFactory
index df3dd7ad03..a5aeb67004 100644
--- a/services/src/main/resources/META-INF/services/org.keycloak.wellknown.WellKnownProviderFactory
+++ b/services/src/main/resources/META-INF/services/org.keycloak.wellknown.WellKnownProviderFactory
@@ -16,4 +16,5 @@
#
org.keycloak.protocol.oidc.OIDCWellKnownProviderFactory
-org.keycloak.authorization.config.UmaWellKnownProviderFactory
\ No newline at end of file
+org.keycloak.authorization.config.UmaWellKnownProviderFactory
+org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerWellKnownProviderFactory
\ No newline at end of file
diff --git a/services/src/test/java/org/keycloak/protocol/oid4vc/OID4VCClientRegistrationProviderTest.java b/services/src/test/java/org/keycloak/protocol/oid4vc/OID4VCClientRegistrationProviderTest.java
new file mode 100644
index 0000000000..29736ffd18
--- /dev/null
+++ b/services/src/test/java/org/keycloak/protocol/oid4vc/OID4VCClientRegistrationProviderTest.java
@@ -0,0 +1,132 @@
+/*
+ * 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;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+import org.keycloak.protocol.oid4vc.model.DisplayObject;
+import org.keycloak.protocol.oid4vc.model.Format;
+import org.keycloak.protocol.oid4vc.model.OID4VCClient;
+import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+@RunWith(Parameterized.class)
+public class OID4VCClientRegistrationProviderTest {
+
+ @Parameterized.Parameters(name = "{0}")
+ public static Collection