[OID4VC]: Update authorization_details for OID4VCI draft-16 compliance (#42622)

Closes #41586

Signed-off-by: forkimenjeckayang <forkimenjeckayang@gmail.com>
This commit is contained in:
forkimenjeckayang
2025-09-22 09:19:24 +01:00
committed by GitHub
parent f6627f99b2
commit 8ad6427123
14 changed files with 1798 additions and 652 deletions

View File

@@ -23,19 +23,23 @@ import org.keycloak.models.ClientSessionContext;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.UserSessionModel;
import org.keycloak.protocol.oid4vc.model.AuthorizationDetail;
import org.keycloak.protocol.oid4vc.model.ClaimsDescription;
import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration;
import org.keycloak.protocol.oid4vc.model.Claim;
import org.keycloak.util.JsonSerialization;
import org.keycloak.protocol.oidc.rar.AuthorizationDetailsProcessor;
import org.keycloak.protocol.oidc.rar.AuthorizationDetailsResponse;
import org.keycloak.util.JsonSerialization;
import org.keycloak.protocol.oid4vc.model.Format;
import static org.keycloak.models.Constants.AUTHORIZATION_DETAILS_RESPONSE;
import static org.keycloak.protocol.oid4vc.model.Format.SUPPORTED_FORMATS;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
import org.keycloak.protocol.oid4vc.utils.ClaimsPathPointer;
import static org.keycloak.models.Constants.AUTHORIZATION_DETAILS_RESPONSE;
public class OID4VCAuthorizationDetailsProcessor implements AuthorizationDetailsProcessor {
private static final Logger logger = Logger.getLogger(OID4VCAuthorizationDetailsProcessor.class);
@@ -54,7 +58,6 @@ public class OID4VCAuthorizationDetailsProcessor implements AuthorizationDetails
}
List<AuthorizationDetail> authDetails = parseAuthorizationDetails(authorizationDetailsParameter);
List<String> supportedFormats = new ArrayList<>(SUPPORTED_FORMATS);
Map<String, SupportedCredentialConfiguration> supportedCredentials = OID4VCIssuerWellKnownProvider.getSupportedCredentials(session);
List<AuthorizationDetailsResponse> authDetailsResponse = new ArrayList<>();
@@ -63,11 +66,15 @@ public class OID4VCAuthorizationDetailsProcessor implements AuthorizationDetails
String issuerIdentifier = OID4VCIssuerWellKnownProvider.getIssuer(session.getContext());
for (AuthorizationDetail detail : authDetails) {
validateAuthorizationDetail(detail, supportedFormats, supportedCredentials, authorizationServers, issuerIdentifier);
AuthorizationDetailsResponse responseDetail = buildAuthorizationDetailResponse(detail, userSession, supportedCredentials, supportedFormats, clientSessionCtx);
validateAuthorizationDetail(detail, supportedCredentials, authorizationServers, issuerIdentifier);
AuthorizationDetailsResponse responseDetail = buildAuthorizationDetailResponse(detail, userSession, supportedCredentials, clientSessionCtx);
authDetailsResponse.add(responseDetail);
}
if (authDetailsResponse.isEmpty()) {
throw getInvalidRequestException("Invalid authorization_details: no valid authorization details found");
}
return authDetailsResponse;
}
@@ -85,11 +92,19 @@ public class OID4VCAuthorizationDetailsProcessor implements AuthorizationDetails
return new RuntimeException("Invalid authorization_details: " + errorDescription);
}
private void validateAuthorizationDetail(AuthorizationDetail detail, List<String> supportedFormats, Map<String, SupportedCredentialConfiguration> supportedCredentials, List<String> authorizationServers, String issuerIdentifier) {
/**
* Validates an authorization detail against supported credentials and other constraints.
*
* @param detail the authorization detail to validate
* @param supportedCredentials map of supported credential configurations
* @param authorizationServers list of authorization servers
* @param issuerIdentifier the issuer identifier
*/
private void validateAuthorizationDetail(AuthorizationDetail detail, Map<String, SupportedCredentialConfiguration> supportedCredentials, List<String> authorizationServers, String issuerIdentifier) {
String type = detail.getType();
String credentialConfigurationId = detail.getCredentialConfigurationId();
String format = detail.getFormat();
Object vct = detail.getAdditionalFields().get("vct");
List<ClaimsDescription> claims = detail.getClaims();
// If authorization_servers is present, locations must be set to issuer identifier
if (authorizationServers != null && !authorizationServers.isEmpty() && OPENID_CREDENTIAL_TYPE.equals(type)) {
@@ -106,50 +121,82 @@ public class OID4VCAuthorizationDetailsProcessor implements AuthorizationDetails
throw getInvalidRequestException("Invalid authorization_details type: " + type + ", expected=" + OPENID_CREDENTIAL_TYPE);
}
// Ensure exactly one of credential_configuration_id or format is present
if ((credentialConfigurationId == null && format == null) || (credentialConfigurationId != null && format != null)) {
logger.warnf("Exactly one of credential_configuration_id or format must be present. credentialConfigurationId: %s, format: %s", credentialConfigurationId, format);
throw getInvalidRequestException("Invalid authorization_details: credentialConfigurationId=" + credentialConfigurationId + ", format=" + format + ". Exactly one must be present.");
// credential_configuration_id is REQUIRED
if (credentialConfigurationId == null) {
logger.warnf("Missing credential_configuration_id in authorization_details");
throw getInvalidRequestException("Invalid authorization_details: credential_configuration_id is required");
}
if (credentialConfigurationId != null) {
// Validate credential_configuration_id
SupportedCredentialConfiguration config = supportedCredentials.get(credentialConfigurationId);
if (config == null) {
logger.warnf("Unsupported credential_configuration_id: %s", credentialConfigurationId);
throw getInvalidRequestException("Invalid credential configuration: unsupported credential_configuration_id=" + credentialConfigurationId);
}
} else {
// Validate format
if (!supportedFormats.contains(format)) {
logger.warnf("Unsupported format: %s", format);
throw getInvalidRequestException("Invalid credential format: unsupported format=" + format + ", supported=" + supportedFormats);
}
// Validate credential_configuration_id
SupportedCredentialConfiguration config = supportedCredentials.get(credentialConfigurationId);
if (config == null) {
logger.warnf("Unsupported credential_configuration_id: %s", credentialConfigurationId);
throw getInvalidRequestException("Invalid credential configuration: unsupported credential_configuration_id=" + credentialConfigurationId);
}
// SD-JWT VC: vct is REQUIRED and must match a supported credential configuration
if (Format.SD_JWT_VC.equals(format)) {
if (!(vct instanceof String) || ((String) vct).isEmpty()) {
logger.warnf("Missing or invalid vct for format %s", Format.SD_JWT_VC);
throw getInvalidRequestException(String.format("Missing or invalid vct for format=%s", Format.SD_JWT_VC));
}
boolean vctSupported = supportedCredentials.values().stream()
.filter(config -> format.equals(config.getFormat()))
.anyMatch(config -> vct.equals(config.getVct()));
if (!vctSupported) {
logger.warnf("Unsupported vct for format %s: %s", format, vct);
throw getInvalidRequestException("Invalid credential configuration: unsupported vct=" + vct + " for format=" + format);
}
} else {
// For other formats, do not require vct; allow for future format-specific fields in additionalFields
// No-op for now
}
// Validate claims if present
if (claims != null && !claims.isEmpty()) {
validateClaims(claims, supportedCredentials, credentialConfigurationId);
}
}
private AuthorizationDetailsResponse buildAuthorizationDetailResponse(AuthorizationDetail detail, UserSessionModel userSession, Map<String, SupportedCredentialConfiguration> supportedCredentials, List<String> supportedFormats, ClientSessionContext clientSessionCtx) {
/**
* Validates that the requested claims are supported by the credential configuration.
* This performs semantic validation by checking if Keycloak supports the requested claims.
*
* @param claims the list of claims to validate
* @param supportedCredentials map of supported credential configurations
* @param credentialConfigurationId the ID of the credential configuration
*/
private void validateClaims(List<ClaimsDescription> claims, Map<String, SupportedCredentialConfiguration> supportedCredentials, String credentialConfigurationId) {
SupportedCredentialConfiguration config = supportedCredentials.get(credentialConfigurationId);
// Get the exposed claims from credential metadata
List<Claim> exposedClaims = null;
if (config.getCredentialMetadata() != null && config.getCredentialMetadata().getClaims() != null && !config.getCredentialMetadata().getClaims().isEmpty()) {
exposedClaims = config.getCredentialMetadata().getClaims();
}
if (exposedClaims == null || exposedClaims.isEmpty()) {
throw getInvalidRequestException("Credential configuration does not expose any claims metadata");
}
// Convert exposed claims to a set of paths for easy comparison
Set<String> exposedClaimPaths = exposedClaims.stream()
.filter(claim -> claim.getPath() != null && !claim.getPath().isEmpty())
.map(claim -> claim.getPath().toString())
.collect(Collectors.toSet());
// Validate each requested claim against exposed metadata
for (ClaimsDescription requestedClaim : claims) {
if (requestedClaim.getPath() == null || requestedClaim.getPath().isEmpty()) {
throw getInvalidRequestException("Invalid claims description: path is required");
}
// Validate the claims path pointer format according to OID4VCI specification
if (!ClaimsPathPointer.isValidPath(requestedClaim.getPath())) {
throw getInvalidRequestException("Invalid claims path pointer: " + requestedClaim.getPath() +
". Path must contain only strings, non-negative integers, and null values.");
}
String requestedPath = requestedClaim.getPath().toString();
// Check if the requested claim path exists in the exposed metadata
if (!exposedClaimPaths.contains(requestedPath)) {
throw getInvalidRequestException("Unsupported claim: " + requestedPath +
". This claim is not supported by the credential configuration.");
}
}
// Check for conflicts using ClaimsPathPointer utility
if (!ClaimsPathPointer.validateClaimsDescriptions(claims)) {
throw getInvalidRequestException("Invalid claims descriptions: conflicting or contradictory claims found");
}
}
private AuthorizationDetailsResponse buildAuthorizationDetailResponse(AuthorizationDetail detail, UserSessionModel userSession, Map<String, SupportedCredentialConfiguration> supportedCredentials, ClientSessionContext clientSessionCtx) {
String credentialConfigurationId = detail.getCredentialConfigurationId();
String format = detail.getFormat();
Object vct = detail.getAdditionalFields().get("vct");
// Try to reuse identifier from authorizationDetailsResponse in client session context
List<AuthorizationDetailsResponse> previousResponses = clientSessionCtx.getAttribute(AUTHORIZATION_DETAILS_RESPONSE, List.class);
@@ -157,15 +204,13 @@ public class OID4VCAuthorizationDetailsProcessor implements AuthorizationDetails
if (previousResponses != null) {
for (AuthorizationDetailsResponse prev : previousResponses) {
if (prev instanceof OID4VCAuthorizationDetailsResponse) {
OID4VCAuthorizationDetailsResponse oid4vcPrev = (OID4VCAuthorizationDetailsResponse) prev;
if ((credentialConfigurationId != null && credentialConfigurationId.equals(oid4vcPrev.getCredentialConfigurationId())) ||
(credentialConfigurationId == null && format != null && format.equals(oid4vcPrev.getFormat()))) {
credentialIdentifiers = oid4vcPrev.getCredentialIdentifiers();
break;
}
OID4VCAuthorizationDetailsResponse oid4vcResponse = (OID4VCAuthorizationDetailsResponse) prev;
credentialIdentifiers = oid4vcResponse.getCredentialIdentifiers();
break;
}
}
}
if (credentialIdentifiers == null) {
credentialIdentifiers = new ArrayList<>();
credentialIdentifiers.add(UUID.randomUUID().toString());
@@ -173,15 +218,39 @@ public class OID4VCAuthorizationDetailsProcessor implements AuthorizationDetails
OID4VCAuthorizationDetailsResponse responseDetail = new OID4VCAuthorizationDetailsResponse();
responseDetail.setType(OPENID_CREDENTIAL_TYPE);
responseDetail.setCredentialConfigurationId(credentialConfigurationId);
responseDetail.setCredentialIdentifiers(credentialIdentifiers);
if (credentialConfigurationId != null) {
responseDetail.setCredentialConfigurationId(credentialConfigurationId);
} else {
responseDetail.setFormat(format);
if (Format.SD_JWT_VC.equals(format) && vct != null) {
responseDetail.getOtherClaims().put("vct", vct);
// Store claims and credential context in user session notes for later use during credential issuance
if (detail.getClaims() != null) {
// Store claims with a unique key based on credential configuration ID
String claimsKey = "AUTHORIZATION_DETAILS_CLAIMS_" + credentialConfigurationId;
try {
userSession.setNote(claimsKey, JsonSerialization.writeValueAsString(detail.getClaims()));
} catch (Exception e) {
logger.warnf(e, "Failed to store claims in user session for credential configuration %s", credentialConfigurationId);
}
// Store credential context mapping using credential identifier as key
for (String credentialIdentifier : credentialIdentifiers) {
String contextKey = "CREDENTIAL_CONTEXT_" + credentialIdentifier;
try {
// Store the complete credential context for later retrieval
Map<String, Object> credentialContext = Map.of(
"credentialConfigurationId", credentialConfigurationId,
"claims", detail.getClaims(),
"type", OPENID_CREDENTIAL_TYPE
);
userSession.setNote(contextKey, JsonSerialization.writeValueAsString(credentialContext));
} catch (Exception e) {
logger.warnf(e, "Failed to store credential context for identifier %s", credentialIdentifier);
}
}
// Include claims in response
responseDetail.setClaims(detail.getClaims());
}
return responseDetail;
}

View File

@@ -17,6 +17,8 @@
package org.keycloak.protocol.oid4vc.issuance;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.keycloak.protocol.oid4vc.model.ClaimsDescription;
import org.keycloak.protocol.oidc.rar.AuthorizationDetailsResponse;
import java.util.List;
@@ -35,15 +37,16 @@ public class OID4VCAuthorizationDetailsResponse extends AuthorizationDetailsResp
@JsonProperty("credential_configuration_id")
private String credentialConfigurationId;
@JsonProperty("format")
private String format;
@JsonProperty("locations")
private List<String> locations;
@JsonProperty("credential_identifiers")
private List<String> credentialIdentifiers;
@JsonProperty("claims")
private List<ClaimsDescription> claims;
public String getType() {
return type;
}
@@ -60,14 +63,6 @@ public class OID4VCAuthorizationDetailsResponse extends AuthorizationDetailsResp
this.credentialConfigurationId = credentialConfigurationId;
}
public String getFormat() {
return format;
}
public void setFormat(String format) {
this.format = format;
}
public List<String> getLocations() {
return locations;
}
@@ -83,4 +78,12 @@ public class OID4VCAuthorizationDetailsResponse extends AuthorizationDetailsResp
public void setCredentialIdentifiers(List<String> credentialIdentifiers) {
this.credentialIdentifiers = credentialIdentifiers;
}
public List<ClaimsDescription> getClaims() {
return claims;
}
public void setClaims(List<ClaimsDescription> claims) {
this.claims = claims;
}
}

View File

@@ -89,6 +89,9 @@ import org.keycloak.protocol.oid4vc.model.ProofType;
import org.keycloak.protocol.oid4vc.model.Proofs;
import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration;
import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
import org.keycloak.protocol.oid4vc.model.ClaimsDescription;
import org.keycloak.protocol.oid4vc.utils.ClaimsPathPointer;
import com.fasterxml.jackson.core.type.TypeReference;
import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantType;
import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantTypeFactory;
import org.keycloak.protocol.oidc.utils.OAuth2Code;
@@ -487,20 +490,62 @@ public class OID4VCIssuerEndpoint {
throw new BadRequestException(getErrorResponse(ErrorType.MISSING_CREDENTIAL_IDENTIFIER_AND_CONFIGURATION_ID));
}
// Find the requested credential
CredentialScopeModel requestedCredential = credentialRequestVO.findCredentialScope(session).orElseThrow(() -> {
LOGGER.debugf("Credential for request '%s' not found.", credentialRequestVO.toString());
// Determine the appropriate error type based on what was requested
ErrorType errorType;
if (credentialRequestVO.getCredentialConfigurationId() != null) {
errorType = ErrorType.UNKNOWN_CREDENTIAL_CONFIGURATION;
CredentialScopeModel requestedCredential;
// If credential_identifier is provided, try to retrieve stored credential context first
if (credentialRequestVO.getCredentialIdentifier() != null) {
String contextKey = "CREDENTIAL_CONTEXT_" + credentialRequestVO.getCredentialIdentifier();
String storedContextJson = authResult.getSession().getNote(contextKey);
if (storedContextJson != null && !storedContextJson.isEmpty()) {
try {
Map<String, Object> storedContext = JsonSerialization.readValue(storedContextJson, Map.class);
String storedCredentialConfigurationId = (String) storedContext.get("credentialConfigurationId");
// Use the stored credential configuration ID to find the credential scope
Map<String, SupportedCredentialConfiguration> supportedCredentials = OID4VCIssuerWellKnownProvider.getSupportedCredentials(session);
if (supportedCredentials.containsKey(storedCredentialConfigurationId)) {
SupportedCredentialConfiguration config = supportedCredentials.get(storedCredentialConfigurationId);
ClientModel client = session.getContext().getClient();
Map<String, ClientScopeModel> clientScopes = client.getClientScopes(false);
ClientScopeModel clientScope = clientScopes.get(config.getScope());
if (clientScope != null) {
requestedCredential = new CredentialScopeModel(clientScope);
} else {
LOGGER.errorf("Client scope not found for stored credential configuration: %s", config.getScope());
throw new BadRequestException(getErrorResponse(ErrorType.UNKNOWN_CREDENTIAL_CONFIGURATION));
}
} else {
LOGGER.errorf("Stored credential configuration not found: %s", storedCredentialConfigurationId);
throw new BadRequestException(getErrorResponse(ErrorType.UNKNOWN_CREDENTIAL_CONFIGURATION));
}
} catch (Exception e) {
LOGGER.errorf(e, "Failed to parse stored credential context for identifier: %s", credentialRequestVO.getCredentialIdentifier());
throw new BadRequestException(getErrorResponse(ErrorType.UNKNOWN_CREDENTIAL_IDENTIFIER));
}
} else {
errorType = ErrorType.UNKNOWN_CREDENTIAL_IDENTIFIER;
// No stored context found, try to use credential_identifier as a direct scope name
try {
requestedCredential = credentialRequestVO.findCredentialScope(session).orElseThrow(() -> {
LOGGER.errorf("Credential scope not found for identifier: %s", credentialRequestVO.getCredentialIdentifier());
return new BadRequestException(getErrorResponse(ErrorType.UNKNOWN_CREDENTIAL_IDENTIFIER));
});
} catch (Exception e) {
LOGGER.errorf(e, "Failed to find credential scope for identifier: %s", credentialRequestVO.getCredentialIdentifier());
throw new BadRequestException(getErrorResponse(ErrorType.UNKNOWN_CREDENTIAL_IDENTIFIER));
}
}
return new BadRequestException(getErrorResponse(errorType));
});
} else if (credentialRequestVO.getCredentialConfigurationId() != null) {
// Use credential_configuration_id for direct lookup
requestedCredential = credentialRequestVO.findCredentialScope(session).orElseThrow(() -> {
LOGGER.errorf("Credential scope not found for configuration ID: %s", credentialRequestVO.getCredentialConfigurationId());
return new BadRequestException(getErrorResponse(ErrorType.UNKNOWN_CREDENTIAL_CONFIGURATION));
});
} else {
// Neither provided - this should not happen due to earlier validation
throw new BadRequestException(getErrorResponse(ErrorType.MISSING_CREDENTIAL_IDENTIFIER_AND_CONFIGURATION_ID));
}
checkScope(requestedCredential);
@@ -1049,6 +1094,10 @@ public class OID4VCIssuerEndpoint {
protocolMappers
.forEach(mapper -> mapper.setClaimsForSubject(subjectClaims, authResult.getSession()));
// Validate that requested claims from authorization_details are present
validateRequestedClaimsArePresent(subjectClaims, authResult.getSession(), credentialConfig.getScope());
// Include all available claims
subjectClaims.forEach((key, value) -> vc.getCredentialSubject().setClaims(key, value));
protocolMappers
@@ -1119,4 +1168,72 @@ public class OID4VCIssuerEndpoint {
return credentialBuilder;
}
/**
* Validates that all requested claims from authorization_details are present in the available claims.
*
* @param allClaims all available claims
* @param userSession the user session
* @param scope the credential scope
* @throws BadRequestException if mandatory requested claims are missing
*/
private void validateRequestedClaimsArePresent(Map<String, Object> allClaims, UserSessionModel userSession, String scope) {
try {
// Look for stored claims in user session notes
String claimsKey = "AUTHORIZATION_DETAILS_CLAIMS_" + scope;
String storedClaimsJson = userSession.getNote(claimsKey);
if (storedClaimsJson != null && !storedClaimsJson.isEmpty()) {
try {
// Parse the stored claims from JSON
List<ClaimsDescription> storedClaims =
JsonSerialization.readValue(storedClaimsJson,
new TypeReference<List<ClaimsDescription>>() {
});
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.infof("Stored claims list is null or empty");
}
} catch (Exception e) {
LOGGER.errorf(e, "Failed to parse stored claims for scope %s", scope);
}
} else {
LOGGER.infof("No stored claims found for scope %s", scope);
}
// No claims filtering requested, all claims are valid
} 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(ErrorType.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(ErrorType.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);
}
}
}

View File

@@ -27,6 +27,8 @@ import java.util.HashMap;
/**
* Represents an authorization_details object in the Token Request as per OID4VCI.
*
* @author <a href="mailto:Forkim.Akwichek@adorsys.com">Forkim Akwichek</a>
*/
public class AuthorizationDetail {
@@ -36,12 +38,12 @@ public class AuthorizationDetail {
@JsonProperty("credential_configuration_id")
private String credentialConfigurationId;
@JsonProperty("format")
private String format;
@JsonProperty("locations")
private List<String> locations;
@JsonProperty("claims")
private List<ClaimsDescription> claims;
@JsonIgnore
private Map<String, Object> additionalFields = new HashMap<>();
@@ -71,14 +73,6 @@ public class AuthorizationDetail {
this.credentialConfigurationId = credentialConfigurationId;
}
public String getFormat() {
return format;
}
public void setFormat(String format) {
this.format = format;
}
public List<String> getLocations() {
return locations;
}
@@ -86,4 +80,12 @@ public class AuthorizationDetail {
public void setLocations(List<String> locations) {
this.locations = locations;
}
public List<ClaimsDescription> getClaims() {
return claims;
}
public void setClaims(List<ClaimsDescription> claims) {
this.claims = claims;
}
}

View File

@@ -0,0 +1,68 @@
/*
* Copyright 2025 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.protocol.oid4vc.model;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
/**
* Represents a claims description object as used in authorization details.
* A claims description object defines the requirements for the claims that the Wallet
* requests to be included in the Credential.
*
* @author <a href="mailto:Forkim.Akwichek@adorsys.com">Forkim Akwichek</a>
*/
public class ClaimsDescription {
@JsonProperty("path")
private List<Object> path;
@JsonProperty("mandatory")
private Boolean mandatory;
public ClaimsDescription() {
}
public ClaimsDescription(List<Object> path, Boolean mandatory) {
this.path = path;
this.mandatory = mandatory;
}
public List<Object> getPath() {
return path;
}
public void setPath(List<Object> path) {
this.path = path;
}
public Boolean getMandatory() {
return mandatory;
}
public void setMandatory(Boolean mandatory) {
this.mandatory = mandatory;
}
/**
* Returns the mandatory flag, defaulting to false if not set.
*/
public boolean isMandatory() {
return mandatory != null ? mandatory : false;
}
}

View File

@@ -0,0 +1,458 @@
/*
* Copyright 2025 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.protocol.oid4vc.utils;
import org.jboss.logging.Logger;
import org.keycloak.protocol.oid4vc.model.ClaimsDescription;
import org.keycloak.utils.StringUtil;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.HashMap;
import java.util.Objects;
/**
* Utility class for handling claims path pointers.
* A claims path pointer is a pointer into the Verifiable Credential, identifying one or more claims.
*
* @author <a href="mailto:Forkim.Akwichek@adorsys.com">Forkim Akwichek</a>
*/
public class ClaimsPathPointer {
private static final Logger logger = Logger.getLogger(ClaimsPathPointer.class);
/**
* Validates a claims path pointer.
*
* @param path the claims path pointer to validate
* @return true if valid, false otherwise
*/
public static boolean isValidPath(List<Object> path) {
if (path == null || path.isEmpty()) {
return false;
}
for (Object component : path) {
if (component == null) {
// null is valid for array selection
continue;
}
if (component instanceof String) {
// String is valid for object key selection, but should not be blank
if (StringUtil.isBlank((String) component)) {
return false;
}
continue;
}
if (component instanceof Integer) {
Integer index = (Integer) component;
if (index < 0) {
// Negative integers are not allowed
return false;
}
// Non-negative integers are valid for array index selection
continue;
}
// Any other type is invalid
return false;
}
return true;
}
/**
* Validates a list of claims descriptions for conflicts and contradictions.
*
* @param claims the list of claims descriptions to validate
* @return true if valid, false if conflicts are found
*/
public static boolean validateClaimsDescriptions(List<ClaimsDescription> claims) {
if (claims == null || claims.isEmpty()) {
return true;
}
// Check for repeated or contradictory claim descriptions
for (int i = 0; i < claims.size(); i++) {
for (int j = i + 1; j < claims.size(); j++) {
ClaimsDescription claim1 = claims.get(i);
ClaimsDescription claim2 = claims.get(j);
if (isConflicting(claim1, claim2)) {
logger.warnf("Conflicting claims descriptions found: %s and %s", claim1.getPath(), claim2.getPath());
return false;
}
}
}
return true;
}
/**
* Checks if two claims descriptions are conflicting.
*
* @param claim1 first claims description
* @param claim2 second claims description
* @return true if conflicting, false otherwise
*/
private static boolean isConflicting(ClaimsDescription claim1, ClaimsDescription claim2) {
List<Object> path1 = claim1.getPath();
List<Object> path2 = claim2.getPath();
if (path1 == null || path2 == null) {
return false;
}
// Check if paths are identical (same claim addressed)
if (path1.equals(path2)) {
return true;
}
// Check for array vs object conflicts
return hasArrayObjectConflict(path1, path2);
}
/**
* Checks if there's a conflict between array and object addressing for the same claim.
*
* @param path1 first path
* @param path2 second path
* @return true if there's an array/object conflict, false otherwise
*/
private static boolean hasArrayObjectConflict(List<Object> path1, List<Object> path2) {
int minLength = Math.min(path1.size(), path2.size());
for (int i = 0; i < minLength; i++) {
Object comp1 = path1.get(i);
Object comp2 = path2.get(i);
// If components are different types and one is null (array selection) and the other is string (object selection)
if (comp1 == null && comp2 instanceof String) {
return true;
}
if (comp2 == null && comp1 instanceof String) {
return true;
}
// If components are different types and one is integer (specific array index) and the other is null (all array elements)
if (comp1 == null && comp2 instanceof Integer) {
return true;
}
if (comp2 == null && comp1 instanceof Integer) {
return true;
}
// If components are equal, return true (as suggested by reviewer)
if (Objects.equals(comp1, comp2)) {
return true;
}
}
return false;
}
/**
* Filters a map of claims based on authorization details claims descriptions.
* Only claims that match the requested paths will be included in the result.
*
* @param allClaims the complete map of claims to filter
* @param requestedClaims the list of claims descriptions from authorization details
* @return filtered map containing only the requested claims
* @throws IllegalArgumentException if mandatory claims are missing
*/
public static Map<String, Object> filterClaimsByAuthorizationDetails(
Map<String, Object> allClaims,
List<ClaimsDescription> requestedClaims) {
if (requestedClaims == null || requestedClaims.isEmpty()) {
return allClaims; // No filtering requested, return all claims
}
Map<String, Object> filteredClaims = new HashMap<>();
for (ClaimsDescription claim : requestedClaims) {
List<Object> path = claim.getPath();
if (path == null || path.isEmpty()) {
continue; // Skip invalid paths
}
// Validate the claims path pointer format according to OID4VCI specification
if (!isValidPath(path)) {
logger.warnf("Invalid claims path pointer: %s. Path must contain only strings, non-negative integers, and null values.", path);
continue; // Skip invalid paths
}
try {
// Get claim values
List<Object> claimValues = processClaimsPathPointer(allClaims, path);
if (!claimValues.isEmpty()) {
// Add all selected claim values to filtered results
if (claimValues.size() == 1) {
// Single value, use existing method
addClaimByPath(filteredClaims, path, claimValues.get(0));
} else {
// Multiple values from array selection, use helper method
addMultipleClaimsByPath(filteredClaims, path, claimValues);
}
} else if (Boolean.TRUE.equals(claim.getMandatory())) {
// Mandatory claim is missing - this should fail
throw new IllegalArgumentException("Mandatory claim not found: " + path);
}
// 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());
// 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());
}
}
return filteredClaims;
}
/**
* Processes a claims path pointer according to OID4VCI specification.
*
* @param claims the claims map to search in
* @param path the claims path pointer
* @return the set of selected JSON elements, or empty list if none found
* @throws IllegalArgumentException if processing fails according to spec rules
*/
public static List<Object> processClaimsPathPointer(Map<String, Object> claims, List<Object> path) {
if (path == null || path.isEmpty()) {
throw new IllegalArgumentException("Claims path pointer must be a non-empty array");
}
if (claims == null) {
throw new IllegalArgumentException("Claims map cannot be null");
}
// Start with root element
List<Object> currentSelection = new ArrayList<>();
currentSelection.add(claims);
// Process each path component from left to right
for (Object component : path) {
if (currentSelection.isEmpty()) {
throw new IllegalArgumentException("No elements currently selected, cannot process further");
}
List<Object> nextSelection = new ArrayList<>();
for (Object current : currentSelection) {
if (component instanceof String) {
// String component: select element by key
if (current instanceof Map) {
Map<?, ?> map = (Map<?, ?>) current;
Object value = map.get(component);
if (value != null) {
nextSelection.add(value);
}
}
} else if (component instanceof Integer) {
// Integer component: select element by index
int index = (Integer) component;
if (index < 0) {
throw new IllegalArgumentException("Negative integer values are not allowed in claims path pointer");
}
if (current instanceof List) {
List<?> list = (List<?>) current;
if (index < list.size()) {
nextSelection.add(list.get(index));
}
}
} else if (component == null) {
// Null component: select all elements of currently selected array(s)
if (current instanceof List) {
List<?> list = (List<?>) current;
nextSelection.addAll(list);
}
} else {
throw new IllegalArgumentException("Invalid path component type: " + component.getClass().getSimpleName() +
". Only String, Integer, and null are allowed.");
}
}
currentSelection = nextSelection;
}
if (currentSelection.isEmpty()) {
throw new IllegalArgumentException("No elements selected after processing claims path pointer");
}
return currentSelection;
}
/**
* Adds multiple claim values to a claims map when array selection is involved.
* This method properly handles paths with null components that select multiple array elements.
*
* @param claims the claims map to add to
* @param path the claims path pointer
* @param values the list of values to add
*/
private static void addMultipleClaimsByPath(Map<String, Object> claims, List<Object> path, List<Object> values) {
if (values == null || values.isEmpty()) {
return;
}
// For simple paths, add the first value
if (path.size() == 1 && path.get(0) instanceof String) {
claims.put((String) path.get(0), values.get(0));
return;
}
// For complex paths with array selection, we need to handle the structure properly
// This creates the appropriate nested structure to hold the selected values
if (values.size() == 1) {
// Single value, use existing method
addClaimByPath(claims, path, values.get(0));
} else {
// Multiple values - this indicates array selection
// We need to create an array structure to hold all values
createArrayStructureForMultipleValues(claims, path, values);
}
}
/**
* Creates an array structure to hold multiple values from array selection.
* This handles the case where a path with null components selects multiple array elements.
*
* @param claims the claims map to add to
* @param path the claims path pointer
* @param values the list of values to add
*/
private static void createArrayStructureForMultipleValues(Map<String, Object> claims, List<Object> path, List<Object> values) {
buildNestedStructure(claims, path, new ArrayList<Object>(values), true);
}
/**
* Adds a claim value to a claims map using a claims path pointer.
*
* @param claims the claims map to add to
* @param path the claims path pointer
* @return the claim value, or null if not found
*/
private static void addClaimByPath(Map<String, Object> claims, List<Object> path, Object value) {
if (path == null || path.isEmpty() || claims == null) {
return;
}
if (path.size() == 1 && path.get(0) instanceof String) {
// Simple case: direct key assignment
claims.put((String) path.get(0), value);
return;
}
// Complex case: nested path - build the structure
buildNestedClaimStructure(claims, path, value);
}
/**
* Builds nested claim structure for complex paths.
*
* @param claims the claims map to build in
* @param path the claims path pointer
* @param value the value to add
*/
private static void buildNestedClaimStructure(Map<String, Object> claims, List<Object> path, Object value) {
buildNestedStructure(claims, path, value, false);
}
/**
* Generic method to build nested structure for both single values and multiple values.
*
* @param claims the claims map to build in
* @param path the claims path pointer
* @param value the value to add (single value or list of values)
* @param isArraySelection true if this is for array selection (multiple values), false for single value
*/
private static void buildNestedStructure(Map<String, Object> claims, List<Object> path, Object value, boolean isArraySelection) {
if (path.size() < 2) {
return;
}
Object current = claims;
String rootKey = (String) path.get(0);
// Ensure root key exists
if (!(current instanceof Map)) {
return;
}
Map<String, Object> rootMap = (Map<String, Object>) current;
if (!rootMap.containsKey(rootKey)) {
// Use ArrayList for array selection, HashMap for single values
rootMap.put(rootKey, isArraySelection ? new ArrayList<Object>() : new HashMap<String, Object>());
}
current = rootMap.get(rootKey);
// Navigate through the path, building structure as needed
for (int i = 1; i < path.size() - 1; i++) {
Object component = path.get(i);
if (component instanceof String) {
if (!(current instanceof Map)) {
return; // Can't navigate further
}
Map<String, Object> map = (Map<String, Object>) current;
if (!map.containsKey(component)) {
map.put((String) component, new HashMap<String, Object>());
}
current = map.get(component);
} else if (component instanceof Integer) {
if (!(current instanceof List)) {
return; // Can't navigate further
}
List<Object> list = (List<Object>) current;
int index = (Integer) component;
while (list.size() <= index) {
// Use HashMap for single values, null for array selection
list.add(isArraySelection ? null : new HashMap<String, Object>());
}
current = list.get(index);
}
}
// Set the final value
Object finalComponent = path.get(path.size() - 1);
if (finalComponent instanceof String) {
if (current instanceof Map) {
Map<String, Object> map = (Map<String, Object>) current;
map.put((String) finalComponent, value);
}
} else if (finalComponent instanceof Integer) {
if (current instanceof List) {
List<Object> list = (List<Object>) current;
int index = (Integer) finalComponent;
while (list.size() <= index) {
list.add(null);
}
list.set(index, value);
}
}
}
}

View File

@@ -217,7 +217,7 @@ public class AuthorizationCodeGrantType extends OAuth2GrantTypeBase {
// Process authorization_details using provider discovery (only if present)
if (formParams.getFirst(AUTHORIZATION_DETAILS_PARAM) != null) {
List<AuthorizationDetailsResponse> authorizationDetailsResponse = processAuthorizationDetails(userSession, clientSessionCtx);
if (authorizationDetailsResponse != null) {
if (authorizationDetailsResponse != null && !authorizationDetailsResponse.isEmpty()) {
clientSessionCtx.setAttribute(AUTHORIZATION_DETAILS_RESPONSE, authorizationDetailsResponse);
} else {
logger.debugf("No available AuthorizationDetailsProcessor being able to process authorization_details '%s'", formParams.getFirst(AUTHORIZATION_DETAILS_PARAM));
@@ -230,7 +230,7 @@ public class AuthorizationCodeGrantType extends OAuth2GrantTypeBase {
@Override
protected void addCustomTokenResponseClaims(AccessTokenResponse res, ClientSessionContext clientSessionCtx) {
List<AuthorizationDetailsResponse> authDetailsResponse = clientSessionCtx.getAttribute(AUTHORIZATION_DETAILS_RESPONSE, List.class);
if (authDetailsResponse != null) {
if (authDetailsResponse != null && !authDetailsResponse.isEmpty()) {
res.setOtherClaims(AUTHORIZATION_DETAILS_PARAM, authDetailsResponse);
}
}

View File

@@ -117,7 +117,7 @@ public class PreAuthorizedCodeGrantType extends OAuth2GrantTypeBase {
}
// If authorization_details is present, add it to otherClaims
if (authorizationDetailsResponse != null) {
if (authorizationDetailsResponse != null && !authorizationDetailsResponse.isEmpty()) {
tokenResponse.setOtherClaims(AUTHORIZATION_DETAILS_PARAM, authorizationDetailsResponse);
}

View File

@@ -0,0 +1,244 @@
/*
* Copyright 2025 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.testsuite.oid4vc.issuance.signing;
import jakarta.ws.rs.core.HttpHeaders;
import org.apache.commons.io.IOUtils;
import org.apache.http.HttpStatus;
import org.apache.http.NameValuePair;
import org.apache.http.client.entity.UrlEncodedFormEntity;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.message.BasicNameValuePair;
import org.junit.Test;
import org.keycloak.OAuth2Constants;
import org.keycloak.protocol.oid4vc.model.AuthorizationDetail;
import org.keycloak.protocol.oid4vc.model.ClaimsDescription;
import org.keycloak.protocol.oid4vc.model.CredentialIssuer;
import org.keycloak.protocol.oid4vc.model.CredentialsOffer;
import org.keycloak.protocol.oid4vc.model.CredentialRequest;
import org.keycloak.protocol.oid4vc.model.CredentialResponse;
import org.keycloak.protocol.oid4vc.issuance.OID4VCAuthorizationDetailsResponse;
import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation;
import org.keycloak.representations.AccessTokenResponse;
import org.keycloak.util.JsonSerialization;
import com.fasterxml.jackson.core.type.TypeReference;
import org.keycloak.models.oid4vci.CredentialScopeModel;
import org.apache.http.entity.StringEntity;
import org.keycloak.representations.idm.ClientScopeRepresentation;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import static org.junit.Assert.*;
/**
* Base class for authorization code flow tests with authorization details and claims validation.
* Contains common test logic that can be reused by JWT and SD-JWT specific test classes.
*
* @author <a href="mailto:Forkim.Akwichek@adorsys.com">Forkim Akwichek</a>
*/
public abstract class OID4VCAuthorizationCodeFlowTestBase extends OID4VCIssuerEndpointTest {
public static final String OPENID_CREDENTIAL_TYPE = "openid_credential";
/**
* Test context for OID4VC tests
*/
protected static class Oid4vcTestContext {
public CredentialIssuer credentialIssuer;
public CredentialsOffer credentialsOffer;
public OIDCConfigurationRepresentation openidConfig;
}
/**
* Get the credential format (jwt_vc or sd_jwt_vc)
*/
protected abstract String getCredentialFormat();
/**
* Get the credential client scope
*/
protected abstract ClientScopeRepresentation getCredentialClientScope();
/**
* Get the expected claim path for the credential format
*/
protected abstract String getExpectedClaimPath();
/**
* Prepare OID4VC test context by fetching issuer metadata and credential offer
*/
protected Oid4vcTestContext prepareOid4vcTestContext(String token) throws Exception {
Oid4vcTestContext ctx = new Oid4vcTestContext();
// Get credential issuer metadata
HttpGet getCredentialIssuer = new HttpGet(getRealmPath(TEST_REALM_NAME) + "/.well-known/openid-credential-issuer");
try (CloseableHttpResponse response = httpClient.execute(getCredentialIssuer)) {
assertEquals(HttpStatus.SC_OK, response.getStatusLine().getStatusCode());
String s = IOUtils.toString(response.getEntity().getContent(), StandardCharsets.UTF_8);
ctx.credentialIssuer = JsonSerialization.readValue(s, CredentialIssuer.class);
}
// Get OpenID configuration
HttpGet getOpenidConfiguration = new HttpGet(ctx.credentialIssuer.getAuthorizationServers().get(0) + "/.well-known/openid-configuration");
try (CloseableHttpResponse response = httpClient.execute(getOpenidConfiguration)) {
assertEquals(HttpStatus.SC_OK, response.getStatusLine().getStatusCode());
String s = IOUtils.toString(response.getEntity().getContent(), StandardCharsets.UTF_8);
ctx.openidConfig = JsonSerialization.readValue(s, OIDCConfigurationRepresentation.class);
}
return ctx;
}
@Test
public void testCompleteFlowWithClaimsValidationAuthorizationCode() throws Exception {
Oid4vcTestContext ctx = prepareOid4vcTestContext(null);
// Perform authorization code flow to get authorization code
oauth.client(client.getClientId());
oauth.scope(getCredentialClientScope().getName()); // Add the credential scope
oauth.loginForm().doLogin("john", "password");
String code = oauth.parseLoginResponse().getCode();
assertNotNull("Authorization code should not be null", code);
// Create authorization details with claims for token exchange
ClaimsDescription claim = new ClaimsDescription();
// Construct claim path based on credential format
List<Object> claimPath;
if ("sd_jwt_vc".equals(getCredentialFormat())) {
claimPath = Arrays.asList(getExpectedClaimPath());
} else {
claimPath = Arrays.asList("credentialSubject", getExpectedClaimPath());
}
claim.setPath(claimPath);
claim.setMandatory(true);
AuthorizationDetail authDetail = new AuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL_TYPE);
authDetail.setCredentialConfigurationId(getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID));
authDetail.setClaims(Arrays.asList(claim));
authDetail.setLocations(Collections.singletonList(ctx.credentialIssuer.getCredentialIssuer()));
List<AuthorizationDetail> authDetails = List.of(authDetail);
String authDetailsJson = JsonSerialization.writeValueAsString(authDetails);
// Exchange authorization code for tokens with authorization_details
HttpPost postToken = new HttpPost(ctx.openidConfig.getTokenEndpoint());
List<NameValuePair> tokenParameters = new LinkedList<>();
tokenParameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, OAuth2Constants.AUTHORIZATION_CODE));
tokenParameters.add(new BasicNameValuePair(OAuth2Constants.CODE, code));
tokenParameters.add(new BasicNameValuePair(OAuth2Constants.REDIRECT_URI, oauth.getRedirectUri()));
tokenParameters.add(new BasicNameValuePair(OAuth2Constants.CLIENT_ID, oauth.getClientId()));
tokenParameters.add(new BasicNameValuePair(OAuth2Constants.CLIENT_SECRET, "password"));
tokenParameters.add(new BasicNameValuePair("authorization_details", authDetailsJson));
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);
}
// Extract authorization_details from token response
List<OID4VCAuthorizationDetailsResponse> authDetailsResponse = parseAuthorizationDetails(JsonSerialization.writeValueAsString(tokenResponse));
assertNotNull("authorization_details should be present in the response", authDetailsResponse);
assertEquals(1, authDetailsResponse.size());
OID4VCAuthorizationDetailsResponse authDetailResponse = authDetailsResponse.get(0);
assertNotNull("Credential identifiers should be present", authDetailResponse.getCredentialIdentifiers());
assertEquals(1, authDetailResponse.getCredentialIdentifiers().size());
String credentialIdentifier = authDetailResponse.getCredentialIdentifiers().get(0);
assertNotNull("Credential identifier should not be null", credentialIdentifier);
// Request the actual credential using the identifier
HttpPost postCredential = new HttpPost(ctx.credentialIssuer.getCredentialEndpoint());
postCredential.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + tokenResponse.getToken());
postCredential.addHeader(HttpHeaders.CONTENT_TYPE, "application/json");
CredentialRequest credentialRequest = new CredentialRequest();
credentialRequest.setFormat(getCredentialFormat());
credentialRequest.setCredentialIdentifier(credentialIdentifier);
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);
// 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());
// 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);
// 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 format
verifyCredentialStructure(credentialObj);
}
}
/**
* Verify the credential structure based on the format.
* Subclasses can override this to provide format-specific verification.
*/
protected void verifyCredentialStructure(Object credentialObj) {
// Default implementation - subclasses should override
assertNotNull("Credential object should not be null", credentialObj);
}
/**
* Parse authorization details from the token response.
*/
protected List<OID4VCAuthorizationDetailsResponse> parseAuthorizationDetails(String responseBody) {
try {
// Parse the JSON response to extract authorization_details
Map<String, Object> responseMap = JsonSerialization.readValue(responseBody, Map.class);
Object authDetailsObj = responseMap.get("authorization_details");
if (authDetailsObj == null) {
return Collections.emptyList();
}
// Convert to list of OID4VCAuthorizationDetailsResponse
return JsonSerialization.readValue(JsonSerialization.writeValueAsString(authDetailsObj),
new TypeReference<List<OID4VCAuthorizationDetailsResponse>>() {
});
} catch (Exception e) {
throw new RuntimeException("Failed to parse authorization_details from response", e);
}
}
}

View File

@@ -0,0 +1,567 @@
/*
* Copyright 2025 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.testsuite.oid4vc.issuance.signing;
import jakarta.ws.rs.core.HttpHeaders;
import org.apache.commons.io.IOUtils;
import org.apache.http.HttpStatus;
import org.apache.http.NameValuePair;
import org.apache.http.client.entity.UrlEncodedFormEntity;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.message.BasicNameValuePair;
import org.junit.Test;
import org.keycloak.OAuth2Constants;
import org.keycloak.protocol.oid4vc.model.AuthorizationDetail;
import org.keycloak.protocol.oid4vc.model.ClaimsDescription;
import org.keycloak.protocol.oid4vc.model.CredentialIssuer;
import org.keycloak.protocol.oid4vc.model.CredentialOfferURI;
import org.keycloak.protocol.oid4vc.model.CredentialsOffer;
import org.keycloak.protocol.oid4vc.model.CredentialRequest;
import org.keycloak.protocol.oid4vc.model.CredentialResponse;
import org.keycloak.protocol.oid4vc.issuance.OID4VCAuthorizationDetailsResponse;
import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantTypeFactory;
import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation;
import org.keycloak.util.JsonSerialization;
import com.fasterxml.jackson.core.type.TypeReference;
import org.keycloak.models.oid4vci.CredentialScopeModel;
import org.apache.http.entity.StringEntity;
import org.keycloak.representations.idm.ClientScopeRepresentation;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.fail;
import static org.keycloak.protocol.oid4vc.issuance.OID4VCAuthorizationDetailsProcessor.OPENID_CREDENTIAL_TYPE;
/**
* Base class for authorization details flow tests.
* Contains common test logic that can be reused by JWT and SD-JWT specific test classes.
*
* @author <a href="mailto:Forkim.Akwichek@adorsys.com">Forkim Akwichek</a>
*/
public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssuerEndpointTest {
protected static class Oid4vcTestContext {
CredentialsOffer credentialsOffer;
CredentialIssuer credentialIssuer;
OIDCConfigurationRepresentation openidConfig;
}
/**
* Get the credential format for this test implementation.
*
* @return the credential format (e.g., "jwt_vc", "sd_jwt_vc")
*/
protected abstract String getCredentialFormat();
/**
* Get the credential client scope for this test implementation.
*
* @return the client scope model
*/
protected abstract ClientScopeRepresentation getCredentialClientScope();
/**
* Get the expected claim path for this test implementation.
*
* @return the claim path as a string
*/
protected abstract String getExpectedClaimPath();
protected Oid4vcTestContext prepareOid4vcTestContext(String token) throws Exception {
Oid4vcTestContext ctx = new Oid4vcTestContext();
String credentialConfigurationId = getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
HttpGet getCredentialOfferURI = new HttpGet(getBasePath(TEST_REALM_NAME) + "credential-offer-uri?credential_configuration_id=" + credentialConfigurationId);
getCredentialOfferURI.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token);
CredentialOfferURI credentialOfferURI;
try (CloseableHttpResponse response = httpClient.execute(getCredentialOfferURI)) {
int status = response.getStatusLine().getStatusCode();
assertEquals(HttpStatus.SC_OK, status);
String s = IOUtils.toString(response.getEntity().getContent(), StandardCharsets.UTF_8);
credentialOfferURI = JsonSerialization.readValue(s, CredentialOfferURI.class);
}
HttpGet getCredentialOffer = new HttpGet(credentialOfferURI.getIssuer() + "/" + credentialOfferURI.getNonce());
try (CloseableHttpResponse response = httpClient.execute(getCredentialOffer)) {
assertEquals(HttpStatus.SC_OK, response.getStatusLine().getStatusCode());
String s = IOUtils.toString(response.getEntity().getContent(), StandardCharsets.UTF_8);
ctx.credentialsOffer = JsonSerialization.readValue(s, CredentialsOffer.class);
}
HttpGet getIssuerMetadata = new HttpGet(ctx.credentialsOffer.getCredentialIssuer() + "/.well-known/openid-credential-issuer");
try (CloseableHttpResponse response = httpClient.execute(getIssuerMetadata)) {
assertEquals(HttpStatus.SC_OK, response.getStatusLine().getStatusCode());
String s = IOUtils.toString(response.getEntity().getContent(), StandardCharsets.UTF_8);
ctx.credentialIssuer = JsonSerialization.readValue(s, CredentialIssuer.class);
}
HttpGet getOpenidConfiguration = new HttpGet(ctx.credentialIssuer.getAuthorizationServers().get(0) + "/.well-known/openid-configuration");
try (CloseableHttpResponse response = httpClient.execute(getOpenidConfiguration)) {
assertEquals(HttpStatus.SC_OK, response.getStatusLine().getStatusCode());
String s = IOUtils.toString(response.getEntity().getContent(), StandardCharsets.UTF_8);
ctx.openidConfig = JsonSerialization.readValue(s, OIDCConfigurationRepresentation.class);
}
return ctx;
}
@Test
public void testPreAuthorizedCodeWithAuthorizationDetailsCredentialConfigurationId() throws Exception {
String token = getBearerToken(oauth, client, getCredentialClientScope().getName());
Oid4vcTestContext ctx = prepareOid4vcTestContext(token);
AuthorizationDetail authDetail = new AuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL_TYPE);
authDetail.setCredentialConfigurationId(getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID));
authDetail.setLocations(Collections.singletonList(ctx.credentialIssuer.getCredentialIssuer()));
List<AuthorizationDetail> authDetails = List.of(authDetail);
String authDetailsJson = JsonSerialization.writeValueAsString(authDetails);
HttpPost postPreAuthorizedCode = new HttpPost(ctx.openidConfig.getTokenEndpoint());
List<NameValuePair> parameters = new LinkedList<>();
parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, PreAuthorizedCodeGrantTypeFactory.GRANT_TYPE));
parameters.add(new BasicNameValuePair(PreAuthorizedCodeGrantTypeFactory.CODE_REQUEST_PARAM, ctx.credentialsOffer.getGrants().getPreAuthorizedCode().getPreAuthorizedCode()));
parameters.add(new BasicNameValuePair("authorization_details", authDetailsJson));
UrlEncodedFormEntity formEntity = new UrlEncodedFormEntity(parameters, StandardCharsets.UTF_8);
postPreAuthorizedCode.setEntity(formEntity);
try (CloseableHttpResponse tokenResponse = httpClient.execute(postPreAuthorizedCode)) {
assertEquals(HttpStatus.SC_OK, tokenResponse.getStatusLine().getStatusCode());
String responseBody = IOUtils.toString(tokenResponse.getEntity().getContent(), StandardCharsets.UTF_8);
List<OID4VCAuthorizationDetailsResponse> authDetailsResponse = parseAuthorizationDetails(responseBody);
assertNotNull("authorization_details should be present in the response", authDetailsResponse);
assertEquals(1, authDetailsResponse.size());
OID4VCAuthorizationDetailsResponse authDetailResponse = authDetailsResponse.get(0);
assertEquals(OPENID_CREDENTIAL_TYPE, authDetailResponse.getType());
assertEquals(getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID), authDetailResponse.getCredentialConfigurationId());
assertNotNull(authDetailResponse.getCredentialIdentifiers());
assertEquals(1, authDetailResponse.getCredentialIdentifiers().size());
String firstIdentifier = authDetailResponse.getCredentialIdentifiers().get(0);
assertNotNull("Identifier should not be null", firstIdentifier);
assertFalse("Identifier should not be empty", firstIdentifier.isEmpty());
try {
UUID.fromString(firstIdentifier);
} catch (IllegalArgumentException e) {
fail("Identifier should be a valid UUID, but was: " + firstIdentifier);
}
}
}
@Test
public void testPreAuthorizedCodeWithAuthorizationDetailsAndClaims() throws Exception {
String token = getBearerToken(oauth, client, getCredentialClientScope().getName());
Oid4vcTestContext ctx = prepareOid4vcTestContext(token);
// Create claims description for a claim that should be supported
ClaimsDescription claim = new ClaimsDescription();
// Construct claim path based on credential format
List<Object> claimPath;
if ("sd_jwt_vc".equals(getCredentialFormat())) {
// SD-JWT doesn't use credentialSubject prefix
claimPath = Arrays.asList(getExpectedClaimPath());
} else {
// JWT and other formats use credentialSubject prefix
claimPath = Arrays.asList("credentialSubject", getExpectedClaimPath());
}
claim.setPath(claimPath);
claim.setMandatory(true);
AuthorizationDetail authDetail = new AuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL_TYPE);
authDetail.setCredentialConfigurationId(getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID));
authDetail.setClaims(Arrays.asList(claim));
authDetail.setLocations(Collections.singletonList(ctx.credentialIssuer.getCredentialIssuer()));
List<AuthorizationDetail> authDetails = List.of(authDetail);
String authDetailsJson = JsonSerialization.writeValueAsString(authDetails);
HttpPost postPreAuthorizedCode = new HttpPost(ctx.openidConfig.getTokenEndpoint());
List<NameValuePair> parameters = new LinkedList<>();
parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, PreAuthorizedCodeGrantTypeFactory.GRANT_TYPE));
parameters.add(new BasicNameValuePair(PreAuthorizedCodeGrantTypeFactory.CODE_REQUEST_PARAM, ctx.credentialsOffer.getGrants().getPreAuthorizedCode().getPreAuthorizedCode()));
parameters.add(new BasicNameValuePair("authorization_details", authDetailsJson));
UrlEncodedFormEntity formEntity = new UrlEncodedFormEntity(parameters, StandardCharsets.UTF_8);
postPreAuthorizedCode.setEntity(formEntity);
try (CloseableHttpResponse tokenResponse = httpClient.execute(postPreAuthorizedCode)) {
assertEquals(HttpStatus.SC_OK, tokenResponse.getStatusLine().getStatusCode());
String responseBody = IOUtils.toString(tokenResponse.getEntity().getContent(), StandardCharsets.UTF_8);
List<OID4VCAuthorizationDetailsResponse> authDetailsResponse = parseAuthorizationDetails(responseBody);
assertNotNull("authorization_details should be present in the response", authDetailsResponse);
assertEquals(1, authDetailsResponse.size());
OID4VCAuthorizationDetailsResponse authDetailResponse = authDetailsResponse.get(0);
assertEquals(OPENID_CREDENTIAL_TYPE, authDetailResponse.getType());
assertEquals(getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID), authDetailResponse.getCredentialConfigurationId());
assertNotNull(authDetailResponse.getClaims());
assertEquals(1, authDetailResponse.getClaims().size());
ClaimsDescription responseClaim = authDetailResponse.getClaims().get(0);
List<Object> expectedClaimPath;
if ("sd_jwt_vc".equals(getCredentialFormat())) {
expectedClaimPath = Arrays.asList(getExpectedClaimPath());
} else {
expectedClaimPath = Arrays.asList("credentialSubject", getExpectedClaimPath());
}
assertEquals(expectedClaimPath, responseClaim.getPath());
assertTrue(responseClaim.isMandatory());
// Verify that credential identifiers are present
assertNotNull(authDetailResponse.getCredentialIdentifiers());
assertEquals(1, authDetailResponse.getCredentialIdentifiers().size());
}
}
@Test
public void testPreAuthorizedCodeWithUnsupportedClaims() throws Exception {
String token = getBearerToken(oauth, client, getCredentialClientScope().getName());
Oid4vcTestContext ctx = prepareOid4vcTestContext(token);
// Create claims description for a claim that should NOT be supported
ClaimsDescription claim = new ClaimsDescription();
claim.setPath(Arrays.asList("credentialSubject", "unsupportedClaim"));
claim.setMandatory(false);
AuthorizationDetail authDetail = new AuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL_TYPE);
authDetail.setCredentialConfigurationId(getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID));
authDetail.setClaims(Arrays.asList(claim));
authDetail.setLocations(Collections.singletonList(ctx.credentialIssuer.getCredentialIssuer()));
List<AuthorizationDetail> authDetails = List.of(authDetail);
String authDetailsJson = JsonSerialization.writeValueAsString(authDetails);
HttpPost postPreAuthorizedCode = new HttpPost(ctx.openidConfig.getTokenEndpoint());
List<NameValuePair> parameters = new LinkedList<>();
parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, PreAuthorizedCodeGrantTypeFactory.GRANT_TYPE));
parameters.add(new BasicNameValuePair(PreAuthorizedCodeGrantTypeFactory.CODE_REQUEST_PARAM, ctx.credentialsOffer.getGrants().getPreAuthorizedCode().getPreAuthorizedCode()));
parameters.add(new BasicNameValuePair("authorization_details", authDetailsJson));
UrlEncodedFormEntity formEntity = new UrlEncodedFormEntity(parameters, StandardCharsets.UTF_8);
postPreAuthorizedCode.setEntity(formEntity);
try (CloseableHttpResponse tokenResponse = httpClient.execute(postPreAuthorizedCode)) {
// Should fail because the claim is not supported by the credential configuration
assertEquals(HttpStatus.SC_BAD_REQUEST, tokenResponse.getStatusLine().getStatusCode());
String responseBody = IOUtils.toString(tokenResponse.getEntity().getContent(), StandardCharsets.UTF_8);
assertTrue("Error message should indicate authorization_details processing error",
responseBody.contains("Error when processing authorization_details"));
}
}
@Test
public void testPreAuthorizedCodeWithMandatoryClaimMissing() throws Exception {
String token = getBearerToken(oauth, client, getCredentialClientScope().getName());
Oid4vcTestContext ctx = prepareOid4vcTestContext(token);
// Create claims description for a mandatory claim
ClaimsDescription claim = new ClaimsDescription();
claim.setPath(Arrays.asList("credentialSubject", "mandatoryClaim"));
claim.setMandatory(true);
AuthorizationDetail authDetail = new AuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL_TYPE);
authDetail.setCredentialConfigurationId(getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID));
authDetail.setClaims(Arrays.asList(claim));
authDetail.setLocations(Collections.singletonList(ctx.credentialIssuer.getCredentialIssuer()));
List<AuthorizationDetail> authDetails = List.of(authDetail);
String authDetailsJson = JsonSerialization.writeValueAsString(authDetails);
HttpPost postPreAuthorizedCode = new HttpPost(ctx.openidConfig.getTokenEndpoint());
List<NameValuePair> parameters = new LinkedList<>();
parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, PreAuthorizedCodeGrantTypeFactory.GRANT_TYPE));
parameters.add(new BasicNameValuePair(PreAuthorizedCodeGrantTypeFactory.CODE_REQUEST_PARAM, ctx.credentialsOffer.getGrants().getPreAuthorizedCode().getPreAuthorizedCode()));
parameters.add(new BasicNameValuePair("authorization_details", authDetailsJson));
UrlEncodedFormEntity formEntity = new UrlEncodedFormEntity(parameters, StandardCharsets.UTF_8);
postPreAuthorizedCode.setEntity(formEntity);
try (CloseableHttpResponse tokenResponse = httpClient.execute(postPreAuthorizedCode)) {
// Should fail because the mandatory claim is not supported
assertEquals(HttpStatus.SC_BAD_REQUEST, tokenResponse.getStatusLine().getStatusCode());
String responseBody = IOUtils.toString(tokenResponse.getEntity().getContent(), StandardCharsets.UTF_8);
assertTrue("Error message should indicate authorization_details processing error",
responseBody.contains("Error when processing authorization_details"));
}
}
@Test
public void testPreAuthorizedCodeWithComplexClaimsPath() throws Exception {
String token = getBearerToken(oauth, client, getCredentialClientScope().getName());
Oid4vcTestContext ctx = prepareOid4vcTestContext(token);
// Create claims description with complex path
ClaimsDescription claim = new ClaimsDescription();
claim.setPath(Arrays.asList("credentialSubject", "address", "street"));
claim.setMandatory(false);
AuthorizationDetail authDetail = new AuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL_TYPE);
authDetail.setCredentialConfigurationId(getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID));
authDetail.setClaims(Arrays.asList(claim));
authDetail.setLocations(Collections.singletonList(ctx.credentialIssuer.getCredentialIssuer()));
List<AuthorizationDetail> authDetails = List.of(authDetail);
String authDetailsJson = JsonSerialization.writeValueAsString(authDetails);
HttpPost postPreAuthorizedCode = new HttpPost(ctx.openidConfig.getTokenEndpoint());
List<NameValuePair> parameters = new LinkedList<>();
parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, PreAuthorizedCodeGrantTypeFactory.GRANT_TYPE));
parameters.add(new BasicNameValuePair(PreAuthorizedCodeGrantTypeFactory.CODE_REQUEST_PARAM, ctx.credentialsOffer.getGrants().getPreAuthorizedCode().getPreAuthorizedCode()));
parameters.add(new BasicNameValuePair("authorization_details", authDetailsJson));
UrlEncodedFormEntity formEntity = new UrlEncodedFormEntity(parameters, StandardCharsets.UTF_8);
postPreAuthorizedCode.setEntity(formEntity);
try (CloseableHttpResponse tokenResponse = httpClient.execute(postPreAuthorizedCode)) {
// Should fail if the complex path is not supported
int statusCode = tokenResponse.getStatusLine().getStatusCode();
if (statusCode == HttpStatus.SC_BAD_REQUEST) {
String responseBody = IOUtils.toString(tokenResponse.getEntity().getContent(), StandardCharsets.UTF_8);
assertTrue("Error message should indicate authorization_details processing error",
responseBody.contains("Error when processing authorization_details"));
} else {
// If it succeeds, verify the response structure
assertEquals(HttpStatus.SC_OK, statusCode);
String responseBody = IOUtils.toString(tokenResponse.getEntity().getContent(), StandardCharsets.UTF_8);
List<OID4VCAuthorizationDetailsResponse> authDetailsResponse = parseAuthorizationDetails(responseBody);
assertNotNull("authorization_details should be present in the response", authDetailsResponse);
assertEquals(1, authDetailsResponse.size());
}
}
}
@Test
public void testPreAuthorizedCodeWithInvalidAuthorizationDetails() throws Exception {
String token = getBearerToken(oauth, client, getCredentialClientScope().getName());
Oid4vcTestContext ctx = prepareOid4vcTestContext(token);
AuthorizationDetail authDetail = new AuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL_TYPE);
// Missing credential_configuration_id - should fail
authDetail.setLocations(Collections.singletonList(ctx.credentialIssuer.getCredentialIssuer()));
List<AuthorizationDetail> authDetails = List.of(authDetail);
String authDetailsJson = JsonSerialization.writeValueAsString(authDetails);
HttpPost postPreAuthorizedCode = new HttpPost(ctx.openidConfig.getTokenEndpoint());
List<NameValuePair> parameters = new LinkedList<>();
parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, PreAuthorizedCodeGrantTypeFactory.GRANT_TYPE));
parameters.add(new BasicNameValuePair(PreAuthorizedCodeGrantTypeFactory.CODE_REQUEST_PARAM, ctx.credentialsOffer.getGrants().getPreAuthorizedCode().getPreAuthorizedCode()));
parameters.add(new BasicNameValuePair("authorization_details", authDetailsJson));
UrlEncodedFormEntity formEntity = new UrlEncodedFormEntity(parameters, StandardCharsets.UTF_8);
postPreAuthorizedCode.setEntity(formEntity);
try (CloseableHttpResponse tokenResponse = httpClient.execute(postPreAuthorizedCode)) {
assertEquals(HttpStatus.SC_BAD_REQUEST, tokenResponse.getStatusLine().getStatusCode());
String responseBody = IOUtils.toString(tokenResponse.getEntity().getContent(), StandardCharsets.UTF_8);
assertTrue("Error message should indicate authorization_details processing error",
responseBody.contains("Error when processing authorization_details"));
}
}
@Test
public void testPreAuthorizedCodeWithInvalidClaims() throws Exception {
String token = getBearerToken(oauth, client, getCredentialClientScope().getName());
Oid4vcTestContext ctx = prepareOid4vcTestContext(token);
// Create claims description with invalid path
ClaimsDescription claim = new ClaimsDescription();
claim.setPath(null); // Invalid: null path
claim.setMandatory(false);
AuthorizationDetail authDetail = new AuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL_TYPE);
authDetail.setCredentialConfigurationId(getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID));
authDetail.setClaims(Arrays.asList(claim));
authDetail.setLocations(Collections.singletonList(ctx.credentialIssuer.getCredentialIssuer()));
List<AuthorizationDetail> authDetails = List.of(authDetail);
String authDetailsJson = JsonSerialization.writeValueAsString(authDetails);
HttpPost postPreAuthorizedCode = new HttpPost(ctx.openidConfig.getTokenEndpoint());
List<NameValuePair> parameters = new LinkedList<>();
parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, PreAuthorizedCodeGrantTypeFactory.GRANT_TYPE));
parameters.add(new BasicNameValuePair(PreAuthorizedCodeGrantTypeFactory.CODE_REQUEST_PARAM, ctx.credentialsOffer.getGrants().getPreAuthorizedCode().getPreAuthorizedCode()));
parameters.add(new BasicNameValuePair("authorization_details", authDetailsJson));
UrlEncodedFormEntity formEntity = new UrlEncodedFormEntity(parameters, StandardCharsets.UTF_8);
postPreAuthorizedCode.setEntity(formEntity);
try (CloseableHttpResponse tokenResponse = httpClient.execute(postPreAuthorizedCode)) {
assertEquals(HttpStatus.SC_BAD_REQUEST, tokenResponse.getStatusLine().getStatusCode());
String responseBody = IOUtils.toString(tokenResponse.getEntity().getContent(), StandardCharsets.UTF_8);
assertTrue("Error message should indicate authorization_details processing error",
responseBody.contains("Error when processing authorization_details"));
}
}
@Test
public void testPreAuthorizedCodeWithEmptyAuthorizationDetails() throws Exception {
String token = getBearerToken(oauth, client, getCredentialClientScope().getName());
Oid4vcTestContext ctx = prepareOid4vcTestContext(token);
// Send empty authorization_details array - should fail
String authDetailsJson = "[]";
HttpPost postPreAuthorizedCode = new HttpPost(ctx.openidConfig.getTokenEndpoint());
List<NameValuePair> parameters = new LinkedList<>();
parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, PreAuthorizedCodeGrantTypeFactory.GRANT_TYPE));
parameters.add(new BasicNameValuePair(PreAuthorizedCodeGrantTypeFactory.CODE_REQUEST_PARAM, ctx.credentialsOffer.getGrants().getPreAuthorizedCode().getPreAuthorizedCode()));
parameters.add(new BasicNameValuePair("authorization_details", authDetailsJson));
UrlEncodedFormEntity formEntity = new UrlEncodedFormEntity(parameters, StandardCharsets.UTF_8);
postPreAuthorizedCode.setEntity(formEntity);
try (CloseableHttpResponse tokenResponse = httpClient.execute(postPreAuthorizedCode)) {
assertEquals(HttpStatus.SC_BAD_REQUEST, tokenResponse.getStatusLine().getStatusCode());
String responseBody = IOUtils.toString(tokenResponse.getEntity().getContent(), StandardCharsets.UTF_8);
assertTrue("Error message should indicate authorization_details processing error",
responseBody.contains("Error when processing authorization_details"));
}
}
@Test
public void testCompleteFlowWithClaimsValidation() throws Exception {
String token = getBearerToken(oauth, client, getCredentialClientScope().getName());
Oid4vcTestContext ctx = prepareOid4vcTestContext(token);
// Step 1: Request token with authorization details containing specific claims
// This tests that requested claims are validated and present in the final credential
ClaimsDescription claim = new ClaimsDescription();
// Construct claim path based on credential format
List<Object> claimPath;
if ("sd_jwt_vc".equals(getCredentialFormat())) {
claimPath = Arrays.asList(getExpectedClaimPath());
} else {
claimPath = Arrays.asList("credentialSubject", getExpectedClaimPath());
}
claim.setPath(claimPath);
claim.setMandatory(true);
AuthorizationDetail authDetail = new AuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL_TYPE);
authDetail.setCredentialConfigurationId(getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID));
authDetail.setClaims(Arrays.asList(claim));
authDetail.setLocations(Collections.singletonList(ctx.credentialIssuer.getCredentialIssuer()));
List<AuthorizationDetail> authDetails = List.of(authDetail);
String authDetailsJson = JsonSerialization.writeValueAsString(authDetails);
HttpPost postPreAuthorizedCode = new HttpPost(ctx.openidConfig.getTokenEndpoint());
List<NameValuePair> parameters = new LinkedList<>();
parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, PreAuthorizedCodeGrantTypeFactory.GRANT_TYPE));
parameters.add(new BasicNameValuePair(PreAuthorizedCodeGrantTypeFactory.CODE_REQUEST_PARAM, ctx.credentialsOffer.getGrants().getPreAuthorizedCode().getPreAuthorizedCode()));
parameters.add(new BasicNameValuePair("authorization_details", authDetailsJson));
UrlEncodedFormEntity formEntity = new UrlEncodedFormEntity(parameters, StandardCharsets.UTF_8);
postPreAuthorizedCode.setEntity(formEntity);
String credentialIdentifier;
try (CloseableHttpResponse tokenResponse = httpClient.execute(postPreAuthorizedCode)) {
assertEquals(HttpStatus.SC_OK, tokenResponse.getStatusLine().getStatusCode());
String responseBody = IOUtils.toString(tokenResponse.getEntity().getContent(), StandardCharsets.UTF_8);
List<OID4VCAuthorizationDetailsResponse> authDetailsResponse = parseAuthorizationDetails(responseBody);
assertNotNull("authorization_details should be present in the response", authDetailsResponse);
assertEquals(1, authDetailsResponse.size());
OID4VCAuthorizationDetailsResponse authDetailResponse = authDetailsResponse.get(0);
assertNotNull("Credential identifiers should be present", authDetailResponse.getCredentialIdentifiers());
assertEquals(1, authDetailResponse.getCredentialIdentifiers().size());
credentialIdentifier = authDetailResponse.getCredentialIdentifiers().get(0);
assertNotNull("Credential identifier should not be null", credentialIdentifier);
}
// Step 2: Request the actual credential using the identifier
HttpPost postCredential = new HttpPost(ctx.credentialIssuer.getCredentialEndpoint());
postCredential.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token);
postCredential.addHeader(HttpHeaders.CONTENT_TYPE, "application/json");
CredentialRequest credentialRequest = new CredentialRequest();
credentialRequest.setFormat(getCredentialFormat());
credentialRequest.setCredentialIdentifier(credentialIdentifier);
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);
// 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());
// Step 3: 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);
// 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 format
verifyCredentialStructure(credentialObj);
}
}
/**
* Verify the credential structure based on the format.
* Subclasses can override this to provide format-specific verification.
*/
protected void verifyCredentialStructure(Object credentialObj) {
// Default implementation - subclasses should override
assertNotNull("Credential object should not be null", credentialObj);
}
/**
* Parse authorization details from the token response.
*/
protected List<OID4VCAuthorizationDetailsResponse> parseAuthorizationDetails(String responseBody) {
try {
// Parse the JSON response to extract authorization_details
Map<String, Object> responseMap = JsonSerialization.readValue(responseBody, Map.class);
Object authDetailsObj = responseMap.get("authorization_details");
if (authDetailsObj == null) {
return Collections.emptyList();
}
// Convert to list of OID4VCAuthorizationDetailsResponse
return JsonSerialization.readValue(JsonSerialization.writeValueAsString(authDetailsObj),
new TypeReference<List<OID4VCAuthorizationDetailsResponse>>() {
});
} catch (Exception e) {
throw new RuntimeException("Failed to parse authorization_details from response", e);
}
}
}

