Support credentials issuance through oid4vci (#27931)

closes #25940 

Signed-off-by: Stefan Wiedemann <wistefan@googlemail.com>
This commit is contained in:
Stefan Wiedemann 2024-04-22 11:37:55 +02:00 committed by GitHub
parent 7e034dbbe0
commit b08c644601
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
59 changed files with 4706 additions and 164 deletions

View file

@ -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 <a href="https://github.com/wistefan">Stefan Wiedemann</a>
*/
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<String, String> 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<String, String> clientAttributes) {
OID4VCClient oid4VCClient = new OID4VCClient()
.setClientDid(clientId);
Set<String> supportedCredentialIds = new HashSet<>();
Map<String, String> 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<SupportedCredentialConfiguration> supportedCredentialConfigurations = supportedCredentialIds
.stream()
.map(id -> SupportedCredentialConfiguration.fromDotNotation(id, attributes))
.toList();
return oid4VCClient.setSupportedVCTypes(supportedCredentialConfigurations);
}
}

View file

@ -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.
* <p>
* {@see https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html}
*
* @author <a href="https://github.com/wistefan">Stefan Wiedemann</a>
*/
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<ProviderConfigProperty> getConfigMetadata() {
return List.of();
}
}

View file

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

View file

@ -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 <a href="https://github.com/wistefan">Stefan Wiedemann</a>
*/
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<String, ProtocolMapperModel> 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<String, ProtocolMapperModel> getBuiltinMappers() {
return builtins;
}
private void addServiceFromComponent(Map<Format, VerifiableCredentialsSigningService> signingServices, KeycloakSession keycloakSession, ComponentModel componentModel) {
ProviderFactory<VerifiableCredentialsSigningService> 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<Format, VerifiableCredentialsSigningService> 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;
}
}

View file

@ -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.
* <p>
* {@see https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html}
*
* @author <a href="https://github.com/wistefan">Stefan Wiedemann</a>
*/
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<Format, VerifiableCredentialsSigningService> signingServices;
public OID4VCIssuerEndpoint(KeycloakSession session,
String issuerDid,
Map<Format, VerifiableCredentialsSigningService> 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<String, SupportedCredentialConfiguration> 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<OID4VCClient> clients = getClientsOfType(vcType, format);
List<OID4VCMapper> 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<ProtocolMapperModel> getProtocolMappers(List<OID4VCClient> 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<OID4VCClient> 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<OID4VCClient> 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<OID4VCMapper> 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<String, Object> 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;
}
}

View file

@ -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 <a href="https://github.com/wistefan">Stefan Wiedemann</a>
*/
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<String, SupportedCredentialConfiguration> getSupportedCredentials(KeycloakSession keycloakSession) {
RealmModel realm = keycloakSession.getContext().getRealm();
List<Format> 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;
}
}

View file

@ -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
* <p>
* {@see https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#section-11.2.2}
*
* @author <a href="https://github.com/wistefan">Stefan Wiedemann</a>
*/
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;
}
}

View file

@ -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 <a href="https://github.com/wistefan">Stefan Wiedemann</a>
*/
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<ProviderConfigProperty> 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<ProviderConfigProperty> getIndividualConfigProperties() {
return CONFIG_PROPERTIES;
}
public void setClaimsForCredential(VerifiableCredential verifiableCredential,
UserSessionModel userSessionModel) {
// remove duplicates
Set<String> 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<String, Object> 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;
}
}

View file

@ -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 <a href="https://github.com/wistefan">Stefan Wiedemann</a>
*/
public abstract class OID4VCMapper implements ProtocolMapper, OID4VCEnvironmentProviderFactory {
protected static final String SUPPORTED_CREDENTIALS_KEY = "supportedCredentialTypes";
protected ProtocolMapperModel mapperModel;
private static final List<ProviderConfigProperty> 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<ProviderConfigProperty> getIndividualConfigProperties();
@Override
public List<ProviderConfigProperty> 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<String, Object> claims,
UserSessionModel userSessionModel);
}

View file

@ -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 <a href="https://github.com/wistefan">Stefan Wiedemann</a>
*/
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<ProviderConfigProperty> 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<ProviderConfigProperty> getIndividualConfigProperties() {
return CONFIG_PROPERTIES;
}
public void setClaimsForCredential(VerifiableCredential verifiableCredential,
UserSessionModel userSessionModel) {
// nothing to do for the mapper.
}
@Override
public void setClaimsForSubject(Map<String, Object> 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;
}
}

