diff --git a/server-spi-private/src/main/java/org/keycloak/protocol/oidc/rar/AuthorizationDetailsProcessor.java b/server-spi-private/src/main/java/org/keycloak/protocol/oidc/rar/AuthorizationDetailsProcessor.java index ce1227cbdb6..4551dd1afa8 100644 --- a/server-spi-private/src/main/java/org/keycloak/protocol/oidc/rar/AuthorizationDetailsProcessor.java +++ b/server-spi-private/src/main/java/org/keycloak/protocol/oidc/rar/AuthorizationDetailsProcessor.java @@ -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 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 processStoredAuthorizationDetails(UserSessionModel userSession, + ClientSessionContext clientSessionCtx, + String storedAuthDetails) throws OAuthErrorException; } diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCAuthorizationDetailsProcessor.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCAuthorizationDetailsProcessor.java index cadd4dd29c0..dbcf10f5f21 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCAuthorizationDetailsProcessor.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCAuthorizationDetailsProcessor.java @@ -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 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 diff --git a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpoint.java b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpoint.java index 3ade0b90870..7dd4f8e004a 100755 --- a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpoint.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpoint.java @@ -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); + } } } diff --git a/services/src/main/java/org/keycloak/protocol/oidc/grants/AuthorizationCodeGrantType.java b/services/src/main/java/org/keycloak/protocol/oidc/grants/AuthorizationCodeGrantType.java index b17398200a8..dfa155ee7de 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/grants/AuthorizationCodeGrantType.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/grants/AuthorizationCodeGrantType.java @@ -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; } } diff --git a/services/src/main/java/org/keycloak/protocol/oidc/grants/OAuth2GrantTypeBase.java b/services/src/main/java/org/keycloak/protocol/oidc/grants/OAuth2GrantTypeBase.java index fea0273d055..90e8a580b42 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/grants/OAuth2GrantTypeBase.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/grants/OAuth2GrantTypeBase.java @@ -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 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() { } diff --git a/services/src/test/java/org/keycloak/protocol/oid4vc/issuance/OID4VCAuthorizationDetailsProcessorTest.java b/services/src/test/java/org/keycloak/protocol/oid4vc/issuance/OID4VCAuthorizationDetailsProcessorTest.java new file mode 100644 index 00000000000..ad4a921e61b --- /dev/null +++ b/services/src/test/java/org/keycloak/protocol/oid4vc/issuance/OID4VCAuthorizationDetailsProcessorTest.java @@ -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. + *

+ * 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 Forkim Akwichek + */ +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 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 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 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 expectedCredentialIdentifiers = List.of("test-identifier-123"); + List 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 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 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 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 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()); + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCAuthorizationCodeFlowWithPARTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCAuthorizationCodeFlowWithPARTest.java new file mode 100644 index 00000000000..fa51c0ca416 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCAuthorizationCodeFlowWithPARTest.java @@ -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 Forkim Akwichek + */ +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 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 authDetails = List.of(authDetail); + String authDetailsJson = JsonSerialization.writeValueAsString(authDetails); + + // Create PAR request + HttpPost parRequest = new HttpPost(ctx.openidConfig.getPushedAuthorizationRequestEndpoint()); + List 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 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 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 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 authDetails = List.of(authDetail); + String authDetailsJson = JsonSerialization.writeValueAsString(authDetails); + + // Create PAR request + HttpPost parRequest = new HttpPost(ctx.openidConfig.getPushedAuthorizationRequestEndpoint()); + List 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 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 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 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 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 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 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 parseAuthorizationDetails(String responseBody) { + try { + // Parse the JSON response to extract authorization_details + Map 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>() { + }); + } catch (Exception e) { + throw new RuntimeException("Failed to parse authorization_details from response", e); + } + } +}