diff --git a/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/ClientPoliciesPoliciesResource.java b/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/ClientPoliciesPoliciesResource.java index c99c3fdce19..34f374a4636 100644 --- a/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/ClientPoliciesPoliciesResource.java +++ b/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/ClientPoliciesPoliciesResource.java @@ -21,7 +21,7 @@ public interface ClientPoliciesPoliciesResource { /** * Get client policies for the realm. * - * @param includeGlobalPolicies Indicates if global server clioent policies should be included or not. Parameter available since Keycloak server 25. Will be ignored on older Keycloak versions with the default value false + * @param includeGlobalPolicies Indicates if global server client policies should be included or not. Parameter available since Keycloak server 25. Will be ignored on older Keycloak versions with the default value false * @return client policies */ @GET diff --git a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties index 8a0d52c1569..2ffc13bf5cc 100644 --- a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties +++ b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties @@ -3973,6 +3973,8 @@ credentialConfigurationId=Credential Configuration ID credentialConfigurationIdHelp=The unique identifier for this credential configuration. This ID is used in the credential issuer metadata and credential requests. credentialIdentifier=Credential Identifier credentialIdentifierHelp=A specific identifier for this credential type. This can be used to distinguish between different variants of the same credential type. +credentialOfferRequired=Credential Offer Required +credentialOfferRequiredHelp=The Issuer requires a credential offer for this credential configuration. issuerDid=Issuer DID issuerDidHelp=The Decentralized Identifier (DID) of the credential issuer. This identifies who is issuing the verifiable credentials. credentialLifetime=Credential Lifetime (seconds) diff --git a/js/apps/admin-ui/src/client-scopes/details/ScopeForm.tsx b/js/apps/admin-ui/src/client-scopes/details/ScopeForm.tsx index dd5d2cbb4b9..1945eb8a682 100644 --- a/js/apps/admin-ui/src/client-scopes/details/ScopeForm.tsx +++ b/js/apps/admin-ui/src/client-scopes/details/ScopeForm.tsx @@ -394,6 +394,17 @@ export const ScopeForm = ({ clientScope, save }: ScopeFormProps) => { label={t("credentialIdentifier")} labelIcon={t("credentialIdentifierHelp")} /> + ( + "attributes.vc.policy.offer.required", + )} + defaultValue={ + clientScope?.attributes?.["vc.policy.offer.required"] ?? "false" + } + label={t("credentialOfferRequired")} + labelIcon={t("credentialOfferRequiredHelp")} + stringify + /> ( "attributes.vc.issuer_did", diff --git a/server-spi-private/src/main/java/org/keycloak/services/clientpolicy/condition/ClientPolicyConditionProvider.java b/server-spi-private/src/main/java/org/keycloak/services/clientpolicy/condition/ClientPolicyConditionProvider.java index e63a3f46e6f..b24460af92b 100644 --- a/server-spi-private/src/main/java/org/keycloak/services/clientpolicy/condition/ClientPolicyConditionProvider.java +++ b/server-spi-private/src/main/java/org/keycloak/services/clientpolicy/condition/ClientPolicyConditionProvider.java @@ -27,9 +27,7 @@ import org.keycloak.services.clientpolicy.ClientPolicyVote; /** * This condition determines to which client a client policy is adopted. * The condition can be evaluated on the events defined in {@link ClientPolicyEvent}. - * It is sufficient for the implementer of this condition to implement methods in which they are interested - * and {@link isEvaluatedOnEvent} method. - * + * * @author Takashi Norimatsu */ public interface ClientPolicyConditionProvider extends Provider { diff --git a/server-spi-private/src/main/java/org/keycloak/services/clientpolicy/executor/ClientPolicyExecutorProvider.java b/server-spi-private/src/main/java/org/keycloak/services/clientpolicy/executor/ClientPolicyExecutorProvider.java index 6ff10105bd3..7d60fbca5dd 100644 --- a/server-spi-private/src/main/java/org/keycloak/services/clientpolicy/executor/ClientPolicyExecutorProvider.java +++ b/server-spi-private/src/main/java/org/keycloak/services/clientpolicy/executor/ClientPolicyExecutorProvider.java @@ -26,9 +26,7 @@ import org.keycloak.services.clientpolicy.ClientPolicyException; /** * This executor specifies what action is executed on the client to which a client policy is adopted. * The executor can be executed on the events defined in {@link ClientPolicyEvent}. - * It is sufficient for the implementer of this executor to implement methods in which they are interested - * and {@link isEvaluatedOnEvent} method. - * + * * @author Takashi Norimatsu */ public interface ClientPolicyExecutorProvider extends Provider { diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/clientpolicy/CredentialClientPolicies.java b/services/src/main/java/org/keycloak/protocol/oid4vc/clientpolicy/CredentialClientPolicies.java new file mode 100644 index 00000000000..fa971f0b242 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/clientpolicy/CredentialClientPolicies.java @@ -0,0 +1,26 @@ +package org.keycloak.protocol.oid4vc.clientpolicy; + +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.representations.idm.ClientPolicyRepresentation; +import org.keycloak.services.clientpolicy.ClientPolicyException; + +public abstract class CredentialClientPolicies { + + /** + * Governs whether the given `credential_configuration_id` requires a Credential Offer + */ + public static PredicateCredentialClientPolicy VC_POLICY_CREDENTIAL_OFFER_REQUIRED = new PredicateCredentialClientPolicy( + "oid4vci-offer-required", "vc.policy.offer.required", true, false); + + public static ClientPolicyRepresentation findClientPolicyByName(KeycloakSession session, String policyName) { + try { + RealmModel realm = session.getContext().getRealm(); + return session.clientPolicy().getClientPolicies(realm, false).getPolicies().stream() + .filter(cp -> cp.getName().equals(policyName)) + .findFirst().orElse(null); + } catch (ClientPolicyException ex) { + throw new RuntimeException("Cannot access client policies", ex); + } + } +} diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/clientpolicy/CredentialClientPolicy.java b/services/src/main/java/org/keycloak/protocol/oid4vc/clientpolicy/CredentialClientPolicy.java new file mode 100644 index 00000000000..a833783c496 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/clientpolicy/CredentialClientPolicy.java @@ -0,0 +1,43 @@ +package org.keycloak.protocol.oid4vc.clientpolicy; + +import org.keycloak.protocol.oid4vc.model.CredentialScopeRepresentation; + + +public abstract class CredentialClientPolicy { + + private final String name; + private final String attrName; + private final Class type; + private final T expectedValue; + private final T defaultValue; + + public CredentialClientPolicy(String name, String attrName, Class type, T expectedValue, T defaultValue) { + this.name = name; + this.attrName = attrName; + this.expectedValue = expectedValue; + this.defaultValue = defaultValue; + this.type = type; + } + + public String getName() { + return name; + } + + public String getAttrName() { + return attrName; + } + + public Class getType() { + return type; + } + + public T getExpectedValue() { + return expectedValue; + } + + public T getDefaultValue() { + return defaultValue; + } + + public abstract T getCurrentValue(CredentialScopeRepresentation credScope); +} diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/clientpolicy/CredentialClientPolicyExecutor.java b/services/src/main/java/org/keycloak/protocol/oid4vc/clientpolicy/CredentialClientPolicyExecutor.java new file mode 100644 index 00000000000..b15554dc849 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/clientpolicy/CredentialClientPolicyExecutor.java @@ -0,0 +1,108 @@ +package org.keycloak.protocol.oid4vc.clientpolicy; + +import java.util.List; +import java.util.Optional; + +import org.keycloak.OAuthErrorException; +import org.keycloak.events.Errors; +import org.keycloak.models.ClientModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.oid4vci.CredentialScopeModel; +import org.keycloak.protocol.oid4vc.issuance.credentialoffer.CredentialOfferState; +import org.keycloak.protocol.oid4vc.issuance.credentialoffer.CredentialOfferStorage; +import org.keycloak.protocol.oid4vc.model.CredentialsOffer; +import org.keycloak.protocol.oid4vc.model.IssuerState; +import org.keycloak.protocol.oid4vc.utils.CredentialScopeUtils; +import org.keycloak.protocol.oidc.endpoints.request.AuthorizationEndpointRequest; +import org.keycloak.representations.idm.ClientPolicyExecutorConfigurationRepresentation; +import org.keycloak.services.clientpolicy.ClientPolicyContext; +import org.keycloak.services.clientpolicy.ClientPolicyException; +import org.keycloak.services.clientpolicy.context.AuthorizationRequestContext; +import org.keycloak.services.clientpolicy.executor.ClientPolicyExecutorProvider; + +import static org.keycloak.OAuth2Constants.ISSUER_STATE; +import static org.keycloak.protocol.oid4vc.clientpolicy.CredentialClientPolicies.VC_POLICY_CREDENTIAL_OFFER_REQUIRED; +import static org.keycloak.services.clientpolicy.ClientPolicyEvent.AUTHORIZATION_REQUEST; + +/** + * This client policy executor can be reference in a client profile definition like this, + * which we currently don't add to the defaults client profile definitions. + * + * { + * "name": "oid4vci-client-profile", + * "description": "Client profile, which enforces various policies on oid4vci clients.", + * "executors": [ + * { + * "executor": "oid4vci-policy-executor", + * "configuration": {} + * } + * ] + * } + */ +public class CredentialClientPolicyExecutor implements ClientPolicyExecutorProvider { + + protected final KeycloakSession session; + + public CredentialClientPolicyExecutor(KeycloakSession session) { + this.session = session; + } + + @Override + public String getProviderId() { + return CredentialClientPolicyExecutorFactory.PROVIDER_ID; + } + + @Override + public void executeOnEvent(ClientPolicyContext context) throws ClientPolicyException { + if (AUTHORIZATION_REQUEST.equals(context.getEvent())) { + AuthorizationRequestContext authRequestContext = (AuthorizationRequestContext) context; + checkCredentialPolicies(authRequestContext); + } + } + + private void checkCredentialPolicies(AuthorizationRequestContext context) throws ClientPolicyException { + + ClientModel client = context.getClient(); + if (client == null) + throw new ClientPolicyException(OAuthErrorException.INVALID_CLIENT, "No issuing client"); + + // Get the list of requested credential scopes that are associated with this client + // + AuthorizationEndpointRequest request = context.getAuthorizationEndpointRequest(); + List credScopes = CredentialScopeUtils.getCredentialScopesForAuthorization(client, request); + + // Proceed when there are requested credential scopes + // + if (!credScopes.isEmpty()) { + + PredicateCredentialClientPolicy offerRequiredPolicy = VC_POLICY_CREDENTIAL_OFFER_REQUIRED; + + // Get the potential offer state derived from issuer_state + // + String issuerStateParam = request.getAdditionalReqParams().get(ISSUER_STATE); + CredentialOfferStorage offerStorage = session.getProvider(CredentialOfferStorage.class); + CredentialOfferState offerState = Optional.ofNullable(issuerStateParam) + .map(IssuerState::fromEncodedString) + .map(IssuerState::getCredentialsOfferId) + .map(offerStorage::getOfferStateById) + .orElse(null); + + // Get the offered credential configuration ids + // + List offeredConfigurationIds = Optional.ofNullable(offerState) + .map(CredentialOfferState::getCredentialsOffer) + .map(CredentialsOffer::getCredentialConfigurationIds) + .orElse(List.of()); + + // Check whether each requested credential_configuration_id has actually been offered + // + for (CredentialScopeModel credScope : credScopes) { + String credConfigId = credScope.getCredentialConfigurationId(); + if (!offeredConfigurationIds.contains(credConfigId)) { + String errorDetail = "Authorization request rejected by policy " + offerRequiredPolicy.getName() + " for client: " + client.getClientId(); + throw new ClientPolicyException(Errors.NOT_ALLOWED, errorDetail); + } + } + } + } +} diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/clientpolicy/CredentialClientPolicyExecutorFactory.java b/services/src/main/java/org/keycloak/protocol/oid4vc/clientpolicy/CredentialClientPolicyExecutorFactory.java new file mode 100644 index 00000000000..da115ca0f10 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/clientpolicy/CredentialClientPolicyExecutorFactory.java @@ -0,0 +1,54 @@ +package org.keycloak.protocol.oid4vc.clientpolicy; + +import java.util.Collections; +import java.util.List; + +import org.keycloak.Config; +import org.keycloak.common.Profile; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.provider.ProviderConfigProperty; +import org.keycloak.services.clientpolicy.executor.ClientPolicyExecutorProviderFactory; + +public class CredentialClientPolicyExecutorFactory implements ClientPolicyExecutorProviderFactory { + + public static final String PROVIDER_ID = "oid4vci-policy-executor"; + + @Override + public CredentialClientPolicyExecutor create(KeycloakSession session) { + return new CredentialClientPolicyExecutor(session); + } + + @Override + public void init(Config.Scope config) { + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + } + + @Override + public void close() { + } + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + public String getHelpText() { + return "This executor checks client policies related to the credential offer process"; + } + + @Override + public List getConfigProperties() { + return Collections.emptyList(); + } + + @Override + public boolean isSupported(Config.Scope config) { + return Profile.isFeatureEnabled(Profile.Feature.CLIENT_POLICIES) + && Profile.isFeatureEnabled(Profile.Feature.OID4VC_VCI); + } +} diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/clientpolicy/PredicateCredentialClientPolicy.java b/services/src/main/java/org/keycloak/protocol/oid4vc/clientpolicy/PredicateCredentialClientPolicy.java new file mode 100644 index 00000000000..775ee5df927 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/clientpolicy/PredicateCredentialClientPolicy.java @@ -0,0 +1,26 @@ +package org.keycloak.protocol.oid4vc.clientpolicy; + +import java.util.Objects; +import java.util.Optional; + +import org.keycloak.protocol.oid4vc.model.CredentialScopeRepresentation; + +public class PredicateCredentialClientPolicy extends CredentialClientPolicy { + + public PredicateCredentialClientPolicy(String name, String key, Boolean exp, Boolean def) { + super(name, key, Boolean.class, exp, def); + } + + public Boolean getCurrentValue(CredentialScopeRepresentation credScope) { + Boolean scopeValue = Optional.ofNullable(credScope.getAttribute(getAttrName())) + .map(Boolean::parseBoolean) + .orElse(getDefaultValue()); + return scopeValue; + } + + public Boolean validate(CredentialScopeRepresentation credScope) { + Boolean scopeValue = credScope.getCredentialPolicyValue(this); + return Objects.equals(scopeValue, getExpectedValue()); + } + +} diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCAuthorizationCheckProvider.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCAuthorizationCheckProvider.java new file mode 100644 index 00000000000..692a0f9ffd9 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCAuthorizationCheckProvider.java @@ -0,0 +1,82 @@ +package org.keycloak.protocol.oid4vc.issuance; + +import java.util.List; +import java.util.Optional; + +import jakarta.ws.rs.core.Response; + +import org.keycloak.OAuthErrorException; +import org.keycloak.models.ClientModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.oid4vci.CredentialScopeModel; +import org.keycloak.protocol.oid4vc.clientpolicy.PredicateCredentialClientPolicy; +import org.keycloak.protocol.oid4vc.issuance.credentialoffer.CredentialOfferState; +import org.keycloak.protocol.oid4vc.issuance.credentialoffer.CredentialOfferStorage; +import org.keycloak.protocol.oid4vc.model.CredentialScopeRepresentation; +import org.keycloak.protocol.oid4vc.model.CredentialsOffer; +import org.keycloak.protocol.oid4vc.model.IssuerState; +import org.keycloak.protocol.oid4vc.utils.CredentialScopeUtils; +import org.keycloak.protocol.oidc.endpoints.AuthorizationEndpointCheckProvider; +import org.keycloak.protocol.oidc.endpoints.AuthorizationEndpointChecker; +import org.keycloak.protocol.oidc.endpoints.AuthorizationEndpointChecker.AuthorizationCheckException; +import org.keycloak.protocol.oidc.endpoints.request.AuthorizationEndpointRequest; + +import static org.keycloak.OAuth2Constants.ISSUER_STATE; +import static org.keycloak.protocol.oid4vc.clientpolicy.CredentialClientPolicies.VC_POLICY_CREDENTIAL_OFFER_REQUIRED; + +public class OID4VCAuthorizationCheckProvider implements AuthorizationEndpointCheckProvider { + + private final KeycloakSession session; + + public OID4VCAuthorizationCheckProvider(KeycloakSession session) { + this.session = session; + } + + @Override + public void check(AuthorizationEndpointChecker context) throws AuthorizationCheckException { + ClientModel client = context.getClient(); + AuthorizationEndpointRequest request = context.getAuthorizationEndpointRequest(); + + // Get the list of requested credential scopes that are associated with this client + // + List credScopes = CredentialScopeUtils.getCredentialScopesForAuthorization(client, request); + + // Proceed when there are requested credential scopes + // + if (!credScopes.isEmpty()) { + + PredicateCredentialClientPolicy offerRequiredPolicy = VC_POLICY_CREDENTIAL_OFFER_REQUIRED; + + // Get the potential offer state derived from issuer_state + // + String issuerStateParam = request.getAdditionalReqParams().get(ISSUER_STATE); + CredentialOfferStorage offerStorage = session.getProvider(CredentialOfferStorage.class); + CredentialOfferState offerState = Optional.ofNullable(issuerStateParam) + .map(IssuerState::fromEncodedString) + .map(IssuerState::getCredentialsOfferId) + .map(offerStorage::getOfferStateById) + .orElse(null); + + List offeredConfigurationIds = Optional.ofNullable(offerState) + .map(CredentialOfferState::getCredentialsOffer) + .map(CredentialsOffer::getCredentialConfigurationIds) + .orElse(List.of()); + + // Check whether each requested credential_configuration_id has actually been offered + // + for (CredentialScopeModel credScope : credScopes) { + String credConfigId = credScope.getCredentialConfigurationId(); + + boolean requiredByScope = offerRequiredPolicy.validate(new CredentialScopeRepresentation(credScope)); + if (requiredByScope && !offeredConfigurationIds.contains(credConfigId)) { + String errorDetail = "Authorization request rejected by policy " + offerRequiredPolicy.getName() + " for scope: " + credScope.getName(); + throw new AuthorizationCheckException(Response.Status.BAD_REQUEST, OAuthErrorException.INVALID_REQUEST, errorDetail); + } + } + } + } + + @Override + public void close() { + } +} diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCAuthorizationCheckProviderFactory.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCAuthorizationCheckProviderFactory.java new file mode 100644 index 00000000000..344e6d9cfee --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCAuthorizationCheckProviderFactory.java @@ -0,0 +1,38 @@ +package org.keycloak.protocol.oid4vc.issuance; + +import org.keycloak.Config; +import org.keycloak.common.Profile; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.protocol.oidc.endpoints.AuthorizationEndpointCheckProvider; +import org.keycloak.protocol.oidc.endpoints.AuthorizationEndpointCheckProviderFactory; + +public class OID4VCAuthorizationCheckProviderFactory implements AuthorizationEndpointCheckProviderFactory { + + @Override + public AuthorizationEndpointCheckProvider create(KeycloakSession session) { + return new OID4VCAuthorizationCheckProvider(session); + } + + @Override + public String getId() { + return "oid4vci-auth-checker"; + } + + @Override + public void init(Config.Scope config) { + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + } + + @Override + public void close() { + } + + @Override + public boolean isSupported(Config.Scope config) { + return Profile.isFeatureEnabled(Profile.Feature.OID4VC_VCI); + } +} diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCAuthorizationDetailsProcessor.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCAuthorizationDetailsProcessor.java index 704ae478d18..6b9b4921d95 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCAuthorizationDetailsProcessor.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCAuthorizationDetailsProcessor.java @@ -50,8 +50,8 @@ import static org.keycloak.OAuth2Constants.ISSUER_STATE; import static org.keycloak.OID4VCConstants.OPENID_CREDENTIAL; import static org.keycloak.models.oid4vci.CredentialScopeModel.VC_CONFIGURATION_ID; import static org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint.CREDENTIALS_OFFER_ID_ATTR; -import static org.keycloak.protocol.oid4vc.utils.CredentialScopeModelUtils.findCredentialScopeModelByConfigurationId; -import static org.keycloak.protocol.oid4vc.utils.CredentialScopeModelUtils.findCredentialScopeModelByName; +import static org.keycloak.protocol.oid4vc.utils.CredentialScopeUtils.findCredentialScopeModelByConfigurationId; +import static org.keycloak.protocol.oid4vc.utils.CredentialScopeUtils.findCredentialScopeModelByName; import static org.keycloak.protocol.oidc.endpoints.AuthorizationEndpoint.LOGIN_SESSION_NOTE_ADDITIONAL_REQ_PARAMS_PREFIX; public class OID4VCAuthorizationDetailsProcessor implements AuthorizationDetailsProcessor { diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerEndpoint.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerEndpoint.java index 2ccaaa8ae02..37171dbc507 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerEndpoint.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerEndpoint.java @@ -108,7 +108,7 @@ import org.keycloak.protocol.oid4vc.model.Proofs; import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration; import org.keycloak.protocol.oid4vc.model.VerifiableCredential; import org.keycloak.protocol.oid4vc.utils.ClaimsPathPointer; -import org.keycloak.protocol.oid4vc.utils.CredentialScopeModelUtils; +import org.keycloak.protocol.oid4vc.utils.CredentialScopeUtils; import org.keycloak.protocol.oid4vc.utils.OID4VCUtil; import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantType; import org.keycloak.protocol.oidc.rar.AuthorizationDetailsProcessor; @@ -944,7 +944,7 @@ public class OID4VCIssuerEndpoint { // Find credential client scope by requested/authorized credential_configuration_id // - CredentialScopeModel authorizedCredentialScope = CredentialScopeModelUtils.findCredentialScopeModelByConfigurationId( + CredentialScopeModel authorizedCredentialScope = CredentialScopeUtils.findCredentialScopeModelByConfigurationId( realmModel, () -> clientModel.getClientScopes(false).values().stream(), authorizedCredentialConfigurationId); if (authorizedCredentialScope == null) { diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialoffer/DefaultCredentialOfferProvider.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialoffer/DefaultCredentialOfferProvider.java index b337a0004c8..7655044ac79 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialoffer/DefaultCredentialOfferProvider.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialoffer/DefaultCredentialOfferProvider.java @@ -37,12 +37,12 @@ import org.keycloak.protocol.oid4vc.model.IssuerState; import org.keycloak.protocol.oid4vc.model.OID4VCAuthorizationDetail; import org.keycloak.protocol.oid4vc.model.PreAuthCodeCtx; import org.keycloak.protocol.oid4vc.model.PreAuthorizedCodeGrant; -import org.keycloak.protocol.oid4vc.utils.CredentialScopeModelUtils; import org.keycloak.util.Strings; import static org.keycloak.OID4VCConstants.OID4VCI_ENABLED_ATTRIBUTE_KEY; import static org.keycloak.constants.OID4VCIConstants.CREDENTIAL_OFFER_CREATE; import static org.keycloak.protocol.oid4vc.model.PreAuthorizedCodeGrant.PRE_AUTH_GRANT_TYPE; +import static org.keycloak.protocol.oid4vc.utils.CredentialScopeUtils.findCredentialScopeModelByConfigurationId; /** * Default implementation of {@link CredentialOfferProvider}. @@ -104,7 +104,7 @@ class DefaultCredentialOfferProvider implements CredentialOfferProvider { CredentialOfferState offerState = new CredentialOfferState(credOffer, targetClientId, targetUserId, expireAt, credOffersId -> { List authDetails = new ArrayList<>(); for (String credConfigId : credentialConfigurationIds) { - CredentialScopeModel credScope = CredentialScopeModelUtils.findCredentialScopeModelByConfigurationId( + CredentialScopeModel credScope = findCredentialScopeModelByConfigurationId( realmModel, () -> session.clientScopes().getClientScopesStream(realmModel), credConfigId); if (credScope == null) { throw new CredentialOfferException(Errors.INVALID_REQUEST, "No credential scope model for: " + credConfigId); diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/requiredactions/VerifiableCredentialOfferAction.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/requiredactions/VerifiableCredentialOfferAction.java index 7f6e8e106ec..3de482446cc 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/requiredactions/VerifiableCredentialOfferAction.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/requiredactions/VerifiableCredentialOfferAction.java @@ -29,7 +29,7 @@ import org.keycloak.protocol.oid4vc.issuance.TimeProvider; import org.keycloak.protocol.oid4vc.issuance.credentialoffer.CredentialOfferProvider; import org.keycloak.protocol.oid4vc.issuance.credentialoffer.CredentialOfferState; import org.keycloak.protocol.oid4vc.issuance.credentialoffer.CredentialOfferStorage; -import org.keycloak.protocol.oid4vc.utils.CredentialScopeModelUtils; +import org.keycloak.protocol.oid4vc.utils.CredentialScopeUtils; import org.keycloak.sessions.AuthenticationSessionModel; import org.keycloak.util.JsonSerialization; @@ -120,7 +120,7 @@ public class VerifiableCredentialOfferAction implements RequiredActionProvider, } String credentialConfigId = actionConfig.getCredentialConfigurationId(); - CredentialScopeModel credScope = CredentialScopeModelUtils.findCredentialScopeModelByConfigurationId( + CredentialScopeModel credScope = CredentialScopeUtils.findCredentialScopeModelByConfigurationId( realm, () -> session.clientScopes().getClientScopesStream(realm), credentialConfigId); if (credScope == null) { event.detail(Details.CREDENTIAL_TYPE, credentialConfigId); @@ -147,7 +147,7 @@ public class VerifiableCredentialOfferAction implements RequiredActionProvider, LoginFormsProvider form = context.form(); try { - String displayName = CredentialScopeModelUtils.getCredentialDisplayName(context.getSession(), context.getUser(), credScope); + String displayName = CredentialScopeUtils.getCredentialDisplayName(context.getSession(), context.getUser(), credScope); form.setAttribute("credentialOffer", new CredentialOfferBean(context.getSession(), nonce)); form.setAttribute("credentialDisplayName", displayName); } catch (WriterException | IOException ex) { diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialScopeRepresentation.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialScopeRepresentation.java index 50eefea1b7a..a362f1c6b98 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialScopeRepresentation.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialScopeRepresentation.java @@ -9,6 +9,7 @@ import java.util.Optional; import org.keycloak.constants.OID4VCIConstants; import org.keycloak.models.ClientScopeModel; import org.keycloak.models.utils.ModelToRepresentation; +import org.keycloak.protocol.oid4vc.clientpolicy.CredentialClientPolicy; import org.keycloak.representations.idm.ClientScopeRepresentation; import static org.keycloak.models.ClientScopeModel.INCLUDE_IN_TOKEN_SCOPE; @@ -279,6 +280,15 @@ public class CredentialScopeRepresentation extends ClientScopeRepresentation { .map(list -> String.join(",")).orElse(null)); } + public T getCredentialPolicyValue(CredentialClientPolicy policy) { + T currentValue = policy.getCurrentValue(this); + return currentValue; + } + + public CredentialScopeRepresentation setCredentialPolicyValue(CredentialClientPolicy policy, T value) { + return setAttribute(policy.getAttrName(), String.valueOf(value)); + } + public String getAttribute(String key) { return attributes != null ? attributes.get(key) : null; } diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialsOffer.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialsOffer.java index 33ef55bc338..1d6d9de97b1 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialsOffer.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialsOffer.java @@ -91,10 +91,6 @@ public class CredentialsOffer { return this; } - public CredentialOfferGrant getGrant(String grantType) { - return grants.get(grantType); - } - public CredentialsOffer addGrant(CredentialOfferGrant grant) { grants.put(grant.getGrantType(), grant); return this; diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/utils/CredentialScopeModelUtils.java b/services/src/main/java/org/keycloak/protocol/oid4vc/utils/CredentialScopeUtils.java similarity index 76% rename from services/src/main/java/org/keycloak/protocol/oid4vc/utils/CredentialScopeModelUtils.java rename to services/src/main/java/org/keycloak/protocol/oid4vc/utils/CredentialScopeUtils.java index c260c0d125a..83b1e301d69 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/utils/CredentialScopeModelUtils.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/utils/CredentialScopeUtils.java @@ -1,32 +1,33 @@ package org.keycloak.protocol.oid4vc.utils; +import java.util.Arrays; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.function.Supplier; import java.util.stream.Stream; +import org.keycloak.models.ClientModel; import org.keycloak.models.ClientScopeModel; import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; import org.keycloak.models.UserModel; import org.keycloak.models.oid4vci.CredentialScopeModel; import org.keycloak.protocol.oid4vc.model.DisplayObject; -import org.keycloak.protocol.oid4vc.model.OID4VCAuthorizationDetail; +import org.keycloak.protocol.oidc.endpoints.request.AuthorizationEndpointRequest; import org.keycloak.util.Strings; import org.keycloak.utils.StringUtil; import org.jboss.logging.Logger; -import static org.keycloak.OID4VCConstants.OPENID_CREDENTIAL; import static org.keycloak.constants.OID4VCIConstants.OID4VC_PROTOCOL; -import static org.keycloak.models.oid4vci.CredentialScopeModel.VC_CONFIGURATION_ID; -public class CredentialScopeModelUtils { +public class CredentialScopeUtils { - private static final Logger log = Logger.getLogger(CredentialScopeModelUtils.class); + private static final Logger log = Logger.getLogger(CredentialScopeUtils.class); // Hide ctor - private CredentialScopeModelUtils() {} + private CredentialScopeUtils() {} public static CredentialScopeModel findCredentialScopeModelByConfigurationId(RealmModel realmModel, Supplier> supplier, String credConfigId) { if (Strings.isEmpty(credConfigId)) { @@ -69,23 +70,27 @@ public class CredentialScopeModelUtils { return !credScopes.isEmpty() ? credScopes.get(0) : null; } - public static OID4VCAuthorizationDetail buildOID4VCAuthorizationDetail(CredentialScopeModel credScope, String credOffersId) { + /** + * Get the list of credential scopes associated by the given and requested by the given authorization request + */ + public static List getCredentialScopesForAuthorization(ClientModel client, AuthorizationEndpointRequest request) { - OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail(); - authDetail.setCredentialsOfferId(credOffersId); - authDetail.setType(OPENID_CREDENTIAL); + List requestScopes = Optional.ofNullable(request.getScope()) + .map(it -> it.split("\\s")) + .map(Arrays::asList) + .orElse(List.of()); - String credConfigId = Optional.ofNullable(credScope.getCredentialConfigurationId()) - .orElseThrow(() -> new IllegalStateException("No " + VC_CONFIGURATION_ID + " in client scope: " + credScope.getName())); + // Get the list of requested credential scopes that are associated with this client + // + Map clientScopes = client.getClientScopes(false); + List credScopes = requestScopes.stream() + .filter(clientScopes::containsKey) + .map(clientScopes::get) + .filter(it -> OID4VC_PROTOCOL.equals(it.getProtocol())) + .map(CredentialScopeModel::new) + .toList(); - authDetail.setCredentialConfigurationId(credConfigId); - - String credIdentifier = credScope.getCredentialIdentifier(); - if (!Strings.isEmpty(credIdentifier)) { - authDetail.setCredentialIdentifiers(List.of(credIdentifier)); - } - - return authDetail; + return credScopes; } /** diff --git a/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocol.java b/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocol.java index 37dc613d10e..64f9e9fe8d3 100755 --- a/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocol.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocol.java @@ -437,7 +437,7 @@ public class OIDCLoginProtocol implements LoginProtocol { checker.checkResponseType(); checker.checkRedirectUri(); } catch (AuthorizationEndpointChecker.AuthorizationCheckException ex) { - ex.throwAsErrorPageException(null); + checker.throwAsErrorPageException(null, ex); } setupResponseTypeAndMode(clientData.getResponseType(), clientData.getResponseMode()); diff --git a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpoint.java b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpoint.java index 17581b95ae6..5701728d0e6 100755 --- a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpoint.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpoint.java @@ -162,7 +162,7 @@ public class AuthorizationEndpoint extends AuthorizationEndpointBase { checker.checkRedirectUri(); this.redirectUri = checker.getRedirectUri(); } catch (AuthorizationEndpointChecker.AuthorizationCheckException ex) { - ex.throwAsErrorPageException(authenticationSession); + checker.throwAsErrorPageException(authenticationSession, ex); } try { @@ -191,6 +191,7 @@ public class AuthorizationEndpoint extends AuthorizationEndpointBase { checker.checkValidResource(); checker.checkOIDCParams(); checker.checkPKCEParams(); + checker.checkProviderAddOns(); } catch (AuthorizationEndpointChecker.AuthorizationCheckException ex) { return redirectErrorToClient(parsedResponseMode, ex.getError(), ex.getErrorDescription()); } @@ -338,7 +339,7 @@ public class AuthorizationEndpoint extends AuthorizationEndpointBase { authenticationSession.setRedirectUri(redirectUri); authenticationSession.setAction(AuthenticationSessionModel.Action.AUTHENTICATE.name()); authenticationSession.setClientNote(OIDCLoginProtocol.RESPONSE_TYPE_PARAM, request.getResponseType()); - authenticationSession.setClientNote(OIDCLoginProtocol.REDIRECT_URI_PARAM, request.getRedirectUriParam()); + authenticationSession.setClientNote(OIDCLoginProtocol.REDIRECT_URI_PARAM, request.getRedirectUri()); authenticationSession.setClientNote(OIDCLoginProtocol.ISSUER, Urls.realmIssuer(session.getContext().getUri().getBaseUri(), realm.getName())); performActionOnParameters(request, (paramName, paramValue) -> {if (paramValue != null) authenticationSession.setClientNote(paramName, paramValue);}); diff --git a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpointCheckProvider.java b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpointCheckProvider.java new file mode 100644 index 00000000000..ef7259784f5 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpointCheckProvider.java @@ -0,0 +1,9 @@ +package org.keycloak.protocol.oidc.endpoints; + +import org.keycloak.protocol.oidc.endpoints.AuthorizationEndpointChecker.AuthorizationCheckException; +import org.keycloak.provider.Provider; + +public interface AuthorizationEndpointCheckProvider extends Provider { + + void check(AuthorizationEndpointChecker context) throws AuthorizationCheckException; +} diff --git a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpointCheckProviderFactory.java b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpointCheckProviderFactory.java new file mode 100644 index 00000000000..9ce2668802b --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpointCheckProviderFactory.java @@ -0,0 +1,7 @@ +package org.keycloak.protocol.oidc.endpoints; + +import org.keycloak.provider.EnvironmentDependentProviderFactory; +import org.keycloak.provider.ProviderFactory; + +public interface AuthorizationEndpointCheckProviderFactory extends ProviderFactory, EnvironmentDependentProviderFactory { +} diff --git a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpointCheckSpi.java b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpointCheckSpi.java new file mode 100644 index 00000000000..acf8f48454f --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpointCheckSpi.java @@ -0,0 +1,28 @@ +package org.keycloak.protocol.oidc.endpoints; + +import org.keycloak.provider.Provider; +import org.keycloak.provider.ProviderFactory; +import org.keycloak.provider.Spi; + +public class AuthorizationEndpointCheckSpi implements Spi { + + @Override + public boolean isInternal() { + return true; + } + + @Override + public String getName() { + return "auth-endpoint-check"; + } + + @Override + public Class getProviderClass() { + return AuthorizationEndpointCheckProvider.class; + } + + @Override + public Class getProviderFactoryClass() { + return AuthorizationEndpointCheckProviderFactory.class; + } +} diff --git a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpointChecker.java b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpointChecker.java index bbeb0fcb14e..7c5ef2e3ec0 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpointChecker.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpointChecker.java @@ -18,6 +18,9 @@ package org.keycloak.protocol.oidc.endpoints; +import java.util.List; +import java.util.Optional; +import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -33,6 +36,14 @@ import org.keycloak.events.EventBuilder; import org.keycloak.models.ClientModel; import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; +import org.keycloak.models.oid4vci.CredentialScopeModel; +import org.keycloak.protocol.oid4vc.clientpolicy.PredicateCredentialClientPolicy; +import org.keycloak.protocol.oid4vc.issuance.credentialoffer.CredentialOfferState; +import org.keycloak.protocol.oid4vc.issuance.credentialoffer.CredentialOfferStorage; +import org.keycloak.protocol.oid4vc.model.CredentialScopeRepresentation; +import org.keycloak.protocol.oid4vc.model.CredentialsOffer; +import org.keycloak.protocol.oid4vc.model.IssuerState; +import org.keycloak.protocol.oid4vc.utils.CredentialScopeUtils; import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper; import org.keycloak.protocol.oidc.OIDCConfigAttributes; import org.keycloak.protocol.oidc.OIDCLoginProtocol; @@ -60,6 +71,8 @@ import org.keycloak.utils.StringUtil; import org.jboss.logging.Logger; import static org.keycloak.OAuth2Constants.AUTHORIZATION_DETAILS; +import static org.keycloak.OAuth2Constants.ISSUER_STATE; +import static org.keycloak.protocol.oid4vc.clientpolicy.CredentialClientPolicies.VC_POLICY_CREDENTIAL_OFFER_REQUIRED; /** * Implements some checks typical for OIDC Authorization Endpoint. Useful to consolidate various checks on single place to avoid duplicated @@ -115,6 +128,26 @@ public class AuthorizationEndpointChecker { return this; } + public AuthorizationEndpointRequest getAuthorizationEndpointRequest() { + return request; + } + + public ClientModel getClient() { + return client; + } + + public EventBuilder getEventBuilder() { + return event; + } + + public RealmModel getRealm() { + return realm; + } + + public MultivaluedMap getQueryParams() { + return params; + } + public String getRedirectUri() { return redirectUri; } @@ -128,7 +161,7 @@ public class AuthorizationEndpointChecker { } public void checkRedirectUri() throws AuthorizationCheckException { - String redirectUriParam = request.getRedirectUriParam(); + String redirectUriParam = request.getRedirectUri(); boolean isOIDCRequest = TokenUtil.isOIDCRequest(request.getScope()); event.detail(Details.REDIRECT_URI, redirectUriParam); @@ -348,6 +381,54 @@ public class AuthorizationEndpointChecker { } } + public void checkProviderAddOns() throws AuthorizationCheckException { + Set additionalChecks = session.getAllProviders(AuthorizationEndpointCheckProvider.class); + for (AuthorizationEndpointCheckProvider check : additionalChecks) { + check.check(this); + } + } + + public void checkCredentialScope() throws AuthorizationCheckException { + + // Get the list of requested credential scopes that are associated with this client + // + List credScopes = CredentialScopeUtils.getCredentialScopesForAuthorization(client, request); + + // Proceed when there are requested credential scopes + // + if (!credScopes.isEmpty()) { + + PredicateCredentialClientPolicy offerRequiredPolicy = VC_POLICY_CREDENTIAL_OFFER_REQUIRED; + + // Get the potential offer state derived from issuer_state + // + String issuerStateParam = request.getAdditionalReqParams().get(ISSUER_STATE); + CredentialOfferStorage offerStorage = session.getProvider(CredentialOfferStorage.class); + CredentialOfferState offerState = Optional.ofNullable(issuerStateParam) + .map(IssuerState::fromEncodedString) + .map(IssuerState::getCredentialsOfferId) + .map(offerStorage::getOfferStateById) + .orElse(null); + + List offeredConfigurationIds = Optional.ofNullable(offerState) + .map(CredentialOfferState::getCredentialsOffer) + .map(CredentialsOffer::getCredentialConfigurationIds) + .orElse(List.of()); + + // Check whether each requested credential_configuration_id has actually been offered + // + for (CredentialScopeModel credScope : credScopes) { + String credConfigId = credScope.getCredentialConfigurationId(); + + boolean requiredByScope = offerRequiredPolicy.validate(new CredentialScopeRepresentation(credScope)); + if (requiredByScope && !offeredConfigurationIds.contains(credConfigId)) { + String errorDetail = "Authorization request rejected by policy " + offerRequiredPolicy.getName() + " for scope: " + credScope.getName(); + throw new AuthorizationCheckException(Response.Status.BAD_REQUEST, OAuthErrorException.INVALID_REQUEST, errorDetail); + } + } + } + } + // https://tools.ietf.org/html/rfc7636#section-4 private boolean isValidPkceCodeChallenge(String codeChallenge) { if (codeChallenge.length() < OIDCLoginProtocol.PKCE_CODE_CHALLENGE_MIN_LENGTH) { @@ -437,9 +518,18 @@ public class AuthorizationEndpointChecker { } } + public void throwAsErrorPageException(AuthenticationSessionModel authenticationSession, AuthorizationCheckException ex) { + throw new ErrorPageException(session, authenticationSession, ex.status, ex.error, ex.errorDescription); + } + + public void throwAsCorsErrorResponseException(Cors cors, AuthorizationCheckException ex) { + event.detail("detail", ex.errorDescription).error(ex.error); + throw new CorsErrorResponseException(cors, ex.error, ex.errorDescription, ex.status); + } + // Exception propagated to the caller, which will allow caller to send proper error response based on the context (Browser OIDC Authorization Endpoint, PAR etc) - public class AuthorizationCheckException extends Exception { + public static class AuthorizationCheckException extends Exception { private final Response.Status status; private final String error; @@ -451,15 +541,6 @@ public class AuthorizationEndpointChecker { this.errorDescription = errorDescription; } - public void throwAsErrorPageException(AuthenticationSessionModel authenticationSession) { - throw new ErrorPageException(session, authenticationSession, status, error, errorDescription); - } - - public void throwAsCorsErrorResponseException(Cors cors) { - AuthorizationEndpointChecker.this.event.detail("detail", errorDescription).error(error); - throw new CorsErrorResponseException(cors, error, errorDescription, status); - } - public String getError() { return error; } diff --git a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/request/AuthorizationEndpointRequest.java b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/request/AuthorizationEndpointRequest.java index d0f728c4a38..72ea7218057 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/request/AuthorizationEndpointRequest.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/request/AuthorizationEndpointRequest.java @@ -31,7 +31,7 @@ public class AuthorizationEndpointRequest { String invalidRequestMessage; String clientId; - String redirectUriParam; + String redirectUri; String responseType; String responseMode; String state; @@ -66,15 +66,15 @@ public class AuthorizationEndpointRequest { return clientId; } - public String getRedirectUriParam() { - return redirectUriParam; + public String getRedirectUri() { + return redirectUri; } public static AuthorizationEndpointRequest fromClientData(ClientData cData) { AuthorizationEndpointRequest request = new AuthorizationEndpointRequest(); request.responseType = cData.getResponseType(); request.responseMode = cData.getResponseMode(); - request.redirectUriParam = cData.getRedirectUri(); + request.redirectUri = cData.getRedirectUri(); return request; } diff --git a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/request/AuthzEndpointRequestParser.java b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/request/AuthzEndpointRequestParser.java index 57f0cb66d66..3c57a2e2e7e 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/request/AuthzEndpointRequestParser.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/request/AuthzEndpointRequestParser.java @@ -130,7 +130,7 @@ public abstract class AuthzEndpointRequestParser { } request.responseMode = replaceIfNotNull(request.responseMode, getAndValidateParameter(OIDCLoginProtocol.RESPONSE_MODE_PARAM)); - request.redirectUriParam = replaceIfNotNull(request.redirectUriParam, getAndValidateParameter(OIDCLoginProtocol.REDIRECT_URI_PARAM)); + request.redirectUri = replaceIfNotNull(request.redirectUri, getAndValidateParameter(OIDCLoginProtocol.REDIRECT_URI_PARAM)); request.state = replaceIfNotNull(request.state, getAndValidateParameter(OIDCLoginProtocol.STATE_PARAM)); request.scope = replaceIfNotNull(request.scope, getAndValidateParameter(OIDCLoginProtocol.SCOPE_PARAM)); request.resource = replaceIfNotNull(request.resource, getAndValidateParameter(OIDCLoginProtocol.RESOURCE_PARAM)); diff --git a/services/src/main/java/org/keycloak/protocol/oidc/grants/PreAuthorizedCodeGrantType.java b/services/src/main/java/org/keycloak/protocol/oidc/grants/PreAuthorizedCodeGrantType.java index 7fbca430fe7..c474faa748c 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/grants/PreAuthorizedCodeGrantType.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/grants/PreAuthorizedCodeGrantType.java @@ -49,7 +49,7 @@ import org.keycloak.protocol.oid4vc.model.CredentialsOffer; import org.keycloak.protocol.oid4vc.model.OID4VCAuthorizationDetail; import org.keycloak.protocol.oid4vc.model.PreAuthCodeCtx; import org.keycloak.protocol.oid4vc.model.PreAuthorizedCodeGrant; -import org.keycloak.protocol.oid4vc.utils.CredentialScopeModelUtils; +import org.keycloak.protocol.oid4vc.utils.CredentialScopeUtils; import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.protocol.oidc.TokenManager.AccessTokenResponseBuilder; import org.keycloak.representations.AccessToken; @@ -207,7 +207,7 @@ public class PreAuthorizedCodeGrantType extends OAuth2GrantTypeBase { // Add the scope referenced by the credential from specified credential offer to the token scopes String credConfigId = authDetails.getCredentialConfigurationId(); - CredentialScopeModel credScope = CredentialScopeModelUtils.findCredentialScopeModelByConfigurationId(realm, + CredentialScopeModel credScope = CredentialScopeUtils.findCredentialScopeModelByConfigurationId(realm, () -> session.clientScopes().getClientScopesStream(realm), credConfigId); if (credScope == null) { String errorMessage = "Credential client scope was not found for credential_configuration_id: " + credConfigId; diff --git a/services/src/main/java/org/keycloak/protocol/oidc/par/endpoints/ParEndpoint.java b/services/src/main/java/org/keycloak/protocol/oidc/par/endpoints/ParEndpoint.java index 22fcdb8a915..de56de9341a 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/par/endpoints/ParEndpoint.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/par/endpoints/ParEndpoint.java @@ -133,7 +133,7 @@ public class ParEndpoint extends AbstractParEndpoint { if (ex.getError().equals(OAuthErrorException.UNSUPPORTED_RESPONSE_TYPE)) { throw throwErrorResponseException(OAuthErrorException.INVALID_REQUEST, "Unsupported response type", Response.Status.BAD_REQUEST); } else { - ex.throwAsCorsErrorResponseException(cors); + checker.throwAsCorsErrorResponseException(cors, ex); } } @@ -151,7 +151,7 @@ public class ParEndpoint extends AbstractParEndpoint { checker.checkPKCEParams(); checker.checkParDPoPParams(); } catch (AuthorizationEndpointChecker.AuthorizationCheckException ex) { - ex.throwAsCorsErrorResponseException(cors); + checker.throwAsCorsErrorResponseException(cors, ex); } try { diff --git a/services/src/main/java/org/keycloak/services/clientpolicy/condition/ClientAttributesCondition.java b/services/src/main/java/org/keycloak/services/clientpolicy/condition/ClientAttributesCondition.java index 2c2e5bab1d7..e24361e5ef0 100644 --- a/services/src/main/java/org/keycloak/services/clientpolicy/condition/ClientAttributesCondition.java +++ b/services/src/main/java/org/keycloak/services/clientpolicy/condition/ClientAttributesCondition.java @@ -95,8 +95,8 @@ public class ClientAttributesCondition extends AbstractClientPolicyConditionProv case JWT_AUTHORIZATION_GRANT: case SAML_AUTHN_REQUEST: case SAML_LOGOUT_REQUEST: - if (isAttributesMatched(session.getContext().getClient())) return ClientPolicyVote.YES; - return ClientPolicyVote.NO; + boolean attributesMatched = isAttributesMatched(session.getContext().getClient()); + return attributesMatched ? ClientPolicyVote.YES : ClientPolicyVote.NO; default: return ClientPolicyVote.ABSTAIN; } diff --git a/services/src/main/java/org/keycloak/services/clientpolicy/context/AuthorizationRequestContext.java b/services/src/main/java/org/keycloak/services/clientpolicy/context/AuthorizationRequestContext.java index 21a2156f5c3..4abf670a3c0 100644 --- a/services/src/main/java/org/keycloak/services/clientpolicy/context/AuthorizationRequestContext.java +++ b/services/src/main/java/org/keycloak/services/clientpolicy/context/AuthorizationRequestContext.java @@ -56,7 +56,7 @@ public class AuthorizationRequestContext implements ClientPolicyContext, ClientM return ClientPolicyEvent.AUTHORIZATION_REQUEST; } - public OIDCResponseType getparsedResponseType() { + public OIDCResponseType getParsedResponseType() { return parsedResponseType; } diff --git a/services/src/main/java/org/keycloak/services/clientpolicy/executor/PKCEEnforcerExecutor.java b/services/src/main/java/org/keycloak/services/clientpolicy/executor/PKCEEnforcerExecutor.java index 76f914ff281..c832365b82b 100644 --- a/services/src/main/java/org/keycloak/services/clientpolicy/executor/PKCEEnforcerExecutor.java +++ b/services/src/main/java/org/keycloak/services/clientpolicy/executor/PKCEEnforcerExecutor.java @@ -99,7 +99,7 @@ public class PKCEEnforcerExecutor implements ClientPolicyExecutorProvider {if (paramValue != null) parRetrievedRequest.add(paramName);}); if (request.getClientId() != null) parRetrievedRequest.add(OIDCLoginProtocol.CLIENT_ID_PARAM); if (request.getResponseType() != null) parRetrievedRequest.add(OIDCLoginProtocol.RESPONSE_TYPE_PARAM); - if (request.getRedirectUriParam() != null) parRetrievedRequest.add(OIDCLoginProtocol.REDIRECT_URI_PARAM); + if (request.getRedirectUri() != null) parRetrievedRequest.add(OIDCLoginProtocol.REDIRECT_URI_PARAM); if (request.getMaxAge() != null) parRetrievedRequest.add(OIDCLoginProtocol.MAX_AGE_PARAM); if (request.getUiLocales() != null) parRetrievedRequest.add(OAuth2Constants.UI_LOCALES_PARAM); for (String additionalParam : request.getAdditionalReqParams().keySet()) { diff --git a/services/src/main/java/org/keycloak/services/clientpolicy/executor/SecureResponseTypeExecutor.java b/services/src/main/java/org/keycloak/services/clientpolicy/executor/SecureResponseTypeExecutor.java index a8da342cdc0..ed2d66a56ba 100644 --- a/services/src/main/java/org/keycloak/services/clientpolicy/executor/SecureResponseTypeExecutor.java +++ b/services/src/main/java/org/keycloak/services/clientpolicy/executor/SecureResponseTypeExecutor.java @@ -101,7 +101,7 @@ public class SecureResponseTypeExecutor implements ClientPolicyExecutorProvider< break; case AUTHORIZATION_REQUEST: AuthorizationRequestContext authorizationRequestContext = (AuthorizationRequestContext)context; - executeOnAuthorizationRequest(authorizationRequestContext.getparsedResponseType(), + executeOnAuthorizationRequest(authorizationRequestContext.getParsedResponseType(), authorizationRequestContext.getAuthorizationEndpointRequest(), authorizationRequestContext.getRedirectUri()); break; diff --git a/services/src/main/java/org/keycloak/services/clientpolicy/executor/SecureSessionEnforceExecutor.java b/services/src/main/java/org/keycloak/services/clientpolicy/executor/SecureSessionEnforceExecutor.java index f0613eaa84e..48e8da54180 100644 --- a/services/src/main/java/org/keycloak/services/clientpolicy/executor/SecureSessionEnforceExecutor.java +++ b/services/src/main/java/org/keycloak/services/clientpolicy/executor/SecureSessionEnforceExecutor.java @@ -52,7 +52,7 @@ public class SecureSessionEnforceExecutor implements ClientPolicyExecutorProvide switch (context.getEvent()) { case AUTHORIZATION_REQUEST: AuthorizationRequestContext authorizationRequestContext = (AuthorizationRequestContext)context; - executeOnAuthorizationRequest(authorizationRequestContext.getparsedResponseType(), + executeOnAuthorizationRequest(authorizationRequestContext.getParsedResponseType(), authorizationRequestContext.getAuthorizationEndpointRequest(), authorizationRequestContext.getRedirectUri()); return; diff --git a/services/src/main/resources/META-INF/services/org.keycloak.protocol.oidc.endpoints.AuthorizationEndpointCheckProviderFactory b/services/src/main/resources/META-INF/services/org.keycloak.protocol.oidc.endpoints.AuthorizationEndpointCheckProviderFactory new file mode 100644 index 00000000000..a625a0a0787 --- /dev/null +++ b/services/src/main/resources/META-INF/services/org.keycloak.protocol.oidc.endpoints.AuthorizationEndpointCheckProviderFactory @@ -0,0 +1,20 @@ +# +# Copyright 2025 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.OID4VCAuthorizationCheckProviderFactory \ No newline at end of file diff --git a/services/src/main/resources/META-INF/services/org.keycloak.provider.Spi b/services/src/main/resources/META-INF/services/org.keycloak.provider.Spi index f4aba2052a6..8c4fd6940d7 100755 --- a/services/src/main/resources/META-INF/services/org.keycloak.provider.Spi +++ b/services/src/main/resources/META-INF/services/org.keycloak.provider.Spi @@ -38,6 +38,7 @@ org.keycloak.protocol.oid4vc.issuance.credentialoffer.preauth.PreAuthCodeHandler org.keycloak.protocol.oid4vc.issuance.keybinding.ProofValidatorSpi org.keycloak.protocol.oid4vc.issuance.signing.CredentialSignerSpi org.keycloak.protocol.oid4vc.issuance.keybinding.CNonceHandlerSpi +org.keycloak.protocol.oidc.endpoints.AuthorizationEndpointCheckSpi org.keycloak.protocol.oidc.rar.AuthorizationDetailsProcessorSpi org.keycloak.protocol.oauth2.cimd.provider.ClientIdMetadataDocumentProviderSpi org.keycloak.protocol.oidc.token.TokenInterceptorSpi diff --git a/services/src/main/resources/META-INF/services/org.keycloak.services.clientpolicy.executor.ClientPolicyExecutorProviderFactory b/services/src/main/resources/META-INF/services/org.keycloak.services.clientpolicy.executor.ClientPolicyExecutorProviderFactory index d7ada70d122..015de4eeba0 100644 --- a/services/src/main/resources/META-INF/services/org.keycloak.services.clientpolicy.executor.ClientPolicyExecutorProviderFactory +++ b/services/src/main/resources/META-INF/services/org.keycloak.services.clientpolicy.executor.ClientPolicyExecutorProviderFactory @@ -14,6 +14,7 @@ org.keycloak.services.clientpolicy.executor.SecureClientUrisPatternExecutorFacto org.keycloak.protocol.oidc.grants.ciba.clientpolicy.executor.SecureCibaSessionEnforceExecutorFactory org.keycloak.protocol.oidc.grants.ciba.clientpolicy.executor.SecureCibaSignedAuthenticationRequestExecutorFactory org.keycloak.protocol.oidc.grants.ciba.clientpolicy.executor.SecureCibaAuthenticationRequestSigningAlgorithmExecutorFactory +org.keycloak.protocol.oid4vc.clientpolicy.CredentialClientPolicyExecutorFactory org.keycloak.services.clientpolicy.executor.SecureLogoutExecutorFactory org.keycloak.services.clientpolicy.executor.RejectResourceOwnerPasswordCredentialsGrantExecutorFactory org.keycloak.services.clientpolicy.executor.ClientSecretRotationExecutorFactory diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCActionTest.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCActionTest.java index 39e78fbcfcd..8f65a1cafb1 100644 --- a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCActionTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCActionTest.java @@ -251,7 +251,7 @@ public class OID4VCActionTest extends OID4VCIssuerTestBase { // AccessTokenResponse tokenResponse = wallet.accessTokenRequest(ctx, authCode).send(); assertNull(tokenResponse.getAccessToken()); - assertEquals("Credential offer target client 'oid4vci-test-pub' different from login client 'oid4vci-test'", tokenResponse.getErrorDescription()); + assertEquals("Credential offer target client 'oid4vci-client-pub' different from login client 'oid4vci-client'", tokenResponse.getErrorDescription()); } diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCIssuerTestBase.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCIssuerTestBase.java index da7ad950533..809c71610af 100644 --- a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCIssuerTestBase.java +++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCIssuerTestBase.java @@ -46,6 +46,7 @@ import org.keycloak.models.KeyManager; import org.keycloak.models.KeycloakSession; import org.keycloak.models.UserModel; import org.keycloak.models.utils.KeycloakModelUtils; +import org.keycloak.protocol.oid4vc.clientpolicy.CredentialClientPolicyExecutorFactory; import org.keycloak.protocol.oid4vc.issuance.OID4VCAuthorizationDetailsParser; import org.keycloak.protocol.oid4vc.issuance.TimeProvider; import org.keycloak.protocol.oid4vc.issuance.mappers.OID4VCGeneratedIdMapper; @@ -55,6 +56,10 @@ import org.keycloak.protocol.oid4vc.model.CredentialSubject; import org.keycloak.protocol.oid4vc.model.DisplayObject; import org.keycloak.protocol.oid4vc.model.OID4VCAuthorizationDetail; import org.keycloak.protocol.oid4vc.model.VerifiableCredential; +import org.keycloak.representations.idm.ClientPolicyConditionRepresentation; +import org.keycloak.representations.idm.ClientPolicyExecutorRepresentation; +import org.keycloak.representations.idm.ClientPolicyRepresentation; +import org.keycloak.representations.idm.ClientProfileRepresentation; import org.keycloak.representations.idm.ClientRepresentation; import org.keycloak.representations.idm.ClientScopeRepresentation; import org.keycloak.representations.idm.ComponentRepresentation; @@ -95,6 +100,8 @@ import org.keycloak.testsuite.util.oauth.AuthorizationEndpointResponse; import org.keycloak.util.AuthorizationDetailsParser; import org.keycloak.util.JsonSerialization; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.node.ObjectNode; import org.jboss.logging.Logger; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -109,6 +116,7 @@ import static org.keycloak.models.oid4vci.CredentialScopeModel.VC_BINDING_REQUIR import static org.keycloak.models.oid4vci.CredentialScopeModel.VC_BINDING_REQUIRED_PROOF_TYPES; import static org.keycloak.models.oid4vci.CredentialScopeModel.VC_CRYPTOGRAPHIC_BINDING_METHODS; import static org.keycloak.models.oid4vci.CredentialScopeModel.VC_FORMAT_DEFAULT; +import static org.keycloak.protocol.oid4vc.clientpolicy.CredentialClientPolicies.VC_POLICY_CREDENTIAL_OFFER_REQUIRED; /** * Abstract base class for OID4VCI Testing @@ -119,9 +127,10 @@ public abstract class OID4VCIssuerTestBase { protected final Logger log = Logger.getLogger(getClass()); - public static final String OID4VCI_CLIENT_ID = "oid4vci-test"; - public static final String OID4VCI_PUBLIC_CLIENT_ID = "oid4vci-test-pub"; - public static final URI ISSUER_DID = URI.create("did:web:test.org"); + public static final String OID4VCI_CLIENT_ID = "oid4vci-client"; + public static final String OID4VCI_PUBLIC_CLIENT_ID = "oid4vci-client-pub"; + + public static final String TEST_ISSUER_DID = "did:web:test.org"; public static final String TEST_CREDENTIAL_MAPPERS_FILE = "/oid4vc/test-credential-mappers.json"; public static final String TEST_USER = "john"; public static final String TEST_PASSWORD = "password"; @@ -139,7 +148,6 @@ public abstract class OID4VCIssuerTestBase { public static final String minimalJwtTypeCredentialConfigurationIdName = "vc-with-minimal-config-id"; public static final String CONTEXT_URL = "https://www.w3.org/2018/credentials/v1"; - protected static final URI TEST_DID = ISSUER_DID; protected static final List TEST_TYPES = List.of("VerifiableCredential"); protected static final Instant TEST_EXPIRATION_DATE = Instant.ofEpochMilli(Time.currentTimeMillis()) .plus(365, ChronoUnit.DAYS) @@ -149,7 +157,7 @@ public abstract class OID4VCIssuerTestBase { @InjectRealm(config = VCTestRealmConfig.class) protected ManagedRealm testRealm; - @InjectClient(ref = "oid4vci-client", config = PrivateOID4VCIClient.class) + @InjectClient(ref = OID4VCI_CLIENT_ID, config = ConfidentialOID4VCIClient.class) protected ManagedClient managedClient; @InjectClient(ref = OID4VCI_PUBLIC_CLIENT_ID, config = PublicOID4VCIClient.class) @@ -188,6 +196,7 @@ public abstract class OID4VCIssuerTestBase { @TestSetup public void configureTestRealm() { + RealmResource realmResource = testRealm.admin(); UPConfig upConfig = realmResource.users().userProfile().getConfiguration(); upConfig.setUnmanagedAttributePolicy(UPConfig.UnmanagedAttributePolicy.ADMIN_EDIT); @@ -261,7 +270,7 @@ public abstract class OID4VCIssuerTestBase { testCredential.setId(URI.create(String.format("uri:uuid:%s", UUID.randomUUID()))); testCredential.setContext(List.of(CONTEXT_URL)); testCredential.setType(TEST_TYPES); - testCredential.setIssuer(TEST_DID); + testCredential.setIssuer(TEST_ISSUER_DID); testCredential.setExpirationDate(TEST_EXPIRATION_DATE); if (claims.containsKey("issuanceDate")) { testCredential.setIssuanceDate((Instant) claims.get("issuanceDate")); @@ -487,7 +496,7 @@ public abstract class OID4VCIssuerTestBase { public static class VCTestServerWithPreAuthCodeEnabled implements KeycloakServerConfig { @Override public KeycloakServerConfigBuilder configure(KeycloakServerConfigBuilder config) { - return config.features(Profile.Feature.OID4VC_VCI, Profile.Feature.OID4VC_VCI_PREAUTH_CODE, Profile.Feature.OID4VC_VCI_REST_CREDENTIAL_OFFER); + return config.features(Profile.Feature.OID4VC_VCI, Profile.Feature.OID4VC_VCI_REST_CREDENTIAL_OFFER, Profile.Feature.OID4VC_VCI_PREAUTH_CODE); } } @@ -536,7 +545,7 @@ public abstract class OID4VCIssuerTestBase { CredentialScopeRepresentation jwtVcScope = createCredentialScope( jwtTypeCredentialScopeName, - ISSUER_DID.toString(), + TEST_ISSUER_DID, jwtTypeCredentialConfigurationIdName, jwtTypeCredentialScopeName, null, @@ -565,9 +574,50 @@ public abstract class OID4VCIssuerTestBase { realm.users(getUserRepresentation("John Doe", Map.of("did", "did:key:1234"), List.of(), Collections.emptyMap())); realm.users(getUserRepresentation("Alice Wonderland", Map.of("did", "did:key:5678"), List.of(), Map.of())); + // Add Client Policies + // + ClientProfileRepresentation profile = createClientPolicyProfile(); + realm.clientPolicy(createClientPolicyOfferRequired(profile)); + realm.clientProfile(profile); + return realm; } + private ClientProfileRepresentation createClientPolicyProfile() { + + ClientProfileRepresentation profile = new ClientProfileRepresentation(); + profile.setName("oid4vci-client-profile"); + + ClientPolicyExecutorRepresentation executor = new ClientPolicyExecutorRepresentation(); + executor.setExecutorProviderId(CredentialClientPolicyExecutorFactory.PROVIDER_ID); + executor.setConfiguration(JsonNodeFactory.instance.objectNode()); + profile.setExecutors(List.of(executor)); + + return profile; + } + + private ClientPolicyRepresentation createClientPolicyOfferRequired(ClientProfileRepresentation profile) { + + ClientPolicyRepresentation policy = new ClientPolicyRepresentation(); + policy.setName(VC_POLICY_CREDENTIAL_OFFER_REQUIRED.getName()); + policy.setDescription("Client policy to determine whether a credential offers is required"); + policy.setEnabled(false); + + ClientPolicyConditionRepresentation condition = new ClientPolicyConditionRepresentation(); + condition.setConditionProviderId("client-attributes"); + ObjectNode config = JsonNodeFactory.instance.objectNode(); + config.put("attributes", JsonSerialization.valueAsString(List.of(Map.of( + "key", OID4VCI_ENABLED_ATTRIBUTE_KEY, + "value", String.valueOf(true) + )))); + condition.setConfiguration(config); + + policy.setConditions(List.of(condition)); + policy.setProfiles(List.of(profile.getName())); + + return policy; + } + private CredentialScopeRepresentation createCredentialScope( String scopeName, String issuerDid, @@ -666,7 +716,7 @@ public abstract class OID4VCIssuerTestBase { } } - public static class PrivateOID4VCIClient implements ClientConfig { + public static class ConfidentialOID4VCIClient implements ClientConfig { @Override public ClientBuilder configure(ClientBuilder client) { diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCJWTIssuerEndpointTest.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCJWTIssuerEndpointTest.java index d16c051c8c4..c1b5bbc3afa 100644 --- a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCJWTIssuerEndpointTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCJWTIssuerEndpointTest.java @@ -1436,7 +1436,7 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest { public void testCredentialRequestWithOptionalClientScope() { ClientScopeRepresentation optionalScope = createOptionalClientScope( "optional-jwt-credential", - ISSUER_DID.toString(), + TEST_ISSUER_DID, "optional-jwt-credential-config-id", null, null, VCFormat.JWT_VC, @@ -1484,7 +1484,7 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest { public void testCannotAssignOid4vciScopeAsDefaultToClient() { ClientScopeRepresentation oid4vciScope = createOptionalClientScope( "test-oid4vci-scope", - ISSUER_DID.toString(), + TEST_ISSUER_DID, "test-oid4vci-config-id", null, null, VCFormat.JWT_VC, @@ -1509,7 +1509,7 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest { public void testCannotAssignOid4vciScopeAsDefaultToRealm() { ClientScopeRepresentation oid4vciScope = createOptionalClientScope( "test-oid4vci-realm-scope", - ISSUER_DID.toString(), + TEST_ISSUER_DID, "test-oid4vci-realm-config-id", null, null, VCFormat.JWT_VC, @@ -1532,7 +1532,7 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest { public void testCannotAssignOid4vciScopeWhenRealmDisabled() { ClientScopeRepresentation oid4vciScope = createOptionalClientScope( "test-oid4vci-disabled-scope", - ISSUER_DID.toString(), + TEST_ISSUER_DID, "test-oid4vci-disabled-config-id", null, null, VCFormat.JWT_VC, diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCredentialByScopeTest.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCredentialByScopeTest.java index 087caa6d7ea..33d3e298660 100644 --- a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCredentialByScopeTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCredentialByScopeTest.java @@ -2,29 +2,49 @@ package org.keycloak.tests.oid4vc; import java.net.URI; import java.util.List; +import java.util.function.Function; import org.keycloak.TokenVerifier; +import org.keycloak.admin.client.resource.ClientPoliciesPoliciesResource; +import org.keycloak.admin.client.resource.RealmResource; +import org.keycloak.protocol.oid4vc.clientpolicy.PredicateCredentialClientPolicy; import org.keycloak.protocol.oid4vc.model.CredentialIssuer; import org.keycloak.protocol.oid4vc.model.CredentialResponse; +import org.keycloak.protocol.oid4vc.model.CredentialsOffer; import org.keycloak.protocol.oid4vc.model.OID4VCAuthorizationDetail; import org.keycloak.protocol.oid4vc.model.VerifiableCredential; import org.keycloak.representations.JsonWebToken; +import org.keycloak.representations.idm.ClientPoliciesRepresentation; +import org.keycloak.representations.idm.ClientPolicyRepresentation; import org.keycloak.testframework.annotations.KeycloakIntegrationTest; -import org.keycloak.tests.oid4vc.OID4VCIssuerTestBase.VCTestServerConfig; +import org.keycloak.testframework.annotations.TestSetup; +import org.keycloak.tests.oid4vc.OID4VCBasicWallet.AuthorizationEndpointRequest; import org.keycloak.testsuite.util.oauth.AccessTokenResponse; import org.keycloak.testsuite.util.oauth.AuthorizationEndpointResponse; import org.keycloak.util.JsonSerialization; import org.junit.jupiter.api.Test; +import org.opentest4j.AssertionFailedError; import static org.keycloak.OID4VCConstants.OPENID_CREDENTIAL; +import static org.keycloak.protocol.oid4vc.clientpolicy.CredentialClientPolicies.VC_POLICY_CREDENTIAL_OFFER_REQUIRED; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; -@KeycloakIntegrationTest(config = VCTestServerConfig.class) +@KeycloakIntegrationTest(config = OID4VCIssuerTestBase.VCTestServerConfigRestCredentialOffer.class) public class OID4VCredentialByScopeTest extends OID4VCIssuerTestBase { + @TestSetup + public void configure() { + RealmResource realmResource = testRealm.admin(); + realmResource.clientPoliciesPoliciesResource().getPolicies().getPolicies().stream() + .filter(cpr -> "oid4vci-offer-required".equals(cpr.getName())) + .findFirst().orElseThrow(() -> new AssertionFailedError("Client policy not installed")); + } + @Test public void testNoOffer_Scope() throws Exception { @@ -77,6 +97,110 @@ public class OID4VCredentialByScopeTest extends OID4VCIssuerTestBase { verifyCredentialResponse(ctx, ctx.getHolder(), credResponse); } + @Test + public void testNoOffer_Scope_RequireOfferPolicy() { + + var ctx = new OID4VCTestContext(client, jwtTypeCredentialScope); + + PredicateCredentialClientPolicy offerRequiredPolicy = VC_POLICY_CREDENTIAL_OFFER_REQUIRED; + + Function, Boolean> runner = (params) -> { + Boolean createOffer = params.get(0); + Boolean policyEnabled = params.get(1); + Boolean scopeEnabled = params.get(2); + + // Set client policy 'oid4vci-offer-required' + // + ClientPoliciesPoliciesResource clientPoliciesResource = testRealm.admin().clientPoliciesPoliciesResource(); + ClientPoliciesRepresentation policies = clientPoliciesResource.getPolicies(); + ClientPolicyRepresentation clientPolicy = policies.getPolicies().stream() + .filter(cp -> cp.getName().equals(offerRequiredPolicy.getName())) + .findFirst().orElseThrow(); + Boolean wasPolicyEnabled = clientPolicy.isEnabled(); + clientPolicy.setEnabled(policyEnabled); + clientPoliciesResource.updatePolicies(policies); + + // Set client scope attribute 'vc.policy.offer.required' + // + var credScope = ctx.getCredentialScope(); + Boolean wasScopeEnabled = credScope.getCredentialPolicyValue(offerRequiredPolicy); + credScope.setCredentialPolicyValue(offerRequiredPolicy, scopeEnabled); + updateCredentialScope(credScope); + + try { + // Build AuthorizationRequest + // + AuthorizationEndpointRequest authRequest = wallet + .authorizationRequest() + .scope(ctx.getScope()); + + if (createOffer) { + CredentialsOffer credOffer = wallet.createCredentialOffer(ctx, req -> { + req.credentialConfigurationId(ctx.getCredentialConfigurationId()); + req.preAuthorized(false); + }); + assertNotNull(credOffer, "No credOffer"); + + String issuerState = credOffer.getIssuerState(); + assertNotNull(issuerState, "No issuerState"); + authRequest.issuerState(issuerState); + } + + // Send AuthorizationRequest + // + if (authRequest.openLoginForm()) { + authRequest.send(ctx.getHolder(), TEST_PASSWORD); + } else { + AuthorizationEndpointResponse authResponse = authRequest.parseLoginResponse(); + String errorDescription = authResponse.getErrorDescription(); + assertTrue(errorDescription.contains("rejected by policy oid4vci-offer-required"), errorDescription); + return false; + } + + AuthorizationEndpointResponse authResponse = authRequest.parseLoginResponse(); + String authCode = authResponse.getCode(); + assertNotNull(authCode, "No authCode"); + + // Build and send AccessTokenRequest + // + AccessTokenResponse tokenResponse = wallet.accessTokenRequest(ctx, authCode).send(); + + String accessToken = wallet.validateHolderAccessToken(ctx, tokenResponse); + assertNotNull(accessToken, "No accessToken"); + + String authorizedIdentifier = ctx.getAuthorizedCredentialIdentifier(); + assertNotNull(authorizedIdentifier,"No authorized credential identifier"); + + CredentialResponse credentialResponse = wallet.credentialRequest(ctx, accessToken) + .credentialIdentifier(authorizedIdentifier) + .proofs(wallet.generateJwtProof(ctx)) + .send() + .getCredentialResponse(); + assertFalse(credentialResponse.getCredentials().isEmpty(), "Credentials expected"); + return true; + } finally { + clientPolicy.setEnabled(wasPolicyEnabled); + clientPoliciesResource.updatePolicies(policies); + + credScope.setCredentialPolicyValue(offerRequiredPolicy, wasScopeEnabled); + updateCredentialScope(credScope); + + wallet.logout(ctx.getHolder()); + } + }; + + // Verification matrix (createOffer, policyEnabled, scopeEnabled) + // + assertTrue(runner.apply(List.of(false, false, false)), "Offer not required"); + assertFalse(runner.apply(List.of(false, false, true)), "Offer required"); + assertFalse(runner.apply(List.of(false, true, false)), "Offer required"); + assertFalse(runner.apply(List.of(false, true, true)), "Offer required"); + assertTrue(runner.apply(List.of(true, false, false)), "Offer not required"); + assertTrue(runner.apply(List.of(true, false, true)), "Offer required"); + assertTrue(runner.apply(List.of(true, true, false)), "Offer required"); + assertTrue(runner.apply(List.of(true, true, true)), "Offer required"); + } + // Private --------------------------------------------------------------------------------------------------------- private void verifyCredentialResponse(OID4VCTestContext ctx, String expUser, CredentialResponse credResponse) throws Exception { diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/issuance/credentialbuilder/SdJwtCredentialBuilderTest.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/issuance/credentialbuilder/SdJwtCredentialBuilderTest.java index 5cb68c894db..8623d7ac403 100644 --- a/tests/base/src/test/java/org/keycloak/tests/oid4vc/issuance/credentialbuilder/SdJwtCredentialBuilderTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/issuance/credentialbuilder/SdJwtCredentialBuilderTest.java @@ -95,9 +95,8 @@ public class SdJwtCredentialBuilderTest extends CredentialBuilderTest { public void testSignSDJwtCredential(Map claims, int decoys, List visibleClaims) throws VerificationException { - String issuerDid = TEST_DID.toString(); CredentialBuildConfig credentialBuildConfig = new CredentialBuildConfig() - .setCredentialIssuer(issuerDid) + .setCredentialIssuer(TEST_ISSUER_DID) .setCredentialType("https://credentials.example.com/test-credential") .setTokenJwsType("example+sd-jwt") .setHashAlgorithm(OID4VCConstants.SD_HASH_DEFAULT_ALGORITHM) @@ -113,7 +112,7 @@ public class SdJwtCredentialBuilderTest extends CredentialBuilderTest { IssuerSignedJWT jwt = sdJwt.getIssuerSignedJWT(); - assertEquals(issuerDid, + assertEquals(TEST_ISSUER_DID, jwt.getPayload().get(CLAIM_NAME_ISSUER).asText(), "The issuer should be set in the token."); diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/issuance/signing/JwtCredentialSignerTest.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/issuance/signing/JwtCredentialSignerTest.java index 8100b4572c3..49c8ef6ebbf 100644 --- a/tests/base/src/test/java/org/keycloak/tests/oid4vc/issuance/signing/JwtCredentialSignerTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/issuance/signing/JwtCredentialSignerTest.java @@ -181,7 +181,7 @@ public class JwtCredentialSignerTest extends OID4VCIssuerTestBase { public static void testSignJwtCredential( KeycloakSession session, String signingKeyId, String algorithm, Map claims) { CredentialBuildConfig credentialBuildConfig = new CredentialBuildConfig() - .setCredentialIssuer(TEST_DID.toString()) + .setCredentialIssuer(TEST_ISSUER_DID) .setTokenJwsType("JWT") .setSigningKeyId(signingKeyId) .setSigningAlgorithm(algorithm); @@ -237,14 +237,14 @@ public class JwtCredentialSignerTest extends OID4VCIssuerTestBase { // if not specific date is set, check against "currentTime" assertEquals(TEST_ISSUANCE_DATE.getEpochSecond(), theToken.getNbf().longValue(), "VC Data Model v1.1 specifies that “issuanceDate” property MUST be represented as an nbf JWT claim, and not iat JWT claim."); } - assertEquals(TEST_DID.toString(), theToken.getIssuer(), "The issuer should be set in the token."); + assertEquals(TEST_ISSUER_DID, theToken.getIssuer(), "The issuer should be set in the token."); assertEquals(testCredential.getId().toString(), theToken.getId(), "The credential ID should be set as the token ID."); Optional.ofNullable(testCredential.getCredentialSubject().getClaims().get("id")).ifPresent(id -> assertEquals(id.toString(), theToken.getSubject(), "If the credentials subject id is set, it should be set as the token subject.")); assertNotNull(theToken.getOtherClaims().get("vc"), "The credentials should be included at the vc-claim."); VerifiableCredential credential = JsonSerialization.mapper.convertValue(theToken.getOtherClaims().get("vc"), VerifiableCredential.class); assertEquals(TEST_TYPES, credential.getType(), "The types should be included"); - assertEquals(TEST_DID, credential.getIssuer(), "The issuer should be included"); + assertEquals(TEST_ISSUER_DID, String.valueOf(credential.getIssuer()), "The issuer should be included"); assertEquals(TEST_EXPIRATION_DATE, credential.getExpirationDate(), "The expiration date should be included"); if (claims.containsKey("issuanceDate")) { assertEquals(claims.get("issuanceDate"), credential.getIssuanceDate(), "The issuance date should be included"); diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/issuance/signing/SdJwtCredentialSignerTest.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/issuance/signing/SdJwtCredentialSignerTest.java index 4ae35710ba7..0db1347a142 100644 --- a/tests/base/src/test/java/org/keycloak/tests/oid4vc/issuance/signing/SdJwtCredentialSignerTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/issuance/signing/SdJwtCredentialSignerTest.java @@ -220,7 +220,7 @@ public class SdJwtCredentialSignerTest extends OID4VCIssuerTestBase { runOnServer.run(session -> { String signingKeyId = getKeyIdFromSession(session); CredentialBuildConfig credentialBuildConfig = new CredentialBuildConfig() - .setCredentialIssuer(TEST_DID.toString()) + .setCredentialIssuer(TEST_ISSUER_DID) .setCredentialType("https://credentials.example.com/test-credential") .setTokenJwsType("example+sd-jwt") .setHashAlgorithm(OID4VCConstants.SD_HASH_DEFAULT_ALGORITHM) @@ -278,7 +278,7 @@ public class SdJwtCredentialSignerTest extends OID4VCIssuerTestBase { public static void testSignSDJwtCredential(KeycloakSession session, String signingKeyId, String overrideKeyId, String algorithm, Map claims, int decoys, List visibleClaims) { CredentialBuildConfig credentialBuildConfig = new CredentialBuildConfig() - .setCredentialIssuer(TEST_DID.toString()) + .setCredentialIssuer(TEST_ISSUER_DID) .setCredentialType("https://credentials.example.com/test-credential") .setTokenJwsType("example+sd-jwt") .setHashAlgorithm(OID4VCConstants.SD_HASH_DEFAULT_ALGORITHM) @@ -337,7 +337,7 @@ public class SdJwtCredentialSignerTest extends OID4VCIssuerTestBase { try { JsonWebToken theToken = verifier.getToken(); - assertEquals(TEST_DID.toString(), theToken.getIssuer(), "The issuer should be set in the token."); + assertEquals(TEST_ISSUER_DID, theToken.getIssuer(), "The issuer should be set in the token."); assertEquals("https://credentials.example.com/test-credential", theToken.getOtherClaims().get("vct"), "The type should be included"); List sds = (List) theToken.getOtherClaims().get(CLAIM_NAME_SD); if (sds != null && !sds.isEmpty()) { diff --git a/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/oid4vc/CredentialOfferUriRequest.java b/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/oid4vc/CredentialOfferUriRequest.java index 2e735b45968..f22a56e11d2 100644 --- a/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/oid4vc/CredentialOfferUriRequest.java +++ b/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/oid4vc/CredentialOfferUriRequest.java @@ -13,7 +13,7 @@ import org.apache.http.client.methods.CloseableHttpResponse; public class CredentialOfferUriRequest extends AbstractHttpGetRequest { - private final String credConfigId; + private String credConfigId; private Boolean preAuthorized; private String targetUser; private Integer expireAt; @@ -25,6 +25,11 @@ public class CredentialOfferUriRequest extends AbstractHttpGetRequest