mirror of
https://github.com/keycloak/keycloak.git
synced 2025-12-21 06:20:05 -06:00
[OID4VC]: Update authorization_details for OID4VCI draft-16 compliance (#42622)
Closes #41586 Signed-off-by: forkimenjeckayang <forkimenjeckayang@gmail.com>
This commit is contained in:
committed by
GitHub
parent
f6627f99b2
commit
8ad6427123
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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("."));
|
||||
}
|
||||
}
|
||||
@@ -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("."));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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("~"));
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user