View file

@ -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 <a href="https://github.com/wistefan">Stefan Wiedemann</a>
*/
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<ProviderConfigProperty> 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<ProviderConfigProperty> getIndividualConfigProperties() {
return CONFIG_PROPERTIES;
}
public static ProtocolMapperModel create(String name, String subjectId) {
var mapperModel = new ProtocolMapperModel();
mapperModel.setName(name);
Map<String, String> 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<String, Object> 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;
}
}

View file

@ -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 <a href="https://github.com/wistefan">Stefan Wiedemann</a>
*/
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<ProviderConfigProperty> 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<ProviderConfigProperty> 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<String, String> 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<String, Object> 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<String> 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<RoleModel> roleModels;
public ClientRoleModel(String clientId, List<RoleModel> roleModels) {
this.clientId = clientId;
this.roleModels = roleModels;
}
public String getClientId() {
return clientId;
}
public List<RoleModel> getRoleModels() {
return roleModels;
}
}
}

View file

@ -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 <a href="https://github.com/wistefan">Stefan Wiedemann</a>
*/
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<ProviderConfigProperty> 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<ProviderConfigProperty> getIndividualConfigProperties() {
return CONFIG_PROPERTIES;
}
public void setClaimsForCredential(VerifiableCredential verifiableCredential,
UserSessionModel userSessionModel) {
// remove duplicates
Set<String> 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<String, Object> 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;
}
}

View file

@ -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 <a href="https://github.com/wistefan">Stefan Wiedemann</a>
*/
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<ProviderConfigProperty> 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<ProviderConfigProperty> getIndividualConfigProperties() {
return CONFIG_PROPERTIES;
}
public void setClaimsForCredential(VerifiableCredential verifiableCredential,
UserSessionModel userSessionModel) {
// nothing to do for the mapper.
}
@Override
public void setClaimsForSubject(Map<String, Object> 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<String> 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<String, String> 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;
}
}

View file

@ -43,7 +43,6 @@ public class JwtSigningService extends SigningService<String> {
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";

View file

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

View file

@ -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<String> 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<String> visibleClaims = Optional.ofNullable(model.get(SigningProperties.VISIBLE_CLAIMS.getKey()))
.map(visibileClaims -> visibileClaims.split(","))

View file

@ -25,19 +25,17 @@ 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 <a href="https://github.com/wistefan">Stefan Wiedemann</a>
*/
public interface VCSigningServiceProviderFactory extends ComponentFactory<VerifiableCredentialsSigningService, VerifiableCredentialsSigningService>, EnvironmentDependentProviderFactory {
public interface VCSigningServiceProviderFactory extends ComponentFactory<VerifiableCredentialsSigningService, VerifiableCredentialsSigningService>, OID4VCEnvironmentProviderFactory {
/**
* Key for the realm attribute providing the issuerDidy.

View file

@ -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 <a href="https://github.com/wistefan">Stefan Wiedemann</a>
*/
@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<String> authorizationServers;
@JsonProperty("batch_credential_endpoint")
private String batchCredentialEndpoint;
@JsonProperty("notification_endpoint")
private String notificationEndpoint;
@JsonProperty("credential_configurations_supported")
private Map<String, SupportedCredentialConfiguration> 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<String, SupportedCredentialConfiguration> getCredentialsSupported() {
return credentialsSupported;
}
public CredentialIssuer setCredentialsSupported(Map<String, SupportedCredentialConfiguration> 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<String> getAuthorizationServers() {
return authorizationServers;
}
public CredentialIssuer setAuthorizationServers(List<String> authorizationServers) {
this.authorizationServers = authorizationServers;
return this;
}
public String getNotificationEndpoint() {
return notificationEndpoint;
}
public CredentialIssuer setNotificationEndpoint(String notificationEndpoint) {
this.notificationEndpoint = notificationEndpoint;
return this;
}
}

View file

@ -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 <a href="https://github.com/wistefan">Stefan Wiedemann</a>
*/
@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;
}
}

View file

@ -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 <a href="https://github.com/wistefan">Stefan Wiedemann</a>
*/
@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;
}
}

View file

@ -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 <a href="https://github.com/wistefan">Stefan Wiedemann</a>
*/
@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;
}
}

