From 51ba0a38a466b91e5c1b122b37b1049e348fc06e Mon Sep 17 00:00:00 2001 From: Shurong Cao <170531907+CAOShurong@users.noreply.github.com> Date: Thu, 13 Aug 2026 13:29:12 +0800 Subject: [PATCH] Report OID4VCI credential request errors Treat absent credential claims metadata as an empty claim set so client scopes without protocol mappers can issue credentials. Record proof validation failures on the credential request error event, including the OID4VCI error type and reason. Closes #51692 Signed-off-by: Shurong Cao <170531907+CAOShurong@users.noreply.github.com> --- .../oid4vc/issuance/OID4VCIssuerEndpoint.java | 14 +++++++++----- .../tests/oid4vc/OID4VCJWTIssuerEndpointTest.java | 15 +++++++++++++++ 2 files changed, 24 insertions(+), 5 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 4036f5e306a4..39579b8b56e5 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 @@ -1769,7 +1769,7 @@ private VCIssuanceContext prepareCredential(AuthenticationManager.AuthResult aut VCIssuanceContext vcIssuanceContext = getVCToSign(protocolMappers, credentialConfig, authResult, authDetail, credentialRequestVO, credentialScopeModel, eventBuilder); // Enforce key binding prior to signing if necessary - enforceKeyBindingIfProofProvided(vcIssuanceContext); + enforceKeyBindingIfProofProvided(vcIssuanceContext, eventBuilder); return vcIssuanceContext; } @@ -1893,7 +1893,7 @@ private VCIssuanceContext getVCToSign(List protocolMappers, Suppor /** * Enforce key binding: Validate proof and bind associated key to credential in issuance context. */ - private void enforceKeyBindingIfProofProvided(VCIssuanceContext vcIssuanceContext) { + private void enforceKeyBindingIfProofProvided(VCIssuanceContext vcIssuanceContext, EventBuilder eventBuilder) { Proofs proofs = vcIssuanceContext.getCredentialRequest().getProofs(); if (proofs == null) { LOGGER.debugf("No proofs provided, skipping key binding"); @@ -1902,11 +1902,11 @@ private void enforceKeyBindingIfProofProvided(VCIssuanceContext vcIssuanceContex // Validate each proof type that is present for (String proofType : proofs.getPresentProofTypes()) { - validateProofs(vcIssuanceContext, proofType); + validateProofs(vcIssuanceContext, proofType, eventBuilder); } } - private void validateProofs(VCIssuanceContext vcIssuanceContext, String proofType) { + private void validateProofs(VCIssuanceContext vcIssuanceContext, String proofType, EventBuilder eventBuilder) { ProofValidator proofValidator = session.getProvider(ProofValidator.class, proofType); if (proofValidator == null) { throw new BadRequestException(String.format("Unable to validate proofs of type %s", proofType)); @@ -1920,6 +1920,8 @@ private void validateProofs(VCIssuanceContext vcIssuanceContext, String proofTyp vcIssuanceContext.getCredentialBody().addKeyBinding(jwks.get(0)); } } catch (VCIssuerException e) { + eventBuilder.detail(Details.REASON, e.getMessage()) + .error(e.getErrorType().getValue()); switch (e.getErrorType()) { case INVALID_NONCE: throw new ErrorResponseException(INVALID_NONCE.getValue(), e.getMessage(), Response.Status.BAD_REQUEST); @@ -1957,8 +1959,10 @@ private CredentialBuilder findCredentialBuilder(SupportedCredentialConfiguration private void validateRequestedClaimsArePresent(Map allClaims, SupportedCredentialConfiguration credentialConfig, UserModel user, OID4VCAuthorizationDetail authzDetail, String scope, EventBuilder eventBuilder) { // Protocol mappers from configuration - Map, ClaimsDescription> claimsConfig = credentialConfig.getCredentialMetadata().getClaims() + Map, ClaimsDescription> claimsConfig = Optional.ofNullable(credentialConfig.getCredentialMetadata()) + .map(metadata -> metadata.getClaims()) .stream() + .flatMap(claims -> claims.stream()) .map(claim -> { List pathObj = new ArrayList<>(claim.getPath()); return new ClaimsDescription(pathObj, claim.isMandatory()); diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCJWTIssuerEndpointTest.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCJWTIssuerEndpointTest.java index 77e319dcc403..5e8bf3ef9b6a 100644 --- a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCJWTIssuerEndpointTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCJWTIssuerEndpointTest.java @@ -40,6 +40,8 @@ import org.keycloak.common.util.Base64Url; import org.keycloak.common.util.Time; import org.keycloak.crypto.Algorithm; +import org.keycloak.events.Details; +import org.keycloak.events.EventType; import org.keycloak.jose.jws.JWSHeader; import org.keycloak.models.Constants; import org.keycloak.models.oid4vci.CredentialScopeModel; @@ -73,6 +75,7 @@ import org.keycloak.representations.idm.oid4vc.UserVerifiableCredentialRepresentation; import org.keycloak.services.managers.AppAuthManager.BearerTokenAuthenticator; import org.keycloak.testframework.annotations.KeycloakIntegrationTest; +import org.keycloak.testframework.events.EventAssertion; import org.keycloak.testframework.remote.runonserver.InjectRunOnServer; import org.keycloak.testframework.remote.runonserver.RunOnServerClient; import org.keycloak.testsuite.util.AccountHelper; @@ -1041,6 +1044,7 @@ public void testRequestCredentialWithFutureIatRejected() { .setCredentialIdentifier(credentialIdentifier) .setProofs(new Proofs().setJwt(List.of(futureIatProof))); + events.clear(); Oid4vcCredentialResponse response = oauth.oid4vc() .credentialRequest(request) .bearerToken(token) @@ -1049,6 +1053,11 @@ public void testRequestCredentialWithFutureIatRejected() { assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatusCode()); assertEquals(ErrorType.INVALID_PROOF.getValue(), response.getError()); assertTrue(response.getErrorDescription().contains("Proof iat is in the future")); + EventAssertion.assertError(events.poll()) + .type(EventType.VERIFIABLE_CREDENTIAL_REQUEST_ERROR) + .clientId(OID4VCI_CLIENT_ID) + .error(ErrorType.INVALID_PROOF.getValue()) + .details(Details.REASON, "Proof iat is in the future beyond allowed clock skew"); } @Test @@ -1496,6 +1505,7 @@ public void testCredentialRequestWithOptionalClientScope() { VCFormat.JWT_VC, null, null ); + optionalScope.setProtocolMappers(List.of()); optionalScope = registerOptionalClientScope(optionalScope); ClientRepresentation testClient = testRealm.admin().clients().findByClientId(OID4VCI_CLIENT_ID).get(0); @@ -1526,6 +1536,7 @@ public void testCredentialRequestWithOptionalClientScope() { CredentialRequest credentialRequest = new CredentialRequest() .setCredentialIdentifier(credentialIdentifier); + events.clear(); Oid4vcCredentialResponse response = oauth.oid4vc() .credentialRequest(credentialRequest) .bearerToken(token) @@ -1538,6 +1549,10 @@ public void testCredentialRequestWithOptionalClientScope() { assertNotNull(credentialResponseVO.getCredentials(), "Credentials array should not be null"); assertFalse(credentialResponseVO.getCredentials().isEmpty(), "Credentials array should not be empty"); + EventAssertion.assertSuccess(events.poll()) + .type(EventType.VERIFIABLE_CREDENTIAL_REQUEST) + .clientId(OID4VCI_CLIENT_ID) + .details(Details.CREDENTIAL_TYPE, configId); } @Test