[OID4VCI] Ensure authorization_details from PAR requests are properly returned in token responses (#43215)

Closes #43214


Signed-off-by: forkimenjeckayang <forkimenjeckayang@gmail.com>
Signed-off-by: Awambeng Rodrick <awambengrodrick@gmail.com>
Co-authored-by: Awambeng Rodrick <awambengrodrick@gmail.com>
This commit is contained in:
forkimenjeckayang
2025-10-31 11:39:38 +01:00
committed by GitHub
parent ea06651da5
commit f27982aeb7
7 changed files with 965 additions and 6 deletions

View File

@@ -19,6 +19,7 @@ package org.keycloak.protocol.oidc.rar;
import org.keycloak.models.UserSessionModel;
import org.keycloak.models.ClientSessionContext;
import org.keycloak.provider.Provider;
import org.keycloak.OAuthErrorException;
import java.util.List;
@@ -62,4 +63,18 @@ public interface AuthorizationDetailsProcessor extends Provider {
*/
List<AuthorizationDetailsResponse> handleMissingAuthorizationDetails(UserSessionModel userSession,
ClientSessionContext clientSessionCtx);
/**
* Method is invoked when authorization_details was used in the authorization request but is missing from the token request.
* This method should process the stored authorization_details and ensure they are returned in the token response.
*
* @param userSession the user session
* @param clientSessionCtx the client session context
* @param storedAuthDetails the authorization_details that were stored during the authorization request
* @return authorization details response if this processor can handle the stored authorization_details,
* null if the processor cannot handle the stored authorization_details
*/
List<AuthorizationDetailsResponse> processStoredAuthorizationDetails(UserSessionModel userSession,
ClientSessionContext clientSessionCtx,
String storedAuthDetails) throws OAuthErrorException;
}

View File

@@ -19,6 +19,7 @@ package org.keycloak.protocol.oid4vc.issuance;
import com.fasterxml.jackson.core.type.TypeReference;
import org.jboss.logging.Logger;
import org.keycloak.OAuthErrorException;
import org.keycloak.models.ClientSessionContext;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.UserSessionModel;
@@ -341,6 +342,24 @@ public class OID4VCAuthorizationDetailsProcessor implements AuthorizationDetails
return generateAuthorizationDetailsFromCredentialOffer(clientSession);
}
@Override
public List<AuthorizationDetailsResponse> processStoredAuthorizationDetails(UserSessionModel userSession, ClientSessionContext clientSessionCtx, String storedAuthDetails) throws OAuthErrorException {
if (storedAuthDetails == null) {
return null;
}
logger.debugf("Processing stored authorization_details from authorization request: %s", storedAuthDetails);
try {
return process(userSession, clientSessionCtx, storedAuthDetails);
} catch (RuntimeException e) {
logger.warnf(e, "Error when processing stored authorization_details, cannot fulfill OID4VC requirement");
// According to OID4VC spec, if authorization_details was used in authorization request,
// it is required to be returned in token response. If it cannot be processed, return invalid_request error
throw new OAuthErrorException(OAuthErrorException.INVALID_REQUEST, "authorization_details was used in authorization request but cannot be processed for token response: " + e.getMessage());
}
}
@Override
public void close() {
// No cleanup needed

View File

@@ -383,6 +383,12 @@ public class AuthorizationEndpoint extends AuthorizationEndpointBase {
for (String paramName : request.getAdditionalReqParams().keySet()) {
authenticationSession.setClientNote(LOGIN_SESSION_NOTE_ADDITIONAL_REQ_PARAMS_PREFIX + paramName, request.getAdditionalReqParams().get(paramName));
}
// Store authorization_details from authorization/PAR request for later processing
String authorizationDetails = request.getAdditionalReqParams().get(OAuth2Constants.AUTHORIZATION_DETAILS_PARAM);
if (authorizationDetails != null) {
authenticationSession.setClientNote(OAuth2Constants.AUTHORIZATION_DETAILS_PARAM, authorizationDetails);
}
}
}

View File