View file

@ -33,7 +33,6 @@ import java.util.Map;
@JsonInclude(JsonInclude.Include.NON_NULL)
public class CredentialSubject {
@JsonIgnore
private Map<String, Object> 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<String, Object> claims) {
this.claims = claims;
return this;
}
}

View file

@ -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 <a href="https://github.com/wistefan">Stefan Wiedemann</a>
*/
@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<String> 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<String> getCredentialConfigurationIds() {
return credentialConfigurationIds;
}
public CredentialsOffer setCredentialConfigurationIds(List<String> credentialConfigurationIds) {
this.credentialConfigurationIds = ImmutableList.copyOf(credentialConfigurationIds);
return this;
}
public PreAuthorizedGrant getGrants() {
return grants;
}
public CredentialsOffer setGrants(PreAuthorizedGrant grants) {
this.grants = grants;
return this;
}
}

View file

@ -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 <a href="https://github.com/wistefan">Stefan Wiedemann</a>
*/
@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<String, String> toDotNotation() {
Map<String, String> 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<String, String> 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;
}
}

View file

@ -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 <a href="https://github.com/wistefan">Stefan Wiedemann</a>
*/
@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;
}
}

View file

@ -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 <a href="https://github.com/wistefan">Stefan Wiedemann</a>
*/
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;
}
}

View file

@ -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 <a href="https://github.com/wistefan">Stefan Wiedemann</a>
*/
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<SupportedCredentialConfiguration> 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<SupportedCredentialConfiguration> 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<SupportedCredentialConfiguration> getSupportedVCTypes() {
return supportedVCTypes;
}
public OID4VCClient setSupportedVCTypes(List<SupportedCredentialConfiguration> 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;
}
}

View file

@ -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 <a href="https://github.com/wistefan">Stefan Wiedemann</a>
*/
@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;
}
}

View file

@ -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
* <p>
* {@see https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-offer}
*
* @author <a href="https://github.com/wistefan">Stefan Wiedemann</a>
*/
@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;
}
}

View file

@ -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 <a href="https://github.com/wistefan">Stefan Wiedemann</a>
*/
@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;
}
}

View file

@ -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 <a href="https://github.com/wistefan">Stefan Wiedemann</a>
*/
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;
}
}

View file

@ -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 <a href="https://github.com/wistefan">Stefan Wiedemann</a>
*/
public class Role {
private Set<String> names;
private String target;
public Role() {
}
public Role(Set<String> names, String target) {
this.names = ImmutableSet.copyOf(names);
this.target = target;
}
public Set<String> getNames() {
return names;
}
public void setNames(Set<String> 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);
}
}

View file

@ -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<String> cryptographicBindingMethodsSupported;
@JsonProperty(CRYPTOGRAPHIC_SUITES_SUPPORTED_KEY)
private List<String> cryptographicSuitesSupported;
@JsonProperty(CREDENTIAL_SIGNING_ALG_VALUES_SUPPORTED_KEY)
private List<String> 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<String> getCryptographicBindingMethodsSupported() {
return cryptographicBindingMethodsSupported;
}
public SupportedCredentialConfiguration setCryptographicBindingMethodsSupported(List<String> cryptographicBindingMethodsSupported) {
this.cryptographicBindingMethodsSupported = ImmutableList.copyOf(cryptographicBindingMethodsSupported);
return this;
}
public List<String> getCryptographicSuitesSupported() {
return cryptographicSuitesSupported;
}
public SupportedCredentialConfiguration setCryptographicSuitesSupported(List<String> 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<String> getCredentialSigningAlgValuesSupported() {
return credentialSigningAlgValuesSupported;
}
public SupportedCredentialConfiguration setCredentialSigningAlgValuesSupported(List<String> credentialSigningAlgValuesSupported) {
this.credentialSigningAlgValuesSupported = ImmutableList.copyOf(credentialSigningAlgValuesSupported);
return this;
}
public Map<String, String> toDotNotation() {
Map<String, String> dotNotation = new HashMap<>();
Optional.ofNullable(format).ifPresent(format -> dotNotation.put(id + DOT_SEPARATOR + FORMAT_KEY, format.toString()));
Optional.ofNullable(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<String, String> 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<String, String> 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<String, String> 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;
}
}

View file

@ -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 <a href="https://github.com/wistefan">Stefan Wiedemann</a>
*/
@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;
}
}

