getAuthorizationDetails() {
diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCredentialOfferAuthCodeTest.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCredentialOfferAuthCodeTest.java
index c05a36ec1836..10de2a323fd6 100644
--- a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCredentialOfferAuthCodeTest.java
+++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCredentialOfferAuthCodeTest.java
@@ -2,8 +2,11 @@
import java.net.URI;
import java.util.List;
+import java.util.Optional;
+import java.util.function.Function;
import org.keycloak.TokenVerifier;
+import org.keycloak.common.VerificationException;
import org.keycloak.protocol.oid4vc.model.CredentialDefinition;
import org.keycloak.protocol.oid4vc.model.CredentialOfferURI;
import org.keycloak.protocol.oid4vc.model.CredentialResponse;
@@ -11,6 +14,7 @@
import org.keycloak.protocol.oid4vc.model.OfferResponseType;
import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
import org.keycloak.representations.JsonWebToken;
+import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.sdjwt.IssuerSignedJWT;
import org.keycloak.sdjwt.vp.SdJwtVP;
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
@@ -30,6 +34,7 @@
import static org.keycloak.tests.oid4vc.CredentialOfferStateUtils.getCredentialOfferStateRecord;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
@@ -39,23 +44,22 @@
/**
* Credential Offer Validity Matrix
*
- * +----------+----------+---------+------------------------------------------------------+
- * | Pre-Auth | Username | Valid | Notes |
- * +----------+----------+---------+------------------------------------------------------+
- * | no | no | yes | Anonymous offer; any logged-in user may redeem. |
- * | no | yes | yes | Offer restricted to a specific user. |
- * +----------+----------+---------+------------------------------------------------------+
- * | yes | no | no | Pre-auth requires a target user. |
- * | yes | yes | yes | Pre-auth for a specific target user. |
- * +----------+----------+---------+------------------------------------------------------+
+ * +----------+----------+-----------------+---------+------------------------------------------------------+
+ * | Pre-Auth | Username | Client | Valid | Notes |
+ * +----------+----------+-----------------+---------+------------------------------------------------------+
+ * | no | no | no | yes | Anonymous offer; any logged-in user may redeem. |
+ * | no | no | explicit | yes | Anonymous offer; bound to a specific client. |
+ * | no | yes | no | yes | Offer restricted to a specific user. |
+ * | no | yes | explicit | yes | Offer restricted to a specific user and client. |
+ * +----------+----------+-----------------+---------+------------------------------------------------------+
*/
@KeycloakIntegrationTest(config = OID4VCIssuerTestBase.VCTestServerWithRestCredentialOfferEnabled.class)
public class OID4VCredentialOfferAuthCodeTest extends OID4VCIssuerTestBase {
@Test
- public void testAuthCodeOffer_Anonymous() throws Exception {
+ public void testAuthCodeOffer_Anonymous() {
- var ctx = new OID4VCTestContext(client, jwtTypeCredentialScope);
+ var ctx = createOID4VCTestContext();
// Create Authorization Code CredentialOffer
//
@@ -71,9 +75,9 @@ public void testAuthCodeOffer_Anonymous() throws Exception {
// Verify internal offer state target user and client
//
- CredentialOfferStateRecord runtimeOfferState = getCredentialOfferStateRecord(runOnServer, offerURI.getNonce());
- assertNull(runtimeOfferState.targetUsername(), "Expected null targetUsername");
- assertNull(runtimeOfferState.targetClientId(), "Expected null targetClientId");
+ CredentialOfferStateRecord offerState = getCredentialOfferStateRecord(runOnServer, offerURI.getNonce());
+ assertNull(offerState.targetUsername(), "Expected null targetUsername");
+ assertNull(offerState.targetClientId(), "Expected null targetClientId");
// Fetch credential offer again
// https://github.com/keycloak/keycloak/issues/48014
@@ -96,9 +100,85 @@ public void testAuthCodeOffer_Anonymous() throws Exception {
}
@Test
- public void testAuthCodeOffer_Anonymous_multipleOffers() throws Exception {
+ public void testAuthCodeOfferMatrix() {
+
+ record MatrixParams(
+ ClientRepresentation issuerClient,
+ String targetUser,
+ ClientRepresentation targetClient
+ ) {
+ String targetClientId() {
+ return Optional.ofNullable(targetClient)
+ .map(ClientRepresentation::getClientId)
+ .orElse(null);
+ }
+ }
+
+ Function runMatrixParams = (p) -> {
+
+ var ctx = createOID4VCTestContext();
+
+ // Create CredentialOfferURI
+ //
+ CredentialOfferUriResponse uriResponse = wallet.createCredentialOfferUri(ctx, req -> {
+ req.targetUser(p.targetUser());
+ req.targetClient(p.targetClientId());
+ req.preAuthorized(false);
+ });
+
+ if (p.targetClient != null && !p.targetClient.getOptionalClientScopes().contains(ctx.getScope())) {
+ assertFalse(uriResponse.isSuccess());
+ assertEquals("invalid_credential_offer_request", uriResponse.getError());
+ String expErrorDescription = String.format("Client '%s' does not support '%s'", p.targetClientId(), ctx.getScope());
+ assertEquals(expErrorDescription, uriResponse.getErrorDescription());
+ return false;
+ }
+ CredentialOfferURI offerURI = uriResponse.getCredentialOfferURI();
+
+ if (p.targetClient != null) {
+ oauth.client(p.targetClient.getClientId(), p.targetClient.getSecret());
+ } else {
+ oauth.client(client.getClientId(), client.getSecret());
+ }
+
+ // Get Credentials Offer
+ //
+ CredentialOfferResponse offerResponse = wallet.credentialsOfferRequest(ctx, offerURI).send();
+ CredentialsOffer credOffer = offerResponse.getCredentialsOffer();
+
+ String issuerState = credOffer.getIssuerState();
+ assertNotNull(issuerState, "No IssuerState");
+
+ // Verify internal offer state target user and client
+ //
+ CredentialOfferStateRecord offerState = getCredentialOfferStateRecord(runOnServer, offerURI.getNonce());
+ assertEquals(p.targetUser(), offerState.targetUsername());
+ assertEquals(p.targetClientId(), offerState.targetClientId());
+
+ // Send the CredentialRequest
+ //
+ CredentialResponse credResponse = wallet.fetchCredentialByOffer(ctx, credOffer)
+ .getCredentialResponse();
+
+ verifyCredentialResponse(ctx, ctx.getHolder(), credResponse);
+
+ wallet.logout(ctx.getHolder());
+
+ return true;
+ };
+
+ assertTrue(runMatrixParams.apply(new MatrixParams(client2, null, null)));
+ assertTrue(runMatrixParams.apply(new MatrixParams(client2, null, client)));
+ assertFalse(runMatrixParams.apply(new MatrixParams(client2, null, client2)));
+ assertTrue(runMatrixParams.apply(new MatrixParams(client2, "alice", null)));
+ assertTrue(runMatrixParams.apply(new MatrixParams(client2, "alice", client)));
+ assertFalse(runMatrixParams.apply(new MatrixParams(client2, "alice", client2)));
+ }
+
+ @Test
+ public void testAuthCodeOffer_Anonymous_multipleOffers() {
- var ctx1 = new OID4VCTestContext(client, jwtTypeCredentialScope);
+ var ctx1 = createOID4VCTestContext();
// Create Authorization Code CredentialOffer
//
@@ -191,7 +271,7 @@ public void testAuthCodeOffer_Anonymous_expiredOffer() throws Exception {
// Bigger accessToken lifespan to avoid same timeout like credential-offer (to enforce that accessToken is still valid in the credential-request, when credential-offer would be invalid)
testRealm.updateWithCleanup(r -> r.accessTokenLifespan(600));
- var ctx = new OID4VCTestContext(client, jwtTypeCredentialScope);
+ var ctx = createOID4VCTestContext();
// Create Authorization Code CredentialOffer
//
@@ -234,62 +314,10 @@ public void testAuthCodeOffer_Anonymous_expiredOffer() throws Exception {
timeOffSet.set(0);
}
- @Test
- public void testAuthCodeOffer_Targeted() throws Exception {
-
- var ctx = new OID4VCTestContext(client, jwtTypeCredentialScope);
-
- // Create Authorization Code CredentialOffer
- //
- CredentialsOffer credOffer = wallet.createCredentialOffer(ctx, req -> {
- req.targetUser(ctx.getHolder());
- });
-
- String issuerState = credOffer.getIssuerState();
- assertNotNull(issuerState, "No IssuerState");
-
- CredentialOfferURI offerURI = ctx.getCredentialsOfferUri();
- assertNotNull(offerURI, "No CredentialOfferURI");
-
- // Verify internal target user and client
- //
- CredentialOfferStateRecord runtimeOfferState = getCredentialOfferStateRecord(runOnServer, offerURI.getNonce());
- assertEquals("alice", runtimeOfferState.targetUsername());
- assertNull(runtimeOfferState.targetClientId(), "targetClientId should be null");
-
- // Send AuthorizationRequest
- //
- AuthorizationEndpointResponse authResponse = wallet
- .authorizationRequest()
- .scope(ctx.getScope())
- .issuerState(issuerState)
- .send(ctx.getHolder(), TEST_PASSWORD);
- String authCode = authResponse.getCode();
- assertNotNull(authCode, "No authCode");
-
- // Build and send AccessTokenRequest
- //
- AccessTokenResponse tokenResponse = wallet.accessTokenRequest(ctx, authCode).send();
- String accessToken = wallet.validateHolderAccessToken(ctx, tokenResponse);
- assertNotNull(accessToken, "No accessToken");
-
- String authorizedIdentifier = ctx.getAuthorizedCredentialIdentifier();
- assertNotNull(authorizedIdentifier, "Has authorized credential identifier");
-
- // Send the CredentialRequest
- //
- CredentialResponse credResponse = wallet.credentialRequest(ctx, accessToken)
- .credentialIdentifier(authorizedIdentifier)
- .proofs(wallet.generateJwtProof(ctx))
- .send().getCredentialResponse();
-
- verifyCredentialResponse(ctx, ctx.getHolder(), credResponse);
- }
-
@Test
public void testAuthCodeOffer_QRCode() {
- var ctx = new OID4VCTestContext(client, jwtTypeCredentialScope);
+ var ctx = createOID4VCTestContext();
CredentialsOffer credOffer = wallet.createCredentialOffer(ctx, req -> {
req.responseType(OfferResponseType.URI_QR);
@@ -306,37 +334,48 @@ public void testAuthCodeOffer_QRCode() {
@Test
public void testAuthCodeOffer_QRCode_InvalidDimensions() {
- var ctx = new OID4VCTestContext(client, jwtTypeCredentialScope);
+ var ctx = createOID4VCTestContext();
- var ex = assertThrows(IllegalStateException.class, () -> wallet.createCredentialOfferUri(ctx, req -> {
+ CredentialOfferUriResponse uriResponse = wallet.createCredentialOfferUri(ctx, req -> {
req.responseType(OfferResponseType.URI_QR);
req.width(1000).height(1000);
- }));
- CredentialOfferUriResponse res = ctx.getCredentialsOfferUriResponse();
+ });
+ assertFalse(uriResponse.isSuccess());
- String error = res.getError();
+ String error = uriResponse.getError();
assertNotNull(error, "No Error");
- String errorDescription = res.getErrorDescription();
+ String errorDescription = uriResponse.getErrorDescription();
assertNotNull(errorDescription, "No ErrorDescription");
- assertEquals(HttpStatus.SC_BAD_REQUEST, res.getStatusCode());
+ assertEquals(HttpStatus.SC_BAD_REQUEST, uriResponse.getStatusCode());
assertEquals("invalid_credential_offer_request", error);
assertEquals("Requested QR Code too large, allowed maximum is 800x800", errorDescription);
- assertEquals(String.format("[%s] %s", error, errorDescription), ex.getMessage());
}
// Private ---------------------------------------------------------------------------------------------------------
- private void verifyCredentialResponse(OID4VCTestContext ctx, String expUser, CredentialResponse credResponse) throws Exception {
+ private OID4VCTestContext createOID4VCTestContext() {
+ return new OID4VCTestContext(client, jwtTypeCredentialScope)
+ .withIssuerClient(client2);
+ }
+
+ private void verifyCredentialResponse(OID4VCTestContext ctx, String expUser, CredentialResponse credResponse) {
String scope = ctx.getCredentialScope().getName();
CredentialResponse.Credential credentialObj = credResponse.getCredentials().get(0);
assertNotNull(credentialObj, "The first credential in the array should not be null");
- JsonWebToken jsonWebToken = TokenVerifier.create((String) credentialObj.getCredential(), JsonWebToken.class).getToken();
+ JsonWebToken jsonWebToken;
+ try {
+ jsonWebToken = TokenVerifier.create((String) credentialObj.getCredential(), JsonWebToken.class).getToken();
+ } catch (VerificationException e) {
+ throw new RuntimeException(e);
+ }
+
assertEquals("did:web:test.org", jsonWebToken.getIssuer());
Object vc = jsonWebToken.getOtherClaims().get("vc");
+
VerifiableCredential credential = JsonSerialization.mapper.convertValue(vc, VerifiableCredential.class);
assertEquals(List.of(CredentialDefinition.VERIFIABLE_CREDENTIAL_TYPE, scope), credential.getType());
assertEquals(URI.create("did:web:test.org"), credential.getIssuer());
diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/issuance/signing/OID4VCSdJwtIssuingEndpointTest.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/issuance/signing/OID4VCSdJwtIssuingEndpointTest.java
index e21e34744b99..e6718c5bd723 100644
--- a/tests/base/src/test/java/org/keycloak/tests/oid4vc/issuance/signing/OID4VCSdJwtIssuingEndpointTest.java
+++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/issuance/signing/OID4VCSdJwtIssuingEndpointTest.java
@@ -344,6 +344,7 @@ public void testCredentialIssuance() throws Exception {
.credentialOfferUriRequest(credentialConfigurationId)
.preAuthorized(true)
.targetUser("john")
+ .targetClient(oauth.getClientId())
.bearerToken(token)
.send()
.getCredentialOfferURI();
diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/preauth/JwtPreAuthCodeHandlerTest.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/preauth/JwtPreAuthCodeHandlerTest.java
index c71429bdaf28..751747b3242c 100644
--- a/tests/base/src/test/java/org/keycloak/tests/oid4vc/preauth/JwtPreAuthCodeHandlerTest.java
+++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/preauth/JwtPreAuthCodeHandlerTest.java
@@ -174,6 +174,7 @@ private String createPreAuthOffer() {
CredentialsOffer offer = wallet.createCredentialOffer(ctx, req -> {
req.targetUser(ctx.getHolder());
+ req.targetClient(ctx.getClientId());
req.preAuthorized(true);
});
diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/preauth/OID4VCAuthorizationDetailsFlowPreAuthTestBase.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/preauth/OID4VCAuthorizationDetailsFlowPreAuthTestBase.java
index 2b73695f3f37..f0330bf8017f 100644
--- a/tests/base/src/test/java/org/keycloak/tests/oid4vc/preauth/OID4VCAuthorizationDetailsFlowPreAuthTestBase.java
+++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/preauth/OID4VCAuthorizationDetailsFlowPreAuthTestBase.java
@@ -123,6 +123,7 @@ protected Oid4vcTestContext prepareOid4vcTestContext(String token) throws Except
.preAuthorized(true)
.bearerToken(token)
.targetUser("john")
+ .targetClient(OID4VCI_CLIENT_ID)
.send();
assertEquals(HttpStatus.SC_OK, credentialOfferURIResponse.getStatusCode());
CredentialOfferURI credOfferUri = credentialOfferURIResponse.getCredentialOfferURI();
diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/preauth/OID4VCJWTIssuerEndpointPreAuthTest.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/preauth/OID4VCJWTIssuerEndpointPreAuthTest.java
index 57b7f4cb315b..fa46d6d057c8 100644
--- a/tests/base/src/test/java/org/keycloak/tests/oid4vc/preauth/OID4VCJWTIssuerEndpointPreAuthTest.java
+++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/preauth/OID4VCJWTIssuerEndpointPreAuthTest.java
@@ -73,6 +73,7 @@ public void testCredentialIssuancePreAuth() {
.credentialOfferUriRequest(credentialConfigurationId)
.preAuthorized(true)
.targetUser("john")
+ .targetClient(client.getClientId())
.bearerToken(token)
.send()
.getCredentialOfferURI();
@@ -163,6 +164,7 @@ public void testPreAuthorizedCodeValidAfterOfferConsumed() {
.credentialOfferUriRequest(credentialConfigurationId)
.preAuthorized(true)
.targetUser("john")
+ .targetClient(oauth.getClientId())
.bearerToken(token)
.send()
.getCredentialOfferURI();
@@ -216,6 +218,7 @@ public void testCredentialOfferRequestWithTamperedNonceSecretIsRejected() {
.credentialOfferUriRequest(credentialConfigurationId)
.preAuthorized(true)
.targetUser("john")
+ .targetClient(oauth.getClientId())
.bearerToken(token)
.send()
.getCredentialOfferURI();
diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/preauth/OID4VCPublicClientPreAuthTest.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/preauth/OID4VCPublicClientPreAuthTest.java
index d9ad4c5f3c86..8c79eb15e6bb 100644
--- a/tests/base/src/test/java/org/keycloak/tests/oid4vc/preauth/OID4VCPublicClientPreAuthTest.java
+++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/preauth/OID4VCPublicClientPreAuthTest.java
@@ -62,6 +62,7 @@ public void testCredentialFromPreAuthCode() throws Exception {
//
CredentialsOffer credOffer = wallet.createCredentialOffer(ctx, req -> {
req.targetUser(ctx.getHolder());
+ req.targetClient(ctx.getClientId());
req.preAuthorized(true);
});
String preAuthCode = credOffer.getPreAuthorizedCode();
diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/preauth/OID4VCredentialOfferPreAuthTest.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/preauth/OID4VCredentialOfferPreAuthTest.java
index 85196389cb42..91d9f7d61dba 100644
--- a/tests/base/src/test/java/org/keycloak/tests/oid4vc/preauth/OID4VCredentialOfferPreAuthTest.java
+++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/preauth/OID4VCredentialOfferPreAuthTest.java
@@ -2,12 +2,16 @@
import java.net.URI;
import java.util.List;
+import java.util.Optional;
+import java.util.function.Function;
import org.keycloak.TokenVerifier;
import org.keycloak.admin.client.resource.UserResource;
+import org.keycloak.common.VerificationException;
import org.keycloak.protocol.oid4vc.model.CredentialDefinition;
import org.keycloak.protocol.oid4vc.model.CredentialOfferURI;
import org.keycloak.protocol.oid4vc.model.CredentialResponse;
+import org.keycloak.protocol.oid4vc.model.CredentialScopeRepresentation;
import org.keycloak.protocol.oid4vc.model.CredentialsOffer;
import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
import org.keycloak.representations.JsonWebToken;
@@ -17,8 +21,8 @@
import org.keycloak.tests.oid4vc.CredentialOfferStateUtils.CredentialOfferStateRecord;
import org.keycloak.tests.oid4vc.OID4VCIssuerTestBase;
import org.keycloak.tests.oid4vc.OID4VCTestContext;
-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.util.JsonSerialization;
import org.junit.jupiter.api.Test;
@@ -28,29 +32,122 @@
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
-import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Credential Offer Validity Matrix
*
- * +----------+----------+---------+------------------------------------------------------+
- * | Pre-Auth | Username | Valid | Notes |
- * +----------+----------+---------+------------------------------------------------------+
- * | no | no | yes | Anonymous offer; any logged-in user may redeem. |
- * | no | yes | yes | Offer restricted to a specific user. |
- * +----------+----------+---------+------------------------------------------------------+
- * | yes | no | no | Pre-auth requires a target user. |
- * | yes | yes | yes | Pre-auth for a specific target user. |
- * +----------+----------+---------+------------------------------------------------------+
+ * +----------+----------+-----------------+---------+------------------------------------------------------+
+ * | Pre-Auth | Username | Client | Valid | Notes |
+ * +----------+----------+-----------------+---------+------------------------------------------------------+
+ * | yes | no | explicit/unique | yes | Defaults to the user from the current login session |
+ * | yes | yes | explicit | yes | Pre-auth for a specific target user and client. |
+ * | yes | yes | omitted unique | yes | Discovered from credential_configuration_id. |
+ * | yes | yes | omitted none | no | Discovery fails: no OID4VCI client has that scope. |
+ * | yes | yes | omitted multi | no | Discovery fails: multiple clients match. |
+ * +----------+----------+-----------------+---------+------------------------------------------------------+
*/
@KeycloakIntegrationTest(config = OID4VCIssuerTestBase.VCTestServerWithPreAuthCodeEnabled.class)
public class OID4VCredentialOfferPreAuthTest extends OID4VCIssuerTestBase {
+ @Test
+ public void testPreAuthOfferMatrix() {
+
+ record MatrixParams(
+ ClientRepresentation issuerClient,
+ String targetUser,
+ ClientRepresentation targetClient
+ ) {
+ String targetClientId() {
+ return Optional.ofNullable(targetClient)
+ .map(ClientRepresentation::getClientId)
+ .orElse(null);
+ }
+ }
+
+ Function runMatrixParams = (p) -> {
+ String expTargetUser = p.targetUser() != null ? p.targetUser() : "john";
+ String expTargetClient = p.targetClientId() != null ? p.targetClientId() : client.getClientId();
+
+ var otherClients = List.of(abcaClient, pubClient);
+ var ctx = createOID4VCTestContext().withHolder(expTargetUser);
+
+ // Create CredentialOfferURI
+ //
+ CredentialOfferUriResponse uriResponse;
+ try {
+
+ // Remove the credential scope from the two other OID4VCI clients so only oid4vci-client has it
+ if (p.targetClient == null) {
+ removeOptionalClientScope(otherClients, ctx.getCredentialScope());
+ }
+
+ oauth.client(p.issuerClient.getClientId(), p.issuerClient.getSecret());
+ uriResponse = wallet.createCredentialOfferUri(ctx, req -> {
+ req.targetUser(p.targetUser());
+ req.targetClient(p.targetClientId());
+ req.preAuthorized(true);
+ });
+ } finally {
+ // Restore the credential scope to the two other OID4VCI clients
+ if (p.targetClient == null) {
+ restoreOptionalClientScope(otherClients, ctx.getCredentialScope());
+ }
+ if (p.targetClient != null) {
+ oauth.client(p.targetClient.getClientId(), p.targetClient.getSecret());
+ } else {
+ oauth.client(client.getClientId(), client.getSecret());
+ }
+ }
+ if (p.targetClient != null && !p.targetClient.getOptionalClientScopes().contains(ctx.getScope())) {
+ assertFalse(uriResponse.isSuccess());
+ assertEquals("invalid_credential_offer_request", uriResponse.getError());
+ String expErrorDescription = String.format("Client '%s' does not support '%s'", p.targetClientId(), ctx.getScope());
+ assertEquals(expErrorDescription, uriResponse.getErrorDescription());
+ return false;
+ }
+ CredentialOfferURI offerURI = uriResponse.getCredentialOfferURI();
+
+ // Get Credentials Offer
+ //
+ CredentialOfferResponse offerResponse = wallet.credentialsOfferRequest(ctx, offerURI).send();
+ CredentialsOffer credOffer = offerResponse.getCredentialsOffer();
+
+ String preAuthCode = credOffer.getPreAuthorizedCode();
+ assertNotNull(preAuthCode, "No PreAuthorizedCode");
+
+ // Verify internal offer state target user and client
+ //
+ CredentialOfferStateRecord offerState = getCredentialOfferStateRecord(runOnServer, offerURI.getNonce());
+ assertEquals(expTargetUser, offerState.targetUsername());
+ assertEquals(expTargetClient, offerState.targetClientId());
+
+ // Send the CredentialRequest
+ //
+ CredentialResponse credResponse = wallet.fetchCredentialByOffer(ctx, credOffer)
+ .getCredentialResponse();
+
+ verifyCredentialResponse(ctx, expTargetUser, credResponse);
+
+ wallet.logout(ctx.getHolder());
+
+ return true;
+ };
+
+ assertTrue(runMatrixParams.apply(new MatrixParams(client2, null, null)));
+ assertTrue(runMatrixParams.apply(new MatrixParams(client2, null, client)));
+ assertFalse(runMatrixParams.apply(new MatrixParams(client2, null, client2)));
+ assertTrue(runMatrixParams.apply(new MatrixParams(client2, "alice", null)));
+ assertTrue(runMatrixParams.apply(new MatrixParams(client2, "alice", client)));
+ assertFalse(runMatrixParams.apply(new MatrixParams(client2, "alice", client2)));
+ }
+
@Test
public void testPreAuthOffer_DisabledUser() {
- var ctx = new OID4VCTestContext(client, jwtTypeCredentialScope);
+ var ctx = createOID4VCTestContext();
+
+ String accessToken = wallet.getIssuerAccessToken(ctx);
// Disable user
UserRepresentation userRep = testRealm.admin().users().search(ctx.getHolder()).get(0);
@@ -59,12 +156,17 @@ public void testPreAuthOffer_DisabledUser() {
userResource.update(userRep);
try {
- IllegalStateException error = assertThrows(IllegalStateException.class,
- () -> wallet.createCredentialOffer(ctx, req -> {
- req.targetUser(ctx.getHolder());
- req.preAuthorized(true);
- }));
- assertTrue(error.getMessage().contains("User 'alice' disabled"), error.getMessage());
+ String credConfigId = ctx.getCredentialConfigurationId();
+ CredentialOfferUriResponse uriResponse = wallet.credentialOfferUriRequest(ctx, credConfigId)
+ .bearerToken(accessToken)
+ .targetUser(ctx.getHolder())
+ .targetClient(ctx.getClientId())
+ .preAuthorized(true)
+ .send();
+ String errorDescription = uriResponse.getErrorDescription();
+ assertFalse(uriResponse.isSuccess(), "Expected to fail");
+ assertEquals("invalid_credential_offer_request", uriResponse.getError());
+ assertEquals("User 'alice' disabled", errorDescription);
} finally {
userRep.setEnabled(true);
userResource.update(userRep);
@@ -72,148 +174,169 @@ public void testPreAuthOffer_DisabledUser() {
}
@Test
- public void testPreAuthOffer_DisabledClient() throws Exception {
-
- var ctx = new OID4VCTestContext(client, jwtTypeCredentialScope);
+ public void testPreAuthOffer_DisabledClient() {
- // Create Pre-Authorized CredentialOffer
- //
- CredentialsOffer credOffer = wallet.createCredentialOffer(ctx, req -> {
- req.targetUser(ctx.getHolder());
- req.preAuthorized(true);
- });
+ var ctx = createOID4VCTestContext();
- String preAuthCode = credOffer.getPreAuthorizedCode();
- assertNotNull(preAuthCode, "preAuthCode");
+ String accessToken = wallet.getIssuerAccessToken(ctx);
// Disable the client
- ClientRepresentation clientRep = testRealm.admin().clients().get(ctx.getClient().getId()).toRepresentation();
+ ClientRepresentation clientRep = testRealm.admin().clients().get(ctx.getHolderClient().getId()).toRepresentation();
clientRep.setEnabled(false);
- testRealm.admin().clients().get(ctx.getClient().getId()).update(clientRep);
+ testRealm.admin().clients().get(ctx.getHolderClient().getId()).update(clientRep);
try {
- // Attempt to redeem Pre-Authorized Code for AccessToken should fail
- //
- AccessTokenResponse tokenResponse = wallet.accessTokenRequestPreAuth(ctx, preAuthCode).send();
- assertFalse(tokenResponse.isSuccess(), "Token request should have failed for disabled client");
- assertEquals("invalid_request", tokenResponse.getError());
- assertTrue(tokenResponse.getErrorDescription().contains("disabled"),
- "Error description should mention disabled: " + tokenResponse.getErrorDescription());
+ String credConfigId = ctx.getCredentialConfigurationId();
+ CredentialOfferUriResponse uriResponse = wallet.credentialOfferUriRequest(ctx, credConfigId)
+ .bearerToken(accessToken)
+ .targetUser(ctx.getHolder())
+ .targetClient(ctx.getClientId())
+ .preAuthorized(true)
+ .send();
+ String errorDescription = uriResponse.getErrorDescription();
+ assertFalse(uriResponse.isSuccess(), "Expected to fail");
+ assertEquals("invalid_credential_offer_request", uriResponse.getError());
+ assertEquals("Client 'oid4vci-client' disabled", errorDescription);
} finally {
- // Re-enable client
clientRep.setEnabled(true);
- testRealm.admin().clients().get(ctx.getClient().getId()).update(clientRep);
+ testRealm.admin().clients().get(ctx.getHolderClient().getId()).update(clientRep);
}
}
+ /**
+ * Explicit targetClient not found: passing an unknown client_id must fail at offer-creation time.
+ */
+ @Test
+ public void testPreAuthOffer_ExplicitTargetClient_NotFound() {
+
+ var ctx = createOID4VCTestContext();
+
+ CredentialOfferUriResponse uriResponse = wallet.credentialOfferUriRequest(ctx, ctx.getCredentialConfigurationId())
+ .bearerToken(wallet.getIssuerAccessToken(ctx))
+ .targetUser(ctx.getHolder())
+ .targetClient("non-existent-client")
+ .preAuthorized(true)
+ .send();
+
+ assertFalse(uriResponse.isSuccess(), "Offer creation should have failed for unknown target client");
+ assertEquals("invalid_credential_offer_request", uriResponse.getError());
+ assertTrue(uriResponse.getErrorDescription().contains("non-existent-client"),
+ "Error should mention the unknown client: " + uriResponse.getErrorDescription());
+ }
+
+ /**
+ * Explicit targetClient that is not OID4VCI-enabled: must fail at offer-creation time.
+ */
@Test
- public void testPreAuthOffer_SelfIssued() throws Exception {
+ public void testPreAuthOffer_ExplicitTargetClient_NotOid4vciEnabled() {
- var ctx = new OID4VCTestContext(client, jwtTypeCredentialScope);
+ var ctx = createOID4VCTestContext();
- // Create Pre-Authorized CredentialOffer
- //
- CredentialsOffer credOffer = wallet.createCredentialOffer(ctx, req -> {
- req.preAuthorized(true);
- req.targetUser(null);
- });
+ // Use a well-known Keycloak built-in client that is not OID4VCI-enabled
+ String nonOid4vciClientId = "account";
- String preAuthCode = credOffer.getPreAuthorizedCode();
+ CredentialOfferUriResponse uriResponse = wallet.credentialOfferUriRequest(ctx, ctx.getCredentialConfigurationId())
+ .bearerToken(wallet.getIssuerAccessToken(ctx))
+ .targetUser(ctx.getHolder())
+ .targetClient(nonOid4vciClientId)
+ .preAuthorized(true)
+ .send();
- CredentialOfferURI offerURI = ctx.getCredentialsOfferUri();
- assertNotNull(offerURI, "No CredentialOfferURI");
+ assertFalse(uriResponse.isSuccess(), "Offer creation should have failed for non-OID4VCI-enabled target client");
+ assertEquals("invalid_credential_offer_request", uriResponse.getError());
+ assertTrue(uriResponse.getErrorDescription().contains(nonOid4vciClientId),
+ "Error should mention the client id: " + uriResponse.getErrorDescription());
+ }
- // Verify internal offer state target user and client
- //
- CredentialOfferStateRecord runtimeOfferState = getCredentialOfferStateRecord(runOnServer, offerURI.getNonce());
- assertEquals("john", runtimeOfferState.targetUsername());
- assertEquals(client.getClientId(), runtimeOfferState.targetClientId());
+ /**
+ * Discovery failure — no match: the credential_configuration_id is not assigned to any
+ * OID4VCI-enabled client, so discovery must fail at offer-creation time.
+ */
+ @Test
+ public void testPreAuthOffer_DiscoverTargetClient_NoMatch() {
- // Redeem Pre-Authorized Code for AccessToken
- //
- AccessTokenResponse tokenResponse = wallet.accessTokenRequestPreAuth(ctx, preAuthCode).send();
- assertTrue(tokenResponse.isSuccess(), tokenResponse.getErrorDescription());
+ var ctx = createOID4VCTestContext();
+ String credConfigId = ctx.getCredentialConfigurationId();
- String accessToken = wallet.validateHolderAccessToken(ctx, tokenResponse);
- assertNotNull(accessToken, "No accessToken");
+ var otherClients = List.of(client, abcaClient, pubClient);
+ try {
+ removeOptionalClientScope(otherClients, ctx.getCredentialScope());
- String authorizedIdentifier = ctx.getAuthorizedCredentialIdentifier();
- assertNotNull(authorizedIdentifier, "No authorized credential identifier");
+ CredentialOfferUriResponse uriResponse = wallet.credentialOfferUriRequest(ctx, credConfigId)
+ .bearerToken(wallet.getIssuerAccessToken(ctx))
+ .targetUser(ctx.getHolder())
+ // no targetClient → discovery attempted, should fail
+ .preAuthorized(true)
+ .send();
- // Send the CredentialRequest
- //
- CredentialResponse credResponse = wallet.credentialRequest(ctx, accessToken)
- .credentialIdentifier(authorizedIdentifier)
- .proofs(wallet.generateJwtProof(ctx))
- .send().getCredentialResponse();
+ assertFalse(uriResponse.isSuccess(), "Offer creation should fail when no client matches");
+ assertEquals("invalid_credential_offer_request", uriResponse.getError());
+ String expError = "No OID4VCI client found for credential configuration ids: [jwt-credential-config-id]";
+ assertEquals(expError, uriResponse.getErrorDescription());
- verifyCredentialResponse(ctx, ctx.getIssuer(), credResponse);
+ } finally {
+ restoreOptionalClientScope(otherClients, ctx.getCredentialScope());
+ }
}
+ /**
+ * Discovery failure — ambiguous: the credential_configuration_id maps to more than one
+ * OID4VCI-enabled client, so discovery must fail with a descriptive error.
+ *
+ * The default realm config assigns all credential scopes as optional to all three OID4VCI
+ * clients, so omitting target_client on jwtTypeCredentialScope always hits this path.
+ */
@Test
- public void testPreAuthOffer_Targeted() throws Exception {
- var ctx = new OID4VCTestContext(client, jwtTypeCredentialScope);
-
- // Create Pre-Authorized CredentialOffer
- //
- CredentialsOffer credOffer = wallet.createCredentialOffer(ctx, req -> {
- req.targetUser(ctx.getHolder());
- req.preAuthorized(true);
- });
-
- CredentialOfferURI offerURI = ctx.getCredentialsOfferUri();
- assertNotNull(offerURI, "No CredentialOfferURI");
-
- // Verify internal offer state target user and client
- //
- CredentialOfferStateRecord runtimeOfferState = getCredentialOfferStateRecord(runOnServer, offerURI.getNonce());
- assertEquals("alice", runtimeOfferState.targetUsername());
- assertEquals(client.getClientId(), runtimeOfferState.targetClientId());
-
- String preAuthCode = credOffer.getPreAuthorizedCode();
- assertNotNull(preAuthCode, "preAuthCode");
-
- // Fetch credential offer again
- // https://github.com/keycloak/keycloak/issues/48014
- credOffer = wallet.credentialsOfferRequest(ctx, offerURI).send().getCredentialsOffer();
- preAuthCode = credOffer.getPreAuthorizedCode();
- assertNotNull(preAuthCode, "preAuthCode");
-
- // Redeem Pre-Authorized Code for AccessToken
- //
- AccessTokenResponse tokenResponse = wallet.accessTokenRequestPreAuth(ctx, preAuthCode).send();
- assertTrue(tokenResponse.isSuccess(), tokenResponse.getErrorDescription());
-
- String accessToken = wallet.validateHolderAccessToken(ctx, tokenResponse);
- assertNotNull(accessToken, "No accessToken");
-
- String authorizedIdentifier = ctx.getAuthorizedCredentialIdentifier();
- assertNotNull(authorizedIdentifier, "Has authorized credential identifier");
-
- // Send the CredentialRequest
- //
- CredentialResponse credResponse = wallet.credentialRequest(ctx, accessToken)
- .credentialIdentifier(authorizedIdentifier)
- .proofs(wallet.generateJwtProof(ctx))
- .send().getCredentialResponse();
-
- verifyCredentialResponse(ctx, ctx.getHolder(), credResponse);
-
- // Attempt to fetch the credential offer again after it has been consumed
- CredentialOfferResponse res = wallet.credentialsOfferRequest(ctx, offerURI).send();
- assertEquals("invalid_credential_offer_request", res.getError());
- assertEquals("Credential offer not found or already consumed", res.getErrorDescription());
+ public void testPreAuthOffer_DiscoverTargetClient_Ambiguous() {
+
+ var ctx = createOID4VCTestContext();
+
+ CredentialOfferUriResponse uriResponse = wallet.credentialOfferUriRequest(ctx, ctx.getCredentialConfigurationId())
+ .bearerToken(wallet.getIssuerAccessToken(ctx))
+ .targetUser(ctx.getHolder())
+ .preAuthorized(true)
+ // no targetClient → multiple clients match → should fail
+ .send();
+
+ assertFalse(uriResponse.isSuccess(), "Offer creation should fail when multiple clients match");
+ assertEquals("invalid_credential_offer_request", uriResponse.getError());
+ assertEquals("Multiple OID4VCI clients for credential configuration ids: [jwt-credential-config-id]", uriResponse.getErrorDescription());
}
// Private ---------------------------------------------------------------------------------------------------------
- private void verifyCredentialResponse(OID4VCTestContext ctx, String expUser, CredentialResponse credResponse) throws Exception {
+ private OID4VCTestContext createOID4VCTestContext() {
+ return new OID4VCTestContext(client, jwtTypeCredentialScope)
+ .withIssuerClient(client2);
+ }
+
+ private void removeOptionalClientScope(List clients, CredentialScopeRepresentation credScope) {
+ String scopeId = credScope.getId();
+ for (ClientRepresentation client : clients) {
+ testRealm.admin().clients().get(client.getId()).removeOptionalClientScope(scopeId);
+ }
+ }
+
+ private void restoreOptionalClientScope(List clients, CredentialScopeRepresentation credScope) {
+ String scopeId = credScope.getId();
+ for (ClientRepresentation client : clients) {
+ testRealm.admin().clients().get(client.getId()).addOptionalClientScope(scopeId);
+ }
+ }
+
+ private void verifyCredentialResponse(OID4VCTestContext ctx, String expUser, CredentialResponse credResponse) {
String scope = ctx.getCredentialScope().getName();
CredentialResponse.Credential credentialObj = credResponse.getCredentials().get(0);
assertNotNull(credentialObj, "The first credential in the array should not be null");
- JsonWebToken jsonWebToken = TokenVerifier.create((String) credentialObj.getCredential(), JsonWebToken.class).getToken();
+ JsonWebToken jsonWebToken;
+ try {
+ jsonWebToken = TokenVerifier.create((String) credentialObj.getCredential(), JsonWebToken.class).getToken();
+ } catch (VerificationException e) {
+ throw new RuntimeException(e);
+ }
+
assertEquals("did:web:test.org", jsonWebToken.getIssuer());
Object vc = jsonWebToken.getOtherClaims().get("vc");
VerifiableCredential credential = JsonSerialization.mapper.convertValue(vc, VerifiableCredential.class);
diff --git a/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/oid4vc/CredentialOfferUriRequest.java b/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/oid4vc/CredentialOfferUriRequest.java
index f22a56e11d27..56f24c1e6a09 100644
--- a/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/oid4vc/CredentialOfferUriRequest.java
+++ b/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/oid4vc/CredentialOfferUriRequest.java
@@ -16,6 +16,7 @@ public class CredentialOfferUriRequest extends AbstractHttpGetRequest