From 6c0484425aa648b5749685aa00f6864e80cc4690 Mon Sep 17 00:00:00 2001 From: Thomas Diesler Date: Thu, 19 Mar 2026 11:23:53 +0100 Subject: [PATCH 1/4] [OID4VCI] Add a client policy to require a credential offer Signed-off-by: Thomas Diesler --- .../ClientPolicyConditionProvider.java | 4 +- .../ClientPolicyExecutorProvider.java | 4 +- .../clientpolicy/ClientPolicyEvent.java | 3 + .../CredentialClientPolicies.java | 10 +++ .../clientpolicy/CredentialClientPolicy.java | 43 +++++++++ .../CredentialClientPolicyContext.java | 49 ++++++++++ .../CredentialClientPolicyExecutor.java | 83 +++++++++++++++++ ...CredentialClientPolicyExecutorFactory.java | 48 ++++++++++ .../PredicateCredentialClientPolicy.java | 26 ++++++ .../OID4VCAuthorizationDetailsProcessor.java | 44 +++++++++ .../oid4vc/issuance/OID4VCIssuerEndpoint.java | 9 +- .../CredentialOfferProvider.java | 3 +- .../DefaultCredentialOfferProvider.java | 7 +- .../VerifiableCredentialOfferAction.java | 10 ++- .../model/CredentialScopeRepresentation.java | 10 +++ .../oid4vc/model/CredentialsOffer.java | 4 +- .../condition/ClientAttributesCondition.java | 9 +- ...ecutor.ClientPolicyExecutorProviderFactory | 1 + .../keycloak-default-client-profiles.json | 10 +++ .../tests/oid4vc/OID4VCIssuerTestBase.java | 53 +++++++++++ .../oid4vc/OID4VCredentialByScopeTest.java | 89 +++++++++++++++++++ .../policies/ClientPoliciesFeatureTest.java | 15 ++-- .../OID4VCCredentialOfferCorsTest.java | 3 +- .../base/src/test/resources/testrealm.json | 50 +++++++++++ 24 files changed, 561 insertions(+), 26 deletions(-) create mode 100644 services/src/main/java/org/keycloak/protocol/oid4vc/clientpolicy/CredentialClientPolicies.java create mode 100644 services/src/main/java/org/keycloak/protocol/oid4vc/clientpolicy/CredentialClientPolicy.java create mode 100644 services/src/main/java/org/keycloak/protocol/oid4vc/clientpolicy/CredentialClientPolicyContext.java create mode 100644 services/src/main/java/org/keycloak/protocol/oid4vc/clientpolicy/CredentialClientPolicyExecutor.java create mode 100644 services/src/main/java/org/keycloak/protocol/oid4vc/clientpolicy/CredentialClientPolicyExecutorFactory.java create mode 100644 services/src/main/java/org/keycloak/protocol/oid4vc/clientpolicy/PredicateCredentialClientPolicy.java 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 e63a3f46e6ff..b24460af92b6 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 @@ /** * 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 6ff10105bd36..7d60fbca5dd6 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 @@ /** * 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/server-spi/src/main/java/org/keycloak/services/clientpolicy/ClientPolicyEvent.java b/server-spi/src/main/java/org/keycloak/services/clientpolicy/ClientPolicyEvent.java index c25ad4fd8755..e819dbdd30d5 100644 --- a/server-spi/src/main/java/org/keycloak/services/clientpolicy/ClientPolicyEvent.java +++ b/server-spi/src/main/java/org/keycloak/services/clientpolicy/ClientPolicyEvent.java @@ -59,4 +59,7 @@ public enum ClientPolicyEvent { SAML_AUTHN_REQUEST, SAML_LOGOUT_REQUEST, + + // OID4VCI + CREDENTIAL_OFFER_CREATE } 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 000000000000..962dd26803f5 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/clientpolicy/CredentialClientPolicies.java @@ -0,0 +1,10 @@ +package org.keycloak.protocol.oid4vc.clientpolicy; + +public interface CredentialClientPolicies { + + /** + * Governs whether the given `credential_configuration_id` requires a Credential Offer + */ + PredicateCredentialClientPolicy VC_POLICY_CREDENTIAL_OFFER_REQUIRED = new PredicateCredentialClientPolicy( + "oid4vci-offer-required", "vc.policy.offer.required", true, false); +} 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 000000000000..a833783c4967 --- /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/CredentialClientPolicyContext.java b/services/src/main/java/org/keycloak/protocol/oid4vc/clientpolicy/CredentialClientPolicyContext.java new file mode 100644 index 000000000000..297c01da45a7 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/clientpolicy/CredentialClientPolicyContext.java @@ -0,0 +1,49 @@ +package org.keycloak.protocol.oid4vc.clientpolicy; + +import org.keycloak.models.oid4vci.CredentialScopeModel; +import org.keycloak.protocol.oid4vc.issuance.credentialoffer.CredentialOfferState; +import org.keycloak.services.clientpolicy.ClientPolicyContext; +import org.keycloak.services.clientpolicy.ClientPolicyEvent; + +public class CredentialClientPolicyContext implements ClientPolicyContext { + + private final ClientPolicyEvent event; + private CredentialScopeModel credScopeModel; + private CredentialOfferState offerState; + private boolean evaluatedOnEvent; + + public CredentialClientPolicyContext(ClientPolicyEvent event) { + this.event = event; + } + + @Override + public ClientPolicyEvent getEvent() { + return event; + } + + public CredentialScopeModel getCredentialScopeModel() { + return credScopeModel; + } + + public CredentialClientPolicyContext setCredentialScopeModel(CredentialScopeModel credScopeModel) { + this.credScopeModel = credScopeModel; + return this; + } + + public CredentialOfferState getCredentialOfferState() { + return offerState; + } + + public CredentialClientPolicyContext setCredentialOfferState(CredentialOfferState offerState) { + this.offerState = offerState; + return this; + } + + public boolean isEvaluatedOnEvent() { + return evaluatedOnEvent; + } + + public void setEvaluatedOnEvent(boolean evaluated) { + this.evaluatedOnEvent = evaluated; + } +} 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 000000000000..4f958b44e498 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/clientpolicy/CredentialClientPolicyExecutor.java @@ -0,0 +1,83 @@ +package org.keycloak.protocol.oid4vc.clientpolicy; + +import org.keycloak.OAuthErrorException; +import org.keycloak.events.Errors; +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.issuance.credentialoffer.CredentialOfferState; +import org.keycloak.protocol.oid4vc.model.CredentialScopeRepresentation; +import org.keycloak.representations.idm.ClientPolicyExecutorConfigurationRepresentation; +import org.keycloak.representations.idm.ClientPolicyRepresentation; +import org.keycloak.services.clientpolicy.ClientPolicyContext; +import org.keycloak.services.clientpolicy.ClientPolicyEvent; +import org.keycloak.services.clientpolicy.ClientPolicyException; +import org.keycloak.services.clientpolicy.executor.ClientPolicyExecutorProvider; + +import org.jspecify.annotations.Nullable; + +import static org.keycloak.protocol.oid4vc.clientpolicy.CredentialClientPolicies.VC_POLICY_CREDENTIAL_OFFER_REQUIRED; + +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 (context instanceof CredentialClientPolicyContext) { + checkCredentialPolicies((CredentialClientPolicyContext) context); + } + } + + private void checkCredentialPolicies(CredentialClientPolicyContext context) throws ClientPolicyException { + + ClientModel client = session.getContext().getClient(); + if (client == null) { + throw new ClientPolicyException(OAuthErrorException.INVALID_CLIENT, "No issuing client"); + } + CredentialScopeModel credScope = context.getCredentialScopeModel(); + if (credScope == null) { + throw new ClientPolicyException(OAuthErrorException.INVALID_SCOPE, "No client scope"); + } + + // Check policy 'oid4vci-offer-required' + // + if (context.getEvent() == ClientPolicyEvent.AUTHORIZATION_REQUEST) { + + PredicateCredentialClientPolicy offerRequiredPolicy = VC_POLICY_CREDENTIAL_OFFER_REQUIRED; + ClientPolicyRepresentation clientPolicy = findClientPolicyByName(session, offerRequiredPolicy.getName()); + + // Required by client when policy is there and enabled + boolean requiredByClient = clientPolicy != null && clientPolicy.isEnabled(); + + // Required by scope when property is defined and true + // See default value on the policy definition + boolean requiredByScope = offerRequiredPolicy.validate(new CredentialScopeRepresentation(credScope)); + + CredentialOfferState offerState = context.getCredentialOfferState(); + if ((requiredByClient || requiredByScope) && offerState == null) { + throw new ClientPolicyException(Errors.NOT_ALLOWED, + "Authorization request rejected by policy " + offerRequiredPolicy.getName() + " for scope " + credScope.getName()); + } + context.setEvaluatedOnEvent(true); + } + } + + public static @Nullable ClientPolicyRepresentation findClientPolicyByName(KeycloakSession session, String policyName) throws ClientPolicyException { + RealmModel realm = session.getContext().getRealm(); + ClientPolicyRepresentation clientPolicy = session.clientPolicy().getClientPolicies(realm, false).getPolicies().stream() + .filter(cp -> cp.getName().equals(policyName)) + .findFirst().orElse(null); + return clientPolicy; + } +} 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 000000000000..47421b591793 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/clientpolicy/CredentialClientPolicyExecutorFactory.java @@ -0,0 +1,48 @@ +package org.keycloak.protocol.oid4vc.clientpolicy; + +import java.util.Collections; +import java.util.List; + +import org.keycloak.Config; +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(); + } + +} 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 000000000000..775ee5df927d --- /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/OID4VCAuthorizationDetailsProcessor.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCAuthorizationDetailsProcessor.java index 0bc194a070f8..cb0e7318e665 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 @@ -24,16 +24,20 @@ import java.util.Set; import java.util.stream.Collectors; +import org.keycloak.events.Errors; import org.keycloak.models.ClientSessionContext; import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; import org.keycloak.models.UserSessionModel; import org.keycloak.models.oid4vci.CredentialScopeModel; +import org.keycloak.protocol.oid4vc.clientpolicy.CredentialClientPolicyContext; +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.Claim; import org.keycloak.protocol.oid4vc.model.ClaimsDescription; import org.keycloak.protocol.oid4vc.model.CredentialIssuer; +import org.keycloak.protocol.oid4vc.model.CredentialScopeRepresentation; import org.keycloak.protocol.oid4vc.model.IssuerState; import org.keycloak.protocol.oid4vc.model.OID4VCAuthorizationDetail; import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration; @@ -41,6 +45,8 @@ import org.keycloak.protocol.oidc.rar.AuthorizationDetailsProcessor; import org.keycloak.protocol.oidc.rar.InvalidAuthorizationDetailsException; import org.keycloak.representations.AuthorizationDetailsJSONRepresentation; +import org.keycloak.services.clientpolicy.ClientPolicyEvent; +import org.keycloak.services.clientpolicy.ClientPolicyException; import org.keycloak.util.JsonSerialization; import org.keycloak.util.Strings; @@ -49,6 +55,7 @@ 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.clientpolicy.CredentialClientPolicies.VC_POLICY_CREDENTIAL_OFFER_REQUIRED; 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; @@ -232,6 +239,10 @@ private OID4VCAuthorizationDetail buildAuthorizationDetailResponse(ClientSession OID4VCAuthorizationDetail responseAuthDetail = generateResponseAuthorizationDetails(credScope, null); responseAuthDetail.setClaims(requestAuthDetail.getClaims()); + // Check Credential Client Policies + // + checkClientPolicies(credScope, offerState); + return responseAuthDetail; } @@ -263,6 +274,10 @@ public List handleMissingAuthorizationDetails(UserSes // OID4VCAuthorizationDetail authDetail = generateResponseAuthorizationDetails(credScope, null); authorizationDetails.add(authDetail); + + // Check Credential Client Policies + // + checkClientPolicies(credScope, offerState); } } @@ -352,4 +367,33 @@ private CredentialOfferState getCredentialOfferState(ClientSessionContext client return offerState; } + + private void checkClientPolicies(CredentialScopeModel credScope, CredentialOfferState offerState) throws InvalidAuthorizationDetailsException { + + var context = new CredentialClientPolicyContext(ClientPolicyEvent.AUTHORIZATION_REQUEST) + .setCredentialScopeModel(credScope) + .setCredentialOfferState(offerState); + + try { + session.clientPolicy().triggerOnEvent(context); + + // The executor is not called when the policy is disabled or not installed at all + // + if (!context.isEvaluatedOnEvent()) { + + // Required by scope when property is defined and true + // See default value on the policy definition + PredicateCredentialClientPolicy offerRequiredPolicy = VC_POLICY_CREDENTIAL_OFFER_REQUIRED; + boolean requiredByScope = offerRequiredPolicy.validate(new CredentialScopeRepresentation(credScope)); + + if (requiredByScope && offerState == null) { + throw new ClientPolicyException(Errors.NOT_ALLOWED, + "Authorization request rejected by policy " + offerRequiredPolicy.getName() + " for scope " + credScope.getName()); + } + } + + } catch (ClientPolicyException ex) { + throw new InvalidAuthorizationDetailsException(ex.getErrorDetail()); + } + } } 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 5b0fa2c1a603..6278485bca3b 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 @@ -107,7 +107,6 @@ 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.OID4VCUtil; import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantType; import org.keycloak.protocol.oidc.rar.AuthorizationDetailsProcessor; @@ -117,6 +116,7 @@ import org.keycloak.saml.processing.api.util.DeflateUtil; import org.keycloak.services.CorsErrorResponseException; import org.keycloak.services.ErrorResponseException; +import org.keycloak.services.clientpolicy.ClientPolicyException; import org.keycloak.services.cors.Cors; import org.keycloak.services.managers.AppAuthManager; import org.keycloak.services.managers.AuthenticationManager; @@ -142,6 +142,7 @@ import static org.keycloak.protocol.oid4vc.model.ErrorType.INVALID_NONCE; import static org.keycloak.protocol.oid4vc.model.ErrorType.INVALID_PROOF; import static org.keycloak.protocol.oid4vc.model.PreAuthorizedCodeGrant.PRE_AUTH_GRANT_TYPE; +import static org.keycloak.protocol.oid4vc.utils.CredentialScopeModelUtils.findCredentialScopeModelByConfigurationId; /** * Provides the (REST-)endpoints required for the OID4VCI protocol. @@ -510,6 +511,10 @@ public Response createCredentialOffer( offerState = offerProvider.createCredentialOffer(loginUserModel, grantType, credentialConfigurationIds, targetClientId, targetUser, expiresAt); + } catch (ClientPolicyException ex) { + eventBuilder.detail(Details.REASON, ex.getErrorDetail()).error(ErrorType.INVALID_CREDENTIAL_OFFER_REQUEST.getValue()); + throw new CorsErrorResponseException(cors, + ErrorType.INVALID_CREDENTIAL_OFFER_REQUEST.getValue(), ex.getErrorDetail(), Response.Status.BAD_REQUEST); } catch (CredentialOfferException ex) { eventBuilder.detail(Details.REASON, ex.getMessage()).error(ex.getErrorType()); throw new CorsErrorResponseException(cors, @@ -910,7 +915,7 @@ public Response requestCredential(String requestPayload) { // Find credential client scope by requested/authorized credential_configuration_id // - CredentialScopeModel authorizedCredentialScope = CredentialScopeModelUtils.findCredentialScopeModelByConfigurationId( + CredentialScopeModel authorizedCredentialScope = findCredentialScopeModelByConfigurationId( realmModel, () -> clientModel.getClientScopes(false).values().stream(), authorizedCredentialConfigurationId); if (authorizedCredentialScope == null) { diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialoffer/CredentialOfferProvider.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialoffer/CredentialOfferProvider.java index 679f4ed97ee1..ef550264c881 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialoffer/CredentialOfferProvider.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialoffer/CredentialOfferProvider.java @@ -20,6 +20,7 @@ import org.keycloak.models.UserModel; import org.keycloak.provider.Provider; +import org.keycloak.services.clientpolicy.ClientPolicyException; /** * A provider for OID4VCI Credential Offers. @@ -82,7 +83,7 @@ CredentialOfferState createCredentialOffer( String targetClientId, String targetUserId, Integer expireAt - ); + ) throws ClientPolicyException; @Override default void close() { 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 6c251e77cdd0..37b5c002c8d3 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 @@ -36,11 +36,12 @@ 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.services.clientpolicy.ClientPolicyException; import org.keycloak.util.Strings; 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.CredentialScopeModelUtils.findCredentialScopeModelByConfigurationId; /** * Default implementation of {@link CredentialOfferProvider}. @@ -62,7 +63,7 @@ public CredentialOfferState createCredentialOffer( List credentialConfigurationIds, String targetClientId, String targetUsername, - Integer expireAt) { + Integer expireAt) throws ClientPolicyException { // Checks whether `--feature=oid4vc_vci_preauth_code` is enabled // @@ -97,7 +98,7 @@ public CredentialOfferState createCredentialOffer( 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 7f6e8e106ecc..e2f8be287aeb 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 @@ -30,6 +30,7 @@ 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.services.clientpolicy.ClientPolicyException; import org.keycloak.sessions.AuthenticationSessionModel; import org.keycloak.util.JsonSerialization; @@ -177,8 +178,13 @@ private CredentialOfferState createCredentialsOffer(KeycloakSession session, Rea String clientId = actionConfig.getClientId(); CredentialOfferProvider offerProvider = session.getProvider(CredentialOfferProvider.class); - CredentialOfferState offerState = offerProvider.createCredentialOffer(user, grantType, - List.of(credentialConfigurationId), clientId, user.getUsername(), expiresAt); + CredentialOfferState offerState; + try { + offerState = offerProvider.createCredentialOffer(user, grantType, + List.of(credentialConfigurationId), clientId, user.getUsername(), expiresAt); + } catch (ClientPolicyException ex) { + throw new CredentialOfferException(ex.getError(), ex.getErrorDetail()); + } CredentialOfferStorage offerStorage = session.getProvider(CredentialOfferStorage.class); offerStorage.putOfferState(offerState); 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 50eefea1b7a6..a362f1c6b984 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 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 CredentialScopeRepresentation setRequiredKeyAttestationUserAuthentication .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 33ef55bc3389..2a7270590c80 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,8 +91,8 @@ public CredentialsOffer setCredentialConfigurationIds(List credentialCon return this; } - public CredentialOfferGrant getGrant(String grantType) { - return grants.get(grantType); + public Map getGrants() { + return Collections.unmodifiableMap(grants); } public CredentialsOffer addGrant(CredentialOfferGrant grant) { 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 2c2e5bab1d7a..5887b0d1b027 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 @@ -68,11 +68,12 @@ public String getProviderId() { @Override public ClientPolicyVote applyPolicy(ClientPolicyContext context) throws ClientPolicyException { switch (context.getEvent()) { - case PRE_AUTHORIZATION_REQUEST: + case PRE_AUTHORIZATION_REQUEST: { PreAuthorizationRequestContext paContext = (PreAuthorizationRequestContext) context; ClientModel client = session.getContext().getRealm().getClientByClientId(paContext.getClientId()); if (isAttributesMatched(client)) return ClientPolicyVote.YES; return ClientPolicyVote.NO; + } case AUTHORIZATION_REQUEST: case TOKEN_REQUEST: case TOKEN_RESPONSE: @@ -95,8 +96,10 @@ public ClientPolicyVote applyPolicy(ClientPolicyContext context) throws ClientPo case JWT_AUTHORIZATION_GRANT: case SAML_AUTHN_REQUEST: case SAML_LOGOUT_REQUEST: - if (isAttributesMatched(session.getContext().getClient())) return ClientPolicyVote.YES; - return ClientPolicyVote.NO; + case CREDENTIAL_OFFER_CREATE: { + boolean attributesMatched = isAttributesMatched(session.getContext().getClient()); + return attributesMatched ? ClientPolicyVote.YES : ClientPolicyVote.NO; + } default: return ClientPolicyVote.ABSTAIN; } 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 d7ada70d122f..015de4eeba08 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/services/src/main/resources/keycloak-default-client-profiles.json b/services/src/main/resources/keycloak-default-client-profiles.json index bb98d7852941..b16fd4dd0ad3 100644 --- a/services/src/main/resources/keycloak-default-client-profiles.json +++ b/services/src/main/resources/keycloak-default-client-profiles.json @@ -564,6 +564,16 @@ "configuration": {} } ] + }, + { + "name": "oid4vci-client-profile", + "description": "Client profile, which enforces various policies on oid4vci clients.", + "executors": [ + { + "executor": "oid4vci-policy-executor", + "configuration": {} + } + ] } ] } 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 444cb5de8103..fe6471483ec1 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 @@ -40,6 +40,7 @@ import org.keycloak.keys.KeyProvider; 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; @@ -47,6 +48,10 @@ import org.keycloak.protocol.oid4vc.model.CredentialScopeRepresentation; import org.keycloak.protocol.oid4vc.model.DisplayObject; import org.keycloak.protocol.oid4vc.model.OID4VCAuthorizationDetail; +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; @@ -84,6 +89,8 @@ 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; @@ -98,6 +105,7 @@ 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 @@ -421,6 +429,8 @@ public KeycloakServerConfigBuilder configure(KeycloakServerConfigBuilder config) public static class VCTestRealmConfig implements RealmConfig { + private static final Logger LOGGER = Logger.getLogger(VCTestRealmConfig.class); + public static final String TEST_REALM_NAME = "test"; @Override @@ -487,9 +497,52 @@ public RealmBuilder configure(RealmBuilder realm) { realm.addUser(getUserRepresentation("John Doe", Map.of("did", "did:key:1234"), List.of(CREDENTIAL_OFFER_CREATE.getName()), Collections.emptyMap())); realm.addUser(getUserRepresentation("Alice Wonderland", Map.of("did", "did:key:5678"), List.of(), Map.of())); + // Add ClientPolicyRepresentations + // + 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)); + + LOGGER.infof(JsonSerialization.valueAsString(profile)); + 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())); + + LOGGER.infof(JsonSerialization.valueAsString(policy)); + return policy; + } + private CredentialScopeRepresentation createCredentialScope( String scopeName, String issuerDid, 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 087caa6d7ea7..079e8792935a 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,13 +2,18 @@ import java.net.URI; import java.util.List; +import java.util.function.BiFunction; import org.keycloak.TokenVerifier; +import org.keycloak.admin.client.resource.ClientPoliciesPoliciesResource; +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.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.testsuite.util.oauth.AccessTokenResponse; @@ -18,9 +23,13 @@ import org.junit.jupiter.api.Test; 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.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; @KeycloakIntegrationTest(config = VCTestServerConfig.class) public class OID4VCredentialByScopeTest extends OID4VCIssuerTestBase { @@ -77,6 +86,86 @@ public void testNoOffer_Scope_AuthDetails() throws Exception { verifyCredentialResponse(ctx, ctx.getHolder(), credResponse); } + @Test + public void testNoOffer_Scope_RequireOfferPolicy() { + + var ctx = new OID4VCTestContext(client, jwtTypeCredentialScope); + + PredicateCredentialClientPolicy offerRequiredPolicy = VC_POLICY_CREDENTIAL_OFFER_REQUIRED; + + BiFunction runner = (policyEnabled, scopeEnabled) -> { + + // 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 { + // Send AuthorizationRequest + // + AuthorizationEndpointResponse authResponse = wallet + .authorizationRequest() + .scope(ctx.getScope()) + .send(ctx.getHolder(), TEST_PASSWORD); + String authCode = authResponse.getCode(); + assertNotNull(authCode, "No authCode"); + + // Build and send AccessTokenRequest + // + AccessTokenResponse tokenResponse = wallet.accessTokenRequest(ctx, authCode).send(); + + if (policyEnabled || scopeEnabled) { + AssertionError error = assertThrows(AssertionError.class, + () -> wallet.validateHolderAccessToken(ctx, tokenResponse)); + assertTrue(error.getMessage().contains("Authorization request rejected by policy oid4vci-offer-required for scope jwt-credential"), error.getMessage()); + return false; + } else { + 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 (policyEnabled, scopeEnabled) - We use an OR condition + // + assertTrue(runner.apply(false, false), "Offer not required"); + assertFalse(runner.apply(false, true), "Offer required"); + assertFalse(runner.apply(true, false), "Offer required"); + assertFalse(runner.apply(true, true), "Offer required"); + } + // Private --------------------------------------------------------------------------------------------------------- private void verifyCredentialResponse(OID4VCTestContext ctx, String expUser, CredentialResponse credResponse) throws Exception { diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/policies/ClientPoliciesFeatureTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/policies/ClientPoliciesFeatureTest.java index 97adc647dc94..8a65705e6615 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/policies/ClientPoliciesFeatureTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/policies/ClientPoliciesFeatureTest.java @@ -18,9 +18,11 @@ package org.keycloak.testsuite.client.policies; +import java.util.List; import java.util.Set; -import org.keycloak.representations.idm.ClientPoliciesRepresentation; +import org.keycloak.admin.client.resource.ClientPoliciesPoliciesResource; +import org.keycloak.representations.idm.ClientPolicyRepresentation; import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.representations.info.ServerInfoRepresentation; import org.keycloak.services.clientpolicy.condition.ClientPolicyConditionSpi; @@ -40,10 +42,10 @@ /** * This test class is for enabling and disabling client policies by feature mechanism. - * + * * @author Marek Posolda */ -public class ClientPoliciesFeatureTest extends AbstractTestRealmKeycloakTest { +public class ClientPoliciesFeatureTest extends AbstractTestRealmKeycloakTest { @Override public void configureTestRealm(RealmRepresentation testRealm) { @@ -64,8 +66,11 @@ public void testFeatureDoesntWorkWhenDisabled() { // Check if the feature really works private void checkIfFeatureWorks(boolean shouldWork) { try { - ClientPoliciesRepresentation clientPolicies = managedRealm.admin().clientPoliciesPoliciesResource().getPolicies(); - Assertions.assertTrue(clientPolicies.getPolicies().isEmpty()); + ClientPoliciesPoliciesResource policiesResource = managedRealm.admin().clientPoliciesPoliciesResource(); + List clientPolicies = policiesResource.getPolicies().getPolicies(); + Assertions.assertEquals(2, clientPolicies.size()); + Assertions.assertEquals("oid4vci-offer-required", clientPolicies.get(0).getName()); + Assertions.assertEquals("oid4vci-offer-preauth-allowed", clientPolicies.get(1).getName()); if (!shouldWork) fail("Feature is available, but at this moment should be disabled"); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCCredentialOfferCorsTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCCredentialOfferCorsTest.java index f76d0fb9ab57..a157075d9d7c 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCCredentialOfferCorsTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCCredentialOfferCorsTest.java @@ -28,7 +28,6 @@ import org.keycloak.common.Profile; import org.keycloak.common.util.Time; import org.keycloak.events.Details; -import org.keycloak.events.Errors; import org.keycloak.events.EventType; import org.keycloak.protocol.oid4vc.issuance.credentialoffer.CredentialOfferState; import org.keycloak.protocol.oid4vc.issuance.credentialoffer.CredentialOfferStorage; @@ -312,7 +311,7 @@ public void testCredentialOfferUriWithInvalidCredentialConfig() throws Exception .client(clientId) .user(AssertEvents.isUUID()) .session(AssertEvents.isSessionId()) - .error(Errors.INVALID_REQUEST) + .error(ErrorType.INVALID_REQUEST.getValue()) .assertEvent(); } diff --git a/testsuite/integration-arquillian/tests/base/src/test/resources/testrealm.json b/testsuite/integration-arquillian/tests/base/src/test/resources/testrealm.json index ea80cd573ac9..4ea1b9443d6b 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/resources/testrealm.json +++ b/testsuite/integration-arquillian/tests/base/src/test/resources/testrealm.json @@ -667,6 +667,56 @@ } ], + "clientProfiles": { + "profiles": [ + { + "name": "oid4vci-client-profile", + "executors": [ + { + "executor": "oid4vci-policy-executor", + "configuration": {} + } + ] + } + ] + }, + + "clientPolicies": { + "policies": [ + { + "name": "oid4vci-offer-required", + "description": "Client policy to determine whether a credential offers is required", + "enabled": false, + "conditions": [ + { + "condition": "client-attributes", + "configuration": { + "attributes": "[{\"key\":\"oid4vci.enabled\", \"value\":\"true\"}]" + } + } + ], + "profiles": [ + "oid4vci-client-profile" + ] + }, + { + "name": "oid4vci-offer-preauth-allowed", + "description": "Client policy to determine whether 'pre-authorized_code' grant credential offers can be issued", + "enabled": true, + "conditions": [ + { + "condition": "client-attributes", + "configuration": { + "attributes": "[{\"key\":\"oid4vci.enabled\", \"value\":\"true\"}]" + } + } + ], + "profiles": [ + "oid4vci-client-profile" + ] + } + ] + }, "clientScopeMappings": { "test-app": [ From 363597d8aa7682a8a67b1e0829965829e7f365f6 Mon Sep 17 00:00:00 2001 From: Thomas Diesler Date: Tue, 14 Apr 2026 07:37:27 +0200 Subject: [PATCH 2/4] -- add switch control in Admin UI Signed-off-by: Thomas Diesler --- .../keycloak.v2/admin/messages/messages_en.properties | 2 ++ .../admin-ui/src/client-scopes/details/ScopeForm.tsx | 11 +++++++++++ 2 files changed, 13 insertions(+) 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 98b1a4c7078e..fb7ed802f7fb 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 @@ -3697,6 +3697,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 c18b400ccfd6..63f6986ce536 100644 --- a/js/apps/admin-ui/src/client-scopes/details/ScopeForm.tsx +++ b/js/apps/admin-ui/src/client-scopes/details/ScopeForm.tsx @@ -395,6 +395,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", From d293cdfdafdb55ad5408fc1653003a8283706910 Mon Sep 17 00:00:00 2001 From: Thomas Diesler Date: Thu, 5 Mar 2026 12:16:37 +0100 Subject: [PATCH 3/4] [OID4VCI] Add a client policy to allow preauth offers Signed-off-by: Thomas Diesler --- .../CredentialClientPolicies.java | 6 ++ .../CredentialClientPolicyExecutor.java | 32 +++++++++ .../DefaultCredentialOfferProvider.java | 39 ++++++++++ .../tests/oid4vc/OID4VCIssuerTestBase.java | 25 +++++++ .../OID4VCredentialOfferAuthCodeTest.java | 14 ++++ .../OID4VCredentialOfferPreAuthTest.java | 71 +++++++++++++++++++ 6 files changed, 187 insertions(+) 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 index 962dd26803f5..450e7b1fb2f5 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/clientpolicy/CredentialClientPolicies.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/clientpolicy/CredentialClientPolicies.java @@ -7,4 +7,10 @@ public interface CredentialClientPolicies { */ PredicateCredentialClientPolicy VC_POLICY_CREDENTIAL_OFFER_REQUIRED = new PredicateCredentialClientPolicy( "oid4vci-offer-required", "vc.policy.offer.required", true, false); + + /** + * Governs whether Credential Offers with `pre-authorized_code` grant are allowed + */ + PredicateCredentialClientPolicy VC_POLICY_CREDENTIAL_OFFER_PREAUTH_ALLOWED = new PredicateCredentialClientPolicy( + "oid4vci-offer-preauth-allowed", "vc.policy.offer.preauth.allowed", true, true); } 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 index 4f958b44e498..c8346bca6a4e 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/clientpolicy/CredentialClientPolicyExecutor.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/clientpolicy/CredentialClientPolicyExecutor.java @@ -8,6 +8,7 @@ import org.keycloak.models.oid4vci.CredentialScopeModel; import org.keycloak.protocol.oid4vc.issuance.credentialoffer.CredentialOfferState; import org.keycloak.protocol.oid4vc.model.CredentialScopeRepresentation; +import org.keycloak.protocol.oid4vc.model.PreAuthorizedCodeGrant; import org.keycloak.representations.idm.ClientPolicyExecutorConfigurationRepresentation; import org.keycloak.representations.idm.ClientPolicyRepresentation; import org.keycloak.services.clientpolicy.ClientPolicyContext; @@ -17,6 +18,7 @@ import org.jspecify.annotations.Nullable; +import static org.keycloak.protocol.oid4vc.clientpolicy.CredentialClientPolicies.VC_POLICY_CREDENTIAL_OFFER_PREAUTH_ALLOWED; import static org.keycloak.protocol.oid4vc.clientpolicy.CredentialClientPolicies.VC_POLICY_CREDENTIAL_OFFER_REQUIRED; public class CredentialClientPolicyExecutor implements ClientPolicyExecutorProvider { @@ -50,6 +52,36 @@ private void checkCredentialPolicies(CredentialClientPolicyContext context) thro throw new ClientPolicyException(OAuthErrorException.INVALID_SCOPE, "No client scope"); } + // Check policy 'oid4vci-offer-preauth-allowed' + // + if (context.getEvent() == ClientPolicyEvent.CREDENTIAL_OFFER_CREATE) { + + CredentialOfferState offerState = context.getCredentialOfferState(); + if (offerState == null) + throw new ClientPolicyException(OAuthErrorException.UNSUPPORTED_GRANT_TYPE, "No credential offer state"); + + PredicateCredentialClientPolicy preAuthPolicy = VC_POLICY_CREDENTIAL_OFFER_PREAUTH_ALLOWED; + ClientPolicyRepresentation clientPolicy = findClientPolicyByName(session, preAuthPolicy.getName()); + + // Allowed by client when policy is not there or enabled + boolean allowedByClient = clientPolicy == null || clientPolicy.isEnabled(); + + for (String grantType : offerState.getCredentialsOffer().getGrants().keySet()) { + if (grantType.equals(PreAuthorizedCodeGrant.PRE_AUTH_GRANT_TYPE)) { + + // Allowed by scope when property is undefined or true + // See default value on the policy definition + boolean allowedByScope = preAuthPolicy.validate(new CredentialScopeRepresentation(credScope)); + + if (!allowedByClient || !allowedByScope) { + throw new ClientPolicyException(Errors.NOT_ALLOWED, + "Pre-Authorized code grant rejected by policy " + preAuthPolicy.getName() + " for scope " + credScope.getName()); + } + } + } + context.setEvaluatedOnEvent(true); + } + // Check policy 'oid4vci-offer-required' // if (context.getEvent() == ClientPolicyEvent.AUTHORIZATION_REQUEST) { 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 37b5c002c8d3..def1eeb79ce0 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 @@ -20,12 +20,15 @@ import java.util.List; import java.util.Optional; +import org.keycloak.OAuthErrorException; import org.keycloak.common.Profile; import org.keycloak.events.Errors; 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.clientpolicy.CredentialClientPolicyContext; +import org.keycloak.protocol.oid4vc.clientpolicy.PredicateCredentialClientPolicy; import org.keycloak.protocol.oid4vc.issuance.CredentialOfferException; import org.keycloak.protocol.oid4vc.issuance.OID4VCAuthorizationDetailsProcessor; import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerWellKnownProvider; @@ -36,10 +39,14 @@ import org.keycloak.protocol.oid4vc.model.OID4VCAuthorizationDetail; import org.keycloak.protocol.oid4vc.model.PreAuthCodeCtx; import org.keycloak.protocol.oid4vc.model.PreAuthorizedCodeGrant; +import org.keycloak.representations.idm.ClientPolicyRepresentation; +import org.keycloak.services.clientpolicy.ClientPolicyEvent; import org.keycloak.services.clientpolicy.ClientPolicyException; import org.keycloak.util.Strings; import static org.keycloak.constants.OID4VCIConstants.CREDENTIAL_OFFER_CREATE; +import static org.keycloak.protocol.oid4vc.clientpolicy.CredentialClientPolicies.VC_POLICY_CREDENTIAL_OFFER_PREAUTH_ALLOWED; +import static org.keycloak.protocol.oid4vc.clientpolicy.CredentialClientPolicyExecutor.findClientPolicyByName; import static org.keycloak.protocol.oid4vc.model.PreAuthorizedCodeGrant.PRE_AUTH_GRANT_TYPE; import static org.keycloak.protocol.oid4vc.utils.CredentialScopeModelUtils.findCredentialScopeModelByConfigurationId; @@ -117,6 +124,38 @@ public CredentialOfferState createCredentialOffer( credOffer.addGrant(new AuthorizationCodeGrant().setIssuerState(issuerState.encodeToString())); } + // Check associated credential client policies + // + for (String credConfigId : credentialConfigurationIds) { + + // Create the ClientPolicyContext + // + CredentialScopeModel credScope = Optional.ofNullable(findCredentialScopeModelByConfigurationId( + realmModel, () -> session.clientScopes().getClientScopesStream(realmModel), credConfigId)) + .orElseThrow(() -> new ClientPolicyException(OAuthErrorException.INVALID_SCOPE, "No client scope")); + + var context = new CredentialClientPolicyContext(ClientPolicyEvent.CREDENTIAL_OFFER_CREATE) + .setCredentialScopeModel(credScope) + .setCredentialOfferState(offerState); + + // Trigger ClientPolicyExecutors + // + session.clientPolicy().triggerOnEvent(context); + + // The executor is not called when the policy is disabled or not installed at all + // + if (!context.isEvaluatedOnEvent()) { + PredicateCredentialClientPolicy preAuthPolicy = VC_POLICY_CREDENTIAL_OFFER_PREAUTH_ALLOWED; + ClientPolicyRepresentation clientPolicy = findClientPolicyByName(session, preAuthPolicy.getName()); + + // Currently, we are permissive when the policy is not installed + if (clientPolicy != null) { + throw new ClientPolicyException(Errors.NOT_ALLOWED, + "Pre-Authorized code grant rejected by policy " + preAuthPolicy.getName() + " for scope " + credScope.getName()); + } + } + } + return offerState; } 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 fe6471483ec1..f9cbb34399b4 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 @@ -105,6 +105,7 @@ 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_PREAUTH_ALLOWED; import static org.keycloak.protocol.oid4vc.clientpolicy.CredentialClientPolicies.VC_POLICY_CREDENTIAL_OFFER_REQUIRED; /** @@ -501,6 +502,7 @@ public RealmBuilder configure(RealmBuilder realm) { // ClientProfileRepresentation profile = createClientPolicyProfile(); realm.clientPolicy(createClientPolicyOfferRequired(profile)); + realm.clientPolicy(createClientPolicyOfferPreAuthAllowed(profile)); realm.clientProfile(profile); return realm; @@ -543,6 +545,29 @@ private ClientPolicyRepresentation createClientPolicyOfferRequired(ClientProfile return policy; } + private ClientPolicyRepresentation createClientPolicyOfferPreAuthAllowed(ClientProfileRepresentation profile) { + + ClientPolicyRepresentation policy = new ClientPolicyRepresentation(); + policy.setName(VC_POLICY_CREDENTIAL_OFFER_PREAUTH_ALLOWED.getName()); + policy.setDescription("Client policy to determine whether 'pre-authorized_code' grant credential offers can be issued"); + policy.setEnabled(true); + + 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())); + + LOGGER.infof(JsonSerialization.valueAsString(policy)); + return policy; + } + private CredentialScopeRepresentation createCredentialScope( String scopeName, String issuerDid, diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCredentialOfferAuthCodeTest.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCredentialOfferAuthCodeTest.java index a89233eeb5ea..bd08093518ef 100644 --- a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCredentialOfferAuthCodeTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCredentialOfferAuthCodeTest.java @@ -10,6 +10,8 @@ import org.keycloak.protocol.oid4vc.model.OfferResponseType; import org.keycloak.protocol.oid4vc.model.VerifiableCredential; import org.keycloak.representations.JsonWebToken; +import org.keycloak.representations.idm.ClientPoliciesRepresentation; +import org.keycloak.representations.idm.ClientProfilesRepresentation; import org.keycloak.sdjwt.IssuerSignedJWT; import org.keycloak.sdjwt.vp.SdJwtVP; import org.keycloak.testframework.annotations.KeycloakIntegrationTest; @@ -49,6 +51,18 @@ @KeycloakIntegrationTest(config = VCTestServerConfig.class) public class OID4VCredentialOfferAuthCodeTest extends OID4VCIssuerTestBase { + @Test + public void testRealmSetup() { + ClientProfilesRepresentation clientProfiles = testRealm.admin().clientPoliciesProfilesResource().getProfiles(true); + assertEquals(1, clientProfiles.getProfiles().size()); + assertEquals("oid4vci-client-profile", clientProfiles.getProfiles().get(0).getName()); + + ClientPoliciesRepresentation clientPolicies = testRealm.admin().clientPoliciesPoliciesResource().getPolicies(); + assertEquals(2, clientPolicies.getPolicies().size()); + assertEquals("oid4vci-offer-required", clientPolicies.getPolicies().get(0).getName()); + assertEquals("oid4vci-offer-preauth-allowed", clientPolicies.getPolicies().get(1).getName()); + } + @Test public void testAuthCodeOffer_Anonymous() throws Exception { diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/preauth/OID4VCredentialOfferPreAuthTest.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/preauth/OID4VCredentialOfferPreAuthTest.java index 51ea165fdf4f..185bcbee9abe 100644 --- a/tests/base/src/test/java/org/keycloak/tests/oid4vc/preauth/OID4VCredentialOfferPreAuthTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/preauth/OID4VCredentialOfferPreAuthTest.java @@ -2,14 +2,19 @@ import java.net.URI; import java.util.List; +import java.util.function.BiFunction; import org.keycloak.TokenVerifier; +import org.keycloak.admin.client.resource.ClientPoliciesPoliciesResource; import org.keycloak.admin.client.resource.UserResource; +import org.keycloak.protocol.oid4vc.clientpolicy.PredicateCredentialClientPolicy; import org.keycloak.protocol.oid4vc.model.CredentialOfferURI; import org.keycloak.protocol.oid4vc.model.CredentialResponse; import org.keycloak.protocol.oid4vc.model.CredentialsOffer; 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.representations.idm.UserRepresentation; import org.keycloak.testframework.annotations.KeycloakIntegrationTest; import org.keycloak.tests.oid4vc.OID4VCIssuerTestBase; @@ -20,7 +25,10 @@ import org.junit.jupiter.api.Test; +import static org.keycloak.protocol.oid4vc.clientpolicy.CredentialClientPolicies.VC_POLICY_CREDENTIAL_OFFER_PREAUTH_ALLOWED; + 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.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -149,6 +157,69 @@ public void testPreAuthOffer_Targeted() throws Exception { assertEquals("Credential offer not found or already consumed", res.getErrorDescription()); } + @Test + public void testPreAuthOffer_NotAllowed() throws Exception { + + var ctx = new OID4VCTestContext(client, jwtTypeCredentialScope); + + PredicateCredentialClientPolicy preAuthPolicy = VC_POLICY_CREDENTIAL_OFFER_PREAUTH_ALLOWED; + + BiFunction runner = (policyEnabled, scopeEnabled) -> { + + // Set client policy 'oid4vci-offer-preauth-allowed' + // + ClientPoliciesPoliciesResource clientPoliciesResource = testRealm.admin().clientPoliciesPoliciesResource(); + ClientPoliciesRepresentation policies = clientPoliciesResource.getPolicies(); + ClientPolicyRepresentation clientPolicy = policies.getPolicies().stream() + .filter(cp -> cp.getName().equals(preAuthPolicy.getName())) + .findFirst().orElseThrow(); + Boolean wasPolicyEnabled = clientPolicy.isEnabled(); + clientPolicy.setEnabled(policyEnabled); + clientPoliciesResource.updatePolicies(policies); + + // Set client scope attribute 'vc.policy.offer.preauth.allowed' + // + var credScope = ctx.getCredentialScope(); + Boolean wasScopeEnabled = credScope.getCredentialPolicyValue(preAuthPolicy); + credScope.setCredentialPolicyValue(preAuthPolicy, scopeEnabled); + updateCredentialScope(credScope); + + try { + if (policyEnabled && scopeEnabled) { + CredentialsOffer credOffer = wallet.createCredentialOffer(ctx, req -> { + req.targetUser(ctx.getHolder()); + req.preAuthorized(true); + }); + assertNotNull(credOffer.getPreAuthorizedCode(), "Expected pre-auth offer"); + return true; + } else { + IllegalStateException error = assertThrows(IllegalStateException.class, + () -> wallet.createCredentialOffer(ctx, req -> { + req.targetUser(ctx.getHolder()); + req.preAuthorized(true); + })); + assertTrue(error.getMessage().contains("Pre-Authorized code grant rejected by policy oid4vci-offer-preauth-allowed for scope jwt-credential"), error.getMessage()); + return false; + } + } finally { + clientPolicy.setEnabled(wasPolicyEnabled); + clientPoliciesResource.updatePolicies(policies); + + credScope.setCredentialPolicyValue(preAuthPolicy, wasScopeEnabled); + updateCredentialScope(credScope); + + wallet.logout(ctx.getHolder()); + } + }; + + // Verification matrix (policyEnabled, scopeEnabled) - We use an AND condition + // + assertFalse(runner.apply(false, false), "Pre-Auth offer denied"); + assertFalse(runner.apply(false, true), "Pre-Auth offer denied"); + assertFalse(runner.apply(true, false), "Pre-Auth offer denied"); + assertTrue(runner.apply(true, true), "Pre-Auth offer allowed"); + } + // Private --------------------------------------------------------------------------------------------------------- private void verifyCredentialResponse(OID4VCTestContext ctx, String expUser, CredentialResponse credResponse) throws Exception { From e89af87a3bda297c0caeb20f2a3d2437f8870a33 Mon Sep 17 00:00:00 2001 From: Thomas Diesler Date: Fri, 6 Mar 2026 19:23:32 +0100 Subject: [PATCH 4/4] [OID4VCI] Restore support for pre-auth tx_code Signed-off-by: Thomas Diesler --- .../oid4vc/issuance/OID4VCIssuerEndpoint.java | 9 +- .../CredentialOfferProvider.java | 1 + .../credentialoffer/CredentialOfferState.java | 16 ++++ .../DefaultCredentialOfferProvider.java | 5 +- .../VerifiableCredentialOfferAction.java | 2 +- .../oid4vc/model/CredentialOfferURI.java | 12 +++ .../grants/PreAuthorizedCodeGrantType.java | 87 ++++++++++--------- .../tests/oid4vc/OID4VCBasicWallet.java | 23 +++-- .../oid4vc/OID4VCJWTIssuerEndpointTest.java | 2 +- .../tests/oid4vc/OID4VCTestContext.java | 2 + .../preauth/JwtPreAuthCodeHandlerTest.java | 8 +- .../preauth/OID4VCActionPreAuthTest.java | 2 +- .../OID4VCPublicClientPreAuthTest.java | 4 +- .../OID4VCredentialOfferPreAuthTest.java | 39 ++++++++- .../oid4vc/CredentialOfferUriRequest.java | 11 +++ .../oid4vc/PreAuthorizedCodeGrantRequest.java | 7 ++ .../rest/TestingResourceProvider.java | 2 +- .../OID4VCCredentialOfferCorsTest.java | 2 +- 18 files changed, 167 insertions(+), 67 deletions(-) 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 6278485bca3b..385e62a4f173 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 @@ -372,7 +372,7 @@ public Response createCredentialOffer(String credConfigId) { * Creates a Credential Offer that is bound to a specific user. */ public Response createCredentialOffer(String credConfigId, boolean preAuthorized, String targetUser) { - return createCredentialOffer(credConfigId, preAuthorized, targetUser, null, OfferResponseType.URI, 0, 0); + return createCredentialOffer(credConfigId, preAuthorized, false, targetUser, null, OfferResponseType.URI, 0, 0); } /** @@ -430,6 +430,7 @@ public Response createCredentialOffer(String credConfigId, boolean preAuthorized * @param preAuthorized A flag whether the offer should be pre-authorized * @param targetUser The username that the offer is authorized for * @param expiresAt The date/time when the offer expires (in Unix timestamp seconds) + * @param withTxCode A flag whether a tx_code should be generated for a pre-auth offer * @param responseType The response type, which can be 'uri', 'qr' or 'uri+qr' * @param width The width of the QR code image * @param height The height of the QR code image @@ -440,6 +441,7 @@ public Response createCredentialOffer(String credConfigId, boolean preAuthorized public Response createCredentialOffer( @QueryParam("credential_configuration_id") String credentialConfigurationId, @QueryParam("pre_authorized") @DefaultValue("false") Boolean preAuthorized, + @QueryParam("tx_code") @DefaultValue("false") Boolean withTxCode, @QueryParam("target_user") String targetUser, @QueryParam("expire") Integer expiresAt, @QueryParam("type") @DefaultValue("uri") OfferResponseType responseType, @@ -509,7 +511,7 @@ public Response createCredentialOffer( CredentialOfferProvider offerProvider = session.getProvider(CredentialOfferProvider.class); offerState = offerProvider.createCredentialOffer(loginUserModel, grantType, - credentialConfigurationIds, targetClientId, targetUser, expiresAt); + credentialConfigurationIds, targetClientId, targetUser, withTxCode, expiresAt); } catch (ClientPolicyException ex) { eventBuilder.detail(Details.REASON, ex.getErrorDetail()).error(ErrorType.INVALID_CREDENTIAL_OFFER_REQUEST.getValue()); @@ -540,7 +542,8 @@ public Response createCredentialOffer( CredentialsOffer credOffer = offerState.getCredentialsOffer(); CredentialOfferURI credOfferURI = new CredentialOfferURI() .setIssuer(credOffer.getCredentialIssuer() + "/protocol/" + OID4VC_PROTOCOL + "/" + CREDENTIAL_OFFER_PATH) - .setNonce(offerState.getNonce()); + .setNonce(offerState.getNonce()) + .setTxCode(offerState.getTxCode()); // Respond with QR-Code as 'image/png' if (responseType == OfferResponseType.QR) { diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialoffer/CredentialOfferProvider.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialoffer/CredentialOfferProvider.java index ef550264c881..22bafa5b9c13 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialoffer/CredentialOfferProvider.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialoffer/CredentialOfferProvider.java @@ -82,6 +82,7 @@ CredentialOfferState createCredentialOffer( List credentialConfigurationIds, String targetClientId, String targetUserId, + Boolean withTxCode, Integer expireAt ) throws ClientPolicyException; diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialoffer/CredentialOfferState.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialoffer/CredentialOfferState.java index e63ad62d4597..ed565a0b5d7e 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialoffer/CredentialOfferState.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialoffer/CredentialOfferState.java @@ -16,6 +16,7 @@ */ package org.keycloak.protocol.oid4vc.issuance.credentialoffer; +import java.security.SecureRandom; import java.util.Collections; import java.util.List; import java.util.Optional; @@ -53,12 +54,14 @@ public class CredentialOfferState { * @param clientId The target client_id * @param userId The target user id * @param expiresAt The expiry date of the offer in seconds + * @param withTxCode A flag to indicate whether a tx_code should be generated * @param authDetailsProvider A provider function for authorization details, (optionally) one for each credential_configuration_id */ public CredentialOfferState( CredentialsOffer credOffer, String clientId, String userId, + boolean withTxCode, long expiresAt, Function> authDetailsProvider ) { @@ -68,6 +71,9 @@ public CredentialOfferState( this.targetUserId = userId; this.expiresAt = expiresAt; this.nonce = Base64Url.encode(RandomSecret.createRandomSecret(64)); + if (withTxCode) { + this.txCode = generateTxCode(); + } if (authDetailsProvider != null) { this.authDetails = authDetailsProvider.apply(credentialsOfferId); } @@ -134,4 +140,14 @@ private CredentialOfferState() { private void setAuthorizationDetails(List authDetails) { this.authDetails = authDetails; } + + private String generateTxCode() { + SecureRandom rnd = new SecureRandom(); + char[] alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".toCharArray(); + StringBuilder sb = new StringBuilder(6); + for (int i = 0; i < 6; i++) { + sb.append(alphabet[rnd.nextInt(alphabet.length)]); + } + return sb.toString(); + } } 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 def1eeb79ce0..a1d3928603b9 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 @@ -70,6 +70,7 @@ public CredentialOfferState createCredentialOffer( List credentialConfigurationIds, String targetClientId, String targetUsername, + Boolean withTxCode, Integer expireAt) throws ClientPolicyException { // Checks whether `--feature=oid4vc_vci_preauth_code` is enabled @@ -86,7 +87,7 @@ public CredentialOfferState createCredentialOffer( throw new CredentialOfferException(Errors.INVALID_REQUEST, "No credentialConfigurationIds"); } - RealmModel realmModel = this.session.getContext().getRealm(); + RealmModel realmModel = session.getContext().getRealm(); // Validate the target user // @@ -102,7 +103,7 @@ public CredentialOfferState createCredentialOffer( // Create the CredentialOfferState // - CredentialOfferState offerState = new CredentialOfferState(credOffer, targetClientId, targetUserId, expireAt, credOffersId -> { + CredentialOfferState offerState = new CredentialOfferState(credOffer, targetClientId, targetUserId, withTxCode, expireAt, credOffersId -> { List authDetails = new ArrayList<>(); for (String credConfigId : credentialConfigurationIds) { CredentialScopeModel credScope = findCredentialScopeModelByConfigurationId( 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 e2f8be287aeb..adb32a4464b8 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 @@ -181,7 +181,7 @@ private CredentialOfferState createCredentialsOffer(KeycloakSession session, Rea CredentialOfferState offerState; try { offerState = offerProvider.createCredentialOffer(user, grantType, - List.of(credentialConfigurationId), clientId, user.getUsername(), expiresAt); + List.of(credentialConfigurationId), clientId, user.getUsername(), false, expiresAt); } catch (ClientPolicyException ex) { throw new CredentialOfferException(ex.getError(), ex.getErrorDetail()); } diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialOfferURI.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialOfferURI.java index fe2c15081066..aa38eae684b8 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialOfferURI.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialOfferURI.java @@ -35,6 +35,9 @@ public class CredentialOfferURI { private String issuer; private String nonce; + @JsonProperty("tx_code") + private String txCode; + @JsonProperty("qr_code") private String qrCode; @@ -56,6 +59,15 @@ public CredentialOfferURI setNonce(String nonce) { return this; } + public String getTxCode() { + return txCode; + } + + public CredentialOfferURI setTxCode(String txCode) { + this.txCode = txCode; + return this; + } + public String getQrCode() { return qrCode; } 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 7fbca430fe7d..6805b0eb93ba 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 @@ -43,6 +43,7 @@ import org.keycloak.models.oid4vci.CredentialScopeModel; import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint; import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerWellKnownProvider; +import org.keycloak.protocol.oid4vc.issuance.credentialoffer.CredentialOfferState; import org.keycloak.protocol.oid4vc.issuance.credentialoffer.CredentialOfferStorage; import org.keycloak.protocol.oid4vc.issuance.credentialoffer.preauth.PreAuthCodeHandler; import org.keycloak.protocol.oid4vc.model.CredentialsOffer; @@ -89,8 +90,6 @@ public Response process(Context context) { // Get token request parameters // https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-token-request String preAuthCode = formParams.getFirst(PreAuthorizedCodeGrant.CODE_REQUEST_PARAM); - String txCode = formParams.getFirst(PreAuthorizedCodeGrant.TX_CODE_PARAM); - if (preAuthCode == null) { // See: https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-token-request String errorMessage = "Missing parameter: " + PRE_AUTH_GRANT_TYPE; @@ -101,39 +100,7 @@ public Response process(Context context) { // Verify the pre-auth code and recover the public context associated with it. // The verification logic is delegated to the configured PreAuthCodeHandler provider. - PreAuthCodeCtx ctx = verifyPreAuthCode(preAuthCode); - - // Recover matching credential offer state in storage - var offerStorage = session.getProvider(CredentialOfferStorage.class); - var offerState = offerStorage.getOfferStateById(ctx.getCredentialsOfferId()); - if (offerState == null) { - var errorMessage = "No credential offer state for pre-auth code: " + preAuthCode; - event.detail(REASON, errorMessage).error(Errors.INVALID_CODE); - throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, - errorMessage, Response.Status.BAD_REQUEST); - } - - if (offerState.isExpired()) { - event.error(Errors.EXPIRED_CODE); - throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_GRANT, - "Code is expired", Response.Status.BAD_REQUEST); - } - - String expTxCode = offerState.getTxCode(); - if (expTxCode != null) { - if (Strings.isEmpty(txCode)) { - event.error(Errors.MISSING_TX_CODE); - throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_GRANT, - "Missing TxCode", Response.Status.BAD_REQUEST); - } - // Prevent timing attacks - execution time does not depend on where the first difference occurs - if (!MessageDigest.isEqual(expTxCode.getBytes(), txCode.getBytes())) { - event.error(Errors.INVALID_TX_CODE); - throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_GRANT, - "Invalid TxCode", Response.Status.BAD_REQUEST); - } - } - + CredentialOfferState offerState = verifyPreAuthCode(preAuthCode); CredentialsOffer credOffer = offerState.getCredentialsOffer(); var targetUserId = offerState.getTargetUserId(); @@ -278,11 +245,14 @@ public boolean isTokenAllowed(KeycloakSession session, AccessToken token) { return audiences != null && audiences.length == 1 && expectedAudience.equals(audiences[0]); } + // Private --------------------------------------------------------------------------------------------------------- + /** * Runs the pre-auth code verification logic using the configured PreAuthCodeHandler provider. - * A public, partial view of the CredentialOfferState is returned upon successful verification. + * @return CredentialOfferState upon successful verification */ - private PreAuthCodeCtx verifyPreAuthCode(String code) { + private CredentialOfferState verifyPreAuthCode(String preAuthCode) { + PreAuthCodeHandler preAuthCodeHandler = session.getProvider(PreAuthCodeHandler.class); if (preAuthCodeHandler == null) { throw new IllegalStateException("No PreAuthCodeHandler provider available"); @@ -290,7 +260,7 @@ private PreAuthCodeCtx verifyPreAuthCode(String code) { PreAuthCodeCtx preAuthCodeCtx; try { - preAuthCodeCtx = preAuthCodeHandler.verifyPreAuthCode(code); + preAuthCodeCtx = preAuthCodeHandler.verifyPreAuthCode(preAuthCode); } catch (VerificationException e) { String errorType = Optional.ofNullable(e.getErrorType()).orElse(Errors.INVALID_CODE); String errorMessage = String.format("Pre-authorized code failed handler verification (%s)", errorType); @@ -302,10 +272,43 @@ private PreAuthCodeCtx verifyPreAuthCode(String code) { : OAuthErrorException.EXPIRED_TOKEN, errorMessage, Response.Status.BAD_REQUEST); } + String credOfferId = preAuthCodeCtx.getCredentialsOfferId(); + + // Get matching credential offer state from storage + var offerStorage = session.getProvider(CredentialOfferStorage.class); + var offerState = offerStorage.getOfferStateById(credOfferId); + if (offerState == null) { + var errorMessage = "No credential offer state: " + credOfferId; + event.detail(REASON, errorMessage).error(Errors.INVALID_CODE); + throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, + errorMessage, Response.Status.BAD_REQUEST); + } + + if (offerState.isExpired()) { + event.error(Errors.EXPIRED_CODE); + throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_GRANT, + "Code is expired", Response.Status.BAD_REQUEST); + } + String txCode = formParams.getFirst(PreAuthorizedCodeGrant.TX_CODE_PARAM); + + String expTxCode = offerState.getTxCode(); + if (expTxCode != null) { + if (Strings.isEmpty(txCode)) { + event.error(Errors.MISSING_TX_CODE); + throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_GRANT, + "Missing TxCode", Response.Status.BAD_REQUEST); + } + // Prevent timing attacks - execution time does not depend on where the first difference occurs + if (!MessageDigest.isEqual(expTxCode.getBytes(), txCode.getBytes())) { + event.error(Errors.INVALID_TX_CODE); + throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_GRANT, + "Invalid TxCode", Response.Status.BAD_REQUEST); + } + } // Pre-auth code is valid, but let's prevent replay attacks (for the remaining validity period) SingleUseObjectProvider singleUseStore = session.singleUseObjects(); - String key = getPreAuthCodeSingleObjectKey(code); + String key = getPreAuthCodeSingleObjectKey(preAuthCode); long expiresIn = preAuthCodeCtx.getExpiresAt() - Time.currentTime(); boolean firstInsertion = singleUseStore.putIfAbsent(key, expiresIn); if (!firstInsertion) { @@ -315,11 +318,11 @@ private PreAuthCodeCtx verifyPreAuthCode(String code) { errorMessage, Response.Status.BAD_REQUEST); } - return preAuthCodeCtx; + return offerState; } - private static String getPreAuthCodeSingleObjectKey(String code) { - String hash = HashUtils.sha256UrlEncodedHash(code.trim(), StandardCharsets.UTF_8); + private static String getPreAuthCodeSingleObjectKey(String preAuthCode) { + String hash = HashUtils.sha256UrlEncodedHash(preAuthCode.trim(), StandardCharsets.UTF_8); String fqcn = PreAuthorizedCodeGrantType.class.getName().toLowerCase(); return fqcn + "." + hash; } diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCBasicWallet.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCBasicWallet.java index a6cf74e2baa0..cf791f8752aa 100644 --- a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCBasicWallet.java +++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCBasicWallet.java @@ -63,6 +63,7 @@ import static org.keycloak.tests.oid4vc.OID4VCTestContext.AttachmentKey; import static org.keycloak.tests.oid4vc.OID4VCTestContext.CLIENT_ATTESTER_ATTACHMENT_KEY; import static org.keycloak.tests.oid4vc.OID4VCTestContext.CREDENTIALS_OFFER_RESPONSE_ATTACHMENT_KEY; +import static org.keycloak.tests.oid4vc.OID4VCTestContext.CREDENTIALS_OFFER_URI_REQUEST_ATTACHMENT_KEY; import static org.keycloak.tests.oid4vc.OID4VCTestContext.CREDENTIALS_OFFER_URI_RESPONSE_ATTACHMENT_KEY; import static org.keycloak.tests.oid4vc.OID4VCTestContext.CREDENTIALS_RESPONSE_ATTACHMENT_KEY; import static org.keycloak.tests.oid4vc.OID4VCTestContext.ISSUER_METADATA_ATTACHMENT_KEY; @@ -116,9 +117,9 @@ public CredentialOfferURI createCredentialOfferUri(OID4VCTestContext ctx, Consum CredentialOfferUriResponse credOfferUriRes; try { String credConfigId = ctx.getCredentialConfigurationId(); - var uriRequest = credentialOfferUriRequest(ctx, credConfigId); - consumer.accept(uriRequest.bearerToken(issToken)); - credOfferUriRes = uriRequest.send(); + var credOfferUriRequest = credentialOfferUriRequest(ctx, credConfigId); + consumer.accept(credOfferUriRequest.bearerToken(issToken)); + credOfferUriRes = credOfferUriRequest.send(); } finally { logout(ctx.getIssuer()); } @@ -135,6 +136,11 @@ public CredentialsOffer createCredentialOffer(OID4VCTestContext ctx, Consumer AUTHORIZATION_SERVER_METADATA_ATTACHMENT_KEY = new AttachmentKey<>(OIDCConfigurationRepresentation.class); public static final AttachmentKey ISSUER_METADATA_ATTACHMENT_KEY = new AttachmentKey<>(CredentialIssuer.class); public static final AttachmentKey CLIENT_ATTESTER_ATTACHMENT_KEY = new AttachmentKey<>(OIDCClientAttester.class); + public static final AttachmentKey CREDENTIALS_OFFER_URI_REQUEST_ATTACHMENT_KEY = new AttachmentKey<>(CredentialOfferUriRequest.class); public static final AttachmentKey CREDENTIALS_OFFER_URI_RESPONSE_ATTACHMENT_KEY = new AttachmentKey<>(CredentialOfferUriResponse.class); public static final AttachmentKey CREDENTIALS_OFFER_RESPONSE_ATTACHMENT_KEY = new AttachmentKey<>(CredentialOfferResponse.class); public static final AttachmentKey ACCESS_TOKEN_RESPONSE_ATTACHMENT_KEY = new AttachmentKey<>(AccessTokenResponse.class); diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/preauth/JwtPreAuthCodeHandlerTest.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/preauth/JwtPreAuthCodeHandlerTest.java index c369c2176fe7..1194c6f89b7b 100644 --- a/tests/base/src/test/java/org/keycloak/tests/oid4vc/preauth/JwtPreAuthCodeHandlerTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/preauth/JwtPreAuthCodeHandlerTest.java @@ -72,7 +72,7 @@ public void shouldGenerateValidJwtPreAuthCodes() { assertPreAuthCodeCtx(preAuthCodeCtx); // Ensure that the pre-auth code can be exchanged for an access token - AccessTokenResponse resp = wallet.accessTokenRequestPreAuth(ctx, preAuthCode).send(); + AccessTokenResponse resp = wallet.accessTokenRequestPreAuth(ctx, preAuthCode, null).send(); assertEquals(HttpStatus.SC_OK, resp.getStatusCode()); assertNotNull(resp.getAccessToken(), "Access token must not be null"); } @@ -106,7 +106,7 @@ public void mustRejectNonPreAuthCodeJwts() { }); // Ensure that it cannot be exchanged for an access token - AccessTokenResponse resp = wallet.accessTokenRequestPreAuth(ctx, imposterPreAuthCode).send(); + AccessTokenResponse resp = wallet.accessTokenRequestPreAuth(ctx, imposterPreAuthCode, null).send(); assertEquals(HttpStatus.SC_BAD_REQUEST, resp.getStatusCode()); assertEquals("Pre-authorized code failed handler verification (invalid_code)", resp.getErrorDescription()); @@ -156,11 +156,11 @@ public void mustRejectReplayedPreAuthCodeJwts() { assertValidPreAuthCodeJwt(preAuthCode); // First use: the pre-auth code can be exchanged for an access token - AccessTokenResponse resp = wallet.accessTokenRequestPreAuth(ctx, preAuthCode).send(); + AccessTokenResponse resp = wallet.accessTokenRequestPreAuth(ctx, preAuthCode, null).send(); assertEquals(HttpStatus.SC_OK, resp.getStatusCode()); // Second use: the same pre-auth code must be rejected as replayed - AccessTokenResponse replayResp = wallet.accessTokenRequestPreAuth(ctx, preAuthCode).send(); + AccessTokenResponse replayResp = wallet.accessTokenRequestPreAuth(ctx, preAuthCode, null).send(); assertEquals(HttpStatus.SC_BAD_REQUEST, replayResp.getStatusCode()); assertEquals("Pre-authorized code has already been used", replayResp.getErrorDescription()); } diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/preauth/OID4VCActionPreAuthTest.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/preauth/OID4VCActionPreAuthTest.java index 46ab07416e35..a0401a3ae34d 100644 --- a/tests/base/src/test/java/org/keycloak/tests/oid4vc/preauth/OID4VCActionPreAuthTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/preauth/OID4VCActionPreAuthTest.java @@ -101,7 +101,7 @@ public void testCredentialOfferAIASuccess_preAuthorizedCode() throws Exception { // Redeem Pre-Authorized Code for AccessToken // - AccessTokenResponse tokenResponse = wallet.accessTokenRequestPreAuth(ctx, preAuthCode).send(); + AccessTokenResponse tokenResponse = wallet.accessTokenRequestPreAuth(ctx, preAuthCode, null).send(); assertTrue(tokenResponse.isSuccess(), tokenResponse.getErrorDescription()); String accessToken = wallet.validateHolderAccessToken(ctx, tokenResponse); diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/preauth/OID4VCPublicClientPreAuthTest.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/preauth/OID4VCPublicClientPreAuthTest.java index 5b5d685e165b..04fcd98cdfea 100644 --- a/tests/base/src/test/java/org/keycloak/tests/oid4vc/preauth/OID4VCPublicClientPreAuthTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/preauth/OID4VCPublicClientPreAuthTest.java @@ -63,11 +63,13 @@ public void testCredentialFromPreAuthCode() throws Exception { req.targetUser(ctx.getHolder()); req.preAuthorized(true); }); + String preAuthCode = credOffer.getPreAuthorizedCode(); + assertNotNull(preAuthCode, "preAuthCode"); // Redeem Pre-Authorized Code for AccessToken // - AccessTokenResponse tokenResponse = wallet.accessTokenRequestPreAuth(ctx, preAuthCode).send(); + AccessTokenResponse tokenResponse = wallet.accessTokenRequestPreAuth(ctx, preAuthCode, null).send(); assertTrue(tokenResponse.isSuccess(), tokenResponse.getErrorDescription()); String accessToken = wallet.validateHolderAccessToken(ctx, tokenResponse); diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/preauth/OID4VCredentialOfferPreAuthTest.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/preauth/OID4VCredentialOfferPreAuthTest.java index 185bcbee9abe..e4186d2a78fc 100644 --- a/tests/base/src/test/java/org/keycloak/tests/oid4vc/preauth/OID4VCredentialOfferPreAuthTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/preauth/OID4VCredentialOfferPreAuthTest.java @@ -86,17 +86,18 @@ public void testPreAuthOffer_SelfIssued() throws Exception { }); String preAuthCode = credOffer.getPreAuthorizedCode(); + assertNotNull(preAuthCode, "preAuthCode"); // Redeem Pre-Authorized Code for AccessToken // - AccessTokenResponse tokenResponse = wallet.accessTokenRequestPreAuth(ctx, preAuthCode).send(); + AccessTokenResponse tokenResponse = wallet.accessTokenRequestPreAuth(ctx, preAuthCode, null).send(); assertTrue(tokenResponse.isSuccess(), tokenResponse.getErrorDescription()); String accessToken = wallet.validateHolderAccessToken(ctx, tokenResponse); - assertNotNull(accessToken, "No accessToken"); + assertNotNull(accessToken,"No accessToken"); String authorizedIdentifier = ctx.getAuthorizedCredentialIdentifier(); - assertNotNull(authorizedIdentifier, "No authorized credential identifier"); + assertNotNull(authorizedIdentifier,"No authorized credential identifier"); // Send the CredentialRequest // @@ -133,7 +134,7 @@ public void testPreAuthOffer_Targeted() throws Exception { // Redeem Pre-Authorized Code for AccessToken // - AccessTokenResponse tokenResponse = wallet.accessTokenRequestPreAuth(ctx, preAuthCode).send(); + AccessTokenResponse tokenResponse = wallet.accessTokenRequestPreAuth(ctx, preAuthCode, null).send(); assertTrue(tokenResponse.isSuccess(), tokenResponse.getErrorDescription()); String accessToken = wallet.validateHolderAccessToken(ctx, tokenResponse); @@ -220,6 +221,36 @@ public void testPreAuthOffer_NotAllowed() throws Exception { assertTrue(runner.apply(true, true), "Pre-Auth offer allowed"); } + @Test + public void testPreAuthOffer_Targeted_Invalid_TxCode() throws Exception { + var ctx = new OID4VCTestContext(client, jwtTypeCredentialScope); + + // Create Pre-Authorized CredentialOffer + // + CredentialsOffer credOffer = wallet.createCredentialOffer(ctx, req -> { + req.targetUser(ctx.getHolder()); + req.preAuthorized(true); + req.txCode(true); + }); + + String preAuthCode = credOffer.getPreAuthorizedCode(); + assertNotNull(preAuthCode, "preAuthCode"); + + // Test missing TxCode + { + AccessTokenResponse tokenResponse = wallet.accessTokenRequestPreAuth(ctx, preAuthCode, null).send(); + AssertionError error = assertThrows(AssertionError.class, () -> wallet.validateHolderAccessToken(ctx, tokenResponse)); + assertTrue(error.getMessage().contains("Missing TxCode"), error.getMessage()); + } + + // Test wrong TxCode + { + AccessTokenResponse tokenResponse = wallet.accessTokenRequestPreAuth(ctx, preAuthCode, "wrong-code").send(); + AssertionError error = assertThrows(AssertionError.class, () -> wallet.validateHolderAccessToken(ctx, tokenResponse)); + assertTrue(error.getMessage().contains("Invalid TxCode"), error.getMessage()); + } + } + // Private --------------------------------------------------------------------------------------------------------- private void verifyCredentialResponse(OID4VCTestContext ctx, String expUser, CredentialResponse credResponse) throws Exception { 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 2e735b45968a..9dfecb7d7f8d 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 @@ -15,6 +15,7 @@ public class CredentialOfferUriRequest extends AbstractHttpGetRequest { private final String preAuthCode; + private String txCode; public PreAuthorizedCodeGrantRequest(AbstractOAuthClient client, String preAuthorizedCode) { super(client); @@ -27,6 +28,11 @@ public PreAuthorizedCodeGrantRequest authorizationDetails(List { + CredentialOfferState offerState = new CredentialOfferState(credOffer, clientId, targetUserId, false, expireAt, credOfferId -> { OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail(); authDetail.setType(OPENID_CREDENTIAL); authDetail.setCredentialConfigurationId(credConfigId); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCCredentialOfferCorsTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCCredentialOfferCorsTest.java index a157075d9d7c..068856b47597 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCCredentialOfferCorsTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCCredentialOfferCorsTest.java @@ -331,7 +331,7 @@ public void testCredentialOfferWithExpiredNonce() throws Exception { CredentialOfferStorage offerStorage = session.getProvider(CredentialOfferStorage.class); // Create offer with expiration time just 1 second in the past // This ensures it's still findable in storage but marked as expired - CredentialOfferState offerState = new CredentialOfferState(credOffer, null, null, Time.currentTime() - 1, null); + CredentialOfferState offerState = new CredentialOfferState(credOffer, null, null, false, Time.currentTime() - 1, null); offerStorage.putOfferState(offerState); session.getTransactionManager().commit(); return offerState.getNonce();