View file

@ -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<String> getContext() {
return context;
}
public void setContext(List<String> context) {
public VerifiableCredential setContext(List<String> context) {
this.context = context;
return this;
}
public List<String> getType() {
return type;
}
public void setType(List<String> type) {
public VerifiableCredential setType(List<String> 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<String, Object> additionalProperties) {
this.additionalProperties = additionalProperties;
return this;
}
}

View file

@ -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<String> DEFAULT_CLIENT_AUTH_SIGNING_ALG_VALUES_SUPPORTED = list(Algorithm.RS256.toString());
// The exact list depends on protocolMappers
public static final List<String> 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<String> 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<String> DEFAULT_CLAIM_TYPES_SUPPORTED= list("normal");
public static final List<String> DEFAULT_CLAIM_TYPES_SUPPORTED = list("normal");
// KEYCLOAK-7451 OAuth Authorization Server Metadata for Proof Key for Code Exchange
public static final List<String> 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.
@ -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.

View file

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

View file

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

View file

@ -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 <a href="https://github.com/wistefan">Stefan Wiedemann</a>
*/
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;
}
}

View file

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

View file

@ -18,3 +18,4 @@
org.keycloak.protocol.oidc.OIDCLoginProtocolFactory
org.keycloak.protocol.saml.SamlProtocolFactory
org.keycloak.protocol.docker.DockerAuthV2ProtocolFactory
org.keycloak.protocol.oid4vc.OID4VCLoginProtocolFactory

View file

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

View file

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

View file

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

View file

@ -19,3 +19,4 @@ org.keycloak.services.clientregistration.DefaultClientRegistrationProviderFactor
org.keycloak.services.clientregistration.oidc.OIDCClientRegistrationProviderFactory
org.keycloak.services.clientregistration.AdapterInstallationClientRegistrationProviderFactory
org.keycloak.protocol.saml.clientregistration.EntityDescriptorClientRegistrationProviderFactory
org.keycloak.protocol.oid4vc.OID4VCClientRegistrationProviderFactory

View file

@ -17,3 +17,4 @@
org.keycloak.protocol.oidc.OIDCWellKnownProviderFactory
org.keycloak.authorization.config.UmaWellKnownProviderFactory
org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerWellKnownProviderFactory

View file

@ -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<Object[]> 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<String, String> clientAttributes;
private OID4VCClient oid4VCClient;
public OID4VCClientRegistrationProviderTest(String name, Map<String, String> clientAttributes, OID4VCClient oid4VCClient) {
this.clientAttributes = clientAttributes;
this.oid4VCClient = oid4VCClient;
}
@Test
public void testToClientRepresentation() {
Map<String, String> 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));
}
}

View file