View File

@@ -0,0 +1,59 @@
/*
* Copyright 2025 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.testsuite.oid4vc.issuance.signing;
import org.keycloak.representations.idm.ClientScopeRepresentation;
import static org.junit.Assert.*;
/**
* JWT-specific authorization code flow tests with authorization details and claims validation.
* Extends the base class to inherit common test logic while providing JWT-specific implementations.
*
* @author <a href="mailto:Forkim.Akwichek@adorsys.com">Forkim Akwichek</a>
*/
public class OID4VCJwtAuthorizationCodeFlowTest extends OID4VCAuthorizationCodeFlowTestBase {
@Override
protected String getCredentialFormat() {
return "jwt_vc";
}
@Override
protected ClientScopeRepresentation getCredentialClientScope() {
return jwtTypeCredentialClientScope;
}
@Override
protected String getExpectedClaimPath() {
return "given_name";
}
@Override
protected void verifyCredentialStructure(Object credentialObj) {
assertNotNull("Credential object should not be null", credentialObj);
// For JWT VC, the credential should be a string
assertTrue("JWT credential should be a string", credentialObj instanceof String);
String jwtString = (String) credentialObj;
assertFalse("JWT credential should not be empty", jwtString.isEmpty());
// Verify it looks like a JWT (contains dots)
assertTrue("JWT should contain dots", jwtString.contains("."));
}
}

