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 parameters() { + return Arrays.asList(new Object[][]{ + { + "Single Supported Credential with format and single-type.", + Map.of( + "vc.credential-id.format", Format.JWT_VC.toString(), + "vc.credential-id.scope", "VerifiableCredential"), + new OID4VCClient(null, "did:web:test.org", + List.of(new SupportedCredentialConfiguration() + .setId("credential-id") + .setFormat(Format.JWT_VC) + .setScope("VerifiableCredential")), + null, null) + }, + { + "Single Supported Credential with format and multi-type.", + Map.of( + "vc.credential-id.format", Format.JWT_VC.toString(), + "vc.credential-id.scope", "AnotherCredential"), + new OID4VCClient(null, "did:web:test.org", + List.of(new SupportedCredentialConfiguration() + .setId("credential-id") + .setFormat(Format.JWT_VC) + .setScope("AnotherCredential")), + null, null) + }, + { + "Single Supported Credential with format, multi-type and a display object.", + Map.of( + "vc.credential-id.format", Format.JWT_VC.toString(), + "vc.credential-id.scope", "AnotherCredential", + "vc.credential-id.display.name", "Another", + "vc.credential-id.display.locale", "en"), + new OID4VCClient(null, "did:web:test.org", + List.of(new SupportedCredentialConfiguration() + .setId("credential-id") + .setFormat(Format.JWT_VC) + .setDisplay(new DisplayObject().setLocale("en").setName("Another")) + .setScope("AnotherCredential")), + null, null) + }, + { + "Multiple Supported Credentials.", + Map.of( + "vc.first-id.format", Format.JWT_VC.toString(), + "vc.first-id.scope", "AnotherCredential", + "vc.first-id.display.name", "First", + "vc.first-id.display.locale", "en", + "vc.second-id.format", Format.SD_JWT_VC.toString(), + "vc.second-id.scope", "MyType", + "vc.second-id.display.name", "Second Credential", + "vc.second-id.display.locale", "de"), + new OID4VCClient(null, "did:web:test.org", + List.of(new SupportedCredentialConfiguration() + .setId("first-id") + .setFormat(Format.JWT_VC) + .setDisplay(new DisplayObject().setLocale("en").setName("First")) + .setScope("AnotherCredential"), + new SupportedCredentialConfiguration() + .setId("second-id") + .setFormat(Format.SD_JWT_VC) + .setDisplay(new DisplayObject().setLocale("de").setName("Second Credential")) + .setScope("MyType")), + null, null) + } + }); + } + + private Map clientAttributes; + private OID4VCClient oid4VCClient; + + public OID4VCClientRegistrationProviderTest(String name, Map clientAttributes, OID4VCClient oid4VCClient) { + this.clientAttributes = clientAttributes; + this.oid4VCClient = oid4VCClient; + } + + @Test + public void testToClientRepresentation() { + Map translatedAttributes = OID4VCClientRegistrationProvider.toClientRepresentation(oid4VCClient).getAttributes(); + + assertEquals("The client should have been translated into the correct clientRepresentation.", clientAttributes.entrySet().size(), translatedAttributes.size()); + clientAttributes.forEach((key, value) -> + assertEquals("The client should have been translated into the correct clientRepresentation.", clientAttributes.get(key), translatedAttributes.get(key))); + } + + @Test + public void testFromClientAttributes() { + assertEquals("The client should have been correctly build from the client representation", + oid4VCClient, + OID4VCClientRegistrationProvider.fromClientAttributes("did:web:test.org", clientAttributes)); + } + +} \ No newline at end of file diff --git a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/rest/TestingResourceProvider.java b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/rest/TestingResourceProvider.java index feb4b036f3..44e87d7f84 100644 --- a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/rest/TestingResourceProvider.java +++ b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/rest/TestingResourceProvider.java @@ -17,13 +17,19 @@ package org.keycloak.testsuite.rest; -import static java.util.Objects.requireNonNull; - +import jakarta.ws.rs.BadRequestException; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.NotFoundException; +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.QueryParam; import jakarta.ws.rs.core.CacheControl; +import jakarta.ws.rs.core.Response; import org.jboss.resteasy.reactive.NoCache; -import org.keycloak.cookie.CookieProvider; -import org.keycloak.cookie.CookieType; -import org.keycloak.http.HttpRequest; import org.keycloak.Config; import org.keycloak.common.Profile; import org.keycloak.common.Profile.Feature; @@ -32,6 +38,8 @@ import org.keycloak.common.profile.PropertiesProfileConfigResolver; import org.keycloak.common.util.HtmlUtils; import org.keycloak.common.util.Time; import org.keycloak.component.ComponentModel; +import org.keycloak.cookie.CookieProvider; +import org.keycloak.cookie.CookieType; import org.keycloak.events.Event; import org.keycloak.events.EventQuery; import org.keycloak.events.EventStoreProvider; @@ -40,6 +48,8 @@ import org.keycloak.events.admin.AdminEvent; import org.keycloak.events.admin.AdminEventQuery; import org.keycloak.events.admin.AuthDetails; import org.keycloak.events.admin.OperationType; +import org.keycloak.http.HttpRequest; +import org.keycloak.models.AuthenticatedClientSessionModel; import org.keycloak.models.AuthenticationFlowModel; import org.keycloak.models.ClientModel; import org.keycloak.models.ClientScopeModel; @@ -57,6 +67,7 @@ import org.keycloak.models.sessions.infinispan.changes.sessions.CrossDCLastSessi import org.keycloak.models.utils.ModelToRepresentation; import org.keycloak.models.utils.ResetTimeOffsetEvent; import org.keycloak.protocol.oidc.OIDCLoginProtocol; +import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantType; import org.keycloak.protocol.oidc.mappers.AudienceProtocolMapper; import org.keycloak.provider.Provider; import org.keycloak.provider.ProviderFactory; @@ -96,17 +107,6 @@ import org.keycloak.truststore.TruststoreProvider; import org.keycloak.util.JsonSerialization; import org.keycloak.utils.MediaType; -import jakarta.ws.rs.BadRequestException; -import jakarta.ws.rs.Consumes; -import jakarta.ws.rs.GET; -import jakarta.ws.rs.NotFoundException; -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.QueryParam; -import jakarta.ws.rs.core.Response; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; @@ -124,10 +124,12 @@ import java.util.List; import java.util.Map; import java.util.Properties; import java.util.Set; +import java.util.UUID; import java.util.function.Consumer; import java.util.stream.Collectors; import java.util.stream.Stream; -import java.util.UUID; + +import static java.util.Objects.requireNonNull; /** * @author Stian Thorgersen @@ -452,14 +454,14 @@ public class TestingResourceProvider implements RealmResourceProvider { /** * Get admin events - * + *

* Returns all admin events, or filters events based on URL query parameters listed here * * @param realmId * @param operationTypes * @param authRealm * @param authClient - * @param authUser user id + * @param authUser user id * @param authIpAddress * @param resourcePath * @param dateFrom @@ -473,10 +475,10 @@ public class TestingResourceProvider implements RealmResourceProvider { @NoCache @Produces(MediaType.APPLICATION_JSON) public Stream getAdminEvents(@QueryParam("realmId") String realmId, @QueryParam("operationTypes") List operationTypes, @QueryParam("authRealm") String authRealm, @QueryParam("authClient") String authClient, - @QueryParam("authUser") String authUser, @QueryParam("authIpAddress") String authIpAddress, - @QueryParam("resourcePath") String resourcePath, @QueryParam("dateFrom") String dateFrom, - @QueryParam("dateTo") String dateTo, @QueryParam("first") Integer firstResult, - @QueryParam("max") Integer maxResults) { + @QueryParam("authUser") String authUser, @QueryParam("authIpAddress") String authIpAddress, + @QueryParam("resourcePath") String resourcePath, @QueryParam("dateFrom") String dateFrom, + @QueryParam("dateTo") String dateTo, @QueryParam("first") Integer firstResult, + @QueryParam("max") Integer maxResults) { EventStoreProvider eventStore = session.getProvider(EventStoreProvider.class); AdminEventQuery query = eventStore.createAdminQuery(); @@ -682,7 +684,7 @@ public class TestingResourceProvider implements RealmResourceProvider { @Produces(MediaType.APPLICATION_JSON) public UserRepresentation getUserByServiceAccountClient(@QueryParam("realmName") String realmName, @QueryParam("clientId") String clientId) { RealmModel realm = getRealmByName(realmName); - ClientModel client = realm.getClientByClientId(clientId); + ClientModel client = realm.getClientByClientId(clientId); UserModel user = session.users().getServiceAccount(client); if (user == null) return null; return ModelToRepresentation.toRepresentation(session, realm, user); @@ -702,10 +704,10 @@ public class TestingResourceProvider implements RealmResourceProvider { .collect(Collectors.toMap(ComponentModel::getName, componentModel -> { ProviderFactory f = session.getKeycloakSessionFactory() - .getProviderFactory(TestProvider.class, componentModel.getProviderId()); - TestProviderFactory factory = (TestProviderFactory) f; - TestProvider p = (TestProvider) factory.create(session, componentModel); - return p.getDetails(); + .getProviderFactory(TestProvider.class, componentModel.getProviderId()); + TestProviderFactory factory = (TestProviderFactory) f; + TestProvider p = (TestProvider) factory.create(session, componentModel); + return p.getDetails(); })); } @@ -716,11 +718,11 @@ public class TestingResourceProvider implements RealmResourceProvider { RealmModel realm = session.getContext().getRealm(); return realm.getComponentsStream(realm.getId(), TestAmphibianProvider.class.getName()) .collect(Collectors.toMap( - ComponentModel::getName, - componentModel -> { - TestAmphibianProvider t = session.getComponentProvider(TestAmphibianProvider.class, componentModel.getId()); - return t == null ? null : t.getDetails(); - })); + ComponentModel::getName, + componentModel -> { + TestAmphibianProvider t = session.getComponentProvider(TestAmphibianProvider.class, componentModel.getId()); + return t == null ? null : t.getDetails(); + })); } @@ -798,13 +800,13 @@ public class TestingResourceProvider implements RealmResourceProvider { } ClientScopeModel clientScopeModel = realm.addClientScope(clientId); - clientScopeModel.setProtocol(serviceClient.getProtocol()==null ? OIDCLoginProtocol.LOGIN_PROTOCOL : serviceClient.getProtocol()); + clientScopeModel.setProtocol(serviceClient.getProtocol() == null ? OIDCLoginProtocol.LOGIN_PROTOCOL : serviceClient.getProtocol()); clientScopeModel.setDisplayOnConsentScreen(true); clientScopeModel.setConsentScreenText(clientId); clientScopeModel.setIncludeInTokenScope(true); // Add audience protocol mapper - ProtocolMapperModel audienceMapper = AudienceProtocolMapper.createClaimMapper("Audience for " + clientId, clientId, null,true, false, true ); + ProtocolMapperModel audienceMapper = AudienceProtocolMapper.createClaimMapper("Audience for " + clientId, clientId, null, true, false, true); clientScopeModel.addProtocolMapper(audienceMapper); return clientScopeModel.getId(); @@ -1015,7 +1017,7 @@ public class TestingResourceProvider implements RealmResourceProvider { @Path("/reinitialize-provider-factory-with-system-properties-scope") @Consumes(MediaType.TEXT_HTML_UTF_8) public void reinitializeProviderFactoryWithSystemPropertiesScope(@QueryParam("provider-type") String providerType, @QueryParam("provider-id") String providerId, - @QueryParam("system-properties-prefix") String systemPropertiesPrefix) throws Exception { + @QueryParam("system-properties-prefix") String systemPropertiesPrefix) throws Exception { Class providerClass = (Class) Class.forName(providerType); ProviderFactory factory = session.getKeycloakSessionFactory().getProviderFactory(providerClass, providerId); factory.init(new Config.SystemPropertiesScope(systemPropertiesPrefix)); @@ -1024,10 +1026,10 @@ public class TestingResourceProvider implements RealmResourceProvider { /** * This will send POST request to specified URL with specified form parameters. It's not easily possible to "trick" web driver to send POST * request with custom parameters, which are not directly available in the form. - * + *

* See URLUtils.sendPOSTWithWebDriver for more details * - * @param postRequestUrl Absolute URL. It can include query parameters etc. The POST request will be send to this URL + * @param postRequestUrl Absolute URL. It can include query parameters etc. The POST request will be send to this URL * @param encodedFormParameters Encoded parameters in the form of "param1=value1¶m2=value2" * @return */ @@ -1035,7 +1037,7 @@ public class TestingResourceProvider implements RealmResourceProvider { @Path("/simulate-post-request") @Produces(MediaType.TEXT_HTML_UTF_8) public Response simulatePostRequest(@QueryParam("postRequestUrl") String postRequestUrl, - @QueryParam("encodedFormParameters") String encodedFormParameters) { + @QueryParam("encodedFormParameters") String encodedFormParameters) { Map params = new HashMap<>(); // Parse parameters to use in the POST request @@ -1178,4 +1180,19 @@ public class TestingResourceProvider implements RealmResourceProvider { public Response getBlankPage() { return Response.ok("").build(); } + + @GET + @Path("/pre-authorized-code") + @NoCache + public String getPreAuthorizedCode(@QueryParam("realm") final String realmName, @QueryParam("userSessionId") final String userSessionId, @QueryParam("clientId") final String clientId, @QueryParam("expiration") final int expiration) { + RealmModel realm = getRealmByName(realmName); + AuthenticatedClientSessionModel ascm = session.sessions() + .getUserSession(realm, userSessionId) + .getAuthenticatedClientSessions() + .values() + .stream().filter(acsm -> acsm.getClient().getClientId().equals(clientId)) + .findFirst() + .orElseThrow(() -> new RuntimeException("No authenticatedClientSession found.")); + return PreAuthorizedCodeGrantType.getPreAuthorizedCode(session, ascm, expiration); + } } diff --git a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/runonserver/RunOnServer.java b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/runonserver/RunOnServer.java index 86be999326..3edb85e788 100644 --- a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/runonserver/RunOnServer.java +++ b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/runonserver/RunOnServer.java @@ -17,6 +17,7 @@ package org.keycloak.testsuite.runonserver; +import org.keycloak.common.VerificationException; import org.keycloak.models.KeycloakSession; import java.io.IOException; @@ -27,6 +28,6 @@ import java.io.Serializable; */ public interface RunOnServer extends Serializable { - void run(KeycloakSession session) throws IOException; + void run(KeycloakSession session) throws IOException, VerificationException; } diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/client/resources/TestingResource.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/client/resources/TestingResource.java index 9fe27e9eb5..6714fb5b2e 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/client/resources/TestingResource.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/client/resources/TestingResource.java @@ -95,7 +95,7 @@ public interface TestingResource { /** * Query events - * + *

* Returns all events, or filters them based on URL query parameters listed here * * @param realmId The realm @@ -114,9 +114,9 @@ public interface TestingResource { @NoCache @Produces(MediaType.APPLICATION_JSON) public List queryEvents(@QueryParam("realmId") String realmId, @QueryParam("type") List types, @QueryParam("client") String client, - @QueryParam("user") String user, @QueryParam("dateFrom") String dateFrom, @QueryParam("dateTo") String dateTo, - @QueryParam("ipAddress") String ipAddress, @QueryParam("first") Integer firstResult, - @QueryParam("max") Integer maxResults); + @QueryParam("user") String user, @QueryParam("dateFrom") String dateFrom, @QueryParam("dateTo") String dateTo, + @QueryParam("ipAddress") String ipAddress, @QueryParam("first") Integer firstResult, + @QueryParam("max") Integer maxResults); @PUT @Path("/on-event") @@ -142,7 +142,7 @@ public interface TestingResource { * @param operationTypes * @param authRealm * @param authClient - * @param authUser user id + * @param authUser user id * @param authIpAddress * @param resourcePath * @param dateFrom @@ -156,10 +156,10 @@ public interface TestingResource { @NoCache @Produces(MediaType.APPLICATION_JSON) public List getAdminEvents(@QueryParam("realmId") String realmId, @QueryParam("operationTypes") List operationTypes, @QueryParam("authRealm") String authRealm, @QueryParam("authClient") String authClient, - @QueryParam("authUser") String authUser, @QueryParam("authIpAddress") String authIpAddress, - @QueryParam("resourcePath") String resourcePath, @QueryParam("dateFrom") String dateFrom, - @QueryParam("dateTo") String dateTo, @QueryParam("first") Integer firstResult, - @QueryParam("max") Integer maxResults); + @QueryParam("authUser") String authUser, @QueryParam("authIpAddress") String authIpAddress, + @QueryParam("resourcePath") String resourcePath, @QueryParam("dateFrom") String dateFrom, + @QueryParam("dateTo") String dateTo, @QueryParam("first") Integer firstResult, + @QueryParam("max") Integer maxResults); @POST @Path("/on-admin-event") @@ -369,12 +369,12 @@ public interface TestingResource { /** * Re-initialize specified provider factory with system properties scope. This will allow to change providerConfig in runtime with {@link #setSystemPropertyOnServer} - * + *

* This works just for the provider factories, which can be re-initialized without any side-effects (EG. some functionality already dependent * on the previously initialized properties, which cannot be easily changed in runtime) * - * @param providerType fully qualified class name of provider (subclass of org.keycloak.provider.Provider) - * @param providerId provider Id + * @param providerType fully qualified class name of provider (subclass of org.keycloak.provider.Provider) + * @param providerId provider Id * @param systemPropertiesPrefix prefix to be used for system properties */ @GET @@ -387,14 +387,14 @@ public interface TestingResource { /** * This method is here just to have all endpoints from TestingResourceProvider available here. - * + *

* But usually it is requested to call this endpoint through WebDriver. See URLUtils.sendPOSTWithWebDriver for more details */ @GET @Path("/simulate-post-request") @Produces(MediaType.TEXT_HTML_UTF_8) Response simulatePostRequest(@QueryParam("postRequestUrl") String postRequestUrl, - @QueryParam("encodedFormParameters") String encodedFormParameters); + @QueryParam("encodedFormParameters") String encodedFormParameters); /** * Display message to Error Page - for testing purposes @@ -408,7 +408,7 @@ public interface TestingResource { /** * @param providerClass Full name of class such as for example "org.keycloak.authentication.Authenticator" - * @param providerId providerId referenced in particular provider factory. Can be null (in this case we're returning default provider for particular providerClass) + * @param providerId providerId referenced in particular provider factory. Can be null (in this case we're returning default provider for particular providerClass) * @return fullname of provider implementation class */ @GET @@ -426,6 +426,7 @@ public interface TestingResource { /** * Temporarily changes the truststore SPI with another hostname verification policy. Call reenableTruststoreSpi to revert. + * * @param hostnamePolicy The hostname verification policy to set */ @GET @@ -444,7 +445,7 @@ public interface TestingResource { /** * Get count of tabs (child authentication sessions) for given "root authentication session" * - * @param realm realm name (not ID) + * @param realm realm name (not ID) * @param authSessionId ID of authentication session * @return count of tabs. Return 0 if authentication session of given ID does not exists (or if it exists, but without any authenticationSessions attached, which should not happen with normal usage) */ @@ -456,4 +457,17 @@ public interface TestingResource { @GET @Path("/no-cache-annotated-endpoint") Response getNoCacheAnnotatedEndpointResponse(@QueryParam("programmatic_max_age_value") Long programmaticMaxAgeValue); + + /** + * Return a pre-authorized code for the current session. + * + * @param realmName name of the realm to be used + * @param userSessionId id of the user session to get a code for + * @param clientId id of the client to be used + * @param expiration expiration time of the code + * @return the code + */ + @GET + @Path("/pre-authorized-code") + String getPreAuthorizedCode(@QueryParam("realm") final String realmName, @QueryParam("userSessionId") final String userSessionId, @QueryParam("clientId") final String clientId, @QueryParam("expiration") final int expiration); } diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/OAuthClient.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/OAuthClient.java index 4fcd2b07f9..703d76dbe7 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/OAuthClient.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/OAuthClient.java @@ -18,6 +18,9 @@ package org.keycloak.testsuite.util; import com.google.common.base.Charsets; +import jakarta.ws.rs.client.Entity; +import jakarta.ws.rs.core.Form; +import jakarta.ws.rs.core.UriBuilder; import org.apache.commons.io.IOUtils; import org.apache.commons.io.output.ByteArrayOutputStream; import org.apache.http.Header; @@ -52,12 +55,13 @@ import org.keycloak.crypto.SignatureSignerContext; import org.keycloak.jose.jwk.JSONWebKeySet; import org.keycloak.jose.jwk.JWK; import org.keycloak.jose.jwk.JWKParser; -import org.keycloak.jose.jws.JWSInput; import org.keycloak.jose.jwk.OKPPublicJWK; +import org.keycloak.jose.jws.JWSInput; import org.keycloak.models.Constants; import org.keycloak.models.utils.KeycloakModelUtils; import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.protocol.oidc.OIDCLoginProtocolService; +import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantTypeFactory; import org.keycloak.protocol.oidc.grants.ciba.CibaGrantType; import org.keycloak.protocol.oidc.grants.ciba.channel.AuthenticationChannelResponse; import org.keycloak.protocol.oidc.par.endpoints.ParEndpoint; @@ -82,9 +86,6 @@ import org.openqa.selenium.By; import org.openqa.selenium.WebDriver; import org.openqa.selenium.WebElement; -import jakarta.ws.rs.client.Entity; -import jakarta.ws.rs.core.Form; -import jakarta.ws.rs.core.UriBuilder; import java.io.IOException; import java.io.UnsupportedEncodingException; import java.net.URI; @@ -246,14 +247,14 @@ public class OAuthClient { public LogoutUrlBuilder uiLocales(String uiLocales) { if (uiLocales != null) { - b.queryParam(OIDCLoginProtocol.UI_LOCALES_PARAM, uiLocales); + b.queryParam(OIDCLoginProtocol.UI_LOCALES_PARAM, uiLocales); } return this; } public LogoutUrlBuilder initiatingIdp(String initiatingIdp) { if (initiatingIdp != null) { - b.queryParam(AuthenticationManager.INITIATING_IDP_PARAM, initiatingIdp); + b.queryParam(AuthenticationManager.INITIATING_IDP_PARAM, initiatingIdp); } return this; } @@ -440,7 +441,7 @@ public class OAuthClient { } public static CloseableHttpClient newCloseableHttpClientSSL(String keyStorePath, - String keyStorePassword, String trustStorePath, String trustStorePassword) { + String keyStorePassword, String trustStorePath, String trustStorePassword) { KeyStore keystore = null; // load the keystore containing the client certificate - keystore type is probably jks or pkcs12 try { @@ -478,7 +479,7 @@ public class OAuthClient { public AccessTokenResponse doAccessTokenRequest(String code, String password) { try (CloseableHttpClient client = httpClient.get()) { return doAccessTokenRequest(code, password, client); - } catch (IOException ioe) { + } catch (IOException ioe) { throw new RuntimeException(ioe); } } @@ -537,7 +538,7 @@ public class OAuthClient { public String introspectTokenWithClientCredential(String clientId, String clientSecret, String tokenType, String tokenToIntrospect) { try (CloseableHttpClient client = HttpClientBuilder.create().build()) { return introspectTokenWithClientCredential(clientId, clientSecret, tokenType, tokenToIntrospect, client); - } catch (IOException ioe) { + } catch (IOException ioe) { throw new RuntimeException(ioe); } } @@ -592,7 +593,7 @@ public class OAuthClient { return doGrantAccessTokenRequest(realm, username, password, null, clientId, clientSecret); } - public AccessTokenResponse doGrantAccessTokenRequest(String clientSecret, String username, String password, String otp) throws Exception { + public AccessTokenResponse doGrantAccessTokenRequest(String clientSecret, String username, String password, String otp) throws Exception { return doGrantAccessTokenRequest(realm, username, password, otp, clientId, clientSecret); } @@ -740,7 +741,7 @@ public class OAuthClient { } - UrlEncodedFormEntity formEntity; + UrlEncodedFormEntity formEntity; try { formEntity = new UrlEncodedFormEntity(parameters, "UTF-8"); } catch (UnsupportedEncodingException e) { @@ -790,6 +791,27 @@ public class OAuthClient { } } + public AccessTokenResponse doPreauthorizedTokenRequest(String preAuthorizedCode) throws Exception { + try (CloseableHttpClient client = httpClient.get()) { + HttpPost post = new HttpPost(getAccessTokenUrl()); + + List parameters = new LinkedList<>(); + parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, PreAuthorizedCodeGrantTypeFactory.GRANT_TYPE)); + parameters.add(new BasicNameValuePair("code", preAuthorizedCode)); + UrlEncodedFormEntity formEntity; + try { + formEntity = new UrlEncodedFormEntity(parameters, "UTF-8"); + } catch (UnsupportedEncodingException e) { + throw new RuntimeException(e); + } + post.setEntity(formEntity); + + return new AccessTokenResponse(client.execute(post)); + } catch (IOException ioe) { + throw new RuntimeException(ioe); + } + } + public AuthenticationRequestAcknowledgement doBackchannelAuthenticationRequest(String clientId, String clientSecret, String userid, String bindingMessage, String acrValues) throws Exception { return doBackchannelAuthenticationRequest(clientId, clientSecret, userid, bindingMessage, acrValues, null, null); } @@ -805,7 +827,8 @@ public class OAuthClient { if (userid != null) parameters.add(new BasicNameValuePair(LOGIN_HINT_PARAM, userid)); if (bindingMessage != null) parameters.add(new BasicNameValuePair(BINDING_MESSAGE, bindingMessage)); if (acrValues != null) parameters.add(new BasicNameValuePair(OAuth2Constants.ACR_VALUES, acrValues)); - if (clientNotificationToken != null) parameters.add(new BasicNameValuePair(CibaGrantType.CLIENT_NOTIFICATION_TOKEN, clientNotificationToken)); + if (clientNotificationToken != null) + parameters.add(new BasicNameValuePair(CibaGrantType.CLIENT_NOTIFICATION_TOKEN, clientNotificationToken)); if (scope != null) { parameters.add(new BasicNameValuePair(OAuth2Constants.SCOPE, OAuth2Constants.SCOPE_OPENID + " " + scope)); } else { @@ -909,7 +932,7 @@ public class OAuthClient { post.addHeader("Origin", origin); } - UrlEncodedFormEntity formEntity; + UrlEncodedFormEntity formEntity; try { formEntity = new UrlEncodedFormEntity(parameters, "UTF-8"); } catch (UnsupportedEncodingException e) { @@ -955,7 +978,7 @@ public class OAuthClient { } public CloseableHttpResponse doTokenRevoke(String token, String tokenTypeHint, String clientSecret, - CloseableHttpClient client) throws IOException { + CloseableHttpClient client) throws IOException { HttpPost post = new HttpPost(getTokenRevocationUrl()); List parameters = new LinkedList<>(); @@ -1167,11 +1190,13 @@ public class OAuthClient { } public ParResponse doPushedAuthorizationRequest(String clientId, String clientSecret) throws IOException { - return doPushedAuthorizationRequest(clientId, clientSecret, (CloseableHttpResponse c)->{}, null); + return doPushedAuthorizationRequest(clientId, clientSecret, (CloseableHttpResponse c) -> { + }, null); } public ParResponse doPushedAuthorizationRequest(String clientId, String clientSecret, String signedJwt) throws IOException { - return doPushedAuthorizationRequest(clientId, clientSecret, (CloseableHttpResponse c)->{}, signedJwt); + return doPushedAuthorizationRequest(clientId, clientSecret, (CloseableHttpResponse c) -> { + }, signedJwt); } public ParResponse doPushedAuthorizationRequest(String clientId, String clientSecret, Consumer c) throws IOException { @@ -1217,10 +1242,10 @@ public class OAuthClient { //if (state != null) { // parameters.add(new BasicNameValuePair(OAuth2Constants.STATE, state)); //} - if (uiLocales != null){ + if (uiLocales != null) { parameters.add(new BasicNameValuePair(OAuth2Constants.UI_LOCALES_PARAM, uiLocales)); } - if (nonce != null){ + if (nonce != null) { parameters.add(new BasicNameValuePair(OIDCLoginProtocol.NONCE_PARAM, nonce)); } String scopeParam = openid ? TokenUtil.attachOIDCScope(scope) : scope; @@ -1469,7 +1494,7 @@ public class OAuthClient { if (idTokenHint != null) { b.queryParam(OAuth2Constants.ID_TOKEN_HINT, idTokenHint); } - if(initiatingIDP != null) { + if (initiatingIDP != null) { b.queryParam(AuthenticationManager.INITIATING_IDP_PARAM, initiatingIDP); } driver.navigate().to(b.build(realm).toString()); @@ -1521,10 +1546,10 @@ public class OAuthClient { if (state != null) { b.queryParam(OAuth2Constants.STATE, state); } - if (uiLocales != null){ + if (uiLocales != null) { b.queryParam(OAuth2Constants.UI_LOCALES_PARAM, uiLocales); } - if (nonce != null){ + if (nonce != null) { b.queryParam(OIDCLoginProtocol.NONCE_PARAM, nonce); } @@ -1738,7 +1763,7 @@ public class OAuthClient { return this; } - public OAuthClient uiLocales(String uiLocales){ + public OAuthClient uiLocales(String uiLocales) { this.uiLocales = uiLocales; return this; } @@ -1810,14 +1835,17 @@ public class OAuthClient { this.codeVerifier = codeVerifier; return this; } + public OAuthClient codeChallenge(String codeChallenge) { this.codeChallenge = codeChallenge; return this; } + public OAuthClient codeChallengeMethod(String codeChallengeMethod) { this.codeChallengeMethod = codeChallengeMethod; return this; } + public OAuthClient origin(String origin) { this.origin = origin; return this; @@ -1880,7 +1908,7 @@ public class OAuthClient { } else { fragment = "fragment".equals(client.responseMode) || "fragment.jwt".equals(client.responseMode); } - init (client, fragment); + init(client, fragment); } public AuthorizationEndpointResponse(OAuthClient client, boolean fragment) { @@ -1947,6 +1975,7 @@ public class OAuthClient { public String getResponse() { return response; } + public String getIssuer() { return issuer; } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/PreAuthorizedGrantTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/PreAuthorizedGrantTest.java new file mode 100644 index 0000000000..7ae67c7f96 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/PreAuthorizedGrantTest.java @@ -0,0 +1,138 @@ +/* + * Copyright 2024 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.testsuite.oauth; + +import jakarta.ws.rs.core.UriBuilder; +import org.apache.http.HttpStatus; +import org.apache.http.NameValuePair; +import org.apache.http.client.entity.UrlEncodedFormEntity; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClientBuilder; +import org.apache.http.message.BasicNameValuePair; +import org.junit.Before; +import org.junit.Test; +import org.keycloak.OAuth2Constants; +import org.keycloak.common.Profile; +import org.keycloak.common.util.Time; +import org.keycloak.protocol.oidc.OIDCLoginProtocolService; +import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantTypeFactory; +import org.keycloak.representations.idm.RealmRepresentation; +import org.keycloak.representations.idm.UserRepresentation; +import org.keycloak.testsuite.AbstractTestRealmKeycloakTest; +import org.keycloak.testsuite.arquillian.annotation.EnableFeature; +import org.keycloak.testsuite.util.OAuthClient; +import org.keycloak.testsuite.util.UserBuilder; + +import java.util.LinkedList; +import java.util.List; + +import static org.junit.Assert.assertEquals; + +@EnableFeature(value = Profile.Feature.OID4VC_VCI, skipRestart = true) +public class PreAuthorizedGrantTest extends AbstractTestRealmKeycloakTest { + + private CloseableHttpClient httpClient; + + @Before + public void setup() { + httpClient = HttpClientBuilder.create().build(); + } + + @Test + public void testPreAuthorizedGrant() throws Exception { + String userSessionId = getUserSession(); + String preAuthorizedCode = getTestingClient().testing().getPreAuthorizedCode(TEST_REALM_NAME, userSessionId, "test-app", Time.currentTime() + 30); + OAuthClient.AccessTokenResponse accessTokenResponse = postCode(preAuthorizedCode); + + assertEquals("An access token should have successfully been returned.", HttpStatus.SC_OK, accessTokenResponse.getStatusCode()); + assertEquals("The correct session should have been used for the pre-authorized code.", userSessionId, accessTokenResponse.getSessionState()); + } + + @Test + public void testPreAuthorizedGrantExpired() throws Exception { + String userSessionId = getUserSession(); + String preAuthorizedCode = getTestingClient().testing().getPreAuthorizedCode(TEST_REALM_NAME, userSessionId, "test-app", Time.currentTime() - 30); + OAuthClient.AccessTokenResponse accessTokenResponse = postCode(preAuthorizedCode); + assertEquals("An expired code should not get an access token.", HttpStatus.SC_BAD_REQUEST, accessTokenResponse.getStatusCode()); + } + + @Test + public void testPreAuthorizedGrantInvalidCode() throws Exception { + // assure that a session exists. + getUserSession(); + OAuthClient.AccessTokenResponse accessTokenResponse = postCode("invalid-code"); + assertEquals("An invalid code should not get an access token.", HttpStatus.SC_BAD_REQUEST, accessTokenResponse.getStatusCode()); + } + + @Test + public void testPreAuthorizedGrantNoCode() throws Exception { + // assure that a session exists. + getUserSession(); + HttpPost post = new HttpPost(getTokenEndpoint()); + List parameters = new LinkedList<>(); + parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, PreAuthorizedCodeGrantTypeFactory.GRANT_TYPE)); + UrlEncodedFormEntity formEntity = new UrlEncodedFormEntity(parameters, "UTF-8"); + post.setEntity(formEntity); + + OAuthClient.AccessTokenResponse accessTokenResponse = new OAuthClient.AccessTokenResponse(httpClient.execute(post)); + assertEquals("If no code is provided, no access token should be returned.", HttpStatus.SC_BAD_REQUEST, accessTokenResponse.getStatusCode()); + } + + private OAuthClient.AccessTokenResponse postCode(String preAuthorizedCode) throws Exception { + HttpPost post = new HttpPost(getTokenEndpoint()); + List parameters = new LinkedList<>(); + parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, PreAuthorizedCodeGrantTypeFactory.GRANT_TYPE)); + parameters.add(new BasicNameValuePair("code", preAuthorizedCode)); + UrlEncodedFormEntity formEntity = new UrlEncodedFormEntity(parameters, "UTF-8"); + post.setEntity(formEntity); + + return new OAuthClient.AccessTokenResponse(httpClient.execute(post)); + } + + private String getTokenEndpoint() { + return OIDCLoginProtocolService + .tokenUrl(UriBuilder.fromUri(suiteContext.getAuthServerInfo().getContextRoot().toString() + "/auth")) + .build(TEST_REALM_NAME) + .toString(); + } + + private String getUserSession() { + // create a session + OAuthClient.AuthorizationEndpointResponse authorizationEndpointResponse = oauth.doLogin("john", "password"); + return authorizationEndpointResponse.getSessionState(); + } + + @Override + public void configureTestRealm(RealmRepresentation testRealm) { + UserRepresentation user = UserBuilder.create() + .id("user-id") + .username("john") + .enabled(true) + .email("john@email.cz") + .emailVerified(true) + .password("password").build(); + if (testRealm.getUsers() != null) { + testRealm.getUsers().add(user); + } else { + testRealm.setUsers(List.of(user)); + } + } + + +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/JwtSigningServiceTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/JwtSigningServiceTest.java index 067685f767..efa6dce176 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/JwtSigningServiceTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/JwtSigningServiceTest.java @@ -24,7 +24,6 @@ import org.junit.Test; import org.keycloak.TokenVerifier; import org.keycloak.common.VerificationException; import org.keycloak.common.crypto.CryptoIntegration; -import org.keycloak.common.util.KeyUtils; import org.keycloak.common.util.MultivaluedHashMap; import org.keycloak.crypto.Algorithm; import org.keycloak.crypto.AsymmetricSignatureVerifierContext; @@ -53,7 +52,7 @@ import static org.junit.Assert.assertNotNull; import static org.junit.Assert.fail; -public class JwtSigningServiceTest extends SigningServiceTest { +public class JwtSigningServiceTest extends OID4VCTest { private static final Logger LOGGER = Logger.getLogger(JwtSigningServiceTest.class); @@ -74,9 +73,9 @@ public class JwtSigningServiceTest extends SigningServiceTest { new JwtSigningService( session, getKeyFromSession(session).getKid(), - "did:web:test.org", - "JWT", "unsupported-algorithm", + "JWT", + "did:web:test.org", new StaticTimeProvider(1000))); } catch (RunOnServerException ros) { throw ros.getCause(); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/LDSigningServiceTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/LDSigningServiceTest.java index 12e3b04a7d..cb2e8d2fbd 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/LDSigningServiceTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/LDSigningServiceTest.java @@ -42,7 +42,7 @@ import java.util.UUID; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; -public class LDSigningServiceTest extends SigningServiceTest { +public class LDSigningServiceTest extends OID4VCTest { @Before public void setup() { diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerEndpointTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerEndpointTest.java new file mode 100644 index 0000000000..c29721834a --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerEndpointTest.java @@ -0,0 +1,534 @@ +/* + * Copyright 2024 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.testsuite.oid4vc.issuance.signing; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.ws.rs.BadRequestException; +import jakarta.ws.rs.core.Response; +import org.apache.commons.io.IOUtils; +import org.apache.http.HttpStatus; +import org.apache.http.NameValuePair; +import org.apache.http.client.entity.UrlEncodedFormEntity; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.entity.ContentType; +import org.apache.http.entity.StringEntity; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClientBuilder; +import org.apache.http.message.BasicNameValuePair; +import org.junit.Before; +import org.junit.Test; +import org.keycloak.OAuth2Constants; +import org.keycloak.TokenVerifier; +import org.keycloak.common.VerificationException; +import org.keycloak.common.crypto.CryptoIntegration; +import org.keycloak.common.util.MultivaluedHashMap; +import org.keycloak.common.util.SecretGenerator; +import org.keycloak.crypto.Algorithm; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.UserSessionModel; +import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint; +import org.keycloak.protocol.oid4vc.issuance.TimeProvider; +import org.keycloak.protocol.oid4vc.issuance.signing.JwtSigningService; +import org.keycloak.protocol.oid4vc.model.CredentialIssuer; +import org.keycloak.protocol.oid4vc.model.CredentialOfferURI; +import org.keycloak.protocol.oid4vc.model.CredentialRequest; +import org.keycloak.protocol.oid4vc.model.CredentialResponse; +import org.keycloak.protocol.oid4vc.model.CredentialsOffer; +import org.keycloak.protocol.oid4vc.model.Format; +import org.keycloak.protocol.oid4vc.model.PreAuthorizedGrant; +import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration; +import org.keycloak.protocol.oid4vc.model.VerifiableCredential; +import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantTypeFactory; +import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation; +import org.keycloak.representations.JsonWebToken; +import org.keycloak.representations.idm.ClientRepresentation; +import org.keycloak.representations.idm.RealmRepresentation; +import org.keycloak.services.managers.AppAuthManager; +import org.keycloak.services.managers.AuthenticationManager; +import org.keycloak.testsuite.runonserver.RunOnServerException; +import org.keycloak.testsuite.util.OAuthClient; +import org.keycloak.util.JsonSerialization; + +import java.io.IOException; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +public class OID4VCIssuerEndpointTest extends OID4VCTest { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final TimeProvider TIME_PROVIDER = new OID4VCTest.StaticTimeProvider(1000); + private CloseableHttpClient httpClient; + + + @Before + public void setup() { + CryptoIntegration.init(this.getClass().getClassLoader()); + httpClient = HttpClientBuilder.create().build(); + } + + + // ----- getCredentialOfferUri + + @Test(expected = BadRequestException.class) + public void testGetCredentialOfferUriUnsupportedCredential() throws Throwable { + String token = getBearerToken(oauth); + withCausePropagation(() -> testingClient.server(TEST_REALM_NAME) + .run((session -> { + AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); + authenticator.setTokenString(token); + + OID4VCIssuerEndpoint oid4VCIssuerEndpoint = prepareIssuerEndpoint(session, authenticator); + oid4VCIssuerEndpoint.getCredentialOfferURI("inexistent-id"); + }))); + + } + + @Test(expected = BadRequestException.class) + public void testGetCredentialOfferUriUnauthorized() throws Throwable { + withCausePropagation(() -> testingClient.server(TEST_REALM_NAME) + .run((session -> { + AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); + authenticator.setTokenString(null); + OID4VCIssuerEndpoint oid4VCIssuerEndpoint = prepareIssuerEndpoint(session, authenticator); + oid4VCIssuerEndpoint.getCredentialOfferURI("test-credential"); + }))); + } + + @Test(expected = BadRequestException.class) + public void testGetCredentialOfferUriInvalidToken() throws Throwable { + withCausePropagation(() -> testingClient.server(TEST_REALM_NAME) + .run((session -> { + AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); + authenticator.setTokenString("invalid-token"); + OID4VCIssuerEndpoint oid4VCIssuerEndpoint = prepareIssuerEndpoint(session, authenticator); + oid4VCIssuerEndpoint.getCredentialOfferURI("test-credential"); + }))); + } + + @Test + public void testGetCredentialOfferURI() { + String token = getBearerToken(oauth); + testingClient + .server(TEST_REALM_NAME) + .run((session) -> { + try { + AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); + authenticator.setTokenString(token); + OID4VCIssuerEndpoint oid4VCIssuerEndpoint = prepareIssuerEndpoint(session, authenticator); + + Response response = oid4VCIssuerEndpoint.getCredentialOfferURI("test-credential"); + + assertEquals("An offer uri should have been returned.", HttpStatus.SC_OK, response.getStatus()); + CredentialOfferURI credentialOfferURI = new ObjectMapper().convertValue(response.getEntity(), CredentialOfferURI.class); + assertNotNull("A nonce should be included.", credentialOfferURI.getNonce()); + assertNotNull("The issuer uri should be provided.", credentialOfferURI.getIssuer()); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + + } + + private static String getBearerToken(OAuthClient oAuthClient) { + OAuthClient.AuthorizationEndpointResponse authorizationEndpointResponse = oAuthClient.doLogin("john", "password"); + return oAuthClient.doAccessTokenRequest(authorizationEndpointResponse.getCode(), "password").getAccessToken(); + } + + // ----- getCredentialOffer + + @Test(expected = BadRequestException.class) + public void testGetCredentialOfferUnauthorized() throws Throwable { + withCausePropagation(() -> { + testingClient + .server(TEST_REALM_NAME) + .run((session) -> { + AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); + authenticator.setTokenString(null); + OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); + issuerEndpoint.getCredentialOffer("nonce"); + }); + }); + } + + @Test(expected = BadRequestException.class) + public void testGetCredentialOfferWithoutNonce() throws Throwable { + String token = getBearerToken(oauth); + withCausePropagation(() -> { + testingClient + .server(TEST_REALM_NAME) + .run((session -> { + AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); + authenticator.setTokenString(token); + OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); + issuerEndpoint.getCredentialOffer(null); + })); + }); + } + + @Test(expected = BadRequestException.class) + public void testGetCredentialOfferWithoutAPreparedOffer() throws Throwable { + String token = getBearerToken(oauth); + withCausePropagation(() -> { + testingClient + .server(TEST_REALM_NAME) + .run((session -> { + AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); + authenticator.setTokenString(token); + OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); + issuerEndpoint.getCredentialOffer("unpreparedNonce"); + })); + }); + } + + @Test(expected = BadRequestException.class) + public void testGetCredentialOfferWithABrokenNote() throws Throwable { + String token = getBearerToken(oauth); + withCausePropagation(() -> { + testingClient + .server(TEST_REALM_NAME) + .run((session -> { + AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); + authenticator.setTokenString(token); + String nonce = prepareNonce(authenticator, "invalidNote"); + OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); + issuerEndpoint.getCredentialOffer(nonce); + })); + }); + } + + @Test + public void testGetCredentialOffer() { + String token = getBearerToken(oauth); + String rootURL = suiteContext.getAuthServerInfo().getContextRoot().toString(); + testingClient + .server(TEST_REALM_NAME) + .run((session) -> { + AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); + authenticator.setTokenString(token); + + SupportedCredentialConfiguration supportedCredentialConfiguration = new SupportedCredentialConfiguration() + .setId("test-credential") + .setScope("VerifiableCredential") + .setFormat(Format.JWT_VC); + String nonce = prepareNonce(authenticator, OBJECT_MAPPER.writeValueAsString(supportedCredentialConfiguration)); + + OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); + Response credentialOfferResponse = issuerEndpoint.getCredentialOffer(nonce); + assertEquals("The offer should have been returned.", HttpStatus.SC_OK, credentialOfferResponse.getStatus()); + Object credentialOfferEntity = credentialOfferResponse.getEntity(); + assertNotNull("An actual offer should be in the response.", credentialOfferEntity); + + CredentialsOffer credentialsOffer = OBJECT_MAPPER.convertValue(credentialOfferEntity, CredentialsOffer.class); + assertNotNull("Credentials should have been offered.", credentialsOffer.getCredentialConfigurationIds()); + assertFalse("Credentials should have been offered.", credentialsOffer.getCredentialConfigurationIds().isEmpty()); + List supportedCredentials = credentialsOffer.getCredentialConfigurationIds(); + assertEquals("Exactly one credential should have been returned.", 1, supportedCredentials.size()); + String offeredCredentialId = supportedCredentials.get(0); + assertEquals("The credential should be as defined in the note.", supportedCredentialConfiguration.getId(), offeredCredentialId); + + PreAuthorizedGrant grant = credentialsOffer.getGrants(); + assertNotNull("The grant should be included.", grant); + assertNotNull("The grant should contain the pre-authorized code.", grant.getPreAuthorizedCode()); + assertNotNull("The actual pre-authorized code should be included.", grant + .getPreAuthorizedCode() + .getPreAuthorizedCode()); + + assertEquals("The correct issuer should be included.", rootURL + "/auth/realms/" + TEST_REALM_NAME, credentialsOffer.getCredentialIssuer()); + }); + } + + // ----- requestCredential + + @Test(expected = BadRequestException.class) + public void testRequestCredentialUnauthorized() throws Throwable { + withCausePropagation(() -> { + testingClient + .server(TEST_REALM_NAME) + .run((session -> { + AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); + authenticator.setTokenString(null); + OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); + issuerEndpoint.requestCredential(new CredentialRequest() + .setFormat(Format.JWT_VC) + .setCredentialIdentifier("test-credential")); + })); + }); + } + + @Test(expected = BadRequestException.class) + public void testRequestCredentialInvalidToken() throws Throwable { + withCausePropagation(() -> { + testingClient + .server(TEST_REALM_NAME) + .run((session -> { + AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); + authenticator.setTokenString("token"); + OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); + issuerEndpoint.requestCredential(new CredentialRequest() + .setFormat(Format.JWT_VC) + .setCredentialIdentifier("test-credential")); + })); + }); + } + + @Test(expected = BadRequestException.class) + public void testRequestCredentialUnsupportedFormat() throws Throwable { + String token = getBearerToken(oauth); + withCausePropagation(() -> { + testingClient + .server(TEST_REALM_NAME) + .run((session -> { + AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); + authenticator.setTokenString(token); + OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); + issuerEndpoint.requestCredential(new CredentialRequest() + .setFormat(Format.SD_JWT_VC) + .setCredentialIdentifier("test-credential")); + })); + }); + } + + @Test(expected = BadRequestException.class) + public void testRequestCredentialUnsupportedCredential() throws Throwable { + String token = getBearerToken(oauth); + withCausePropagation(() -> { + testingClient + .server(TEST_REALM_NAME) + .run((session -> { + AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); + authenticator.setTokenString(token); + OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); + issuerEndpoint.requestCredential(new CredentialRequest() + .setFormat(Format.JWT_VC) + .setCredentialIdentifier("no-such-credential")); + })); + }); + } + + @Test + public void testRequestCredential() { + String token = getBearerToken(oauth); + testingClient + .server(TEST_REALM_NAME) + .run((session -> { + AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); + authenticator.setTokenString(token); + OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); + CredentialRequest credentialRequest = new CredentialRequest() + .setFormat(Format.JWT_VC) + .setCredentialIdentifier("test-credential"); + Response credentialResponse = issuerEndpoint.requestCredential(credentialRequest); + assertEquals("The credential request should be answered successfully.", HttpStatus.SC_OK, credentialResponse.getStatus()); + assertNotNull("A credential should be responded.", credentialResponse.getEntity()); + CredentialResponse credentialResponseVO = OBJECT_MAPPER.convertValue(credentialResponse.getEntity(), CredentialResponse.class); + JsonWebToken jsonWebToken = TokenVerifier.create((String) credentialResponseVO.getCredential(), JsonWebToken.class).getToken(); + // correct signing and contents are verified in the JwtSigningServiceTest, thus we only check that it is a JWT + assertNotNull("A valid credential string should have been responded", jsonWebToken); + })); + } + + + // Tests the complete flow from + // 1. Retrieving the credential-offer-uri + // 2. Using the uri to get the actual credential offer + // 3. Get the issuer metadata + // 4. Get the openid-configuration + // 5. Get an access token for the pre-authorized code + // 6. Get the credential + @Test + public void testCredentialIssuance() throws Exception { + + String token = getBearerToken(oauth); + + // 1. Retrieving the credential-offer-uri + HttpGet getCredentialOfferURI = new HttpGet(getBasePath(TEST_REALM_NAME) + "credential-offer-uri?credential_configuration_id=test-credential"); + getCredentialOfferURI.addHeader("Authorization", "Bearer " + token); + CloseableHttpResponse credentialOfferURIResponse = httpClient.execute(getCredentialOfferURI); + + assertEquals("A valid offer uri should be returned", HttpStatus.SC_OK, credentialOfferURIResponse.getStatusLine().getStatusCode()); + String s = IOUtils.toString(credentialOfferURIResponse.getEntity().getContent(), StandardCharsets.UTF_8); + CredentialOfferURI credentialOfferURI = JsonSerialization.readValue(s, CredentialOfferURI.class); + + // 2. Using the uri to get the actual credential offer + HttpGet getCredentialOffer = new HttpGet(credentialOfferURI.getIssuer() + "/" + credentialOfferURI.getNonce()); + getCredentialOffer.addHeader("Authorization", "Bearer " + token); + CloseableHttpResponse credentialOfferResponse = httpClient.execute(getCredentialOffer); + + assertEquals("A valid offer should be returned", HttpStatus.SC_OK, credentialOfferResponse.getStatusLine().getStatusCode()); + s = IOUtils.toString(credentialOfferResponse.getEntity().getContent(), StandardCharsets.UTF_8); + CredentialsOffer credentialsOffer = JsonSerialization.readValue(s, CredentialsOffer.class); + + // 3. Get the issuer metadata + HttpGet getIssuerMetadata = new HttpGet(credentialsOffer.getCredentialIssuer() + "/.well-known/openid-credential-issuer"); + CloseableHttpResponse issuerMetadataResponse = httpClient.execute(getIssuerMetadata); + assertEquals(HttpStatus.SC_OK, issuerMetadataResponse.getStatusLine().getStatusCode()); + s = IOUtils.toString(issuerMetadataResponse.getEntity().getContent(), StandardCharsets.UTF_8); + CredentialIssuer credentialIssuer = JsonSerialization.readValue(s, CredentialIssuer.class); + + assertEquals("We only expect one authorization server.", 1, credentialIssuer.getAuthorizationServers().size()); + + // 4. Get the openid-configuration + HttpGet getOpenidConfiguration = new HttpGet(credentialIssuer.getAuthorizationServers().get(0) + "/.well-known/openid-configuration"); + CloseableHttpResponse openidConfigResponse = httpClient.execute(getOpenidConfiguration); + assertEquals(HttpStatus.SC_OK, openidConfigResponse.getStatusLine().getStatusCode()); + s = IOUtils.toString(openidConfigResponse.getEntity().getContent(), StandardCharsets.UTF_8); + OIDCConfigurationRepresentation openidConfig = JsonSerialization.readValue(s, OIDCConfigurationRepresentation.class); + + assertNotNull("A token endpoint should be included.", openidConfig.getTokenEndpoint()); + assertTrue("The pre-authorized code should be supported.", openidConfig.getGrantTypesSupported().contains(PreAuthorizedCodeGrantTypeFactory.GRANT_TYPE)); + + // 5. Get an access token for the pre-authorized code + HttpPost postPreAuthorizedCode = new HttpPost(openidConfig.getTokenEndpoint()); + List parameters = new LinkedList<>(); + parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, PreAuthorizedCodeGrantTypeFactory.GRANT_TYPE)); + parameters.add(new BasicNameValuePair("code", credentialsOffer.getGrants().getPreAuthorizedCode().getPreAuthorizedCode())); + UrlEncodedFormEntity formEntity = new UrlEncodedFormEntity(parameters, "UTF-8"); + postPreAuthorizedCode.setEntity(formEntity); + OAuthClient.AccessTokenResponse accessTokenResponse = new OAuthClient.AccessTokenResponse(httpClient.execute(postPreAuthorizedCode)); + assertEquals(HttpStatus.SC_OK, accessTokenResponse.getStatusCode()); + String theToken = accessTokenResponse.getAccessToken(); + + // 6. Get the credential + credentialsOffer.getCredentialConfigurationIds().stream() + .map(offeredCredentialId -> credentialIssuer.getCredentialsSupported().get(offeredCredentialId)) + .forEach(supportedCredential -> { + try { + requestOffer(theToken, credentialIssuer.getCredentialEndpoint(), supportedCredential); + } catch (IOException e) { + fail("Was not able to get the credential."); + } catch (VerificationException e) { + throw new RuntimeException(e); + } + }); + } + + + private static String prepareNonce(AppAuthManager.BearerTokenAuthenticator authenticator, String note) { + String nonce = SecretGenerator.getInstance().randomString(); + AuthenticationManager.AuthResult authResult = authenticator.authenticate(); + UserSessionModel userSessionModel = authResult.getSession(); + userSessionModel.getAuthenticatedClientSessionByClient(authResult.getClient().getId()).setNote(nonce, note); + return nonce; + } + + private static OID4VCIssuerEndpoint prepareIssuerEndpoint(KeycloakSession session, AppAuthManager.BearerTokenAuthenticator authenticator) { + JwtSigningService jwtSigningService = new JwtSigningService( + session, + getKeyFromSession(session).getKid(), + Algorithm.RS256, + "JWT", + "did:web:issuer.org", + TIME_PROVIDER); + return new OID4VCIssuerEndpoint( + session, + "did:web:issuer.org", + Map.of(Format.JWT_VC, jwtSigningService), + authenticator, + new ObjectMapper(), + TIME_PROVIDER, + 30); + } + + private String getBasePath(String realm) { + return suiteContext.getAuthServerInfo().getContextRoot().toString() + "/auth/realms/" + realm + "/protocol/oid4vc/"; + } + + private void requestOffer(String token, String credentialEndpoint, SupportedCredentialConfiguration offeredCredential) throws IOException, VerificationException { + CredentialRequest request = new CredentialRequest(); + request.setFormat(offeredCredential.getFormat()); + request.setCredentialIdentifier(offeredCredential.getId()); + + StringEntity stringEntity = new StringEntity(OBJECT_MAPPER.writeValueAsString(request), ContentType.APPLICATION_JSON); + + HttpPost postCredential = new HttpPost(credentialEndpoint); + postCredential.addHeader("Authorization", "Bearer " + token); + postCredential.setEntity(stringEntity); + CloseableHttpResponse credentialRequestResponse = httpClient.execute(postCredential); + assertEquals(HttpStatus.SC_OK, credentialRequestResponse.getStatusLine().getStatusCode()); + String s = IOUtils.toString(credentialRequestResponse.getEntity().getContent(), StandardCharsets.UTF_8); + CredentialResponse credentialResponse = JsonSerialization.readValue(s, CredentialResponse.class); + + assertNotNull("The credential should have been responded.", credentialResponse.getCredential()); + JsonWebToken jsonWebToken = TokenVerifier.create((String) credentialResponse.getCredential(), JsonWebToken.class).getToken(); + assertEquals("did:web:test.org", jsonWebToken.getIssuer()); + VerifiableCredential credential = new ObjectMapper().convertValue(jsonWebToken.getOtherClaims().get("vc"), VerifiableCredential.class); + assertEquals(List.of("VerifiableCredential"), credential.getType()); + assertEquals(URI.create("did:web:test.org"), credential.getIssuer()); + assertEquals("john@email.cz", credential.getCredentialSubject().getClaims().get("email")); + } + + @Override + public void configureTestRealm(RealmRepresentation testRealm) { + if (testRealm.getComponents() != null) { + testRealm.getComponents().add("org.keycloak.keys.KeyProvider", getRsaKeyProvider(RSA_KEY)); + testRealm.getComponents().add("org.keycloak.protocol.oid4vc.issuance.signing.VerifiableCredentialsSigningService", getJwtSigningProvider(RSA_KEY)); + } else { + testRealm.setComponents(new MultivaluedHashMap<>( + Map.of("org.keycloak.keys.KeyProvider", List.of(getRsaKeyProvider(RSA_KEY)), + "org.keycloak.protocol.oid4vc.issuance.signing.VerifiableCredentialsSigningService", List.of(getJwtSigningProvider(RSA_KEY)) + ))); + } + ClientRepresentation clientRepresentation = getTestClient("did:web:test.org"); + if (testRealm.getClients() != null) { + testRealm.getClients().add(clientRepresentation); + } else { + testRealm.setClients(List.of(clientRepresentation)); + } + if (testRealm.getRoles() != null) { + testRealm.getRoles().getClient() + .put(clientRepresentation.getClientId(), List.of(getRoleRepresentation("testRole", clientRepresentation.getClientId()))); + } else { + testRealm.getRoles() + .setClient(Map.of(clientRepresentation.getClientId(), List.of(getRoleRepresentation("testRole", clientRepresentation.getClientId())))); + } + if (testRealm.getUsers() != null) { + testRealm.getUsers().add(getUserRepresentation(Map.of(clientRepresentation.getClientId(), List.of("testRole")))); + } else { + testRealm.setUsers(List.of(getUserRepresentation(Map.of(clientRepresentation.getClientId(), List.of("testRole"))))); + } + if (testRealm.getAttributes() != null) { + testRealm.getAttributes().put("issuerDid", TEST_DID.toString()); + } else { + testRealm.setAttributes(Map.of("issuerDid", TEST_DID.toString())); + } + } + + private void withCausePropagation(Runnable r) throws Throwable { + try { + r.run(); + } catch (Exception e) { + if (e instanceof RunOnServerException) { + throw e.getCause(); + } + throw e; + } + } +} + diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerWellKnownProviderTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerWellKnownProviderTest.java new file mode 100644 index 0000000000..2ee6127216 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerWellKnownProviderTest.java @@ -0,0 +1,87 @@ +/* + * Copyright 2024 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.testsuite.oid4vc.issuance.signing; + +import org.junit.Test; +import org.keycloak.common.util.MultivaluedHashMap; +import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerWellKnownProvider; +import org.keycloak.protocol.oid4vc.model.CredentialIssuer; +import org.keycloak.protocol.oid4vc.model.Format; +import org.keycloak.representations.idm.ClientRepresentation; +import org.keycloak.representations.idm.RealmRepresentation; + +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class OID4VCIssuerWellKnownProviderTest extends OID4VCTest { + + @Test + public void getConfig() { + String expectedIssuer = suiteContext.getAuthServerInfo().getContextRoot().toString() + "/auth/realms/" + TEST_REALM_NAME; + String expectedCredentialsEndpoint = expectedIssuer + "/protocol/oid4vc/credential"; + String expectedAuthorizationServer = expectedIssuer; + testingClient + .server(TEST_REALM_NAME) + .run((session -> { + OID4VCIssuerWellKnownProvider oid4VCIssuerWellKnownProvider = new OID4VCIssuerWellKnownProvider(session); + Object issuerConfig = oid4VCIssuerWellKnownProvider.getConfig(); + assertTrue("Valid credential-issuer metadata should be returned.", issuerConfig instanceof CredentialIssuer); + CredentialIssuer credentialIssuer = (CredentialIssuer) issuerConfig; + assertEquals("The correct issuer should be included.", expectedIssuer, credentialIssuer.getCredentialIssuer()); + assertEquals("The correct credentials endpoint should be included.", expectedCredentialsEndpoint, credentialIssuer.getCredentialEndpoint()); + assertEquals("Since the authorization server is equal to the issuer, just 1 should be returned.", 1, credentialIssuer.getAuthorizationServers().size()); + assertEquals("The expected server should have been returned.", expectedAuthorizationServer, credentialIssuer.getAuthorizationServers().get(0)); + assertTrue("The test-credential should be supported.", credentialIssuer.getCredentialsSupported().containsKey("test-credential")); + assertEquals("The test-credential should offer type VerifiableCredential", "VerifiableCredential", credentialIssuer.getCredentialsSupported().get("test-credential").getScope()); + assertEquals("The test-credential should be offered in the jwt-vc format.", Format.JWT_VC, credentialIssuer.getCredentialsSupported().get("test-credential").getFormat()); + })); + } + + + @Override + public void configureTestRealm(RealmRepresentation testRealm) { + if (testRealm.getComponents() != null) { + testRealm.getComponents().add("org.keycloak.keys.KeyProvider", getRsaKeyProvider(RSA_KEY)); + testRealm.getComponents().add("org.keycloak.protocol.oid4vc.issuance.signing.VerifiableCredentialsSigningService", getJwtSigningProvider(RSA_KEY)); + } else { + testRealm.setComponents(new MultivaluedHashMap<>( + Map.of("org.keycloak.keys.KeyProvider", List.of(getRsaKeyProvider(RSA_KEY)), + "org.keycloak.protocol.oid4vc.issuance.signing.VerifiableCredentialsSigningService", List.of(getJwtSigningProvider(RSA_KEY)) + ))); + } + ClientRepresentation clientRepresentation = getTestClient("did:web:test.org"); + if (testRealm.getClients() != null) { + testRealm.getClients().add(clientRepresentation); + } else { + testRealm.setClients(List.of(clientRepresentation)); + } + if (testRealm.getUsers() != null) { + testRealm.getUsers().add(getUserRepresentation(Map.of(clientRepresentation.getClientId(), List.of("testRole")))); + } else { + testRealm.setUsers(List.of(getUserRepresentation(Map.of(clientRepresentation.getClientId(), List.of("testRole"))))); + } + if (testRealm.getAttributes() != null) { + testRealm.getAttributes().put("issuerDid", TEST_DID.toString()); + } else { + testRealm.setAttributes(Map.of("issuerDid", TEST_DID.toString())); + } + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/SigningServiceTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCTest.java similarity index 52% rename from testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/SigningServiceTest.java rename to testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCTest.java index 423be5208a..a953aeb835 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/SigningServiceTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCTest.java @@ -19,6 +19,7 @@ package org.keycloak.testsuite.oid4vc.issuance.signing; import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.jboss.logging.Logger; +import org.keycloak.common.Profile; import org.keycloak.common.util.CertificateUtils; import org.keycloak.common.util.KeyUtils; import org.keycloak.common.util.MultivaluedHashMap; @@ -26,11 +27,20 @@ import org.keycloak.common.util.PemUtils; import org.keycloak.crypto.KeyUse; import org.keycloak.crypto.KeyWrapper; import org.keycloak.models.KeycloakSession; +import org.keycloak.models.utils.KeycloakModelUtils; +import org.keycloak.protocol.oid4vc.OID4VCLoginProtocolFactory; import org.keycloak.protocol.oid4vc.issuance.TimeProvider; import org.keycloak.protocol.oid4vc.model.CredentialSubject; +import org.keycloak.protocol.oid4vc.model.Format; import org.keycloak.protocol.oid4vc.model.VerifiableCredential; +import org.keycloak.representations.idm.ClientRepresentation; import org.keycloak.representations.idm.ComponentExportRepresentation; +import org.keycloak.representations.idm.ProtocolMapperRepresentation; +import org.keycloak.representations.idm.RoleRepresentation; +import org.keycloak.representations.idm.UserRepresentation; import org.keycloak.testsuite.AbstractTestRealmKeycloakTest; +import org.keycloak.testsuite.arquillian.annotation.EnableFeature; +import org.keycloak.testsuite.util.UserBuilder; import java.net.URI; import java.security.KeyPair; @@ -48,16 +58,20 @@ import java.util.Map; import java.util.UUID; /** - * Super class for all signing service tests. Provides convenience methods to ease the testing. + * Super class for all OID4VC tests. Provides convenience methods to ease the testing. */ -public abstract class SigningServiceTest extends AbstractTestRealmKeycloakTest { +@EnableFeature(value = Profile.Feature.OID4VC_VCI, skipRestart = false) +public abstract class OID4VCTest extends AbstractTestRealmKeycloakTest { + private static final Logger LOGGER = Logger.getLogger(OID4VCTest.class); protected static final String CONTEXT_URL = "https://www.w3.org/2018/credentials/v1"; protected static final URI TEST_DID = URI.create("did:web:test.org"); protected static final List TEST_TYPES = List.of("VerifiableCredential"); protected static final Date TEST_EXPIRATION_DATE = Date.from(Instant.ofEpochSecond(2000)); protected static final Date TEST_ISSUANCE_DATE = Date.from(Instant.ofEpochSecond(1000)); + protected static final KeyWrapper RSA_KEY = getRsaKey(); + protected static CredentialSubject getCredentialSubject(Map claims) { CredentialSubject credentialSubject = new CredentialSubject(); claims.forEach(credentialSubject::setClaims); @@ -138,13 +152,12 @@ public abstract class SigningServiceTest extends AbstractTestRealmKeycloakTest { } } - protected ComponentExportRepresentation getRsaKeyProvider(KeyWrapper keyWrapper) { + public static ComponentExportRepresentation getRsaKeyProvider(KeyWrapper keyWrapper) { ComponentExportRepresentation componentExportRepresentation = new ComponentExportRepresentation(); componentExportRepresentation.setName("rsa-key-provider"); componentExportRepresentation.setId(UUID.randomUUID().toString()); componentExportRepresentation.setProviderId("rsa"); - Certificate certificate = CertificateUtils.generateV1SelfSignedCertificate( new KeyPair((PublicKey) keyWrapper.getPublicKey(), (PrivateKey) keyWrapper.getPrivateKey()), "TestKey"); @@ -161,21 +174,32 @@ public abstract class SigningServiceTest extends AbstractTestRealmKeycloakTest { return componentExportRepresentation; } - protected static KeyWrapper getKeyFromSession(KeycloakSession keycloakSession) { - // we only set one key to the realm, thus can just take the first one - // if run inside the testsuite, configure is called separated from the test itself, thus we cannot just take - // the key from the `configureTestRealm` method. - return keycloakSession - .keys() - .getKeysStream(keycloakSession.getContext().getRealm()) - .findFirst() - .orElseThrow(() -> new RuntimeException("No key was configured")); + public static ClientRepresentation getTestClient(String clientId) { + ClientRepresentation clientRepresentation = new ClientRepresentation(); + clientRepresentation.setClientId(clientId); + clientRepresentation.setProtocol(OID4VCLoginProtocolFactory.PROTOCOL_ID); + clientRepresentation.setEnabled(true); + clientRepresentation.setAttributes(Map.of( + "vc.test-credential.expiry_in_s", "100", + "vc.test-credential.format", Format.JWT_VC.toString(), + "vc.test-credential.scope", "VerifiableCredential")); + clientRepresentation.setProtocolMappers( + List.of( + getRoleMapper(clientId), + getEmailMapper(), + getIdMapper(), + getStaticClaimMapper() + ) + ); + return clientRepresentation; } protected ComponentExportRepresentation getEdDSAKeyProvider() { ComponentExportRepresentation componentExportRepresentation = new ComponentExportRepresentation(); componentExportRepresentation.setName("eddsa-generated"); componentExportRepresentation.setId(UUID.randomUUID().toString()); + componentExportRepresentation.setName("eddsa-generated"); + componentExportRepresentation.setId(UUID.randomUUID().toString()); componentExportRepresentation.setProviderId("eddsa-generated"); componentExportRepresentation.setConfig(new MultivaluedHashMap<>( @@ -186,6 +210,123 @@ public abstract class SigningServiceTest extends AbstractTestRealmKeycloakTest { return componentExportRepresentation; } + public static ProtocolMapperRepresentation getRoleMapper(String clientId) { + ProtocolMapperRepresentation protocolMapperRepresentation = new ProtocolMapperRepresentation(); + protocolMapperRepresentation.setName("role-mapper"); + protocolMapperRepresentation.setId(UUID.randomUUID().toString()); + protocolMapperRepresentation.setProtocol("oid4vc"); + protocolMapperRepresentation.setProtocolMapper("oid4vc-target-role-mapper"); + protocolMapperRepresentation.setConfig( + Map.of( + "subjectProperty", "roles", + "clientId", clientId, + "supportedCredentialTypes", "VerifiableCredential") + ); + return protocolMapperRepresentation; + } + + public static ProtocolMapperRepresentation getEmailMapper() { + ProtocolMapperRepresentation protocolMapperRepresentation = new ProtocolMapperRepresentation(); + protocolMapperRepresentation.setName("email-mapper"); + protocolMapperRepresentation.setProtocol("oid4vc"); + protocolMapperRepresentation.setId(UUID.randomUUID().toString()); + protocolMapperRepresentation.setProtocolMapper("oid4vc-user-attribute-mapper"); + protocolMapperRepresentation.setConfig( + Map.of( + "subjectProperty", "email", + "userAttribute", "email", + "supportedCredentialTypes", "VerifiableCredential") + ); + return protocolMapperRepresentation; + } + + public static ProtocolMapperRepresentation getIdMapper() { + ProtocolMapperRepresentation protocolMapperRepresentation = new ProtocolMapperRepresentation(); + protocolMapperRepresentation.setName("id-mapper"); + protocolMapperRepresentation.setProtocol("oid4vc"); + protocolMapperRepresentation.setId(UUID.randomUUID().toString()); + protocolMapperRepresentation.setProtocolMapper("oid4vc-subject-id-mapper"); + protocolMapperRepresentation.setConfig( + Map.of( + "supportedCredentialTypes", "VerifiableCredential") + ); + return protocolMapperRepresentation; + } + + public static ProtocolMapperRepresentation getStaticClaimMapper() { + ProtocolMapperRepresentation protocolMapperRepresentation = new ProtocolMapperRepresentation(); + protocolMapperRepresentation.setName("static-mapper"); + protocolMapperRepresentation.setProtocol("oid4vc"); + protocolMapperRepresentation.setId(UUID.randomUUID().toString()); + protocolMapperRepresentation.setProtocolMapper("oid4vc-static-claim-mapper"); + protocolMapperRepresentation.setConfig( + Map.of( + "subjectProperty", "static", + "subjectValue", "Value", + "supportedCredentialTypes", "VerifiableCredential") + ); + return protocolMapperRepresentation; + } + + public static KeyWrapper getKeyFromSession(KeycloakSession keycloakSession) { + // we only set one key to the realm, thus can just take the first one + // if run inside the testsuite, configure is called separated from the test itself, thus we cannot just take + // the key from the `configureTestRealm` method. + KeyWrapper kw = keycloakSession + .keys() + .getKeysStream(keycloakSession.getContext().getRealm()) + .peek(k -> LOGGER.warnf("THE KEY: %s - %s", k.getKid(), k.getAlgorithm())) + .findFirst() + .orElseThrow(() -> new RuntimeException("No key was configured")); + LOGGER.warnf("Kid is %s", kw.getKid()); + return kw; + } + + public static ComponentExportRepresentation getJwtSigningProvider(KeyWrapper keyWrapper) { + ComponentExportRepresentation componentExportRepresentation = new ComponentExportRepresentation(); + componentExportRepresentation.setName("jwt-signing-service"); + componentExportRepresentation.setId(UUID.randomUUID().toString()); + componentExportRepresentation.setProviderId("jwt_vc"); + + componentExportRepresentation.setConfig(new MultivaluedHashMap<>( + Map.of( + "keyId", List.of(keyWrapper.getKid()), + "algorithmType", List.of(keyWrapper.getAlgorithm()), + "tokenType", List.of("JWT"), + "issuerDid", List.of(TEST_DID.toString()) + ) + )); + return componentExportRepresentation; + } + + public static UserRepresentation getUserRepresentation(Map> clientRoles) { + UserBuilder userBuilder = UserBuilder.create() + .id(KeycloakModelUtils.generateId()) + .username("john") + .enabled(true) + .email("john@email.cz") + .emailVerified(true) + .firstName("John") + .lastName("Doe") + .password("password") + .role("account", "manage-account") + .role("account", "view-profile"); + + clientRoles.entrySet().forEach(entry -> { + entry.getValue().forEach(role -> userBuilder.role(entry.getKey(), role)); + }); + + return userBuilder.build(); + } + + public static RoleRepresentation getRoleRepresentation(String roleName, String clientId) { + + RoleRepresentation role = new RoleRepresentation(); + role.setName(roleName); + role.setId(clientId); + role.setClientRole(true); + return role; + } static class StaticTimeProvider implements TimeProvider { private final int currentTimeInS; diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/SdJwtSigningServiceTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/SdJwtSigningServiceTest.java index 34ed77fa5d..c1e7abed65 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/SdJwtSigningServiceTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/SdJwtSigningServiceTest.java @@ -52,7 +52,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; -public class SdJwtSigningServiceTest extends SigningServiceTest { +public class SdJwtSigningServiceTest extends OID4VCTest { private static ObjectMapper OBJECT_MAPPER = new ObjectMapper(); private static KeyWrapper rsaKey = getRsaKey();