From 6e3da500a765f0ea63a15fd311f842affb9b447b Mon Sep 17 00:00:00 2001 From: Thomas Diesler Date: Tue, 14 Jul 2026 16:17:03 +0200 Subject: [PATCH] [OID4VCI] Explicit client association for pre-authorized offers closes #50587 Signed-off-by: Thomas Diesler Decouples pre-authorized credential offers from the creating client by supporting explicit or uniquely discovered target clients. * Adds target_client request support and client discovery. * Updates pre-authorized issuance flows and tests. * Adds offer-state inspection utilities. --- .../oid4vc/issuance/OID4VCIssuerEndpoint.java | 52 ++- .../CredentialOfferProvider.java | 8 +- .../DefaultCredentialOfferProvider.java | 77 +++- .../VerifiableCredentialOfferAction.java | 4 +- .../oid4vc/utils/CredentialScopeUtils.java | 19 +- .../CredentialOfferProviderTest.java | 8 +- .../tests/oid4vc/OID4VCBasicWallet.java | 63 ++- .../oid4vc/OID4VCCredentialOfferCorsTest.java | 7 +- .../tests/oid4vc/OID4VCIssuerTestBase.java | 27 ++ .../tests/oid4vc/OID4VCTestContext.java | 35 +- .../OID4VCredentialOfferAuthCodeTest.java | 199 +++++---- .../OID4VCSdJwtIssuingEndpointTest.java | 1 + .../preauth/JwtPreAuthCodeHandlerTest.java | 1 + ...thorizationDetailsFlowPreAuthTestBase.java | 1 + .../OID4VCJWTIssuerEndpointPreAuthTest.java | 3 + .../OID4VCPublicClientPreAuthTest.java | 1 + .../OID4VCredentialOfferPreAuthTest.java | 417 +++++++++++------- .../oid4vc/CredentialOfferUriRequest.java | 9 +- 18 files changed, 615 insertions(+), 317 deletions(-) diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerEndpoint.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerEndpoint.java index 4036f5e306a4..b66eaffa8d79 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerEndpoint.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerEndpoint.java @@ -360,7 +360,7 @@ private void checkClientEnabled(EventBuilder eventBuilder) { } /** - * the OpenId4VCI nonce-endpoint + * The OID4VCI nonce endpoint * * @return a short-lived c_nonce value that must be presented in key-bound proofs at the credential endpoint. * @see Nonce endpoint @@ -421,37 +421,42 @@ public Response createCredentialOfferPreflight() { * Creates an authorization code grant offer that is bound to the calling user. */ public Response createCredentialOffer(String credConfigId) { - return createCredentialOffer(credConfigId, false, null); + return createCredentialOffer(credConfigId, false, null, null, null, OfferResponseType.URI, 0, 0); } /** * Creates a Credential Offer that is bound to a specific user. */ public Response createCredentialOffer(String credConfigId, boolean preAuthorized, String targetUser) { - return createCredentialOffer(credConfigId, preAuthorized, targetUser, null, OfferResponseType.URI, 0, 0); + return createCredentialOffer(credConfigId, preAuthorized, targetUser, null, null, OfferResponseType.URI, 0, 0); } /** - * Creates a Credential Offer that can be pre-authorized and/or bound to a specific target user. + * Creates a Credential Offer that can be pre-authorized and/or bound to a specific target user and client. *

- * Credential Offer Validity Matrix for the supported request parameters "pre_authorized", "targetUser" combinations. + * Credential Offer Validity Matrix for the supported request parameters. *

- * +----------+----------+---------+--------------------------------------------+ - * | Pre-Auth | Username | Valid | Notes | - * +----------+----------+---------+--------------------------------------------+ - * | no | no | yes | Anonymous offer; works for any login user. | - * | no | yes | yes | Offer restricted to a specific user. | - * +----------+----------+---------+--------------------------------------------+ - * | yes | no | yes | Self issued pre-auth offer. | - * | yes | yes | yes | Offer restricted to a specific 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. | + * +----------+----------+-----------------+---------+------------------------------------------------------+ + * | 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. | + * +----------+----------+-----------------+---------+------------------------------------------------------+ *