@ -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 <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
@ -452,14 +454,14 @@ public class TestingResourceProvider implements RealmResourceProvider {
/**
* Get admin events
*
* <p>
* 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<AdminEventRepresentation> getAdminEvents(@QueryParam("realmId") String realmId, @QueryParam("operationTypes") List<String> 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<TestProvider> 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<? extends Provider> providerClass = (Class<? extends Provider>) 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.
*
* <p>
* 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&param2=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<String, String> 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("<html><body></body></html>").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);
}
}

View file

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

View file

@ -95,7 +95,7 @@ public interface TestingResource {
/**
* Query events
*
* <p>
* 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<EventRepresentation> queryEvents(@QueryParam("realmId") String realmId, @QueryParam("type") List<String> 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<AdminEventRepresentation> getAdminEvents(@QueryParam("realmId") String realmId, @QueryParam("operationTypes") List<String> 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}
*
* <p>
* 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.
*
* <p>
* 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);
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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<String> supportedCredentials = credentialsOffer.getCredentialConfigurationIds();
assertEquals("Exactly one credential should have been returned.", 1, supportedCredentials.size());
String offeredCredentialId = supportedCredentials.get(0);
assertEquals("The credential should be as defined in the note.", supportedCredentialConfiguration.getId(), offeredCredentialId);
PreAuthorizedGrant grant = credentialsOffer.getGrants();
assertNotNull("The grant should be included.", grant);
assertNotNull("The grant should contain the pre-authorized code.", grant.getPreAuthorizedCode());
assertNotNull("The actual pre-authorized code should be included.", grant
.getPreAuthorizedCode()
.getPreAuthorizedCode());
assertEquals("The correct issuer should be included.", rootURL + "/auth/realms/" + TEST_REALM_NAME, credentialsOffer.getCredentialIssuer());
});
}
// ----- requestCredential
@Test(expected = BadRequestException.class)
public void testRequestCredentialUnauthorized() throws Throwable {
withCausePropagation(() -> {
testingClient
.server(TEST_REALM_NAME)
.run((session -> {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
authenticator.setTokenString(null);
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
issuerEndpoint.requestCredential(new CredentialRequest()
.setFormat(Format.JWT_VC)
.setCredentialIdentifier("test-credential"));
}));
});
}
@Test(expected = BadRequestException.class)
public void testRequestCredentialInvalidToken() throws Throwable {
withCausePropagation(() -> {
testingClient
.server(TEST_REALM_NAME)
.run((session -> {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
authenticator.setTokenString("token");
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
issuerEndpoint.requestCredential(new CredentialRequest()
.setFormat(Format.JWT_VC)
.setCredentialIdentifier("test-credential"));
}));
});
}
@Test(expected = BadRequestException.class)
public void testRequestCredentialUnsupportedFormat() throws Throwable {
String token = getBearerToken(oauth);
withCausePropagation(() -> {
testingClient
.server(TEST_REALM_NAME)
.run((session -> {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
authenticator.setTokenString(token);
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
issuerEndpoint.requestCredential(new CredentialRequest()
.setFormat(Format.SD_JWT_VC)
.setCredentialIdentifier("test-credential"));
}));
});
}
@Test(expected = BadRequestException.class)
public void testRequestCredentialUnsupportedCredential() throws Throwable {
String token = getBearerToken(oauth);
withCausePropagation(() -> {
testingClient
.server(TEST_REALM_NAME)
.run((session -> {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
authenticator.setTokenString(token);
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
issuerEndpoint.requestCredential(new CredentialRequest()
.setFormat(Format.JWT_VC)
.setCredentialIdentifier("no-such-credential"));
}));
});
}
@Test
public void testRequestCredential() {
String token = getBearerToken(oauth);
testingClient
.server(TEST_REALM_NAME)
.run((session -> {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
authenticator.setTokenString(token);
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
CredentialRequest credentialRequest = new CredentialRequest()
.setFormat(Format.JWT_VC)
.setCredentialIdentifier("test-credential");
Response credentialResponse = issuerEndpoint.requestCredential(credentialRequest);
assertEquals("The credential request should be answered successfully.", HttpStatus.SC_OK, credentialResponse.getStatus());
assertNotNull("A credential should be responded.", credentialResponse.getEntity());
CredentialResponse credentialResponseVO = OBJECT_MAPPER.convertValue(credentialResponse.getEntity(), CredentialResponse.class);
JsonWebToken jsonWebToken = TokenVerifier.create((String) credentialResponseVO.getCredential(), JsonWebToken.class).getToken();
// 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<NameValuePair> parameters = new LinkedList<>();
parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, PreAuthorizedCodeGrantTypeFactory.GRANT_TYPE));
parameters.add(new BasicNameValuePair("code", credentialsOffer.getGrants().getPreAuthorizedCode().getPreAuthorizedCode()));
UrlEncodedFormEntity formEntity = new UrlEncodedFormEntity(parameters, "UTF-8");
postPreAuthorizedCode.setEntity(formEntity);
OAuthClient.AccessTokenResponse accessTokenResponse = new OAuthClient.AccessTokenResponse(httpClient.execute(postPreAuthorizedCode));
assertEquals(HttpStatus.SC_OK, accessTokenResponse.getStatusCode());
String theToken = accessTokenResponse.getAccessToken();
// 6. Get the credential
credentialsOffer.getCredentialConfigurationIds().stream()
.map(offeredCredentialId -> credentialIssuer.getCredentialsSupported().get(offeredCredentialId))
.forEach(supportedCredential -> {
try {
requestOffer(theToken, credentialIssuer.getCredentialEndpoint(), supportedCredential);
} catch (IOException e) {
fail("Was not able to get the credential.");
} catch (VerificationException e) {
throw new RuntimeException(e);
}
});
}
private 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;
}
}
}

View file

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

View file

@ -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<String> 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<String, Object> 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<String, List<String>> 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;

View file

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