[OID4VCI] Add OID4VCI request/response support to OAuthClient utility (#45784)

closes: #44671


Signed-off-by: forkimenjeckayang <forkimenjeckayang@gmail.com>
This commit is contained in:
forkimenjeckayang 2026-01-28 11:54:42 +01:00 committed by GitHub
parent 5e3c0b6b28
commit f2f185b367
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 1943 additions and 1627 deletions

View file

@ -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;

View file

@ -61,6 +61,10 @@ public class AuthorizationDetailsProcessorManager {
List<AuthorizationDetailsJSONRepresentation> authzDetails = parseAuthorizationDetails(authorizationDetailsParam);
if (authzDetails.isEmpty()) {
throw new InvalidAuthorizationDetailsException("Authorization_Details parameter cannot be empty");
}
Map<String, AuthorizationDetailsProcessor<?>> processors = getProcessors(session);
for (AuthorizationDetailsJSONRepresentation authzDetail : authzDetails) {

View file

@ -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<R> {
public abstract class AbstractHttpGetRequest<T, R> {
protected final AbstractOAuthClient<?> client;
private HttpGet get;
protected String endpointOverride;
protected String bearerToken;
protected Map<String, String> 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<String, String> 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<R> {
}
}
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;
}
}

View file

@ -34,16 +34,30 @@ public abstract class AbstractHttpPostRequest<T, R> {
protected Map<String, String> headers = new HashMap<>();
protected List<NameValuePair> 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());

View file

@ -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<T> {
return new DeviceClient(this);
}
public OID4VCClient oid4vc() {
return new OID4VCClient(this);
}
public ParRequest pushedAuthorizationRequest() {
return new ParRequest(this);
}

View file

@ -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<AuthorizationDetailsJSONRepresentation> authorizationDetails;
private Map<String, Object> responseJson;
private Map<String, Object> otherClaims;
@ -32,8 +36,8 @@ public class AccessTokenResponse extends AbstractHttpResponse {
}
protected void parseContent() throws IOException {
@SuppressWarnings("unchecked")
Map<String, Object> responseJson = asJson(Map.class);
this.responseJson = responseJson;
otherClaims = new HashMap<>();
@ -123,12 +127,37 @@ public class AccessTokenResponse extends AbstractHttpResponse {
}
public <ADR extends AuthorizationDetailsJSONRepresentation> List<ADR> getAuthorizationDetails(Class<ADR> 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<OID4VCAuthorizationDetailResponse> 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<List<OID4VCAuthorizationDetailResponse>>() {
}
);
} catch (IOException e) {
throw new RuntimeException("Failed to parse authorization_details from token response", e);
}
}
}

View file

@ -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);
}

View file

@ -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<AccessTokenResponse> {
public class FetchExternalIdpTokenRequest extends AbstractHttpGetRequest<FetchExternalIdpTokenRequest, AccessTokenResponse> {
private final String providerAlias;
private final String accessToken;

View file

@ -4,12 +4,16 @@ import java.io.IOException;
import org.apache.http.client.methods.CloseableHttpResponse;
public class OpenIDProviderConfigurationRequest extends AbstractHttpGetRequest<OpenIDProviderConfigurationResponse> {
public class OpenIDProviderConfigurationRequest extends AbstractHttpGetRequest<OpenIDProviderConfigurationRequest, OpenIDProviderConfigurationResponse> {
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();

View file

@ -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<ParRequest, ParResponse> {
private boolean scopeExplicitlySet = false;
public ParRequest(AbstractOAuthClient<?> client) {
super(client);
}
@ -58,6 +62,11 @@ public class ParRequest extends AbstractHttpPostRequest<ParRequest, ParResponse>
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<ParRequest, ParResponse>
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

View file

@ -6,7 +6,7 @@ import org.keycloak.util.TokenUtil;
import org.apache.http.client.methods.CloseableHttpResponse;
public class UserInfoRequest extends AbstractHttpGetRequest<UserInfoResponse> {
public class UserInfoRequest extends AbstractHttpGetRequest<UserInfoRequest, UserInfoResponse> {
private final String token;

View file

@ -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<T, R> {
protected final AbstractOAuthClient<?> client;
protected String bearerToken;
protected String endpointOverride;
protected Map<String, String> 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;
}
}

View file

@ -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<CredentialIssuerMetadataRequest, CredentialIssuerMetadataResponse> {
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);
}
}

View file

@ -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;
}
}

View file

@ -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<CredentialOfferRequest, CredentialOfferResponse> {
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);
}
}

View file

@ -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;
}
}

View file

