mirror of
https://github.com/keycloak/keycloak.git
synced 2025-12-30 11:29:57 -06:00
[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:
committed by
GitHub
parent
ea06651da5
commit
f27982aeb7
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user