* Pre-Authorized Code Grant Offer * @@ -460,8 +465,8 @@ public Response createCredentialOffer(String credConfigId, boolean preAuthorized * * @@ -485,6 +490,7 @@ public Response createCredentialOffer(String credConfigId, boolean preAuthorized * @param credentialConfigurationId A valid credential configuration id * @param preAuthorized A flag whether the offer should be pre-authorized * @param targetUser The username that the offer is authorized for + * @param targetClient The client_id that the offer is scoped to (optional) * @param expiresAt The date/time when the offer expires (in Unix timestamp seconds) * @param responseType The response type, which can be 'uri', 'qr' or 'uri+qr' * @param width The width of the QR code image @@ -497,6 +503,7 @@ public Response createCredentialOffer( @QueryParam("credential_configuration_id") String credentialConfigurationId, @QueryParam("pre_authorized") @DefaultValue("false") Boolean preAuthorized, @QueryParam("target_user") String targetUser, + @QueryParam("target_client") String targetClient, @QueryParam("expire") Long expiresAt, @QueryParam("type") @DefaultValue("uri") OfferResponseType responseType, @QueryParam("width") @DefaultValue("200") int width, @@ -569,17 +576,13 @@ public Response createCredentialOffer( String grantType = preAuthorized ? PRE_AUTH_GRANT_TYPE : AUTH_CODE_GRANT_TYPE; List credentialConfigurationIds = List.of(credentialConfigurationId); - // Bind pre-authorized code offers to the authenticated client to prevent redemption from a different client_id. - // Authorization Code Grant offers are intentionally not bound to a client_id. - // https://github.com/keycloak/keycloak/issues/48188 - String targetClientId = preAuthorized ? clientModel.getClientId() : null; - CredentialOfferState offerState; try { CredentialOfferProvider offerProvider = session.getProvider(CredentialOfferProvider.class); offerState = offerProvider.createCredentialOffer(loginUserModel, grantType, - credentialConfigurationIds, targetClientId, targetUser, expiresAt); + credentialConfigurationIds, targetClient, targetUser, expiresAt); + if (preAuthorized) { offerState.setOriginatingUserId(loginUserModel.getId()); // Bearer authentication reconstructs request-scoped sessions for stateless tokens. Bind the offer to @@ -617,8 +620,9 @@ public Response createCredentialOffer( offerStorage.putOfferState(offerState); String targetUserId = offerState.getTargetUserId(); + String targetClientId = offerState.getTargetClientId(); LOGGER.debugf("Stored credential offer state: [grant=%s, ids=%s, cid=%s, uid=%s, nonce=%s]", - grantType, credentialConfigurationIds, offerState.getTargetClientId(), targetUserId, offerState.getNonce()); + grantType, credentialConfigurationIds, targetClientId, targetUserId, offerState.getNonce()); // Add event details eventBuilder.detail(Details.VERIFIABLE_CREDENTIAL_PRE_AUTHORIZED, String.valueOf(preAuthorized)) diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialoffer/CredentialOfferProvider.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialoffer/CredentialOfferProvider.java index c447b6baa736..5a4e305fb91c 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialoffer/CredentialOfferProvider.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialoffer/CredentialOfferProvider.java @@ -82,7 +82,7 @@ CredentialOfferState createCredentialOffer( String grantType, List credentialConfigurationIds, String targetClientId, - String targetUserId, + String targetUsername, Integer expireAt ); @@ -97,15 +97,15 @@ default CredentialOfferState createCredentialOffer( String grantType, List credentialConfigurationIds, String targetClientId, - String targetUserId, + String targetUsername, long expireAt ) { if (expireAt < Integer.MIN_VALUE || expireAt > Integer.MAX_VALUE) { throw new CredentialOfferException(Errors.INVALID_REQUEST, "Credential offer expiration is outside the integer range supported by this provider: " + expireAt); } - return createCredentialOffer(user, grantType, credentialConfigurationIds, targetClientId, targetUserId, - Integer.valueOf((int) expireAt)); + return createCredentialOffer(user, grantType, credentialConfigurationIds, + targetClientId, targetUsername, Integer.valueOf((int) expireAt)); } @Override diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialoffer/DefaultCredentialOfferProvider.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialoffer/DefaultCredentialOfferProvider.java index b15324d2cf87..4691ed924f68 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialoffer/DefaultCredentialOfferProvider.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialoffer/DefaultCredentialOfferProvider.java @@ -17,8 +17,11 @@ package org.keycloak.protocol.oid4vc.issuance.credentialoffer; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Optional; +import java.util.Set; import org.keycloak.common.Profile; import org.keycloak.events.Errors; @@ -27,6 +30,7 @@ import org.keycloak.models.RealmModel; import org.keycloak.models.UserModel; import org.keycloak.models.oid4vci.CredentialScopeModel; +import org.keycloak.models.utils.ModelToRepresentation; import org.keycloak.protocol.oid4vc.issuance.CredentialOfferException; import org.keycloak.protocol.oid4vc.issuance.OID4VCAuthorizationDetailsProcessor; import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerWellKnownProvider; @@ -38,6 +42,7 @@ import org.keycloak.protocol.oid4vc.model.PreAuthCodeCtx; import org.keycloak.protocol.oid4vc.model.PreAuthorizedCodeGrant; import org.keycloak.protocol.oid4vc.utils.OID4VCUtil; +import org.keycloak.representations.idm.ClientRepresentation; import org.keycloak.util.Strings; import static org.keycloak.OID4VCConstants.OID4VCI_ENABLED_ATTRIBUTE_KEY; @@ -66,8 +71,8 @@ public CredentialOfferState createCredentialOffer( String targetClientId, String targetUsername, Integer expireAt) { - return createCredentialOffer(user, grantType, credentialConfigurationIds, targetClientId, targetUsername, - expireAt.longValue()); + return createCredentialOffer(user, grantType, credentialConfigurationIds, + targetClientId, targetUsername, expireAt.longValue()); } @Override @@ -95,16 +100,30 @@ public CredentialOfferState createCredentialOffer( RealmModel realmModel = this.session.getContext().getRealm(); + Map credScopeModels = new LinkedHashMap<>(); + for (String cid : credentialConfigurationIds) { + CredentialScopeModel credScopeModel = Optional.ofNullable(findCredentialScopeModelByConfigurationId( + realmModel, () -> session.clientScopes().getClientScopesStream(realmModel), cid)) + .orElseThrow(() -> new CredentialOfferException(Errors.INVALID_REQUEST, "No credential scope model for: " + cid)); + credScopeModels.put(cid, credScopeModel); + } + // Validate the target user // UserModel targetUser = Optional.ofNullable(targetUsername) .map(tu -> validateTargetUser(session, realmModel, user, tu)) .orElse(null); - String targetUserId = targetUser == null ? null : targetUser.getId(); + String targetUserId = targetUser != null ? targetUser.getId() : null; + + // Discovery: find the unique OID4VCI-enabled client that has this scope assigned + if (preAuthorized && targetClientId == null) { + targetClientId = discoverTargetClient(realmModel, credScopeModels); + } // Validate the target client if (targetClientId != null) { - validateTargetClient(realmModel, targetClientId); + List credScopeNames = credScopeModels.values().stream().map(CredentialScopeModel::getName).toList(); + validateTargetClient(realmModel, targetClientId, credScopeNames); } // Create the CredentialsOffer @@ -117,16 +136,11 @@ public CredentialOfferState createCredentialOffer( // CredentialOfferState offerState = new CredentialOfferState(credOffer, targetClientId, targetUserId, expireAt, credOffersId -> { List authDetails = new ArrayList<>(); - for (String credConfigId : credentialConfigurationIds) { - CredentialScopeModel credScope = findCredentialScopeModelByConfigurationId( - realmModel, () -> session.clientScopes().getClientScopesStream(realmModel), credConfigId); - if (credScope == null) { - throw new CredentialOfferException(Errors.INVALID_REQUEST, "No credential scope model for: " + credConfigId); - } + for (CredentialScopeModel credScope : credScopeModels.values()) { + String credConfigId = credScope.getCredentialConfigurationId(); if (targetUser != null && !OID4VCUtil.hasVerifiableCredential(session, targetUser, credScope)) { throw new CredentialOfferException(Errors.INVALID_REQUEST, "User '" + targetUser.getUsername() + "' does not have verifiable credential '" + credConfigId + "'."); } - OID4VCAuthorizationDetailsProcessor authDetailsProcessor = new OID4VCAuthorizationDetailsProcessor(session); authDetails.add(authDetailsProcessor.generateResponseAuthorizationDetails(credScope, credOffersId)); } @@ -175,11 +189,40 @@ private UserModel validateTargetUser(KeycloakSession session, RealmModel realmMo return targetUserModel; } - private void validateTargetClient(RealmModel realm, String clientId) { - ClientModel client = session.clients().getClientByClientId(realm, clientId); - if (client == null) { + /** + * Discovers the unique OID4VCI-enabled client that has the given credential scope. + * Fails fast if zero or more than one client match. + */ + private String discoverTargetClient(RealmModel realm, Map credScopeModels) { + + List candidateClients = session.clients().getClientsStream(realm) + .filter(ClientModel::isEnabled) + .filter(c -> Boolean.parseBoolean(c.getAttributes().get(OID4VCI_ENABLED_ATTRIBUTE_KEY))) + .toList(); + + Set credConfigIds = credScopeModels.keySet(); + List credScopeNames = credScopeModels.values().stream().map(CredentialScopeModel::getName).toList(); + List matches = candidateClients.stream() + .filter(c -> c.getClientScopes(false).keySet().containsAll(credScopeNames)) + .toList(); + + if (matches.isEmpty()) { + throw new CredentialOfferException(Errors.INVALID_REQUEST, + "No OID4VCI client found for credential configuration ids: " + credConfigIds); + } + if (matches.size() > 1) { + throw new CredentialOfferException(Errors.INVALID_REQUEST, + "Multiple OID4VCI clients for credential configuration ids: " + credConfigIds); + } + return matches.get(0).getClientId(); + } + + private void validateTargetClient(RealmModel realm, String clientId, List credentialScopeNames) { + ClientModel clientModel = session.clients().getClientByClientId(realm, clientId); + if (clientModel == null) { throw new CredentialOfferException(Errors.CLIENT_NOT_FOUND, "Client '" + clientId + "' not found"); } + ClientRepresentation client = ModelToRepresentation.toRepresentation(clientModel, session); if (!client.isEnabled()) { throw new CredentialOfferException(Errors.CLIENT_DISABLED, "Client '" + clientId + "' disabled"); } @@ -187,6 +230,12 @@ private void validateTargetClient(RealmModel realm, String clientId) { if (!oid4vciEnabled) { throw new CredentialOfferException(Errors.INVALID_CLIENT, "Client '" + clientId + "' is not enabled for OID4VCI features."); } + List clientScopes = client.getOptionalClientScopes(); + for (String scope : credentialScopeNames) { + if (!clientScopes.contains(scope)) { + throw new CredentialOfferException(Errors.INVALID_CLIENT, "Client '" + clientId + "' does not support '" + scope + "'"); + } + } } /** diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/requiredactions/VerifiableCredentialOfferAction.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/requiredactions/VerifiableCredentialOfferAction.java index 2a9e954239a6..419aa6e1683e 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/requiredactions/VerifiableCredentialOfferAction.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/requiredactions/VerifiableCredentialOfferAction.java @@ -170,14 +170,14 @@ private CredentialOfferState createCredentialsOffer(KeycloakSession session, Rea String credentialConfigurationId = actionConfig.getCredentialConfigurationId(); event = event.clone().detail(Details.CREDENTIAL_TYPE, credentialConfigurationId); - String clientId = actionConfig.getClientId(); CredentialOfferProvider offerProvider = session.getProvider(CredentialOfferProvider.class); CredentialOfferState offerState = offerProvider.createCredentialOffer(user, grantType, - List.of(credentialConfigurationId), clientId, user.getUsername(), expiresAt); + List.of(credentialConfigurationId), actionConfig.getClientId(), user.getUsername(), expiresAt); CredentialOfferStorage offerStorage = session.getProvider(CredentialOfferStorage.class); offerStorage.putOfferState(offerState); + String clientId = offerState.getTargetClientId(); logger.debugf("Stored credential offer state: [credentialConfigId=%s, clientId=%s, username=%s, nonce=%s]", credentialConfigurationId, clientId, user.getUsername(), offerState.getNonce()); diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/utils/CredentialScopeUtils.java b/services/src/main/java/org/keycloak/protocol/oid4vc/utils/CredentialScopeUtils.java index 83b1e301d69c..1a7459b02157 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/utils/CredentialScopeUtils.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/utils/CredentialScopeUtils.java @@ -29,27 +29,32 @@ public class CredentialScopeUtils { // Hide ctor private CredentialScopeUtils() {} - public static CredentialScopeModel findCredentialScopeModelByConfigurationId(RealmModel realmModel, Supplier> supplier, String credConfigId) { + public static List findCredentialScopeModelsByConfigurationId(RealmModel realmModel, Supplier> supplier, String credConfigId) { if (Strings.isEmpty(credConfigId)) { - return null; + return List.of(); } - List credScopes = supplier.get() + List scopesModels = supplier.get() .filter(it -> it.getProtocol().equals(OID4VC_PROTOCOL)) .map(CredentialScopeModel::new) .filter(it -> credConfigId.equals(it.getCredentialConfigurationId())) .toList(); + return scopesModels; + } + + public static CredentialScopeModel findCredentialScopeModelByConfigurationId(RealmModel realmModel, Supplier> supplier, String credConfigId) { + List credScopes = findCredentialScopeModelsByConfigurationId(realmModel, supplier, credConfigId); if (credScopes.size() > 1) { List clientScopeNames = credScopes.stream().map(ClientScopeModel::getName).toList(); log.warnf("Multiple client scopes found for credential configuration '%s' in realm '%s': %s", credConfigId, realmModel.getName(), clientScopeNames); return null; - } else if (credScopes.isEmpty()) { - log.warnf("No client scopes found for credential configuration '%s' in realm '%s'", + } + if (credScopes.isEmpty()) { + log.debugf("No client scopes found for credential configuration '%s' in realm '%s'", credConfigId, realmModel.getName()); return null; - } else { - return credScopes.get(0); } + return credScopes.get(0); } public static CredentialScopeModel findCredentialScopeModelByName(RealmModel realmModel, Supplier> supplier, String scope) { diff --git a/services/src/test/java/org/keycloak/protocol/oid4vc/issuance/credentialoffer/CredentialOfferProviderTest.java b/services/src/test/java/org/keycloak/protocol/oid4vc/issuance/credentialoffer/CredentialOfferProviderTest.java index 12135e6d650d..13ac2f747652 100644 --- a/services/src/test/java/org/keycloak/protocol/oid4vc/issuance/credentialoffer/CredentialOfferProviderTest.java +++ b/services/src/test/java/org/keycloak/protocol/oid4vc/issuance/credentialoffer/CredentialOfferProviderTest.java @@ -15,8 +15,8 @@ public class CredentialOfferProviderTest { @Test public void shouldDelegateLongExpirationToIntegerSpiImplementation() { AtomicReference delegatedExpiration = new AtomicReference<>(); - CredentialOfferProvider provider = (user, grantType, credentialConfigurationIds, targetClientId, targetUserId, - expireAt) -> { + CredentialOfferProvider provider = (user, grantType, credentialConfigurationIds, + targetClientId, targetUsername, expireAt) -> { delegatedExpiration.set(expireAt); return null; }; @@ -28,8 +28,8 @@ public void shouldDelegateLongExpirationToIntegerSpiImplementation() { @Test public void shouldRejectLongExpirationOutsideIntegerSpiRange() { - CredentialOfferProvider provider = (user, grantType, credentialConfigurationIds, targetClientId, targetUserId, - expireAt) -> null; + CredentialOfferProvider provider = (user, grantType, credentialConfigurationIds, + targetClientId, targetUsername, expireAt) -> null; long expireAt = (long) Integer.MAX_VALUE + 1; CredentialOfferException exception = assertThrows(CredentialOfferException.class, diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCBasicWallet.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCBasicWallet.java index e8cd0d433ebc..3b7420a15175 100644 --- a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCBasicWallet.java +++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCBasicWallet.java @@ -55,6 +55,7 @@ import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation; import org.keycloak.representations.AccessToken; import org.keycloak.representations.JsonWebToken; +import org.keycloak.representations.idm.ClientRepresentation; import org.keycloak.representations.idm.UserRepresentation; import org.keycloak.sdjwt.vp.SdJwtVP; import org.keycloak.testframework.oauth.OAuthClient; @@ -133,11 +134,11 @@ public OID4VCBasicWallet(Keycloak keycloak, OAuthClient oauth) { /** * A composite action to create a credential offer uri with caller provided properties */ - public CredentialOfferURI createCredentialOfferUri(OID4VCTestContext ctx, Consumer consumer) { + public CredentialOfferUriResponse createCredentialOfferUri(OID4VCTestContext ctx, Consumer consumer) { // Get Issuer AccessToken // - AccessTokenResponse issTokenResponse = getAccessToken(ctx.getIssuer()); + AccessTokenResponse issTokenResponse = getAccessToken(ctx.getIssuerClient(), ctx.getIssuer()); // Exclude scope: // Require role: credential-offer-create @@ -160,7 +161,7 @@ public CredentialOfferURI createCredentialOfferUri(OID4VCTestContext ctx, Consum } } - return credOfferUriRes.getCredentialOfferURI(); + return credOfferUriRes; } /** @@ -170,7 +171,8 @@ public CredentialsOffer createCredentialOffer(OID4VCTestContext ctx, Consumer, Object> attachments = new HashMap<>(); public OID4VCTestContext(ClientRepresentation client, CredentialScopeRepresentation credentialScope) { - this.client = client; + this.holderClient = client; + this.issuerClient = client; this.issuer = "john"; this.holder = "alice"; this.credentialScope = credentialScope; } - public ClientRepresentation getClient() { - return client; + public ClientRepresentation getHolderClient() { + return holderClient; } - public void setClient(ClientRepresentation client) { - this.client = client; + public OID4VCTestContext withHolderClient(ClientRepresentation client) { + this.holderClient = client; + return this; } + public ClientRepresentation getIssuerClient() { + return issuerClient; + } + + public OID4VCTestContext withIssuerClient(ClientRepresentation client) { + this.issuerClient = client; + return this; + } + + public String getClientId() { return holderClient.getClientId(); } + public String getIssuer() { return issuer; } - public void setIssuer(String issuer) { + public OID4VCTestContext withIssuer(String issuer) { this.issuer = issuer; + return this; } public String getHolder() { return holder; } - public void setHolder(String holder) { + public OID4VCTestContext withHolder(String holder) { this.holder = holder; + return this; } public CredentialScopeRepresentation getCredentialScope() { return credentialScope; } - public void setCredentialScope(CredentialScopeRepresentation credentialScope) { + public OID4VCTestContext setCredentialScope(CredentialScopeRepresentation credentialScope) { this.credentialScope = credentialScope; + return this; } public List 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 dd7a1b3aa8ff..7488c7b70ce7 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 @@ -251,6 +251,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 b71be8a0186e..32dc564dd305 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 @@ -4,13 +4,17 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +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.common.util.Time; 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; @@ -35,27 +39,25 @@ import static org.keycloak.constants.OID4VCIConstants.CREDENTIAL_OFFER_CREATE; import static org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint.CREDENTIAL_OFFER_LIFESPAN_REALM_ATTRIBUTE_KEY; import static org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint.DEFAULT_CREDENTIAL_OFFER_LIFESPAN_S; - 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.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 { @@ -64,33 +66,28 @@ public class OID4VCredentialOfferPreAuthTest extends OID4VCIssuerTestBase { public void testPreAuthOffer_RejectsExpirationBeyondConfiguredLifespan() { var ctx = new OID4VCTestContext(client, jwtTypeCredentialScope); - IllegalStateException error = assertThrows(IllegalStateException.class, - () -> wallet.createCredentialOfferUri(ctx, req -> { - req.targetUser(ctx.getHolder()); - req.preAuthorized(true); - req.expireAt(Time.currentTimeSeconds() + DEFAULT_CREDENTIAL_OFFER_LIFESPAN_S + 60); - })); - - CredentialOfferUriResponse response = ctx.getCredentialsOfferUriResponse(); + CredentialOfferUriResponse response = wallet.createCredentialOfferUri(ctx, req -> { + req.targetClient(ctx.getClientId()); + req.targetUser(ctx.getHolder()); + req.preAuthorized(true); + req.expireAt(Time.currentTimeSeconds() + DEFAULT_CREDENTIAL_OFFER_LIFESPAN_S + 60); + }); assertEquals(HttpStatus.SC_BAD_REQUEST, response.getStatusCode()); assertEquals("invalid_credential_offer_request", response.getError()); assertTrue(response.getErrorDescription() .contains("Credential offer expiration must be after the current time and no later than")); - assertEquals(String.format("[%s] %s", response.getError(), response.getErrorDescription()), error.getMessage()); } @Test public void testPreAuthOffer_RejectsLongMaximumExpiration() { var ctx = new OID4VCTestContext(client, jwtTypeCredentialScope); - assertThrows(IllegalStateException.class, - () -> wallet.createCredentialOfferUri(ctx, req -> { - req.targetUser(ctx.getHolder()); - req.preAuthorized(true); - req.expireAt(Long.MAX_VALUE); - })); - - CredentialOfferUriResponse response = ctx.getCredentialsOfferUriResponse(); + CredentialOfferUriResponse response = wallet.createCredentialOfferUri(ctx, req -> { + req.targetClient(ctx.getClientId()); + req.targetUser(ctx.getHolder()); + req.preAuthorized(true); + req.expireAt(Long.MAX_VALUE); + }); assertEquals(HttpStatus.SC_BAD_REQUEST, response.getStatusCode()); assertEquals("invalid_credential_offer_request", response.getError()); } @@ -99,14 +96,12 @@ public void testPreAuthOffer_RejectsLongMaximumExpiration() { public void testPreAuthOffer_RejectsExpirationInThePast() { var ctx = new OID4VCTestContext(client, jwtTypeCredentialScope); - assertThrows(IllegalStateException.class, - () -> wallet.createCredentialOfferUri(ctx, req -> { - req.targetUser(ctx.getHolder()); - req.preAuthorized(true); - req.expireAt(Time.currentTimeSeconds() - 1L); - })); - - CredentialOfferUriResponse response = ctx.getCredentialsOfferUriResponse(); + CredentialOfferUriResponse response = wallet.createCredentialOfferUri(ctx, req -> { + req.targetClient(ctx.getClientId()); + req.targetUser(ctx.getHolder()); + req.preAuthorized(true); + req.expireAt(Time.currentTimeSeconds() - 1L); + }); assertEquals(HttpStatus.SC_BAD_REQUEST, response.getStatusCode()); assertEquals("invalid_credential_offer_request", response.getError()); } @@ -120,13 +115,11 @@ public void testPreAuthOffer_RejectsDefaultExpirationForNonPositiveConfiguredLif Integer.toString(invalidLifespan))); var ctx = new OID4VCTestContext(client, jwtTypeCredentialScope); - assertThrows(IllegalStateException.class, - () -> wallet.createCredentialOfferUri(ctx, req -> { - req.targetUser(ctx.getHolder()); - req.preAuthorized(true); - })); - - CredentialOfferUriResponse response = ctx.getCredentialsOfferUriResponse(); + CredentialOfferUriResponse response = wallet.createCredentialOfferUri(ctx, req -> { + req.targetClient(ctx.getClientId()); + req.targetUser(ctx.getHolder()); + req.preAuthorized(true); + }); assertEquals(HttpStatus.SC_BAD_REQUEST, response.getStatusCode()); assertEquals("invalid_credential_offer_request", response.getError()); } finally { @@ -147,6 +140,7 @@ public void testPreAuthOffer_OriginatingSessionLogoutRevokesCode() { var ctx = new OID4VCTestContext(client, jwtTypeCredentialScope); CredentialsOffer credOffer = wallet.createCredentialOffer(ctx, req -> { + req.targetClient(ctx.getClientId()); req.targetUser(ctx.getHolder()); req.preAuthorized(true); }); @@ -166,6 +160,7 @@ public void testPreAuthOffer_OriginatingUserPasswordChangeRevokesCode() { var ctx = new OID4VCTestContext(client, jwtTypeCredentialScope); CredentialsOffer credOffer = wallet.createCredentialOffer(ctx, req -> { + req.targetClient(ctx.getClientId()); req.targetUser(ctx.getHolder()); req.preAuthorized(true); }); @@ -205,6 +200,7 @@ public void testPreAuthOffer_ServiceAccountCanRedeemCode() { CredentialOfferURI offerUri = oauth.oid4vc() .credentialOfferUriRequest(ctx.getCredentialConfigurationId()) + .targetClient(ctx.getClientId()) .targetUser(ctx.getHolder()) .preAuthorized(true) .bearerToken(clientCredentialsResponse.getAccessToken()) @@ -221,10 +217,105 @@ public void testPreAuthOffer_ServiceAccountCanRedeemCode() { } } + @Test + public void testPreAuthOfferMatrix() { + + record MatrixParams( + ClientRepresentation issuerClient, + ClientRepresentation targetClient, + String targetUser + ) { + 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.targetClient(p.targetClientId()); + req.targetUser(p.targetUser()); + 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()); + wallet.logout(); + 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(); + + return true; + }; + + assertTrue(runMatrixParams.apply(new MatrixParams(client2, null, null))); + assertTrue(runMatrixParams.apply(new MatrixParams(client2, client, null))); + assertFalse(runMatrixParams.apply(new MatrixParams(client2, client2, null))); + assertTrue(runMatrixParams.apply(new MatrixParams(client2, null, "alice"))); + assertTrue(runMatrixParams.apply(new MatrixParams(client2, client, "alice"))); + assertFalse(runMatrixParams.apply(new MatrixParams(client2, client2, "alice"))); + } + @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); @@ -233,12 +324,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) + .targetClient(ctx.getClientId()) + .targetUser(ctx.getHolder()) + .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); @@ -246,137 +342,133 @@ 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_SelfIssued() throws Exception { + public void testPreAuthOffer_ExplicitTargetClient_NotFound() { - 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); - }); + CredentialOfferUriResponse uriResponse = wallet.credentialOfferUriRequest(ctx, ctx.getCredentialConfigurationId()) + .bearerToken(wallet.getIssuerAccessToken(ctx)) + .targetUser(ctx.getHolder()) + .targetClient("non-existent-client") + .preAuthorized(true) + .send(); - String preAuthCode = credOffer.getPreAuthorizedCode(); - - CredentialOfferURI offerURI = ctx.getCredentialsOfferUri(); - assertNotNull(offerURI, "No CredentialOfferURI"); - - // Verify internal offer state target user and client - // - CredentialOfferStateRecord runtimeOfferState = getCredentialOfferStateRecord(runOnServer, offerURI.getNonce()); - assertEquals("john", runtimeOfferState.targetUsername()); - assertEquals(client.getClientId(), runtimeOfferState.targetClientId()); + 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()); + } - // Redeem Pre-Authorized Code for AccessToken - // - AccessTokenResponse tokenResponse = wallet.accessTokenRequestPreAuth(ctx, preAuthCode).send(); - assertTrue(tokenResponse.isSuccess(), tokenResponse.getErrorDescription()); + /** + * Explicit targetClient that is not OID4VCI-enabled: must fail at offer-creation time. + */ + @Test + public void testPreAuthOffer_ExplicitTargetClient_NotOid4vciEnabled() { - String accessToken = wallet.validateHolderAccessToken(ctx, tokenResponse); - assertNotNull(accessToken, "No accessToken"); + var ctx = createOID4VCTestContext(); - String authorizedIdentifier = ctx.getAuthorizedCredentialIdentifier(); - assertNotNull(authorizedIdentifier, "No authorized credential identifier"); + // Use a well-known Keycloak built-in client that is not OID4VCI-enabled + String nonOid4vciClientId = "account"; - // Send the CredentialRequest - // - CredentialResponse credResponse = wallet.credentialRequest(ctx, accessToken) - .credentialIdentifier(authorizedIdentifier) - .proofs(wallet.generateJwtProof(ctx)) - .send().getCredentialResponse(); + CredentialOfferUriResponse uriResponse = wallet.credentialOfferUriRequest(ctx, ctx.getCredentialConfigurationId()) + .bearerToken(wallet.getIssuerAccessToken(ctx)) + .targetUser(ctx.getHolder()) + .targetClient(nonOid4vciClientId) + .preAuthorized(true) + .send(); - verifyCredentialResponse(ctx, ctx.getIssuer(), credResponse); + 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()); } + /** + * 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_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); - }); + public void testPreAuthOffer_DiscoverTargetClient_NoMatch() { - CredentialOfferURI offerURI = ctx.getCredentialsOfferUri(); - assertNotNull(offerURI, "No CredentialOfferURI"); + var ctx = createOID4VCTestContext(); + String credConfigId = ctx.getCredentialConfigurationId(); - // 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"); + var otherClients = List.of(client, abcaClient, pubClient); + try { + removeOptionalClientScope(otherClients, ctx.getCredentialScope()); - // Fetch credential offer again - // https://github.com/keycloak/keycloak/issues/48014 - credOffer = wallet.credentialsOfferRequest(ctx, offerURI).send().getCredentialsOffer(); - preAuthCode = credOffer.getPreAuthorizedCode(); - assertNotNull(preAuthCode, "preAuthCode"); + CredentialOfferUriResponse uriResponse = wallet.credentialOfferUriRequest(ctx, credConfigId) + .bearerToken(wallet.getIssuerAccessToken(ctx)) + .targetUser(ctx.getHolder()) + // no targetClient → discovery attempted, should fail + .preAuthorized(true) + .send(); - // Redeem Pre-Authorized Code for AccessToken - // - AccessTokenResponse tokenResponse = wallet.accessTokenRequestPreAuth(ctx, preAuthCode).send(); - assertTrue(tokenResponse.isSuccess(), tokenResponse.getErrorDescription()); + 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()); - String accessToken = wallet.validateHolderAccessToken(ctx, tokenResponse); - assertNotNull(accessToken, "No accessToken"); + } finally { + restoreOptionalClientScope(otherClients, ctx.getCredentialScope()); + } + } - String authorizedIdentifier = ctx.getAuthorizedCredentialIdentifier(); - assertNotNull(authorizedIdentifier, "Has authorized credential identifier"); + /** + * 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_DiscoverTargetClient_Ambiguous() { - // Send the CredentialRequest - // - CredentialResponse credResponse = wallet.credentialRequest(ctx, accessToken) - .credentialIdentifier(authorizedIdentifier) - .proofs(wallet.generateJwtProof(ctx)) - .send().getCredentialResponse(); + var ctx = createOID4VCTestContext(); - verifyCredentialResponse(ctx, ctx.getHolder(), credResponse); + CredentialOfferUriResponse uriResponse = wallet.credentialOfferUriRequest(ctx, ctx.getCredentialConfigurationId()) + .bearerToken(wallet.getIssuerAccessToken(ctx)) + .targetUser(ctx.getHolder()) + .preAuthorized(true) + // no targetClient → multiple clients match → should fail + .send(); - // 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()); + 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 --------------------------------------------------------------------------------------------------------- @@ -388,13 +480,38 @@ private CredentialRepresentation passwordCredential(String password) { return credential; } - 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 7d60ca9807e0..2c2f42e0f1c7 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