@ -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<CredentialOfferUriRequest, CredentialOfferUriResponse> {
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);
}
}

View file

@ -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;
}
}

View file

@ -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();
}
}

View file

@ -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<Oid4vcCredentialRequest, Oid4vcCredentialResponse> {
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);
}
}

View file

@ -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;
}
}

View file

@ -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<Oid4vcNonceRequest, Oid4vcNonceResponse> {
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);
}
}

View file

@ -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;
}
}

View file

@ -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<PreAuthorizedCodeGrantRequest, AccessTokenResponse> {
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);
}
}

View file

@ -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<OID4VCAuthorizationDetailResponse> authDetails = accessToken.getAuthorizationDetails(OID4VCAuthorizationDetailResponse.class);
if (authDetails == null)
List<OID4VCAuthorizationDetailResponse> 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<NameValuePair> 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;

View file

@ -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<NameValuePair> 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<OID4VCAuthorizationDetailResponse> authDetails = tokenResponseRef.getOid4vcAuthorizationDetails();
String credentialIdentifier = null;
if (authDetails != null && !authDetails.isEmpty()) {
List<String> 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<String, String, CredentialRequest> credRequestSupplier = (credentialConfigurationId, credentialIdentifier) -> {
CredentialRequest credentialRequest = new CredentialRequest();
credentialRequest.setCredentialIdentifier(credentialIdentifier);
return credentialRequest;
};
ClientScopeResource clientScope = ApiUtil.findClientScopeByName(testRealm(), getCredentialClientScope().getName());
ClientScopeRepresentation clientScopeRep = clientScope.toRepresentation();
Map<String, String> 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<String, String, CredentialRequest> credentialRequestSupplier) throws Exception {
private void testCompleteFlowWithClaimsValidationAuthorizationCode(BiFunction<String, String, CredentialRequest> 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<OID4VCAuthorizationDetail> authDetails = List.of(authDetail);
String authDetailsJson = JsonSerialization.writeValueAsString(authDetails);
// Exchange authorization code for tokens with authorization_details
HttpPost postToken = new HttpPost(ctx.openidConfig.getTokenEndpoint());
List<NameValuePair> tokenParameters = new LinkedList<>();
tokenParameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, OAuth2Constants.AUTHORIZATION_CODE));
tokenParameters.add(new BasicNameValuePair(OAuth2Constants.CODE, code));
tokenParameters.add(new BasicNameValuePair(OAuth2Constants.REDIRECT_URI, oauth.getRedirectUri()));
tokenParameters.add(new BasicNameValuePair(OAuth2Constants.CLIENT_ID, oauth.getClientId()));
tokenParameters.add(new BasicNameValuePair(OAuth2Constants.CLIENT_SECRET, "password"));
tokenParameters.add(new BasicNameValuePair(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<OID4VCAuthorizationDetailResponse> authDetailsResponse = parseAuthorizationDetails(tokenResponse);
List<OID4VCAuthorizationDetailResponse> 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<String, String, CredentialRequest> credentialRequestSupplier, AccessTokenResponse tokenResponse,
String credentialConfigurationId, String credentialIdentifier) throws Exception {
private Oid4vcCredentialRequest getCredentialRequest(Oid4vcTestContext ctx, BiFunction<String, String, CredentialRequest> 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<OID4VCAuthorizationDetailResponse> parseAuthorizationDetails(AccessTokenResponse tokenResponse) {
return tokenResponse.getAuthorizationDetails()
.stream()
.map(authzDetailsResponse -> authzDetailsResponse.asSubtype(OID4VCAuthorizationDetailResponse.class))
.toList();
}
}

View file

@ -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<OID4VCAuthorizationDetail> authDetails = List.of(authDetail);
String authDetailsJson = JsonSerialization.writeValueAsString(authDetails);
// Create PAR request
HttpPost parRequest = new HttpPost(ctx.openidConfig.getPushedAuthorizationRequestEndpoint());
List<NameValuePair> parParameters = new LinkedList<>();
parParameters.add(new BasicNameValuePair(OAuth2Constants.RESPONSE_TYPE, OAuth2Constants.CODE));
parParameters.add(new BasicNameValuePair(OAuth2Constants.CLIENT_ID, oauth.getClientId()));
parParameters.add(new BasicNameValuePair(OAuth2Constants.CLIENT_SECRET, "password"));
parParameters.add(new BasicNameValuePair(OAuth2Constants.REDIRECT_URI, oauth.getRedirectUri()));
parParameters.add(new BasicNameValuePair(OAuth2Constants.SCOPE, getCredentialClientScope().getName()));
parParameters.add(new BasicNameValuePair("authorization_details", authDetailsJson));
parParameters.add(new BasicNameValuePair(OAuth2Constants.STATE, "test-state"));
parParameters.add(new BasicNameValuePair(OIDCLoginProtocol.NONCE_PARAM, "test-nonce"));
UrlEncodedFormEntity parFormEntity = new UrlEncodedFormEntity(parParameters, StandardCharsets.UTF_8);
parRequest.setEntity(parFormEntity);
String requestUri;
try (CloseableHttpResponse parResponse = httpClient.execute(parRequest)) {
assertEquals(HttpStatus.SC_CREATED, parResponse.getStatusLine().getStatusCode());
String parResponseBody = IOUtils.toString(parResponse.getEntity().getContent(), StandardCharsets.UTF_8);
Map<String, Object> parResult = JsonSerialization.readValue(parResponseBody, Map.class);
requestUri = (String) parResult.get("request_uri");
assertNotNull("Request URI should not be null", requestUri);
}
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<NameValuePair> tokenParameters = new LinkedList<>();
tokenParameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, OAuth2Constants.AUTHORIZATION_CODE));
tokenParameters.add(new BasicNameValuePair(OAuth2Constants.CODE, code));
tokenParameters.add(new BasicNameValuePair(OAuth2Constants.REDIRECT_URI, oauth.getRedirectUri()));
tokenParameters.add(new BasicNameValuePair(OAuth2Constants.CLIENT_ID, oauth.getClientId()));
tokenParameters.add(new BasicNameValuePair(OAuth2Constants.CLIENT_SECRET, "password"));
// Note: NO authorization_details parameter in token request - it should come from PAR
UrlEncodedFormEntity tokenFormEntity = new UrlEncodedFormEntity(tokenParameters, StandardCharsets.UTF_8);
postToken.setEntity(tokenFormEntity);
AccessTokenResponse tokenResponse;
try (CloseableHttpResponse tokenHttpResponse = httpClient.execute(postToken)) {
assertEquals(HttpStatus.SC_OK, tokenHttpResponse.getStatusLine().getStatusCode());
String tokenResponseBody = IOUtils.toString(tokenHttpResponse.getEntity().getContent(), StandardCharsets.UTF_8);
tokenResponse = JsonSerialization.readValue(tokenResponseBody, AccessTokenResponse.class);
}
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<OID4VCAuthorizationDetailResponse> authDetailsResponse = parseAuthorizationDetails(JsonSerialization.writeValueAsString(tokenResponse));
List<OID4VCAuthorizationDetailResponse> 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<OID4VCAuthorizationDetail> authDetails = List.of(authDetail);
String authDetailsJson = JsonSerialization.writeValueAsString(authDetails);
// Create PAR request
HttpPost parRequest = new HttpPost(ctx.openidConfig.getPushedAuthorizationRequestEndpoint());
List<NameValuePair> parParameters = new LinkedList<>();
parParameters.add(new BasicNameValuePair(OAuth2Constants.RESPONSE_TYPE, OAuth2Constants.CODE));
parParameters.add(new BasicNameValuePair(OAuth2Constants.CLIENT_ID, oauth.getClientId()));
parParameters.add(new BasicNameValuePair(OAuth2Constants.CLIENT_SECRET, "password"));
parParameters.add(new BasicNameValuePair(OAuth2Constants.REDIRECT_URI, oauth.getRedirectUri()));
parParameters.add(new BasicNameValuePair(OAuth2Constants.SCOPE, getCredentialClientScope().getName()));
parParameters.add(new BasicNameValuePair("authorization_details", authDetailsJson));
parParameters.add(new BasicNameValuePair(OAuth2Constants.STATE, "test-state"));
parParameters.add(new BasicNameValuePair(OIDCLoginProtocol.NONCE_PARAM, "test-nonce"));
UrlEncodedFormEntity parFormEntity = new UrlEncodedFormEntity(parParameters, StandardCharsets.UTF_8);
parRequest.setEntity(parFormEntity);
String requestUri;
try (CloseableHttpResponse parResponse = httpClient.execute(parRequest)) {
assertEquals(HttpStatus.SC_CREATED, parResponse.getStatusLine().getStatusCode());
String parResponseBody = IOUtils.toString(parResponse.getEntity().getContent(), StandardCharsets.UTF_8);
Map<String, Object> parResult = JsonSerialization.readValue(parResponseBody, Map.class);
requestUri = (String) parResult.get("request_uri");
assertNotNull("Request URI should not be null", requestUri);
}
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<NameValuePair> tokenParameters = new LinkedList<>();
tokenParameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, OAuth2Constants.AUTHORIZATION_CODE));
tokenParameters.add(new BasicNameValuePair(OAuth2Constants.CODE, code));
tokenParameters.add(new BasicNameValuePair(OAuth2Constants.REDIRECT_URI, oauth.getRedirectUri()));
tokenParameters.add(new BasicNameValuePair(OAuth2Constants.CLIENT_ID, oauth.getClientId()));
tokenParameters.add(new BasicNameValuePair(OAuth2Constants.CLIENT_SECRET, "password"));
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<NameValuePair> parParameters = new LinkedList<>();
parParameters.add(new BasicNameValuePair(OAuth2Constants.RESPONSE_TYPE, OAuth2Constants.CODE));
parParameters.add(new BasicNameValuePair(OAuth2Constants.CLIENT_ID, oauth.getClientId()));
parParameters.add(new BasicNameValuePair(OAuth2Constants.CLIENT_SECRET, "password"));
parParameters.add(new BasicNameValuePair(OAuth2Constants.REDIRECT_URI, oauth.getRedirectUri()));
parParameters.add(new BasicNameValuePair(OAuth2Constants.SCOPE, getCredentialClientScope().getName()));
parParameters.add(new BasicNameValuePair(OAuth2Constants.STATE, "test-state"));
parParameters.add(new BasicNameValuePair(OIDCLoginProtocol.NONCE_PARAM, "test-nonce"));
UrlEncodedFormEntity parFormEntity = new UrlEncodedFormEntity(parParameters, StandardCharsets.UTF_8);
parRequest.setEntity(parFormEntity);
String requestUri;
try (CloseableHttpResponse parResponse = httpClient.execute(parRequest)) {
assertEquals(HttpStatus.SC_CREATED, parResponse.getStatusLine().getStatusCode());
String parResponseBody = IOUtils.toString(parResponse.getEntity().getContent(), StandardCharsets.UTF_8);
Map<String, Object> parResult = JsonSerialization.readValue(parResponseBody, Map.class);
requestUri = (String) parResult.get("request_uri");
assertNotNull("Request URI should not be null", requestUri);
}
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<NameValuePair> tokenParameters = new LinkedList<>();
tokenParameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, OAuth2Constants.AUTHORIZATION_CODE));
tokenParameters.add(new BasicNameValuePair(OAuth2Constants.CODE, code));
tokenParameters.add(new BasicNameValuePair(OAuth2Constants.REDIRECT_URI, oauth.getRedirectUri()));
tokenParameters.add(new BasicNameValuePair(OAuth2Constants.CLIENT_ID, oauth.getClientId()));
tokenParameters.add(new BasicNameValuePair(OAuth2Constants.CLIENT_SECRET, "password"));
UrlEncodedFormEntity tokenFormEntity = new UrlEncodedFormEntity(tokenParameters, StandardCharsets.UTF_8);
postToken.setEntity(tokenFormEntity);
AccessTokenResponse tokenResponse;
try (CloseableHttpResponse tokenHttpResponse = httpClient.execute(postToken)) {
assertEquals(HttpStatus.SC_OK, tokenHttpResponse.getStatusLine().getStatusCode());
String tokenResponseBody = IOUtils.toString(tokenHttpResponse.getEntity().getContent(), StandardCharsets.UTF_8);
tokenResponse = JsonSerialization.readValue(tokenResponseBody, AccessTokenResponse.class);
}
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<OID4VCAuthorizationDetailResponse> authDetailsResponse = parseAuthorizationDetails(JsonSerialization.writeValueAsString(tokenResponse));
List<OID4VCAuthorizationDetailResponse> 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<OID4VCAuthorizationDetailResponse> parseAuthorizationDetails(String responseBody) {
try {
// Parse the JSON response to extract authorization_details
Map<String, Object> responseMap = JsonSerialization.readValue(responseBody, Map.class);
Object authDetailsObj = responseMap.get("authorization_details");
if (authDetailsObj == null) {
return Collections.emptyList();
}
// Convert to list of OID4VCAuthorizationDetailsResponse
return JsonSerialization.readValue(JsonSerialization.writeValueAsString(authDetailsObj),
new TypeReference<List<OID4VCAuthorizationDetailResponse>>() {
});
} catch (Exception e) {
throw new RuntimeException("Failed to parse authorization_details from response", e);
}
}
}

View file

@ -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<String> 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<String> 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<String> 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<String> 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));
}
}

View file

@ -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) {

View file

@ -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<String, Object> 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<String, Object> 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);
}

View file

@ -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<NameValuePair> 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();

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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<NameValuePair> 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();