8000 [OID4VCI] Add a client policy to require a credential offer by tdiesler · Pull Request #47286 · keycloak/keycloak · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
11 changes: 11 additions & 0 deletions js/apps/admin-ui/src/client-scopes/details/ScopeForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,17 @@ export const ScopeForm = ({ clientScope, save }: ScopeFormProps) => {
label={t("credentialIdentifier")}
labelIcon={t("credentialIdentifierHelp")}
/>
<DefaultSwitchControl
name={convertAttributeNameToForm<ClientScopeDefaultOptionalType>(
"attributes.vc.policy.offer.required",
)}
defaultValue={
clientScope?.attributes?.["vc.policy.offer.required"] ?? "false"
}
label={t("credentialOfferRequired")}
labelIcon={t("credentialOfferRequiredHelp")}
stringify
/>
<TextControl
name={convertAttributeNameToForm<ClientScopeDefaultOptionalType>(
"attributes.vc.issuer_did",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,7 @@
/**
* 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 <a href="mailto:takashi.norimatsu.ws@hitachi.com">Takashi Norimatsu</a>
*/
public interface ClientPolicyConditionProvider<CONFIG extends ClientPolicyConditionConfigurationRepresentation> extends Provider {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,7 @@
/**
* 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 <a href="mailto:takashi.norimatsu.ws@hitachi.com">Takashi Norimatsu</a>
*/
public interface ClientPolicyExecutorProvider<CONFIG extends ClientPolicyExecutorConfigurationRepresentation> extends Provider {
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package org.keycloak.protocol.oid4vc.clientpolicy;

import org.keycloak.protocol.oid4vc.model.CredentialScopeRepresentation;


public abstract class CredentialClientPolicy<T> {

private final String name;
private final String attrName;
private final Class<T> type;
private final T expectedValue;
private final T defaultValue;

public CredentialClientPolicy(String name, String attrName, Class<T> 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<T> getType() {
return type;
}

public T getExpectedValue() {
return expectedValue;
}

public T getDefaultValue() {
return defaultValue;
}

public abstract T getCurrentValue(CredentialScopeRepresentation credScope);
}
Original file line number Diff line number Diff line change
@@ -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<ClientPolicyExecutorConfigurationRepresentation> {

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<CredentialScopeModel> 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<String> 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);
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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 {
Comment thread
mposolda marked this conversation as resolved.
Resolved

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<ProviderConfigProperty> getConfigProperties() {
return Collections.emptyList();
}

@Override
public boolean isSupported(Config.Scope config) {
return Profile.isFeatureEnabled(Profile.Feature.CLIENT_POLICIES)
&& Profile.isFeatureEnabled(Profile.Feature.OID4VC_VCI);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package org.keycloak.protocol.oid4vc.clientpolicy;

import java.util.Objects;
import java.util.Optional;

im 80F7 port org.keycloak.protocol.oid4vc.model.CredentialScopeRepresentation;

public class PredicateCredentialClientPolicy extends CredentialClientPolicy<Boolean> {

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

}
Loading
Loading
0