View File

@@ -17,291 +17,43 @@
package org.keycloak.testsuite.oid4vc.issuance.signing;
import jakarta.ws.rs.core.HttpHeaders;
import org.apache.commons.io.IOUtils;
import org.apache.http.HttpStatus;
import org.apache.http.NameValuePair;
import org.apache.http.client.entity.UrlEncodedFormEntity;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.message.BasicNameValuePair;
import org.junit.Test;
import org.keycloak.OAuth2Constants;
import org.keycloak.protocol.oid4vc.model.AuthorizationDetail;
import org.keycloak.protocol.oid4vc.issuance.OID4VCAuthorizationDetailsResponse;
import org.keycloak.protocol.oid4vc.model.CredentialIssuer;
import org.keycloak.protocol.oid4vc.model.CredentialOfferURI;
import org.keycloak.protocol.oid4vc.model.CredentialsOffer;
import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantTypeFactory;
import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation;
import org.keycloak.util.JsonSerialization;
import org.keycloak.models.oid4vci.CredentialScopeModel;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
import java.util.UUID;
import org.keycloak.representations.idm.ClientScopeRepresentation;
import static org.junit.Assert.*;
import static org.keycloak.protocol.oid4vc.issuance.OID4VCAuthorizationDetailsProcessor.OPENID_CREDENTIAL_TYPE;
import static org.keycloak.protocol.oid4vc.model.Format.JWT_VC;
public class OID4VCJwtAuthorizationDetailsFlowTest extends OID4VCIssuerEndpointTest {
/**
* JWT-specific authorization details flow tests.
* Extends the base class to inherit common test logic while providing JWT-specific implementations.
*
* @author <a href="mailto:Forkim.Akwichek@adorsys.com">Forkim Akwichek</a>
*/
public class OID4VCJwtAuthorizationDetailsFlowTest extends OID4VCAuthorizationDetailsFlowTestBase {
private static class Oid4vcTestContext {
CredentialsOffer credentialsOffer;
CredentialIssuer credentialIssuer;
OIDCConfigurationRepresentation openidConfig;
@Override
protected String getCredentialFormat() {
return "jwt_vc";
}
private Oid4vcTestContext prepareOid4vcTestContext(String token) throws Exception {
Oid4vcTestContext ctx = new Oid4vcTestContext();
String credentialConfigurationId = jwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
HttpGet getCredentialOfferURI = new HttpGet(getBasePath(TEST_REALM_NAME) + "credential-offer-uri?credential_configuration_id=" + credentialConfigurationId);
getCredentialOfferURI.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token);
CredentialOfferURI credentialOfferURI;
try (CloseableHttpResponse response = httpClient.execute(getCredentialOfferURI)) {
int status = response.getStatusLine().getStatusCode();
if (status != HttpStatus.SC_OK) {
String body = IOUtils.toString(response.getEntity().getContent(), StandardCharsets.UTF_8);
}
assertEquals(HttpStatus.SC_OK, status);
String s = IOUtils.toString(response.getEntity().getContent(), StandardCharsets.UTF_8);
credentialOfferURI = JsonSerialization.readValue(s, CredentialOfferURI.class);
}
HttpGet getCredentialOffer = new HttpGet(credentialOfferURI.getIssuer() + "/" + credentialOfferURI.getNonce());
try (CloseableHttpResponse response = httpClient.execute(getCredentialOffer)) {
assertEquals(HttpStatus.SC_OK, response.getStatusLine().getStatusCode());
String s = IOUtils.toString(response.getEntity().getContent(), StandardCharsets.UTF_8);
ctx.credentialsOffer = JsonSerialization.readValue(s, CredentialsOffer.class);
}
HttpGet getIssuerMetadata = new HttpGet(ctx.credentialsOffer.getCredentialIssuer() + "/.well-known/openid-credential-issuer");
try (CloseableHttpResponse response = httpClient.execute(getIssuerMetadata)) {
assertEquals(HttpStatus.SC_OK, response.getStatusLine().getStatusCode());
String s = IOUtils.toString(response.getEntity().getContent(), StandardCharsets.UTF_8);
ctx.credentialIssuer = JsonSerialization.readValue(s, CredentialIssuer.class);
}
HttpGet getOpenidConfiguration = new HttpGet(ctx.credentialIssuer.getAuthorizationServers().get(0) + "/.well-known/openid-configuration");
try (CloseableHttpResponse response = httpClient.execute(getOpenidConfiguration)) {
assertEquals(HttpStatus.SC_OK, response.getStatusLine().getStatusCode());
String s = IOUtils.toString(response.getEntity().getContent(), StandardCharsets.UTF_8);
ctx.openidConfig = JsonSerialization.readValue(s, OIDCConfigurationRepresentation.class);
}
return ctx;
@Override
protected ClientScopeRepresentation getCredentialClientScope() {
return jwtTypeCredentialClientScope;
}
@Test
public void testPreAuthorizedCodeWithAuthorizationDetailsFormat() throws Exception {
String token = getBearerToken(oauth, client, jwtTypeCredentialClientScope.getName());
Oid4vcTestContext ctx = prepareOid4vcTestContext(token);
AuthorizationDetail authDetail = new AuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL_TYPE);
authDetail.setFormat(JWT_VC);
authDetail.setLocations(Collections.singletonList(ctx.credentialIssuer.getCredentialIssuer()));
List<AuthorizationDetail> authDetails = List.of(authDetail);
String authDetailsJson = JsonSerialization.writeValueAsString(authDetails);
HttpPost postPreAuthorizedCode = new HttpPost(ctx.openidConfig.getTokenEndpoint());
List<NameValuePair> parameters = new LinkedList<>();
parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, PreAuthorizedCodeGrantTypeFactory.GRANT_TYPE));
parameters.add(new BasicNameValuePair(PreAuthorizedCodeGrantTypeFactory.CODE_REQUEST_PARAM, ctx.credentialsOffer.getGrants().getPreAuthorizedCode().getPreAuthorizedCode()));
parameters.add(new BasicNameValuePair("authorization_details", authDetailsJson));
UrlEncodedFormEntity formEntity = new UrlEncodedFormEntity(parameters, StandardCharsets.UTF_8);
postPreAuthorizedCode.setEntity(formEntity);
try (CloseableHttpResponse tokenResponse = httpClient.execute(postPreAuthorizedCode)) {
assertEquals(HttpStatus.SC_OK, tokenResponse.getStatusLine().getStatusCode());
String responseBody = IOUtils.toString(tokenResponse.getEntity().getContent(), StandardCharsets.UTF_8);
List<OID4VCAuthorizationDetailsResponse> authDetailsResponse = parseAuthorizationDetails(responseBody);
assertNotNull("authorization_details should be present in the response", authDetailsResponse);
assertEquals(1, authDetailsResponse.size());
OID4VCAuthorizationDetailsResponse authDetailResponse = authDetailsResponse.get(0);
assertEquals(OPENID_CREDENTIAL_TYPE, authDetailResponse.getType());
assertEquals(JWT_VC, authDetailResponse.getFormat());
assertNotNull(authDetailResponse.getCredentialIdentifiers());
assertEquals(1, authDetailResponse.getCredentialIdentifiers().size());
String firstIdentifier = authDetailResponse.getCredentialIdentifiers().get(0);
assertNotNull("Identifier should not be null", firstIdentifier);
assertFalse("Identifier should not be empty", firstIdentifier.isEmpty());
try {
UUID.fromString(firstIdentifier);
} catch (IllegalArgumentException e) {
fail("Identifier should be a valid UUID, but was: " + firstIdentifier);
}
}
@Override
protected String getExpectedClaimPath() {
return "given_name";
}
@Test
public void testPreAuthorizedCodeWithInvalidAuthorizationDetails() throws Exception {
String token = getBearerToken(oauth, client, jwtTypeCredentialClientScope.getName());
Oid4vcTestContext ctx = prepareOid4vcTestContext(token);
@Override
protected void verifyCredentialStructure(Object credentialObj) {
assertNotNull("Credential object should not be null", credentialObj);
AuthorizationDetail authDetail = new AuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL_TYPE);
authDetail.setCredentialConfigurationId(jwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID));
authDetail.setFormat(JWT_VC); // Invalid: format should not be combined with credential_configuration_id
authDetail.setLocations(Collections.singletonList(ctx.credentialIssuer.getCredentialIssuer()));
// For JWT VC, the credential should be a string
assertTrue("JWT credential should be a string", credentialObj instanceof String);
String jwtString = (String) credentialObj;
assertFalse("JWT credential should not be empty", jwtString.isEmpty());
List<AuthorizationDetail> authDetails = List.of(authDetail);
String authDetailsJson = JsonSerialization.writeValueAsString(authDetails);
HttpPost postPreAuthorizedCode = new HttpPost(ctx.openidConfig.getTokenEndpoint());
List<NameValuePair> parameters = new LinkedList<>();
parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, PreAuthorizedCodeGrantTypeFactory.GRANT_TYPE));
parameters.add(new BasicNameValuePair(PreAuthorizedCodeGrantTypeFactory.CODE_REQUEST_PARAM, ctx.credentialsOffer.getGrants().getPreAuthorizedCode().getPreAuthorizedCode()));
parameters.add(new BasicNameValuePair("authorization_details", authDetailsJson));
UrlEncodedFormEntity formEntity = new UrlEncodedFormEntity(parameters, StandardCharsets.UTF_8);
postPreAuthorizedCode.setEntity(formEntity);
try (CloseableHttpResponse tokenResponse = httpClient.execute(postPreAuthorizedCode)) {
assertEquals(HttpStatus.SC_BAD_REQUEST, tokenResponse.getStatusLine().getStatusCode());
String responseBody = IOUtils.toString(tokenResponse.getEntity().getContent(), StandardCharsets.UTF_8);
assertTrue("Error message should mention authorization_details processing error",
responseBody.contains("Error when processing authorization_details"));
}
}
@Test
public void testAuthorizationCodeWithAuthorizationDetailsFormat() throws Exception {
// Simulate the authorization code flow for JWT VC with valid authorization_details
String testClientId = client.getClientId();
String testScope = jwtTypeCredentialClientScope.getName();
oauth.clientId(testClientId)
.scope(testScope)
.openid(false);
// Get authorization code
org.keycloak.testsuite.util.oauth.AuthorizationEndpointResponse authResponse = oauth.doLogin("john", "password");
String authorizationCode = authResponse.getCode();
assertNotNull("Authorization code should be present", authorizationCode);
// Get token endpoint from .well-known
java.net.URI oid4vciDiscoveryUri = org.keycloak.services.resources.RealmsResource.wellKnownProviderUrl(
jakarta.ws.rs.core.UriBuilder.fromUri(org.keycloak.testsuite.util.oauth.OAuthClient.AUTH_SERVER_ROOT))
.build(TEST_REALM_NAME, org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerWellKnownProviderFactory.PROVIDER_ID);
HttpGet getIssuerMetadata = new HttpGet(oid4vciDiscoveryUri);
CredentialIssuer credentialIssuer;
try (CloseableHttpResponse response = httpClient.execute(getIssuerMetadata)) {
assertEquals(HttpStatus.SC_OK, response.getStatusLine().getStatusCode());
String s = IOUtils.toString(response.getEntity().getContent(), StandardCharsets.UTF_8);
credentialIssuer = JsonSerialization.readValue(s, CredentialIssuer.class);
}
HttpGet getOpenidConfiguration = new HttpGet(credentialIssuer.getAuthorizationServers().get(0) + "/.well-known/openid-configuration");
OIDCConfigurationRepresentation openidConfig;
try (CloseableHttpResponse response = httpClient.execute(getOpenidConfiguration)) {
assertEquals(HttpStatus.SC_OK, response.getStatusLine().getStatusCode());
String s = IOUtils.toString(response.getEntity().getContent(), StandardCharsets.UTF_8);
openidConfig = JsonSerialization.readValue(s, OIDCConfigurationRepresentation.class);
}
AuthorizationDetail authDetail = new AuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL_TYPE);
authDetail.setFormat(JWT_VC);
authDetail.setLocations(Collections.singletonList(credentialIssuer.getCredentialIssuer()));
List<AuthorizationDetail> authDetails = List.of(authDetail);
String authDetailsJson = JsonSerialization.writeValueAsString(authDetails);
HttpPost tokenRequest = new HttpPost(openidConfig.getTokenEndpoint());
List<NameValuePair> parameters = new LinkedList<>();
parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, OAuth2Constants.AUTHORIZATION_CODE));
parameters.add(new BasicNameValuePair(OAuth2Constants.CODE, authorizationCode));
parameters.add(new BasicNameValuePair(OAuth2Constants.CLIENT_ID, testClientId));
parameters.add(new BasicNameValuePair(OAuth2Constants.CLIENT_SECRET, "password"));
parameters.add(new BasicNameValuePair(OAuth2Constants.REDIRECT_URI, oauth.getRedirectUri()));
parameters.add(new BasicNameValuePair("authorization_details", authDetailsJson));
UrlEncodedFormEntity formEntity = new UrlEncodedFormEntity(parameters, StandardCharsets.UTF_8);
tokenRequest.setEntity(formEntity);
try (CloseableHttpResponse tokenResponse = httpClient.execute(tokenRequest)) {
String tokenResponseBody = IOUtils.toString(tokenResponse.getEntity().getContent(), StandardCharsets.UTF_8);
assertEquals(HttpStatus.SC_OK, tokenResponse.getStatusLine().getStatusCode());
List<OID4VCAuthorizationDetailsResponse> authDetailsResponse = parseAuthorizationDetails(tokenResponseBody);
assertEquals(1, authDetailsResponse.size());
OID4VCAuthorizationDetailsResponse authDetailResponse = authDetailsResponse.get(0);
assertEquals(OPENID_CREDENTIAL_TYPE, authDetailResponse.getType());
assertEquals(JWT_VC, authDetailResponse.getFormat());
assertNotNull(authDetailResponse.getCredentialIdentifiers());
assertEquals(1, authDetailResponse.getCredentialIdentifiers().size());
String formatIdentifier = authDetailResponse.getCredentialIdentifiers().get(0);
assertNotNull("Identifier should not be null", formatIdentifier);
assertFalse("Identifier should not be empty", formatIdentifier.isEmpty());
try {
UUID.fromString(formatIdentifier);
} catch (IllegalArgumentException e) {
fail("Identifier should be a valid UUID, but was: " + formatIdentifier);
}
}
}
@Test
public void testAuthorizationCodeWithInvalidAuthorizationDetails() throws Exception {
// Simulate the authorization code flow for JWT VC with invalid authorization_details
String testClientId = client.getClientId();
String testScope = jwtTypeCredentialClientScope.getName();
oauth.clientId(testClientId)
.scope(testScope)
.openid(false);
// Get authorization code
org.keycloak.testsuite.util.oauth.AuthorizationEndpointResponse authResponse = oauth.doLogin("john", "password");
String authorizationCode = authResponse.getCode();
assertNotNull("Authorization code should be present", authorizationCode);
// Get token endpoint from .well-known
java.net.URI oid4vciDiscoveryUri = org.keycloak.services.resources.RealmsResource.wellKnownProviderUrl(
jakarta.ws.rs.core.UriBuilder.fromUri(org.keycloak.testsuite.util.oauth.OAuthClient.AUTH_SERVER_ROOT))
.build(TEST_REALM_NAME, org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerWellKnownProviderFactory.PROVIDER_ID);
HttpGet getIssuerMetadata = new HttpGet(oid4vciDiscoveryUri);
CredentialIssuer credentialIssuer;
try (CloseableHttpResponse response = httpClient.execute(getIssuerMetadata)) {
assertEquals(HttpStatus.SC_OK, response.getStatusLine().getStatusCode());
String s = IOUtils.toString(response.getEntity().getContent(), StandardCharsets.UTF_8);
credentialIssuer = JsonSerialization.readValue(s, CredentialIssuer.class);
}
HttpGet getOpenidConfiguration = new HttpGet(credentialIssuer.getAuthorizationServers().get(0) + "/.well-known/openid-configuration");
OIDCConfigurationRepresentation openidConfig;
try (CloseableHttpResponse response = httpClient.execute(getOpenidConfiguration)) {
assertEquals(HttpStatus.SC_OK, response.getStatusLine().getStatusCode());
String s = IOUtils.toString(response.getEntity().getContent(), StandardCharsets.UTF_8);
openidConfig = JsonSerialization.readValue(s, OIDCConfigurationRepresentation.class);
}
AuthorizationDetail authDetail = new AuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL_TYPE);
authDetail.setCredentialConfigurationId(jwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID));
authDetail.setFormat(JWT_VC); // Invalid: format should not be combined with credential_configuration_id
authDetail.setLocations(Collections.singletonList(credentialIssuer.getCredentialIssuer()));
List<AuthorizationDetail> authDetails = List.of(authDetail);
String authDetailsJson = JsonSerialization.writeValueAsString(authDetails);
HttpPost tokenRequest = new HttpPost(openidConfig.getTokenEndpoint());
List<NameValuePair> parameters = new LinkedList<>();
parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, OAuth2Constants.AUTHORIZATION_CODE));
parameters.add(new BasicNameValuePair(OAuth2Constants.CODE, authorizationCode));
parameters.add(new BasicNameValuePair(OAuth2Constants.CLIENT_ID, testClientId));
parameters.add(new BasicNameValuePair(OAuth2Constants.CLIENT_SECRET, "password"));
parameters.add(new BasicNameValuePair(OAuth2Constants.REDIRECT_URI, oauth.getRedirectUri()));
parameters.add(new BasicNameValuePair("authorization_details", authDetailsJson));
UrlEncodedFormEntity formEntity = new UrlEncodedFormEntity(parameters, StandardCharsets.UTF_8);
tokenRequest.setEntity(formEntity);
try (CloseableHttpResponse tokenResponse = httpClient.execute(tokenRequest)) {
String tokenResponseBody = IOUtils.toString(tokenResponse.getEntity().getContent(), StandardCharsets.UTF_8);
assertEquals(HttpStatus.SC_BAD_REQUEST, tokenResponse.getStatusLine().getStatusCode());
assertTrue("Error message should mention authorization_details processing error",
tokenResponseBody.contains("Error when processing authorization_details"));
}
// Verify it looks like a JWT (contains dots)
assertTrue("JWT should contain dots", jwtString.contains("."));
}
}

