From ff1274c07a1ed36c3f3dda75bd63bda68dc06ff4 Mon Sep 17 00:00:00 2001 From: mposolda Date: Mon, 15 Dec 2025 09:55:57 +0100 Subject: [PATCH] Mandatory claims are not enforced for OID4VCI closes #44796 Signed-off-by: mposolda --- .../oid4vc/issuance/OID4VCIssuerEndpoint.java | 121 ++++++----- .../oid4vc/issuance/mappers/OID4VCMapper.java | 31 +++ .../oid4vc/utils/ClaimsPathPointer.java | 8 +- .../OID4VCAuthorizationCodeFlowTestBase.java | 201 ++++++++++++++++-- .../OID4VCJwtAuthorizationCodeFlowTest.java | 7 +- .../OID4VCSdJwtAuthorizationCodeFlowTest.java | 5 + .../oid4vc/test-credential-mappers.json | 2 +- 7 files changed, 296 insertions(+), 79 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 5bd30135031..fc15917a974 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 @@ -25,6 +25,7 @@ import java.security.PublicKey; import java.time.Instant; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -1369,8 +1370,13 @@ public class OID4VCIssuerEndpoint { Map subjectClaims = new HashMap<>(); protocolMappers.forEach(mapper -> mapper.setClaim(subjectClaims, authResult.session())); + Map subjectClaimsWithMetadataPrefix = new HashMap<>(); + protocolMappers + .forEach(mapper -> mapper.setClaimWithMetadataPrefix(subjectClaims, subjectClaimsWithMetadataPrefix)); + // Validate that requested claims from authorization_details are present - validateRequestedClaimsArePresent(subjectClaims, authResult.session(), credentialConfig.getScope()); + String credentialConfigId = credentialConfig.getId(); + validateRequestedClaimsArePresent(subjectClaimsWithMetadataPrefix, credentialConfig, authResult.session(), credentialConfigId); // Include all available claims subjectClaims.forEach((key, value) -> vc.getCredentialSubject().setClaims(key, value)); @@ -1446,68 +1452,77 @@ public class OID4VCIssuerEndpoint { /** * Validates that all requested claims from authorization_details are present in the available claims. * - * @param allClaims all available claims + * @param allClaims all available claims. These are the claims including metadata prefix with the resolved path + * @param credentialConfig Credential configuration * @param userSession the user session * @param scope the credential scope * @throws BadRequestException if mandatory requested claims are missing */ - private void validateRequestedClaimsArePresent(Map allClaims, UserSessionModel userSession, String scope) { - try { - // Look for stored claims in user session notes - String claimsKey = AUTHORIZATION_DETAILS_CLAIMS_PREFIX + scope; - String storedClaimsJson = userSession.getNote(claimsKey); + private void validateRequestedClaimsArePresent(Map allClaims, SupportedCredentialConfiguration credentialConfig, + UserSessionModel userSession, String scope) { + // Protocol mappers from configuration + Map, ClaimsDescription> claimsConfig = credentialConfig.getCredentialMetadata().getClaims() + .stream() + .map(claim -> { + List pathObj = new ArrayList<>(claim.getPath()); + return new ClaimsDescription(pathObj, claim.isMandatory()); + }) + .collect(Collectors.toMap(ClaimsDescription::getPath, claimsDescription -> claimsDescription)); - if (storedClaimsJson != null && !storedClaimsJson.isEmpty()) { - try { - // Parse the stored claims from JSON - List storedClaims = - JsonSerialization.readValue(storedClaimsJson, - new TypeReference>() { - }); + List claimsFromAuthzDetails = getClaimsFromAuthzDetails(scope, userSession); - if (storedClaims != null && !storedClaims.isEmpty()) { - // Validate that all requested claims are present in the available claims - // We use filterClaimsByAuthorizationDetails to check if claims can be found - // but we don't actually filter - we just validate presence - try { - ClaimsPathPointer.filterClaimsByAuthorizationDetails(allClaims, storedClaims); - LOGGER.debugf("All requested claims are present for scope %s", scope); - } catch (IllegalArgumentException e) { - // If filtering fails, it means some requested claims are missing - LOGGER.errorf("Requested claims validation failed for scope %s: %s", scope, e.getMessage()); - throw new BadRequestException("Credential issuance failed: " + e.getMessage() + - ". The requested claims are not available in the user profile."); - } - } else { - LOGGER.debug("Stored claims list is null or empty"); - } - } catch (Exception e) { - LOGGER.errorf(e, "Failed to parse stored claims for scope %s", scope); + // Merge claims from both protocolMappers and authorizationDetails. If either source specifies "mandatory" as true, claim is considered mandatory + for (ClaimsDescription claimDescription : claimsFromAuthzDetails) { + List path = claimDescription.getPath(); + ClaimsDescription existing = claimsConfig.get(path); + if (existing == null) { + claimsConfig.put(path, claimDescription); + } else { + if (claimDescription.isMandatory()) { + existing.setMandatory(true); } - } else { - LOGGER.debugf("No stored claims found for scope %s", scope); } - // No claims filtering requested, all claims are valid + } + List claimsDescriptions = new ArrayList<>(claimsConfig.values()); + + // Validate that all requested claims are present in the available claims + // We use filterClaimsByAuthorizationDetails to check if claims can be found + // but we don't actually filter - we just validate presence + try { + ClaimsPathPointer.filterClaimsByAuthorizationDetails(allClaims, claimsDescriptions); + LOGGER.debugf("All requested claims are present for scope %s", scope); } catch (IllegalArgumentException e) { - // Mandatory claim missing - this should fail credential issuance - String errorMessage = e.getMessage(); - if (errorMessage.contains("Mandatory claim not found:")) { - LOGGER.errorf("Mandatory claim missing during claims filtering for scope %s: %s", scope, errorMessage); - throw new BadRequestException(getErrorResponse(INVALID_CREDENTIAL_REQUEST, - "Credential issuance failed: " + errorMessage + - ". The requested mandatory claim is not available in the user profile.")); - } else { - LOGGER.errorf("Claims filtering error for scope %s: %s", scope, errorMessage); - throw new BadRequestException(getErrorResponse(INVALID_CREDENTIAL_REQUEST, - "Credential issuance failed: " + errorMessage)); - } - } catch (BadRequestException e) { - // Re-throw BadRequestException to ensure client receives proper error response - throw e; - } catch (Exception e) { - // Log error but continue with all claims to avoid breaking existing functionality - LOGGER.errorf(e, "Unexpected error during claims validation for scope %s, continuing with all claims", scope); + // If filtering fails, it means some requested claims are missing + LOGGER.warnf("Requested claims validation failed for scope '%s', user '%s', client '%s': %s" + , scope, userSession.getUser().getUsername(), session.getContext().getClient().getClientId(), e.getMessage()); + throw new BadRequestException("Credential issuance failed: " + e.getMessage() + + ". The requested claims are not available in the user profile."); } } + + + private List getClaimsFromAuthzDetails(String scope, UserSessionModel userSession) { + String username = userSession.getUser().getUsername(); + String clientId = session.getContext().getClient().getClientId(); + + // Look for stored claims in user session notes + String claimsKey = AUTHORIZATION_DETAILS_CLAIMS_PREFIX + scope; + String storedClaimsJson = userSession.getNote(claimsKey); + + if (storedClaimsJson != null && !storedClaimsJson.isEmpty()) { + try { + // Parse the stored claims from JSON + return JsonSerialization.readValue(storedClaimsJson, + new TypeReference<>() { + }); + } catch (Exception e) { + LOGGER.warnf(e, "Failed to parse stored claims for scope '%s', user '%s', client '%s'", scope, username, clientId); + } + } else { + LOGGER.debugf("No stored claims found for scope '%s', user '%s', client '%s'", scope, username, clientId); + } + + return Collections.emptyList(); + } } diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCMapper.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCMapper.java index 3ed6f770d4d..78d79d6ca45 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCMapper.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCMapper.java @@ -19,6 +19,7 @@ package org.keycloak.protocol.oid4vc.issuance.mappers; import java.util.ArrayList; import java.util.Collections; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; @@ -158,4 +159,34 @@ public abstract class OID4VCMapper implements ProtocolMapper, OID4VCEnvironmentP public abstract void setClaim(Map claims, UserSessionModel userSessionModel); + /** + * Creates new map "claimsWithPrefix" with the resolved claims including path prefix + * + * @param claimsOrig Map with the original claims, which were returned by {@link #setClaim(Map, UserSessionModel)} . This method usually just reads from this map + * @param claimsWithPrefix Map with the claims including path prefix. This method might write to this map + */ + public void setClaimWithMetadataPrefix(Map claimsOrig, Map claimsWithPrefix) { + List attributePath = getMetadataAttributePath(); + String propertyName = attributePath.get(attributePath.size() - 1); + if (claimsOrig.get(propertyName) != null) { + Object claimValue = claimsOrig.get(propertyName); + Map current = claimsWithPrefix; + + for (int i = 0; i < attributePath.size() ; i++) { + String currentSnippetName = attributePath.get(i); + if (i < attributePath.size() - 1) { + Map obj = (Map) current.get(currentSnippetName); + if (obj == null) { + obj = new HashMap<>(); + current.put(currentSnippetName, obj); + } + current = obj; + } else { + // Last element + current.put(currentSnippetName, claimValue); + } + } + } + } + } diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/utils/ClaimsPathPointer.java b/services/src/main/java/org/keycloak/protocol/oid4vc/utils/ClaimsPathPointer.java index b5265839a69..942947c4d1e 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/utils/ClaimsPathPointer.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/utils/ClaimsPathPointer.java @@ -220,13 +220,13 @@ public class ClaimsPathPointer { // Optional claims that don't exist are simply not included } catch (IllegalArgumentException e) { if (Boolean.TRUE.equals(claim.getMandatory())) { - // Log error for mandatory claims before re-throwing - logger.errorf("Failed to process mandatory claim path %s: %s", path, e.getMessage()); + // Log warning for mandatory claims before re-throwing + logger.warnf("Failed to process mandatory claim path %s: %s", path, e.getMessage()); // Re-throw for mandatory claims throw e; } - // For optional claims, log warning and continue - logger.warnf("Failed to process optional claim path %s: %s", path, e.getMessage()); + // For optional claims, log debug and continue + logger.debugf("Failed to process optional claim path %s: %s", path, e.getMessage()); } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCAuthorizationCodeFlowTestBase.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCAuthorizationCodeFlowTestBase.java index bbf49513279..ff914672247 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCAuthorizationCodeFlowTestBase.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCAuthorizationCodeFlowTestBase.java @@ -28,7 +28,10 @@ import java.util.function.BiFunction; import jakarta.ws.rs.core.HttpHeaders; import org.keycloak.OAuth2Constants; +import org.keycloak.admin.client.resource.ClientScopeResource; +import org.keycloak.admin.client.resource.UserResource; import org.keycloak.models.oid4vci.CredentialScopeModel; +import org.keycloak.models.oid4vci.Oid4vcProtocolMapperModel; import org.keycloak.protocol.oid4vc.issuance.OID4VCAuthorizationDetailsResponse; import org.keycloak.protocol.oid4vc.model.AuthorizationDetail; import org.keycloak.protocol.oid4vc.model.ClaimsDescription; @@ -38,6 +41,10 @@ import org.keycloak.protocol.oid4vc.model.CredentialResponse; import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation; import org.keycloak.representations.AccessTokenResponse; import org.keycloak.representations.idm.ClientScopeRepresentation; +import org.keycloak.representations.idm.OAuth2ErrorRepresentation; +import org.keycloak.representations.idm.ProtocolMapperRepresentation; +import org.keycloak.representations.idm.UserRepresentation; +import org.keycloak.testsuite.admin.ApiUtil; import org.keycloak.util.JsonSerialization; import com.fasterxml.jackson.core.type.TypeReference; @@ -88,6 +95,11 @@ public abstract class OID4VCAuthorizationCodeFlowTestBase extends OID4VCIssuerEn */ protected abstract String getExpectedClaimPath(); + /** + * Get the name of the protocol mapper for firstName + */ + protected abstract String getFirstNameProtocolMapperName(); + /** * Prepare OID4VC test context by fetching issuer metadata and credential offer */ @@ -137,10 +149,145 @@ public abstract class OID4VCAuthorizationCodeFlowTestBase extends OID4VCIssuerEn testCompleteFlowWithClaimsValidationAuthorizationCode(credRequestSupplier); } + // Test for the authorization_code flow with "mandatory" claim specified in the "authorization_details" parameter + @Test + public void testCompleteFlow_mandatoryClaimsInAuthzDetailsParameter() throws Exception { + Oid4vcTestContext ctx = prepareOid4vcTestContext(); + BiFunction credRequestSupplier = (credentialConfigurationId, credentialIdentifier) -> { + CredentialRequest credentialRequest = new CredentialRequest(); + credentialRequest.setCredentialIdentifier(credentialIdentifier); + return credentialRequest; + }; + + // 1 - Update user to have missing "lastName" (mandatory attribute) + UserResource user = ApiUtil.findUserByUsernameId(testRealm(), "john"); + UserRepresentation userRep = user.toRepresentation(); + // NOTE: Need to call both "setLastName" and set attributes to be able to set last name as null + userRep.setAttributes(Collections.emptyMap()); + userRep.setLastName(null); + user.update(userRep); + + // 2 - Test the flow. Credential request should fail due the missing "lastName" + // Perform authorization code flow to get authorization code + AccessTokenResponse tokenResponse = authzCodeFlow(ctx); + String credentialIdentifier = assertTokenResponse(tokenResponse); + String credentialConfigurationId = getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID); + + // Request the actual credential using the identifier + HttpPost postCredential = getCredentialRequest(ctx, credRequestSupplier, tokenResponse, credentialConfigurationId, credentialIdentifier); + + try (CloseableHttpResponse credentialResponse = httpClient.execute(postCredential)) { + assertErrorCredentialResponse(credentialResponse); + } + + // 3 - Update user to add "lastName" + userRep.setLastName("Doe"); + user.update(userRep); + + // 4 - Test the credential-request again. Should be OK now + try (CloseableHttpResponse credentialResponse = httpClient.execute(postCredential)) { + assertSuccessfulCredentialResponse(credentialResponse); + } + } + + + // Test for the authorization_code flow with "mandatory" claim specified in the "authorization_details" parameter as well as + // mandatory claims in the protocol mappers configuration + @Test + public void testCompleteFlow_mandatoryClaimsInAuthzDetailsParameterAndProtocolMappersConfig() throws Exception { + Oid4vcTestContext ctx = prepareOid4vcTestContext(); + BiFunction credRequestSupplier = (credentialConfigurationId, credentialIdentifier) -> { + CredentialRequest credentialRequest = new CredentialRequest(); + credentialRequest.setCredentialIdentifier(credentialIdentifier); + return credentialRequest; + }; + + // 1 - Update "firstName" protocol mapper to be mandatory + ClientScopeResource clientScopeResource = ApiUtil.findClientScopeByName(testRealm(), getCredentialClientScope().getName()); + assertNotNull(clientScopeResource); + ProtocolMapperRepresentation protocolMapper = clientScopeResource.getProtocolMappers().getMappers() + .stream() + .filter(protMapper -> getFirstNameProtocolMapperName().equals(protMapper.getName())) + .findFirst() + .orElseThrow((() -> new RuntimeException("Not found protocol mapper with name 'firstName-mapper'."))); + protocolMapper.getConfig().put(Oid4vcProtocolMapperModel.MANDATORY, "true"); + clientScopeResource.getProtocolMappers().update(protocolMapper.getId(), protocolMapper); + + try { + // 2 - Update user to have missing "lastName" (mandatory attribute by authorization_details parameter) and "firstName" (mandatory attribute by protocol mapper) + UserResource user = ApiUtil.findUserByUsernameId(testRealm(), "john"); + UserRepresentation userRep = user.toRepresentation(); + // NOTE: Need to call both "setLastName" and set attributes to be able to set last name as null + userRep.setAttributes(Collections.emptyMap()); + userRep.setFirstName(null); + userRep.setLastName(null); + user.update(userRep); + + // 2 - Test the flow. Credential request should fail due the missing "lastName" + // Perform authorization code flow to get authorization code + AccessTokenResponse tokenResponse = authzCodeFlow(ctx); + String credentialIdentifier = assertTokenResponse(tokenResponse); + String credentialConfigurationId = getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID); + + // Request the actual credential using the identifier + HttpPost postCredential = getCredentialRequest(ctx, credRequestSupplier, tokenResponse, credentialConfigurationId, credentialIdentifier); + + try (CloseableHttpResponse credentialResponse = httpClient.execute(postCredential)) { + assertErrorCredentialResponse(credentialResponse); + } + + // 3 - Update user to add "lastName", but keep "firstName" missing. Credential request should still fail + userRep.setLastName("Doe"); + userRep.setFirstName(null); + user.update(userRep); + + try (CloseableHttpResponse credentialResponse = httpClient.execute(postCredential)) { + assertErrorCredentialResponse(credentialResponse); + } + + // 4 - Update user to add "firstName", but missing "lastName" + userRep.setLastName(null); + userRep.setFirstName("John"); + user.update(userRep); + + try (CloseableHttpResponse credentialResponse = httpClient.execute(postCredential)) { + assertErrorCredentialResponse(credentialResponse); + } + + // 5 - Update user to both "firstName" and "lastName". Credential request should be successful + userRep.setLastName("Doe"); + userRep.setFirstName("John"); + user.update(userRep); + + try (CloseableHttpResponse credentialResponse = httpClient.execute(postCredential)) { + assertSuccessfulCredentialResponse(credentialResponse); + } + } finally { + // 6 - Revert protocol mapper config + protocolMapper.getConfig().put(Oid4vcProtocolMapperModel.MANDATORY, "false"); + clientScopeResource.getProtocolMappers().update(protocolMapper.getId(), protocolMapper); + } + } + private void testCompleteFlowWithClaimsValidationAuthorizationCode(BiFunction credentialRequestSupplier) throws Exception { Oid4vcTestContext ctx = prepareOid4vcTestContext(); + // Perform authorization code flow to get authorization code + AccessTokenResponse tokenResponse = authzCodeFlow(ctx); + String credentialIdentifier = assertTokenResponse(tokenResponse); + String credentialConfigurationId = getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID); + + // Request the actual credential using the identifier + HttpPost postCredential = getCredentialRequest(ctx, credentialRequestSupplier, tokenResponse, credentialConfigurationId, credentialIdentifier); + + try (CloseableHttpResponse credentialResponse = httpClient.execute(postCredential)) { + assertSuccessfulCredentialResponse(credentialResponse); + } + } + + // Successful authorization_code flow + private AccessTokenResponse authzCodeFlow(Oid4vcTestContext ctx) throws Exception { // Perform authorization code flow to get authorization code oauth.client(client.getClientId()); oauth.scope(getCredentialClientScope().getName()); // Add the credential scope @@ -183,13 +330,15 @@ public abstract class OID4VCAuthorizationCodeFlowTestBase extends OID4VCIssuerEn UrlEncodedFormEntity tokenFormEntity = new UrlEncodedFormEntity(tokenParameters, StandardCharsets.UTF_8); postToken.setEntity(tokenFormEntity); - AccessTokenResponse tokenResponse; try (CloseableHttpResponse tokenHttpResponse = httpClient.execute(postToken)) { assertEquals(HttpStatus.SC_OK, tokenHttpResponse.getStatusLine().getStatusCode()); String tokenResponseBody = IOUtils.toString(tokenHttpResponse.getEntity().getContent(), StandardCharsets.UTF_8); - tokenResponse = JsonSerialization.readValue(tokenResponseBody, AccessTokenResponse.class); + return JsonSerialization.readValue(tokenResponseBody, AccessTokenResponse.class); } + } + // Test successful token response. Returns "Credential identifier" of the VC credential + private String assertTokenResponse(AccessTokenResponse tokenResponse) throws Exception { // Extract authorization_details from token response List authDetailsResponse = parseAuthorizationDetails(JsonSerialization.writeValueAsString(tokenResponse)); assertNotNull("authorization_details should be present in the response", authDetailsResponse); @@ -206,8 +355,11 @@ public abstract class OID4VCAuthorizationCodeFlowTestBase extends OID4VCIssuerEn assertNotNull("Credential identifiers should not be null", credentialIdentifiers); assertEquals("Credential identifiers expected to have 1 item. It had " + credentialIdentifiers.size() + " with value " + credentialIdentifiers, 1, credentialIdentifiers.size()); - String credentialIdentifier = credentialIdentifiers.get(0); + return credentialIdentifiers.get(0); + } + private HttpPost getCredentialRequest(Oid4vcTestContext ctx, BiFunction credentialRequestSupplier, AccessTokenResponse tokenResponse, + String credentialConfigurationId, String credentialIdentifier) throws Exception { // Request the actual credential using the identifier HttpPost postCredential = new HttpPost(ctx.credentialIssuer.getCredentialEndpoint()); postCredential.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + tokenResponse.getToken()); @@ -218,27 +370,36 @@ public abstract class OID4VCAuthorizationCodeFlowTestBase extends OID4VCIssuerEn String requestBody = JsonSerialization.writeValueAsString(credentialRequest); postCredential.setEntity(new StringEntity(requestBody, StandardCharsets.UTF_8)); - try (CloseableHttpResponse credentialResponse = httpClient.execute(postCredential)) { - assertEquals(HttpStatus.SC_OK, credentialResponse.getStatusLine().getStatusCode()); - String responseBody = IOUtils.toString(credentialResponse.getEntity().getContent(), StandardCharsets.UTF_8); + return postCredential; + } - // Parse the credential response - CredentialResponse parsedResponse = JsonSerialization.readValue(responseBody, CredentialResponse.class); - assertNotNull("Credential response should not be null", parsedResponse); - assertNotNull("Credentials should be present", parsedResponse.getCredentials()); - assertEquals("Should have exactly one credential", 1, parsedResponse.getCredentials().size()); + private void assertSuccessfulCredentialResponse(CloseableHttpResponse credentialResponse) throws Exception { + assertEquals(HttpStatus.SC_OK, credentialResponse.getStatusLine().getStatusCode()); + String responseBody = IOUtils.toString(credentialResponse.getEntity().getContent(), StandardCharsets.UTF_8); - // Verify that the issued credential contains the requested claims AND may contain additional claims - CredentialResponse.Credential credentialWrapper = parsedResponse.getCredentials().get(0); - assertNotNull("Credential wrapper should not be null", credentialWrapper); + // Parse the credential response + CredentialResponse parsedResponse = JsonSerialization.readValue(responseBody, CredentialResponse.class); + assertNotNull("Credential response should not be null", parsedResponse); + assertNotNull("Credentials should be present", parsedResponse.getCredentials()); + assertEquals("Should have exactly one credential", 1, parsedResponse.getCredentials().size()); - // The credential is stored as Object, so we need to cast it - Object credentialObj = credentialWrapper.getCredential(); - assertNotNull("Credential object should not be null", credentialObj); + // Verify that the issued credential contains the requested claims AND may contain additional claims + CredentialResponse.Credential credentialWrapper = parsedResponse.getCredentials().get(0); + assertNotNull("Credential wrapper should not be null", credentialWrapper); - // Verify the credential structure based on formatfix-authorization_details-processing - verifyCredentialStructure(credentialObj); - } + // The credential is stored as Object, so we need to cast it + Object credentialObj = credentialWrapper.getCredential(); + assertNotNull("Credential object should not be null", credentialObj); + + // Verify the credential structure based on formatfix-authorization_details-processing + verifyCredentialStructure(credentialObj); + } + + private void assertErrorCredentialResponse(CloseableHttpResponse credentialResponse) throws Exception { + assertEquals(HttpStatus.SC_BAD_REQUEST, credentialResponse.getStatusLine().getStatusCode()); + String responseBody = IOUtils.toString(credentialResponse.getEntity().getContent(), StandardCharsets.UTF_8); + OAuth2ErrorRepresentation error = JsonSerialization.readValue(responseBody, OAuth2ErrorRepresentation.class); + assertEquals("Credential issuance failed: No elements selected after processing claims path pointer. The requested claims are not available in the user profile.", error.getError()); } /** diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCJwtAuthorizationCodeFlowTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCJwtAuthorizationCodeFlowTest.java index e7f2698542f..c11861dd0d6 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCJwtAuthorizationCodeFlowTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCJwtAuthorizationCodeFlowTest.java @@ -43,7 +43,12 @@ public class OID4VCJwtAuthorizationCodeFlowTest extends OID4VCAuthorizationCodeF @Override protected String getExpectedClaimPath() { - return "given_name"; + return "family_name"; + } + + @Override + protected String getFirstNameProtocolMapperName() { + return "givenName"; } @Override diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCSdJwtAuthorizationCodeFlowTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCSdJwtAuthorizationCodeFlowTest.java index db016ebd313..af0b2e1dc4d 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCSdJwtAuthorizationCodeFlowTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCSdJwtAuthorizationCodeFlowTest.java @@ -48,6 +48,11 @@ public class OID4VCSdJwtAuthorizationCodeFlowTest extends OID4VCAuthorizationCod return "lastName"; } + @Override + protected String getFirstNameProtocolMapperName() { + return "firstName-mapper"; + } + @Override protected void verifyCredentialStructure(Object credentialObj) { assertNotNull("Credential object should not be null", credentialObj); diff --git a/testsuite/integration-arquillian/tests/base/src/test/resources/oid4vc/test-credential-mappers.json b/testsuite/integration-arquillian/tests/base/src/test/resources/oid4vc/test-credential-mappers.json index 80415600009..57e5ce6af63 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/resources/oid4vc/test-credential-mappers.json +++ b/testsuite/integration-arquillian/tests/base/src/test/resources/oid4vc/test-credential-mappers.json @@ -25,7 +25,7 @@ "protocolMapper": "oid4vc-user-attribute-mapper", "config": { "claim.name": "family_name", - "userProperty": "lastName", + "userAttribute": "lastName", "vc.mandatory": "false", "vc.display": "[{\"name\": \"اسم العائلة\", \"locale\": \"ar-SA\"}, {\"name\": \"Nachname\", \"locale\": \"de-DE\"}, {\"name\": \"Family Name\", \"locale\": \"en-US\"}, {\"name\": \"Apellido\", \"locale\": \"es-ES\"}, {\"name\": \"نام خانوادگی\", \"locale\": \"fa-IR\"}, {\"name\": \"Sukunimi\", \"locale\": \"fi-FI\"}, {\"name\": \"Nom de famille\", \"locale\": \"fr-FR\"}, {\"name\": \"परिवार का नाम\", \"locale\": \"hi-IN\"}, {\"name\": \"Cognome\", \"locale\": \"it-IT\"}, {\"name\": \"姓\", \"locale\": \"ja-JP\"}, {\"name\": \"өөрийн нэр\", \"locale\": \"mn-MN\"}, {\"name\": \"Achternaam\", \"locale\": \"nl-NL\"}, {\"name\": \"Sobrenome\", \"locale\": \"pt-PT\"}, {\"name\": \"Efternamn\", \"locale\": \"sv-SE\"}, {\"name\": \"خاندانی نام\", \"locale\": \"ur-PK\"}]" }