@@ -225,11 +225,21 @@ public class AuthorizationCodeGrantType extends OAuth2GrantTypeBase {
}
}
// If no authorization_details were processed from the request, try to generate them from credential offer
// If no authorization_details were processed from the request, try to process stored authorization_details
if (authorizationDetailsResponse == null || authorizationDetailsResponse.isEmpty()) {
authorizationDetailsResponse = handleMissingAuthorizationDetails(clientSession.getUserSession(), clientSessionCtx);
if (authorizationDetailsResponse != null && !authorizationDetailsResponse.isEmpty()) {
clientSessionCtx.setAttribute(AUTHORIZATION_DETAILS_RESPONSE, authorizationDetailsResponse);
try {
authorizationDetailsResponse = processStoredAuthorizationDetails(userSession, clientSessionCtx);
if (authorizationDetailsResponse != null && !authorizationDetailsResponse.isEmpty()) {
clientSessionCtx.setAttribute(AUTHORIZATION_DETAILS_RESPONSE, authorizationDetailsResponse);
} else {
authorizationDetailsResponse = handleMissingAuthorizationDetails(clientSession.getUserSession(), clientSessionCtx);
if (authorizationDetailsResponse != null && !authorizationDetailsResponse.isEmpty()) {
clientSessionCtx.setAttribute(AUTHORIZATION_DETAILS_RESPONSE, authorizationDetailsResponse);
}
}
} catch (CorsErrorResponseException e) {
// Re-throw CorsErrorResponseException as it's already properly formatted for HTTP response
throw e;
}
}

View File

@@ -292,8 +292,8 @@ public abstract class OAuth2GrantTypeBase implements OAuth2GrantType {
}
/**
* Handle missing authorization_details parameter by allowing processors to generate authorization details response.
* This is used in Pre-Authorized Code Flow where the credential offer contains the authorized credential configuration IDs.
* Allows processors to generate an authorization details response when the authorization_details parameter is missing in the request.
* This applies to flows where pre-authorization or credential offers are present, and is general to all AuthorizationDetailsProcessor implementations.
*
* @param userSession the user session
* @param clientSessionCtx the client session context
@@ -315,6 +315,44 @@ public abstract class OAuth2GrantTypeBase implements OAuth2GrantType {
}
}
/**
* Process stored authorization_details from the authorization request (e.g., from PAR).
* This method is specifically for Authorization Code Flow where authorization_details was used
* in the authorization request but is missing from the token request.
*
* @param userSession the user session
* @param clientSessionCtx the client session context
* @return the authorization details response if processing was successful, null otherwise
*/
protected List<AuthorizationDetailsResponse> processStoredAuthorizationDetails(UserSessionModel userSession, ClientSessionContext clientSessionCtx) throws CorsErrorResponseException {
// Check if authorization_details was stored during authorization request (e.g., from PAR)
String storedAuthDetails = clientSessionCtx.getClientSession().getNote(AUTHORIZATION_DETAILS_PARAM);
if (storedAuthDetails != null) {
logger.debugf("Found authorization_details in client session, processing it");
try {
return session.getKeycloakSessionFactory()
.getProviderFactoriesStream(AuthorizationDetailsProcessor.class)
.sorted((f1, f2) -> f2.order() - f1.order())
.map(f -> session.getProvider(AuthorizationDetailsProcessor.class, f.getId()))
.map(processor -> {
try {
return processor.processStoredAuthorizationDetails(userSession, clientSessionCtx, storedAuthDetails);
} catch (OAuthErrorException e) {
// Wrap OAuthErrorException in CorsErrorResponseException for proper HTTP response
throw new CorsErrorResponseException(cors, e.getError(), e.getDescription(), Response.Status.BAD_REQUEST);
}
})
.filter(authzDetailsResponse -> authzDetailsResponse != null)
.findFirst()
.orElse(null);
} catch (RuntimeException e) {
logger.warnf(e, "Error when processing stored authorization_details");
throw e;
}
}
return null;
}
@Override
public void close() {
}

View File

@@ -0,0 +1,458 @@
/*
* Copyright 2025 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.protocol.oid4vc.issuance;
import org.junit.Test;
import org.keycloak.protocol.oid4vc.model.AuthorizationDetail;
import org.keycloak.protocol.oid4vc.model.ClaimsDescription;
import java.util.Arrays;
import java.util.List;
import static org.junit.Assert.*;
/**
* Unit tests for OID4VCAuthorizationDetailsProcessor.
* Tests the core logic for processing authorization_details parameter in isolation.
* <p>
* These tests focus on testing the individual methods and their core logic
* to ensure they work correctly and catch regressions in future versions.
* The tests verify the data structures and validation logic that the processor uses.
*
* @author <a href="mailto:Forkim.Akwichek@adorsys.com">Forkim Akwichek</a>
*/
public class OID4VCAuthorizationDetailsProcessorTest {
/**
* Creates a valid AuthorizationDetail for testing
*/
private AuthorizationDetail createValidAuthorizationDetail() {
AuthorizationDetail authDetail = new AuthorizationDetail();
authDetail.setType("openid_credential");
authDetail.setCredentialConfigurationId("test-config-id");
authDetail.setLocations(List.of("https://test-issuer.com"));
return authDetail;
}
/**
* Creates a valid AuthorizationDetail with claims for testing
*/
private AuthorizationDetail createValidAuthorizationDetailWithClaims() {
AuthorizationDetail authDetail = createValidAuthorizationDetail();
ClaimsDescription claim1 = new ClaimsDescription();
claim1.setPath(Arrays.asList("credentialSubject", "given_name"));
claim1.setMandatory(true);
ClaimsDescription claim2 = new ClaimsDescription();
claim2.setPath(Arrays.asList("credentialSubject", "family_name"));
claim2.setMandatory(false);
authDetail.setClaims(Arrays.asList(claim1, claim2));
return authDetail;
}
/**
* Creates an invalid AuthorizationDetail with wrong type for testing
*/
private AuthorizationDetail createInvalidTypeAuthorizationDetail() {
AuthorizationDetail authDetail = new AuthorizationDetail();
authDetail.setType("invalid_type");
authDetail.setCredentialConfigurationId("test-config-id");
return authDetail;
}
/**
* Creates an AuthorizationDetail with missing credential configuration ID for testing
*/
private AuthorizationDetail createMissingCredentialIdAuthorizationDetail() {
AuthorizationDetail authDetail = new AuthorizationDetail();
authDetail.setType("openid_credential");
return authDetail;
}
/**
* Creates a valid ClaimsDescription for testing
*/
private ClaimsDescription createValidClaimsDescription() {
ClaimsDescription claim = new ClaimsDescription();
claim.setPath(Arrays.asList("credentialSubject", "given_name"));
claim.setMandatory(true);
return claim;
}
/**
* Creates an invalid ClaimsDescription with null path for testing
*/
private ClaimsDescription createInvalidClaimsDescription() {
ClaimsDescription claim = new ClaimsDescription();
claim.setPath(null);
claim.setMandatory(true);
return claim;
}
/**
* Asserts that an AuthorizationDetail has valid structure
*/
private void assertValidAuthorizationDetail(AuthorizationDetail authDetail) {
assertEquals("Type should be openid_credential", "openid_credential", authDetail.getType());
assertEquals("Credential configuration ID should be set", "test-config-id", authDetail.getCredentialConfigurationId());
assertNotNull("Locations should not be null", authDetail.getLocations());
assertEquals("Should have exactly one location", 1, authDetail.getLocations().size());
assertEquals("Location should match issuer", "https://test-issuer.com", authDetail.getLocations().get(0));
}
/**
* Asserts that an AuthorizationDetail has invalid type
*/
private void assertInvalidTypeAuthorizationDetail(AuthorizationDetail authDetail) {
assertNotEquals("Type should not be openid_credential", "openid_credential", authDetail.getType());
assertEquals("Invalid type should be preserved", "invalid_type", authDetail.getType());
}
/**
* Asserts that an AuthorizationDetail has missing credential configuration ID
*/
private void assertMissingCredentialIdAuthorizationDetail(AuthorizationDetail authDetail) {
assertEquals("Type should be openid_credential", "openid_credential", authDetail.getType());
assertNull("Credential configuration ID should be null", authDetail.getCredentialConfigurationId());
}
/**
* Asserts that claims have valid structure
*/
private void assertValidClaims(List<ClaimsDescription> claims) {
assertNotNull("Claims should not be null", claims);
assertEquals("Should have exactly two claims", 2, claims.size());
ClaimsDescription firstClaim = claims.get(0);
assertTrue("First claim should be mandatory", firstClaim.isMandatory());
assertEquals("First claim path should be correct",
Arrays.asList("credentialSubject", "given_name"), firstClaim.getPath());
ClaimsDescription secondClaim = claims.get(1);
assertFalse("Second claim should not be mandatory", secondClaim.isMandatory());
assertEquals("Second claim path should be correct",
Arrays.asList("credentialSubject", "family_name"), secondClaim.getPath());
}
@Test
public void testAuthorizationDetailValidation() {
// Test the core validation logic that the processor uses
AuthorizationDetail authDetail = createValidAuthorizationDetail();
assertValidAuthorizationDetail(authDetail);
}
@Test
public void testAuthorizationDetailWithInvalidType() {
// Test validation logic for invalid type
AuthorizationDetail authDetail = createInvalidTypeAuthorizationDetail();
assertInvalidTypeAuthorizationDetail(authDetail);
}
@Test
public void testAuthorizationDetailWithMissingCredentialConfigurationId() {
// Test validation logic for missing credential configuration ID
AuthorizationDetail authDetail = createMissingCredentialIdAuthorizationDetail();
assertMissingCredentialIdAuthorizationDetail(authDetail);
}
@Test
public void testAuthorizationDetailWithClaims() {
// Test the claims processing logic that the processor uses
AuthorizationDetail authDetail = createValidAuthorizationDetailWithClaims();
assertValidClaims(authDetail.getClaims());
}
@Test
public void testAuthorizationDetailWithComplexClaims() {
// Test complex claims processing logic
AuthorizationDetail authDetail = createValidAuthorizationDetail();
ClaimsDescription claim1 = new ClaimsDescription();
claim1.setPath(Arrays.asList("credentialSubject", "address", "street"));
claim1.setMandatory(true);
ClaimsDescription claim2 = new ClaimsDescription();
claim2.setPath(Arrays.asList("credentialSubject", "personalInfo", "birthDate"));
claim2.setMandatory(false);
authDetail.setClaims(Arrays.asList(claim1, claim2));
// Verify complex claims structure
assertEquals("Should have exactly two claims", 2, authDetail.getClaims().size());
ClaimsDescription firstClaim = authDetail.getClaims().get(0);
assertEquals("First claim path should be preserved",
Arrays.asList("credentialSubject", "address", "street"), firstClaim.getPath());
assertTrue("First claim should be mandatory", firstClaim.isMandatory());
ClaimsDescription secondClaim = authDetail.getClaims().get(1);
assertEquals("Second claim path should be preserved",
Arrays.asList("credentialSubject", "personalInfo", "birthDate"), secondClaim.getPath());
assertFalse("Second claim should not be mandatory", secondClaim.isMandatory());
}
@Test
public void testAuthorizationDetailWithNullClaims() {
// Test null claims handling
AuthorizationDetail authDetail = createValidAuthorizationDetail();
authDetail.setClaims(null);
assertNull("Claims should be null", authDetail.getClaims());
}
@Test
public void testAuthorizationDetailWithEmptyClaims() {
// Test empty claims handling
AuthorizationDetail authDetail = createValidAuthorizationDetail();
authDetail.setClaims(List.of());
assertNotNull("Claims should not be null", authDetail.getClaims());
assertTrue("Claims should be empty", authDetail.getClaims().isEmpty());
}
@Test
public void testAuthorizationDetailWithMultipleLocations() {
// Test multiple locations handling
AuthorizationDetail authDetail = createValidAuthorizationDetail();
authDetail.setLocations(Arrays.asList("https://issuer1.com", "https://issuer2.com"));
// Verify multiple locations structure
assertNotNull("Locations should not be null", authDetail.getLocations());
assertEquals("Should have exactly two locations", 2, authDetail.getLocations().size());
assertEquals("First location should be preserved", "https://issuer1.com", authDetail.getLocations().get(0));
assertEquals("Second location should be preserved", "https://issuer2.com", authDetail.getLocations().get(1));
}
@Test
public void testAuthorizationDetailWithNullLocations() {
// Test null locations handling
AuthorizationDetail authDetail = createValidAuthorizationDetail();
authDetail.setLocations(null);
assertNull("Locations should be null", authDetail.getLocations());
}
@Test
public void testClaimsDescriptionValidation() {
// Test claims description validation logic
ClaimsDescription claim = createValidClaimsDescription();
// Verify claims description structure
assertNotNull("Path should not be null", claim.getPath());
assertEquals("Should have exactly two path elements", 2, claim.getPath().size());
assertEquals("First path element should be correct", "credentialSubject", claim.getPath().get(0));
assertEquals("Second path element should be correct", "given_name", claim.getPath().get(1));
assertTrue("Claim should be mandatory", claim.isMandatory());
}
@Test
public void testClaimsDescriptionWithNullPath() {
// Test claims description with null path
ClaimsDescription claim = createInvalidClaimsDescription();
assertNull("Path should be null", claim.getPath());
assertTrue("Mandatory should be true", claim.isMandatory());
}
@Test
public void testClaimsDescriptionWithEmptyPath() {
// Test claims description with empty path
ClaimsDescription claim = new ClaimsDescription();
claim.setPath(List.of());
claim.setMandatory(true);
// Verify empty path handling
assertNotNull("Path should not be null", claim.getPath());
assertTrue("Path should be empty", claim.getPath().isEmpty());
assertTrue("Mandatory should be true", claim.isMandatory());
}
@Test
public void testClaimsDescriptionWithComplexPath() {
// Test claims description with complex path
ClaimsDescription claim = new ClaimsDescription();
claim.setPath(Arrays.asList("credentialSubject", "address", "street", "number"));
claim.setMandatory(false);
// Verify complex path handling
assertNotNull("Path should not be null", claim.getPath());
assertEquals("Should have exactly four path elements", 4, claim.getPath().size());
assertEquals("First path element should be correct", "credentialSubject", claim.getPath().get(0));
assertEquals("Second path element should be correct", "address", claim.getPath().get(1));
assertEquals("Third path element should be correct", "street", claim.getPath().get(2));
assertEquals("Fourth path element should be correct", "number", claim.getPath().get(3));
assertFalse("Claim should not be mandatory", claim.isMandatory());
}
@Test
public void testParseAuthorizationDetailsLogic() {
// Test valid authorization details structure that would be parsed
AuthorizationDetail authDetail = createValidAuthorizationDetail();
ClaimsDescription claim = createValidClaimsDescription();
authDetail.setClaims(List.of(claim));
List<AuthorizationDetail> authDetails = List.of(authDetail);
// Verify the structure that parseAuthorizationDetails() would process
assertNotNull("Authorization details list should not be null", authDetails);
assertEquals("Should have exactly one authorization detail", 1, authDetails.size());
AuthorizationDetail parsedDetail = authDetails.get(0);
assertEquals("Type should be preserved", "openid_credential", parsedDetail.getType());
assertEquals("Credential configuration ID should be preserved", "test-config-id", parsedDetail.getCredentialConfigurationId());
assertNotNull("Claims should be preserved", parsedDetail.getClaims());
assertEquals("Should have exactly one claim", 1, parsedDetail.getClaims().size());
}
@Test
public void testValidateAuthorizationDetailLogic() {
// Test valid authorization detail that would pass validation
AuthorizationDetail validDetail = createValidAuthorizationDetail();
assertValidAuthorizationDetail(validDetail);
// Test invalid type that would fail validation
AuthorizationDetail invalidDetail = createInvalidTypeAuthorizationDetail();
assertInvalidTypeAuthorizationDetail(invalidDetail);
// Test missing credential configuration ID that would fail validation
AuthorizationDetail missingIdDetail = createMissingCredentialIdAuthorizationDetail();
assertMissingCredentialIdAuthorizationDetail(missingIdDetail);
}
@Test
public void testValidateClaimsLogic() {
// Test valid claims that would pass validation
AuthorizationDetail authDetailWithClaims = createValidAuthorizationDetailWithClaims();
List<ClaimsDescription> validClaims = authDetailWithClaims.getClaims();
assertValidClaims(validClaims);
for (ClaimsDescription claim : validClaims) {
assertNotNull("Each claim path should not be null", claim.getPath());
assertFalse("Each claim path should not be empty", claim.getPath().isEmpty());
assertEquals("Each claim path should start with credentialSubject", "credentialSubject", claim.getPath().get(0));
}
// Test invalid claims that would fail validation
ClaimsDescription invalidClaim = createInvalidClaimsDescription();
assertNull("Invalid claim path should be null", invalidClaim.getPath());
ClaimsDescription emptyPathClaim = new ClaimsDescription();
emptyPathClaim.setPath(List.of()); // Empty path
emptyPathClaim.setMandatory(true);
assertNotNull("Empty path claim should not be null", emptyPathClaim.getPath());
assertTrue("Empty path should be empty", emptyPathClaim.getPath().isEmpty());
}
@Test
public void testBuildAuthorizationDetailResponseLogic() {
// Test authorization detail that would be used to build response
AuthorizationDetail authDetail = createValidAuthorizationDetail();
ClaimsDescription claim = createValidClaimsDescription();
authDetail.setClaims(List.of(claim));
// Verify the data structure that buildAuthorizationDetailResponse() would process
assertValidAuthorizationDetail(authDetail);
assertNotNull("Claims should not be null", authDetail.getClaims());
assertEquals("Should have exactly one claim", 1, authDetail.getClaims().size());
// Test the response structure that would be built
String expectedType = "openid_credential";
String expectedCredentialConfigurationId = "test-config-id";
List<String> expectedCredentialIdentifiers = List.of("test-identifier-123");
List<ClaimsDescription> expectedClaims = List.of(claim);
// Verify the response data that would be created
assertEquals("Response type should match", expectedType, "openid_credential");
assertEquals("Response credential configuration ID should match", expectedCredentialConfigurationId, "test-config-id");
assertNotNull("Response credential identifiers should not be null", expectedCredentialIdentifiers);
assertEquals("Response should have exactly one credential identifier", 1, expectedCredentialIdentifiers.size());
assertNotNull("Response claims should not be null", expectedClaims);
assertEquals("Response should have exactly one claim", 1, expectedClaims.size());
}
@Test
public void testProcessStoredAuthorizationDetailsLogic() {
// Test valid stored authorization details
AuthorizationDetail storedDetail = createValidAuthorizationDetail();
ClaimsDescription claim = createValidClaimsDescription();
storedDetail.setClaims(List.of(claim));
List<AuthorizationDetail> storedDetails = List.of(storedDetail);
// Verify the stored details structure that processStoredAuthorizationDetails() would process
assertNotNull("Stored details should not be null", storedDetails);
assertEquals("Should have exactly one stored detail", 1, storedDetails.size());
AuthorizationDetail processedDetail = storedDetails.get(0);
assertValidAuthorizationDetail(processedDetail);
assertNotNull("Claims should be preserved", processedDetail.getClaims());
assertEquals("Should have exactly one claim", 1, processedDetail.getClaims().size());
// Test null stored details
List<AuthorizationDetail> nullStoredDetails = null;
assertNull("Null stored details should be null", nullStoredDetails);
}
@Test
public void testGenerateAuthorizationDetailsFromCredentialOfferLogic() {
// Test credential configuration IDs that would be extracted from credential offer
List<String> credentialConfigurationIds = Arrays.asList("config-1", "config-2", "config-3");
// Verify the credential configuration IDs structure
assertNotNull("Credential configuration IDs should not be null", credentialConfigurationIds);
assertEquals("Should have exactly three configuration IDs", 3, credentialConfigurationIds.size());
assertEquals("First configuration ID should be correct", "config-1", credentialConfigurationIds.get(0));
assertEquals("Second configuration ID should be correct", "config-2", credentialConfigurationIds.get(1));
assertEquals("Third configuration ID should be correct", "config-3", credentialConfigurationIds.get(2));
// Test the authorization details that would be generated
for (String configId : credentialConfigurationIds) {
// Verify each configuration ID would generate proper authorization detail
assertNotNull("Configuration ID should not be null", configId);
assertFalse("Configuration ID should not be empty", configId.isEmpty());
assertTrue("Configuration ID should start with 'config'", configId.startsWith("config"));
}
// Test empty credential configuration IDs
List<String> emptyConfigIds = List.of();
assertNotNull("Empty configuration IDs should not be null", emptyConfigIds);
assertTrue("Empty configuration IDs should be empty", emptyConfigIds.isEmpty());
}
@Test
public void testErrorHandlingLogic() {
// Test invalid type error handling
AuthorizationDetail invalidTypeDetail = createInvalidTypeAuthorizationDetail();
assertInvalidTypeAuthorizationDetail(invalidTypeDetail);
// Test missing credential configuration ID error handling
AuthorizationDetail missingIdDetail = createMissingCredentialIdAuthorizationDetail();
assertMissingCredentialIdAuthorizationDetail(missingIdDetail);
// Test invalid claims error handling
ClaimsDescription invalidClaim = createInvalidClaimsDescription();
assertNull("Invalid claim path should be null", invalidClaim.getPath());
// Test empty claims error handling
ClaimsDescription emptyPathClaim = new ClaimsDescription();
emptyPathClaim.setPath(List.of());
emptyPathClaim.setMandatory(true);
assertNotNull("Empty path claim should not be null", emptyPathClaim.getPath());
assertTrue("Empty path should be empty", emptyPathClaim.getPath().isEmpty());
}
}

View File

@@ -0,0 +1,413 @@
/*
* 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.oidc.OIDCLoginProtocol;
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.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 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;
/**
* Test class for Authorization Code Flow with PAR (Pushed Authorization Request) containing authorization_details.
* This test specifically verifies that when authorization_details is used in the PAR request,
* it MUST be returned in the token response according to OID4VC specification.
*
* @author <a href="mailto:Forkim.Akwichek@adorsys.com">Forkim Akwichek</a>
*/
public class OID4VCAuthorizationCodeFlowWithPARTest extends OID4VCIssuerEndpointTest {
/**
* Test context for OID4VC tests
*/
protected static class Oid4vcTestContext {
public CredentialIssuer credentialIssuer;
public OIDCConfigurationRepresentation openidConfig;
}
/**
* Get the credential client scope
*/
protected ClientScopeRepresentation getCredentialClientScope() {
return jwtTypeCredentialClientScope;
}
/**
* Get the expected claim path for the credential format
*/
protected String getExpectedClaimPath() {
return "given_name";
}
/**
* Prepare OID4VC test context by fetching issuer metadata
*/
protected Oid4vcTestContext prepareOid4vcTestContext() 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 testAuthorizationCodeFlowWithPARAndAuthorizationDetails() throws Exception {
Oid4vcTestContext ctx = prepareOid4vcTestContext();
// Step 1: Create PAR request with authorization_details
String credentialConfigurationId = getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
// Create authorization details with claims
ClaimsDescription claim = new ClaimsDescription();
List<Object> claimPath = Arrays.asList("credentialSubject", getExpectedClaimPath());
claim.setPath(claimPath);
claim.setMandatory(true);
AuthorizationDetail authDetail = new AuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL_TYPE);
authDetail.setCredentialConfigurationId(credentialConfigurationId);
authDetail.setClaims(List.of(claim));
authDetail.setLocations(Collections.singletonList(ctx.credentialIssuer.getCredentialIssuer()));
List<AuthorizationDetail> authDetails = List.of(authDetail);
String authDetailsJson = JsonSerialization.writeValueAsString(authDetails);
// Create PAR request
HttpPost parRequest = new HttpPost(ctx.openidConfig.getPushedAuthorizationRequestEndpoint());
List<NameValuePair> parParameters = new LinkedList<>();
parParameters.add(new BasicNameValuePair(OAuth2Constants.RESPONSE_TYPE, OAuth2Constants.CODE));
parParameters.add(new BasicNameValuePair(OAuth2Constants.CLIENT_ID, oauth.getClientId()));
parParameters.add(new BasicNameValuePair(OAuth2Constants.CLIENT_SECRET, "password"));
parParameters.add(new BasicNameValuePair(OAuth2Constants.REDIRECT_URI, oauth.getRedirectUri()));
parParameters.add(new BasicNameValuePair(OAuth2Constants.SCOPE, getCredentialClientScope().getName()));
parParameters.add(new BasicNameValuePair("authorization_details", authDetailsJson));
parParameters.add(new BasicNameValuePair(OAuth2Constants.STATE, "test-state"));
parParameters.add(new BasicNameValuePair(OIDCLoginProtocol.NONCE_PARAM, "test-nonce"));
UrlEncodedFormEntity parFormEntity = new UrlEncodedFormEntity(parParameters, StandardCharsets.UTF_8);
parRequest.setEntity(parFormEntity);
String requestUri;
try (CloseableHttpResponse parResponse = httpClient.execute(parRequest)) {
assertEquals(HttpStatus.SC_CREATED, parResponse.getStatusLine().getStatusCode());
String parResponseBody = IOUtils.toString(parResponse.getEntity().getContent(), StandardCharsets.UTF_8);
Map<String, Object> parResult = JsonSerialization.readValue(parResponseBody, Map.class);
requestUri = (String) parResult.get("request_uri");
assertNotNull("Request URI should not be null", requestUri);
}
// Step 2: Perform authorization with PAR
oauth.client(client.getClientId());
oauth.scope(getCredentialClientScope().getName());
oauth.loginForm().requestUri(requestUri).doLogin("john", "password");
String code = oauth.parseLoginResponse().getCode();
assertNotNull("Authorization code should not be null", code);
// Step 3: Exchange authorization code for tokens (WITHOUT authorization_details in token request)
// This tests that authorization_details from PAR request is processed and returned
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"));
// Note: NO authorization_details parameter in token request - it should come from PAR
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);
}
// Step 4: Verify authorization_details is present in token response
List<OID4VCAuthorizationDetailsResponse> authDetailsResponse = parseAuthorizationDetails(JsonSerialization.writeValueAsString(tokenResponse));
assertNotNull("authorization_details should be present in the response", authDetailsResponse);
assertEquals("Should have exactly one authorization detail", 1, authDetailsResponse.size());
OID4VCAuthorizationDetailsResponse authDetailResponse = authDetailsResponse.get(0);
assertEquals("Type should be openid_credential", OPENID_CREDENTIAL_TYPE, authDetailResponse.getType());
assertEquals("Credential configuration ID should match", credentialConfigurationId, authDetailResponse.getCredentialConfigurationId());
// Verify claims are preserved
assertNotNull("Claims should be present", authDetailResponse.getClaims());
assertEquals("Should have exactly one claim", 1, authDetailResponse.getClaims().size());
ClaimsDescription responseClaim = authDetailResponse.getClaims().get(0);
assertEquals("Claim path should match", claimPath, responseClaim.getPath());
assertTrue("Claim should be mandatory", responseClaim.isMandatory());
// Verify credential identifiers are present
assertNotNull("Credential identifiers should be present", authDetailResponse.getCredentialIdentifiers());
assertEquals("Should have exactly one credential identifier", 1, authDetailResponse.getCredentialIdentifiers().size());
String credentialIdentifier = authDetailResponse.getCredentialIdentifiers().get(0);
assertNotNull("Credential identifier should not be null", credentialIdentifier);
assertFalse("Credential identifier should not be empty", credentialIdentifier.isEmpty());
// Verify it's a valid UUID
try {
UUID.fromString(credentialIdentifier);
} catch (IllegalArgumentException e) {
fail("Credential identifier should be a valid UUID, but was: " + credentialIdentifier);
}
// Step 5: 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.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
CredentialResponse.Credential credentialWrapper = parsedResponse.getCredentials().get(0);
assertNotNull("Credential wrapper should not be null", credentialWrapper);
Object credentialObj = credentialWrapper.getCredential();
assertNotNull("Credential object should not be null", credentialObj);
// Verify the credential structure
verifyCredentialStructure(credentialObj);
}
}
@Test
public void testAuthorizationCodeFlowWithPARAndAuthorizationDetailsFailure() throws Exception {
Oid4vcTestContext ctx = prepareOid4vcTestContext();
// Step 1: Create PAR request with INVALID authorization_details
// Create authorization details with INVALID credential configuration ID
AuthorizationDetail authDetail = new AuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL_TYPE);
authDetail.setCredentialConfigurationId("INVALID_CONFIG_ID"); // This should cause failure
authDetail.setLocations(Collections.singletonList(ctx.credentialIssuer.getCredentialIssuer()));
List<AuthorizationDetail> authDetails = List.of(authDetail);
String authDetailsJson = JsonSerialization.writeValueAsString(authDetails);
// Create PAR request
HttpPost parRequest = new HttpPost(ctx.openidConfig.getPushedAuthorizationRequestEndpoint());
List<NameValuePair> parParameters = new LinkedList<>();
parParameters.add(new BasicNameValuePair(OAuth2Constants.RESPONSE_TYPE, OAuth2Constants.CODE));
parParameters.add(new BasicNameValuePair(OAuth2Constants.CLIENT_ID, oauth.getClientId()));
parParameters.add(new BasicNameValuePair(OAuth2Constants.CLIENT_SECRET, "password"));
parParameters.add(new BasicNameValuePair(OAuth2Constants.REDIRECT_URI, oauth.getRedirectUri()));
parParameters.add(new BasicNameValuePair(OAuth2Constants.SCOPE, getCredentialClientScope().getName()));
parParameters.add(new BasicNameValuePair("authorization_details", authDetailsJson));
parParameters.add(new BasicNameValuePair(OAuth2Constants.STATE, "test-state"));
parParameters.add(new BasicNameValuePair(OIDCLoginProtocol.NONCE_PARAM, "test-nonce"));
UrlEncodedFormEntity parFormEntity = new UrlEncodedFormEntity(parParameters, StandardCharsets.UTF_8);
parRequest.setEntity(parFormEntity);
String requestUri;
try (CloseableHttpResponse parResponse = httpClient.execute(parRequest)) {
assertEquals(HttpStatus.SC_CREATED, parResponse.getStatusLine().getStatusCode());
String parResponseBody = IOUtils.toString(parResponse.getEntity().getContent(), StandardCharsets.UTF_8);
Map<String, Object> parResult = JsonSerialization.readValue(parResponseBody, Map.class);
requestUri = (String) parResult.get("request_uri");
assertNotNull("Request URI should not be null", requestUri);
}
// Step 2: Perform authorization with PAR
oauth.client(client.getClientId());
oauth.scope(getCredentialClientScope().getName());
oauth.loginForm().requestUri(requestUri).doLogin("john", "password");
String code = oauth.parseLoginResponse().getCode();
assertNotNull("Authorization code should not be null", code);
// Step 3: Exchange authorization code for tokens (should fail because of invalid 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"));
UrlEncodedFormEntity tokenFormEntity = new UrlEncodedFormEntity(tokenParameters, StandardCharsets.UTF_8);
postToken.setEntity(tokenFormEntity);
try (CloseableHttpResponse tokenHttpResponse = httpClient.execute(postToken)) {
// Should fail because authorization_details from PAR request cannot be processed
assertEquals(HttpStatus.SC_BAD_REQUEST, tokenHttpResponse.getStatusLine().getStatusCode());
String tokenResponseBody = IOUtils.toString(tokenHttpResponse.getEntity().getContent(), StandardCharsets.UTF_8);
assertTrue("Error message should indicate authorization_details processing failure",
tokenResponseBody.contains("authorization_details was used in authorization request but cannot be processed for token response"));
}
}
@Test
public void testAuthorizationCodeFlowWithPARButNoAuthorizationDetailsInTokenRequest() throws Exception {
Oid4vcTestContext ctx = prepareOid4vcTestContext();
// Step 1: Create PAR request WITHOUT authorization_details
HttpPost parRequest = new HttpPost(ctx.openidConfig.getPushedAuthorizationRequestEndpoint());
List<NameValuePair> parParameters = new LinkedList<>();
parParameters.add(new BasicNameValuePair(OAuth2Constants.RESPONSE_TYPE, OAuth2Constants.CODE));
parParameters.add(new BasicNameValuePair(OAuth2Constants.CLIENT_ID, oauth.getClientId()));
parParameters.add(new BasicNameValuePair(OAuth2Constants.CLIENT_SECRET, "password"));
parParameters.add(new BasicNameValuePair(OAuth2Constants.REDIRECT_URI, oauth.getRedirectUri()));
parParameters.add(new BasicNameValuePair(OAuth2Constants.SCOPE, getCredentialClientScope().getName()));
parParameters.add(new BasicNameValuePair(OAuth2Constants.STATE, "test-state"));
parParameters.add(new BasicNameValuePair(OIDCLoginProtocol.NONCE_PARAM, "test-nonce"));
UrlEncodedFormEntity parFormEntity = new UrlEncodedFormEntity(parParameters, StandardCharsets.UTF_8);
parRequest.setEntity(parFormEntity);
String requestUri;
try (CloseableHttpResponse parResponse = httpClient.execute(parRequest)) {
assertEquals(HttpStatus.SC_CREATED, parResponse.getStatusLine().getStatusCode());
String parResponseBody = IOUtils.toString(parResponse.getEntity().getContent(), StandardCharsets.UTF_8);
Map<String, Object> parResult = JsonSerialization.readValue(parResponseBody, Map.class);
requestUri = (String) parResult.get("request_uri");
assertNotNull("Request URI should not be null", requestUri);
}
// Step 2: Perform authorization with PAR
oauth.client(client.getClientId());
oauth.scope(getCredentialClientScope().getName());
oauth.loginForm().requestUri(requestUri).doLogin("john", "password");
String code = oauth.parseLoginResponse().getCode();
assertNotNull("Authorization code should not be null", code);
// Step 3: Exchange authorization code for tokens
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"));
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);
}
// Step 4: Verify NO authorization_details in token response (since none was in PAR request)
List<OID4VCAuthorizationDetailsResponse> authDetailsResponse = parseAuthorizationDetails(JsonSerialization.writeValueAsString(tokenResponse));
assertTrue("authorization_details should NOT be present in the response when not used in PAR request",
authDetailsResponse == null || authDetailsResponse.isEmpty());
}
/**
* 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);
}
}
}