View File

@@ -0,0 +1,60 @@
/*
* Copyright 2025 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.testsuite.oid4vc.issuance.signing;
import org.keycloak.representations.idm.ClientScopeRepresentation;
import static org.junit.Assert.*;
/**
* SD-JWT-specific authorization code flow tests with authorization details and claims validation.
* Extends the base class to inherit common test logic while providing SD-JWT-specific implementations.
*
* @author <a href="mailto:Forkim.Akwichek@adorsys.com">Forkim Akwichek</a>
*/
public class OID4VCSdJwtAuthorizationCodeFlowTest extends OID4VCAuthorizationCodeFlowTestBase {
@Override
protected String getCredentialFormat() {
return "sd_jwt_vc";
}
@Override
protected ClientScopeRepresentation getCredentialClientScope() {
return sdJwtTypeCredentialClientScope;
}
@Override
protected String getExpectedClaimPath() {
return "lastName";
}
@Override
protected void verifyCredentialStructure(Object credentialObj) {
assertNotNull("Credential object should not be null", credentialObj);
// For SD-JWT VC, the credential should be a string
assertTrue("SD-JWT credential should be a string", credentialObj instanceof String);
String sdJwtString = (String) credentialObj;
assertFalse("SD-JWT credential should not be empty", sdJwtString.isEmpty());
// Verify it looks like an SD-JWT (contains dots and ~)
assertTrue("SD-JWT should contain dots", sdJwtString.contains("."));
assertTrue("SD-JWT should contain tilde", sdJwtString.contains("~"));
}
}

