[OID4VCI] Revisit and fix OAuthClient.credentialOfferRequest()

Signed-off-by: Thomas Diesler <tdiesler@ibm.com>
This commit is contained in:
Thomas Diesler 2026-02-04 07:18:22 +01:00 committed by Marek Posolda
parent 05ff44b8a0
commit 64dee82f9f
10 changed files with 50 additions and 93 deletions

View file

@ -90,8 +90,8 @@ public class Endpoints {
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 getOid4vcCredentialOffer() {
return asString(getBase().path(RealmsResource.class).path("{realm}/protocol/oid4vc/credential-offer"));
}
public String getOid4vcCredentialOfferUri() {

View file

@ -1,7 +1,9 @@
package org.keycloak.testsuite.util.oauth.oid4vc;
import java.io.IOException;
import java.util.Optional;
import org.keycloak.protocol.oid4vc.model.CredentialOfferURI;
import org.keycloak.testsuite.util.oauth.AbstractHttpGetRequest;
import org.keycloak.testsuite.util.oauth.AbstractOAuthClient;
@ -9,28 +11,25 @@ import org.apache.http.client.methods.CloseableHttpResponse;
public class CredentialOfferRequest extends AbstractHttpGetRequest<CredentialOfferRequest, CredentialOfferResponse> {
private String nonce;
private final CredentialOfferURI credOfferURI;
public CredentialOfferRequest(AbstractOAuthClient<?> client) {
public CredentialOfferRequest(AbstractOAuthClient<?> client, CredentialOfferURI credOfferUri) {
super(client);
this.credOfferURI = credOfferUri;
}
public CredentialOfferRequest(String nonce, AbstractOAuthClient<?> client) {
public CredentialOfferRequest(AbstractOAuthClient<?> client, String nonce) {
super(client);
this.nonce = nonce;
}
public CredentialOfferRequest nonce(String nonce) {
this.nonce = nonce;
return this;
credOfferURI = new CredentialOfferURI();
credOfferURI.setIssuer(client.getEndpoints().getOid4vcCredentialOffer());
credOfferURI.setNonce(nonce);
}
@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);
return Optional.ofNullable(credOfferURI)
.map(CredentialOfferURI::getCredentialOfferUri)
.orElseThrow(() -> new IllegalStateException("No credOfferURI"));
}
@Override

View file

@ -1,6 +1,7 @@
package org.keycloak.testsuite.util.oauth.oid4vc;
import java.io.IOException;
import java.util.Optional;
import org.keycloak.protocol.oid4vc.model.CredentialsOffer;
import org.keycloak.testsuite.util.oauth.AbstractHttpResponse;
@ -21,6 +22,7 @@ public class CredentialOfferResponse extends AbstractHttpResponse {
}
public CredentialsOffer getCredentialsOffer() {
return credentialsOffer;
return Optional.ofNullable(credentialsOffer).orElseThrow(() ->
new IllegalStateException(String.format("[%s] %s", getError(), getErrorDescription())));
}
}

View file

@ -1,5 +1,6 @@
package org.keycloak.testsuite.util.oauth.oid4vc;
import org.keycloak.protocol.oid4vc.model.CredentialOfferURI;
import org.keycloak.testsuite.util.oauth.AbstractOAuthClient;
import org.keycloak.testsuite.util.oauth.AccessTokenResponse;
@ -23,16 +24,12 @@ public class OID4VCClient {
return new CredentialOfferUriRequest(client, credConfigId);
}
public CredentialOfferRequest credentialOfferRequest() {
return new CredentialOfferRequest(client);
public CredentialOfferRequest credentialOfferRequest(CredentialOfferURI credOfferUri) {
return new CredentialOfferRequest(client, credOfferUri);
}
public CredentialOfferRequest credentialOfferRequest(String nonce) {
return new CredentialOfferRequest(nonce, client);
}
public CredentialOfferResponse doCredentialOfferRequest(String nonce) {
return credentialOfferRequest(nonce).send();
return new CredentialOfferRequest(client, nonce);
}
public Oid4vcCredentialRequest credentialRequest() {
@ -50,8 +47,4 @@ public class OID4VCClient {
public Oid4vcNonceRequest nonceRequest() {
return new Oid4vcNonceRequest(client);
}
public String doNonceRequest() {
return nonceRequest().send().getNonce();
}
}

View file

@ -105,8 +105,8 @@ public class OID4VCICredentialOfferMatrixTest extends OID4VCIssuerEndpointTest {
String appClient;
CredentialIssuer issuerMetadata;
OIDCConfigurationRepresentation authorizationMetadata;
SupportedCredentialConfiguration supportedCredentialConfiguration;
SupportedCredentialConfiguration credentialConfiguration;
TestContext(boolean preAuth, String appClient, String appUser) {
this.preAuthorized = preAuth;
this.issUser = issUsername;
@ -115,7 +115,7 @@ public class OID4VCICredentialOfferMatrixTest extends OID4VCIssuerEndpointTest {
this.appClient = appClient;
this.issuerMetadata = getCredentialIssuerMetadata();
this.authorizationMetadata = getAuthorizationMetadata(this.issuerMetadata.getAuthorizationServers().get(0));
this.supportedCredentialConfiguration = this.issuerMetadata.getCredentialsSupported().get(credConfigId);
this.credentialConfiguration = this.issuerMetadata.getCredentialsSupported().get(credConfigId);
}
}
@ -252,13 +252,12 @@ public class OID4VCICredentialOfferMatrixTest extends OID4VCIssuerEndpointTest {
// Exclude scope: <credScope>
// Require role: credential-offer-create
verifyTokenJwt(ctx, issToken,
List.of(), List.of(ctx.supportedCredentialConfiguration.getScope()),
List.of(), List.of(ctx.credentialConfiguration.getScope()),
List.of(CREDENTIAL_OFFER_CREATE.getName()), List.of());
// Retrieving the credential-offer-uri
//
CredentialOfferURI credOfferUri = getCredentialOfferUri(ctx, issToken);
String offerUri = credOfferUri.getCredentialOfferUri();
// Issuer logout in order to remove unwanted session state
//
@ -268,7 +267,7 @@ public class OID4VCICredentialOfferMatrixTest extends OID4VCIssuerEndpointTest {
// Using the uri to get the actual credential offer
//
CredentialsOffer credOffer = getCredentialsOffer(ctx, offerUri);
CredentialsOffer credOffer = getCredentialsOffer(ctx, credOfferUri);
if (credOffer.getCredentialConfigurationIds().size() > 1)
throw new IllegalStateException("Multiple credential configuration ids not supported in: " + JsonSerialization.valueAsString(credOffer));
@ -397,7 +396,7 @@ public class OID4VCICredentialOfferMatrixTest extends OID4VCIssuerEndpointTest {
}
private CredentialOfferURI getCredentialOfferUri(TestContext ctx, String token) throws Exception {
String credConfigId = ctx.supportedCredentialConfiguration.getId();
String credConfigId = ctx.credentialConfiguration.getId();
CredentialOfferUriResponse credentialOfferURIResponse = oauth.oid4vc()
.credentialOfferUriRequest(credConfigId)
.preAuthorized(ctx.preAuthorized)
@ -411,19 +410,12 @@ public class OID4VCICredentialOfferMatrixTest extends OID4VCIssuerEndpointTest {
return credentialOfferURI;
}
private CredentialsOffer getCredentialsOffer(TestContext ctx, String offerUri) throws Exception {
private CredentialsOffer getCredentialsOffer(TestContext ctx, CredentialOfferURI credOfferUri) throws Exception {
CredentialOfferResponse credentialOfferResponse = oauth.oid4vc()
.credentialOfferRequest()
.endpoint(offerUri)
.credentialOfferRequest(credOfferUri)
.send();
int statusCode = credentialOfferResponse.getStatusCode();
if (HttpStatus.SC_OK != statusCode) {
throw new IllegalStateException(credentialOfferResponse.getErrorDescription() != null
? credentialOfferResponse.getErrorDescription()
: "Request failed with status " + statusCode);
}
CredentialsOffer credOffer = credentialOfferResponse.getCredentialsOffer();
assertEquals(List.of(ctx.supportedCredentialConfiguration.getId()), credOffer.getCredentialConfigurationIds());
assertEquals(List.of(ctx.credentialConfiguration.getId()), credOffer.getCredentialConfigurationIds());
return credOffer;
}
@ -479,7 +471,7 @@ public class OID4VCICredentialOfferMatrixTest extends OID4VCIssuerEndpointTest {
// No authorization_details, use credential_configuration_id
credentialRequest.setCredentialConfigurationId(credConfigIds.get(0));
}
return sendCredentialRequest(ctx, accessToken, credentialRequest);
}
@ -512,7 +504,7 @@ public class OID4VCICredentialOfferMatrixTest extends OID4VCIssuerEndpointTest {
private void verifyCredentialResponse(TestContext ctx, CredentialResponse credResponse) throws Exception {
String scope = ctx.supportedCredentialConfiguration.getScope();
String scope = ctx.credentialConfiguration.getScope();
CredentialResponse.Credential credentialObj = credResponse.getCredentials().get(0);
assertNotNull("The first credential in the array should not be null", credentialObj);

View file

@ -138,8 +138,8 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
// Clear events before credential offer request
events.clear();
CredentialOfferResponse credentialOfferResponse = oauth.oid4vc().credentialOfferRequest()
.endpoint(credentialOfferURI.getIssuer() + "/" + credentialOfferURI.getNonce())
CredentialOfferResponse credentialOfferResponse = oauth.oid4vc()
.credentialOfferRequest(credentialOfferURI)
.send();
assertEquals(HttpStatus.SC_OK, credentialOfferResponse.getStatusCode());
ctx.credentialsOffer = credentialOfferResponse.getCredentialsOffer();

View file

@ -21,7 +21,6 @@ import java.io.IOException;
import java.util.Arrays;
import java.util.List;
import java.util.Set;
import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.Collectors;
import jakarta.ws.rs.core.HttpHeaders;
@ -83,7 +82,6 @@ public class OID4VCCredentialOfferCorsTest extends OID4VCIssuerEndpointTest {
@Rule
public TokenUtil tokenUtil = new TokenUtil();
@Override
public void configureTestRealm(RealmRepresentation testRealm) {
super.configureTestRealm(testRealm);
@ -175,14 +173,14 @@ public class OID4VCCredentialOfferCorsTest extends OID4VCIssuerEndpointTest {
public void testCredentialOfferSessionCorsValidOrigin() throws Exception {
// First get a credential offer URI to obtain a nonce
AccessTokenResponse tokenResponse = getAccessToken();
String nonce = getNonceFromOfferUri(tokenResponse.getAccessToken());
CredentialOfferURI credOfferUri = getCredentialOfferUri(tokenResponse.getAccessToken());
// Clear events before credential offer request
events.clear();
// Test credential offer endpoint with valid origin
CredentialOfferResponse response = oauth.oid4vc()
.credentialOfferRequest(nonce)
.credentialOfferRequest(credOfferUri)
.header("Origin", VALID_CORS_URL)
.send();
@ -210,14 +208,11 @@ public class OID4VCCredentialOfferCorsTest extends OID4VCIssuerEndpointTest {
public void testCredentialOfferSessionCorsInvalidOrigin() throws Exception {
// First get a credential offer URI to obtain a nonce
AccessTokenResponse tokenResponse = getAccessToken();
String nonce = getNonceFromOfferUri(tokenResponse.getAccessToken());
CredentialOfferURI credOfferUri = getCredentialOfferUri(tokenResponse.getAccessToken());
// Test credential offer endpoint with invalid origin
String offerUrl = getCredentialOfferUrl(nonce);
CredentialOfferResponse response = oauth.oid4vc()
.credentialOfferRequest()
.endpoint(offerUrl)
.credentialOfferRequest(credOfferUri)
.header("Origin", INVALID_CORS_URL)
.send();
@ -230,10 +225,10 @@ public class OID4VCCredentialOfferCorsTest extends OID4VCIssuerEndpointTest {
public void testCredentialOfferSessionCorsPreflightRequest() throws Exception {
// First get a credential offer URI to obtain a nonce
AccessTokenResponse tokenResponse = getAccessToken();
String nonce = getNonceFromOfferUri(tokenResponse.getAccessToken());
CredentialOfferURI credOfferUri = getCredentialOfferUri(tokenResponse.getAccessToken());
// Test preflight request for credential offer endpoint
String offerUrl = getCredentialOfferUrl(nonce);
String offerUrl = credOfferUri.getCredentialOfferUri();
try (CloseableHttpResponse response = makePreflightRequest(offerUrl, VALID_CORS_URL)) {
assertEquals(HttpStatus.SC_OK, response.getStatusLine().getStatusCode());
@ -330,9 +325,8 @@ public class OID4VCCredentialOfferCorsTest extends OID4VCIssuerEndpointTest {
// Create an expired credential offer using testing client
// Use AtomicReference to avoid serialization issues with lambda captures
AtomicReference<String> nonceHolder = new AtomicReference<>();
final String issuerPath = getRealmPath(TEST_REALM_NAME);
testingClient.server(TEST_REALM_NAME).run(session -> {
String issuerPath = getRealmPath(TEST_REALM_NAME);
String nonce = testingClient.server(TEST_REALM_NAME).fetchString(session -> {
CredentialsOffer credOffer = new CredentialsOffer()
.setCredentialIssuer(issuerPath)
.setGrants(new PreAuthorizedGrant().setPreAuthorizedCode(new PreAuthorizedCode().setPreAuthorizedCode("test-code")))
@ -344,18 +338,15 @@ public class OID4VCCredentialOfferCorsTest extends OID4VCIssuerEndpointTest {
CredentialOfferState offerState = new CredentialOfferState(credOffer, null, null, Time.currentTime() - 1);
offerStorage.putOfferState(session, offerState);
session.getTransactionManager().commit();
nonceHolder.set(offerState.getNonce());
return offerState.getNonce();
});
String nonce = nonceHolder.get();
assertNotNull("CredentialOffer nonce not null", nonce);
events.clear();
// Try to fetch the expired credential offer
String offerUrl = getCredentialOfferUrl(nonce);
CredentialOfferResponse response = oauth.oid4vc()
.credentialOfferRequest()
.endpoint(offerUrl)
.credentialOfferRequest(nonce)
.header("Origin", VALID_CORS_URL)
.send();
@ -401,7 +392,8 @@ public class OID4VCCredentialOfferCorsTest extends OID4VCIssuerEndpointTest {
return res;
}
private String getNonceFromOfferUri(String accessToken) throws Exception {
private CredentialOfferURI getCredentialOfferUri(String accessToken) throws Exception {
CredentialOfferUriResponse response = oauth.oid4vc()
.credentialOfferUriRequest(jwtTypeCredentialConfigurationIdName)
.preAuthorized(true)
@ -411,8 +403,7 @@ public class OID4VCCredentialOfferCorsTest extends OID4VCIssuerEndpointTest {
.send();
assertEquals(HttpStatus.SC_OK, response.getStatusCode());
CredentialOfferURI offerUri = response.getCredentialOfferURI();
return offerUri.getNonce();
return response.getCredentialOfferURI();
}
private CloseableHttpResponse makeCorsRequest(String url, String origin, String accessToken) throws IOException {

View file

@ -107,7 +107,6 @@ 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.userprofile.DeclarativeUserProfileProviderFactory;
import org.keycloak.userprofile.config.UPConfigUtils;
import org.keycloak.util.JsonSerialization;
@ -617,25 +616,6 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest {
return getBasePath("test") + "credential-offer/" + nonce;
}
protected void requestCredential(String token,
String credentialEndpoint,
SupportedCredentialConfiguration offeredCredential,
CredentialResponseHandler responseHandler,
ClientScopeRepresentation expectedClientScope) throws IOException, VerificationException {
Oid4vcCredentialResponse credentialRequestResponse = oauth.oid4vc()
.credentialRequest()
.endpoint(credentialEndpoint)
.bearerToken(token)
.credentialConfigurationId(offeredCredential.getId())
.send();
assertEquals(HttpStatus.SC_OK, credentialRequestResponse.getStatusCode());
CredentialResponse credentialResponse = credentialRequestResponse.getCredentialResponse();
// Use response handler to customize checks based on formats.
responseHandler.handleCredentialResponse(credentialResponse, expectedClientScope);
}
protected void requestCredentialWithIdentifier(String token,
String credentialEndpoint,
String credentialIdentifier,

View file

@ -463,7 +463,7 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
// 2. Using the uri to get the actual credential offer
CredentialsOffer credentialsOffer = oauth.oid4vc()
.credentialOfferRequest(credentialOfferURI.getNonce())
.credentialOfferRequest(credentialOfferURI)
.bearerToken(token)
.send()
.getCredentialsOffer();

View file

@ -386,7 +386,7 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest {
// 2. Using the uri to get the actual credential offer
CredentialsOffer credentialsOffer = oauth.oid4vc()
.credentialOfferRequest(credentialOfferURI.getNonce())
.credentialOfferRequest(credentialOfferURI)
.bearerToken(token)
.send()
.getCredentialsOffer();