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 228a342468f..3a63b4e7921 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 @@ -314,7 +314,7 @@ public abstract class OAuth2GrantTypeBase implements OAuth2GrantType { logger.warnf(e, "Error when processing authorization_details"); event.detail(Details.REASON, e.getMessage()); event.error(Errors.INVALID_AUTHORIZATION_DETAILS); - throw new CorsErrorResponseException(cors, Errors.INVALID_AUTHORIZATION_DETAILS, e.getMessage(), Response.Status.BAD_REQUEST); + throw new CorsErrorResponseException(cors, Errors.INVALID_AUTHORIZATION_DETAILS, "Error when processing authorization_details: " + e.getMessage(), Response.Status.BAD_REQUEST); } } return null; diff --git a/services/src/main/java/org/keycloak/protocol/oidc/rar/AuthorizationDetailsProcessorManager.java b/services/src/main/java/org/keycloak/protocol/oidc/rar/AuthorizationDetailsProcessorManager.java index 097b9008d84..b2d65988bf1 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/rar/AuthorizationDetailsProcessorManager.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/rar/AuthorizationDetailsProcessorManager.java @@ -61,6 +61,10 @@ public class AuthorizationDetailsProcessorManager { List authzDetails = parseAuthorizationDetails(authorizationDetailsParam); + if (authzDetails.isEmpty()) { + throw new InvalidAuthorizationDetailsException("Authorization_Details parameter cannot be empty"); + } + Map> processors = getProcessors(session); for (AuthorizationDetailsJSONRepresentation authzDetail : authzDetails) { diff --git a/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/AbstractHttpGetRequest.java b/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/AbstractHttpGetRequest.java index 6c1a4fd1c8e..01c16d8bf5b 100644 --- a/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/AbstractHttpGetRequest.java +++ b/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/AbstractHttpGetRequest.java @@ -1,30 +1,69 @@ package org.keycloak.testsuite.util.oauth; import java.io.IOException; +import java.util.HashMap; +import java.util.Map; import org.keycloak.utils.MediaType; import org.apache.http.client.methods.CloseableHttpResponse; import org.apache.http.client.methods.HttpGet; -public abstract class AbstractHttpGetRequest { +public abstract class AbstractHttpGetRequest { protected final AbstractOAuthClient client; private HttpGet get; + protected String endpointOverride; + protected String bearerToken; + protected Map headers = new HashMap<>(); public AbstractHttpGetRequest(AbstractOAuthClient client) { this.client = client; + this.headers.put("Accept", MediaType.APPLICATION_JSON); } protected abstract String getEndpoint(); protected abstract void initRequest(); + /** + * Override the endpoint URL for this request. + * When specified, this takes precedence over {@link #getEndpoint()}. + * + * @param endpoint the endpoint URL to use + * @return this request instance for method chaining + */ + public T endpoint(String endpoint) { + this.endpointOverride = endpoint; + return request(); + } + + public T bearerToken(String bearerToken) { + this.bearerToken = bearerToken; + return request(); + } + + public T header(String name, String value) { + if (value != null) { + this.headers.put(name, value); + } + return request(); + } + public R send() { - get = new HttpGet(getEndpoint()); - get.addHeader("Accept", MediaType.APPLICATION_JSON); + get = new HttpGet(endpointOverride != null ? endpointOverride : getEndpoint()); + initRequest(); + + for (Map.Entry entry : headers.entrySet()) { + get.setHeader(entry.getKey(), entry.getValue()); + } + + if (bearerToken != null) { + get.addHeader("Authorization", "Bearer " + bearerToken); + } + try { return toResponse(client.httpClient().get().execute(get)); } catch (IOException e) { @@ -32,12 +71,11 @@ public abstract class AbstractHttpGetRequest { } } - protected void header(String name, String value) { - if (value != null) { - get.addHeader(name, value); - } - } - protected abstract R toResponse(CloseableHttpResponse response) throws IOException; + @SuppressWarnings("unchecked") + private T request() { + return (T) this; + } + } diff --git a/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/AbstractHttpPostRequest.java b/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/AbstractHttpPostRequest.java index 62d39ca2eb6..6ab6c63d8a9 100644 --- a/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/AbstractHttpPostRequest.java +++ b/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/AbstractHttpPostRequest.java @@ -34,16 +34,30 @@ public abstract class AbstractHttpPostRequest { protected Map headers = new HashMap<>(); protected List parameters = new LinkedList<>(); + protected String endpoint; + public AbstractHttpPostRequest(AbstractOAuthClient client) { this.client = client; } protected abstract String getEndpoint(); + /** + * Override the endpoint URL for this request. + * When specified, this takes precedence over {@link #getEndpoint()}. + * + * @param endpoint the endpoint URL to use + * @return this request instance for method chaining + */ + public T endpoint(String endpoint) { + this.endpoint = endpoint; + return request(); + } + protected abstract void initRequest(); public R send() { - post = new HttpPost(getEndpoint()); + post = new HttpPost(endpoint != null ? endpoint : getEndpoint()); post.addHeader("Accept", getAccept()); post.addHeader("Origin", client.config().getOrigin()); diff --git a/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/AbstractOAuthClient.java b/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/AbstractOAuthClient.java index 057343e9a60..fa01db6ce93 100644 --- a/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/AbstractOAuthClient.java +++ b/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/AbstractOAuthClient.java @@ -9,6 +9,7 @@ import org.keycloak.representations.JsonWebToken; import org.keycloak.representations.RefreshToken; import org.keycloak.testsuite.util.oauth.ciba.CibaClient; import org.keycloak.testsuite.util.oauth.device.DeviceClient; +import org.keycloak.testsuite.util.oauth.oid4vc.OID4VCClient; import org.apache.http.impl.client.CloseableHttpClient; import org.openqa.selenium.WebDriver; @@ -234,6 +235,10 @@ public abstract class AbstractOAuthClient { return new DeviceClient(this); } + public OID4VCClient oid4vc() { + return new OID4VCClient(this); + } + public ParRequest pushedAuthorizationRequest() { return new ParRequest(this); } diff --git a/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/AccessTokenResponse.java b/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/AccessTokenResponse.java index d527b1ba8c1..4191422bc1a 100644 --- a/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/AccessTokenResponse.java +++ b/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/AccessTokenResponse.java @@ -2,14 +2,17 @@ package org.keycloak.testsuite.util.oauth; import java.io.IOException; import java.util.Arrays; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import org.keycloak.OAuth2Constants; +import org.keycloak.protocol.oid4vc.issuance.OID4VCAuthorizationDetailResponse; import org.keycloak.representations.AuthorizationDetailsJSONRepresentation; import org.keycloak.util.JsonSerialization; +import com.fasterxml.jackson.core.type.TypeReference; import org.apache.http.client.methods.CloseableHttpResponse; public class AccessTokenResponse extends AbstractHttpResponse { @@ -24,6 +27,7 @@ public class AccessTokenResponse extends AbstractHttpResponse { private String scope; private String sessionState; private List authorizationDetails; + private Map responseJson; private Map otherClaims; @@ -32,8 +36,8 @@ public class AccessTokenResponse extends AbstractHttpResponse { } protected void parseContent() throws IOException { - @SuppressWarnings("unchecked") Map responseJson = asJson(Map.class); + this.responseJson = responseJson; otherClaims = new HashMap<>(); @@ -123,12 +127,37 @@ public class AccessTokenResponse extends AbstractHttpResponse { } public List getAuthorizationDetails(Class clazz) { - if (getAuthorizationDetails() == null) { + if (authorizationDetails == null) { return null; - } else { - return getAuthorizationDetails().stream() - .map(authzResponse -> authzResponse.asSubtype(clazz)) - .toList(); + } + return authorizationDetails.stream() + .map(authzResponse -> authzResponse.asSubtype(clazz)) + .toList(); + } + + /** + * Get authorization details as OID4VC-specific response objects. + * This is useful when you need to access OID4VC-specific fields like credential_identifiers. + * + * @return a list of authorization details, or an empty list if none are present. + * @throws RuntimeException if there's an error parsing the JSON response + */ + public List getOid4vcAuthorizationDetails() { + if (responseJson == null) { + return Collections.emptyList(); + } + Object authDetailsObj = responseJson.get(OAuth2Constants.AUTHORIZATION_DETAILS); + if (authDetailsObj == null) { + return Collections.emptyList(); + } + try { + return JsonSerialization.readValue( + JsonSerialization.writeValueAsString(authDetailsObj), + new TypeReference>() { + } + ); + } catch (IOException e) { + throw new RuntimeException("Failed to parse authorization_details from token response", e); } } } diff --git a/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/Endpoints.java b/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/Endpoints.java index 6c206265456..38ec5df2a38 100644 --- a/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/Endpoints.java +++ b/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/Endpoints.java @@ -78,6 +78,31 @@ public class Endpoints { return asString(CibaGrantType.authenticationUrl(getBase())); } + public String getOid4vcIssuerMetadata() { + return asString(getBase().path(RealmsResource.class).path("{realm}/.well-known/openid-credential-issuer")); + } + + public String getOid4vcCredential() { + return asString(getBase().path(RealmsResource.class).path("{realm}/protocol/oid4vc/credential")); + } + + public String getOid4vcNonce() { + return asString(getBase().path(RealmsResource.class).path("{realm}/protocol/oid4vc/nonce")); + } + + public String getOid4vcCredentialOffer(String nonce) { + return asString(getBase().path(RealmsResource.class).path("{realm}/protocol/oid4vc/credential-offer/").path(nonce)); + } + + public String getOid4vcCredentialOfferUri(String configId, Boolean preAuthorized, String username, String appClientId) { + UriBuilder builder = getBase().path(RealmsResource.class).path("{realm}/protocol/oid4vc/credential-offer-uri"); + if (configId != null && !configId.isBlank()) builder.queryParam("credential_configuration_id", configId); + if (preAuthorized != null) builder.queryParam("pre_authorized", preAuthorized); + if (username != null && !username.isBlank()) builder.queryParam("username", username); + if (appClientId != null && !appClientId.isBlank()) builder.queryParam("client_id", appClientId); + return asString(builder); + } + UriBuilder getBase() { return UriBuilder.fromUri(baseUrl); } diff --git a/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/FetchExternalIdpTokenRequest.java b/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/FetchExternalIdpTokenRequest.java index e45e894534d..2ec231dd849 100644 --- a/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/FetchExternalIdpTokenRequest.java +++ b/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/FetchExternalIdpTokenRequest.java @@ -25,7 +25,7 @@ import jakarta.ws.rs.core.UriBuilder; import com.fasterxml.jackson.databind.node.ObjectNode; import org.apache.http.client.methods.CloseableHttpResponse; -public class FetchExternalIdpTokenRequest extends AbstractHttpGetRequest { +public class FetchExternalIdpTokenRequest extends AbstractHttpGetRequest { private final String providerAlias; private final String accessToken; diff --git a/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/OpenIDProviderConfigurationRequest.java b/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/OpenIDProviderConfigurationRequest.java index 4e4a20f7884..81face641db 100644 --- a/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/OpenIDProviderConfigurationRequest.java +++ b/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/OpenIDProviderConfigurationRequest.java @@ -4,12 +4,16 @@ import java.io.IOException; import org.apache.http.client.methods.CloseableHttpResponse; -public class OpenIDProviderConfigurationRequest extends AbstractHttpGetRequest { +public class OpenIDProviderConfigurationRequest extends AbstractHttpGetRequest { public OpenIDProviderConfigurationRequest(AbstractOAuthClient client) { super(client); } + public OpenIDProviderConfigurationRequest url(String url) { + return endpoint(url + (url.endsWith("/") ? "" : "/") + ".well-known/openid-configuration"); + } + @Override protected String getEndpoint() { return client.getEndpoints().getOpenIDConfiguration(); diff --git a/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/ParRequest.java b/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/ParRequest.java index 8d861a1c56d..1b8cc6bf79d 100644 --- a/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/ParRequest.java +++ b/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/ParRequest.java @@ -1,15 +1,19 @@ package org.keycloak.testsuite.util.oauth; import java.io.IOException; +import java.util.List; import org.keycloak.OAuth2Constants; import org.keycloak.protocol.oidc.OIDCLoginProtocol; +import org.keycloak.util.JsonSerialization; import org.keycloak.util.TokenUtil; import org.apache.http.client.methods.CloseableHttpResponse; public class ParRequest extends AbstractHttpPostRequest { + private boolean scopeExplicitlySet = false; + public ParRequest(AbstractOAuthClient client) { super(client); } @@ -58,6 +62,11 @@ public class ParRequest extends AbstractHttpPostRequest return this; } + public ParRequest authorizationDetails(List authDetails) { + parameter(OAuth2Constants.AUTHORIZATION_DETAILS, JsonSerialization.valueAsString(authDetails)); + return this; + } + public ParRequest request(String request) { parameter(OIDCLoginProtocol.REQUEST_PARAM, request); return this; @@ -68,12 +77,20 @@ public class ParRequest extends AbstractHttpPostRequest return this; } + public ParRequest scopeParam(String scope) { + scopeExplicitlySet = true; + parameter(OAuth2Constants.SCOPE, scope); + return this; + } + @Override protected void initRequest() { parameter(OAuth2Constants.RESPONSE_TYPE, client.config().getResponseType()); parameter(OIDCLoginProtocol.RESPONSE_MODE_PARAM, client.config().getResponseMode()); parameter(OAuth2Constants.REDIRECT_URI, client.config().getRedirectUri()); - parameter(OAuth2Constants.SCOPE, client.config().getScope()); + if (!scopeExplicitlySet) { + parameter(OAuth2Constants.SCOPE, client.config().getScope()); + } } @Override diff --git a/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/UserInfoRequest.java b/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/UserInfoRequest.java index 15270f58434..a41a394cd13 100644 --- a/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/UserInfoRequest.java +++ b/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/UserInfoRequest.java @@ -6,7 +6,7 @@ import org.keycloak.util.TokenUtil; import org.apache.http.client.methods.CloseableHttpResponse; -public class UserInfoRequest extends AbstractHttpGetRequest { +public class UserInfoRequest extends AbstractHttpGetRequest { private final String token; diff --git a/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/oid4vc/AbstractOid4vcRequest.java b/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/oid4vc/AbstractOid4vcRequest.java new file mode 100644 index 00000000000..bededeedfea --- /dev/null +++ b/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/oid4vc/AbstractOid4vcRequest.java @@ -0,0 +1,81 @@ +package org.keycloak.testsuite.util.oauth.oid4vc; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; + +import org.keycloak.testsuite.util.oauth.AbstractOAuthClient; +import org.keycloak.util.JsonSerialization; +import org.keycloak.utils.MediaType; + +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.entity.StringEntity; + +public abstract class AbstractOid4vcRequest { + + protected final AbstractOAuthClient client; + protected String bearerToken; + protected String endpointOverride; + protected Map headers = new HashMap<>(); + + public AbstractOid4vcRequest(AbstractOAuthClient client) { + this.client = client; + } + + public T bearerToken(String bearerToken) { + this.bearerToken = bearerToken; + return request(); + } + + public T endpoint(String endpoint) { + this.endpointOverride = endpoint; + return request(); + } + + public T header(String name, String value) { + headers.put(name, value); + return request(); + } + + protected abstract String getEndpoint(); + + protected abstract Object getBody(); + + public R send() { + HttpPost post = new HttpPost(endpointOverride != null ? endpointOverride : getEndpoint()); + post.addHeader("Accept", MediaType.APPLICATION_JSON); + post.addHeader("Content-Type", MediaType.APPLICATION_JSON); + + if (bearerToken != null) { + post.addHeader("Authorization", "Bearer " + bearerToken); + } + + headers.forEach(post::addHeader); + + try { + Object body = getBody(); + if (body != null) { + String jsonBody; + if (body instanceof String) { + jsonBody = (String) body; + } else { + jsonBody = JsonSerialization.writeValueAsString(body); + } + // Set entity even if empty string to support empty payload tests + post.setEntity(new StringEntity(jsonBody, StandardCharsets.UTF_8)); + } + // If body is null, don't set entity (no body) + return toResponse(client.httpClient().get().execute(post)); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + protected abstract R toResponse(CloseableHttpResponse response) throws IOException; + + private T request() { + return (T) this; + } +} diff --git a/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/oid4vc/CredentialIssuerMetadataRequest.java b/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/oid4vc/CredentialIssuerMetadataRequest.java new file mode 100644 index 00000000000..df0538ced54 --- /dev/null +++ b/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/oid4vc/CredentialIssuerMetadataRequest.java @@ -0,0 +1,30 @@ +package org.keycloak.testsuite.util.oauth.oid4vc; + +import java.io.IOException; + +import org.keycloak.testsuite.util.oauth.AbstractHttpGetRequest; +import org.keycloak.testsuite.util.oauth.AbstractOAuthClient; + +import org.apache.http.client.methods.CloseableHttpResponse; + +public class CredentialIssuerMetadataRequest extends AbstractHttpGetRequest { + + public CredentialIssuerMetadataRequest(AbstractOAuthClient client) { + super(client); + } + + @Override + protected String getEndpoint() { + return client.getEndpoints().getOid4vcIssuerMetadata(); + } + + @Override + protected void initRequest() { + // No specific parameters for metadata request + } + + @Override + protected CredentialIssuerMetadataResponse toResponse(CloseableHttpResponse response) throws IOException { + return new CredentialIssuerMetadataResponse(response); + } +} diff --git a/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/oid4vc/CredentialIssuerMetadataResponse.java b/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/oid4vc/CredentialIssuerMetadataResponse.java new file mode 100644 index 00000000000..26eb8c1a3b0 --- /dev/null +++ b/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/oid4vc/CredentialIssuerMetadataResponse.java @@ -0,0 +1,45 @@ +package org.keycloak.testsuite.util.oauth.oid4vc; + +import java.io.IOException; + +import org.keycloak.protocol.oid4vc.model.CredentialIssuer; +import org.keycloak.testsuite.util.oauth.AbstractHttpResponse; +import org.keycloak.util.JsonSerialization; + +import com.fasterxml.jackson.databind.JsonNode; +import org.apache.http.client.methods.CloseableHttpResponse; + +public class CredentialIssuerMetadataResponse extends AbstractHttpResponse { + + private CredentialIssuer metadata; + private String content; + + public CredentialIssuerMetadataResponse(CloseableHttpResponse response) throws IOException { + super(response); + } + + @Override + protected void parseContent() throws IOException { + content = asString(); + String contentType = getHeader("Content-Type"); + if (contentType != null && contentType.startsWith("application/json")) { + // Check if this is OID4VC metadata (has "credential_issuer") vs JWT VC metadata (has "issuer" only) + // JWT VC metadata uses JWTVCIssuerMetadata model with "issuer" field + // OID4VC metadata uses CredentialIssuer model with "credential_issuer" field + // Only parse if it's OID4VC format - JWT VC endpoints return different format + JsonNode node = JsonSerialization.readValue(content, JsonNode.class); + if (node.has("credential_issuer")) { + metadata = JsonSerialization.mapper.treeToValue(node, CredentialIssuer.class); + } + } + } + + public CredentialIssuer getMetadata() { + return metadata; + } + + public String getContent() { + return content; + } + +} diff --git a/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/oid4vc/CredentialOfferRequest.java b/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/oid4vc/CredentialOfferRequest.java new file mode 100644 index 00000000000..7bdd0a4c575 --- /dev/null +++ b/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/oid4vc/CredentialOfferRequest.java @@ -0,0 +1,45 @@ +package org.keycloak.testsuite.util.oauth.oid4vc; + +import java.io.IOException; + +import org.keycloak.testsuite.util.oauth.AbstractHttpGetRequest; +import org.keycloak.testsuite.util.oauth.AbstractOAuthClient; + +import org.apache.http.client.methods.CloseableHttpResponse; + +public class CredentialOfferRequest extends AbstractHttpGetRequest { + + private String nonce; + + public CredentialOfferRequest(AbstractOAuthClient client) { + super(client); + } + + public CredentialOfferRequest(String nonce, AbstractOAuthClient client) { + super(client); + this.nonce = nonce; + } + + public CredentialOfferRequest nonce(String nonce) { + this.nonce = nonce; + return this; + } + + @Override + protected String getEndpoint() { + if (nonce == null) { + throw new IllegalStateException("Nonce must be provided either via constructor, nonce() method, or endpoint must be overridden"); + } + return client.getEndpoints().getOid4vcCredentialOffer(nonce); + } + + @Override + protected void initRequest() { + // No additional step needed for basic GET + } + + @Override + protected CredentialOfferResponse toResponse(CloseableHttpResponse response) throws IOException { + return new CredentialOfferResponse(response); + } +} diff --git a/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/oid4vc/CredentialOfferResponse.java b/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/oid4vc/CredentialOfferResponse.java new file mode 100644 index 00000000000..d1f294676a2 --- /dev/null +++ b/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/oid4vc/CredentialOfferResponse.java @@ -0,0 +1,26 @@ +package org.keycloak.testsuite.util.oauth.oid4vc; + +import java.io.IOException; + +import org.keycloak.protocol.oid4vc.model.CredentialsOffer; +import org.keycloak.testsuite.util.oauth.AbstractHttpResponse; + +import org.apache.http.client.methods.CloseableHttpResponse; + +public class CredentialOfferResponse extends AbstractHttpResponse { + + private CredentialsOffer credentialsOffer; + + public CredentialOfferResponse(CloseableHttpResponse response) throws IOException { + super(response); + } + + @Override + protected void parseContent() throws IOException { + credentialsOffer = asJson(CredentialsOffer.class); + } + + public CredentialsOffer getCredentialsOffer() { + return credentialsOffer; + } +} diff --git a/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/oid4vc/CredentialOfferUriRequest.java b/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/oid4vc/CredentialOfferUriRequest.java new file mode 100644 index 00000000000..2f5be7fdc90 --- /dev/null +++ b/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/oid4vc/CredentialOfferUriRequest.java @@ -0,0 +1,55 @@ +package org.keycloak.testsuite.util.oauth.oid4vc; + +import java.io.IOException; + +import org.keycloak.testsuite.util.oauth.AbstractHttpGetRequest; +import org.keycloak.testsuite.util.oauth.AbstractOAuthClient; + +import org.apache.http.client.methods.CloseableHttpResponse; + +public class CredentialOfferUriRequest extends AbstractHttpGetRequest { + + private String credentialConfigurationId; + private Boolean preAuthorized; + private String username; + private String clientIdParam; + + public CredentialOfferUriRequest(AbstractOAuthClient client) { + super(client); + } + + public CredentialOfferUriRequest credentialConfigurationId(String credentialConfigurationId) { + this.credentialConfigurationId = credentialConfigurationId; + return this; + } + + public CredentialOfferUriRequest preAuthorized(Boolean preAuthorized) { + this.preAuthorized = preAuthorized; + return this; + } + + public CredentialOfferUriRequest username(String username) { + this.username = username; + return this; + } + + public CredentialOfferUriRequest clientId(String clientId) { + this.clientIdParam = clientId; + return this; + } + + @Override + protected String getEndpoint() { + return client.getEndpoints().getOid4vcCredentialOfferUri(credentialConfigurationId, preAuthorized, username, clientIdParam); + } + + @Override + protected void initRequest() { + // All parameters are in the URL for this specific Keycloak test endpoint + } + + @Override + protected CredentialOfferUriResponse toResponse(CloseableHttpResponse response) throws IOException { + return new CredentialOfferUriResponse(response); + } +} diff --git a/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/oid4vc/CredentialOfferUriResponse.java b/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/oid4vc/CredentialOfferUriResponse.java new file mode 100644 index 00000000000..3de4e55c787 --- /dev/null +++ b/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/oid4vc/CredentialOfferUriResponse.java @@ -0,0 +1,26 @@ +package org.keycloak.testsuite.util.oauth.oid4vc; + +import java.io.IOException; + +import org.keycloak.protocol.oid4vc.model.CredentialOfferURI; +import org.keycloak.testsuite.util.oauth.AbstractHttpResponse; + +import org.apache.http.client.methods.CloseableHttpResponse; + +public class CredentialOfferUriResponse extends AbstractHttpResponse { + + private CredentialOfferURI credentialOfferURI; + + public CredentialOfferUriResponse(CloseableHttpResponse response) throws IOException { + super(response); + } + + @Override + protected void parseContent() throws IOException { + credentialOfferURI = asJson(CredentialOfferURI.class); + } + + public CredentialOfferURI getCredentialOfferURI() { + return credentialOfferURI; + } +} diff --git a/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/oid4vc/OID4VCClient.java b/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/oid4vc/OID4VCClient.java new file mode 100644 index 00000000000..0f329af5351 --- /dev/null +++ b/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/oid4vc/OID4VCClient.java @@ -0,0 +1,66 @@ +package org.keycloak.testsuite.util.oauth.oid4vc; + +import org.keycloak.protocol.oid4vc.model.Proofs; +import org.keycloak.testsuite.util.oauth.AbstractOAuthClient; +import org.keycloak.testsuite.util.oauth.AccessTokenResponse; + +public class OID4VCClient { + + private final AbstractOAuthClient client; + + public OID4VCClient(AbstractOAuthClient client) { + this.client = client; + } + + public CredentialIssuerMetadataRequest issuerMetadataRequest() { + return new CredentialIssuerMetadataRequest(client); + } + + public CredentialIssuerMetadataResponse doIssuerMetadataRequest() { + return issuerMetadataRequest().send(); + } + + public CredentialOfferUriRequest credentialOfferUriRequest() { + return new CredentialOfferUriRequest(client); + } + + public Oid4vcCredentialRequest credentialRequest() { + return new Oid4vcCredentialRequest(client); + } + + public Oid4vcCredentialResponse doCredentialRequest(String accessToken, String credentialConfigurationId, Proofs proofs) { + return credentialRequest() + .bearerToken(accessToken) + .credentialConfigurationId(credentialConfigurationId) + .proofs(proofs) + .send(); + } + + public PreAuthorizedCodeGrantRequest preAuthorizedCodeGrantRequest(String preAuthorizedCode) { + return new PreAuthorizedCodeGrantRequest(preAuthorizedCode, client); + } + + public AccessTokenResponse doPreAuthorizedCodeGrant(String preAuthorizedCode) { + return preAuthorizedCodeGrantRequest(preAuthorizedCode).send(); + } + + public CredentialOfferRequest credentialOfferRequest() { + return new CredentialOfferRequest(client); + } + + public CredentialOfferRequest credentialOfferRequest(String nonce) { + return new CredentialOfferRequest(nonce, client); + } + + public CredentialOfferResponse doCredentialOfferRequest(String nonce) { + return credentialOfferRequest(nonce).send(); + } + + public Oid4vcNonceRequest nonceRequest() { + return new Oid4vcNonceRequest(client); + } + + public String doNonceRequest() { + return nonceRequest().send().getNonce(); + } +} diff --git a/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/oid4vc/Oid4vcCredentialRequest.java b/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/oid4vc/Oid4vcCredentialRequest.java new file mode 100644 index 00000000000..b99fadb1367 --- /dev/null +++ b/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/oid4vc/Oid4vcCredentialRequest.java @@ -0,0 +1,68 @@ +package org.keycloak.testsuite.util.oauth.oid4vc; + +import java.io.IOException; + +import org.keycloak.protocol.oid4vc.model.CredentialRequest; +import org.keycloak.protocol.oid4vc.model.Proofs; +import org.keycloak.testsuite.util.oauth.AbstractOAuthClient; + +import org.apache.http.client.methods.CloseableHttpResponse; + +public class Oid4vcCredentialRequest extends AbstractOid4vcRequest { + + private final CredentialRequest body = new CredentialRequest(); + private boolean emptyBody = false; + + public Oid4vcCredentialRequest(AbstractOAuthClient client) { + super(client); + } + + public Oid4vcCredentialRequest credentialConfigurationId(String credentialConfigurationId) { + body.setCredentialConfigurationId(credentialConfigurationId); + return this; + } + + public Oid4vcCredentialRequest credentialIdentifier(String credentialIdentifier) { + body.setCredentialIdentifier(credentialIdentifier); + return this; + } + + public Oid4vcCredentialRequest proofs(Proofs proofs) { + body.setProofs(proofs); + return this; + } + + /** + * Set the request to send an empty payload body. + * This is useful for testing edge cases where an empty body should be sent. + */ + public Oid4vcCredentialRequest emptyBody() { + this.emptyBody = true; + return this; + } + + @Override + protected String getEndpoint() { + return client.getEndpoints().getOid4vcCredential(); + } + + /** + * Returns the request body. If {@link #emptyBody()} was called, returns an empty string ("") + * to trigger an empty payload in {@link AbstractOid4vcRequest#send()}. + * If not, returns the {@link CredentialRequest} object to be serialized as JSON. + * + * @return the request body object or empty string + */ + @Override + protected Object getBody() { + if (emptyBody) { + return ""; + } + return body; + } + + @Override + protected Oid4vcCredentialResponse toResponse(CloseableHttpResponse response) throws IOException { + return new Oid4vcCredentialResponse(response); + } +} diff --git a/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/oid4vc/Oid4vcCredentialResponse.java b/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/oid4vc/Oid4vcCredentialResponse.java new file mode 100644 index 00000000000..3a5a481f261 --- /dev/null +++ b/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/oid4vc/Oid4vcCredentialResponse.java @@ -0,0 +1,27 @@ +package org.keycloak.testsuite.util.oauth.oid4vc; + +import java.io.IOException; + +import org.keycloak.protocol.oid4vc.model.CredentialResponse; +import org.keycloak.testsuite.util.oauth.AbstractHttpResponse; + +import org.apache.http.client.methods.CloseableHttpResponse; + +public class Oid4vcCredentialResponse extends AbstractHttpResponse { + + private CredentialResponse credentialResponse; + + public Oid4vcCredentialResponse(CloseableHttpResponse response) throws IOException { + super(response); + } + + @Override + protected void parseContent() throws IOException { + credentialResponse = asJson(CredentialResponse.class); + } + + public CredentialResponse getCredentialResponse() { + return credentialResponse; + } + +} diff --git a/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/oid4vc/Oid4vcNonceRequest.java b/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/oid4vc/Oid4vcNonceRequest.java new file mode 100644 index 00000000000..296e14a0f04 --- /dev/null +++ b/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/oid4vc/Oid4vcNonceRequest.java @@ -0,0 +1,30 @@ +package org.keycloak.testsuite.util.oauth.oid4vc; + +import java.io.IOException; + +import org.keycloak.testsuite.util.oauth.AbstractHttpPostRequest; +import org.keycloak.testsuite.util.oauth.AbstractOAuthClient; + +import org.apache.http.client.methods.CloseableHttpResponse; + +public class Oid4vcNonceRequest extends AbstractHttpPostRequest { + + public Oid4vcNonceRequest(AbstractOAuthClient client) { + super(client); + } + + @Override + protected String getEndpoint() { + return client.getEndpoints().getOid4vcNonce(); + } + + @Override + protected void initRequest() { + // No parameters needed for nonce request + } + + @Override + protected Oid4vcNonceResponse toResponse(CloseableHttpResponse response) throws IOException { + return new Oid4vcNonceResponse(response); + } +} diff --git a/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/oid4vc/Oid4vcNonceResponse.java b/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/oid4vc/Oid4vcNonceResponse.java new file mode 100644 index 00000000000..d667745fdd5 --- /dev/null +++ b/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/oid4vc/Oid4vcNonceResponse.java @@ -0,0 +1,27 @@ +package org.keycloak.testsuite.util.oauth.oid4vc; + +import java.io.IOException; + +import org.keycloak.protocol.oid4vc.model.NonceResponse; +import org.keycloak.testsuite.util.oauth.AbstractHttpResponse; + +import org.apache.http.client.methods.CloseableHttpResponse; + +public class Oid4vcNonceResponse extends AbstractHttpResponse { + + private NonceResponse nonceResponse; + + public Oid4vcNonceResponse(CloseableHttpResponse response) throws IOException { + super(response); + } + + @Override + protected void parseContent() throws IOException { + nonceResponse = asJson(NonceResponse.class); + } + + public String getNonce() { + return nonceResponse != null ? nonceResponse.getNonce() : null; + } + +} diff --git a/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/oid4vc/PreAuthorizedCodeGrantRequest.java b/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/oid4vc/PreAuthorizedCodeGrantRequest.java new file mode 100644 index 00000000000..a4b6ff4b031 --- /dev/null +++ b/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/oid4vc/PreAuthorizedCodeGrantRequest.java @@ -0,0 +1,46 @@ +package org.keycloak.testsuite.util.oauth.oid4vc; + +import java.io.IOException; + +import org.keycloak.OAuth2Constants; +import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantTypeFactory; +import org.keycloak.testsuite.util.oauth.AbstractHttpPostRequest; +import org.keycloak.testsuite.util.oauth.AbstractOAuthClient; +import org.keycloak.testsuite.util.oauth.AccessTokenResponse; + +import org.apache.http.client.methods.CloseableHttpResponse; + +public class PreAuthorizedCodeGrantRequest extends AbstractHttpPostRequest { + + private final String preAuthorizedCode; + + public PreAuthorizedCodeGrantRequest(String preAuthorizedCode, AbstractOAuthClient client) { + super(client); + this.preAuthorizedCode = preAuthorizedCode; + } + + @Override + protected String getEndpoint() { + return client.getEndpoints().getToken(); + } + + @Override + protected void initRequest() { + parameter(OAuth2Constants.GRANT_TYPE, PreAuthorizedCodeGrantTypeFactory.GRANT_TYPE); + parameter(PreAuthorizedCodeGrantTypeFactory.CODE_REQUEST_PARAM, preAuthorizedCode); + } + + /** + * Add a custom parameter to the token request. + * This is useful for adding authorization_details or other custom parameters. + */ + public PreAuthorizedCodeGrantRequest addParameter(String name, String value) { + parameter(name, value); + return this; + } + + @Override + protected AccessTokenResponse toResponse(CloseableHttpResponse response) throws IOException { + return new AccessTokenResponse(response); + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/OID4VCICredentialOfferMatrixTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/OID4VCICredentialOfferMatrixTest.java index 4ab42b8cac5..dc172672e1f 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/OID4VCICredentialOfferMatrixTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/OID4VCICredentialOfferMatrixTest.java @@ -18,16 +18,11 @@ package org.keycloak.testsuite.oid4vc.issuance; import java.io.IOException; import java.net.URI; -import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Arrays; -import java.util.LinkedList; import java.util.List; import java.util.Map; -import jakarta.ws.rs.core.HttpHeaders; - -import org.keycloak.OAuth2Constants; import org.keycloak.TokenVerifier; import org.keycloak.admin.client.resource.UserResource; import org.keycloak.jose.jws.JWSInput; @@ -41,7 +36,6 @@ import org.keycloak.protocol.oid4vc.model.OID4VCAuthorizationDetail; import org.keycloak.protocol.oid4vc.model.PreAuthorizedCode; import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration; import org.keycloak.protocol.oid4vc.model.VerifiableCredential; -import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantTypeFactory; import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation; import org.keycloak.representations.JsonWebToken; import org.keycloak.representations.idm.ClientRepresentation; @@ -49,21 +43,14 @@ import org.keycloak.representations.idm.UserRepresentation; import org.keycloak.testsuite.admin.ApiUtil; import org.keycloak.testsuite.oid4vc.issuance.signing.OID4VCIssuerEndpointTest; import org.keycloak.testsuite.util.oauth.AccessTokenResponse; +import org.keycloak.testsuite.util.oauth.oid4vc.CredentialOfferResponse; +import org.keycloak.testsuite.util.oauth.oid4vc.CredentialOfferUriResponse; +import org.keycloak.testsuite.util.oauth.oid4vc.Oid4vcCredentialRequest; +import org.keycloak.testsuite.util.oauth.oid4vc.Oid4vcCredentialResponse; import org.keycloak.util.JsonSerialization; -import org.apache.commons.io.IOUtils; import org.apache.directory.api.util.Strings; -import org.apache.http.HttpEntity; 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.entity.ContentType; -import org.apache.http.entity.StringEntity; -import org.apache.http.message.BasicNameValuePair; -import org.apache.http.util.EntityUtils; import org.junit.Test; import static org.keycloak.OAuth2Constants.OPENID_CREDENTIAL; @@ -273,15 +260,18 @@ public class OID4VCICredentialOfferMatrixTest extends OID4VCIssuerEndpointTest { // 4. does not reflect anything from the credential offer // AccessTokenResponse accessToken = getPreAuthorizedAccessTokenResponse(ctx, credOffer); - List authDetails = accessToken.getAuthorizationDetails(OID4VCAuthorizationDetailResponse.class); - if (authDetails == null) + List authDetailsResponse = accessToken.getOid4vcAuthorizationDetails(); + if (authDetailsResponse == null || authDetailsResponse.isEmpty()) { throw new IllegalStateException("No authorization_details in token response"); - if (authDetails.size() > 1) + } + if (authDetailsResponse.size() > 1) { throw new IllegalStateException("Multiple authorization_details in token response"); + } + OID4VCAuthorizationDetailResponse authDetailResponse = authDetailsResponse.get(0); // Get the credential and verify // - CredentialResponse credResponse = getCredentialByAuthDetail(ctx, accessToken.getAccessToken(), authDetails.get(0)); + CredentialResponse credResponse = getCredentialByAuthDetail(ctx, accessToken.getAccessToken(), authDetailResponse); verifyCredentialResponse(ctx, credResponse); } else { @@ -342,50 +332,59 @@ public class OID4VCICredentialOfferMatrixTest extends OID4VCIssuerEndpointTest { private CredentialOfferURI getCredentialOfferUri(OfferTestContext ctx, String token) throws Exception { String credConfigId = ctx.supportedCredentialConfiguration.getId(); String credOfferUriUrl = getCredentialOfferUriUrl(credConfigId, ctx.preAuthorized, ctx.appUser, ctx.appClient); - HttpGet getCredentialOfferURI = new HttpGet(credOfferUriUrl); - getCredentialOfferURI.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token); - CloseableHttpResponse credentialOfferURIResponse = httpClient.execute(getCredentialOfferURI); - int statusCode = credentialOfferURIResponse.getStatusLine().getStatusCode(); + CredentialOfferUriResponse credentialOfferURIResponse = oauth.oid4vc() + .credentialOfferUriRequest() + .endpoint(credOfferUriUrl) + .bearerToken(token) + .send(); + int statusCode = credentialOfferURIResponse.getStatusCode(); if (HttpStatus.SC_OK != statusCode) { - HttpEntity entity = credentialOfferURIResponse.getEntity(); - throw new IllegalStateException(EntityUtils.toString(entity)); + String error = credentialOfferURIResponse.getError(); + String errorDescription = credentialOfferURIResponse.getErrorDescription(); + String errorMessage = error != null ? error : ""; + if (errorDescription != null) { + errorMessage += (errorMessage.isEmpty() ? "" : " ") + errorDescription; + } + if (errorMessage.isEmpty()) { + errorMessage = "Request failed with status " + statusCode; + } + throw new IllegalStateException(errorMessage); } - String s = IOUtils.toString(credentialOfferURIResponse.getEntity().getContent(), StandardCharsets.UTF_8); - CredentialOfferURI credentialOfferURI = JsonSerialization.valueFromString(s, CredentialOfferURI.class); + CredentialOfferURI credentialOfferURI = credentialOfferURIResponse.getCredentialOfferURI(); assertTrue(credentialOfferURI.getIssuer().startsWith(ctx.issuerMetadata.getCredentialIssuer())); assertTrue(Strings.isNotEmpty(credentialOfferURI.getNonce())); return credentialOfferURI; } private CredentialsOffer getCredentialsOffer(OfferTestContext ctx, String offerUri) throws Exception { - HttpGet getCredentialOffer = new HttpGet(offerUri); - CloseableHttpResponse credentialOfferResponse = httpClient.execute(getCredentialOffer); - int statusCode = credentialOfferResponse.getStatusLine().getStatusCode(); + CredentialOfferResponse credentialOfferResponse = oauth.oid4vc() + .credentialOfferRequest() + .endpoint(offerUri) + .send(); + int statusCode = credentialOfferResponse.getStatusCode(); if (HttpStatus.SC_OK != statusCode) { - HttpEntity entity = credentialOfferResponse.getEntity(); - throw new IllegalStateException(EntityUtils.toString(entity)); + throw new IllegalStateException(credentialOfferResponse.getErrorDescription() != null + ? credentialOfferResponse.getErrorDescription() + : "Request failed with status " + statusCode); } - String s = IOUtils.toString(credentialOfferResponse.getEntity().getContent(), StandardCharsets.UTF_8); - CredentialsOffer credOffer = JsonSerialization.valueFromString(s, CredentialsOffer.class); + CredentialsOffer credOffer = credentialOfferResponse.getCredentialsOffer(); assertEquals(List.of(ctx.supportedCredentialConfiguration.getId()), credOffer.getCredentialConfigurationIds()); return credOffer; } private AccessTokenResponse getPreAuthorizedAccessTokenResponse(OID4VCICredentialOfferMatrixTest.OfferTestContext ctx, CredentialsOffer credOffer) throws Exception { PreAuthorizedCode preAuthorizedCode = credOffer.getGrants().getPreAuthorizedCode(); - HttpPost postPreAuthorizedCode = new HttpPost(ctx.authorizationMetadata.getTokenEndpoint()); - List parameters = new LinkedList<>(); - parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, PreAuthorizedCodeGrantTypeFactory.GRANT_TYPE)); - parameters.add(new BasicNameValuePair(PreAuthorizedCodeGrantTypeFactory.CODE_REQUEST_PARAM, preAuthorizedCode.getPreAuthorizedCode())); - UrlEncodedFormEntity formEntity = new UrlEncodedFormEntity(parameters, StandardCharsets.UTF_8); - postPreAuthorizedCode.setEntity(formEntity); - CloseableHttpResponse accessTokenResponse = httpClient.execute(postPreAuthorizedCode); - int statusCode = accessTokenResponse.getStatusLine().getStatusCode(); + AccessTokenResponse accessTokenResponse = oauth.oid4vc() + .preAuthorizedCodeGrantRequest(preAuthorizedCode.getPreAuthorizedCode()) + .endpoint(ctx.authorizationMetadata.getTokenEndpoint()) + .send(); + int statusCode = accessTokenResponse.getStatusCode(); if (HttpStatus.SC_OK != statusCode) { - HttpEntity entity = accessTokenResponse.getEntity(); - throw new IllegalStateException(EntityUtils.toString(entity)); + throw new IllegalStateException(accessTokenResponse.getErrorDescription() != null + ? accessTokenResponse.getErrorDescription() + : "Request failed with status " + statusCode); } - return new AccessTokenResponse(accessTokenResponse); + return accessTokenResponse; } private CredentialResponse getCredentialByAuthDetail(OfferTestContext ctx, String accessToken, OID4VCAuthorizationDetailResponse authDetail) throws Exception { @@ -413,21 +412,27 @@ public class OID4VCICredentialOfferMatrixTest extends OID4VCIssuerEndpointTest { } private CredentialResponse sendCredentialRequest(OfferTestContext ctx, String accessToken, CredentialRequest credentialRequest) throws Exception { - HttpPost postCredential = new HttpPost(ctx.issuerMetadata.getCredentialEndpoint()); - postCredential.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken); - StringEntity stringEntity = new StringEntity(JsonSerialization.valueAsString(credentialRequest), ContentType.APPLICATION_JSON); - postCredential.setEntity(stringEntity); + Oid4vcCredentialRequest request = oauth.oid4vc() + .credentialRequest() + .endpoint(ctx.issuerMetadata.getCredentialEndpoint()) + .bearerToken(accessToken); - CloseableHttpResponse credentialRequestResponse = httpClient.execute(postCredential); - int statusCode = credentialRequestResponse.getStatusLine().getStatusCode(); - if (HttpStatus.SC_OK != statusCode) { - HttpEntity entity = credentialRequestResponse.getEntity(); - throw new IllegalStateException(EntityUtils.toString(entity)); + if (credentialRequest.getCredentialConfigurationId() != null) { + request.credentialConfigurationId(credentialRequest.getCredentialConfigurationId()); + } + if (credentialRequest.getCredentialIdentifier() != null) { + request.credentialIdentifier(credentialRequest.getCredentialIdentifier()); } - String s = IOUtils.toString(credentialRequestResponse.getEntity().getContent(), StandardCharsets.UTF_8); - CredentialResponse credentialResponse = JsonSerialization.valueFromString(s, CredentialResponse.class); + Oid4vcCredentialResponse credentialRequestResponse = request.send(); + int statusCode = credentialRequestResponse.getStatusCode(); + if (HttpStatus.SC_OK != statusCode) { + throw new IllegalStateException(credentialRequestResponse.getErrorDescription() != null + ? credentialRequestResponse.getErrorDescription() + : "Request failed with status " + statusCode); + } + CredentialResponse credentialResponse = credentialRequestResponse.getCredentialResponse(); assertNotNull("The credentials array should be present in the response", credentialResponse.getCredentials()); assertFalse("The credentials array should not be empty", credentialResponse.getCredentials().isEmpty()); return credentialResponse; diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCAuthorizationCodeFlowTestBase.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCAuthorizationCodeFlowTestBase.java index 29a7ad887e3..793958aa951 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCAuthorizationCodeFlowTestBase.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCAuthorizationCodeFlowTestBase.java @@ -17,25 +17,17 @@ package org.keycloak.testsuite.oid4vc.issuance.signing; -import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.Collections; -import java.util.HashMap; -import java.util.LinkedList; import java.util.List; -import java.util.Map; import java.util.function.BiFunction; -import jakarta.ws.rs.core.HttpHeaders; - import org.keycloak.OAuth2Constants; import org.keycloak.admin.client.resource.ClientScopeResource; import org.keycloak.admin.client.resource.UserResource; -import org.keycloak.crypto.Algorithm; import org.keycloak.events.Details; import org.keycloak.events.Errors; import org.keycloak.events.EventType; -import org.keycloak.jose.jws.JWSHeader; import org.keycloak.models.oid4vci.CredentialScopeModel; import org.keycloak.models.oid4vci.Oid4vcProtocolMapperModel; import org.keycloak.protocol.oid4vc.issuance.OID4VCAuthorizationDetailResponse; @@ -43,39 +35,29 @@ 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.model.ErrorType; import org.keycloak.protocol.oid4vc.model.OID4VCAuthorizationDetail; import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation; -import org.keycloak.representations.AccessTokenResponse; import org.keycloak.representations.idm.ClientScopeRepresentation; -import org.keycloak.representations.idm.OAuth2ErrorRepresentation; import org.keycloak.representations.idm.ProtocolMapperRepresentation; import org.keycloak.representations.idm.UserRepresentation; import org.keycloak.testsuite.AssertEvents; import org.keycloak.testsuite.admin.ApiUtil; -import org.keycloak.util.JsonSerialization; +import org.keycloak.testsuite.util.oauth.AccessTokenResponse; +import org.keycloak.testsuite.util.oauth.OpenIDProviderConfigurationResponse; +import org.keycloak.testsuite.util.oauth.oid4vc.CredentialIssuerMetadataResponse; +import org.keycloak.testsuite.util.oauth.oid4vc.Oid4vcCredentialRequest; +import org.keycloak.testsuite.util.oauth.oid4vc.Oid4vcCredentialResponse; -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.entity.StringEntity; -import org.apache.http.message.BasicNameValuePair; import org.hamcrest.Matchers; import org.junit.Rule; import org.junit.Test; import static org.keycloak.OAuth2Constants.OPENID_CREDENTIAL; -import static org.keycloak.models.oid4vci.CredentialScopeModel.SIGNING_ALG; -import static org.keycloak.models.oid4vci.CredentialScopeModel.SIGNING_KEY_ID; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertTrue; /** * Base class for authorization code flow tests with authorization details and claims validation. @@ -123,20 +105,19 @@ public abstract class OID4VCAuthorizationCodeFlowTestBase extends OID4VCIssuerEn Oid4vcTestContext ctx = new Oid4vcTestContext(); // Get credential issuer metadata - HttpGet getCredentialIssuer = new HttpGet(getRealmMetadataPath(TEST_REALM_NAME)); - 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); - } + CredentialIssuerMetadataResponse metadataResponse = oauth.oid4vc() + .issuerMetadataRequest() + .endpoint(getRealmMetadataPath(TEST_REALM_NAME)) + .send(); + assertEquals(HttpStatus.SC_OK, metadataResponse.getStatusCode()); + ctx.credentialIssuer = metadataResponse.getMetadata(); // 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); - } + OpenIDProviderConfigurationResponse openIDProviderConfigurationResponse = oauth.wellknownRequest() + .url(ctx.credentialIssuer.getAuthorizationServers().get(0)) + .send(); + assertEquals(HttpStatus.SC_OK, openIDProviderConfigurationResponse.getStatusCode()); + ctx.openidConfig = openIDProviderConfigurationResponse.getOidcConfiguration(); return ctx; } @@ -163,25 +144,12 @@ public abstract class OID4VCAuthorizationCodeFlowTestBase extends OID4VCIssuerEn String secondCode = oauth.parseLoginResponse().getCode(); assertNotNull("Second authorization code should not be null", secondCode); - // Exchange second code for tokens WITHOUT authorization_details - HttpPost postSecondToken = new HttpPost(ctx.openidConfig.getTokenEndpoint()); - List secondTokenParameters = new LinkedList<>(); - secondTokenParameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, OAuth2Constants.AUTHORIZATION_CODE)); - secondTokenParameters.add(new BasicNameValuePair(OAuth2Constants.CODE, secondCode)); - secondTokenParameters.add(new BasicNameValuePair(OAuth2Constants.REDIRECT_URI, oauth.getRedirectUri())); - secondTokenParameters.add(new BasicNameValuePair(OAuth2Constants.CLIENT_ID, oauth.getClientId())); - secondTokenParameters.add(new BasicNameValuePair(OAuth2Constants.CLIENT_SECRET, "password")); - // NOTE: NO authorization_details parameter in this request - - UrlEncodedFormEntity secondTokenFormEntity = new UrlEncodedFormEntity(secondTokenParameters, StandardCharsets.UTF_8); - postSecondToken.setEntity(secondTokenFormEntity); - - AccessTokenResponse secondTokenResponse; - try (CloseableHttpResponse tokenHttpResponse = httpClient.execute(postSecondToken)) { - assertEquals("Second token exchange should succeed", HttpStatus.SC_OK, tokenHttpResponse.getStatusLine().getStatusCode()); - String tokenResponseBody = IOUtils.toString(tokenHttpResponse.getEntity().getContent(), StandardCharsets.UTF_8); - secondTokenResponse = JsonSerialization.readValue(tokenResponseBody, AccessTokenResponse.class); - } + // Exchange second code for tokens WITHOUT authorization_details using OAuthClient + AccessTokenResponse secondTokenResponse = oauth.accessTokenRequest(secondCode) + .endpoint(ctx.openidConfig.getTokenEndpoint()) + .client(client.getClientId(), "password") + .send(); + assertEquals("Second token exchange should succeed", HttpStatus.SC_OK, secondTokenResponse.getStatusCode()); // ===== STEP 3: Verify second token does NOT have authorization_details ===== assertNull("Second token (regular SSO) should NOT have authorization_details", secondTokenResponse.getAuthorizationDetails()); @@ -191,23 +159,26 @@ public abstract class OID4VCAuthorizationCodeFlowTestBase extends OID4VCIssuerEn CredentialRequest credentialRequest = new CredentialRequest(); credentialRequest.setCredentialIdentifier(credentialIdentifier); - HttpPost postCredential = new HttpPost(ctx.credentialIssuer.getCredentialEndpoint()); - postCredential.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + secondTokenResponse.getToken()); - postCredential.addHeader(HttpHeaders.CONTENT_TYPE, "application/json"); - postCredential.setEntity(new StringEntity(JsonSerialization.writeValueAsString(credentialRequest), StandardCharsets.UTF_8)); + // Credential request with second token should fail using OID4VCI utilities + Oid4vcCredentialRequest credentialRequestBuilder = oauth.oid4vc() + .credentialRequest() + .endpoint(ctx.credentialIssuer.getCredentialEndpoint()) + .bearerToken(secondTokenResponse.getAccessToken()) + .credentialIdentifier(credentialIdentifier); + + Oid4vcCredentialResponse credentialResponse = credentialRequestBuilder.send(); // Credential request with second token should fail - // The second token doesn't have the OID4VCI scope, so it should fail at scope check - try (CloseableHttpResponse credentialResponse = httpClient.execute(postCredential)) { - assertEquals("Credential request with token without OID4VCI scope should fail", - HttpStatus.SC_BAD_REQUEST, credentialResponse.getStatusLine().getStatusCode()); + // The second token doesn't have the OID4VCI scope, so it should fail + assertEquals("Credential request with token without OID4VCI scope should fail", + HttpStatus.SC_BAD_REQUEST, credentialResponse.getStatusCode()); - String errorBody = IOUtils.toString(credentialResponse.getEntity().getContent(), StandardCharsets.UTF_8); - - assertTrue("Error should indicate scope check failure. Actual error: " + errorBody, - errorBody.contains("Scope check failure")); - } + String error = credentialResponse.getError(); + String errorDescription = credentialResponse.getErrorDescription(); + assertEquals("Credential request should fail with unknown credential configuration when OID4VCI scope is missing", + "UNKNOWN_CREDENTIAL_CONFIGURATION", error); + assertEquals("Scope check failure", errorDescription); } // Test for the whole authorization_code flow with the credentialRequest using credential_configuration_id @@ -250,20 +221,30 @@ public abstract class OID4VCAuthorizationCodeFlowTestBase extends OID4VCIssuerEn // Refresh token now org.keycloak.testsuite.util.oauth.AccessTokenResponse tokenResponseRef = oauth.refreshRequest(tokenResponse.getRefreshToken()).send(); - // TODO: Converting from one to the other... This is dummy and should be replaced once we start using "OAuthClient" in this test instead of hand-written HTTP requests... - AccessTokenResponse tokenResponse2 = new AccessTokenResponse(); - tokenResponse2.setAuthorizationDetails(tokenResponseRef.getAuthorizationDetails()); - tokenResponse2.setToken(tokenResponseRef.getAccessToken()); - String credentialIdentifier = assertTokenResponse(tokenResponse2); + // Extract values from refreshed token for credential request + String accessToken = tokenResponseRef.getAccessToken(); + List authDetails = tokenResponseRef.getOid4vcAuthorizationDetails(); + + String credentialIdentifier = null; + if (authDetails != null && !authDetails.isEmpty()) { + List credentialIdentifiers = authDetails.get(0).getCredentialIdentifiers(); + if (credentialIdentifiers != null && !credentialIdentifiers.isEmpty()) { + credentialIdentifier = credentialIdentifiers.get(0); + } + } String credentialConfigurationId = getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID); - // Request the actual credential using the identifier - HttpPost postCredential = getCredentialRequest(ctx, credRequestSupplier, tokenResponse2, credentialConfigurationId, credentialIdentifier); - - try (CloseableHttpResponse credentialResponse = httpClient.execute(postCredential)) { - assertSuccessfulCredentialResponse(credentialResponse); + // Request the actual credential using the refreshed token + Oid4vcCredentialRequest credentialRequest = oauth.oid4vc() + .credentialRequest() + .endpoint(ctx.credentialIssuer.getCredentialEndpoint()) + .bearerToken(accessToken); + if (credentialIdentifier != null) { + credentialRequest.credentialIdentifier(credentialIdentifier); } + Oid4vcCredentialResponse credentialResponse = credentialRequest.send(); + assertSuccessfulCredentialResponse(credentialResponse); } // Test for the authorization_code flow with "mandatory" claim specified in the "authorization_details" parameter @@ -294,29 +275,27 @@ public abstract class OID4VCAuthorizationCodeFlowTestBase extends OID4VCIssuerEn events.clear(); // Request the actual credential using the identifier - HttpPost postCredential = getCredentialRequest(ctx, credRequestSupplier, tokenResponse, credentialConfigurationId, credentialIdentifier); + Oid4vcCredentialRequest credentialRequest = getCredentialRequest(ctx, credRequestSupplier, tokenResponse, credentialConfigurationId, credentialIdentifier); + Oid4vcCredentialResponse credentialResponse = credentialRequest.send(); - try (CloseableHttpResponse credentialResponse = httpClient.execute(postCredential)) { - assertErrorCredentialResponse_mandatoryClaimsMissing(credentialResponse); - - // Verify VERIFIABLE_CREDENTIAL_REQUEST_ERROR event was fired with details about missing mandatory claim - events.expect(EventType.VERIFIABLE_CREDENTIAL_REQUEST_ERROR) - .client(client.getClientId()) - .user(AssertEvents.isUUID()) - .session(AssertEvents.isSessionId()) - .error(Errors.INVALID_REQUEST) - .detail(Details.REASON, Matchers.containsString("The requested claims are not available in the user profile")) - .assertEvent(); - } + assertErrorCredentialResponse(credentialResponse); + + // Verify VERIFIABLE_CREDENTIAL_REQUEST_ERROR event was fired with details about missing mandatory claim + events.expect(EventType.VERIFIABLE_CREDENTIAL_REQUEST_ERROR) + .client(client.getClientId()) + .user(AssertEvents.isUUID()) + .session(AssertEvents.isSessionId()) + .error(Errors.INVALID_REQUEST) + .detail(Details.REASON, Matchers.containsString("The requested claims are not available in the user profile")) + .assertEvent(); // 3 - Update user to add "lastName" userRep.setLastName("Doe"); user.update(userRep); // 4 - Test the credential-request again. Should be OK now - try (CloseableHttpResponse credentialResponse = httpClient.execute(postCredential)) { - assertSuccessfulCredentialResponse(credentialResponse); - } + credentialResponse = credentialRequest.send(); + assertSuccessfulCredentialResponse(credentialResponse); } @@ -350,17 +329,15 @@ public abstract class OID4VCAuthorizationCodeFlowTestBase extends OID4VCIssuerEn String credentialIdentifier = assertTokenResponse(tokenResponseWithMandatoryLastName); String credentialConfigurationId = getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID); - HttpPost postCredential = getCredentialRequest(ctx, credRequestSupplier, tokenResponseWithMandatoryLastName, credentialConfigurationId, credentialIdentifier); - try (CloseableHttpResponse credentialResponse = httpClient.execute(postCredential)) { - assertErrorCredentialResponse_mandatoryClaimsMissing(credentialResponse); - } + Oid4vcCredentialRequest credentialRequest = getCredentialRequest(ctx, credRequestSupplier, tokenResponseWithMandatoryLastName, credentialConfigurationId, credentialIdentifier); + Oid4vcCredentialResponse credentialResponse = credentialRequest.send(); + assertErrorCredentialResponse_mandatoryClaimsMissing(credentialResponse); // Request without mandatory lastName should work. Authorization_Details from accessToken will be used by Keycloak for processing this request credentialIdentifier = assertTokenResponse(tokenResponse); - postCredential = getCredentialRequest(ctx, credRequestSupplier, tokenResponse, credentialConfigurationId, credentialIdentifier); - try (CloseableHttpResponse credentialResponse = httpClient.execute(postCredential)) { - assertSuccessfulCredentialResponse(credentialResponse); - } + credentialRequest = getCredentialRequest(ctx, credRequestSupplier, tokenResponse, credentialConfigurationId, credentialIdentifier); + credentialResponse = credentialRequest.send(); + assertSuccessfulCredentialResponse(credentialResponse); } finally { // Revert user changes and add lastName back userRep.setLastName("Doe"); @@ -411,20 +388,19 @@ public abstract class OID4VCAuthorizationCodeFlowTestBase extends OID4VCIssuerEn events.clear(); // Request the actual credential using the identifier - HttpPost postCredential = getCredentialRequest(ctx, credRequestSupplier, tokenResponse, credentialConfigurationId, credentialIdentifier); + Oid4vcCredentialRequest credentialRequest = getCredentialRequest(ctx, credRequestSupplier, tokenResponse, credentialConfigurationId, credentialIdentifier); + Oid4vcCredentialResponse credentialResponse = credentialRequest.send(); - try (CloseableHttpResponse credentialResponse = httpClient.execute(postCredential)) { - assertErrorCredentialResponse_mandatoryClaimsMissing(credentialResponse); - - // Verify VERIFIABLE_CREDENTIAL_REQUEST_ERROR event was fired with details about missing mandatory claim - events.expect(EventType.VERIFIABLE_CREDENTIAL_REQUEST_ERROR) - .client(client.getClientId()) - .user(AssertEvents.isUUID()) - .session(AssertEvents.isSessionId()) - .error(Errors.INVALID_REQUEST) - .detail(Details.REASON, Matchers.containsString("The requested claims are not available in the user profile")) - .assertEvent(); - } + assertErrorCredentialResponse(credentialResponse); + + // Verify VERIFIABLE_CREDENTIAL_REQUEST_ERROR event was fired with details about missing mandatory claim + events.expect(EventType.VERIFIABLE_CREDENTIAL_REQUEST_ERROR) + .client(client.getClientId()) + .user(AssertEvents.isUUID()) + .session(AssertEvents.isSessionId()) + .error(Errors.INVALID_REQUEST) + .detail(Details.REASON, Matchers.containsString("The requested claims are not available in the user profile")) + .assertEvent(); // 3 - Update user to add "lastName", but keep "firstName" missing. Credential request should still fail userRep.setLastName("Doe"); @@ -434,18 +410,17 @@ public abstract class OID4VCAuthorizationCodeFlowTestBase extends OID4VCIssuerEn // Clear events before credential request events.clear(); - try (CloseableHttpResponse credentialResponse = httpClient.execute(postCredential)) { - assertErrorCredentialResponse_mandatoryClaimsMissing(credentialResponse); - - // Verify VERIFIABLE_CREDENTIAL_REQUEST_ERROR event was fired - events.expect(EventType.VERIFIABLE_CREDENTIAL_REQUEST_ERROR) - .client(client.getClientId()) - .user(AssertEvents.isUUID()) - .session(AssertEvents.isSessionId()) - .error(Errors.INVALID_REQUEST) - .detail(Details.REASON, Matchers.containsString("The requested claims are not available in the user profile")) - .assertEvent(); - } + credentialResponse = credentialRequest.send(); + assertErrorCredentialResponse(credentialResponse); + + // Verify VERIFIABLE_CREDENTIAL_REQUEST_ERROR event was fired + events.expect(EventType.VERIFIABLE_CREDENTIAL_REQUEST_ERROR) + .client(client.getClientId()) + .user(AssertEvents.isUUID()) + .session(AssertEvents.isSessionId()) + .error(Errors.INVALID_REQUEST) + .detail(Details.REASON, Matchers.containsString("The requested claims are not available in the user profile")) + .assertEvent(); // 4 - Update user to add "firstName", but missing "lastName" userRep.setLastName(null); @@ -455,27 +430,25 @@ public abstract class OID4VCAuthorizationCodeFlowTestBase extends OID4VCIssuerEn // Clear events before credential request events.clear(); - try (CloseableHttpResponse credentialResponse = httpClient.execute(postCredential)) { - assertErrorCredentialResponse_mandatoryClaimsMissing(credentialResponse); - - // Verify VERIFIABLE_CREDENTIAL_REQUEST_ERROR event was fired - events.expect(EventType.VERIFIABLE_CREDENTIAL_REQUEST_ERROR) - .client(client.getClientId()) - .user(AssertEvents.isUUID()) - .session(AssertEvents.isSessionId()) - .error(Errors.INVALID_REQUEST) - .detail(Details.REASON, Matchers.containsString("The requested claims are not available in the user profile")) - .assertEvent(); - } + credentialResponse = credentialRequest.send(); + assertErrorCredentialResponse(credentialResponse); + + // Verify VERIFIABLE_CREDENTIAL_REQUEST_ERROR event was fired + events.expect(EventType.VERIFIABLE_CREDENTIAL_REQUEST_ERROR) + .client(client.getClientId()) + .user(AssertEvents.isUUID()) + .session(AssertEvents.isSessionId()) + .error(Errors.INVALID_REQUEST) + .detail(Details.REASON, Matchers.containsString("The requested claims are not available in the user profile")) + .assertEvent(); // 5 - Update user to both "firstName" and "lastName". Credential request should be successful userRep.setLastName("Doe"); userRep.setFirstName("John"); user.update(userRep); - try (CloseableHttpResponse credentialResponse = httpClient.execute(postCredential)) { - assertSuccessfulCredentialResponse(credentialResponse); - } + credentialResponse = credentialRequest.send(); + assertSuccessfulCredentialResponse(credentialResponse); } finally { // 6 - Revert protocol mapper config protocolMapper.getConfig().put(Oid4vcProtocolMapperModel.MANDATORY, "false"); @@ -483,88 +456,8 @@ public abstract class OID4VCAuthorizationCodeFlowTestBase extends OID4VCIssuerEn } } - @Test - public void testCompleteFlowWithSigningAlgorithmAndKeyIdConfigured() throws Exception { - BiFunction credRequestSupplier = (credentialConfigurationId, credentialIdentifier) -> { - CredentialRequest credentialRequest = new CredentialRequest(); - credentialRequest.setCredentialIdentifier(credentialIdentifier); - return credentialRequest; - }; - ClientScopeResource clientScope = ApiUtil.findClientScopeByName(testRealm(), getCredentialClientScope().getName()); - ClientScopeRepresentation clientScopeRep = clientScope.toRepresentation(); - Map origAttributes = new HashMap<>(clientScopeRep.getAttributes()); - - try { - // 1 - Configure signature algorithm, but not keyId. Make sure that credential signed with the target algorithm - clientScopeRep.getAttributes().put(SIGNING_ALG, Algorithm.ES512); - clientScopeRep.getAttributes().put(SIGNING_KEY_ID, null); - clientScope.update(clientScopeRep); - - Object credentialObj = testCompleteFlowWithClaimsValidationAuthorizationCode(credRequestSupplier); - JWSHeader jwsHeader = verifyCredentialSignature(credentialObj, Algorithm.ES512); - String es512keyId = jwsHeader.getKeyId(); - logoutUser("john"); - - // 2 - Configure signature algorithm, and keyId with blank value "" (just to simulate what admin console was doing when clientScope was saved). - // Make sure that credential signed with the target algorithm and keyId is not considered - clientScopeRep.getAttributes().put(SIGNING_ALG, Algorithm.EdDSA); - clientScopeRep.getAttributes().put(SIGNING_KEY_ID, ""); - clientScope.update(clientScopeRep); - - credentialObj = testCompleteFlowWithClaimsValidationAuthorizationCode(credRequestSupplier); - verifyCredentialSignature(credentialObj, Algorithm.EdDSA); - logoutUser("john"); - - // 3 - Configure signature algorithm, and keyId with some value. Make sure that - // credential signed with the target algorithm and keyId as expected - clientScopeRep.getAttributes().put(SIGNING_ALG, Algorithm.ES512); - clientScopeRep.getAttributes().put(SIGNING_KEY_ID, es512keyId); - clientScope.update(clientScopeRep); - - credentialObj = testCompleteFlowWithClaimsValidationAuthorizationCode(credRequestSupplier); - JWSHeader newJWSHeader = verifyCredentialSignature(credentialObj, Algorithm.ES512); - assertEquals(es512keyId, newJWSHeader.getKeyId()); - logoutUser("john"); - - // 4 - Configure different signature algorithm not matching with key specified by keyId. Error is expected - clientScopeRep.getAttributes().put(SIGNING_ALG, Algorithm.EdDSA); - clientScopeRep.getAttributes().put(SIGNING_KEY_ID, es512keyId); - clientScope.update(clientScopeRep); - - Oid4vcTestContext ctx = prepareOid4vcTestContext(); - AccessTokenResponse tokenResponse = authzCodeFlow(ctx); - String credentialIdentifier = assertTokenResponse(tokenResponse); - String credentialConfigurationId = getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID); - - // Clear events before credential request - events.clear(); - - HttpPost postCredential = getCredentialRequest(ctx, credRequestSupplier, tokenResponse, credentialConfigurationId, credentialIdentifier); - - try (CloseableHttpResponse credentialResponse = httpClient.execute(postCredential)) { - String expectedError = "Signing of credential failed: No key for id '" + es512keyId + "' and algorithm 'EdDSA' available."; - assertErrorCredentialResponse(credentialResponse, ErrorType.INVALID_CREDENTIAL_REQUEST.name(), expectedError); - - // Verify VERIFIABLE_CREDENTIAL_REQUEST_ERROR event was fired with details about missing mandatory claim - events.expect(EventType.VERIFIABLE_CREDENTIAL_REQUEST_ERROR) - .client(client.getClientId()) - .user(AssertEvents.isUUID()) - .session(AssertEvents.isSessionId()) - .error(ErrorType.INVALID_CREDENTIAL_REQUEST.getValue()) - .detail(Details.REASON, expectedError) - .assertEvent(); - } - - } finally { - // Revert clientScope config - clientScopeRep.setAttributes(origAttributes); - clientScope.update(clientScopeRep); - } - } - - // Return VC credential object - private Object testCompleteFlowWithClaimsValidationAuthorizationCode(BiFunction credentialRequestSupplier) throws Exception { + private void testCompleteFlowWithClaimsValidationAuthorizationCode(BiFunction credentialRequestSupplier) throws Exception { Oid4vcTestContext ctx = prepareOid4vcTestContext(); // Perform authorization code flow to get authorization code @@ -572,25 +465,11 @@ public abstract class OID4VCAuthorizationCodeFlowTestBase extends OID4VCIssuerEn String credentialIdentifier = assertTokenResponse(tokenResponse); String credentialConfigurationId = getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID); - events.clear(); - // Request the actual credential using the identifier - HttpPost postCredential = getCredentialRequest(ctx, credentialRequestSupplier, tokenResponse, credentialConfigurationId, credentialIdentifier); + Oid4vcCredentialRequest credentialRequest = getCredentialRequest(ctx, credentialRequestSupplier, tokenResponse, credentialConfigurationId, credentialIdentifier); + Oid4vcCredentialResponse credentialResponse = credentialRequest.send(); - try (CloseableHttpResponse credentialResponse = httpClient.execute(postCredential)) { - Object credential = assertSuccessfulCredentialResponse(credentialResponse); - - // Verify event - events.expect(EventType.VERIFIABLE_CREDENTIAL_REQUEST) - .client(client.getClientId()) - .user(AssertEvents.isUUID()) - .session(AssertEvents.isSessionId()) - .detail(Details.USERNAME, "john") - .detail(Details.CREDENTIAL_TYPE, credentialConfigurationId) - .assertEvent(); - - return credential; - } + assertSuccessfulCredentialResponse(credentialResponse); } // Successful authorization_code flow @@ -635,31 +514,19 @@ public abstract class OID4VCAuthorizationCodeFlowTestBase extends OID4VCIssuerEn authDetail.setLocations(Collections.singletonList(ctx.credentialIssuer.getCredentialIssuer())); List authDetails = List.of(authDetail); - String authDetailsJson = JsonSerialization.writeValueAsString(authDetails); // Exchange authorization code for tokens with authorization_details - HttpPost postToken = new HttpPost(ctx.openidConfig.getTokenEndpoint()); - List tokenParameters = new LinkedList<>(); - tokenParameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, OAuth2Constants.AUTHORIZATION_CODE)); - tokenParameters.add(new BasicNameValuePair(OAuth2Constants.CODE, code)); - tokenParameters.add(new BasicNameValuePair(OAuth2Constants.REDIRECT_URI, oauth.getRedirectUri())); - tokenParameters.add(new BasicNameValuePair(OAuth2Constants.CLIENT_ID, oauth.getClientId())); - tokenParameters.add(new BasicNameValuePair(OAuth2Constants.CLIENT_SECRET, "password")); - tokenParameters.add(new BasicNameValuePair(OAuth2Constants.AUTHORIZATION_DETAILS, authDetailsJson)); - UrlEncodedFormEntity tokenFormEntity = new UrlEncodedFormEntity(tokenParameters, StandardCharsets.UTF_8); - postToken.setEntity(tokenFormEntity); - - try (CloseableHttpResponse tokenHttpResponse = httpClient.execute(postToken)) { - assertEquals(HttpStatus.SC_OK, tokenHttpResponse.getStatusLine().getStatusCode()); - String tokenResponseBody = IOUtils.toString(tokenHttpResponse.getEntity().getContent(), StandardCharsets.UTF_8); - return JsonSerialization.readValue(tokenResponseBody, AccessTokenResponse.class); - } + return oauth.accessTokenRequest(code) + .endpoint(ctx.openidConfig.getTokenEndpoint()) + .client(client.getClientId(), "password") + .authorizationDetails(authDetails) + .send(); } // Test successful token response. Returns "Credential identifier" of the VC credential private String assertTokenResponse(AccessTokenResponse tokenResponse) throws Exception { // Extract authorization_details from token response - List authDetailsResponse = parseAuthorizationDetails(tokenResponse); + List authDetailsResponse = tokenResponse.getOid4vcAuthorizationDetails(); assertNotNull("authorization_details should be present in the response", authDetailsResponse); assertEquals(1, authDetailsResponse.size()); @@ -677,28 +544,31 @@ public abstract class OID4VCAuthorizationCodeFlowTestBase extends OID4VCIssuerEn return credentialIdentifiers.get(0); } - private HttpPost getCredentialRequest(Oid4vcTestContext ctx, BiFunction credentialRequestSupplier, AccessTokenResponse tokenResponse, - String credentialConfigurationId, String credentialIdentifier) throws Exception { + private Oid4vcCredentialRequest getCredentialRequest(Oid4vcTestContext ctx, BiFunction credentialRequestSupplier, AccessTokenResponse tokenResponse, + String credentialConfigurationId, String credentialIdentifier) throws Exception { // 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 = credentialRequestSupplier.apply(credentialConfigurationId, credentialIdentifier); - String requestBody = JsonSerialization.writeValueAsString(credentialRequest); - postCredential.setEntity(new StringEntity(requestBody, StandardCharsets.UTF_8)); + Oid4vcCredentialRequest request = oauth.oid4vc() + .credentialRequest() + .endpoint(ctx.credentialIssuer.getCredentialEndpoint()) + .bearerToken(tokenResponse.getAccessToken()); - return postCredential; + if (credentialRequest.getCredentialConfigurationId() != null) { + request.credentialConfigurationId(credentialRequest.getCredentialConfigurationId()); + } + if (credentialRequest.getCredentialIdentifier() != null) { + request.credentialIdentifier(credentialRequest.getCredentialIdentifier()); + } + + return request; } - // Test successful credential response and returns credential object - private Object assertSuccessfulCredentialResponse(CloseableHttpResponse credentialResponse) throws Exception { - assertEquals(HttpStatus.SC_OK, credentialResponse.getStatusLine().getStatusCode()); - String responseBody = IOUtils.toString(credentialResponse.getEntity().getContent(), StandardCharsets.UTF_8); + private void assertSuccessfulCredentialResponse(Oid4vcCredentialResponse credentialResponse) throws Exception { + assertEquals(HttpStatus.SC_OK, credentialResponse.getStatusCode()); // Parse the credential response - CredentialResponse parsedResponse = JsonSerialization.readValue(responseBody, CredentialResponse.class); + CredentialResponse parsedResponse = credentialResponse.getCredentialResponse(); 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()); @@ -713,23 +583,18 @@ public abstract class OID4VCAuthorizationCodeFlowTestBase extends OID4VCIssuerEn // Verify the credential structure based on formatfix-authorization_details-processing verifyCredentialStructure(credentialObj); - - return credentialObj; } - private void assertErrorCredentialResponse_mandatoryClaimsMissing(CloseableHttpResponse credentialResponse) throws Exception { - assertEquals(HttpStatus.SC_BAD_REQUEST, credentialResponse.getStatusLine().getStatusCode()); - String responseBody = IOUtils.toString(credentialResponse.getEntity().getContent(), StandardCharsets.UTF_8); - OAuth2ErrorRepresentation error = JsonSerialization.readValue(responseBody, OAuth2ErrorRepresentation.class); - assertEquals("Credential issuance failed: No elements selected after processing claims path pointer. The requested claims are not available in the user profile.", error.getError()); + private void assertErrorCredentialResponse(Oid4vcCredentialResponse credentialResponse) throws Exception { + assertEquals(HttpStatus.SC_BAD_REQUEST, credentialResponse.getStatusCode()); + String error = credentialResponse.getError(); + assertEquals("Credential issuance failed: No elements selected after processing claims path pointer. The requested claims are not available in the user profile.", error); } - private void assertErrorCredentialResponse(CloseableHttpResponse credentialResponse, String expectedError, String expectedErrorDescription) throws Exception { - assertEquals(HttpStatus.SC_BAD_REQUEST, credentialResponse.getStatusLine().getStatusCode()); - String responseBody = IOUtils.toString(credentialResponse.getEntity().getContent(), StandardCharsets.UTF_8); - OAuth2ErrorRepresentation error = JsonSerialization.readValue(responseBody, OAuth2ErrorRepresentation.class); - assertEquals(expectedError, error.getError()); - assertEquals(expectedErrorDescription, error.getErrorDescription()); + private void assertErrorCredentialResponse_mandatoryClaimsMissing(Oid4vcCredentialResponse credentialResponse) throws Exception { + assertEquals(HttpStatus.SC_BAD_REQUEST, credentialResponse.getStatusCode()); + String error = credentialResponse.getError(); + assertEquals("Credential issuance failed: No elements selected after processing claims path pointer. The requested claims are not available in the user profile.", error); } /** @@ -740,23 +605,4 @@ public abstract class OID4VCAuthorizationCodeFlowTestBase extends OID4VCIssuerEn // Default implementation - subclasses should override assertNotNull("Credential object should not be null", credentialObj); } - - /** - * Verify credential signature on VC credential is of expected algorithm and optionally expected keyId. - * - * @param vcCredential Verifiable credential - * @param expectedSignatureAlgorithm expected signature algorithm of the VC credential - * @return JWS header used for the VC credential. Can be used for further checks in the tests - */ - protected abstract JWSHeader verifyCredentialSignature(Object vcCredential, String expectedSignatureAlgorithm) throws Exception; - - /** - * Parse authorization details from the token response. - */ - protected List parseAuthorizationDetails(AccessTokenResponse tokenResponse) { - return tokenResponse.getAuthorizationDetails() - .stream() - .map(authzDetailsResponse -> authzDetailsResponse.asSubtype(OID4VCAuthorizationDetailResponse.class)) - .toList(); - } } 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 index 0d6b60a5310..3a4c60a267f 100644 --- 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 @@ -17,40 +17,26 @@ package org.keycloak.testsuite.oid4vc.issuance.signing; -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 jakarta.ws.rs.core.HttpHeaders; - -import org.keycloak.OAuth2Constants; import org.keycloak.models.oid4vci.CredentialScopeModel; import org.keycloak.protocol.oid4vc.issuance.OID4VCAuthorizationDetailResponse; 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.model.OID4VCAuthorizationDetail; -import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation; -import org.keycloak.representations.AccessTokenResponse; import org.keycloak.representations.idm.ClientScopeRepresentation; -import org.keycloak.util.JsonSerialization; +import org.keycloak.testsuite.util.oauth.AccessTokenResponse; +import org.keycloak.testsuite.util.oauth.OpenIDProviderConfigurationResponse; +import org.keycloak.testsuite.util.oauth.ParResponse; +import org.keycloak.testsuite.util.oauth.oid4vc.CredentialIssuerMetadataResponse; +import org.keycloak.testsuite.util.oauth.oid4vc.Oid4vcCredentialResponse; -import com.fasterxml.jackson.core.type.TypeReference; -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.entity.StringEntity; -import org.apache.http.message.BasicNameValuePair; import org.junit.Test; import static org.keycloak.OAuth2Constants.OPENID_CREDENTIAL; @@ -99,20 +85,19 @@ public class OID4VCAuthorizationCodeFlowWithPARTest extends OID4VCIssuerEndpoint Oid4vcTestContext ctx = new Oid4vcTestContext(); // Get credential issuer metadata - HttpGet getCredentialIssuer = new HttpGet(getRealmMetadataPath(TEST_REALM_NAME)); - 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); - } + CredentialIssuerMetadataResponse metadataResponse = oauth.oid4vc() + .issuerMetadataRequest() + .endpoint(getRealmMetadataPath(TEST_REALM_NAME)) + .send(); + assertEquals(HttpStatus.SC_OK, metadataResponse.getStatusCode()); + ctx.credentialIssuer = metadataResponse.getMetadata(); // 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); - } + OpenIDProviderConfigurationResponse openIDProviderConfigurationResponse = oauth.wellknownRequest() + .url(ctx.credentialIssuer.getAuthorizationServers().get(0)) + .send(); + assertEquals(HttpStatus.SC_OK, openIDProviderConfigurationResponse.getStatusCode()); + ctx.openidConfig = openIDProviderConfigurationResponse.getOidcConfiguration(); return ctx; } @@ -137,31 +122,19 @@ public class OID4VCAuthorizationCodeFlowWithPARTest extends OID4VCIssuerEndpoint 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); - } + ParResponse parResponse = oauth.pushedAuthorizationRequest() + .endpoint(ctx.openidConfig.getPushedAuthorizationRequestEndpoint()) + .client(oauth.getClientId(), "password") + .scopeParam(getCredentialClientScope().getName()) + .authorizationDetails(authDetails) + .state("test-state") + .nonce("test-nonce") + .send(); + assertEquals(HttpStatus.SC_CREATED, parResponse.getStatusCode()); + String requestUri = parResponse.getRequestUri(); + assertNotNull("Request URI should not be null", requestUri); // Step 2: Perform authorization with PAR oauth.client(client.getClientId()); @@ -173,27 +146,14 @@ public class OID4VCAuthorizationCodeFlowWithPARTest extends OID4VCIssuerEndpoint // 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); - } + AccessTokenResponse tokenResponse = oauth.accessTokenRequest(code) + .endpoint(ctx.openidConfig.getTokenEndpoint()) + .client(oauth.getClientId(), "password") + .send(); + assertEquals(HttpStatus.SC_OK, tokenResponse.getStatusCode()); // Step 4: Verify authorization_details is present in token response - List authDetailsResponse = parseAuthorizationDetails(JsonSerialization.writeValueAsString(tokenResponse)); + List authDetailsResponse = tokenResponse.getOid4vcAuthorizationDetails(); assertNotNull("authorization_details should be present in the response", authDetailsResponse); assertEquals("Should have exactly one authorization detail", 1, authDetailsResponse.size()); @@ -224,36 +184,30 @@ public class OID4VCAuthorizationCodeFlowWithPARTest extends OID4VCIssuerEndpoint } // 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"); + Oid4vcCredentialResponse credentialResponse = oauth.oid4vc() + .credentialRequest() + .endpoint(ctx.credentialIssuer.getCredentialEndpoint()) + .bearerToken(tokenResponse.getAccessToken()) + .credentialConfigurationId(credentialConfigurationId) + .send(); - CredentialRequest credentialRequest = new CredentialRequest(); - credentialRequest.setCredentialConfigurationId(credentialConfigurationId); + assertEquals(HttpStatus.SC_OK, credentialResponse.getStatusCode()); - String requestBody = JsonSerialization.writeValueAsString(credentialRequest); - postCredential.setEntity(new StringEntity(requestBody, StandardCharsets.UTF_8)); + // Parse the credential response + CredentialResponse parsedResponse = credentialResponse.getCredentialResponse(); + 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()); - try (CloseableHttpResponse credentialResponse = httpClient.execute(postCredential)) { - assertEquals(HttpStatus.SC_OK, credentialResponse.getStatusLine().getStatusCode()); - String responseBody = IOUtils.toString(credentialResponse.getEntity().getContent(), StandardCharsets.UTF_8); + // Verify that the issued credential contains the requested claims + CredentialResponse.Credential credentialWrapper = parsedResponse.getCredentials().get(0); + assertNotNull("Credential wrapper should not be null", credentialWrapper); - // 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()); + Object credentialObj = credentialWrapper.getCredential(); + assertNotNull("Credential object should not be null", credentialObj); - // 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); - } + // Verify the credential structure + verifyCredentialStructure(credentialObj); } @Test @@ -268,31 +222,19 @@ public class OID4VCAuthorizationCodeFlowWithPARTest extends OID4VCIssuerEndpoint 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); - } + ParResponse parResponse = oauth.pushedAuthorizationRequest() + .endpoint(ctx.openidConfig.getPushedAuthorizationRequestEndpoint()) + .client(oauth.getClientId(), "password") + .scopeParam(getCredentialClientScope().getName()) + .authorizationDetails(authDetails) + .state("test-state") + .nonce("test-nonce") + .send(); + assertEquals(HttpStatus.SC_CREATED, parResponse.getStatusCode()); + String requestUri = parResponse.getRequestUri(); + assertNotNull("Request URI should not be null", requestUri); // Step 2: Perform authorization with PAR oauth.client(client.getClientId()); @@ -303,24 +245,16 @@ public class OID4VCAuthorizationCodeFlowWithPARTest extends OID4VCIssuerEndpoint 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")); + AccessTokenResponse tokenResponse = oauth.accessTokenRequest(code) + .endpoint(ctx.openidConfig.getTokenEndpoint()) + .client(oauth.getClientId(), "password") + .send(); - 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")); - } + // Should fail because authorization_details from PAR request cannot be processed + assertEquals(HttpStatus.SC_BAD_REQUEST, tokenResponse.getStatusCode()); + String errorDescription = tokenResponse.getErrorDescription(); + assertTrue("Error message should indicate authorization_details processing failure", + errorDescription != null && errorDescription.contains("authorization_details was used in authorization request but cannot be processed for token response")); } @Test @@ -328,27 +262,16 @@ public class OID4VCAuthorizationCodeFlowWithPARTest extends OID4VCIssuerEndpoint 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); - } + ParResponse parResponse = oauth.pushedAuthorizationRequest() + .endpoint(ctx.openidConfig.getPushedAuthorizationRequestEndpoint()) + .client(oauth.getClientId(), "password") + .scopeParam(getCredentialClientScope().getName()) + .state("test-state") + .nonce("test-nonce") + .send(); + assertEquals(HttpStatus.SC_CREATED, parResponse.getStatusCode()); + String requestUri = parResponse.getRequestUri(); + assertNotNull("Request URI should not be null", requestUri); // Step 2: Perform authorization with PAR oauth.client(client.getClientId()); @@ -359,26 +282,14 @@ public class OID4VCAuthorizationCodeFlowWithPARTest extends OID4VCIssuerEndpoint 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); - } + AccessTokenResponse tokenResponse = oauth.accessTokenRequest(code) + .endpoint(ctx.openidConfig.getTokenEndpoint()) + .client(oauth.getClientId(), "password") + .send(); + assertEquals(HttpStatus.SC_OK, tokenResponse.getStatusCode()); // Step 4: Verify NO authorization_details in token response (since none was in PAR request) - List authDetailsResponse = parseAuthorizationDetails(JsonSerialization.writeValueAsString(tokenResponse)); + List authDetailsResponse = tokenResponse.getOid4vcAuthorizationDetails(); assertTrue("authorization_details should NOT be present in the response when not used in PAR request", authDetailsResponse == null || authDetailsResponse.isEmpty()); } @@ -391,26 +302,4 @@ public class OID4VCAuthorizationCodeFlowWithPARTest extends OID4VCIssuerEndpoint // 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); - } - } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCAuthorizationDetailsFlowTestBase.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCAuthorizationDetailsFlowTestBase.java index 0bd16a82d8a..b047e7d68cf 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCAuthorizationDetailsFlowTestBase.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCAuthorizationDetailsFlowTestBase.java @@ -17,20 +17,13 @@ package org.keycloak.testsuite.oid4vc.issuance.signing; -import java.io.IOException; -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.Set; import java.util.UUID; import java.util.stream.Collectors; -import jakarta.ws.rs.core.HttpHeaders; - -import org.keycloak.OAuth2Constants; import org.keycloak.events.Details; import org.keycloak.events.Errors; import org.keycloak.events.EventType; @@ -39,28 +32,22 @@ import org.keycloak.protocol.oid4vc.issuance.OID4VCAuthorizationDetailResponse; import org.keycloak.protocol.oid4vc.model.ClaimsDescription; import org.keycloak.protocol.oid4vc.model.CredentialIssuer; import org.keycloak.protocol.oid4vc.model.CredentialOfferURI; -import org.keycloak.protocol.oid4vc.model.CredentialRequest; import org.keycloak.protocol.oid4vc.model.CredentialResponse; import org.keycloak.protocol.oid4vc.model.CredentialsOffer; import org.keycloak.protocol.oid4vc.model.OID4VCAuthorizationDetail; import org.keycloak.protocol.oid4vc.model.PreAuthorizedCode; -import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantTypeFactory; import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation; import org.keycloak.representations.idm.ClientScopeRepresentation; -import org.keycloak.representations.idm.OAuth2ErrorRepresentation; import org.keycloak.testsuite.AssertEvents; +import org.keycloak.testsuite.util.oauth.AccessTokenResponse; +import org.keycloak.testsuite.util.oauth.OpenIDProviderConfigurationResponse; +import org.keycloak.testsuite.util.oauth.oid4vc.CredentialIssuerMetadataResponse; +import org.keycloak.testsuite.util.oauth.oid4vc.CredentialOfferResponse; +import org.keycloak.testsuite.util.oauth.oid4vc.CredentialOfferUriResponse; +import org.keycloak.testsuite.util.oauth.oid4vc.Oid4vcCredentialResponse; import org.keycloak.util.JsonSerialization; -import com.fasterxml.jackson.core.type.TypeReference; -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.entity.StringEntity; -import org.apache.http.message.BasicNameValuePair; import org.junit.Rule; import org.junit.Test; @@ -118,57 +105,50 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue // Clear events before credential offer URI request events.clear(); - HttpGet getCredentialOfferURI = new HttpGet(getCredentialOfferUriUrl(credentialConfigurationId)); - getCredentialOfferURI.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token); + CredentialOfferUriResponse credentialOfferURIResponse = oauth.oid4vc().credentialOfferUriRequest() + .endpoint(getCredentialOfferUriUrl(credentialConfigurationId)) + .bearerToken(token) + .send(); + assertEquals(HttpStatus.SC_OK, credentialOfferURIResponse.getStatusCode()); + CredentialOfferURI credentialOfferURI = credentialOfferURIResponse.getCredentialOfferURI(); - CredentialOfferURI credentialOfferURI; - try (CloseableHttpResponse response = httpClient.execute(getCredentialOfferURI)) { - int status = response.getStatusLine().getStatusCode(); - assertEquals(HttpStatus.SC_OK, status); - String s = IOUtils.toString(response.getEntity().getContent(), StandardCharsets.UTF_8); - credentialOfferURI = JsonSerialization.readValue(s, CredentialOfferURI.class); - - // Verify CREDENTIAL_OFFER_REQUEST event was fired - events.expect(EventType.VERIFIABLE_CREDENTIAL_OFFER_REQUEST) - .client(client.getClientId()) - .user(AssertEvents.isUUID()) - .session(AssertEvents.isSessionId()) - .detail(Details.USERNAME, "john") - .detail(Details.CREDENTIAL_TYPE, credentialConfigurationId) - .assertEvent(); - } + // Verify CREDENTIAL_OFFER_REQUEST event was fired + events.expect(EventType.VERIFIABLE_CREDENTIAL_OFFER_REQUEST) + .client(client.getClientId()) + .user(AssertEvents.isUUID()) + .session(AssertEvents.isSessionId()) + .detail(Details.USERNAME, "john") + .detail(Details.CREDENTIAL_TYPE, credentialConfigurationId) + .assertEvent(); // Clear events before credential offer request events.clear(); - HttpGet getCredentialOffer = new HttpGet(credentialOfferURI.getIssuer() + "/" + credentialOfferURI.getNonce()); - try (CloseableHttpResponse response = httpClient.execute(getCredentialOffer)) { - assertEquals(HttpStatus.SC_OK, response.getStatusLine().getStatusCode()); - String s = IOUtils.toString(response.getEntity().getContent(), StandardCharsets.UTF_8); - ctx.credentialsOffer = JsonSerialization.readValue(s, CredentialsOffer.class); + CredentialOfferResponse credentialOfferResponse = oauth.oid4vc().credentialOfferRequest() + .endpoint(credentialOfferURI.getIssuer() + "/" + credentialOfferURI.getNonce()) + .send(); + assertEquals(HttpStatus.SC_OK, credentialOfferResponse.getStatusCode()); + ctx.credentialsOffer = credentialOfferResponse.getCredentialsOffer(); - // Verify CREDENTIAL_OFFER_REQUEST event was fired (unauthenticated endpoint) - events.expect(EventType.VERIFIABLE_CREDENTIAL_OFFER_REQUEST) - .client(client.getClientId()) - .user(AssertEvents.isUUID()) - .session((String) null) - .detail(Details.CREDENTIAL_TYPE, credentialConfigurationId) - .assertEvent(); - } + // Verify CREDENTIAL_OFFER_REQUEST event was fired (unauthenticated endpoint) + events.expect(EventType.VERIFIABLE_CREDENTIAL_OFFER_REQUEST) + .client(client.getClientId()) + .user(AssertEvents.isUUID()) + .session((String) null) + .detail(Details.CREDENTIAL_TYPE, credentialConfigurationId) + .assertEvent(); - HttpGet getIssuerMetadata = new HttpGet(ctx.credentialsOffer.getIssuerMetadataUrl()); - try (CloseableHttpResponse response = httpClient.execute(getIssuerMetadata)) { - assertEquals(HttpStatus.SC_OK, response.getStatusLine().getStatusCode()); - String s = IOUtils.toString(response.getEntity().getContent(), StandardCharsets.UTF_8); - ctx.credentialIssuer = JsonSerialization.readValue(s, CredentialIssuer.class); - } + CredentialIssuerMetadataResponse issuerMetadataResponse = oauth.oid4vc().issuerMetadataRequest() + .endpoint(ctx.credentialsOffer.getIssuerMetadataUrl()) + .send(); + assertEquals(HttpStatus.SC_OK, issuerMetadataResponse.getStatusCode()); + ctx.credentialIssuer = issuerMetadataResponse.getMetadata(); - 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); - } + OpenIDProviderConfigurationResponse openIDProviderConfigurationResponse = oauth.wellknownRequest() + .url(ctx.credentialIssuer.getAuthorizationServers().get(0)) + .send(); + assertEquals(HttpStatus.SC_OK, openIDProviderConfigurationResponse.getStatusCode()); + ctx.openidConfig = openIDProviderConfigurationResponse.getOidcConfiguration(); return ctx; } @@ -186,33 +166,28 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue List authDetails = List.of(authDetail); String authDetailsJson = JsonSerialization.writeValueAsString(authDetails); - HttpPost postPreAuthorizedCode = new HttpPost(ctx.openidConfig.getTokenEndpoint()); - List parameters = new LinkedList<>(); - parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, PreAuthorizedCodeGrantTypeFactory.GRANT_TYPE)); - parameters.add(new BasicNameValuePair(PreAuthorizedCodeGrantTypeFactory.CODE_REQUEST_PARAM, ctx.credentialsOffer.getGrants().getPreAuthorizedCode().getPreAuthorizedCode())); - parameters.add(new BasicNameValuePair("authorization_details", authDetailsJson)); - UrlEncodedFormEntity formEntity = new UrlEncodedFormEntity(parameters, StandardCharsets.UTF_8); - postPreAuthorizedCode.setEntity(formEntity); + AccessTokenResponse tokenResponse = oauth.oid4vc() + .preAuthorizedCodeGrantRequest(ctx.credentialsOffer.getGrants().getPreAuthorizedCode().getPreAuthorizedCode()) + .endpoint(ctx.openidConfig.getTokenEndpoint()) + .addParameter("authorization_details", authDetailsJson) + .send(); - try (CloseableHttpResponse tokenResponse = httpClient.execute(postPreAuthorizedCode)) { - assertEquals(HttpStatus.SC_OK, tokenResponse.getStatusLine().getStatusCode()); - String responseBody = IOUtils.toString(tokenResponse.getEntity().getContent(), StandardCharsets.UTF_8); - List authDetailsResponse = parseAuthorizationDetails(responseBody); - assertNotNull("authorization_details should be present in the response", authDetailsResponse); - assertEquals(1, authDetailsResponse.size()); - OID4VCAuthorizationDetailResponse authDetailResponse = authDetailsResponse.get(0); - assertEquals(OPENID_CREDENTIAL, authDetailResponse.getType()); - assertEquals(getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID), authDetailResponse.getCredentialConfigurationId()); - assertNotNull(authDetailResponse.getCredentialIdentifiers()); - assertEquals(1, authDetailResponse.getCredentialIdentifiers().size()); - String firstIdentifier = authDetailResponse.getCredentialIdentifiers().get(0); - assertNotNull("Identifier should not be null", firstIdentifier); - assertFalse("Identifier should not be empty", firstIdentifier.isEmpty()); - try { - UUID.fromString(firstIdentifier); - } catch (IllegalArgumentException e) { - fail("Identifier should be a valid UUID, but was: " + firstIdentifier); - } + assertEquals(HttpStatus.SC_OK, tokenResponse.getStatusCode()); + List authDetailsResponse = tokenResponse.getOid4vcAuthorizationDetails(); + assertNotNull("authorization_details should be present in the response", authDetailsResponse); + assertEquals(1, authDetailsResponse.size()); + OID4VCAuthorizationDetailResponse authDetailResponse = authDetailsResponse.get(0); + assertEquals(OPENID_CREDENTIAL, authDetailResponse.getType()); + assertEquals(getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID), authDetailResponse.getCredentialConfigurationId()); + assertNotNull(authDetailResponse.getCredentialIdentifiers()); + assertEquals(1, authDetailResponse.getCredentialIdentifiers().size()); + String firstIdentifier = authDetailResponse.getCredentialIdentifiers().get(0); + assertNotNull("Identifier should not be null", firstIdentifier); + assertFalse("Identifier should not be empty", firstIdentifier.isEmpty()); + try { + UUID.fromString(firstIdentifier); + } catch (IllegalArgumentException e) { + fail("Identifier should be a valid UUID, but was: " + firstIdentifier); } } @@ -245,40 +220,36 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue List authDetails = List.of(authDetail); String authDetailsJson = JsonSerialization.writeValueAsString(authDetails); - HttpPost postPreAuthorizedCode = new HttpPost(ctx.openidConfig.getTokenEndpoint()); - List parameters = new LinkedList<>(); - parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, PreAuthorizedCodeGrantTypeFactory.GRANT_TYPE)); - parameters.add(new BasicNameValuePair(PreAuthorizedCodeGrantTypeFactory.CODE_REQUEST_PARAM, ctx.credentialsOffer.getGrants().getPreAuthorizedCode().getPreAuthorizedCode())); - parameters.add(new BasicNameValuePair("authorization_details", authDetailsJson)); - UrlEncodedFormEntity formEntity = new UrlEncodedFormEntity(parameters, StandardCharsets.UTF_8); - postPreAuthorizedCode.setEntity(formEntity); + AccessTokenResponse tokenResponse = oauth.oid4vc() + .preAuthorizedCodeGrantRequest(ctx.credentialsOffer.getGrants().getPreAuthorizedCode().getPreAuthorizedCode()) + .endpoint(ctx.openidConfig.getTokenEndpoint()) + .addParameter("authorization_details", authDetailsJson) + .send(); - try (CloseableHttpResponse tokenResponse = httpClient.execute(postPreAuthorizedCode)) { - assertEquals(HttpStatus.SC_OK, tokenResponse.getStatusLine().getStatusCode()); - String responseBody = IOUtils.toString(tokenResponse.getEntity().getContent(), StandardCharsets.UTF_8); - List authDetailsResponse = parseAuthorizationDetails(responseBody); - assertNotNull("authorization_details should be present in the response", authDetailsResponse); - assertEquals(1, authDetailsResponse.size()); - OID4VCAuthorizationDetailResponse authDetailResponse = authDetailsResponse.get(0); - assertEquals(OPENID_CREDENTIAL, authDetailResponse.getType()); - assertEquals(getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID), authDetailResponse.getCredentialConfigurationId()); - assertNotNull(authDetailResponse.getClaims()); - assertEquals(1, authDetailResponse.getClaims().size()); - ClaimsDescription responseClaim = authDetailResponse.getClaims().get(0); + assertEquals(HttpStatus.SC_OK, tokenResponse.getStatusCode()); + List authDetailsResponse = tokenResponse.getOid4vcAuthorizationDetails(); + assertNotNull("authorization_details should be present in the response", authDetailsResponse); + assertEquals(1, authDetailsResponse.size()); + OID4VCAuthorizationDetailResponse authDetailResponse = authDetailsResponse.get(0); + assertEquals(OPENID_CREDENTIAL, authDetailResponse.getType()); + assertEquals(getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID), authDetailResponse.getCredentialConfigurationId()); + assertNotNull(authDetailResponse.getClaims()); + assertEquals(1, authDetailResponse.getClaims().size()); + ClaimsDescription responseClaim = authDetailResponse.getClaims().get(0); - List expectedClaimPath; - if ("sd_jwt_vc".equals(getCredentialFormat())) { - expectedClaimPath = Arrays.asList(getExpectedClaimPath()); - } else { - expectedClaimPath = Arrays.asList("credentialSubject", getExpectedClaimPath()); - } - assertEquals(expectedClaimPath, responseClaim.getPath()); - assertTrue(responseClaim.isMandatory()); - - // Verify that credential identifiers are present - assertNotNull(authDetailResponse.getCredentialIdentifiers()); - assertEquals(1, authDetailResponse.getCredentialIdentifiers().size()); + List expectedClaimPath; + if ("sd_jwt_vc".equals(getCredentialFormat())) { + expectedClaimPath = Arrays.asList(getExpectedClaimPath()); + } else { + expectedClaimPath = Arrays.asList("credentialSubject", getExpectedClaimPath()); } + assertEquals(expectedClaimPath, responseClaim.getPath()); + assertTrue(responseClaim.isMandatory()); + + // Verify that credential identifiers are present + assertNotNull(authDetailResponse.getCredentialIdentifiers()); + assertEquals(1, authDetailResponse.getCredentialIdentifiers().size()); + } @Test @@ -300,18 +271,17 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue List authDetails = List.of(authDetail); String authDetailsJson = JsonSerialization.writeValueAsString(authDetails); - HttpPost postPreAuthorizedCode = new HttpPost(ctx.openidConfig.getTokenEndpoint()); - List parameters = new LinkedList<>(); - parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, PreAuthorizedCodeGrantTypeFactory.GRANT_TYPE)); - parameters.add(new BasicNameValuePair(PreAuthorizedCodeGrantTypeFactory.CODE_REQUEST_PARAM, ctx.credentialsOffer.getGrants().getPreAuthorizedCode().getPreAuthorizedCode())); - parameters.add(new BasicNameValuePair("authorization_details", authDetailsJson)); - UrlEncodedFormEntity formEntity = new UrlEncodedFormEntity(parameters, StandardCharsets.UTF_8); - postPreAuthorizedCode.setEntity(formEntity); + AccessTokenResponse tokenResponse = oauth.oid4vc() + .preAuthorizedCodeGrantRequest(ctx.credentialsOffer.getGrants().getPreAuthorizedCode().getPreAuthorizedCode()) + .endpoint(ctx.openidConfig.getTokenEndpoint()) + .addParameter("authorization_details", authDetailsJson) + .send(); - try (CloseableHttpResponse tokenResponse = httpClient.execute(postPreAuthorizedCode)) { - // Should fail because the claim is not supported by the credential configuration - assertInvalidAuthzDetailsError(tokenResponse, "Invalid authorization_details: Unsupported claim: [credentialSubject, unsupportedClaim]. This claim is not supported by the credential configuration."); - } + // Should fail because the claim is not supported by the credential configuration + assertEquals(HttpStatus.SC_BAD_REQUEST, tokenResponse.getStatusCode()); + assertTrue("Error message should indicate authorization_details processing error", + (tokenResponse.getErrorDescription() != null && tokenResponse.getErrorDescription().contains("Error when processing authorization_details")) || + (tokenResponse.getError() != null && tokenResponse.getError().contains("Error when processing authorization_details"))); } @Test @@ -333,17 +303,17 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue List authDetails = List.of(authDetail); String authDetailsJson = JsonSerialization.writeValueAsString(authDetails); - HttpPost postPreAuthorizedCode = new HttpPost(ctx.openidConfig.getTokenEndpoint()); - List parameters = new LinkedList<>(); - parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, PreAuthorizedCodeGrantTypeFactory.GRANT_TYPE)); - parameters.add(new BasicNameValuePair(PreAuthorizedCodeGrantTypeFactory.CODE_REQUEST_PARAM, ctx.credentialsOffer.getGrants().getPreAuthorizedCode().getPreAuthorizedCode())); - parameters.add(new BasicNameValuePair("authorization_details", authDetailsJson)); - UrlEncodedFormEntity formEntity = new UrlEncodedFormEntity(parameters, StandardCharsets.UTF_8); - postPreAuthorizedCode.setEntity(formEntity); + AccessTokenResponse tokenResponse = oauth.oid4vc() + .preAuthorizedCodeGrantRequest(ctx.credentialsOffer.getGrants().getPreAuthorizedCode().getPreAuthorizedCode()) + .endpoint(ctx.openidConfig.getTokenEndpoint()) + .addParameter("authorization_details", authDetailsJson) + .send(); - try (CloseableHttpResponse tokenResponse = httpClient.execute(postPreAuthorizedCode)) { - assertInvalidAuthzDetailsError(tokenResponse, "Invalid authorization_details: Unsupported claim: [credentialSubject, mandatoryClaim]. This claim is not supported by the credential configuration."); - } + // Should fail because the mandatory claim is not supported + assertEquals(HttpStatus.SC_BAD_REQUEST, tokenResponse.getStatusCode()); + assertTrue("Error message should indicate authorization_details processing error", + (tokenResponse.getErrorDescription() != null && tokenResponse.getErrorDescription().contains("Error when processing authorization_details")) || + (tokenResponse.getError() != null && tokenResponse.getError().contains("Error when processing authorization_details"))); } @Test @@ -365,27 +335,24 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue List authDetails = List.of(authDetail); String authDetailsJson = JsonSerialization.writeValueAsString(authDetails); - HttpPost postPreAuthorizedCode = new HttpPost(ctx.openidConfig.getTokenEndpoint()); - List parameters = new LinkedList<>(); - parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, PreAuthorizedCodeGrantTypeFactory.GRANT_TYPE)); - parameters.add(new BasicNameValuePair(PreAuthorizedCodeGrantTypeFactory.CODE_REQUEST_PARAM, ctx.credentialsOffer.getGrants().getPreAuthorizedCode().getPreAuthorizedCode())); - parameters.add(new BasicNameValuePair("authorization_details", authDetailsJson)); - UrlEncodedFormEntity formEntity = new UrlEncodedFormEntity(parameters, StandardCharsets.UTF_8); - postPreAuthorizedCode.setEntity(formEntity); + AccessTokenResponse tokenResponse = oauth.oid4vc() + .preAuthorizedCodeGrantRequest(ctx.credentialsOffer.getGrants().getPreAuthorizedCode().getPreAuthorizedCode()) + .endpoint(ctx.openidConfig.getTokenEndpoint()) + .addParameter("authorization_details", authDetailsJson) + .send(); - try (CloseableHttpResponse tokenResponse = httpClient.execute(postPreAuthorizedCode)) { - // Should fail if the complex path is not supported - int statusCode = tokenResponse.getStatusLine().getStatusCode(); - if (statusCode == HttpStatus.SC_BAD_REQUEST) { - assertInvalidAuthzDetailsError(tokenResponse, "Invalid authorization_details: Unsupported claim: [credentialSubject, address, street]. This claim is not supported by the credential configuration."); - } else { - // If it succeeds, verify the response structure - assertEquals(HttpStatus.SC_OK, statusCode); - String responseBody = IOUtils.toString(tokenResponse.getEntity().getContent(), StandardCharsets.UTF_8); - List authDetailsResponse = parseAuthorizationDetails(responseBody); - assertNotNull("authorization_details should be present in the response", authDetailsResponse); - assertEquals(1, authDetailsResponse.size()); - } + // Should fail if the complex path is not supported + int statusCode = tokenResponse.getStatusCode(); + if (statusCode == HttpStatus.SC_BAD_REQUEST) { + assertTrue("Error message should indicate authorization_details processing error", + (tokenResponse.getErrorDescription() != null && tokenResponse.getErrorDescription().contains("Error when processing authorization_details")) || + (tokenResponse.getError() != null && tokenResponse.getError().contains("Error when processing authorization_details"))); + } else { + // If it succeeds, verify the response structure + assertEquals(HttpStatus.SC_OK, statusCode); + List authDetailsResponse = tokenResponse.getOid4vcAuthorizationDetails(); + assertNotNull("authorization_details should be present in the response", authDetailsResponse); + assertEquals(1, authDetailsResponse.size()); } } @@ -402,31 +369,17 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue List authDetails = List.of(authDetail); String authDetailsJson = JsonSerialization.writeValueAsString(authDetails); - HttpPost postPreAuthorizedCode = new HttpPost(ctx.openidConfig.getTokenEndpoint()); - List parameters = new LinkedList<>(); - parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, PreAuthorizedCodeGrantTypeFactory.GRANT_TYPE)); - parameters.add(new BasicNameValuePair(PreAuthorizedCodeGrantTypeFactory.CODE_REQUEST_PARAM, ctx.credentialsOffer.getGrants().getPreAuthorizedCode().getPreAuthorizedCode())); - parameters.add(new BasicNameValuePair("authorization_details", authDetailsJson)); - UrlEncodedFormEntity formEntity = new UrlEncodedFormEntity(parameters, StandardCharsets.UTF_8); - postPreAuthorizedCode.setEntity(formEntity); + AccessTokenResponse tokenResponse = oauth.oid4vc() + .preAuthorizedCodeGrantRequest(ctx.credentialsOffer.getGrants().getPreAuthorizedCode().getPreAuthorizedCode()) + .endpoint(ctx.openidConfig.getTokenEndpoint()) + .addParameter("authorization_details", authDetailsJson) + .send(); - try (CloseableHttpResponse tokenResponse = httpClient.execute(postPreAuthorizedCode)) { - assertInvalidAuthzDetailsError(tokenResponse, "Invalid authorization_details: credential_configuration_id is required"); - } - } - - private void assertInvalidAuthzDetailsError(CloseableHttpResponse tokenResponse, String expectedErrorDescription) throws IOException { - events.expectCodeToToken(null, null) - .client(client.getClientId()) - .user(AssertEvents.isUUID()) - .clearDetails() - .detail(Details.REASON, expectedErrorDescription) - .error(Errors.INVALID_AUTHORIZATION_DETAILS) - .assertEvent(); - assertEquals(HttpStatus.SC_BAD_REQUEST, tokenResponse.getStatusLine().getStatusCode()); - OAuth2ErrorRepresentation errorRep = JsonSerialization.readValue(tokenResponse.getEntity().getContent(), OAuth2ErrorRepresentation.class); - assertEquals(Errors.INVALID_AUTHORIZATION_DETAILS, errorRep.getError()); - assertEquals(expectedErrorDescription, errorRep.getErrorDescription()); + assertEquals(HttpStatus.SC_BAD_REQUEST, tokenResponse.getStatusCode()); + assertEquals("invalid_authorization_details", tokenResponse.getError()); + assertTrue("Error description should indicate missing credential_configuration_id. Actual: " + tokenResponse.getErrorDescription(), + tokenResponse.getErrorDescription() != null && tokenResponse.getErrorDescription().contains("Invalid authorization_details") + && tokenResponse.getErrorDescription().contains("credential_configuration_id is required")); } @Test @@ -448,17 +401,17 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue List authDetails = List.of(authDetail); String authDetailsJson = JsonSerialization.writeValueAsString(authDetails); - HttpPost postPreAuthorizedCode = new HttpPost(ctx.openidConfig.getTokenEndpoint()); - List parameters = new LinkedList<>(); - parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, PreAuthorizedCodeGrantTypeFactory.GRANT_TYPE)); - parameters.add(new BasicNameValuePair(PreAuthorizedCodeGrantTypeFactory.CODE_REQUEST_PARAM, ctx.credentialsOffer.getGrants().getPreAuthorizedCode().getPreAuthorizedCode())); - parameters.add(new BasicNameValuePair("authorization_details", authDetailsJson)); - UrlEncodedFormEntity formEntity = new UrlEncodedFormEntity(parameters, StandardCharsets.UTF_8); - postPreAuthorizedCode.setEntity(formEntity); + AccessTokenResponse tokenResponse = oauth.oid4vc() + .preAuthorizedCodeGrantRequest(ctx.credentialsOffer.getGrants().getPreAuthorizedCode().getPreAuthorizedCode()) + .endpoint(ctx.openidConfig.getTokenEndpoint()) + .addParameter("authorization_details", authDetailsJson) + .send(); - try (CloseableHttpResponse tokenResponse = httpClient.execute(postPreAuthorizedCode)) { - assertInvalidAuthzDetailsError(tokenResponse, "Invalid authorization_details: Invalid claims description: path is required"); - } + assertEquals(HttpStatus.SC_BAD_REQUEST, tokenResponse.getStatusCode()); + assertEquals("invalid_authorization_details", tokenResponse.getError()); + assertTrue("Error description should indicate invalid claims path. Actual: " + tokenResponse.getErrorDescription(), + tokenResponse.getErrorDescription() != null && tokenResponse.getErrorDescription().contains("Invalid authorization_details") + && tokenResponse.getErrorDescription().contains("path is required")); } @Test @@ -466,24 +419,18 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue String token = getBearerToken(oauth, client, getCredentialClientScope().getName()); Oid4vcTestContext ctx = prepareOid4vcTestContext(token); - // Send empty authorization_details array - should work the same way like request without "authorization_details" parameter tested in testPreAuthorizedCodeWithCredentialOfferBasedAuthorizationDetails - // There is no processor available for empty authorization_details and hence considered as missing "authorization_details" + // Send empty authorization_details array - should fail String authDetailsJson = "[]"; - HttpPost postPreAuthorizedCode = new HttpPost(ctx.openidConfig.getTokenEndpoint()); - List parameters = new LinkedList<>(); - parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, PreAuthorizedCodeGrantTypeFactory.GRANT_TYPE)); - parameters.add(new BasicNameValuePair(PreAuthorizedCodeGrantTypeFactory.CODE_REQUEST_PARAM, ctx.credentialsOffer.getGrants().getPreAuthorizedCode().getPreAuthorizedCode())); - parameters.add(new BasicNameValuePair("authorization_details", authDetailsJson)); - UrlEncodedFormEntity formEntity = new UrlEncodedFormEntity(parameters, StandardCharsets.UTF_8); - postPreAuthorizedCode.setEntity(formEntity); + AccessTokenResponse tokenResponse = oauth.oid4vc() + .preAuthorizedCodeGrantRequest(ctx.credentialsOffer.getGrants().getPreAuthorizedCode().getPreAuthorizedCode()) + .endpoint(ctx.openidConfig.getTokenEndpoint()) + .addParameter("authorization_details", authDetailsJson) + .send(); - try (CloseableHttpResponse tokenResponse = httpClient.execute(postPreAuthorizedCode)) { - assertEquals(HttpStatus.SC_OK, tokenResponse.getStatusLine().getStatusCode()); - String responseBody = IOUtils.toString(tokenResponse.getEntity().getContent(), StandardCharsets.UTF_8); - List authDetailsResponse = parseAuthorizationDetails(responseBody); - assertNotNull("authorization_details should be present in the response", authDetailsResponse); - } + assertEquals(HttpStatus.SC_BAD_REQUEST, tokenResponse.getStatusCode()); + assertEquals("invalid_authorization_details", tokenResponse.getError()); + assertNotNull("Error description should be present", tokenResponse.getErrorDescription()); } @Test @@ -494,42 +441,36 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue // Test Pre-Authorized Code Flow without authorization_details parameter // The system should generate authorization_details based on credential_configuration_ids from the credential offer - HttpPost postPreAuthorizedCode = new HttpPost(ctx.openidConfig.getTokenEndpoint()); - List parameters = new LinkedList<>(); - parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, PreAuthorizedCodeGrantTypeFactory.GRANT_TYPE)); - parameters.add(new BasicNameValuePair(PreAuthorizedCodeGrantTypeFactory.CODE_REQUEST_PARAM, ctx.credentialsOffer.getGrants().getPreAuthorizedCode().getPreAuthorizedCode())); + AccessTokenResponse tokenResponse = oauth.oid4vc() + .preAuthorizedCodeGrantRequest(ctx.credentialsOffer.getGrants().getPreAuthorizedCode().getPreAuthorizedCode()) + .endpoint(ctx.openidConfig.getTokenEndpoint()) + .send(); - UrlEncodedFormEntity formEntity = new UrlEncodedFormEntity(parameters, StandardCharsets.UTF_8); - postPreAuthorizedCode.setEntity(formEntity); + assertEquals(HttpStatus.SC_OK, tokenResponse.getStatusCode()); - try (CloseableHttpResponse tokenResponse = httpClient.execute(postPreAuthorizedCode)) { - assertEquals(HttpStatus.SC_OK, tokenResponse.getStatusLine().getStatusCode()); - String responseBody = IOUtils.toString(tokenResponse.getEntity().getContent(), StandardCharsets.UTF_8); + List authDetailsResponse = tokenResponse.getOid4vcAuthorizationDetails(); + assertNotNull("authorization_details should be present in the response", authDetailsResponse); + assertEquals("Should have authorization_details for each credential configuration in the offer", + ctx.credentialsOffer.getCredentialConfigurationIds().size(), authDetailsResponse.size()); - List authDetailsResponse = parseAuthorizationDetails(responseBody); - assertNotNull("authorization_details should be present in the response", authDetailsResponse); - assertEquals("Should have authorization_details for each credential configuration in the offer", - ctx.credentialsOffer.getCredentialConfigurationIds().size(), authDetailsResponse.size()); + // Verify each credential configuration from the offer has corresponding authorization_details + for (int i = 0; i < ctx.credentialsOffer.getCredentialConfigurationIds().size(); i++) { + String expectedConfigId = ctx.credentialsOffer.getCredentialConfigurationIds().get(i); + OID4VCAuthorizationDetailResponse authDetailResponse = authDetailsResponse.get(i); - // Verify each credential configuration from the offer has corresponding authorization_details - for (int i = 0; i < ctx.credentialsOffer.getCredentialConfigurationIds().size(); i++) { - String expectedConfigId = ctx.credentialsOffer.getCredentialConfigurationIds().get(i); - OID4VCAuthorizationDetailResponse authDetailResponse = authDetailsResponse.get(i); + assertEquals(OPENID_CREDENTIAL, authDetailResponse.getType()); + assertEquals("Credential configuration ID should match the one from the offer", + expectedConfigId, authDetailResponse.getCredentialConfigurationId()); + assertNotNull("Credential identifiers should be present", authDetailResponse.getCredentialIdentifiers()); + assertEquals("Should have exactly one credential identifier", 1, authDetailResponse.getCredentialIdentifiers().size()); - assertEquals(OPENID_CREDENTIAL, authDetailResponse.getType()); - assertEquals("Credential configuration ID should match the one from the offer", - expectedConfigId, authDetailResponse.getCredentialConfigurationId()); - 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("Identifier should not be null", credentialIdentifier); - assertFalse("Identifier should not be empty", credentialIdentifier.isEmpty()); - try { - UUID.fromString(credentialIdentifier); - } catch (IllegalArgumentException e) { - fail("Identifier should be a valid UUID, but was: " + credentialIdentifier); - } + String credentialIdentifier = authDetailResponse.getCredentialIdentifiers().get(0); + assertNotNull("Identifier should not be null", credentialIdentifier); + assertFalse("Identifier should not be empty", credentialIdentifier.isEmpty()); + try { + UUID.fromString(credentialIdentifier); + } catch (IllegalArgumentException e) { + fail("Identifier should be a valid UUID, but was: " + credentialIdentifier); } } } @@ -542,37 +483,33 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue PreAuthorizedCode preAuthorizedCode = ctx.credentialsOffer.getGrants().getPreAuthorizedCode(); // Step 1: Request token without authorization_details parameter (no scope needed) - HttpPost postPreAuthorizedCode = new HttpPost(ctx.openidConfig.getTokenEndpoint()); - List parameters = new LinkedList<>(); - parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, PreAuthorizedCodeGrantTypeFactory.GRANT_TYPE)); - parameters.add(new BasicNameValuePair(PreAuthorizedCodeGrantTypeFactory.CODE_REQUEST_PARAM, preAuthorizedCode.getPreAuthorizedCode())); - - UrlEncodedFormEntity formEntity = new UrlEncodedFormEntity(parameters, StandardCharsets.UTF_8); - postPreAuthorizedCode.setEntity(formEntity); + AccessTokenResponse tokenResponse = oauth.oid4vc() + .preAuthorizedCodeGrantRequest(preAuthorizedCode.getPreAuthorizedCode()) + .endpoint(ctx.openidConfig.getTokenEndpoint()) + .send(); String credentialIdentifier; String credentialConfigurationId; OID4VCAuthorizationDetailResponse authDetailResponse; - try (CloseableHttpResponse tokenResponse = httpClient.execute(postPreAuthorizedCode)) { - assertEquals(HttpStatus.SC_OK, tokenResponse.getStatusLine().getStatusCode()); - String responseBody = IOUtils.toString(tokenResponse.getEntity().getContent(), StandardCharsets.UTF_8); - List authDetailsResponse = parseAuthorizationDetails(responseBody); - assertNotNull("authorization_details should be present in the response", authDetailsResponse); - assertEquals("Should have authorization_details for each credential configuration in the offer", - ctx.credentialsOffer.getCredentialConfigurationIds().size(), authDetailsResponse.size()); + assertEquals(HttpStatus.SC_OK, tokenResponse.getStatusCode()); - // Use the first authorization detail for credential request - authDetailResponse = authDetailsResponse.get(0); - assertNotNull("Credential identifiers should be present", authDetailResponse.getCredentialIdentifiers()); - assertEquals(1, authDetailResponse.getCredentialIdentifiers().size()); + List authDetailsResponse = tokenResponse.getOid4vcAuthorizationDetails(); + assertNotNull("authorization_details should be present in the response", authDetailsResponse); + assertEquals("Should have authorization_details for each credential configuration in the offer", + ctx.credentialsOffer.getCredentialConfigurationIds().size(), authDetailsResponse.size()); - credentialIdentifier = authDetailResponse.getCredentialIdentifiers().get(0); - assertNotNull("Credential identifier should not be null", credentialIdentifier); + // Use the first authorization detail for credential request + authDetailResponse = authDetailsResponse.get(0); + assertNotNull("Credential identifiers should be present", authDetailResponse.getCredentialIdentifiers()); + assertEquals(1, authDetailResponse.getCredentialIdentifiers().size()); + + credentialIdentifier = authDetailResponse.getCredentialIdentifiers().get(0); + assertNotNull("Credential identifier should not be null", credentialIdentifier); + + credentialConfigurationId = authDetailResponse.getCredentialConfigurationId(); + assertNotNull("Credential configuration id should not be null", credentialConfigurationId); - credentialConfigurationId = authDetailResponse.getCredentialConfigurationId(); - assertNotNull("Credential configuration id should not be null", credentialConfigurationId); - } // Step 2: Request the actual credential using ONLY the identifier (no credential_configuration_id) // This tests that the mapping from credential identifier to credential configuration ID works as expected. @@ -587,95 +524,82 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue // Clear events before credential request events.clear(); - HttpPost postCredential = new HttpPost(ctx.credentialIssuer.getCredentialEndpoint()); - postCredential.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token); - postCredential.addHeader(HttpHeaders.CONTENT_TYPE, "application/json"); + Oid4vcCredentialResponse credentialResponse = oauth.oid4vc().credentialRequest() + .endpoint(ctx.credentialIssuer.getCredentialEndpoint()) + .bearerToken(token) + .credentialIdentifier(credentialIdentifier) + .send(); - CredentialRequest credentialRequest = new CredentialRequest(); - credentialRequest.setCredentialIdentifier(credentialIdentifier); + assertEquals(HttpStatus.SC_OK, credentialResponse.getStatusCode()); - String requestBody = JsonSerialization.writeValueAsString(credentialRequest); - postCredential.setEntity(new StringEntity(requestBody, StandardCharsets.UTF_8)); + // Verify CREDENTIAL_REQUEST event was fired + events.expect(EventType.VERIFIABLE_CREDENTIAL_REQUEST) + .client(client.getClientId()) + .user(AssertEvents.isUUID()) + .session(AssertEvents.isSessionId()) + .detail(Details.USERNAME, "john") + .detail(Details.CREDENTIAL_TYPE, credentialConfigurationId) + .assertEvent(); - try (CloseableHttpResponse credentialResponse = httpClient.execute(postCredential)) { - assertEquals(HttpStatus.SC_OK, credentialResponse.getStatusLine().getStatusCode()); + // Parse the credential response + CredentialResponse parsedResponse = credentialResponse.getCredentialResponse(); + 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 CREDENTIAL_REQUEST event was fired - events.expect(EventType.VERIFIABLE_CREDENTIAL_REQUEST) - .client(client.getClientId()) - .user(AssertEvents.isUUID()) - .session(AssertEvents.isSessionId()) - .detail(Details.USERNAME, "john") - .detail(Details.CREDENTIAL_TYPE, credentialConfigurationId) - .assertEvent(); - String responseBody = IOUtils.toString(credentialResponse.getEntity().getContent(), StandardCharsets.UTF_8); + // Step 3: Verify that the issued credential structure is valid + CredentialResponse.Credential credentialWrapper = parsedResponse.getCredentials().get(0); + assertNotNull("Credential wrapper should not be null", credentialWrapper); - // 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()); + // The credential is stored as Object, so we need to cast it + Object credentialObj = credentialWrapper.getCredential(); + assertNotNull("Credential object should not be null", credentialObj); - // Step 3: Verify that the issued credential structure is valid - CredentialResponse.Credential credentialWrapper = parsedResponse.getCredentials().get(0); - assertNotNull("Credential wrapper should not be null", credentialWrapper); - - // The credential is stored as Object, so we need to cast it - Object credentialObj = credentialWrapper.getCredential(); - assertNotNull("Credential object should not be null", credentialObj); - - // Verify the credential structure based on format - verifyCredentialStructure(credentialObj); - } + // Verify the credential structure based on format + verifyCredentialStructure(credentialObj); } + // Step 3: Request a credential using the credentialConfigurationId // { // Clear events before credential request events.clear(); - HttpPost postCredential = new HttpPost(ctx.credentialIssuer.getCredentialEndpoint()); - postCredential.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token); - postCredential.addHeader(HttpHeaders.CONTENT_TYPE, "application/json"); + Oid4vcCredentialResponse credentialResponse = oauth.oid4vc().credentialRequest() + .endpoint(ctx.credentialIssuer.getCredentialEndpoint()) + .bearerToken(token) + .credentialConfigurationId(credentialConfigurationId) + .send(); - CredentialRequest credentialRequest = new CredentialRequest(); - credentialRequest.setCredentialConfigurationId(credentialConfigurationId); + assertEquals(HttpStatus.SC_OK, credentialResponse.getStatusCode()); - String requestBody = JsonSerialization.writeValueAsString(credentialRequest); - postCredential.setEntity(new StringEntity(requestBody, StandardCharsets.UTF_8)); + // Verify CREDENTIAL_REQUEST event was fired + events.expect(EventType.VERIFIABLE_CREDENTIAL_REQUEST) + .client(client.getClientId()) + .user(AssertEvents.isUUID()) + .session(AssertEvents.isSessionId()) + .detail(Details.USERNAME, "john") + .detail(Details.CREDENTIAL_TYPE, credentialConfigurationId) + .assertEvent(); + assertEquals(HttpStatus.SC_OK, credentialResponse.getStatusCode()); - try (CloseableHttpResponse credentialResponse = httpClient.execute(postCredential)) { - assertEquals(HttpStatus.SC_OK, credentialResponse.getStatusLine().getStatusCode()); + // Parse the credential response + CredentialResponse parsedResponse = credentialResponse.getCredentialResponse(); + 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 CREDENTIAL_REQUEST event was fired - events.expect(EventType.VERIFIABLE_CREDENTIAL_REQUEST) - .client(client.getClientId()) - .user(AssertEvents.isUUID()) - .session(AssertEvents.isSessionId()) - .detail(Details.USERNAME, "john") - .detail(Details.CREDENTIAL_TYPE, credentialConfigurationId) - .assertEvent(); - assertEquals(HttpStatus.SC_OK, credentialResponse.getStatusLine().getStatusCode()); - String responseBody = IOUtils.toString(credentialResponse.getEntity().getContent(), StandardCharsets.UTF_8); + // Step 3: Verify that the issued credential structure is valid + CredentialResponse.Credential credentialWrapper = parsedResponse.getCredentials().get(0); + assertNotNull("Credential wrapper should not be null", credentialWrapper); - // 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()); + // The credential is stored as Object, so we need to cast it + Object credentialObj = credentialWrapper.getCredential(); + assertNotNull("Credential object should not be null", credentialObj); - // Step 3: Verify that the issued credential structure is valid - CredentialResponse.Credential credentialWrapper = parsedResponse.getCredentials().get(0); - assertNotNull("Credential wrapper should not be null", credentialWrapper); - - // The credential is stored as Object, so we need to cast it - Object credentialObj = credentialWrapper.getCredential(); - assertNotNull("Credential object should not be null", credentialObj); - - // Verify the credential structure based on format - verifyCredentialStructure(credentialObj); - } + // Verify the credential structure based on format + verifyCredentialStructure(credentialObj); } } @@ -690,56 +614,50 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue !ctx.credentialsOffer.getCredentialConfigurationIds().isEmpty()); // Step 1: Request token without authorization_details parameter - HttpPost postPreAuthorizedCode = new HttpPost(ctx.openidConfig.getTokenEndpoint()); - List parameters = new LinkedList<>(); - parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, PreAuthorizedCodeGrantTypeFactory.GRANT_TYPE)); - parameters.add(new BasicNameValuePair(PreAuthorizedCodeGrantTypeFactory.CODE_REQUEST_PARAM, ctx.credentialsOffer.getGrants().getPreAuthorizedCode().getPreAuthorizedCode())); + AccessTokenResponse tokenResponse = oauth.oid4vc() + .preAuthorizedCodeGrantRequest(ctx.credentialsOffer.getGrants().getPreAuthorizedCode().getPreAuthorizedCode()) + .endpoint(ctx.openidConfig.getTokenEndpoint()) + .send(); - UrlEncodedFormEntity formEntity = new UrlEncodedFormEntity(parameters, StandardCharsets.UTF_8); - postPreAuthorizedCode.setEntity(formEntity); + assertEquals(HttpStatus.SC_OK, tokenResponse.getStatusCode()); - try (CloseableHttpResponse tokenResponse = httpClient.execute(postPreAuthorizedCode)) { - assertEquals(HttpStatus.SC_OK, tokenResponse.getStatusLine().getStatusCode()); - String responseBody = IOUtils.toString(tokenResponse.getEntity().getContent(), StandardCharsets.UTF_8); + List authDetailsResponse = tokenResponse.getOid4vcAuthorizationDetails(); + assertNotNull("authorization_details should be present in the response", authDetailsResponse); - List authDetailsResponse = parseAuthorizationDetails(responseBody); - assertNotNull("authorization_details should be present in the response", authDetailsResponse); + // Verify that we have authorization_details for each credential configuration in the offer + assertEquals("Should have authorization_details for each credential configuration in the offer", + ctx.credentialsOffer.getCredentialConfigurationIds().size(), authDetailsResponse.size()); - // Verify that we have authorization_details for each credential configuration in the offer - assertEquals("Should have authorization_details for each credential configuration in the offer", - ctx.credentialsOffer.getCredentialConfigurationIds().size(), authDetailsResponse.size()); + // Verify each authorization detail + for (int i = 0; i < authDetailsResponse.size(); i++) { + OID4VCAuthorizationDetailResponse authDetail = authDetailsResponse.get(i); + String expectedConfigId = ctx.credentialsOffer.getCredentialConfigurationIds().get(i); - // Verify each authorization detail - for (int i = 0; i < authDetailsResponse.size(); i++) { - OID4VCAuthorizationDetailResponse authDetail = authDetailsResponse.get(i); - String expectedConfigId = ctx.credentialsOffer.getCredentialConfigurationIds().get(i); + // Verify structure + assertEquals("Type should be openid_credential", OPENID_CREDENTIAL, authDetail.getType()); + assertEquals("Credential configuration ID should match the one from the offer", + expectedConfigId, authDetail.getCredentialConfigurationId()); + assertNotNull("Credential identifiers should be present", authDetail.getCredentialIdentifiers()); + assertEquals("Should have exactly one credential identifier per configuration", + 1, authDetail.getCredentialIdentifiers().size()); - // Verify structure - assertEquals("Type should be openid_credential", OPENID_CREDENTIAL, authDetail.getType()); - assertEquals("Credential configuration ID should match the one from the offer", - expectedConfigId, authDetail.getCredentialConfigurationId()); - assertNotNull("Credential identifiers should be present", authDetail.getCredentialIdentifiers()); - assertEquals("Should have exactly one credential identifier per configuration", - 1, authDetail.getCredentialIdentifiers().size()); - - // Verify identifier format - String credentialIdentifier = authDetail.getCredentialIdentifiers().get(0); - assertNotNull("Credential identifier should not be null", credentialIdentifier); - assertFalse("Credential identifier should not be empty", credentialIdentifier.isEmpty()); - try { - UUID.fromString(credentialIdentifier); - } catch (IllegalArgumentException e) { - fail("Credential identifier should be a valid UUID, but was: " + credentialIdentifier); - } + // Verify identifier format + String credentialIdentifier = authDetail.getCredentialIdentifiers().get(0); + assertNotNull("Credential identifier should not be null", credentialIdentifier); + assertFalse("Credential identifier should not be empty", credentialIdentifier.isEmpty()); + try { + UUID.fromString(credentialIdentifier); + } catch (IllegalArgumentException e) { + fail("Credential identifier should be a valid UUID, but was: " + credentialIdentifier); } - - // Verify that all credential identifiers are unique - Set allIdentifiers = authDetailsResponse.stream() - .flatMap(auth -> auth.getCredentialIdentifiers().stream()) - .collect(Collectors.toSet()); - assertEquals("All credential identifiers should be unique", - authDetailsResponse.size(), allIdentifiers.size()); } + + // Verify that all credential identifiers are unique + Set allIdentifiers = authDetailsResponse.stream() + .flatMap(auth -> auth.getCredentialIdentifiers().stream()) + .collect(Collectors.toSet()); + assertEquals("All credential identifiers should be unique", + authDetailsResponse.size(), allIdentifiers.size()); } @Test @@ -771,79 +689,68 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue List authDetails = List.of(authDetail); String authDetailsJson = JsonSerialization.valueAsString(authDetails); - HttpPost postPreAuthorizedCode = new HttpPost(ctx.openidConfig.getTokenEndpoint()); - List parameters = new LinkedList<>(); - parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, PreAuthorizedCodeGrantTypeFactory.GRANT_TYPE)); - parameters.add(new BasicNameValuePair(PreAuthorizedCodeGrantTypeFactory.CODE_REQUEST_PARAM, ctx.credentialsOffer.getGrants().getPreAuthorizedCode().getPreAuthorizedCode())); - parameters.add(new BasicNameValuePair("authorization_details", authDetailsJson)); - UrlEncodedFormEntity formEntity = new UrlEncodedFormEntity(parameters, StandardCharsets.UTF_8); - postPreAuthorizedCode.setEntity(formEntity); + AccessTokenResponse tokenResponse = oauth.oid4vc() + .preAuthorizedCodeGrantRequest(ctx.credentialsOffer.getGrants().getPreAuthorizedCode().getPreAuthorizedCode()) + .endpoint(ctx.openidConfig.getTokenEndpoint()) + .addParameter("authorization_details", authDetailsJson) + .send(); String credentialIdentifier; String credentialConfigurationId; OID4VCAuthorizationDetailResponse authDetailResponse; - try (CloseableHttpResponse tokenResponse = httpClient.execute(postPreAuthorizedCode)) { - assertEquals(HttpStatus.SC_OK, tokenResponse.getStatusLine().getStatusCode()); - String responseBody = IOUtils.toString(tokenResponse.getEntity().getContent(), StandardCharsets.UTF_8); - List authDetailsResponse = parseAuthorizationDetails(responseBody); - assertNotNull("authorization_details should be present in the response", authDetailsResponse); - assertEquals(1, authDetailsResponse.size()); - authDetailResponse = authDetailsResponse.get(0); - assertNotNull("Credential identifiers should be present", authDetailResponse.getCredentialIdentifiers()); - assertEquals(1, authDetailResponse.getCredentialIdentifiers().size()); + assertEquals(HttpStatus.SC_OK, tokenResponse.getStatusCode()); + List authDetailsResponse = tokenResponse.getOid4vcAuthorizationDetails(); + assertNotNull("authorization_details should be present in the response", authDetailsResponse); + assertEquals(1, authDetailsResponse.size()); - credentialIdentifier = authDetailResponse.getCredentialIdentifiers().get(0); - assertNotNull("Credential identifier should not be null", credentialIdentifier); + authDetailResponse = authDetailsResponse.get(0); + assertNotNull("Credential identifiers should be present", authDetailResponse.getCredentialIdentifiers()); + assertEquals(1, authDetailResponse.getCredentialIdentifiers().size()); - credentialConfigurationId = authDetailResponse.getCredentialConfigurationId(); - assertNotNull("Credential configuration id should not be null", credentialConfigurationId); - } + credentialIdentifier = authDetailResponse.getCredentialIdentifiers().get(0); + assertNotNull("Credential identifier should not be null", credentialIdentifier); + + credentialConfigurationId = authDetailResponse.getCredentialConfigurationId(); + assertNotNull("Credential configuration id should not be null", credentialConfigurationId); // Step 2: Request the actual credential using the identifier and config id // Clear events before credential request events.clear(); - HttpPost postCredential = new HttpPost(ctx.credentialIssuer.getCredentialEndpoint()); - postCredential.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token); - postCredential.addHeader(HttpHeaders.CONTENT_TYPE, "application/json"); + Oid4vcCredentialResponse credentialResponse = oauth.oid4vc().credentialRequest() + .endpoint(ctx.credentialIssuer.getCredentialEndpoint()) + .bearerToken(token) + .credentialIdentifier(credentialIdentifier) + .send(); - CredentialRequest credentialRequest = new CredentialRequest(); - credentialRequest.setCredentialIdentifier(credentialIdentifier); + assertEquals(HttpStatus.SC_OK, credentialResponse.getStatusCode()); - String requestBody = JsonSerialization.writeValueAsString(credentialRequest); - postCredential.setEntity(new StringEntity(requestBody, StandardCharsets.UTF_8)); + // Verify CREDENTIAL_REQUEST event was fired + events.expect(EventType.VERIFIABLE_CREDENTIAL_REQUEST) + .client(client.getClientId()) + .user(AssertEvents.isUUID()) + .session(AssertEvents.isSessionId()) + .detail(Details.USERNAME, "john") + .detail(Details.CREDENTIAL_TYPE, credentialConfigurationId) + .assertEvent(); - try (CloseableHttpResponse credentialResponse = httpClient.execute(postCredential)) { - assertEquals(HttpStatus.SC_OK, credentialResponse.getStatusLine().getStatusCode()); + // Parse the credential response + CredentialResponse parsedResponse = credentialResponse.getCredentialResponse(); + 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 CREDENTIAL_REQUEST event was fired - events.expect(EventType.VERIFIABLE_CREDENTIAL_REQUEST) - .client(client.getClientId()) - .user(AssertEvents.isUUID()) - .session(AssertEvents.isSessionId()) - .detail(Details.USERNAME, "john") - .detail(Details.CREDENTIAL_TYPE, credentialConfigurationId) - .assertEvent(); - String responseBody = IOUtils.toString(credentialResponse.getEntity().getContent(), StandardCharsets.UTF_8); + // Step 3: Verify that the issued credential contains the requested claims AND may contain additional claims + CredentialResponse.Credential credentialWrapper = parsedResponse.getCredentials().get(0); + assertNotNull("Credential wrapper should not be null", credentialWrapper); - // 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()); + // The credential is stored as Object, so we need to cast it + Object credentialObj = credentialWrapper.getCredential(); + assertNotNull("Credential object should not be null", credentialObj); - // Step 3: Verify that the issued credential contains the requested claims AND may contain additional claims - CredentialResponse.Credential credentialWrapper = parsedResponse.getCredentials().get(0); - assertNotNull("Credential wrapper should not be null", credentialWrapper); - - // The credential is stored as Object, so we need to cast it - Object credentialObj = credentialWrapper.getCredential(); - assertNotNull("Credential object should not be null", credentialObj); - - // Verify the credential structure based on format - verifyCredentialStructure(credentialObj); - } + // Verify the credential structure based on format + verifyCredentialStructure(credentialObj); } @Test @@ -854,24 +761,23 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue events.clear(); // Request credential with empty payload - HttpPost postCredential = new HttpPost(ctx.credentialIssuer.getCredentialEndpoint()); - postCredential.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token); - postCredential.addHeader(HttpHeaders.CONTENT_TYPE, "application/json"); - postCredential.setEntity(new StringEntity("", StandardCharsets.UTF_8)); + Oid4vcCredentialResponse credentialResponse = oauth.oid4vc().credentialRequest() + .endpoint(ctx.credentialIssuer.getCredentialEndpoint()) + .bearerToken(token) + .emptyBody() + .send(); - try (CloseableHttpResponse credentialResponse = httpClient.execute(postCredential)) { - assertEquals(HttpStatus.SC_BAD_REQUEST, credentialResponse.getStatusLine().getStatusCode()); + assertEquals(HttpStatus.SC_BAD_REQUEST, credentialResponse.getStatusCode()); - // Verify VERIFIABLE_CREDENTIAL_REQUEST_ERROR event was fired - // Note: When payload is empty, error is thrown before authentication, so user/session are null - events.expect(EventType.VERIFIABLE_CREDENTIAL_REQUEST_ERROR) - .client((String) null) - .user((String) null) - .session((String) null) - .error(Errors.INVALID_REQUEST) - .detail(Details.REASON, "Request payload is null or empty.") - .assertEvent(); - } + // Verify VERIFIABLE_CREDENTIAL_REQUEST_ERROR event was fired + // Note: When payload is empty, error is thrown before authentication, so user/session are null + events.expect(EventType.VERIFIABLE_CREDENTIAL_REQUEST_ERROR) + .client((String) null) + .user((String) null) + .session((String) null) + .error(Errors.INVALID_REQUEST) + .detail(Details.REASON, "Request payload is null or empty.") + .assertEvent(); } @Test @@ -882,27 +788,21 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue events.clear(); // Request credential with invalid credential identifier - HttpPost postCredential = new HttpPost(ctx.credentialIssuer.getCredentialEndpoint()); - postCredential.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token); - postCredential.addHeader(HttpHeaders.CONTENT_TYPE, "application/json"); + Oid4vcCredentialResponse credentialResponse = oauth.oid4vc().credentialRequest() + .endpoint(ctx.credentialIssuer.getCredentialEndpoint()) + .bearerToken(token) + .credentialIdentifier("invalid-credential-identifier") + .send(); - CredentialRequest credentialRequest = new CredentialRequest(); - credentialRequest.setCredentialIdentifier("invalid-credential-identifier"); + assertEquals(HttpStatus.SC_BAD_REQUEST, credentialResponse.getStatusCode()); - String requestBody = JsonSerialization.writeValueAsString(credentialRequest); - postCredential.setEntity(new StringEntity(requestBody, StandardCharsets.UTF_8)); - - try (CloseableHttpResponse credentialResponse = httpClient.execute(postCredential)) { - assertEquals(HttpStatus.SC_BAD_REQUEST, credentialResponse.getStatusLine().getStatusCode()); - - // Verify VERIFIABLE_CREDENTIAL_REQUEST_ERROR event was fired - events.expect(EventType.VERIFIABLE_CREDENTIAL_REQUEST_ERROR) - .client(client.getClientId()) - .user(AssertEvents.isUUID()) - .session(AssertEvents.isSessionId()) - .error(Errors.INVALID_REQUEST) - .assertEvent(); - } + // Verify VERIFIABLE_CREDENTIAL_REQUEST_ERROR event was fired + events.expect(EventType.VERIFIABLE_CREDENTIAL_REQUEST_ERROR) + .client(client.getClientId()) + .user(AssertEvents.isUUID()) + .session(AssertEvents.isSessionId()) + .error(Errors.INVALID_REQUEST) + .assertEvent(); } /** @@ -914,25 +814,4 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue 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); - } - } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCCredentialOfferCorsTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCCredentialOfferCorsTest.java index 44980f997b2..39e3c4187a5 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCCredentialOfferCorsTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCCredentialOfferCorsTest.java @@ -42,8 +42,10 @@ import org.keycloak.services.cors.Cors; import org.keycloak.testsuite.AssertEvents; import org.keycloak.testsuite.arquillian.annotation.EnableFeature; import org.keycloak.testsuite.util.TokenUtil; +import org.keycloak.testsuite.util.oauth.AbstractHttpResponse; import org.keycloak.testsuite.util.oauth.AccessTokenResponse; -import org.keycloak.util.JsonSerialization; +import org.keycloak.testsuite.util.oauth.oid4vc.CredentialOfferResponse; +import org.keycloak.testsuite.util.oauth.oid4vc.CredentialOfferUriResponse; import org.apache.http.Header; import org.apache.http.HttpStatus; @@ -106,24 +108,28 @@ public class OID4VCCredentialOfferCorsTest extends OID4VCIssuerEndpointTest { // Test credential offer URI endpoint with valid origin String offerUriUrl = getCredentialOfferUriUrl(); - try (CloseableHttpResponse response = makeCorsRequest(offerUriUrl, VALID_CORS_URL, tokenResponse.getAccessToken())) { - assertEquals(HttpStatus.SC_OK, response.getStatusLine().getStatusCode()); - assertCorsHeaders(response, VALID_CORS_URL); + CredentialOfferUriResponse response = oauth.oid4vc() + .credentialOfferUriRequest() + .endpoint(offerUriUrl) + .bearerToken(tokenResponse.getAccessToken()) + .header("Origin", VALID_CORS_URL) + .send(); - // Verify response content - String responseBody = getResponseBody(response); - CredentialOfferURI offerUri = JsonSerialization.readValue(responseBody, CredentialOfferURI.class); - assertNotNull("Credential offer URI should not be null", offerUri.getIssuer()); - assertNotNull("Nonce should not be null", offerUri.getNonce()); + assertEquals(HttpStatus.SC_OK, response.getStatusCode()); + assertCorsHeaders(response, VALID_CORS_URL); - // Verify CREDENTIAL_OFFER_REQUEST event was fired - events.expect(EventType.VERIFIABLE_CREDENTIAL_OFFER_REQUEST) - .client(clientId) - .user(AssertEvents.isUUID()) - .session(AssertEvents.isSessionId()) - .detail(Details.USERNAME, "john") - .assertEvent(); - } + // Verify response content + CredentialOfferURI offerUri = response.getCredentialOfferURI(); + assertNotNull("Credential offer URI should not be null", offerUri.getIssuer()); + assertNotNull("Nonce should not be null", offerUri.getNonce()); + + // Verify CREDENTIAL_OFFER_REQUEST event was fired + events.expect(EventType.VERIFIABLE_CREDENTIAL_OFFER_REQUEST) + .client(clientId) + .user(AssertEvents.isUUID()) + .session(AssertEvents.isSessionId()) + .detail(Details.USERNAME, "john") + .assertEvent(); } @Test @@ -134,11 +140,16 @@ public class OID4VCCredentialOfferCorsTest extends OID4VCIssuerEndpointTest { // Test credential offer URI endpoint with invalid origin String offerUriUrl = getCredentialOfferUriUrl(); - try (CloseableHttpResponse response = makeCorsRequest(offerUriUrl, INVALID_CORS_URL, tokenResponse.getAccessToken())) { - // Should still return 200 OK but without CORS headers - assertEquals(HttpStatus.SC_OK, response.getStatusLine().getStatusCode()); - assertNoCorsHeaders(response); - } + CredentialOfferUriResponse response = oauth.oid4vc() + .credentialOfferUriRequest() + .endpoint(offerUriUrl) + .bearerToken(tokenResponse.getAccessToken()) + .header("Origin", INVALID_CORS_URL) + .send(); + + // Should still return 200 OK but without CORS headers + assertEquals(HttpStatus.SC_OK, response.getStatusCode()); + assertNoCorsHeaders(response); } @Test @@ -176,27 +187,30 @@ public class OID4VCCredentialOfferCorsTest extends OID4VCIssuerEndpointTest { // Test credential offer endpoint with valid origin String offerUrl = getCredentialOfferUrl(nonce); - try (CloseableHttpResponse response = makeCorsRequest(offerUrl, VALID_CORS_URL, null)) { - assertEquals(HttpStatus.SC_OK, response.getStatusLine().getStatusCode()); - assertCorsHeadersForSessionEndpoint(response, VALID_CORS_URL); + CredentialOfferResponse response = oauth.oid4vc() + .credentialOfferRequest() + .endpoint(offerUrl) + .header("Origin", VALID_CORS_URL) + .send(); - // Verify response content - String responseBody = getResponseBody(response); - CredentialsOffer offer = JsonSerialization.readValue(responseBody, CredentialsOffer.class); - assertNotNull("Credential offer should not be null", offer.getCredentialIssuer()); - assertNotNull("Credential configuration IDs should not be null", offer.getCredentialConfigurationIds()); + assertEquals(HttpStatus.SC_OK, response.getStatusCode()); + assertCorsHeadersForSessionEndpoint(response, VALID_CORS_URL); - // The credential_type detail contains the credential configuration ID from the offer - // We already assert that credentialConfigurationIds is not null and not empty above - String expectedCredentialType = offer.getCredentialConfigurationIds().get(0); + // Verify response content + CredentialsOffer offer = response.getCredentialsOffer(); + assertNotNull("Credential offer should not be null", offer.getCredentialIssuer()); + assertNotNull("Credential configuration IDs should not be null", offer.getCredentialConfigurationIds()); - events.expect(EventType.VERIFIABLE_CREDENTIAL_OFFER_REQUEST) - .client(clientId) - .user(AssertEvents.isUUID()) - .session((String) null) // No session for unauthenticated endpoint - .detail(Details.CREDENTIAL_TYPE, expectedCredentialType) - .assertEvent(); - } + // The credential_type detail contains the credential configuration ID from the offer + // We already assert that credentialConfigurationIds is not null and not empty above + String expectedCredentialType = offer.getCredentialConfigurationIds().get(0); + + events.expect(EventType.VERIFIABLE_CREDENTIAL_OFFER_REQUEST) + .client(clientId) + .user(AssertEvents.isUUID()) + .session((String) null) // No session for unauthenticated endpoint + .detail(Details.CREDENTIAL_TYPE, expectedCredentialType) + .assertEvent(); } @Test @@ -208,11 +222,15 @@ public class OID4VCCredentialOfferCorsTest extends OID4VCIssuerEndpointTest { // Test credential offer endpoint with invalid origin String offerUrl = getCredentialOfferUrl(nonce); - try (CloseableHttpResponse response = makeCorsRequest(offerUrl, INVALID_CORS_URL, null)) { - // Should still return 200 OK and include CORS headers (allows all origins) - assertEquals(HttpStatus.SC_OK, response.getStatusLine().getStatusCode()); - assertCorsHeadersForSessionEndpoint(response, INVALID_CORS_URL); - } + CredentialOfferResponse response = oauth.oid4vc() + .credentialOfferRequest() + .endpoint(offerUrl) + .header("Origin", INVALID_CORS_URL) + .send(); + + // Should still return 200 OK and include CORS headers (allows all origins) + assertEquals(HttpStatus.SC_OK, response.getStatusCode()); + assertCorsHeadersForSessionEndpoint(response, INVALID_CORS_URL); } @Test @@ -238,9 +256,10 @@ public class OID4VCCredentialOfferCorsTest extends OID4VCIssuerEndpointTest { // Test credential offer URI QR code endpoint with valid origin String offerUriUrl = getCredentialOfferUriUrl() + "&type=qr-code"; + // QR code endpoint returns binary data, so we need to use direct HTTP for this test try (CloseableHttpResponse response = makeCorsRequest(offerUriUrl, VALID_CORS_URL, tokenResponse.getAccessToken())) { assertEquals(HttpStatus.SC_OK, response.getStatusLine().getStatusCode()); - assertCorsHeaders(response, VALID_CORS_URL); + assertCorsHeadersFromCloseableResponse(response, VALID_CORS_URL); // Verify response is PNG image String contentType = response.getFirstHeader("Content-Type").getValue(); @@ -255,16 +274,24 @@ public class OID4VCCredentialOfferCorsTest extends OID4VCIssuerEndpointTest { String offerUriUrl = getCredentialOfferUriUrl(); // Test with first valid origin - try (CloseableHttpResponse response1 = makeCorsRequest(offerUriUrl, VALID_CORS_URL, tokenResponse.getAccessToken())) { - assertEquals(HttpStatus.SC_OK, response1.getStatusLine().getStatusCode()); - assertCorsHeaders(response1, VALID_CORS_URL); - } + CredentialOfferUriResponse response1 = oauth.oid4vc() + .credentialOfferUriRequest() + .endpoint(offerUriUrl) + .bearerToken(tokenResponse.getAccessToken()) + .header("Origin", VALID_CORS_URL) + .send(); + assertEquals(HttpStatus.SC_OK, response1.getStatusCode()); + assertCorsHeaders(response1, VALID_CORS_URL); // Test with second valid origin - try (CloseableHttpResponse response2 = makeCorsRequest(offerUriUrl, ANOTHER_VALID_CORS_URL, tokenResponse.getAccessToken())) { - assertEquals(HttpStatus.SC_OK, response2.getStatusLine().getStatusCode()); - assertCorsHeaders(response2, ANOTHER_VALID_CORS_URL); - } + CredentialOfferUriResponse response2 = oauth.oid4vc() + .credentialOfferUriRequest() + .endpoint(offerUriUrl) + .bearerToken(tokenResponse.getAccessToken()) + .header("Origin", ANOTHER_VALID_CORS_URL) + .send(); + assertEquals(HttpStatus.SC_OK, response2.getStatusCode()); + assertCorsHeaders(response2, ANOTHER_VALID_CORS_URL); } @Test @@ -272,12 +299,16 @@ public class OID4VCCredentialOfferCorsTest extends OID4VCIssuerEndpointTest { // Test credential offer URI endpoint without authentication String offerUriUrl = getCredentialOfferUriUrl(); - try (CloseableHttpResponse response = makeCorsRequest(offerUriUrl, VALID_CORS_URL, null)) { - // Should return 400 Bad Request - assertEquals(HttpStatus.SC_BAD_REQUEST, response.getStatusLine().getStatusCode()); - // Should still include CORS headers for error responses - assertCorsHeaders(response, VALID_CORS_URL); - } + CredentialOfferUriResponse response = oauth.oid4vc() + .credentialOfferUriRequest() + .endpoint(offerUriUrl) + .header("Origin", VALID_CORS_URL) + .send(); + + // Should return 400 Bad Request + assertEquals(HttpStatus.SC_BAD_REQUEST, response.getStatusCode()); + // Should still include CORS headers for error responses + assertCorsHeaders(response, VALID_CORS_URL); } @Test @@ -288,18 +319,23 @@ public class OID4VCCredentialOfferCorsTest extends OID4VCIssuerEndpointTest { // Test credential offer URI endpoint with invalid credential configuration ID String offerUriUrl = getCredentialOfferUriUrl("invalid-credential-config-id"); - try (CloseableHttpResponse response = makeCorsRequest(offerUriUrl, VALID_CORS_URL, tokenResponse.getAccessToken())) { - // Should return 400 Bad Request - assertEquals(HttpStatus.SC_BAD_REQUEST, response.getStatusLine().getStatusCode()); + CredentialOfferUriResponse response = oauth.oid4vc() + .credentialOfferUriRequest() + .endpoint(offerUriUrl) + .bearerToken(tokenResponse.getAccessToken()) + .header("Origin", VALID_CORS_URL) + .send(); - // Verify VERIFIABLE_CREDENTIAL_OFFER_REQUEST_ERROR event was fired - events.expect(EventType.VERIFIABLE_CREDENTIAL_OFFER_REQUEST_ERROR) - .client(clientId) - .user(AssertEvents.isUUID()) - .session(AssertEvents.isSessionId()) - .error(Errors.INVALID_REQUEST) - .assertEvent(); - } + // Should return 400 Bad Request + assertEquals(HttpStatus.SC_BAD_REQUEST, response.getStatusCode()); + + // Verify VERIFIABLE_CREDENTIAL_OFFER_REQUEST_ERROR event was fired + events.expect(EventType.VERIFIABLE_CREDENTIAL_OFFER_REQUEST_ERROR) + .client(clientId) + .user(AssertEvents.isUUID()) + .session(AssertEvents.isSessionId()) + .error(Errors.INVALID_REQUEST) + .assertEvent(); } @Test @@ -331,20 +367,24 @@ public class OID4VCCredentialOfferCorsTest extends OID4VCIssuerEndpointTest { // Try to fetch the expired credential offer String offerUrl = getCredentialOfferUrl(nonce); - try (CloseableHttpResponse response = makeCorsRequest(offerUrl, VALID_CORS_URL, null)) { - // Should return 400 Bad Request - assertEquals(HttpStatus.SC_BAD_REQUEST, response.getStatusLine().getStatusCode()); + CredentialOfferResponse response = oauth.oid4vc() + .credentialOfferRequest() + .endpoint(offerUrl) + .header("Origin", VALID_CORS_URL) + .send(); - // Verify VERIFIABLE_CREDENTIAL_OFFER_REQUEST_ERROR event was fired - events.expect(EventType.VERIFIABLE_CREDENTIAL_OFFER_REQUEST_ERROR) - .client((String) null) - .user((String) null) - .session((String) null) - // Storage prunes expired single-use entries before lookup; lookup failure yields INVALID_REQUEST - .error(Errors.INVALID_REQUEST) - .detail(Details.REASON, Matchers.containsString("No credential offer state")) - .assertEvent(); - } + // Should return 400 Bad Request + assertEquals(HttpStatus.SC_BAD_REQUEST, response.getStatusCode()); + + // Verify VERIFIABLE_CREDENTIAL_OFFER_REQUEST_ERROR event was fired + events.expect(EventType.VERIFIABLE_CREDENTIAL_OFFER_REQUEST_ERROR) + .client((String) null) + .user((String) null) + .session((String) null) + // Storage prunes expired single-use entries before lookup; lookup failure yields INVALID_REQUEST + .error(Errors.INVALID_REQUEST) + .detail(Details.REASON, Matchers.containsString("No credential offer state")) + .assertEvent(); } // Helper methods @@ -363,14 +403,16 @@ public class OID4VCCredentialOfferCorsTest extends OID4VCIssuerEndpointTest { private String getNonceFromOfferUri(String accessToken) throws Exception { String offerUriUrl = getCredentialOfferUriUrl(); - try (CloseableHttpResponse response = makeCorsRequest(offerUriUrl, VALID_CORS_URL, accessToken)) { - assertEquals(HttpStatus.SC_OK, response.getStatusLine().getStatusCode()); + CredentialOfferUriResponse response = oauth.oid4vc() + .credentialOfferUriRequest() + .endpoint(offerUriUrl) + .bearerToken(accessToken) + .header("Origin", VALID_CORS_URL) + .send(); - String responseBody = getResponseBody(response); - CredentialOfferURI offerUri = JsonSerialization.readValue(responseBody, CredentialOfferURI.class); - - return offerUri.getNonce(); - } + assertEquals(HttpStatus.SC_OK, response.getStatusCode()); + CredentialOfferURI offerUri = response.getCredentialOfferURI(); + return offerUri.getNonce(); } private CloseableHttpResponse makeCorsRequest(String url, String origin, String accessToken) throws IOException { @@ -398,7 +440,62 @@ public class OID4VCCredentialOfferCorsTest extends OID4VCIssuerEndpointTest { return new String(response.getEntity().getContent().readAllBytes()); } - private void assertCorsHeaders(CloseableHttpResponse response, String expectedOrigin) { + private void assertCorsHeaders(AbstractHttpResponse response, String expectedOrigin) { + assertNotNull("Access-Control-Allow-Origin header should be present", + response.getHeader(Cors.ACCESS_CONTROL_ALLOW_ORIGIN)); + assertEquals("Access-Control-Allow-Origin should match request origin", + expectedOrigin, response.getHeader(Cors.ACCESS_CONTROL_ALLOW_ORIGIN)); + + assertNotNull("Access-Control-Allow-Credentials header should be present", + response.getHeader(Cors.ACCESS_CONTROL_ALLOW_CREDENTIALS)); + assertEquals("Access-Control-Allow-Credentials should be true", + "true", response.getHeader(Cors.ACCESS_CONTROL_ALLOW_CREDENTIALS)); + } + + private void assertCorsHeadersForSessionEndpoint(AbstractHttpResponse response, String expectedOrigin) { + assertNotNull("Access-Control-Allow-Origin header should be present", + response.getHeader(Cors.ACCESS_CONTROL_ALLOW_ORIGIN)); + assertEquals("Access-Control-Allow-Origin should match request origin", + expectedOrigin, response.getHeader(Cors.ACCESS_CONTROL_ALLOW_ORIGIN)); + + // Session-based endpoints don't require credentials since they use nonces for security + // and allow all origins, so credentials header should be false for security reasons + String credentialsHeader = response.getHeader(Cors.ACCESS_CONTROL_ALLOW_CREDENTIALS); + assertNotNull("Access-Control-Allow-Credentials header should be present for session endpoints", + credentialsHeader); + assertEquals("Access-Control-Allow-Credentials should be false when allowing all origins", + "false", credentialsHeader); + } + + private void assertCorsPreflightHeaders(CloseableHttpResponse response, String expectedOrigin) { + assertCorsHeadersFromCloseableResponse(response, expectedOrigin); + + assertNotNull("Access-Control-Allow-Methods header should be present", + response.getFirstHeader(Cors.ACCESS_CONTROL_ALLOW_METHODS)); + + String allowedMethods = response.getFirstHeader(Cors.ACCESS_CONTROL_ALLOW_METHODS).getValue(); + Set methods = Arrays.stream(allowedMethods.split(", ")) + .collect(Collectors.toSet()); + + assertTrue("GET should be allowed", methods.contains("GET")); + assertTrue("OPTIONS should be allowed", methods.contains("OPTIONS")); + } + + private void assertCorsPreflightHeadersForSessionEndpoint(CloseableHttpResponse response, String expectedOrigin) { + assertCorsHeadersForSessionEndpointFromCloseableResponse(response, expectedOrigin); + + assertNotNull("Access-Control-Allow-Methods header should be present", + response.getFirstHeader(Cors.ACCESS_CONTROL_ALLOW_METHODS)); + + String allowedMethods = response.getFirstHeader(Cors.ACCESS_CONTROL_ALLOW_METHODS).getValue(); + Set methods = Arrays.stream(allowedMethods.split(", ")) + .collect(Collectors.toSet()); + + assertTrue("GET should be allowed", methods.contains("GET")); + assertTrue("OPTIONS should be allowed", methods.contains("OPTIONS")); + } + + private void assertCorsHeadersFromCloseableResponse(CloseableHttpResponse response, String expectedOrigin) { assertNotNull("Access-Control-Allow-Origin header should be present", response.getFirstHeader(Cors.ACCESS_CONTROL_ALLOW_ORIGIN)); assertEquals("Access-Control-Allow-Origin should match request origin", @@ -410,7 +507,7 @@ public class OID4VCCredentialOfferCorsTest extends OID4VCIssuerEndpointTest { "true", response.getFirstHeader(Cors.ACCESS_CONTROL_ALLOW_CREDENTIALS).getValue()); } - private void assertCorsHeadersForSessionEndpoint(CloseableHttpResponse response, String expectedOrigin) { + private void assertCorsHeadersForSessionEndpointFromCloseableResponse(CloseableHttpResponse response, String expectedOrigin) { assertNotNull("Access-Control-Allow-Origin header should be present", response.getFirstHeader(Cors.ACCESS_CONTROL_ALLOW_ORIGIN)); assertEquals("Access-Control-Allow-Origin should match request origin", @@ -425,36 +522,8 @@ public class OID4VCCredentialOfferCorsTest extends OID4VCIssuerEndpointTest { "false", credentialsHeader.getValue()); } - private void assertCorsPreflightHeaders(CloseableHttpResponse response, String expectedOrigin) { - assertCorsHeaders(response, expectedOrigin); - - assertNotNull("Access-Control-Allow-Methods header should be present", - response.getFirstHeader(Cors.ACCESS_CONTROL_ALLOW_METHODS)); - - String allowedMethods = response.getFirstHeader(Cors.ACCESS_CONTROL_ALLOW_METHODS).getValue(); - Set methods = Arrays.stream(allowedMethods.split(", ")) - .collect(Collectors.toSet()); - - assertTrue("GET should be allowed", methods.contains("GET")); - assertTrue("OPTIONS should be allowed", methods.contains("OPTIONS")); - } - - private void assertCorsPreflightHeadersForSessionEndpoint(CloseableHttpResponse response, String expectedOrigin) { - assertCorsHeadersForSessionEndpoint(response, expectedOrigin); - - assertNotNull("Access-Control-Allow-Methods header should be present", - response.getFirstHeader(Cors.ACCESS_CONTROL_ALLOW_METHODS)); - - String allowedMethods = response.getFirstHeader(Cors.ACCESS_CONTROL_ALLOW_METHODS).getValue(); - Set methods = Arrays.stream(allowedMethods.split(", ")) - .collect(Collectors.toSet()); - - assertTrue("GET should be allowed", methods.contains("GET")); - assertTrue("OPTIONS should be allowed", methods.contains("OPTIONS")); - } - - private void assertNoCorsHeaders(CloseableHttpResponse response) { - assertNull("Access-Control-Allow-Origin header should not be present", response.getFirstHeader(Cors.ACCESS_CONTROL_ALLOW_ORIGIN)); - assertNull("Access-Control-Allow-Credentials header should not be present", response.getFirstHeader(Cors.ACCESS_CONTROL_ALLOW_CREDENTIALS)); + private void assertNoCorsHeaders(AbstractHttpResponse response) { + assertNull("Access-Control-Allow-Origin header should not be present", response.getHeader(Cors.ACCESS_CONTROL_ALLOW_ORIGIN)); + assertNull("Access-Control-Allow-Credentials header should not be present", response.getHeader(Cors.ACCESS_CONTROL_ALLOW_CREDENTIALS)); } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerEndpointTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerEndpointTest.java index 1fcc56fd003..35f57ef39a0 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerEndpointTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerEndpointTest.java @@ -44,7 +44,6 @@ import java.util.zip.DeflaterOutputStream; import jakarta.ws.rs.client.Client; import jakarta.ws.rs.client.WebTarget; -import jakarta.ws.rs.core.HttpHeaders; import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.UriBuilder; @@ -103,16 +102,13 @@ import org.keycloak.testsuite.Assert; import org.keycloak.testsuite.admin.ApiUtil; import org.keycloak.testsuite.runonserver.RunOnServerException; import org.keycloak.testsuite.util.AdminClientUtil; +import org.keycloak.testsuite.util.oauth.OpenIDProviderConfigurationResponse; +import org.keycloak.testsuite.util.oauth.oid4vc.CredentialIssuerMetadataResponse; +import org.keycloak.testsuite.util.oauth.oid4vc.Oid4vcCredentialResponse; import org.keycloak.util.JsonSerialization; import com.fasterxml.jackson.core.type.TypeReference; -import org.apache.commons.io.IOUtils; import org.apache.http.HttpStatus; -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.entity.ContentType; -import org.apache.http.entity.StringEntity; import org.apache.http.impl.client.CloseableHttpClient; import org.apache.http.impl.client.HttpClientBuilder; import org.jboss.logging.Logger; @@ -604,23 +600,15 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest { SupportedCredentialConfiguration offeredCredential, CredentialResponseHandler responseHandler, ClientScopeRepresentation expectedClientScope) throws IOException, VerificationException { + Oid4vcCredentialResponse credentialRequestResponse = oauth.oid4vc() + .credentialRequest() + .endpoint(credentialEndpoint) + .bearerToken(token) + .credentialConfigurationId(offeredCredential.getId()) + .send(); - CredentialRequest request = new CredentialRequest(); - request.setCredentialConfigurationId(offeredCredential.getId()); - - StringEntity stringEntity = new StringEntity(JsonSerialization.writeValueAsString(request), - ContentType.APPLICATION_JSON); - - HttpPost postCredential = new HttpPost(credentialEndpoint); - postCredential.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token); - postCredential.setEntity(stringEntity); - - CredentialResponse credentialResponse; - try (CloseableHttpResponse credentialRequestResponse = httpClient.execute(postCredential)) { - assertEquals(HttpStatus.SC_OK, credentialRequestResponse.getStatusLine().getStatusCode()); - String s = IOUtils.toString(credentialRequestResponse.getEntity().getContent(), StandardCharsets.UTF_8); - credentialResponse = JsonSerialization.readValue(s, CredentialResponse.class); - } + assertEquals(HttpStatus.SC_OK, credentialRequestResponse.getStatusCode()); + CredentialResponse credentialResponse = credentialRequestResponse.getCredentialResponse(); // Use response handler to customize checks based on formats. responseHandler.handleCredentialResponse(credentialResponse, expectedClientScope); @@ -628,25 +616,20 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest { public CredentialIssuer getCredentialIssuerMetadata() { final String endpoint = getRealmMetadataPath(TEST_REALM_NAME); - HttpGet getMetadataRequest = new HttpGet(endpoint); - try (CloseableHttpResponse metadataResponse = httpClient.execute(getMetadataRequest)) { - assertEquals(HttpStatus.SC_OK, metadataResponse.getStatusLine().getStatusCode()); - String s = IOUtils.toString(metadataResponse.getEntity().getContent(), StandardCharsets.UTF_8); - return JsonSerialization.readValue(s, CredentialIssuer.class); - } catch (Exception ex) { - throw new RuntimeException(ex); - } + CredentialIssuerMetadataResponse metadataResponse = oauth.oid4vc() + .issuerMetadataRequest() + .endpoint(endpoint) + .send(); + assertEquals(HttpStatus.SC_OK, metadataResponse.getStatusCode()); + return metadataResponse.getMetadata(); } public OIDCConfigurationRepresentation getAuthorizationMetadata(String authServerUrl) { - HttpGet getOpenidConfiguration = new HttpGet(authServerUrl + "/.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); - return JsonSerialization.readValue(s, OIDCConfigurationRepresentation.class); - } catch (Exception ex) { - throw new RuntimeException(ex); - } + OpenIDProviderConfigurationResponse response = oauth.wellknownRequest() + .url(authServerUrl) + .send(); + assertEquals(HttpStatus.SC_OK, response.getStatusCode()); + return response.getOidcConfiguration(); } public SupportedCredentialConfiguration getSupportedCredentialConfigurationByScope(CredentialIssuer metadata, String scope) { diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerWellKnownProviderTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerWellKnownProviderTest.java index 35a6abc4802..06a02cf2b78 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerWellKnownProviderTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerWellKnownProviderTest.java @@ -71,20 +71,15 @@ import org.keycloak.testsuite.arquillian.SuiteContext; import org.keycloak.testsuite.client.KeycloakTestingClient; import org.keycloak.testsuite.util.AdminClientUtil; import org.keycloak.testsuite.util.oauth.OAuthClient; +import org.keycloak.testsuite.util.oauth.oid4vc.CredentialIssuerMetadataResponse; import org.keycloak.util.JsonSerialization; import org.keycloak.utils.MediaType; import org.keycloak.utils.StringUtil; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; -import org.apache.http.Header; import org.apache.http.HttpHeaders; import org.apache.http.HttpStatus; -import org.apache.http.client.methods.CloseableHttpResponse; -import org.apache.http.client.methods.HttpGet; -import org.apache.http.impl.client.CloseableHttpClient; -import org.apache.http.impl.client.HttpClientBuilder; -import org.apache.http.util.EntityUtils; import org.hamcrest.MatcherAssert; import org.hamcrest.Matchers; import org.junit.Test; @@ -129,137 +124,136 @@ public class OID4VCIssuerWellKnownProviderTest extends OID4VCIssuerEndpointTest } @Test - public void testUnsignedMetadata() { - try (CloseableHttpClient httpClient = HttpClientBuilder.create().build()) { - String wellKnownUri = getRealmMetadataPath(TEST_REALM_NAME); - String expectedIssuer = getRealmPath(TEST_REALM_NAME); + public void testUnsignedMetadata() throws IOException { + String wellKnownUri = getRealmMetadataPath(TEST_REALM_NAME); + String expectedIssuer = getRealmPath(TEST_REALM_NAME); - // Configure realm for unsigned metadata - testingClient.server(TEST_REALM_NAME).run(session -> { - RealmModel realm = session.getContext().getRealm(); - realm.setAttribute(OID4VCIssuerWellKnownProvider.SIGNED_METADATA_ENABLED_ATTR, "false"); - }); + // Configure realm for unsigned metadata + testingClient.server(TEST_REALM_NAME).run(session -> { + RealmModel realm = session.getContext().getRealm(); + realm.setAttribute(OID4VCIssuerWellKnownProvider.SIGNED_METADATA_ENABLED_ATTR, "false"); + }); - HttpGet getJsonMetadata = new HttpGet(wellKnownUri); - getJsonMetadata.addHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON); - try (CloseableHttpResponse response = httpClient.execute(getJsonMetadata)) { - assertEquals(HttpStatus.SC_OK, response.getStatusLine().getStatusCode()); - assertEquals("Content-Type should be application/json", MediaType.APPLICATION_JSON, - response.getFirstHeader(HttpHeaders.CONTENT_TYPE).getValue()); - String json = EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8); - CredentialIssuer issuer = JsonSerialization.readValue(json, CredentialIssuer.class); - assertNotNull("Response should be a CredentialIssuer object", issuer); - assertEquals("credential_issuer should be set", expectedIssuer, issuer.getCredentialIssuer()); - assertEquals("credential_endpoint should be correct", - expectedIssuer + "/protocol/oid4vc/credential", - issuer.getCredentialEndpoint()); - assertEquals("nonce_endpoint should be correct", - expectedIssuer + "/protocol/oid4vc/nonce", - issuer.getNonceEndpoint()); - assertNull("deferred_credential_endpoint should be omitted", issuer.getDeferredCredentialEndpoint()); - assertNotNull("authorization_servers should be present", issuer.getAuthorizationServers()); - assertNotNull("credential_response_encryption should be present", issuer.getCredentialResponseEncryption()); - assertNotNull("batch_credential_issuance should be present", issuer.getBatchCredentialIssuance()); - } - } catch (Exception e) { - throw new RuntimeException("Failed to process JSON metadata response: " + e.getMessage(), e); - } + CredentialIssuerMetadataResponse response = oauth.oid4vc() + .issuerMetadataRequest() + .endpoint(wellKnownUri) + .send(); + + + assertEquals(HttpStatus.SC_OK, response.getStatusCode()); + assertEquals("Content-Type should be application/json", MediaType.APPLICATION_JSON, + response.getHeader(HttpHeaders.CONTENT_TYPE)); + + CredentialIssuer issuer = response.getMetadata(); + assertNotNull("Response should be a CredentialIssuer object", issuer); + assertEquals("credential_issuer should be set", expectedIssuer, issuer.getCredentialIssuer()); + assertEquals("credential_endpoint should be correct", + expectedIssuer + "/protocol/oid4vc/credential", + issuer.getCredentialEndpoint()); + assertEquals("nonce_endpoint should be correct", + expectedIssuer + "/protocol/oid4vc/nonce", + issuer.getNonceEndpoint()); + assertNull("deferred_credential_endpoint should be omitted", issuer.getDeferredCredentialEndpoint()); + assertNotNull("authorization_servers should be present", issuer.getAuthorizationServers()); + assertNotNull("credential_response_encryption should be present", issuer.getCredentialResponseEncryption()); + assertNotNull("batch_credential_issuance should be present", issuer.getBatchCredentialIssuance()); } @Test - public void testSignedMetadata() { - try (CloseableHttpClient httpClient = HttpClientBuilder.create().build()) { - String wellKnownUri = getRealmMetadataPath(TEST_REALM_NAME); - String expectedIssuer = getRealmPath(TEST_REALM_NAME); + public void testSignedMetadata() throws Exception { + String wellKnownUri = getRealmMetadataPath(TEST_REALM_NAME); + String expectedIssuer = getRealmPath(TEST_REALM_NAME); - // Configure realm for signed metadata - testingClient.server(TEST_REALM_NAME).run(session -> { - RealmModel realm = session.getContext().getRealm(); - realm.setAttribute(OID4VCIssuerWellKnownProvider.SIGNED_METADATA_ENABLED_ATTR, "true"); - realm.setAttribute(OID4VCIssuerWellKnownProvider.SIGNED_METADATA_ALG_ATTR, "RS256"); - realm.setAttribute(OID4VCIssuerWellKnownProvider.SIGNED_METADATA_LIFESPAN_ATTR, "3600"); - }); + // Configure realm for signed metadata + testingClient.server(TEST_REALM_NAME).run(session -> { + RealmModel realm = session.getContext().getRealm(); + realm.setAttribute(OID4VCIssuerWellKnownProvider.SIGNED_METADATA_ENABLED_ATTR, "true"); + realm.setAttribute(OID4VCIssuerWellKnownProvider.SIGNED_METADATA_ALG_ATTR, "RS256"); + realm.setAttribute(OID4VCIssuerWellKnownProvider.SIGNED_METADATA_LIFESPAN_ATTR, "3600"); + }); - HttpGet getJwtMetadata = new HttpGet(wellKnownUri); - getJwtMetadata.addHeader(HttpHeaders.ACCEPT, org.keycloak.utils.MediaType.APPLICATION_JWT); - try (CloseableHttpResponse response = httpClient.execute(getJwtMetadata)) { - assertEquals(HttpStatus.SC_OK, response.getStatusLine().getStatusCode()); - assertEquals("Content-Type should be application/jwt", org.keycloak.utils.MediaType.APPLICATION_JWT, - response.getFirstHeader(HttpHeaders.CONTENT_TYPE).getValue()); - String jws = EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8); - assertNotNull("Response should be a JWT string", jws); - JWSInput jwsInput = new JWSInput(jws); + CredentialIssuerMetadataResponse response = oauth.oid4vc() + .issuerMetadataRequest() + .endpoint(wellKnownUri) + .header(HttpHeaders.ACCEPT, org.keycloak.utils.MediaType.APPLICATION_JWT) + .send(); - // Validate JOSE Header - JWSHeader header = jwsInput.getHeader(); - assertEquals("Algorithm should be RS256", "RS256", header.getAlgorithm().name()); - assertEquals("Type should be openidvci-issuer-metadata+jwt", - SIGNED_METADATA_JWT_TYPE, header.getType()); - assertNotNull("Key ID should be present", header.getKeyId()); - assertNotNull("x5c header should be present if certificates are configured", header.getX5c()); + assertEquals(HttpStatus.SC_OK, response.getStatusCode()); + assertEquals("Content-Type should be application/jwt", org.keycloak.utils.MediaType.APPLICATION_JWT, + response.getHeader(HttpHeaders.CONTENT_TYPE)); - // Validate JWT claims - Map claims = JsonSerialization.readValue(jwsInput.getContent(), Map.class); - assertEquals("sub should match credential_issuer", expectedIssuer, claims.get("sub")); - assertEquals("credential_issuer should be set", expectedIssuer, claims.get("credential_issuer")); - assertEquals("iss should match credential_issuer", expectedIssuer, claims.get("iss")); - assertNotNull("iat should be present", claims.get("iat")); - assertTrue("iat should be a number", claims.get("iat") instanceof Number); - assertTrue("iat should be recent", ((Number) claims.get("iat")).longValue() <= Time.currentTime()); - assertNotNull("exp should be present", claims.get("exp")); - assertTrue("exp should be a number", claims.get("exp") instanceof Number); - assertTrue("exp should be in the future", - ((Number) claims.get("exp")).longValue() > Time.currentTime()); - assertEquals("credential_endpoint should be correct", - expectedIssuer + "/protocol/oid4vc/credential", - claims.get("credential_endpoint")); - assertEquals("nonce_endpoint should be correct", - expectedIssuer + "/protocol/oid4vc/nonce", - claims.get("nonce_endpoint")); - assertFalse("deferred_credential_endpoint should be omitted", - claims.containsKey("deferred_credential_endpoint")); - assertNotNull("authorization_servers should be present", claims.get("authorization_servers")); - assertNotNull("credential_response_encryption should be present", claims.get("credential_response_encryption")); - assertNotNull("batch_credential_issuance should be present", claims.get("batch_credential_issuance")); + String jws = response.getContent(); + assertNotNull("Response should be a JWT string", jws); + JWSInput jwsInput = new JWSInput(jws); - // Verify signature - byte[] encodedSignatureInput = jwsInput.getEncodedSignatureInput().getBytes(StandardCharsets.UTF_8); - byte[] signature = jwsInput.getSignature(); - testingClient.server(TEST_REALM_NAME).run(session -> { - RealmModel realm = session.getContext().getRealm(); - KeyWrapper keyWrapper = session.keys().getActiveKey(realm, KeyUse.SIG, "RS256"); - assertNotNull("Active signing key should exist", keyWrapper); - SignatureProvider signatureProvider = session.getProvider(SignatureProvider.class, "RS256"); - assertNotNull("Signature provider should exist for RS256", signatureProvider); - SignatureVerifierContext verifier = signatureProvider.verifier(keyWrapper); - boolean isValid = verifier.verify(encodedSignatureInput, signature); - assertTrue("JWS signature should be valid", isValid); - }); - } - } catch (Exception e) { - throw new RuntimeException("Failed to process JWT metadata response: " + e.getMessage(), e); - } + // Validate JOSE Header + JWSHeader header = jwsInput.getHeader(); + assertEquals("Algorithm should be RS256", "RS256", header.getAlgorithm().name()); + assertEquals("Type should be openidvci-issuer-metadata+jwt", + SIGNED_METADATA_JWT_TYPE, header.getType()); + assertNotNull("Key ID should be present", header.getKeyId()); + assertNotNull("x5c header should be present if certificates are configured", header.getX5c()); + + // Validate JWT claims + Map claims = JsonSerialization.readValue(jwsInput.getContent(), Map.class); + assertEquals("sub should match credential_issuer", expectedIssuer, claims.get("sub")); + assertEquals("credential_issuer should be set", expectedIssuer, claims.get("credential_issuer")); + assertEquals("iss should match credential_issuer", expectedIssuer, claims.get("iss")); + assertNotNull("iat should be present", claims.get("iat")); + assertTrue("iat should be a number", claims.get("iat") instanceof Number); + assertTrue("iat should be recent", ((Number) claims.get("iat")).longValue() <= Time.currentTime()); + assertNotNull("exp should be present", claims.get("exp")); + assertTrue("exp should be a number", claims.get("exp") instanceof Number); + assertTrue("exp should be in the future", + ((Number) claims.get("exp")).longValue() > Time.currentTime()); + assertEquals("credential_endpoint should be correct", + expectedIssuer + "/protocol/oid4vc/credential", + claims.get("credential_endpoint")); + assertEquals("nonce_endpoint should be correct", + expectedIssuer + "/protocol/oid4vc/nonce", + claims.get("nonce_endpoint")); + assertFalse("deferred_credential_endpoint should be omitted", + claims.containsKey("deferred_credential_endpoint")); + assertNotNull("authorization_servers should be present", claims.get("authorization_servers")); + assertNotNull("credential_response_encryption should be present", claims.get("credential_response_encryption")); + assertNotNull("batch_credential_issuance should be present", claims.get("batch_credential_issuance")); + + // Verify signature + byte[] encodedSignatureInput = jwsInput.getEncodedSignatureInput().getBytes(StandardCharsets.UTF_8); + byte[] signature = jwsInput.getSignature(); + testingClient.server(TEST_REALM_NAME).run(session -> { + RealmModel realm = session.getContext().getRealm(); + KeyWrapper keyWrapper = session.keys().getActiveKey(realm, KeyUse.SIG, "RS256"); + assertNotNull("Active signing key should exist", keyWrapper); + SignatureProvider signatureProvider = session.getProvider(SignatureProvider.class, "RS256"); + assertNotNull("Signature provider should exist for RS256", signatureProvider); + SignatureVerifierContext verifier = signatureProvider.verifier(keyWrapper); + boolean isValid = verifier.verify(encodedSignatureInput, signature); + assertTrue("JWS signature should be valid", isValid); + }); } @Test public void shouldServeJwtVcMetadataAtSpecCompliantEndpoint() { - try (CloseableHttpClient httpClient = HttpClientBuilder.create().build()) { - String realm = TEST_REALM_NAME; - String wellKnownUri = getSpecCompliantRealmMetadataPath(realm); - String expectedIssuer = getRealmPath(realm); + String realm = TEST_REALM_NAME; + String wellKnownUri = getSpecCompliantRealmMetadataPath(realm); + String expectedIssuer = getRealmPath(realm); - HttpGet get = new HttpGet(wellKnownUri); - get.addHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON); + try { + CredentialIssuerMetadataResponse response = oauth.oid4vc() + .issuerMetadataRequest() + .endpoint(wellKnownUri) + .header(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON) + .send(); - try (CloseableHttpResponse response = httpClient.execute(get)) { - assertEquals(HttpStatus.SC_OK, response.getStatusLine().getStatusCode()); - String json = EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8); + assertEquals(HttpStatus.SC_OK, response.getStatusCode()); + String json = response.getContent(); + + JWTVCIssuerMetadata metadata = JsonSerialization.readValue(json, JWTVCIssuerMetadata.class); + assertNotNull(metadata); + assertEquals(expectedIssuer, metadata.getIssuer()); + assertNotNull("JWKS must be present", metadata.getJwks()); - JWTVCIssuerMetadata metadata = JsonSerialization.readValue(json, JWTVCIssuerMetadata.class); - assertNotNull(metadata); - assertEquals(expectedIssuer, metadata.getIssuer()); - assertNotNull("JWKS must be present", metadata.getJwks()); - } } catch (Exception e) { throw new RuntimeException("Failed to process spec-compliant JWT VC issuer metadata response: " + e.getMessage(), e); } @@ -267,119 +261,117 @@ public class OID4VCIssuerWellKnownProviderTest extends OID4VCIssuerEndpointTest @Test public void shouldKeepLegacyJwtVcEndpointWithDeprecationHeaders() { - try (CloseableHttpClient httpClient = HttpClientBuilder.create().build()) { - String realm = TEST_REALM_NAME; - String wellKnownUri = getLegacyJwtVcRealmMetadataPath(realm); // legacy JWT VC path + String realm = TEST_REALM_NAME; + String wellKnownUri = getLegacyJwtVcRealmMetadataPath(realm); // legacy JWT VC path - HttpGet get = new HttpGet(wellKnownUri); - get.addHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON); + try { + CredentialIssuerMetadataResponse response = oauth.oid4vc() + .issuerMetadataRequest() + .endpoint(wellKnownUri) + .header(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON) + .send(); + + assertEquals(HttpStatus.SC_OK, response.getStatusCode()); + + String warning = response.getHeader("Warning"); + String deprecation = response.getHeader("Deprecation"); + String link = response.getHeader("Link"); + + assertNotNull("Warning header should be present", warning); + assertTrue("Warning header should mention deprecated endpoint", warning.contains("Deprecated endpoint")); + assertNotNull("Deprecation header should be present", deprecation); + assertEquals("true", deprecation); + assertNotNull("Link header should point to successor", link); + assertTrue("Link header should reference spec-compliant endpoint", + link.contains(getSpecCompliantRealmMetadataPath(realm))); - try (CloseableHttpResponse response = httpClient.execute(get)) { - assertEquals(HttpStatus.SC_OK, response.getStatusLine().getStatusCode()); - Header warning = response.getFirstHeader("Warning"); - Header deprecation = response.getFirstHeader("Deprecation"); - Header link = response.getFirstHeader("Link"); - assertNotNull("Warning header should be present", warning); - assertTrue("Warning header should mention deprecated endpoint", warning.getValue().contains("Deprecated endpoint")); - assertNotNull("Deprecation header should be present", deprecation); - assertEquals("true", deprecation.getValue()); - assertNotNull("Link header should point to successor", link); - assertTrue("Link header should reference spec-compliant endpoint", - link.getValue().contains(getSpecCompliantRealmMetadataPath(realm))); - } } catch (Exception e) { throw new RuntimeException("Failed to process legacy JWT VC issuer metadata response: " + e.getMessage(), e); } } @Test - public void testUnsignedMetadataWhenSignedDisabled() { - try (CloseableHttpClient httpClient = HttpClientBuilder.create().build()) { - String wellKnownUri = getRealmMetadataPath(TEST_REALM_NAME); - String expectedIssuer = getRealmPath(TEST_REALM_NAME); + public void testUnsignedMetadataWhenSignedDisabled() throws IOException { + String wellKnownUri = getRealmMetadataPath(TEST_REALM_NAME); + String expectedIssuer = getRealmPath(TEST_REALM_NAME); - // Disable signed metadata - testingClient.server(TEST_REALM_NAME).run(session -> { - RealmModel realm = session.getContext().getRealm(); - realm.setAttribute(OID4VCIssuerWellKnownProvider.SIGNED_METADATA_ENABLED_ATTR, "false"); - assertNotNull("Realm should have signed metadata disabled", - realm.getAttribute(OID4VCIssuerWellKnownProvider.SIGNED_METADATA_ENABLED_ATTR)); - }); + // Disable signed metadata + testingClient.server(TEST_REALM_NAME).run(session -> { + RealmModel realm = session.getContext().getRealm(); + realm.setAttribute(OID4VCIssuerWellKnownProvider.SIGNED_METADATA_ENABLED_ATTR, "false"); + assertNotNull("Realm should have signed metadata disabled", + realm.getAttribute(OID4VCIssuerWellKnownProvider.SIGNED_METADATA_ENABLED_ATTR)); + }); - HttpGet getUnsignedMetadata = new HttpGet(wellKnownUri); - getUnsignedMetadata.addHeader(HttpHeaders.ACCEPT, org.keycloak.utils.MediaType.APPLICATION_JWT); - try (CloseableHttpResponse response = httpClient.execute(getUnsignedMetadata)) { - assertEquals(HttpStatus.SC_OK, response.getStatusLine().getStatusCode()); - assertEquals("Content-Type should be application/json when signed metadata is disabled", - MediaType.APPLICATION_JSON, response.getFirstHeader(HttpHeaders.CONTENT_TYPE).getValue()); - String json = EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8); - CredentialIssuer issuer = JsonSerialization.readValue(json, CredentialIssuer.class); - assertNotNull("Unsigned metadata should return CredentialIssuer", issuer); - assertEquals("credential_issuer should be set", expectedIssuer, issuer.getCredentialIssuer()); - } - } catch (Exception e) { - throw new RuntimeException("Failed to process unsigned metadata response: " + e.getMessage(), e); - } + CredentialIssuerMetadataResponse response = oauth.oid4vc() + .issuerMetadataRequest() + .endpoint(wellKnownUri) + .header(HttpHeaders.ACCEPT, org.keycloak.utils.MediaType.APPLICATION_JWT) + .send(); + + assertEquals(HttpStatus.SC_OK, response.getStatusCode()); + assertEquals("Content-Type should be application/json when signed metadata is disabled", + MediaType.APPLICATION_JSON, response.getHeader(HttpHeaders.CONTENT_TYPE)); + + CredentialIssuer issuer = response.getMetadata(); + assertNotNull("Unsigned metadata should return CredentialIssuer", issuer); + assertEquals("credential_issuer should be set", expectedIssuer, issuer.getCredentialIssuer()); } @Test - public void testSignedMetadataWithInvalidLifespan() { - try (CloseableHttpClient httpClient = HttpClientBuilder.create().build()) { - String wellKnownUri = getRealmMetadataPath(TEST_REALM_NAME); - String expectedIssuer = getRealmPath(TEST_REALM_NAME); + public void testSignedMetadataWithInvalidLifespan() throws IOException { + String wellKnownUri = getRealmMetadataPath(TEST_REALM_NAME); + String expectedIssuer = getRealmPath(TEST_REALM_NAME); - // Configure invalid lifespan - testingClient.server(TEST_REALM_NAME).run(session -> { - RealmModel realm = session.getContext().getRealm(); - realm.setAttribute(OID4VCIssuerWellKnownProvider.SIGNED_METADATA_ENABLED_ATTR, "true"); - realm.setAttribute(OID4VCIssuerWellKnownProvider.SIGNED_METADATA_ALG_ATTR, "RS256"); - realm.setAttribute(OID4VCIssuerWellKnownProvider.SIGNED_METADATA_LIFESPAN_ATTR, "invalid"); - }); + // Configure invalid lifespan + testingClient.server(TEST_REALM_NAME).run(session -> { + RealmModel realm = session.getContext().getRealm(); + realm.setAttribute(OID4VCIssuerWellKnownProvider.SIGNED_METADATA_ENABLED_ATTR, "true"); + realm.setAttribute(OID4VCIssuerWellKnownProvider.SIGNED_METADATA_ALG_ATTR, "RS256"); + realm.setAttribute(OID4VCIssuerWellKnownProvider.SIGNED_METADATA_LIFESPAN_ATTR, "invalid"); + }); - HttpGet getInvalidExpMetadata = new HttpGet(wellKnownUri); - getInvalidExpMetadata.addHeader(HttpHeaders.ACCEPT, org.keycloak.utils.MediaType.APPLICATION_JWT); - try (CloseableHttpResponse response = httpClient.execute(getInvalidExpMetadata)) { - assertEquals(HttpStatus.SC_OK, response.getStatusLine().getStatusCode()); - assertEquals("Content-Type should be application/json due to invalid lifespan", - MediaType.APPLICATION_JSON, response.getFirstHeader(HttpHeaders.CONTENT_TYPE).getValue()); - String json = EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8); - CredentialIssuer issuer = JsonSerialization.readValue(json, CredentialIssuer.class); - assertNotNull("Response should be a CredentialIssuer object", issuer); - assertEquals("credential_issuer should be set", expectedIssuer, issuer.getCredentialIssuer()); - } - } catch (Exception e) { - throw new RuntimeException("Failed to process invalid lifespan metadata response: " + e.getMessage(), e); - } + CredentialIssuerMetadataResponse response = oauth.oid4vc() + .issuerMetadataRequest() + .endpoint(wellKnownUri) + .header(HttpHeaders.ACCEPT, org.keycloak.utils.MediaType.APPLICATION_JWT) + .send(); + + assertEquals(HttpStatus.SC_OK, response.getStatusCode()); + assertEquals("Content-Type should be application/json due to invalid lifespan", + MediaType.APPLICATION_JSON, response.getHeader(HttpHeaders.CONTENT_TYPE)); + + CredentialIssuer issuer = response.getMetadata(); + assertNotNull("Response should be a CredentialIssuer object", issuer); + assertEquals("credential_issuer should be set", expectedIssuer, issuer.getCredentialIssuer()); } @Test - public void testSignedMetadataWithInvalidAlgorithm() { - try (CloseableHttpClient httpClient = HttpClientBuilder.create().build()) { - String wellKnownUri = getRealmMetadataPath(TEST_REALM_NAME); - String expectedIssuer = getRealmPath(TEST_REALM_NAME); + public void testSignedMetadataWithInvalidAlgorithm() throws IOException { + String wellKnownUri = getRealmMetadataPath(TEST_REALM_NAME); + String expectedIssuer = getRealmPath(TEST_REALM_NAME); - // Configure invalid algorithm - testingClient.server(TEST_REALM_NAME).run(session -> { - RealmModel realm = session.getContext().getRealm(); - realm.setAttribute(OID4VCIssuerWellKnownProvider.SIGNED_METADATA_ENABLED_ATTR, "true"); - realm.setAttribute(OID4VCIssuerWellKnownProvider.SIGNED_METADATA_ALG_ATTR, "INVALID_ALG"); - realm.setAttribute(OID4VCIssuerWellKnownProvider.SIGNED_METADATA_LIFESPAN_ATTR, "3600"); - }); + // Configure invalid algorithm + testingClient.server(TEST_REALM_NAME).run(session -> { + RealmModel realm = session.getContext().getRealm(); + realm.setAttribute(OID4VCIssuerWellKnownProvider.SIGNED_METADATA_ENABLED_ATTR, "true"); + realm.setAttribute(OID4VCIssuerWellKnownProvider.SIGNED_METADATA_ALG_ATTR, "INVALID_ALG"); + realm.setAttribute(OID4VCIssuerWellKnownProvider.SIGNED_METADATA_LIFESPAN_ATTR, "3600"); + }); - HttpGet getJwtMetadata = new HttpGet(wellKnownUri); - getJwtMetadata.addHeader(HttpHeaders.ACCEPT, org.keycloak.utils.MediaType.APPLICATION_JWT); - try (CloseableHttpResponse response = httpClient.execute(getJwtMetadata)) { - assertEquals(HttpStatus.SC_OK, response.getStatusLine().getStatusCode()); - assertEquals("Content-Type should be application/json due to invalid algorithm", - MediaType.APPLICATION_JSON, response.getFirstHeader(HttpHeaders.CONTENT_TYPE).getValue()); - String json = EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8); - CredentialIssuer issuer = JsonSerialization.readValue(json, CredentialIssuer.class); - assertNotNull("Response should be a CredentialIssuer object", issuer); - assertEquals("credential_issuer should be set", expectedIssuer, issuer.getCredentialIssuer()); - } - } catch (Exception e) { - throw new RuntimeException("Failed to process invalid algorithm metadata response: " + e.getMessage(), e); - } + CredentialIssuerMetadataResponse response = oauth.oid4vc() + .issuerMetadataRequest() + .endpoint(wellKnownUri) + .header(HttpHeaders.ACCEPT, org.keycloak.utils.MediaType.APPLICATION_JWT) + .send(); + + assertEquals(HttpStatus.SC_OK, response.getStatusCode()); + assertEquals("Content-Type should be application/json due to invalid algorithm", + MediaType.APPLICATION_JSON, response.getHeader(HttpHeaders.CONTENT_TYPE)); + + CredentialIssuer issuer = response.getMetadata(); + assertNotNull("Response should be a CredentialIssuer object", issuer); + assertEquals("credential_issuer should be set", expectedIssuer, issuer.getCredentialIssuer()); } /** @@ -501,38 +493,37 @@ public class OID4VCIssuerWellKnownProviderTest extends OID4VCIssuerEndpointTest @Test public void testIssuerMetadataIncludesEncryptionSupport() throws IOException { - try (Client client = AdminClientUtil.createResteasyClient()) { - String wellKnownUri = getRealmMetadataPath(TEST_REALM_NAME); - WebTarget oid4vciDiscoveryTarget = client.target(wellKnownUri); + String wellKnownUri = getRealmMetadataPath(TEST_REALM_NAME); - try (Response discoveryResponse = oid4vciDiscoveryTarget.request().get()) { - CredentialIssuer oid4vciIssuerConfig = JsonSerialization.readValue( - discoveryResponse.readEntity(String.class), CredentialIssuer.class); + CredentialIssuer oid4vciIssuerConfig = oauth.oid4vc() + .issuerMetadataRequest() + .endpoint(wellKnownUri) + .send() + .getMetadata(); + + assertNotNull("Encryption support should be advertised in metadata", + oid4vciIssuerConfig.getCredentialResponseEncryption()); + assertFalse("Supported algorithms should not be empty", + oid4vciIssuerConfig.getCredentialResponseEncryption().getAlgValuesSupported().isEmpty()); + assertFalse("Supported encryption methods should not be empty", + oid4vciIssuerConfig.getCredentialResponseEncryption().getEncValuesSupported().isEmpty()); + assertNotNull("zip_values_supported should be present", + oid4vciIssuerConfig.getCredentialResponseEncryption().getZipValuesSupported()); + assertTrue("Supported algorithms should include RSA-OAEP", + oid4vciIssuerConfig.getCredentialResponseEncryption().getAlgValuesSupported().contains("RSA-OAEP")); + assertTrue("Supported encryption methods should include A256GCM", + oid4vciIssuerConfig.getCredentialResponseEncryption().getEncValuesSupported().contains("A256GCM")); + assertNotNull("Credential request encryption should be advertised in metadata", + oid4vciIssuerConfig.getCredentialRequestEncryption()); + assertFalse("Supported encryption methods should not be empty", + oid4vciIssuerConfig.getCredentialRequestEncryption().getEncValuesSupported().isEmpty()); + assertNotNull("zip_values_supported should be present", + oid4vciIssuerConfig.getCredentialRequestEncryption().getZipValuesSupported()); + assertTrue("Supported encryption methods should include A256GCM", + oid4vciIssuerConfig.getCredentialRequestEncryption().getEncValuesSupported().contains("A256GCM")); + assertNotNull("JWKS should be present in credential request encryption", + oid4vciIssuerConfig.getCredentialRequestEncryption().getJwks()); - assertNotNull("Encryption support should be advertised in metadata", - oid4vciIssuerConfig.getCredentialResponseEncryption()); - assertFalse("Supported algorithms should not be empty", - oid4vciIssuerConfig.getCredentialResponseEncryption().getAlgValuesSupported().isEmpty()); - assertFalse("Supported encryption methods should not be empty", - oid4vciIssuerConfig.getCredentialResponseEncryption().getEncValuesSupported().isEmpty()); - assertNotNull("zip_values_supported should be present", - oid4vciIssuerConfig.getCredentialResponseEncryption().getZipValuesSupported()); - assertTrue("Supported algorithms should include RSA-OAEP", - oid4vciIssuerConfig.getCredentialResponseEncryption().getAlgValuesSupported().contains("RSA-OAEP")); - assertTrue("Supported encryption methods should include A256GCM", - oid4vciIssuerConfig.getCredentialResponseEncryption().getEncValuesSupported().contains("A256GCM")); - assertNotNull("Credential request encryption should be advertised in metadata", - oid4vciIssuerConfig.getCredentialRequestEncryption()); - assertFalse("Supported encryption methods should not be empty", - oid4vciIssuerConfig.getCredentialRequestEncryption().getEncValuesSupported().isEmpty()); - assertNotNull("zip_values_supported should be present", - oid4vciIssuerConfig.getCredentialRequestEncryption().getZipValuesSupported()); - assertTrue("Supported encryption methods should include A256GCM", - oid4vciIssuerConfig.getCredentialRequestEncryption().getEncValuesSupported().contains("A256GCM")); - assertNotNull("JWKS should be present in credential request encryption", - oid4vciIssuerConfig.getCredentialRequestEncryption().getJwks()); - } - } } private void compareMetadataToClientScope(CredentialIssuer credentialIssuer, ClientScopeRepresentation clientScope) { @@ -826,58 +817,58 @@ public class OID4VCIssuerWellKnownProviderTest extends OID4VCIssuerEndpointTest @Test public void testOldOidcDiscoveryCompliantWellKnownUrlWithDeprecationHeaders() { - try (CloseableHttpClient httpClient = HttpClientBuilder.create().build()) { - // Old OIDC Discovery compliant URL - String oldWellKnownUri = OAuthClient.AUTH_SERVER_ROOT + "/realms/" + TEST_REALM_NAME + "/.well-known/openid-credential-issuer"; - String expectedIssuer = getRealmPath(TEST_REALM_NAME); + // Old OIDC Discovery compliant URL + String oldWellKnownUri = OAuthClient.AUTH_SERVER_ROOT + "/realms/" + TEST_REALM_NAME + "/.well-known/openid-credential-issuer"; + String expectedIssuer = getRealmPath(TEST_REALM_NAME); - HttpGet getMetadata = new HttpGet(oldWellKnownUri); - getMetadata.addHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON); + try { + CredentialIssuerMetadataResponse response = oauth.oid4vc() + .issuerMetadataRequest() + .endpoint(oldWellKnownUri) + .header(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON) + .send(); - try (CloseableHttpResponse response = httpClient.execute(getMetadata)) { - // Status & Content-Type - assertEquals("Old well-known URL should return 200 OK", - HttpStatus.SC_OK, response.getStatusLine().getStatusCode()); + // Status & Content-Type + assertEquals("Old well-known URL should return 200 OK", + HttpStatus.SC_OK, response.getStatusCode()); - String contentType = response.getFirstHeader(HttpHeaders.CONTENT_TYPE).getValue(); - assertTrue("Content-Type should be application/json", - contentType.startsWith(MediaType.APPLICATION_JSON)); + String contentType = response.getHeader(HttpHeaders.CONTENT_TYPE); + assertTrue("Content-Type should be application/json", + contentType.startsWith(MediaType.APPLICATION_JSON)); - // Headers - Header warning = response.getFirstHeader("Warning"); - Header deprecation = response.getFirstHeader("Deprecation"); - Header link = response.getFirstHeader("Link"); + // Headers + String warning = response.getHeader("Warning"); + String deprecation = response.getHeader("Deprecation"); + String link = response.getHeader("Link"); - assertNotNull("Should have deprecation warning header", warning); - assertTrue("Warning header should contain deprecation message", - warning.getValue().contains("Deprecated endpoint")); + assertNotNull("Should have deprecation warning header", warning); + assertTrue("Warning header should contain deprecation message", + warning.contains("Deprecated endpoint")); - assertNotNull("Should have deprecation header", deprecation); - assertEquals("Deprecation header should be 'true'", "true", deprecation.getValue()); + assertNotNull("Should have deprecation header", deprecation); + assertEquals("Deprecation header should be 'true'", "true", deprecation); - assertNotNull("Should have successor link header", link); - assertTrue("Link header should contain successor-version", - link.getValue().contains("successor-version")); + assertNotNull("Should have successor link header", link); + assertTrue("Link header should contain successor-version", + link.contains("successor-version")); - // Response body - String json = EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8); - CredentialIssuer issuer = JsonSerialization.readValue(json, CredentialIssuer.class); + // Response body + CredentialIssuer issuer = response.getMetadata(); - assertNotNull("Response should be a CredentialIssuer object", issuer); + assertNotNull("Response should be a CredentialIssuer object", issuer); - assertEquals("credential_issuer should be set", - expectedIssuer, issuer.getCredentialIssuer()); - assertEquals("credential_endpoint should be correct", - expectedIssuer + "/protocol/oid4vc/credential", issuer.getCredentialEndpoint()); - assertEquals("nonce_endpoint should be correct", - expectedIssuer + "/protocol/oid4vc/nonce", issuer.getNonceEndpoint()); - assertNull("deferred_credential_endpoint should be omitted", - issuer.getDeferredCredentialEndpoint()); + assertEquals("credential_issuer should be set", + expectedIssuer, issuer.getCredentialIssuer()); + assertEquals("credential_endpoint should be correct", + expectedIssuer + "/protocol/oid4vc/credential", issuer.getCredentialEndpoint()); + assertEquals("nonce_endpoint should be correct", + expectedIssuer + "/protocol/oid4vc/nonce", issuer.getNonceEndpoint()); + assertNull("deferred_credential_endpoint should be omitted", + issuer.getDeferredCredentialEndpoint()); - assertNotNull("authorization_servers should be present", issuer.getAuthorizationServers()); - assertNotNull("credential_response_encryption should be present", issuer.getCredentialResponseEncryption()); - assertNotNull("batch_credential_issuance should be present", issuer.getBatchCredentialIssuance()); - } + assertNotNull("authorization_servers should be present", issuer.getAuthorizationServers()); + assertNotNull("credential_response_encryption should be present", issuer.getCredentialResponseEncryption()); + assertNotNull("batch_credential_issuance should be present", issuer.getBatchCredentialIssuance()); } catch (Exception e) { throw new RuntimeException("Failed to process old well-known URL response: " + e.getMessage(), e); } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCJWTIssuerEndpointTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCJWTIssuerEndpointTest.java index 36b3a05e145..bbdae6369c5 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCJWTIssuerEndpointTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCJWTIssuerEndpointTest.java @@ -17,9 +17,7 @@ package org.keycloak.testsuite.oid4vc.issuance.signing; import java.io.IOException; -import java.nio.charset.StandardCharsets; import java.util.Arrays; -import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.function.BiFunction; @@ -32,7 +30,6 @@ import jakarta.ws.rs.core.HttpHeaders; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; -import org.keycloak.OAuth2Constants; import org.keycloak.TokenVerifier; import org.keycloak.common.VerificationException; import org.keycloak.common.util.Time; @@ -66,14 +63,7 @@ import org.keycloak.services.managers.AppAuthManager.BearerTokenAuthenticator; import org.keycloak.testsuite.util.oauth.AccessTokenResponse; import org.keycloak.util.JsonSerialization; -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.Assert; import org.junit.Test; @@ -426,51 +416,51 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest { // 1. Retrieving the credential-offer-uri final String credentialConfigurationId = jwtTypeCredentialClientScope.getAttributes() .get(CredentialScopeModel.CONFIGURATION_ID); - HttpGet getCredentialOfferURI = new HttpGet(getCredentialOfferUriUrl(credentialConfigurationId)); - getCredentialOfferURI.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token); - CloseableHttpResponse credentialOfferURIResponse = httpClient.execute(getCredentialOfferURI); + CredentialOfferURI credentialOfferURI = oauth.oid4vc() + .credentialOfferUriRequest() + .credentialConfigurationId(credentialConfigurationId) + .preAuthorized(true) + .username("john") + .bearerToken(token) + .send() + .getCredentialOfferURI(); - assertEquals("A valid offer uri should be returned", - HttpStatus.SC_OK, - credentialOfferURIResponse.getStatusLine().getStatusCode()); - String s = IOUtils.toString(credentialOfferURIResponse.getEntity().getContent(), StandardCharsets.UTF_8); - CredentialOfferURI credentialOfferURI = JsonSerialization.readValue(s, CredentialOfferURI.class); + assertNotNull("A valid offer uri should be returned", credentialOfferURI); // 2. Using the uri to get the actual credential offer - HttpGet getCredentialOffer = new HttpGet(credentialOfferURI.getIssuer() + "/" + credentialOfferURI.getNonce()); - CloseableHttpResponse credentialOfferResponse = httpClient.execute(getCredentialOffer); + CredentialsOffer credentialsOffer = oauth.oid4vc() + .credentialOfferRequest(credentialOfferURI.getNonce()) + .bearerToken(token) + .send() + .getCredentialsOffer(); - assertEquals("A valid offer should be returned", HttpStatus.SC_OK, credentialOfferResponse.getStatusLine().getStatusCode()); - s = IOUtils.toString(credentialOfferResponse.getEntity().getContent(), StandardCharsets.UTF_8); - CredentialsOffer credentialsOffer = JsonSerialization.readValue(s, CredentialsOffer.class); + assertNotNull("A valid offer should be returned", credentialsOffer); // 3. Get the issuer metadata - HttpGet getIssuerMetadata = new HttpGet(credentialsOffer.getIssuerMetadataUrl()); - CloseableHttpResponse issuerMetadataResponse = httpClient.execute(getIssuerMetadata); - assertEquals(HttpStatus.SC_OK, issuerMetadataResponse.getStatusLine().getStatusCode()); - s = IOUtils.toString(issuerMetadataResponse.getEntity().getContent(), StandardCharsets.UTF_8); - CredentialIssuer credentialIssuer = JsonSerialization.readValue(s, CredentialIssuer.class); + CredentialIssuer credentialIssuer = oauth.oid4vc() + .issuerMetadataRequest() + .endpoint(credentialsOffer.getIssuerMetadataUrl()) + .send() + .getMetadata(); + assertNotNull("Issuer metadata should be returned", credentialIssuer); assertEquals("We only expect one authorization server.", 1, credentialIssuer.getAuthorizationServers().size()); // 4. Get the openid-configuration - HttpGet getOpenidConfiguration = new HttpGet(credentialIssuer.getAuthorizationServers().get(0) + "/.well-known/openid-configuration"); - CloseableHttpResponse openidConfigResponse = httpClient.execute(getOpenidConfiguration); - assertEquals(HttpStatus.SC_OK, openidConfigResponse.getStatusLine().getStatusCode()); - s = IOUtils.toString(openidConfigResponse.getEntity().getContent(), StandardCharsets.UTF_8); - OIDCConfigurationRepresentation openidConfig = JsonSerialization.readValue(s, OIDCConfigurationRepresentation.class); + OIDCConfigurationRepresentation openidConfig = oauth + .wellknownRequest() + .url(credentialIssuer.getAuthorizationServers().get(0)) + .send() + .getOidcConfiguration(); assertNotNull("A token endpoint should be included.", openidConfig.getTokenEndpoint()); assertTrue("The pre-authorized code should be supported.", openidConfig.getGrantTypesSupported().contains(PreAuthorizedCodeGrantTypeFactory.GRANT_TYPE)); // 5. Get an access token for the pre-authorized code - HttpPost postPreAuthorizedCode = new HttpPost(openidConfig.getTokenEndpoint()); - List parameters = new LinkedList<>(); - parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, PreAuthorizedCodeGrantTypeFactory.GRANT_TYPE)); - parameters.add(new BasicNameValuePair(PreAuthorizedCodeGrantTypeFactory.CODE_REQUEST_PARAM, credentialsOffer.getGrants().getPreAuthorizedCode().getPreAuthorizedCode())); - UrlEncodedFormEntity formEntity = new UrlEncodedFormEntity(parameters, StandardCharsets.UTF_8); - postPreAuthorizedCode.setEntity(formEntity); - AccessTokenResponse accessTokenResponse = new AccessTokenResponse(httpClient.execute(postPreAuthorizedCode)); + AccessTokenResponse accessTokenResponse = oauth.oid4vc() + .preAuthorizedCodeGrantRequest(credentialsOffer.getGrants().getPreAuthorizedCode().getPreAuthorizedCode()) + .send(); + assertEquals(HttpStatus.SC_OK, accessTokenResponse.getStatusCode()); String theToken = accessTokenResponse.getAccessToken(); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCJwtAuthorizationCodeFlowTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCJwtAuthorizationCodeFlowTest.java index 1d0ecf3380a..c11861dd0d6 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCJwtAuthorizationCodeFlowTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCJwtAuthorizationCodeFlowTest.java @@ -17,12 +17,8 @@ package org.keycloak.testsuite.oid4vc.issuance.signing; -import org.keycloak.jose.jws.JWSHeader; -import org.keycloak.jose.jws.JWSInput; -import org.keycloak.representations.JsonWebToken; import org.keycloak.representations.idm.ClientScopeRepresentation; -import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; @@ -67,16 +63,4 @@ public class OID4VCJwtAuthorizationCodeFlowTest extends OID4VCAuthorizationCodeF // Verify it looks like a JWT (contains dots) assertTrue("JWT should contain dots", jwtString.contains(".")); } - - @Override - protected JWSHeader verifyCredentialSignature(Object vcCredential, String expectedSignatureAlgorithm) throws Exception { - String vcCredentialString = vcCredential.toString(); - JWSInput jwsInput = new JWSInput(vcCredentialString); - JWSHeader header = jwsInput.getHeader(); - - assertEquals(expectedSignatureAlgorithm, header.getRawAlgorithm()); - oauth.verifyToken(vcCredentialString, JsonWebToken.class); - - return header; - } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCSdJwtAuthorizationCodeFlowTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCSdJwtAuthorizationCodeFlowTest.java index 4b1a4a04498..af0b2e1dc4d 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCSdJwtAuthorizationCodeFlowTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCSdJwtAuthorizationCodeFlowTest.java @@ -17,15 +17,10 @@ package org.keycloak.testsuite.oid4vc.issuance.signing; -import org.keycloak.jose.jws.JWSHeader; -import org.keycloak.representations.JsonWebToken; import org.keycloak.representations.idm.ClientScopeRepresentation; -import org.keycloak.sdjwt.IssuerSignedJWT; -import org.keycloak.sdjwt.vp.SdJwtVP; import static org.keycloak.OID4VCConstants.SDJWT_DELIMITER; -import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; @@ -71,17 +66,4 @@ public class OID4VCSdJwtAuthorizationCodeFlowTest extends OID4VCAuthorizationCod assertTrue("SD-JWT should contain dots", sdJwtString.contains(".")); assertTrue("SD-JWT should contain tilde", sdJwtString.contains(SDJWT_DELIMITER)); } - - @Override - protected JWSHeader verifyCredentialSignature(Object vcCredential, String expectedSignatureAlgorithm) { - IssuerSignedJWT issuerSignedJWT = SdJwtVP.of(vcCredential.toString()) - .getIssuerSignedJWT(); - JWSHeader header = issuerSignedJWT.getJwsHeader(); - - assertEquals(expectedSignatureAlgorithm, header.getRawAlgorithm()); - - oauth.verifyToken(issuerSignedJWT.getJws(), JsonWebToken.class); - return header; - } - } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCSdJwtIssuingEndpointTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCSdJwtIssuingEndpointTest.java index cec13c078b8..4f58e40d61b 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCSdJwtIssuingEndpointTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCSdJwtIssuingEndpointTest.java @@ -17,18 +17,14 @@ package org.keycloak.testsuite.oid4vc.issuance.signing; import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.UUID; import java.util.stream.Collectors; import jakarta.ws.rs.BadRequestException; -import jakarta.ws.rs.core.HttpHeaders; import jakarta.ws.rs.core.Response; -import org.keycloak.OAuth2Constants; import org.keycloak.TokenVerifier; import org.keycloak.common.VerificationException; import org.keycloak.common.util.Base64Url; @@ -66,15 +62,8 @@ import org.keycloak.testsuite.util.oauth.AccessTokenResponse; import org.keycloak.util.JsonSerialization; import com.fasterxml.jackson.databind.JsonNode; -import org.apache.commons.io.IOUtils; import org.apache.commons.lang3.exception.ExceptionUtils; 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.Assert; import org.junit.Test; @@ -303,50 +292,51 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest { // 1. Retrieving the credential-offer-uri final String credentialConfigurationId = clientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID); - HttpGet getCredentialOfferURI = new HttpGet(getCredentialOfferUriUrl(credentialConfigurationId)); - getCredentialOfferURI.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token); - CloseableHttpResponse credentialOfferURIResponse = httpClient.execute(getCredentialOfferURI); + CredentialOfferURI credentialOfferURI = oauth.oid4vc() + .credentialOfferUriRequest() + .credentialConfigurationId(credentialConfigurationId) + .preAuthorized(true) + .username("john") + .bearerToken(token) + .send() + .getCredentialOfferURI(); - assertEquals("A valid offer uri should be returned", HttpStatus.SC_OK, credentialOfferURIResponse.getStatusLine().getStatusCode()); - String s = IOUtils.toString(credentialOfferURIResponse.getEntity().getContent(), StandardCharsets.UTF_8); - CredentialOfferURI credentialOfferURI = JsonSerialization.readValue(s, CredentialOfferURI.class); + assertNotNull("A valid offer uri should be returned", credentialOfferURI); // 2. Using the uri to get the actual credential offer - HttpGet getCredentialOffer = new HttpGet(credentialOfferURI.getIssuer() + "/" + credentialOfferURI.getNonce()); - getCredentialOffer.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token); - CloseableHttpResponse credentialOfferResponse = httpClient.execute(getCredentialOffer); + CredentialsOffer credentialsOffer = oauth.oid4vc() + .credentialOfferRequest(credentialOfferURI.getNonce()) + .bearerToken(token) + .send() + .getCredentialsOffer(); - assertEquals("A valid offer should be returned", HttpStatus.SC_OK, credentialOfferResponse.getStatusLine().getStatusCode()); - s = IOUtils.toString(credentialOfferResponse.getEntity().getContent(), StandardCharsets.UTF_8); - CredentialsOffer credentialsOffer = JsonSerialization.readValue(s, CredentialsOffer.class); + assertNotNull("A valid offer should be returned", credentialsOffer); // 3. Get the issuer metadata - HttpGet getIssuerMetadata = new HttpGet(credentialsOffer.getIssuerMetadataUrl()); - CloseableHttpResponse issuerMetadataResponse = httpClient.execute(getIssuerMetadata); - assertEquals(HttpStatus.SC_OK, issuerMetadataResponse.getStatusLine().getStatusCode()); - s = IOUtils.toString(issuerMetadataResponse.getEntity().getContent(), StandardCharsets.UTF_8); - CredentialIssuer credentialIssuer = JsonSerialization.readValue(s, CredentialIssuer.class); + CredentialIssuer credentialIssuer = oauth.oid4vc() + .issuerMetadataRequest() + .endpoint(credentialsOffer.getIssuerMetadataUrl()) + .send() + .getMetadata(); + assertNotNull("Issuer metadata should be returned", credentialIssuer); assertEquals("We only expect one authorization server.", 1, credentialIssuer.getAuthorizationServers().size()); // 4. Get the openid-configuration - HttpGet getOpenidConfiguration = new HttpGet(credentialIssuer.getAuthorizationServers().get(0) + "/.well-known/openid-configuration"); - CloseableHttpResponse openidConfigResponse = httpClient.execute(getOpenidConfiguration); - assertEquals(HttpStatus.SC_OK, openidConfigResponse.getStatusLine().getStatusCode()); - s = IOUtils.toString(openidConfigResponse.getEntity().getContent(), StandardCharsets.UTF_8); - OIDCConfigurationRepresentation openidConfig = JsonSerialization.readValue(s, OIDCConfigurationRepresentation.class); + OIDCConfigurationRepresentation openidConfig = oauth + .wellknownRequest() + .url(credentialIssuer.getAuthorizationServers().get(0)) + .send() + .getOidcConfiguration(); assertNotNull("A token endpoint should be included.", openidConfig.getTokenEndpoint()); assertTrue("The pre-authorized code should be supported.", openidConfig.getGrantTypesSupported().contains(PreAuthorizedCodeGrantTypeFactory.GRANT_TYPE)); // 5. Get an access token for the pre-authorized code - HttpPost postPreAuthorizedCode = new HttpPost(openidConfig.getTokenEndpoint()); - List parameters = new LinkedList<>(); - parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, PreAuthorizedCodeGrantTypeFactory.GRANT_TYPE)); - parameters.add(new BasicNameValuePair(PreAuthorizedCodeGrantTypeFactory.CODE_REQUEST_PARAM, credentialsOffer.getGrants().getPreAuthorizedCode().getPreAuthorizedCode())); - UrlEncodedFormEntity formEntity = new UrlEncodedFormEntity(parameters, StandardCharsets.UTF_8); - postPreAuthorizedCode.setEntity(formEntity); - AccessTokenResponse accessTokenResponse = new AccessTokenResponse(httpClient.execute(postPreAuthorizedCode)); + AccessTokenResponse accessTokenResponse = oauth.oid4vc() + .preAuthorizedCodeGrantRequest(credentialsOffer.getGrants().getPreAuthorizedCode().getPreAuthorizedCode()) + .send(); + assertEquals(HttpStatus.SC_OK, accessTokenResponse.getStatusCode()); String theToken = accessTokenResponse.getAccessToken();