View File

@@ -17,297 +17,44 @@
package org.keycloak.testsuite.oid4vc.issuance.signing;
import jakarta.ws.rs.core.HttpHeaders;
import org.apache.commons.io.IOUtils;
import org.apache.http.HttpStatus;
import org.apache.http.NameValuePair;
import org.apache.http.client.entity.UrlEncodedFormEntity;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.message.BasicNameValuePair;
import org.junit.Test;
import org.keycloak.OAuth2Constants;
import org.keycloak.protocol.oid4vc.model.AuthorizationDetail;
import org.keycloak.protocol.oid4vc.issuance.OID4VCAuthorizationDetailsResponse;
import org.keycloak.protocol.oid4vc.model.CredentialIssuer;
import org.keycloak.protocol.oid4vc.model.CredentialOfferURI;
import org.keycloak.protocol.oid4vc.model.CredentialsOffer;
import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantTypeFactory;
import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation;
import org.keycloak.util.JsonSerialization;
import org.keycloak.models.oid4vci.CredentialScopeModel;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
import java.util.UUID;
import org.keycloak.representations.idm.ClientScopeRepresentation;
import static org.junit.Assert.*;
import static org.keycloak.protocol.oid4vc.issuance.OID4VCAuthorizationDetailsProcessor.OPENID_CREDENTIAL_TYPE;
import static org.keycloak.protocol.oid4vc.model.Format.SD_JWT_VC;
public class OID4VCSdJwtAuthorizationDetailsFlowTest extends OID4VCSdJwtIssuingEndpointTest {
/**
* SD-JWT-specific authorization details flow tests.
* Extends the base class to inherit common test logic while providing SD-JWT-specific implementations.
*
* @author <a href="mailto:Forkim.Akwichek@adorsys.com">Forkim Akwichek</a>
*/
public class OID4VCSdJwtAuthorizationDetailsFlowTest extends OID4VCAuthorizationDetailsFlowTestBase {
private static class Oid4vcTestContext {
CredentialsOffer credentialsOffer;
CredentialIssuer credentialIssuer;
OIDCConfigurationRepresentation openidConfig;
@Override
protected String getCredentialFormat() {
return "sd_jwt_vc";
}
private Oid4vcTestContext prepareOid4vcTestContext(String token) throws Exception {
Oid4vcTestContext ctx = new Oid4vcTestContext();
String credentialConfigurationId = sdJwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
HttpGet getCredentialOfferURI = new HttpGet(getBasePath(TEST_REALM_NAME) + "credential-offer-uri?credential_configuration_id=" + credentialConfigurationId);
getCredentialOfferURI.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token);
CredentialOfferURI credentialOfferURI;
try (CloseableHttpResponse response = httpClient.execute(getCredentialOfferURI)) {
int status = response.getStatusLine().getStatusCode();
if (status != HttpStatus.SC_OK) {
String body = IOUtils.toString(response.getEntity().getContent(), StandardCharsets.UTF_8);
}
assertEquals(HttpStatus.SC_OK, status);
String s = IOUtils.toString(response.getEntity().getContent(), StandardCharsets.UTF_8);
credentialOfferURI = JsonSerialization.readValue(s, CredentialOfferURI.class);
}
HttpGet getCredentialOffer = new HttpGet(credentialOfferURI.getIssuer() + "/" + credentialOfferURI.getNonce());
try (CloseableHttpResponse response = httpClient.execute(getCredentialOffer)) {
assertEquals(HttpStatus.SC_OK, response.getStatusLine().getStatusCode());
String s = IOUtils.toString(response.getEntity().getContent(), StandardCharsets.UTF_8);
ctx.credentialsOffer = JsonSerialization.readValue(s, CredentialsOffer.class);
}
HttpGet getIssuerMetadata = new HttpGet(ctx.credentialsOffer.getCredentialIssuer() + "/.well-known/openid-credential-issuer");
try (CloseableHttpResponse response = httpClient.execute(getIssuerMetadata)) {
assertEquals(HttpStatus.SC_OK, response.getStatusLine().getStatusCode());
String s = IOUtils.toString(response.getEntity().getContent(), StandardCharsets.UTF_8);
ctx.credentialIssuer = JsonSerialization.readValue(s, CredentialIssuer.class);
}
HttpGet getOpenidConfiguration = new HttpGet(ctx.credentialIssuer.getAuthorizationServers().get(0) + "/.well-known/openid-configuration");
try (CloseableHttpResponse response = httpClient.execute(getOpenidConfiguration)) {
assertEquals(HttpStatus.SC_OK, response.getStatusLine().getStatusCode());
String s = IOUtils.toString(response.getEntity().getContent(), StandardCharsets.UTF_8);
ctx.openidConfig = JsonSerialization.readValue(s, OIDCConfigurationRepresentation.class);
}
return ctx;
@Override
protected ClientScopeRepresentation getCredentialClientScope() {
return sdJwtTypeCredentialClientScope;
}
@Test
public void testPreAuthorizedCodeWithAuthorizationDetailsFormat() throws Exception {
String token = getBearerToken(oauth, client, sdJwtTypeCredentialClientScope.getName());
Oid4vcTestContext ctx = prepareOid4vcTestContext(token);
AuthorizationDetail authDetail = new AuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL_TYPE);
authDetail.setFormat(SD_JWT_VC);
authDetail.setLocations(Collections.singletonList(ctx.credentialIssuer.getCredentialIssuer()));
String vct = sdJwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.VCT);
authDetail.getAdditionalFields().put("vct", vct);
List<AuthorizationDetail> authDetails = List.of(authDetail);
String authDetailsJson = JsonSerialization.writeValueAsString(authDetails);
HttpPost postPreAuthorizedCode = new HttpPost(ctx.openidConfig.getTokenEndpoint());
List<NameValuePair> parameters = new LinkedList<>();
parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, PreAuthorizedCodeGrantTypeFactory.GRANT_TYPE));
parameters.add(new BasicNameValuePair(PreAuthorizedCodeGrantTypeFactory.CODE_REQUEST_PARAM, ctx.credentialsOffer.getGrants().getPreAuthorizedCode().getPreAuthorizedCode()));
parameters.add(new BasicNameValuePair("authorization_details", authDetailsJson));
UrlEncodedFormEntity formEntity = new UrlEncodedFormEntity(parameters, StandardCharsets.UTF_8);
postPreAuthorizedCode.setEntity(formEntity);
try (CloseableHttpResponse tokenResponse = httpClient.execute(postPreAuthorizedCode)) {
assertEquals(HttpStatus.SC_OK, tokenResponse.getStatusLine().getStatusCode());
String responseBody = IOUtils.toString(tokenResponse.getEntity().getContent(), StandardCharsets.UTF_8);
List<OID4VCAuthorizationDetailsResponse> authDetailsResponse = parseAuthorizationDetails(responseBody);
assertNotNull("authorization_details should be present in the response", authDetailsResponse);
assertEquals(1, authDetailsResponse.size());
OID4VCAuthorizationDetailsResponse authDetailResponse = authDetailsResponse.get(0);
assertEquals(OPENID_CREDENTIAL_TYPE, authDetailResponse.getType());
assertEquals(SD_JWT_VC, authDetailResponse.getFormat());
assertNotNull(authDetailResponse.getCredentialIdentifiers());
assertEquals(1, authDetailResponse.getCredentialIdentifiers().size());
String firstIdentifier = authDetailResponse.getCredentialIdentifiers().get(0);
assertNotNull("Identifier should not be null", firstIdentifier);
assertFalse("Identifier should not be empty", firstIdentifier.isEmpty());
try {
UUID.fromString(firstIdentifier);
} catch (IllegalArgumentException e) {
fail("Identifier should be a valid UUID, but was: " + firstIdentifier);
}
}
@Override
protected String getExpectedClaimPath() {
return "lastName";
}
@Test
public void testPreAuthorizedCodeWithInvalidAuthorizationDetails() throws Exception {
String token = getBearerToken(oauth, client, sdJwtTypeCredentialClientScope.getName());
Oid4vcTestContext ctx = prepareOid4vcTestContext(token);
@Override
protected void verifyCredentialStructure(Object credentialObj) {
assertNotNull("Credential object should not be null", credentialObj);
AuthorizationDetail authDetail = new AuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL_TYPE);
authDetail.setCredentialConfigurationId(sdJwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID));
authDetail.setFormat(SD_JWT_VC); // Invalid: format should not be combined with credential_configuration_id
authDetail.setLocations(Collections.singletonList(ctx.credentialIssuer.getCredentialIssuer()));
String vct = sdJwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.VCT);
authDetail.getAdditionalFields().put("vct", vct);
// For SD-JWT VC, the credential should be a string
assertTrue("SD-JWT credential should be a string", credentialObj instanceof String);
String sdJwtString = (String) credentialObj;
assertFalse("SD-JWT credential should not be empty", sdJwtString.isEmpty());
List<AuthorizationDetail> authDetails = List.of(authDetail);
String authDetailsJson = JsonSerialization.writeValueAsString(authDetails);
HttpPost postPreAuthorizedCode = new HttpPost(ctx.openidConfig.getTokenEndpoint());
List<NameValuePair> parameters = new LinkedList<>();
parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, PreAuthorizedCodeGrantTypeFactory.GRANT_TYPE));
parameters.add(new BasicNameValuePair(PreAuthorizedCodeGrantTypeFactory.CODE_REQUEST_PARAM, ctx.credentialsOffer.getGrants().getPreAuthorizedCode().getPreAuthorizedCode()));
parameters.add(new BasicNameValuePair("authorization_details", authDetailsJson));
UrlEncodedFormEntity formEntity = new UrlEncodedFormEntity(parameters, StandardCharsets.UTF_8);
postPreAuthorizedCode.setEntity(formEntity);
try (CloseableHttpResponse tokenResponse = httpClient.execute(postPreAuthorizedCode)) {
assertEquals(HttpStatus.SC_BAD_REQUEST, tokenResponse.getStatusLine().getStatusCode());
String responseBody = IOUtils.toString(tokenResponse.getEntity().getContent(), StandardCharsets.UTF_8);
assertTrue("Error message should mention authorization_details processing error",
responseBody.contains("Error when processing authorization_details"));
}
// Verify it looks like an SD-JWT (contains dots and ~)
assertTrue("SD-JWT should contain dots", sdJwtString.contains("."));
assertTrue("SD-JWT should contain tilde", sdJwtString.contains("~"));
}
@Test
public void testAuthorizationCodeWithAuthorizationDetailsFormat() throws Exception {
String testClientId = client.getClientId();
String testScope = sdJwtTypeCredentialClientScope.getName();
oauth.clientId(testClientId)
.scope(testScope)
.openid(false);
// Get authorization code
org.keycloak.testsuite.util.oauth.AuthorizationEndpointResponse authResponse = oauth.doLogin("john", "password");
String authorizationCode = authResponse.getCode();
assertNotNull("Authorization code should be present", authorizationCode);
// Get token endpoint from .well-known
java.net.URI oid4vciDiscoveryUri = org.keycloak.services.resources.RealmsResource.wellKnownProviderUrl(
jakarta.ws.rs.core.UriBuilder.fromUri(org.keycloak.testsuite.util.oauth.OAuthClient.AUTH_SERVER_ROOT))
.build(TEST_REALM_NAME, org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerWellKnownProviderFactory.PROVIDER_ID);
HttpGet getIssuerMetadata = new HttpGet(oid4vciDiscoveryUri);
CredentialIssuer credentialIssuer;
try (CloseableHttpResponse response = httpClient.execute(getIssuerMetadata)) {
assertEquals(HttpStatus.SC_OK, response.getStatusLine().getStatusCode());
String s = IOUtils.toString(response.getEntity().getContent(), StandardCharsets.UTF_8);
credentialIssuer = JsonSerialization.readValue(s, CredentialIssuer.class);
}
HttpGet getOpenidConfiguration = new HttpGet(credentialIssuer.getAuthorizationServers().get(0) + "/.well-known/openid-configuration");
OIDCConfigurationRepresentation openidConfig;
try (CloseableHttpResponse response = httpClient.execute(getOpenidConfiguration)) {
assertEquals(HttpStatus.SC_OK, response.getStatusLine().getStatusCode());
String s = IOUtils.toString(response.getEntity().getContent(), StandardCharsets.UTF_8);
openidConfig = JsonSerialization.readValue(s, OIDCConfigurationRepresentation.class);
}
AuthorizationDetail authDetail = new AuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL_TYPE);
authDetail.setFormat(SD_JWT_VC);
authDetail.setLocations(Collections.singletonList(credentialIssuer.getCredentialIssuer()));
String vct = sdJwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.VCT);
authDetail.getAdditionalFields().put("vct", vct);
List<AuthorizationDetail> authDetails = List.of(authDetail);
String authDetailsJson = JsonSerialization.writeValueAsString(authDetails);
HttpPost tokenRequest = new HttpPost(openidConfig.getTokenEndpoint());
List<NameValuePair> parameters = new LinkedList<>();
parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, OAuth2Constants.AUTHORIZATION_CODE));
parameters.add(new BasicNameValuePair(OAuth2Constants.CODE, authorizationCode));
parameters.add(new BasicNameValuePair(OAuth2Constants.CLIENT_ID, testClientId));
parameters.add(new BasicNameValuePair(OAuth2Constants.CLIENT_SECRET, "password"));
parameters.add(new BasicNameValuePair(OAuth2Constants.REDIRECT_URI, oauth.getRedirectUri()));
parameters.add(new BasicNameValuePair("authorization_details", authDetailsJson));
UrlEncodedFormEntity formEntity = new UrlEncodedFormEntity(parameters, StandardCharsets.UTF_8);
tokenRequest.setEntity(formEntity);
try (CloseableHttpResponse tokenResponse = httpClient.execute(tokenRequest)) {
String tokenResponseBody = IOUtils.toString(tokenResponse.getEntity().getContent(), StandardCharsets.UTF_8);
assertEquals(HttpStatus.SC_OK, tokenResponse.getStatusLine().getStatusCode());
List<OID4VCAuthorizationDetailsResponse> authDetailsResponse = parseAuthorizationDetails(tokenResponseBody);
assertEquals(1, authDetailsResponse.size());
OID4VCAuthorizationDetailsResponse authDetailResponse = authDetailsResponse.get(0);
assertEquals(OPENID_CREDENTIAL_TYPE, authDetailResponse.getType());
assertEquals(SD_JWT_VC, authDetailResponse.getFormat());
assertNotNull(authDetailResponse.getCredentialIdentifiers());
assertEquals(1, authDetailResponse.getCredentialIdentifiers().size());
String formatIdentifier = authDetailResponse.getCredentialIdentifiers().get(0);
assertNotNull("Identifier should not be null", formatIdentifier);
assertFalse("Identifier should not be empty", formatIdentifier.isEmpty());
try {
UUID.fromString(formatIdentifier);
} catch (IllegalArgumentException e) {
fail("Identifier should be a valid UUID, but was: " + formatIdentifier);
}
}
}
@Test
public void testAuthorizationCodeWithInvalidAuthorizationDetails() throws Exception {
String testClientId = client.getClientId();
String testScope = sdJwtTypeCredentialClientScope.getName();
oauth.clientId(testClientId)
.scope(testScope)
.openid(false);
// Get authorization code
org.keycloak.testsuite.util.oauth.AuthorizationEndpointResponse authResponse = oauth.doLogin("john", "password");
String authorizationCode = authResponse.getCode();
assertNotNull("Authorization code should be present", authorizationCode);
// Get token endpoint from .well-known
java.net.URI oid4vciDiscoveryUri = org.keycloak.services.resources.RealmsResource.wellKnownProviderUrl(
jakarta.ws.rs.core.UriBuilder.fromUri(org.keycloak.testsuite.util.oauth.OAuthClient.AUTH_SERVER_ROOT))
.build(TEST_REALM_NAME, org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerWellKnownProviderFactory.PROVIDER_ID);
HttpGet getIssuerMetadata = new HttpGet(oid4vciDiscoveryUri);
CredentialIssuer credentialIssuer;
try (CloseableHttpResponse response = httpClient.execute(getIssuerMetadata)) {
assertEquals(HttpStatus.SC_OK, response.getStatusLine().getStatusCode());
String s = IOUtils.toString(response.getEntity().getContent(), StandardCharsets.UTF_8);
credentialIssuer = JsonSerialization.readValue(s, CredentialIssuer.class);
}
HttpGet getOpenidConfiguration = new HttpGet(credentialIssuer.getAuthorizationServers().get(0) + "/.well-known/openid-configuration");
OIDCConfigurationRepresentation openidConfig;
try (CloseableHttpResponse response = httpClient.execute(getOpenidConfiguration)) {
assertEquals(HttpStatus.SC_OK, response.getStatusLine().getStatusCode());
String s = IOUtils.toString(response.getEntity().getContent(), StandardCharsets.UTF_8);
openidConfig = JsonSerialization.readValue(s, OIDCConfigurationRepresentation.class);
}
AuthorizationDetail authDetail = new AuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL_TYPE);
authDetail.setCredentialConfigurationId(sdJwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID));
authDetail.setFormat(SD_JWT_VC); // Invalid: format should not be combined with credential_configuration_id
authDetail.setLocations(Collections.singletonList(credentialIssuer.getCredentialIssuer()));
String vct = sdJwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.VCT);
authDetail.getAdditionalFields().put("vct", vct);
List<AuthorizationDetail> authDetails = List.of(authDetail);
String authDetailsJson = JsonSerialization.writeValueAsString(authDetails);
HttpPost tokenRequest = new HttpPost(openidConfig.getTokenEndpoint());
List<NameValuePair> parameters = new LinkedList<>();
parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, OAuth2Constants.AUTHORIZATION_CODE));
parameters.add(new BasicNameValuePair(OAuth2Constants.CODE, authorizationCode));
parameters.add(new BasicNameValuePair(OAuth2Constants.CLIENT_ID, testClientId));
parameters.add(new BasicNameValuePair(OAuth2Constants.CLIENT_SECRET, "password"));
parameters.add(new BasicNameValuePair(OAuth2Constants.REDIRECT_URI, oauth.getRedirectUri()));
parameters.add(new BasicNameValuePair("authorization_details", authDetailsJson));
UrlEncodedFormEntity formEntity = new UrlEncodedFormEntity(parameters, StandardCharsets.UTF_8);
tokenRequest.setEntity(formEntity);
try (CloseableHttpResponse tokenResponse = httpClient.execute(tokenRequest)) {
String tokenResponseBody = IOUtils.toString(tokenResponse.getEntity().getContent(), StandardCharsets.UTF_8);
assertEquals(HttpStatus.SC_BAD_REQUEST, tokenResponse.getStatusLine().getStatusCode());
assertTrue("Error message should mention authorization_details processing error",
tokenResponseBody.contains("Error when processing authorization_details"));
}
}
}
}