diff --git a/.gitignore b/.gitignore index 5b5e5407c4d..b6ac133facd 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ # Intellij ################### .idea +.run *.iml !.idea/icon.png diff --git a/core/src/main/java/org/keycloak/OAuth2Constants.java b/core/src/main/java/org/keycloak/OAuth2Constants.java index a029bd8c50b..83ab3a540e9 100755 --- a/core/src/main/java/org/keycloak/OAuth2Constants.java +++ b/core/src/main/java/org/keycloak/OAuth2Constants.java @@ -164,7 +164,8 @@ public interface OAuth2Constants { String CNF = "cnf"; // RAR - https://datatracker.ietf.org/doc/html/rfc9396 - String AUTHORIZATION_DETAILS_PARAM = "authorization_details"; + // Used as url parameter in AuthorizationRequest and as claim in TokenResponse + String AUTHORIZATION_DETAILS = "authorization_details"; // DPoP - https://datatracker.ietf.org/doc/html/rfc9449 String DPOP_HTTP_HEADER = "DPoP"; @@ -173,4 +174,7 @@ public interface OAuth2Constants { String DPOP_JWT_HEADER_TYPE = "dpop+jwt"; String ALGS_ATTRIBUTE = "algs"; + // OID4VCI - https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html + String OPENID_CREDENTIAL = "openid_credential"; + String CREDENTIAL_IDENTIFIERS = "credential_identifiers"; } diff --git a/core/src/main/java/org/keycloak/util/JsonSerialization.java b/core/src/main/java/org/keycloak/util/JsonSerialization.java index 1b2b9d9872a..e6d93a138d5 100755 --- a/core/src/main/java/org/keycloak/util/JsonSerialization.java +++ b/core/src/main/java/org/keycloak/util/JsonSerialization.java @@ -51,6 +51,22 @@ public class JsonSerialization { prettyMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); } + public static String valueAsString(Object obj) { + try { + return mapper.writeValueAsString(obj); + } catch (IOException e) { + throw new IllegalArgumentException(e); + } + } + + public static T valueFromString(String string, Class type) { + try { + return mapper.readValue(string, type); + } catch (IOException e) { + throw new IllegalArgumentException(e); + } + } + public static void writeValueToStream(OutputStream os, Object obj) throws IOException { mapper.writeValue(os, obj); } diff --git a/server-spi-private/src/main/java/org/keycloak/constants/Oid4VciConstants.java b/server-spi-private/src/main/java/org/keycloak/constants/OID4VCIConstants.java similarity index 84% rename from server-spi-private/src/main/java/org/keycloak/constants/Oid4VciConstants.java rename to server-spi-private/src/main/java/org/keycloak/constants/OID4VCIConstants.java index 9b21a3b1346..c35dff07e40 100644 --- a/server-spi-private/src/main/java/org/keycloak/constants/Oid4VciConstants.java +++ b/server-spi-private/src/main/java/org/keycloak/constants/OID4VCIConstants.java @@ -18,13 +18,15 @@ package org.keycloak.constants; +import org.keycloak.representations.idm.RoleRepresentation; + /** * Keycloak specific constants related to OID4VC and related functionality. Useful for example for internal constants (EG. name of Keycloak realm attributes). * For protocol constants defined in the specification, see {@link org.keycloak.OID4VCConstants} * * @author Pascal Knüppel */ -public final class Oid4VciConstants { +public final class OID4VCIConstants { public static final String OID4VC_PROTOCOL = "oid4vc"; @@ -38,6 +40,9 @@ public final class Oid4VciConstants { public static final String SOURCE_ENDPOINT = "source_endpoint"; public static final String BATCH_CREDENTIAL_ISSUANCE_BATCH_SIZE = "batch_credential_issuance.batch_size"; - private Oid4VciConstants() { + public static final RoleRepresentation CREDENTIAL_OFFER_CREATE = + new RoleRepresentation("credential-offer-create", "Allow credential offer creation", false); + + private OID4VCIConstants() { } } diff --git a/server-spi-private/src/main/java/org/keycloak/models/oid4vci/CredentialScopeModel.java b/server-spi-private/src/main/java/org/keycloak/models/oid4vci/CredentialScopeModel.java index 69eff70d320..62ebafe5726 100644 --- a/server-spi-private/src/main/java/org/keycloak/models/oid4vci/CredentialScopeModel.java +++ b/server-spi-private/src/main/java/org/keycloak/models/oid4vci/CredentialScopeModel.java @@ -25,14 +25,14 @@ import java.util.Map; import java.util.Optional; import java.util.stream.Stream; -import org.keycloak.constants.Oid4VciConstants; +import org.keycloak.constants.OID4VCIConstants; import org.keycloak.models.ClientScopeModel; import org.keycloak.models.ProtocolMapperModel; import org.keycloak.models.RealmModel; import org.keycloak.models.RoleModel; import static org.keycloak.OID4VCConstants.SD_JWT_VC_FORMAT; -import static org.keycloak.constants.Oid4VciConstants.OID4VC_PROTOCOL; +import static org.keycloak.constants.OID4VCIConstants.OID4VC_PROTOCOL; /** * This class acts as delegate for a {@link ClientScopeModel} implementation and adds additional functionality for @@ -424,7 +424,7 @@ public class CredentialScopeModel implements ClientScopeModel { public Stream getOid4vcProtocolMappersStream() { return clientScope.getProtocolMappersStream().filter(pm -> { - return Oid4VciConstants.OID4VC_PROTOCOL.equals(pm.getProtocol()); + return OID4VCIConstants.OID4VC_PROTOCOL.equals(pm.getProtocol()); }).map(Oid4vcProtocolMapperModel::new); } diff --git a/server-spi-private/src/main/java/org/keycloak/models/utils/KeycloakModelUtils.java b/server-spi-private/src/main/java/org/keycloak/models/utils/KeycloakModelUtils.java index 4a4b75c2b7a..625aac6e5c0 100755 --- a/server-spi-private/src/main/java/org/keycloak/models/utils/KeycloakModelUtils.java +++ b/server-spi-private/src/main/java/org/keycloak/models/utils/KeycloakModelUtils.java @@ -55,7 +55,7 @@ import org.keycloak.common.util.PemUtils; import org.keycloak.common.util.SecretGenerator; import org.keycloak.common.util.Time; import org.keycloak.component.ComponentModel; -import org.keycloak.constants.Oid4VciConstants; +import org.keycloak.constants.OID4VCIConstants; import org.keycloak.crypto.Algorithm; import org.keycloak.deployment.DeployedConfigurationsManager; import org.keycloak.models.AccountRoles; @@ -1241,7 +1241,7 @@ public final class KeycloakModelUtils { public static List getAcceptedClientScopeProtocols(ClientModel client) { List acceptedClientProtocols; if (client.getProtocol() == null || "openid-connect".equals(client.getProtocol())) { - acceptedClientProtocols = List.of("openid-connect", Oid4VciConstants.OID4VC_PROTOCOL); + acceptedClientProtocols = List.of("openid-connect", OID4VCIConstants.OID4VC_PROTOCOL); }else { acceptedClientProtocols = List.of(client.getProtocol()); } diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/OID4VCLoginProtocolFactory.java b/services/src/main/java/org/keycloak/protocol/oid4vc/OID4VCLoginProtocolFactory.java index 7a9d3e53946..cf61bacbb58 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/OID4VCLoginProtocolFactory.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/OID4VCLoginProtocolFactory.java @@ -21,7 +21,7 @@ import java.util.HashMap; import java.util.Map; import org.keycloak.Config; -import org.keycloak.constants.Oid4VciConstants; +import org.keycloak.constants.OID4VCIConstants; import org.keycloak.events.EventBuilder; import org.keycloak.models.ClientModel; import org.keycloak.models.ClientScopeModel; @@ -43,7 +43,7 @@ import org.keycloak.representations.idm.ClientScopeRepresentation; import org.jboss.logging.Logger; -import static org.keycloak.constants.Oid4VciConstants.OID4VC_PROTOCOL; +import static org.keycloak.constants.OID4VCIConstants.OID4VC_PROTOCOL; import static org.keycloak.models.ClientScopeModel.INCLUDE_IN_TOKEN_SCOPE; import static org.keycloak.models.oid4vci.CredentialScopeModel.CONFIGURATION_ID; import static org.keycloak.models.oid4vci.CredentialScopeModel.CONTEXTS; @@ -82,7 +82,7 @@ public class OID4VCLoginProtocolFactory implements LoginProtocolFactory, OID4VCE private static final String LAST_NAME_MAPPER = "last-name"; private static final String FIRST_NAME_MAPPER = "first-name"; - public static final String PROTOCOL_ID = Oid4VciConstants.OID4VC_PROTOCOL; + public static final String PROTOCOL_ID = OID4VCIConstants.OID4VC_PROTOCOL; private final Map builtins = new HashMap<>(); @@ -181,6 +181,8 @@ public class OID4VCLoginProtocolFactory implements LoginProtocolFactory, OID4VCE return OIDCLoginProtocolFactory.UI_ORDER - 20; } + // Private --------------------------------------------------------------------------------------------------------- + private void addClientScopeDefaults(ClientScopeModel clientScope) { ClientScopeRepresentation clientScopeRep = ModelToRepresentation.toRepresentation(clientScope); addClientScopeDefaults(clientScopeRep); diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCAuthorizationDetailsProcessor.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCAuthorizationDetailsProcessor.java index 037e96e0e9b..79d3c86e584 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCAuthorizationDetailsProcessor.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCAuthorizationDetailsProcessor.java @@ -41,14 +41,13 @@ import org.keycloak.util.JsonSerialization; import com.fasterxml.jackson.core.type.TypeReference; import org.jboss.logging.Logger; +import static org.keycloak.OAuth2Constants.OPENID_CREDENTIAL; import static org.keycloak.models.Constants.AUTHORIZATION_DETAILS_RESPONSE; public class OID4VCAuthorizationDetailsProcessor implements AuthorizationDetailsProcessor { private static final Logger logger = Logger.getLogger(OID4VCAuthorizationDetailsProcessor.class); private final KeycloakSession session; - public static final String OPENID_CREDENTIAL_TYPE = "openid_credential"; - public OID4VCAuthorizationDetailsProcessor(KeycloakSession session) { this.session = session; } @@ -114,9 +113,9 @@ public class OID4VCAuthorizationDetailsProcessor implements AuthorizationDetails List claims = detail.getClaims(); // Validate type first - if (!OPENID_CREDENTIAL_TYPE.equals(type)) { + if (!OPENID_CREDENTIAL.equals(type)) { logger.warnf("Invalid authorization_details type: %s", type); - throw getInvalidRequestException("type: " + type + ", expected=" + OPENID_CREDENTIAL_TYPE); + throw getInvalidRequestException("type: " + type + ", expected=" + OPENID_CREDENTIAL); } // If authorization_servers is present, locations must be set to issuer identifier @@ -222,19 +221,10 @@ public class OID4VCAuthorizationDetailsProcessor implements AuthorizationDetails } OID4VCAuthorizationDetailsResponse responseDetail = new OID4VCAuthorizationDetailsResponse(); - responseDetail.setType(OPENID_CREDENTIAL_TYPE); + responseDetail.setType(OPENID_CREDENTIAL); responseDetail.setCredentialConfigurationId(credentialConfigurationId); responseDetail.setCredentialIdentifiers(credentialIdentifiers); - // Store credential identifier mapping in client session for later use during credential issuance - AuthenticatedClientSessionModel clientSession = clientSessionCtx.getClientSession(); - for (String credentialIdentifier : credentialIdentifiers) { - // Store the mapping between credential identifier and configuration ID in client session - String mappingKey = OID4VCIssuerEndpoint.CREDENTIAL_IDENTIFIER_PREFIX + credentialIdentifier; - clientSession.setNote(mappingKey, credentialConfigurationId); - logger.debugf("Stored credential identifier mapping: %s -> %s", credentialIdentifier, credentialConfigurationId); - } - // Store claims in user session for later use during credential issuance if (detail.getClaims() != null) { // Store claims with a unique key based on credential configuration ID @@ -289,17 +279,11 @@ public class OID4VCAuthorizationDetailsProcessor implements AuthorizationDetails } String credentialIdentifier = UUID.randomUUID().toString(); - - // Store the mapping between credential identifier and configuration ID in client session - // This will be used later when processing credential requests - String mappingKey = OID4VCIssuerEndpoint.CREDENTIAL_IDENTIFIER_PREFIX + credentialIdentifier; - clientSession.setNote(mappingKey, credentialConfigurationId); - logger.debugf("Generated credential identifier '%s' for configuration '%s'", credentialIdentifier, credentialConfigurationId); OID4VCAuthorizationDetailsResponse authDetail = new OID4VCAuthorizationDetailsResponse(); - authDetail.setType(OPENID_CREDENTIAL_TYPE); + authDetail.setType(OPENID_CREDENTIAL); authDetail.setCredentialConfigurationId(credentialConfigurationId); authDetail.setCredentialIdentifiers(List.of(credentialIdentifier)); diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCAuthorizationDetailsProcessorFactory.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCAuthorizationDetailsProcessorFactory.java index 395aee75ecb..41a1112162a 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCAuthorizationDetailsProcessorFactory.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCAuthorizationDetailsProcessorFactory.java @@ -23,6 +23,8 @@ import org.keycloak.protocol.oid4vc.OID4VCEnvironmentProviderFactory; import org.keycloak.protocol.oidc.rar.AuthorizationDetailsProcessor; import org.keycloak.protocol.oidc.rar.AuthorizationDetailsProcessorFactory; +import static org.keycloak.OAuth2Constants.OPENID_CREDENTIAL; + /** * Factory for creating OID4VCI-specific authorization details processors. * This factory is only enabled when the OID4VCI feature is available. @@ -31,7 +33,7 @@ import org.keycloak.protocol.oidc.rar.AuthorizationDetailsProcessorFactory; */ public class OID4VCAuthorizationDetailsProcessorFactory implements AuthorizationDetailsProcessorFactory, OID4VCEnvironmentProviderFactory { - public static final String PROVIDER_ID = OID4VCAuthorizationDetailsProcessor.OPENID_CREDENTIAL_TYPE; + public static final String PROVIDER_ID = OPENID_CREDENTIAL; @Override public AuthorizationDetailsProcessor create(KeycloakSession session) { 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 bb46442dd19..b3d1afedcc7 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 @@ -52,6 +52,7 @@ import org.keycloak.common.VerificationException; import org.keycloak.common.util.SecretGenerator; import org.keycloak.crypto.KeyUse; import org.keycloak.crypto.KeyWrapper; +import org.keycloak.events.Details; import org.keycloak.events.Errors; import org.keycloak.events.EventBuilder; import org.keycloak.jose.JOSEHeader; @@ -67,6 +68,7 @@ import org.keycloak.models.KeyManager; import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSessionFactory; import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; import org.keycloak.models.UserSessionModel; import org.keycloak.models.oid4vci.CredentialScopeModel; import org.keycloak.protocol.ProtocolMapper; @@ -74,6 +76,8 @@ import org.keycloak.protocol.oid4vc.OID4VCLoginProtocolFactory; import org.keycloak.protocol.oid4vc.issuance.credentialbuilder.CredentialBody; import org.keycloak.protocol.oid4vc.issuance.credentialbuilder.CredentialBuilder; import org.keycloak.protocol.oid4vc.issuance.credentialbuilder.CredentialBuilderFactory; +import org.keycloak.protocol.oid4vc.issuance.credentialoffer.CredentialOfferStorage; +import org.keycloak.protocol.oid4vc.issuance.credentialoffer.CredentialOfferStorage.CredentialOfferState; import org.keycloak.protocol.oid4vc.issuance.keybinding.CNonceHandler; import org.keycloak.protocol.oid4vc.issuance.keybinding.JwtCNonceHandler; import org.keycloak.protocol.oid4vc.issuance.keybinding.ProofValidator; @@ -103,8 +107,6 @@ import org.keycloak.protocol.oid4vc.model.VerifiableCredential; import org.keycloak.protocol.oid4vc.utils.ClaimsPathPointer; import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantType; import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantTypeFactory; -import org.keycloak.protocol.oidc.utils.OAuth2Code; -import org.keycloak.protocol.oidc.utils.OAuth2CodeParser; import org.keycloak.representations.AccessToken; import org.keycloak.representations.dpop.DPoP; import org.keycloak.saml.processing.api.util.DeflateUtil; @@ -130,6 +132,14 @@ import org.apache.http.client.methods.HttpOptions; import org.apache.http.client.methods.HttpPost; import org.jboss.logging.Logger; +import static org.keycloak.constants.OID4VCIConstants.CREDENTIAL_OFFER_CREATE; +import static org.keycloak.constants.OID4VCIConstants.OID4VC_PROTOCOL; +import static org.keycloak.events.EventType.INTROSPECT_TOKEN_ERROR; +import static org.keycloak.protocol.oid4vc.model.ErrorType.INVALID_CREDENTIAL_OFFER_REQUEST; +import static org.keycloak.protocol.oid4vc.model.ErrorType.INVALID_CREDENTIAL_REQUEST; +import static org.keycloak.protocol.oid4vc.model.ErrorType.UNKNOWN_CREDENTIAL_CONFIGURATION; +import static org.keycloak.protocol.oid4vc.model.ErrorType.UNKNOWN_CREDENTIAL_IDENTIFIER; + /** * Provides the (REST-)endpoints required for the OID4VCI protocol. *

@@ -148,12 +158,6 @@ public class OID4VCIssuerEndpoint { */ public static final String CREDENTIAL_CONFIGURATION_IDS_NOTE = "CREDENTIAL_CONFIGURATION_IDS"; - /** - * Prefix for session note keys that store the mapping between credential identifiers and configuration IDs. - * This is used to store mappings generated during authorization details processing. - */ - public static final String CREDENTIAL_IDENTIFIER_PREFIX = "credential_identifier_"; - /** * Prefix for session note keys that store authorization details claims. * This is used to store claims from authorization details for later use during credential issuance. @@ -318,82 +322,165 @@ public class OID4VCIssuerEndpoint { } /** - * Provides the URI to the OID4VCI compliant credentials offer + * Creates a Credential Offer Uri that is bound to the calling user. + */ + public Response getCredentialOfferURI(String credConfigId) { + UserSessionModel userSession = getAuthenticatedClientSession().getUserSession(); + return getCredentialOfferURI(credConfigId, true, userSession.getLoginUsername()); + } + + /** + * Creates a Credential Offer Uri that is bound to a specific user. + */ + public Response getCredentialOfferURI(String credConfigId, boolean preAuthorized, String targetUser) { + return getCredentialOfferURI(credConfigId, preAuthorized, null, targetUser, OfferUriType.URI, 0, 0); + } + + /** + * Creates a Credential Offer Uri that can be pre-authorized and hence bound to a specific client/user id. + *

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

+ * +----------+-----------+---------+---------+-----------------------------------------------------+ + * | pre-auth | clientId | userId | Valid | Notes | + * +----------+-----------+---------+---------+-----------------------------------------------------+ + * | no | no | no | yes | Generic offer; any logged-in user may redeem. | + * | no | no | yes | yes | Offer restricted to a specific user. | + * | no | yes | no | yes | Bound to client; user determined at login. | + * | no | yes | yes | yes | Bound to both client and user. | + * +----------+-----------+---------+---------+-----------------------------------------------------+ + * | yes | no | no | no | Pre-auth requires a user subject; missing userId. | + * | yes | yes | no | no | Same as above; userId required. | + * | yes | no | yes | yes | Pre-auth for a specific user; client unconstrained. | + * | yes | yes | yes | yes | Fully constrained: user + client. | + * +----------+-----------+---------+---------+-----------------------------------------------------+ + * + * @param credConfigId A valid credential configuration id + * @param preAuthorized A flag whether the offer should be pre-authorized (requires targetUser) + * @param appClientId The client id that the offer is authorized for + * @param appUserId The user id that the offer is authorized for + * @param type The response type, which can be 'uri' or 'qr-code' + * @param width The width of the QR code image + * @param height The height of the QR code image + * @see https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-offer-endpoint */ @GET @Produces({MediaType.APPLICATION_JSON, RESPONSE_TYPE_IMG_PNG}) @Path("credential-offer-uri") - public Response getCredentialOfferURI(@QueryParam("credential_configuration_id") String vcId, @QueryParam("type") @DefaultValue("uri") OfferUriType type, @QueryParam("width") @DefaultValue("200") int width, @QueryParam("height") @DefaultValue("200") int height) { + public Response getCredentialOfferURI( + @QueryParam("credential_configuration_id") String credConfigId, + @QueryParam("pre_authorized") @DefaultValue("true") boolean preAuthorized, + @QueryParam("client_id") String appClientId, + @QueryParam("user_id") String appUserId, + @QueryParam("type") @DefaultValue("uri") OfferUriType type, + @QueryParam("width") @DefaultValue("200") int width, + @QueryParam("height") @DefaultValue("200") int height + ) { configureCors(true); AuthenticatedClientSessionModel clientSession = getAuthenticatedClientSession(); - cors.allowedOrigins(session, clientSession.getClient()); + UserModel userModel = clientSession.getUserSession().getUser(); + ClientModel clientModel = clientSession.getClient(); + RealmModel realmModel = clientModel.getRealm(); + + cors.allowedOrigins(session, clientModel); checkClientEnabled(); - Map credentialsMap = OID4VCIssuerWellKnownProvider.getSupportedCredentials(session); - LOGGER.debugf("Get an offer for %s", vcId); - if (!credentialsMap.containsKey(vcId)) { - LOGGER.debugf("No credential with id %s exists.", vcId); - LOGGER.debugf("Supported credentials are %s.", credentialsMap); - throw new CorsErrorResponseException( - cors, - ErrorType.INVALID_CREDENTIAL_REQUEST.toString(), - "Invalid credential configuration ID", - Response.Status.BAD_REQUEST); + // Check required role to create a credential offer + // + boolean hasCredentialOfferRole = userModel.getRoleMappingsStream() + .anyMatch(rm -> rm.getName().equals(CREDENTIAL_OFFER_CREATE.getName())); + if (!hasCredentialOfferRole) { + var errorMessage = "Credential offer creation requires role: " + CREDENTIAL_OFFER_CREATE.getName(); + throw new CorsErrorResponseException(cors, + INVALID_CREDENTIAL_OFFER_REQUEST.toString(), errorMessage, Response.Status.FORBIDDEN); } - SupportedCredentialConfiguration supportedCredentialConfiguration = credentialsMap.get(vcId); - // calculate the expiration of the preAuthorizedCode. The sessionCode will also expire at that time. - int expiration = timeProvider.currentTimeSeconds() + preAuthorizedCodeLifeSpan; - String preAuthorizedCode = generateAuthorizationCodeForClientSession(expiration, clientSession); + LOGGER.debugf("Get an offer for %s", credConfigId); - CredentialsOffer theOffer = new CredentialsOffer() + // Check whether given client/user ids actually exist + // + if (appClientId != null && session.clients().getClientByClientId(realmModel, appClientId) == null) { + var errorMessage = "No such client id: " + appClientId; + throw new CorsErrorResponseException(cors, + INVALID_CREDENTIAL_OFFER_REQUEST.toString(), errorMessage, Response.Status.BAD_REQUEST); + } + if (appUserId != null && session.users().getUserByUsername(realmModel, appUserId) == null) { + var errorMessage = "No such user id: " + appUserId; + throw new CorsErrorResponseException(cors, + INVALID_CREDENTIAL_OFFER_REQUEST.toString(), errorMessage, Response.Status.BAD_REQUEST); + } + + if (preAuthorized) { + if (appClientId == null) { + appClientId = clientModel.getClientId(); + LOGGER.warnf("Using fallback client id for credential offer: %s", appClientId); + } + if (appUserId == null) { + var errorMessage = "Pre-Authorized credential offer requires a target user"; + throw new CorsErrorResponseException(cors, + INVALID_CREDENTIAL_OFFER_REQUEST.toString(), errorMessage, Response.Status.BAD_REQUEST); + } + } + + // Check whether the credential configuration exists in available client scopes + // + List availableInClientScopes = session.clientScopes() + .getClientScopesByProtocol(realmModel, OID4VC_PROTOCOL) + .map(it -> it.getAttribute(CredentialScopeModel.CONFIGURATION_ID)) + .toList(); + if (!availableInClientScopes.contains(credConfigId)) { + var errorMessage = "Invalid credential configuration id: " + credConfigId; + LOGGER.debugf("%s not found in supported credential config ids: %s", credConfigId, availableInClientScopes); + throw new CorsErrorResponseException(cors, + INVALID_CREDENTIAL_OFFER_REQUEST.toString(), errorMessage, Response.Status.BAD_REQUEST); + } + + CredentialsOffer credOffer = new CredentialsOffer() .setCredentialIssuer(OID4VCIssuerWellKnownProvider.getIssuer(session.getContext())) - .setCredentialConfigurationIds(List.of(supportedCredentialConfiguration.getId())) - .setGrants( - new PreAuthorizedGrant() - .setPreAuthorizedCode( - new PreAuthorizedCode() - .setPreAuthorizedCode(preAuthorizedCode))); + .setCredentialConfigurationIds(List.of(credConfigId)); - String sessionCode = generateCodeForSession(expiration, clientSession); - try { - clientSession.setNote(sessionCode, JsonSerialization.mapper.writeValueAsString(theOffer)); + int expiration = timeProvider.currentTimeSeconds() + preAuthorizedCodeLifeSpan; + CredentialOfferState offerState = new CredentialOfferState(credOffer, appClientId, appUserId, expiration); - // Store the credential configuration IDs in a predictable location for token processing - // This allows the authorization details processor to easily retrieve the configuration IDs - // without having to search through all session notes or parse the full credential offer - String credentialConfigIdsJson = JsonSerialization.mapper.writeValueAsString(theOffer.getCredentialConfigurationIds()); - clientSession.setNote(CREDENTIAL_CONFIGURATION_IDS_NOTE, credentialConfigIdsJson); - LOGGER.debugf("Stored credential configuration IDs for token processing: %s", credentialConfigIdsJson); - } catch (JsonProcessingException e) { - LOGGER.errorf("Could not convert the offer POJO to JSON: %s", e.getMessage()); - throw new CorsErrorResponseException( - cors, - ErrorType.INVALID_CREDENTIAL_REQUEST.toString(), - "Failed to process credential offer", - Response.Status.BAD_REQUEST); + if (preAuthorized) { + String code = "urn:oid4vci:code:" + SecretGenerator.getInstance().randomString(64); + credOffer.setGrants(new PreAuthorizedGrant().setPreAuthorizedCode( + new PreAuthorizedCode().setPreAuthorizedCode(code))); } + CredentialOfferStorage offerStorage = session.getProvider(CredentialOfferStorage.class); + offerStorage.putOfferState(session, offerState); + + LOGGER.debugf("Stored credential offer state: [ids=%s, cid=%s, uid=%s, nonce=%s]", + credOffer.getCredentialConfigurationIds(), offerState.getClientId(), offerState.getUserId(), offerState.getNonce()); + + // Store the credential configuration IDs in a predictable location for token processing + // This allows the authorization details processor to easily retrieve the configuration IDs + // without having to search through all session notes or parse the full credential offer + String credentialConfigIdsJson = JsonSerialization.valueAsString(credOffer.getCredentialConfigurationIds()); + clientSession.setNote(CREDENTIAL_CONFIGURATION_IDS_NOTE, credentialConfigIdsJson); + LOGGER.debugf("Stored credential configuration IDs for token processing: %s", credentialConfigIdsJson); + return switch (type) { - case URI -> getOfferUriAsUri(sessionCode); - case QR_CODE -> getOfferUriAsQr(sessionCode, width, height); + case URI -> getOfferUriAsUri(offerState.getNonce()); + case QR_CODE -> getOfferUriAsQr(offerState.getNonce(), width, height); }; } - private Response getOfferUriAsUri(String sessionCode) { + private Response getOfferUriAsUri(String nonce) { CredentialOfferURI credentialOfferURI = new CredentialOfferURI() .setIssuer(OID4VCIssuerWellKnownProvider.getIssuer(session.getContext()) + "/protocol/" + OID4VCLoginProtocolFactory.PROTOCOL_ID + "/" + CREDENTIAL_OFFER_PATH) - .setNonce(sessionCode); + .setNonce(nonce); return cors.add(Response.ok() .type(MediaType.APPLICATION_JSON) .entity(credentialOfferURI)); } - private Response getOfferUriAsQr(String sessionCode, int width, int height) { + private Response getOfferUriAsQr(String nonce, int width, int height) { QRCodeWriter qrCodeWriter = new QRCodeWriter(); - String encodedOfferUri = URLEncoder.encode(OID4VCIssuerWellKnownProvider.getIssuer(session.getContext()) + "/protocol/" + OID4VCLoginProtocolFactory.PROTOCOL_ID + "/" + CREDENTIAL_OFFER_PATH + sessionCode, StandardCharsets.UTF_8); + String encodedOfferUri = URLEncoder.encode(OID4VCIssuerWellKnownProvider.getIssuer(session.getContext()) + "/protocol/" + OID4VCLoginProtocolFactory.PROTOCOL_ID + "/" + CREDENTIAL_OFFER_PATH + nonce, StandardCharsets.UTF_8); try { BitMatrix bitMatrix = qrCodeWriter.encode("openid-credential-offer://?credential_offer_uri=" + encodedOfferUri, BarcodeFormat.QR_CODE, width, height); ByteArrayOutputStream bos = new ByteArrayOutputStream(); @@ -434,19 +521,45 @@ public class OID4VCIssuerEndpoint { */ @GET @Produces(MediaType.APPLICATION_JSON) - @Path(CREDENTIAL_OFFER_PATH + "{sessionCode}") - public Response getCredentialOffer(@PathParam("sessionCode") String sessionCode) { + @Path(CREDENTIAL_OFFER_PATH + "{nonce}") + public Response getCredentialOffer(@PathParam("nonce") String nonce) { configureCors(false); - if (sessionCode == null) { - throw new BadRequestException(getErrorResponse(ErrorType.INVALID_CREDENTIAL_REQUEST)); + if (nonce == null) { + var errorMessage = "No credential offer nonce"; + throw new BadRequestException(getErrorResponse(INVALID_CREDENTIAL_OFFER_REQUEST, errorMessage)); } - CredentialsOffer credentialsOffer = getOfferFromSessionCode(sessionCode); - LOGGER.debugf("Responding with offer: %s", credentialsOffer); + RealmModel realm = session.getContext().getRealm(); - return cors.add(Response.ok() - .entity(credentialsOffer)); + EventBuilder eventBuilder = new EventBuilder(realm, session, session.getContext().getConnection()); + + // Retrieve the associated credential offer state + // + CredentialOfferStorage offerStorage = session.getProvider(CredentialOfferStorage.class); + CredentialOfferState offerState = offerStorage.findOfferStateByNonce(session, nonce); + if (offerState == null) { + var errorMessage = "No credential offer state for nonce: " + nonce; + eventBuilder.event(INTROSPECT_TOKEN_ERROR).detail(Details.REASON, errorMessage).error(Errors.INVALID_REQUEST); + throw new BadRequestException(getErrorResponse(INVALID_CREDENTIAL_OFFER_REQUEST, errorMessage)); + } + + // We treat the credential offer URI as an unprotected capability URL and rely solely on the later authorization step + // i.e. an authenticated client/user session is not required nor checked against the offer state + + CredentialsOffer credOffer = offerState.getCredentialsOffer(); + LOGGER.debugf("Found credential offer state: [ids=%s, cid=%s, uid=%s, nonce=%s]", + credOffer.getCredentialConfigurationIds(), offerState.getClientId(), offerState.getUserId(), offerState.getNonce()); + + if (offerState.isExpired()) { + var errorMessage = "Credential offer already expired"; + eventBuilder.event(INTROSPECT_TOKEN_ERROR).detail(Details.REASON, errorMessage).error(Errors.EXPIRED_CODE); + throw new BadRequestException(getErrorResponse(INVALID_CREDENTIAL_OFFER_REQUEST, errorMessage)); + } + + LOGGER.debugf("Responding with offer: %s", JsonSerialization.valueAsString(credOffer)); + + return cors.add(Response.ok().entity(credOffer)); } private void checkScope(CredentialScopeModel requestedCredential) { @@ -487,7 +600,7 @@ public class OID4VCIssuerEndpoint { if (requestPayload == null || requestPayload.trim().isEmpty()) { String errorMessage = "Request payload is null or empty."; LOGGER.debug(errorMessage); - throw new BadRequestException(getErrorResponse(ErrorType.INVALID_CREDENTIAL_REQUEST, errorMessage)); + throw new BadRequestException(getErrorResponse(INVALID_CREDENTIAL_REQUEST, errorMessage)); } cors = Cors.builder().auth().allowedMethods(HttpPost.METHOD_NAME).auth().exposedHeaders(Cors.ACCESS_CONTROL_ALLOW_METHODS); @@ -551,75 +664,85 @@ public class OID4VCIssuerEndpoint { // Both credential_configuration_id and credential_identifier are optional. // If the credential_configuration_id is present, credential_identifier can't be present. // But this implementation will tolerate the presence of both, waiting for clarity in specifications. - // This implementation will privilege the presence of the credential_configuration_id. - String requestedCredentialConfigurationId = credentialRequestVO.getCredentialConfigurationId(); - String requestedCredentialIdentifier = credentialRequestVO.getCredentialIdentifier(); + // This implementation will privilege the presence of credential_identifier. + + String credentialIdentifier = credentialRequestVO.getCredentialIdentifier(); + String credentialConfigurationId = credentialRequestVO.getCredentialConfigurationId(); // Check if at least one of both is available. - if (requestedCredentialConfigurationId == null && requestedCredentialIdentifier == null) { + if (credentialIdentifier == null && credentialConfigurationId == null) { LOGGER.debugf("Missing both credential_configuration_id and credential_identifier. At least one must be specified."); throw new BadRequestException(getErrorResponse(ErrorType.MISSING_CREDENTIAL_IDENTIFIER_AND_CONFIGURATION_ID)); } CredentialScopeModel requestedCredential; - // If credential_identifier is provided, retrieve the mapping from client session - if (credentialRequestVO.getCredentialIdentifier() != null) { - String mappingKey = CREDENTIAL_IDENTIFIER_PREFIX + credentialRequestVO.getCredentialIdentifier(); + // When the CredentialRequest contains a credential identifier the caller must have gone through the + // CredentialOffer process or otherwise have set up a valid CredentialOfferState - // First try to get the client session and look for the mapping there + if (credentialIdentifier != null) { + + // Retrieve the associated credential offer state + // + CredentialOfferStorage offerStorage = session.getProvider(CredentialOfferStorage.class); + CredentialOfferState offerState = offerStorage.findOfferStateByCredentialId(session, credentialIdentifier); + if (offerState == null) { + var errorMessage = "No credential offer state for credential id: " + credentialIdentifier; + throw new BadRequestException(getErrorResponse(UNKNOWN_CREDENTIAL_IDENTIFIER, errorMessage)); + } + + // Get the credential_configuration_id from AuthorizationDetails + // + OID4VCAuthorizationDetailsResponse authDetails = offerState.getAuthorizationDetails(); + String credConfigId = authDetails.getCredentialConfigurationId(); + if (credConfigId == null) { + var errorMessage = "No credential_configuration_id in AuthorizationDetails"; + throw new BadRequestException(getErrorResponse(UNKNOWN_CREDENTIAL_CONFIGURATION, errorMessage)); + } + + // Find the credential configuration in the Issuer's metadata + // + SupportedCredentialConfiguration credConfig = OID4VCIssuerWellKnownProvider.getSupportedCredentials(session).get(credConfigId); + if (credConfig == null) { + var errorMessage = "Mapped credential configuration not found: " + credConfigId; + throw new BadRequestException(getErrorResponse(UNKNOWN_CREDENTIAL_CONFIGURATION, errorMessage)); + } + + // Verify the user login session + // UserSessionModel userSession = authResult.session(); - AuthenticatedClientSessionModel clientSession = userSession.getAuthenticatedClientSessionByClient(authResult.client().getId()); - String mappedCredentialConfigurationId = null; - - if (clientSession != null) { - mappedCredentialConfigurationId = clientSession.getNote(mappingKey); - if (mappedCredentialConfigurationId != null) { - LOGGER.debugf("Found credential configuration ID mapping in client session for identifier %s: %s", - credentialRequestVO.getCredentialIdentifier(), mappedCredentialConfigurationId); - } + UserModel userModel = userSession.getUser(); + if (!userModel.getUsername().equals(offerState.getUserId())) { + var errorMessage = "Unexpected login user: " + userModel.getUsername(); + LOGGER.errorf(errorMessage + " != %s", offerState.getUserId()); + throw new BadRequestException(getErrorResponse(ErrorType.INVALID_CREDENTIAL_REQUEST, errorMessage)); } - if (mappedCredentialConfigurationId != null) { - // Use the mapped credential configuration ID to find the credential scope - Map supportedCredentials = OID4VCIssuerWellKnownProvider.getSupportedCredentials(session); - if (supportedCredentials.containsKey(mappedCredentialConfigurationId)) { - SupportedCredentialConfiguration config = supportedCredentials.get(mappedCredentialConfigurationId); - ClientModel client = session.getContext().getClient(); - Map clientScopes = client.getClientScopes(false); - ClientScopeModel clientScope = clientScopes.get(config.getScope()); - - if (clientScope != null) { - requestedCredential = new CredentialScopeModel(clientScope); - LOGGER.debugf("Successfully mapped credential identifier %s to configuration %s", - credentialRequestVO.getCredentialIdentifier(), mappedCredentialConfigurationId); - } else { - LOGGER.errorf("Client scope not found for mapped credential configuration: %s", config.getScope()); - throw new BadRequestException(getErrorResponse(ErrorType.UNKNOWN_CREDENTIAL_CONFIGURATION)); - } - } else { - LOGGER.errorf("Mapped credential configuration not found: %s", mappedCredentialConfigurationId); - throw new BadRequestException(getErrorResponse(ErrorType.UNKNOWN_CREDENTIAL_CONFIGURATION)); - } - } else { - // No mapping found, try to use credential_identifier as a direct scope name - LOGGER.debugf("No mapping found for credential identifier %s, trying direct scope lookup", - credentialRequestVO.getCredentialIdentifier()); - try { - requestedCredential = credentialRequestVO.findCredentialScope(session).orElseThrow(() -> { - LOGGER.errorf("Credential scope not found for identifier: %s", credentialRequestVO.getCredentialIdentifier()); - return new BadRequestException(getErrorResponse(ErrorType.UNKNOWN_CREDENTIAL_IDENTIFIER)); - }); - } catch (Exception e) { - LOGGER.errorf(e, "Failed to find credential scope for identifier: %s", credentialRequestVO.getCredentialIdentifier()); - throw new BadRequestException(getErrorResponse(ErrorType.UNKNOWN_CREDENTIAL_IDENTIFIER)); - } + // Verify the login client + // + ClientModel clientModel = session.getContext().getClient(); + if (offerState.getClientId() != null && !clientModel.getClientId().equals(offerState.getClientId())) { + var errorMessage = "Unexpected login client: " + clientModel.getClientId(); + LOGGER.errorf(errorMessage + " != %s", offerState.getClientId()); + throw new BadRequestException(getErrorResponse(ErrorType.INVALID_CREDENTIAL_REQUEST, errorMessage)); } - } else if (credentialRequestVO.getCredentialConfigurationId() != null) { + + // Find the configured scope in the login client + // + ClientScopeModel clientScope = clientModel.getClientScopes(false).get(credConfig.getScope()); + if (clientScope == null) { + var errorMessage = String.format("Client scope not found: %s", credConfig.getScope()); + throw new BadRequestException(getErrorResponse(UNKNOWN_CREDENTIAL_CONFIGURATION, errorMessage)); + } + + requestedCredential = new CredentialScopeModel(clientScope); + LOGGER.debugf("Successfully mapped credential identifier %s to scope %s", credentialIdentifier, clientScope.getName()); + + } else if (credentialConfigurationId != null) { // Use credential_configuration_id for direct lookup requestedCredential = credentialRequestVO.findCredentialScope(session).orElseThrow(() -> { - LOGGER.errorf("Credential scope not found for configuration ID: %s", credentialRequestVO.getCredentialConfigurationId()); - return new BadRequestException(getErrorResponse(ErrorType.UNKNOWN_CREDENTIAL_CONFIGURATION)); + var errorMessage = "Credential scope not found for configuration id: " + credentialConfigurationId; + return new BadRequestException(getErrorResponse(ErrorType.UNKNOWN_CREDENTIAL_CONFIGURATION, errorMessage)); }); } else { // Neither provided - this should not happen due to earlier validation @@ -708,7 +831,7 @@ public class OID4VCIssuerEndpoint { } catch (JsonProcessingException e) { String errorMessage = "Failed to parse JSON request: " + e.getMessage(); LOGGER.debug(errorMessage); - throw new BadRequestException(getErrorResponse(ErrorType.INVALID_CREDENTIAL_REQUEST, errorMessage)); + throw new BadRequestException(getErrorResponse(INVALID_CREDENTIAL_REQUEST, errorMessage)); } } @@ -828,7 +951,7 @@ public class OID4VCIssuerEndpoint { if (credentialRequest.getProof() != null && credentialRequest.getProofs() != null) { String message = "Both 'proof' and 'proofs' must not be present at the same time"; LOGGER.debug(message); - throw new BadRequestException(getErrorResponse(ErrorType.INVALID_CREDENTIAL_REQUEST, message)); + throw new BadRequestException(getErrorResponse(INVALID_CREDENTIAL_REQUEST, message)); } if (credentialRequest.getProof() != null) { @@ -1160,39 +1283,6 @@ public class OID4VCIssuerEndpoint { return new CredentialScopeModel(clientScopeModel); } - private String generateCodeForSession(int expiration, AuthenticatedClientSessionModel clientSession) { - String codeId = SecretGenerator.getInstance().randomString(); - String nonce = SecretGenerator.getInstance().randomString(); - OAuth2Code oAuth2Code = new OAuth2Code(codeId, expiration, nonce, CREDENTIAL_OFFER_URI_CODE_SCOPE, null, null, null, null, - clientSession.getUserSession().getId()); - - return OAuth2CodeParser.persistCode(session, clientSession, oAuth2Code); - } - - private CredentialsOffer getOfferFromSessionCode(String sessionCode) { - EventBuilder eventBuilder = new EventBuilder(session.getContext().getRealm(), session, - session.getContext().getConnection()); - OAuth2CodeParser.ParseResult result = OAuth2CodeParser.parseCode(session, sessionCode, - session.getContext().getRealm(), - eventBuilder); - if (result.isExpiredCode() || result.isIllegalCode() || !result.getCodeData().getScope().equals(CREDENTIAL_OFFER_URI_CODE_SCOPE)) { - throw new BadRequestException(getErrorResponse(ErrorType.INVALID_TOKEN)); - } - try { - String offer = result.getClientSession().getNote(sessionCode); - return JsonSerialization.mapper.readValue(offer, CredentialsOffer.class); - } catch (JsonProcessingException e) { - LOGGER.errorf("Could not convert JSON to POJO: %s", e); - throw new BadRequestException(getErrorResponse(ErrorType.INVALID_TOKEN)); - } finally { - result.getClientSession().removeNote(sessionCode); - } - } - - private String generateAuthorizationCodeForClientSession(int expiration, AuthenticatedClientSessionModel clientSessionModel) { - return PreAuthorizedCodeGrantType.getPreAuthorizedCode(session, clientSessionModel, expiration); - } - private Response getErrorResponse(ErrorType errorType) { return getErrorResponse(errorType, null); } @@ -1358,12 +1448,12 @@ public class OID4VCIssuerEndpoint { String errorMessage = e.getMessage(); if (errorMessage.contains("Mandatory claim not found:")) { LOGGER.errorf("Mandatory claim missing during claims filtering for scope %s: %s", scope, errorMessage); - throw new BadRequestException(getErrorResponse(ErrorType.INVALID_CREDENTIAL_REQUEST, + throw new BadRequestException(getErrorResponse(INVALID_CREDENTIAL_REQUEST, "Credential issuance failed: " + errorMessage + ". The requested mandatory claim is not available in the user profile.")); } else { LOGGER.errorf("Claims filtering error for scope %s: %s", scope, errorMessage); - throw new BadRequestException(getErrorResponse(ErrorType.INVALID_CREDENTIAL_REQUEST, + throw new BadRequestException(getErrorResponse(INVALID_CREDENTIAL_REQUEST, "Credential issuance failed: " + errorMessage)); } } catch (BadRequestException e) { diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerWellKnownProvider.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerWellKnownProvider.java index 863d59a47de..e288bb78019 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerWellKnownProvider.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerWellKnownProvider.java @@ -28,7 +28,7 @@ import jakarta.ws.rs.core.UriBuilder; import jakarta.ws.rs.core.UriInfo; import org.keycloak.common.util.Time; -import org.keycloak.constants.Oid4VciConstants; +import org.keycloak.constants.OID4VCIConstants; import org.keycloak.crypto.CryptoUtils; import org.keycloak.crypto.KeyUse; import org.keycloak.crypto.KeyWrapper; @@ -64,7 +64,7 @@ import org.jboss.logging.Logger; import static org.keycloak.OID4VCConstants.SIGNED_METADATA_JWT_TYPE; import static org.keycloak.OID4VCConstants.WELL_KNOWN_OPENID_CREDENTIAL_ISSUER; -import static org.keycloak.constants.Oid4VciConstants.BATCH_CREDENTIAL_ISSUANCE_BATCH_SIZE; +import static org.keycloak.constants.OID4VCIConstants.BATCH_CREDENTIAL_ISSUANCE_BATCH_SIZE; import static org.keycloak.crypto.KeyType.RSA; import static org.keycloak.jose.jwk.RSAPublicJWK.RS256; @@ -456,7 +456,7 @@ public class OID4VCIssuerWellKnownProvider implements WellKnownProvider { RealmModel realm = keycloakSession.getContext().getRealm(); Map supportedCredentialConfigurations = keycloakSession.clientScopes() - .getClientScopesByProtocol(realm, Oid4VciConstants.OID4VC_PROTOCOL) + .getClientScopesByProtocol(realm, OID4VCIConstants.OID4VC_PROTOCOL) .map(CredentialScopeModel::new) .map(clientScope -> { return SupportedCredentialConfiguration.parse(keycloakSession, diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/TimeClaimNormalizer.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/TimeClaimNormalizer.java index bd03a109a8a..1e2b5f84065 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/TimeClaimNormalizer.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/TimeClaimNormalizer.java @@ -23,7 +23,7 @@ import java.time.ZonedDateTime; import java.time.temporal.ChronoUnit; import java.util.Locale; -import org.keycloak.constants.Oid4VciConstants; +import org.keycloak.constants.OID4VCIConstants; import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; import org.keycloak.utils.StringUtil; @@ -71,9 +71,9 @@ public class TimeClaimNormalizer { } public TimeClaimNormalizer(RealmModel realm) { - this.strategy = parseStrategy(realm.getAttribute(Oid4VciConstants.TIME_CLAIMS_STRATEGY)); - this.randomizeWindowSeconds = parseRandomizeWindow(realm.getAttribute(Oid4VciConstants.TIME_RANDOMIZE_WINDOW_SECONDS)); - this.roundUnit = parseRoundUnit(realm.getAttribute(Oid4VciConstants.TIME_ROUND_UNIT)); + this.strategy = parseStrategy(realm.getAttribute(OID4VCIConstants.TIME_CLAIMS_STRATEGY)); + this.randomizeWindowSeconds = parseRandomizeWindow(realm.getAttribute(OID4VCIConstants.TIME_RANDOMIZE_WINDOW_SECONDS)); + this.roundUnit = parseRoundUnit(realm.getAttribute(OID4VCIConstants.TIME_ROUND_UNIT)); } TimeClaimNormalizer(Strategy strategy, Long randomizeWindowSeconds, RoundUnit roundUnit) { diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialoffer/CredentialOfferStorage.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialoffer/CredentialOfferStorage.java new file mode 100644 index 00000000000..9f1e4aad1b1 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialoffer/CredentialOfferStorage.java @@ -0,0 +1,135 @@ +/* + * Copyright 2025 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.protocol.oid4vc.issuance.credentialoffer; + +import java.beans.Transient; +import java.util.Optional; + +import org.keycloak.common.util.Base64Url; +import org.keycloak.common.util.Time; +import org.keycloak.models.KeycloakSession; +import org.keycloak.protocol.oid4vc.issuance.OID4VCAuthorizationDetailsResponse; +import org.keycloak.protocol.oid4vc.model.CredentialsOffer; +import org.keycloak.protocol.oid4vc.model.PreAuthorizedCode; +import org.keycloak.protocol.oid4vc.model.PreAuthorizedGrant; +import org.keycloak.provider.Provider; +import org.keycloak.saml.RandomSecret; + +import com.fasterxml.jackson.annotation.JsonInclude; + +public interface CredentialOfferStorage extends Provider { + + @JsonInclude(JsonInclude.Include.NON_NULL) + class CredentialOfferState { + + private CredentialsOffer credentialsOffer; + private String clientId; + private String userId; + private String nonce; + private int expiration; + private OID4VCAuthorizationDetailsResponse authorizationDetails; + + public CredentialOfferState(CredentialsOffer credOffer, String clientId, String userId, int expiration) { + this.credentialsOffer = credOffer; + this.clientId = clientId; + this.userId = userId; + this.expiration = expiration; + this.nonce = Base64Url.encode(RandomSecret.createRandomSecret(64)); + } + + // For json serialization + CredentialOfferState() { + } + + @Transient + public Optional getPreAuthorizedCode() { + return Optional.ofNullable(credentialsOffer.getGrants()) + .map(PreAuthorizedGrant::getPreAuthorizedCode) + .map(PreAuthorizedCode::getPreAuthorizedCode); + } + + @Transient + public boolean isExpired() { + int currentTime = Time.currentTime(); + return currentTime > expiration; + } + + public CredentialsOffer getCredentialsOffer() { + return credentialsOffer; + } + + public String getClientId() { + return clientId; + } + + public String getUserId() { + return userId; + } + + public String getNonce() { + return nonce; + } + + public int getExpiration() { + return expiration; + } + + public OID4VCAuthorizationDetailsResponse getAuthorizationDetails() { + return authorizationDetails; + } + + public void setAuthorizationDetails(OID4VCAuthorizationDetailsResponse authorizationDetails) { + this.authorizationDetails = authorizationDetails; + } + + void setCredentialsOffer(CredentialsOffer credentialsOffer) { + this.credentialsOffer = credentialsOffer; + } + + void setClientId(String clientId) { + this.clientId = clientId; + } + + void setUserId(String userId) { + this.userId = userId; + } + + void setNonce(String nonce) { + this.nonce = nonce; + } + + void setExpiration(int expiration) { + this.expiration = expiration; + } + } + + void putOfferState(KeycloakSession session, CredentialOfferState entry); + + CredentialOfferState findOfferStateByNonce(KeycloakSession session, String nonce); + + CredentialOfferState findOfferStateByCode(KeycloakSession session, String code); + + CredentialOfferState findOfferStateByCredentialId(KeycloakSession session, String credId); + + void replaceOfferState(KeycloakSession session, CredentialOfferState entry); + + void removeOfferState(KeycloakSession session, CredentialOfferState entry); + + @Override + default void close() { + } +} diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialoffer/CredentialOfferStorageFactory.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialoffer/CredentialOfferStorageFactory.java new file mode 100644 index 00000000000..dbf3ccbe2f2 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialoffer/CredentialOfferStorageFactory.java @@ -0,0 +1,26 @@ +/* + * Copyright 2025 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.protocol.oid4vc.issuance.credentialoffer; + +import org.keycloak.provider.ProviderFactory; + +public interface CredentialOfferStorageFactory extends ProviderFactory { + + @Override + default void close() { } + +} diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialoffer/CredentialOfferStorageSpi.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialoffer/CredentialOfferStorageSpi.java new file mode 100644 index 00000000000..2a1911a15e3 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialoffer/CredentialOfferStorageSpi.java @@ -0,0 +1,26 @@ +/* + * Copyright 2025 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.protocol.oid4vc.issuance.credentialoffer; + +import org.keycloak.provider.Spi; + +public class CredentialOfferStorageSpi implements Spi { + @Override public String getName() { return "credential-offer-storage"; } + @Override public Class getProviderClass() { return CredentialOfferStorage.class; } + @Override public Class getProviderFactoryClass() { return CredentialOfferStorageFactory.class; } + @Override public boolean isInternal() { return false; } +} diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialoffer/InMemoryCredentialOfferStorage.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialoffer/InMemoryCredentialOfferStorage.java new file mode 100644 index 00000000000..ee3728a9736 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialoffer/InMemoryCredentialOfferStorage.java @@ -0,0 +1,99 @@ +/* + * Copyright 2025 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.protocol.oid4vc.issuance.credentialoffer; + +import java.util.Map; +import java.util.Optional; + +import org.keycloak.models.KeycloakSession; +import org.keycloak.util.JsonSerialization; + +class InMemoryCredentialOfferStorage implements CredentialOfferStorage { + + private static final String ENTRY_KEY = "json"; + + @Override + public void putOfferState(KeycloakSession session, CredentialOfferState entry) { + String entryJson = JsonSerialization.valueAsString(entry); + session.singleUseObjects().put(entry.getNonce(), entry.getExpiration(), Map.of(ENTRY_KEY, entryJson)); + entry.getPreAuthorizedCode().ifPresent(it -> { + session.singleUseObjects().put(it, entry.getExpiration(), Map.of(ENTRY_KEY, entryJson)); + }); + Optional.ofNullable(entry.getAuthorizationDetails()).ifPresent(it -> { + it.getCredentialIdentifiers().forEach( cid -> { + session.singleUseObjects().put(cid, entry.getExpiration(), Map.of(ENTRY_KEY, entryJson)); + }); + }); + } + + @Override + public CredentialOfferState findOfferStateByNonce(KeycloakSession session, String nonce) { + if (session.singleUseObjects().contains(nonce)) { + String entryJson = session.singleUseObjects().get(nonce).get(ENTRY_KEY); + return JsonSerialization.valueFromString(entryJson, CredentialOfferState.class); + } + return null; + } + + @Override + public CredentialOfferState findOfferStateByCode(KeycloakSession session, String code) { + if (session.singleUseObjects().contains(code)) { + String entryJson = session.singleUseObjects().get(code).get(ENTRY_KEY); + return JsonSerialization.valueFromString(entryJson, CredentialOfferState.class); + } + return null; + } + + @Override + public CredentialOfferState findOfferStateByCredentialId(KeycloakSession session, String credId) { + if (session.singleUseObjects().contains(credId)) { + String entryJson = session.singleUseObjects().get(credId).get(ENTRY_KEY); + return JsonSerialization.valueFromString(entryJson, CredentialOfferState.class); + } + return null; + } + + public void replaceOfferState(KeycloakSession session, CredentialOfferState entry) { + String entryJson = JsonSerialization.valueAsString(entry); + session.singleUseObjects().replace(entry.getNonce(), Map.of(ENTRY_KEY, entryJson)); + entry.getPreAuthorizedCode().ifPresent(it -> { + session.singleUseObjects().replace(it, Map.of(ENTRY_KEY, entryJson)); + }); + Optional.ofNullable(entry.getAuthorizationDetails()).ifPresent(it -> { + it.getCredentialIdentifiers().forEach( cid -> { + if (session.singleUseObjects().contains(cid)) { + session.singleUseObjects().replace(cid, Map.of(ENTRY_KEY, entryJson)); + } else { + session.singleUseObjects().put(cid, entry.getExpiration(), Map.of(ENTRY_KEY, entryJson)); + } + }); + }); + } + + @Override + public void removeOfferState(KeycloakSession session, CredentialOfferState entry) { + session.singleUseObjects().remove(entry.getNonce()); + entry.getPreAuthorizedCode().ifPresent(it -> { + session.singleUseObjects().remove(it); + }); + Optional.ofNullable(entry.getAuthorizationDetails()).ifPresent(it -> { + it.getCredentialIdentifiers().forEach( cid -> { + session.singleUseObjects().remove(cid); + }); + }); + } +} diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialoffer/InMemoryCredentialOfferStorageFactory.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialoffer/InMemoryCredentialOfferStorageFactory.java new file mode 100644 index 00000000000..e534a8976fb --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialoffer/InMemoryCredentialOfferStorageFactory.java @@ -0,0 +1,47 @@ +/* + * Copyright 2025 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.protocol.oid4vc.issuance.credentialoffer; + +import org.keycloak.Config; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; + +public class InMemoryCredentialOfferStorageFactory implements CredentialOfferStorageFactory { + + private static CredentialOfferStorage INSTANCE; + + @Override + public CredentialOfferStorage create(KeycloakSession session) { + if (INSTANCE == null) { + INSTANCE = new InMemoryCredentialOfferStorage(); + } + return INSTANCE; + } + + @Override + public void init(Config.Scope config) { + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + } + + @Override + public String getId() { + return "in_memory"; + } +} diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/JwtCNonceHandler.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/JwtCNonceHandler.java index 3b27a98cfef..d29a35889d0 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/JwtCNonceHandler.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/JwtCNonceHandler.java @@ -34,7 +34,7 @@ import jakarta.annotation.Nullable; import org.keycloak.TokenVerifier; import org.keycloak.common.VerificationException; -import org.keycloak.constants.Oid4VciConstants; +import org.keycloak.constants.OID4VCIConstants; import org.keycloak.crypto.Algorithm; import org.keycloak.crypto.KeyUse; import org.keycloak.crypto.KeyWrapper; @@ -58,7 +58,7 @@ import org.slf4j.LoggerFactory; */ public class JwtCNonceHandler implements CNonceHandler { - public static final String SOURCE_ENDPOINT = Oid4VciConstants.SOURCE_ENDPOINT; + public static final String SOURCE_ENDPOINT = OID4VCIConstants.SOURCE_ENDPOINT; public static final int NONCE_DEFAULT_LENGTH = 50; @@ -80,7 +80,7 @@ public class JwtCNonceHandler implements CNonceHandler { RealmModel realm = keycloakSession.getContext().getRealm(); final String issuer = OID4VCIssuerWellKnownProvider.getIssuer(keycloakSession.getContext()); // TODO discussion about the attribute name to use - final Integer nonceLifetimeMillis = realm.getAttribute(Oid4VciConstants.C_NONCE_LIFETIME_IN_SECONDS, 60); + final Integer nonceLifetimeMillis = realm.getAttribute(OID4VCIConstants.C_NONCE_LIFETIME_IN_SECONDS, 60); audiences = Optional.ofNullable(audiences).orElseGet(Collections::emptyList); final Instant now = Instant.now(); final long expiresAt = now.plus(nonceLifetimeMillis, ChronoUnit.SECONDS).getEpochSecond(); diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/ErrorType.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/ErrorType.java index a58d15b9078..ae8a03febc6 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/model/ErrorType.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/ErrorType.java @@ -26,6 +26,7 @@ package org.keycloak.protocol.oid4vc.model; */ public enum ErrorType { + INVALID_CREDENTIAL_OFFER_REQUEST("invalid_credential_offer_request"), INVALID_CREDENTIAL_REQUEST("invalid_credential_request"), INVALID_TOKEN("invalid_token"), UNKNOWN_CREDENTIAL_CONFIGURATION("unknown_credential_configuration"), diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/SupportedCredentialConfiguration.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/SupportedCredentialConfiguration.java index b1aeb43cd25..2d7926f825f 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/model/SupportedCredentialConfiguration.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/SupportedCredentialConfiguration.java @@ -160,7 +160,7 @@ public class SupportedCredentialConfiguration { return null; } - public CredentialConfigId deriveConfiId() { + public CredentialConfigId deriveConfigId() { return CredentialConfigId.from(id); } diff --git a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpoint.java b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpoint.java index e765cd79a81..4593586fbd5 100755 --- a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpoint.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpoint.java @@ -386,9 +386,9 @@ public class AuthorizationEndpoint extends AuthorizationEndpointBase { } // Store authorization_details from authorization/PAR request for later processing - String authorizationDetails = request.getAdditionalReqParams().get(OAuth2Constants.AUTHORIZATION_DETAILS_PARAM); + String authorizationDetails = request.getAdditionalReqParams().get(OAuth2Constants.AUTHORIZATION_DETAILS); if (authorizationDetails != null) { - authenticationSession.setClientNote(OAuth2Constants.AUTHORIZATION_DETAILS_PARAM, authorizationDetails); + authenticationSession.setClientNote(OAuth2Constants.AUTHORIZATION_DETAILS, authorizationDetails); } } } diff --git a/services/src/main/java/org/keycloak/protocol/oidc/grants/AuthorizationCodeGrantType.java b/services/src/main/java/org/keycloak/protocol/oidc/grants/AuthorizationCodeGrantType.java index a1d4b32c3ab..55b2debc953 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/grants/AuthorizationCodeGrantType.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/grants/AuthorizationCodeGrantType.java @@ -51,7 +51,7 @@ import org.keycloak.services.util.DefaultClientSessionContext; import org.jboss.logging.Logger; -import static org.keycloak.OAuth2Constants.AUTHORIZATION_DETAILS_PARAM; +import static org.keycloak.OAuth2Constants.AUTHORIZATION_DETAILS; import static org.keycloak.models.Constants.AUTHORIZATION_DETAILS_RESPONSE; /** @@ -217,12 +217,12 @@ public class AuthorizationCodeGrantType extends OAuth2GrantTypeBase { // Process authorization_details using provider discovery (if present in request) List authorizationDetailsResponse = null; - if (formParams.getFirst(AUTHORIZATION_DETAILS_PARAM) != null) { + if (formParams.getFirst(AUTHORIZATION_DETAILS) != null) { authorizationDetailsResponse = processAuthorizationDetails(userSession, clientSessionCtx); if (authorizationDetailsResponse != null && !authorizationDetailsResponse.isEmpty()) { clientSessionCtx.setAttribute(AUTHORIZATION_DETAILS_RESPONSE, authorizationDetailsResponse); } else { - logger.debugf("No available AuthorizationDetailsProcessor being able to process authorization_details '%s'", formParams.getFirst(AUTHORIZATION_DETAILS_PARAM)); + logger.debugf("No available AuthorizationDetailsProcessor being able to process authorization_details '%s'", formParams.getFirst(AUTHORIZATION_DETAILS)); } } @@ -251,7 +251,7 @@ public class AuthorizationCodeGrantType extends OAuth2GrantTypeBase { protected void addCustomTokenResponseClaims(AccessTokenResponse res, ClientSessionContext clientSessionCtx) { List authDetailsResponse = clientSessionCtx.getAttribute(AUTHORIZATION_DETAILS_RESPONSE, List.class); if (authDetailsResponse != null && !authDetailsResponse.isEmpty()) { - res.setOtherClaims(AUTHORIZATION_DETAILS_PARAM, authDetailsResponse); + res.setOtherClaims(AUTHORIZATION_DETAILS, authDetailsResponse); } } diff --git a/services/src/main/java/org/keycloak/protocol/oidc/grants/ClientCredentialsGrantType.java b/services/src/main/java/org/keycloak/protocol/oidc/grants/ClientCredentialsGrantType.java index aa37366f8d8..35a599f8d01 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/grants/ClientCredentialsGrantType.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/grants/ClientCredentialsGrantType.java @@ -44,7 +44,7 @@ import org.keycloak.sessions.RootAuthenticationSessionModel; import org.jboss.logging.Logger; -import static org.keycloak.OAuth2Constants.AUTHORIZATION_DETAILS_PARAM; +import static org.keycloak.OAuth2Constants.AUTHORIZATION_DETAILS; /** * OAuth 2.0 Client Credentials Grant @@ -160,9 +160,9 @@ public class ClientCredentialsGrantType extends OAuth2GrantTypeBase { * until RAR is fully implemented. */ private void setAuthorizationDetailsNoteIfIncluded(AuthenticationSessionModel authSession) { - String authorizationDetails = formParams.getFirst(AUTHORIZATION_DETAILS_PARAM); + String authorizationDetails = formParams.getFirst(AUTHORIZATION_DETAILS); if (authorizationDetails != null) { - authSession.setClientNote(AUTHORIZATION_DETAILS_PARAM, authorizationDetails); + authSession.setClientNote(AUTHORIZATION_DETAILS, authorizationDetails); } } } diff --git a/services/src/main/java/org/keycloak/protocol/oidc/grants/OAuth2GrantTypeBase.java b/services/src/main/java/org/keycloak/protocol/oidc/grants/OAuth2GrantTypeBase.java index 600184835d4..d9a29837ca3 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/grants/OAuth2GrantTypeBase.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/grants/OAuth2GrantTypeBase.java @@ -67,7 +67,7 @@ import org.keycloak.util.TokenUtil; import org.jboss.logging.Logger; -import static org.keycloak.OAuth2Constants.AUTHORIZATION_DETAILS_PARAM; +import static org.keycloak.OAuth2Constants.AUTHORIZATION_DETAILS; /** * Base class for OAuth 2.0 grant types @@ -278,7 +278,7 @@ public abstract class OAuth2GrantTypeBase implements OAuth2GrantType { * @return the authorization details response if processing was successful, null otherwise */ protected List processAuthorizationDetails(UserSessionModel userSession, ClientSessionContext clientSessionCtx) { - String authorizationDetailsParam = formParams.getFirst(AUTHORIZATION_DETAILS_PARAM); + String authorizationDetailsParam = formParams.getFirst(AUTHORIZATION_DETAILS); if (authorizationDetailsParam != null) { try { return session.getKeycloakSessionFactory() @@ -312,7 +312,7 @@ public abstract class OAuth2GrantTypeBase implements OAuth2GrantType { */ protected List handleMissingAuthorizationDetails(UserSessionModel userSession, ClientSessionContext clientSessionCtx) { try { - return session.getKeycloakSessionFactory() + var result = session.getKeycloakSessionFactory() .getProviderFactoriesStream(AuthorizationDetailsProcessor.class) .sorted((f1, f2) -> f2.order() - f1.order()) .map(f -> session.getProvider(AuthorizationDetailsProcessor.class, f.getId())) @@ -320,6 +320,7 @@ public abstract class OAuth2GrantTypeBase implements OAuth2GrantType { .filter(authzDetailsResponse -> authzDetailsResponse != null) .findFirst() .orElse(null); + return result; } catch (RuntimeException e) { logger.warnf(e, "Error when handling missing authorization_details"); return null; @@ -337,7 +338,7 @@ public abstract class OAuth2GrantTypeBase implements OAuth2GrantType { */ protected List processStoredAuthorizationDetails(UserSessionModel userSession, ClientSessionContext clientSessionCtx) throws CorsErrorResponseException { // Check if authorization_details was stored during authorization request (e.g., from PAR) - String storedAuthDetails = clientSessionCtx.getClientSession().getNote(AUTHORIZATION_DETAILS_PARAM); + String storedAuthDetails = clientSessionCtx.getClientSession().getNote(AUTHORIZATION_DETAILS); if (storedAuthDetails != null) { logger.debugf("Found authorization_details in client session, processing it"); try { diff --git a/services/src/main/java/org/keycloak/protocol/oidc/grants/PreAuthorizedCodeGrantType.java b/services/src/main/java/org/keycloak/protocol/oidc/grants/PreAuthorizedCodeGrantType.java index 9722084e8a6..e04673ca382 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/grants/PreAuthorizedCodeGrantType.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/grants/PreAuthorizedCodeGrantType.java @@ -18,33 +18,36 @@ package org.keycloak.protocol.oidc.grants; import java.util.List; -import java.util.UUID; +import java.util.Optional; import jakarta.ws.rs.core.Response; import org.keycloak.OAuth2Constants; import org.keycloak.OAuthErrorException; -import org.keycloak.common.util.SecretGenerator; import org.keycloak.events.Details; import org.keycloak.events.Errors; import org.keycloak.events.EventType; import org.keycloak.models.AuthenticatedClientSessionModel; +import org.keycloak.models.ClientModel; import org.keycloak.models.ClientSessionContext; import org.keycloak.models.Constants; -import org.keycloak.models.KeycloakSession; -import org.keycloak.protocol.oidc.TokenManager; +import org.keycloak.models.UserSessionModel; +import org.keycloak.protocol.oid4vc.issuance.OID4VCAuthorizationDetailsResponse; +import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint; +import org.keycloak.protocol.oid4vc.issuance.credentialoffer.CredentialOfferStorage; +import org.keycloak.protocol.oidc.OIDCLoginProtocol; +import org.keycloak.protocol.oidc.TokenManager.AccessTokenResponseBuilder; import org.keycloak.protocol.oidc.rar.AuthorizationDetailsResponse; -import org.keycloak.protocol.oidc.utils.OAuth2Code; -import org.keycloak.protocol.oidc.utils.OAuth2CodeParser; import org.keycloak.representations.AccessToken; import org.keycloak.representations.AccessTokenResponse; import org.keycloak.services.CorsErrorResponseException; -import org.keycloak.services.util.DefaultClientSessionContext; +import org.keycloak.util.JsonSerialization; import org.keycloak.utils.MediaType; import org.jboss.logging.Logger; -import static org.keycloak.OAuth2Constants.AUTHORIZATION_DETAILS_PARAM; +import static org.keycloak.OAuth2Constants.AUTHORIZATION_DETAILS; +import static org.keycloak.services.util.DefaultClientSessionContext.fromClientSessionAndScopeParameter; public class PreAuthorizedCodeGrantType extends OAuth2GrantTypeBase { @@ -63,72 +66,114 @@ public class PreAuthorizedCodeGrantType extends OAuth2GrantTypeBase { if (code == null) { // See: https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-token-request String errorMessage = "Missing parameter: " + PreAuthorizedCodeGrantTypeFactory.CODE_REQUEST_PARAM; - event.detail(Details.REASON, errorMessage); - event.error(Errors.INVALID_CODE); + event.detail(Details.REASON, errorMessage).error(Errors.INVALID_CODE); throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, errorMessage, Response.Status.BAD_REQUEST); } - OAuth2CodeParser.ParseResult result = OAuth2CodeParser.parseCode(session, code, realm, event); - if (result.isIllegalCode()) { - event.error(Errors.INVALID_CODE); - throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_GRANT, "Code not valid", - Response.Status.BAD_REQUEST); + + var offerStorage = session.getProvider(CredentialOfferStorage.class); + var offerState = offerStorage.findOfferStateByCode(session, code); + if (offerState == null) { + var errorMessage = "No credential offer state for code: " + code; + event.detail(Details.REASON, errorMessage).error(Errors.INVALID_CODE); + throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, + errorMessage, Response.Status.BAD_REQUEST); } - if (result.isExpiredCode()) { + + if (offerState.isExpired()) { event.error(Errors.EXPIRED_CODE); - throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_GRANT, "Code is expired", - Response.Status.BAD_REQUEST); + throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_GRANT, + "Code is expired", Response.Status.BAD_REQUEST); } - AuthenticatedClientSessionModel clientSession = result.getClientSession(); - ClientSessionContext sessionContext = DefaultClientSessionContext.fromClientSessionAndScopeParameter(clientSession, - OAuth2Constants.SCOPE_OPENID, session); + var credOffer = offerState.getCredentialsOffer(); + + var appUserId = offerState.getUserId(); + var userModel = session.users().getUserByUsername(realm, appUserId); + if (userModel == null) { + var errorMessage = "No user model for: " + appUserId; + event.detail(Details.REASON, errorMessage).error(Errors.INVALID_CODE); + throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, + errorMessage, Response.Status.BAD_REQUEST); + } + + var appClientId = offerState.getClientId(); + ClientModel clientModel = realm.getClientByClientId(appClientId); + if (clientModel == null) { + var errorMessage = "No client model for: " + appClientId; + event.detail(Details.REASON, errorMessage).error(Errors.INVALID_CODE); + throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, + errorMessage, Response.Status.BAD_REQUEST); + } + + UserSessionModel userSession = session.sessions().createUserSession(null, realm, userModel, userModel.getUsername(), + null, "pre-authorized-code", false, null, + null, UserSessionModel.SessionPersistenceState.PERSISTENT); + + AuthenticatedClientSessionModel clientSession = session.sessions().createClientSession(realm, clientModel, userSession); + String credentialConfigurationIds = JsonSerialization.valueAsString(credOffer.getCredentialConfigurationIds()); + clientSession.setNote(OID4VCIssuerEndpoint.CREDENTIAL_CONFIGURATION_IDS_NOTE, credentialConfigurationIds); + clientSession.setNote(OIDCLoginProtocol.ISSUER, credOffer.getCredentialIssuer()); clientSession.setNote(VC_ISSUANCE_FLOW, PreAuthorizedCodeGrantTypeFactory.GRANT_TYPE); + + ClientSessionContext sessionContext = fromClientSessionAndScopeParameter(clientSession, OAuth2Constants.SCOPE_OPENID, session); sessionContext.setAttribute(Constants.GRANT_TYPE, PreAuthorizedCodeGrantTypeFactory.GRANT_TYPE); // set the client as retrieved from the pre-authorized session - session.getContext().setClient(result.getClientSession().getClient()); + session.getContext().setClient(clientModel); + + // Process authorization_details using provider discovery + List authorizationDetailsResponses = processAuthorizationDetails(userSession, sessionContext); + LOGGER.infof("Initial authorization_details processing result: %s", authorizationDetailsResponses); + + // If no authorization_details were processed from the request, try to generate them from credential offer + if (authorizationDetailsResponses == null || authorizationDetailsResponses.isEmpty()) { + authorizationDetailsResponses = handleMissingAuthorizationDetails(userSession, sessionContext); + } + + authorizationDetailsResponses = Optional.ofNullable(authorizationDetailsResponses).orElse(List.of()); + if (authorizationDetailsResponses.size() != 1) { + boolean emptyAuthDetails = authorizationDetailsResponses.isEmpty(); + String errorMessage = (emptyAuthDetails ? "No" : "Multiple") + " authorization details"; + event.detail(Details.REASON, errorMessage).error(Errors.INVALID_CODE); + throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, + errorMessage, Response.Status.BAD_REQUEST); + } + + // Add authorization_details to the OfferState and otherClaims + var authDetails = (OID4VCAuthorizationDetailsResponse) authorizationDetailsResponses.get(0); + offerState.setAuthorizationDetails(authDetails); + offerStorage.replaceOfferState(session, offerState); AccessToken accessToken = tokenManager.createClientAccessToken(session, clientSession.getRealm(), clientSession.getClient(), - clientSession.getUserSession().getUser(), - clientSession.getUserSession(), + userSession.getUser(), + userSession, sessionContext); - TokenManager.AccessTokenResponseBuilder responseBuilder = tokenManager.responseBuilder( + accessToken.setOtherClaims(AUTHORIZATION_DETAILS, authorizationDetailsResponses); + + AccessTokenResponseBuilder responseBuilder = tokenManager.responseBuilder( clientSession.getRealm(), clientSession.getClient(), event, session, - clientSession.getUserSession(), + userSession, sessionContext).accessToken(accessToken); - // Process authorization_details using provider discovery - List authorizationDetailsResponse = processAuthorizationDetails(clientSession.getUserSession(), sessionContext); - LOGGER.infof("Initial authorization_details processing result: %s", authorizationDetailsResponse); - - // If no authorization_details were processed from the request, try to generate them from credential offer - if (authorizationDetailsResponse == null || authorizationDetailsResponse.isEmpty()) { - authorizationDetailsResponse = handleMissingAuthorizationDetails(clientSession.getUserSession(), sessionContext); - } - AccessTokenResponse tokenResponse; try { tokenResponse = responseBuilder.build(); + tokenResponse.setOtherClaims(AUTHORIZATION_DETAILS, authorizationDetailsResponses); } catch (RuntimeException re) { - if ("cannot get encryption KEK".equals(re.getMessage())) { - throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, - "cannot get encryption KEK", Response.Status.BAD_REQUEST); + String errorMessage = "Cannot get encryption KEK"; + if (errorMessage.equals(re.getMessage())) { + throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, errorMessage, Response.Status.BAD_REQUEST); } else { throw re; } } - // If authorization_details is present, add it to otherClaims - if (authorizationDetailsResponse != null && !authorizationDetailsResponse.isEmpty()) { - tokenResponse.setOtherClaims(AUTHORIZATION_DETAILS_PARAM, authorizationDetailsResponse); - } - event.success(); return cors.allowAllOrigins().add(Response.ok(tokenResponse).type(MediaType.APPLICATION_JSON_TYPE)); } @@ -137,20 +182,4 @@ public class PreAuthorizedCodeGrantType extends OAuth2GrantTypeBase { public EventType getEventType() { return EventType.CODE_TO_TOKEN; } - - /** - * Create a pre-authorized Code for the given client session. - * - * @param session - keycloak session to be used - * @param authenticatedClientSession - client session to be persisted - * @param expirationTime - expiration time of the code, the code should be short-lived - * @return the pre-authorized code - */ - public static String getPreAuthorizedCode(KeycloakSession session, AuthenticatedClientSessionModel authenticatedClientSession, int expirationTime) { - String codeId = UUID.randomUUID().toString(); - String nonce = SecretGenerator.getInstance().randomString(); - OAuth2Code oAuth2Code = new OAuth2Code(codeId, expirationTime, nonce, null, null, null, null, null, - authenticatedClientSession.getUserSession().getId()); - return OAuth2CodeParser.persistCode(session, authenticatedClientSession, oAuth2Code); - } } diff --git a/services/src/main/java/org/keycloak/protocol/oidc/utils/OAuth2Code.java b/services/src/main/java/org/keycloak/protocol/oidc/utils/OAuth2Code.java index 0316314a08e..af8c16ce123 100755 --- a/services/src/main/java/org/keycloak/protocol/oidc/utils/OAuth2Code.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/utils/OAuth2Code.java @@ -22,9 +22,9 @@ import java.util.Map; /** * Data associated with the oauth2 code. - * + *

* Those data are typically valid just for the very short time - they're created at the point before we redirect to the application - * after successful and they're removed when application sends requests to the token endpoint (code-to-token endpoint) to exchange the + * and removed when application sends requests to the token endpoint (code-to-token endpoint) to exchange the * single-use OAuth2 code parameter for those data. * * @author Marek Posolda @@ -59,6 +59,18 @@ public class OAuth2Code { private final String userSessionId; + public OAuth2Code(String id, int expiration, String nonce, String scope, String userSessionId) { + this.id = id; + this.expiration = expiration; + this.nonce = nonce; + this.scope = scope; + this.redirectUriParam = null; + this.codeChallenge = null; + this.codeChallengeMethod = null; + this.dpopJkt = null; + this.userSessionId = userSessionId; + } + public OAuth2Code(String id, int expiration, String nonce, String scope, String redirectUriParam, String codeChallenge, String codeChallengeMethod, String dpopJkt, String userSessionId) { this.id = id; @@ -85,7 +97,7 @@ public class OAuth2Code { } - public static final OAuth2Code deserializeCode(Map data) { + public static OAuth2Code deserializeCode(Map data) { return new OAuth2Code(data); } @@ -93,7 +105,7 @@ public class OAuth2Code { public Map serializeCode() { Map result = new HashMap<>(); - result.put(ID_NOTE, id.toString()); + result.put(ID_NOTE, id); result.put(EXPIRATION_NOTE, String.valueOf(expiration)); result.put(NONCE_NOTE, nonce); result.put(SCOPE_NOTE, scope); @@ -106,7 +118,6 @@ public class OAuth2Code { return result; } - public String getId() { return id; } diff --git a/services/src/main/java/org/keycloak/protocol/oidc/utils/OAuth2CodeParser.java b/services/src/main/java/org/keycloak/protocol/oidc/utils/OAuth2CodeParser.java index f697d61e2c7..6c0383beae1 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/utils/OAuth2CodeParser.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/utils/OAuth2CodeParser.java @@ -40,13 +40,9 @@ public class OAuth2CodeParser { private static final Pattern DOT = Pattern.compile("\\."); - /** * Will persist the code to the cache and return the object with the codeData and code correctly set * - * @param session - * @param clientSession - * @param codeData * @return code parameter to be used in OAuth2 handshake */ public static String persistCode(KeycloakSession session, AuthenticatedClientSessionModel clientSession, OAuth2Code codeData) { @@ -67,12 +63,6 @@ public class OAuth2CodeParser { * Will parse the code and retrieve the corresponding OAuth2Code and AuthenticatedClientSessionModel. Will also check if code wasn't already * used and if it wasn't expired. If it was already used (or other error happened during parsing), then returned parser will have "isIllegalCode" * set to true. If it was expired, the parser will have "isExpired" set to true - * - * @param session - * @param code - * @param realm - * @param event - * @return */ public static ParseResult parseCode(KeycloakSession session, String code, RealmModel realm, EventBuilder event) { ParseResult result = new ParseResult(code); @@ -180,5 +170,4 @@ public class OAuth2CodeParser { return this; } } - } diff --git a/services/src/main/java/org/keycloak/services/managers/RealmManager.java b/services/src/main/java/org/keycloak/services/managers/RealmManager.java index bebd1d0f77e..729f117ace3 100755 --- a/services/src/main/java/org/keycloak/services/managers/RealmManager.java +++ b/services/src/main/java/org/keycloak/services/managers/RealmManager.java @@ -77,6 +77,8 @@ import org.keycloak.utils.ReservedCharValidator; import org.keycloak.utils.SMTPUtil; import org.keycloak.utils.StringUtil; +import static org.keycloak.constants.OID4VCIConstants.CREDENTIAL_OFFER_CREATE; + /** * Per request object * @@ -97,7 +99,7 @@ public class RealmManager { return session; } - public RealmModel getKeycloakAdminstrationRealm() { + public RealmModel getKeycloakAdministrationRealm() { return getRealmByName(Config.getAdminRealm()); } @@ -246,7 +248,6 @@ public class RealmManager { viewUsers.addCompositeRole(queryGroups); } - public String getRealmAdminClientId(RealmModel realm) { return Constants.REALM_MANAGEMENT_CLIENT_ID; } @@ -255,8 +256,6 @@ public class RealmManager { return Constants.REALM_MANAGEMENT_CLIENT_ID; } - - protected void setupRealmDefaults(RealmModel realm) { realm.setBrowserSecurityHeaders(BrowserSecurityHeaders.realmDefaultHeaders); @@ -275,6 +274,13 @@ public class RealmManager { realm.setOTPPolicy(OTPPolicy.DEFAULT_POLICY); realm.setLoginWithEmailAllowed(true); + if (Profile.isFeatureEnabled(Profile.Feature.OID4VC_VCI)) { + if (realm.getRole(CREDENTIAL_OFFER_CREATE.getName()) == null) { + RoleModel roleModel = realm.addRole(CREDENTIAL_OFFER_CREATE.getName()); + roleModel.setDescription(CREDENTIAL_OFFER_CREATE.getDescription()); + } + } + realm.setEventsListeners(Collections.singleton("jboss-logging")); } @@ -284,7 +290,7 @@ public class RealmManager { boolean removed = model.removeRealm(realm.getId()); if (removed) { if (masterAdminClient != null) { - session.clients().removeClient(getKeycloakAdminstrationRealm(), masterAdminClient.getId()); + session.clients().removeClient(getKeycloakAdministrationRealm(), masterAdminClient.getId()); } UserSessionProvider sessions = session.sessions(); diff --git a/services/src/main/java/org/keycloak/services/resources/admin/AdminConsole.java b/services/src/main/java/org/keycloak/services/resources/admin/AdminConsole.java index dee8e0cd00c..192a1dac29b 100644 --- a/services/src/main/java/org/keycloak/services/resources/admin/AdminConsole.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/AdminConsole.java @@ -326,7 +326,7 @@ public class AdminConsole { } protected RealmModel getAdminstrationRealm(RealmManager realmManager) { - return realmManager.getKeycloakAdminstrationRealm(); + return realmManager.getKeycloakAdministrationRealm(); } /** diff --git a/services/src/main/java/org/keycloak/services/resources/admin/AdminRoot.java b/services/src/main/java/org/keycloak/services/resources/admin/AdminRoot.java index e82a8833970..fab02f43c89 100755 --- a/services/src/main/java/org/keycloak/services/resources/admin/AdminRoot.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/AdminRoot.java @@ -97,7 +97,7 @@ public class AdminRoot { public Response masterRealmAdminConsoleRedirect() { KeycloakUriInfo adminUriInfo = session.getContext().getUri(UrlType.ADMIN); if (shouldRedirect(adminUriInfo)) { - RealmModel master = new RealmManager(session).getKeycloakAdminstrationRealm(); + RealmModel master = new RealmManager(session).getKeycloakAdministrationRealm(); return Response.status(302).location( adminUriInfo.getBaseUriBuilder().path(AdminRoot.class).path(AdminRoot.class, "getAdminConsole").path("/").build(master.getName()) ).build(); diff --git a/services/src/main/resources/META-INF/services/org.keycloak.protocol.oid4vc.issuance.credentialoffer.CredentialOfferStorageFactory b/services/src/main/resources/META-INF/services/org.keycloak.protocol.oid4vc.issuance.credentialoffer.CredentialOfferStorageFactory new file mode 100644 index 00000000000..55dab8979ea --- /dev/null +++ b/services/src/main/resources/META-INF/services/org.keycloak.protocol.oid4vc.issuance.credentialoffer.CredentialOfferStorageFactory @@ -0,0 +1 @@ +org.keycloak.protocol.oid4vc.issuance.credentialoffer.InMemoryCredentialOfferStorageFactory diff --git a/services/src/main/resources/META-INF/services/org.keycloak.provider.Spi b/services/src/main/resources/META-INF/services/org.keycloak.provider.Spi index f8c40cf6559..5051d534144 100755 --- a/services/src/main/resources/META-INF/services/org.keycloak.provider.Spi +++ b/services/src/main/resources/META-INF/services/org.keycloak.provider.Spi @@ -32,6 +32,7 @@ org.keycloak.protocol.oidc.rar.AuthorizationRequestParserSpi org.keycloak.services.resources.admin.ext.AdminRealmResourceSpi org.keycloak.theme.freemarker.FreeMarkerSPI org.keycloak.protocol.oid4vc.issuance.credentialbuilder.CredentialBuilderSpi +org.keycloak.protocol.oid4vc.issuance.credentialoffer.CredentialOfferStorageSpi org.keycloak.protocol.oid4vc.issuance.keybinding.ProofValidatorSpi org.keycloak.protocol.oid4vc.issuance.signing.CredentialSignerSpi org.keycloak.protocol.oid4vc.issuance.keybinding.CNonceHandlerSpi diff --git a/services/src/test/java/org/keycloak/protocol/oid4vc/issuance/OID4VCAuthorizationDetailsProcessorTest.java b/services/src/test/java/org/keycloak/protocol/oid4vc/issuance/OID4VCAuthorizationDetailsProcessorTest.java index a57cbfb7516..59072e2e544 100644 --- a/services/src/test/java/org/keycloak/protocol/oid4vc/issuance/OID4VCAuthorizationDetailsProcessorTest.java +++ b/services/src/test/java/org/keycloak/protocol/oid4vc/issuance/OID4VCAuthorizationDetailsProcessorTest.java @@ -24,6 +24,9 @@ import org.keycloak.protocol.oid4vc.model.ClaimsDescription; import org.junit.Test; +import static org.keycloak.OAuth2Constants.CREDENTIAL_IDENTIFIERS; +import static org.keycloak.OAuth2Constants.OPENID_CREDENTIAL; + import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotEquals; @@ -48,7 +51,7 @@ public class OID4VCAuthorizationDetailsProcessorTest { */ private AuthorizationDetail createValidAuthorizationDetail() { AuthorizationDetail authDetail = new AuthorizationDetail(); - authDetail.setType("openid_credential"); + authDetail.setType(OPENID_CREDENTIAL); authDetail.setCredentialConfigurationId("test-config-id"); authDetail.setLocations(List.of("https://test-issuer.com")); return authDetail; @@ -87,7 +90,7 @@ public class OID4VCAuthorizationDetailsProcessorTest { */ private AuthorizationDetail createMissingCredentialIdAuthorizationDetail() { AuthorizationDetail authDetail = new AuthorizationDetail(); - authDetail.setType("openid_credential"); + authDetail.setType(OPENID_CREDENTIAL); return authDetail; } @@ -116,7 +119,7 @@ public class OID4VCAuthorizationDetailsProcessorTest { * Asserts that an AuthorizationDetail has valid structure */ private void assertValidAuthorizationDetail(AuthorizationDetail authDetail) { - assertEquals("Type should be openid_credential", "openid_credential", authDetail.getType()); + assertEquals("Type should be openid_credential", OPENID_CREDENTIAL, authDetail.getType()); assertEquals("Credential configuration ID should be set", "test-config-id", authDetail.getCredentialConfigurationId()); assertNotNull("Locations should not be null", authDetail.getLocations()); assertEquals("Should have exactly one location", 1, authDetail.getLocations().size()); @@ -127,7 +130,7 @@ public class OID4VCAuthorizationDetailsProcessorTest { * Asserts that an AuthorizationDetail has invalid type */ private void assertInvalidTypeAuthorizationDetail(AuthorizationDetail authDetail) { - assertNotEquals("Type should not be openid_credential", "openid_credential", authDetail.getType()); + assertNotEquals("Type should not be openid_credential", OPENID_CREDENTIAL, authDetail.getType()); assertEquals("Invalid type should be preserved", "invalid_type", authDetail.getType()); } @@ -135,7 +138,7 @@ public class OID4VCAuthorizationDetailsProcessorTest { * Asserts that an AuthorizationDetail has missing credential configuration ID */ private void assertMissingCredentialIdAuthorizationDetail(AuthorizationDetail authDetail) { - assertEquals("Type should be openid_credential", "openid_credential", authDetail.getType()); + assertEquals("Type should be openid_credential", OPENID_CREDENTIAL, authDetail.getType()); assertNull("Credential configuration ID should be null", authDetail.getCredentialConfigurationId()); } @@ -317,7 +320,7 @@ public class OID4VCAuthorizationDetailsProcessorTest { assertEquals("Should have exactly one authorization detail", 1, authDetails.size()); AuthorizationDetail parsedDetail = authDetails.get(0); - assertEquals("Type should be preserved", "openid_credential", parsedDetail.getType()); + assertEquals("Type should be preserved", OPENID_CREDENTIAL, parsedDetail.getType()); assertEquals("Credential configuration ID should be preserved", "test-config-id", parsedDetail.getCredentialConfigurationId()); assertNotNull("Claims should be preserved", parsedDetail.getClaims()); assertEquals("Should have exactly one claim", 1, parsedDetail.getClaims().size()); @@ -365,29 +368,34 @@ public class OID4VCAuthorizationDetailsProcessorTest { @Test public void testBuildAuthorizationDetailResponseLogic() { + // Test the response structure that would be built + String expectedCredentialConfigurationId = "test-config-id"; + List expectedCredentialIdentifiers = List.of("test-identifier-123"); + ClaimsDescription claim = createValidClaimsDescription(); + List expectedClaims = List.of(claim); + // Test authorization detail that would be used to build response AuthorizationDetail authDetail = createValidAuthorizationDetail(); - ClaimsDescription claim = createValidClaimsDescription(); - authDetail.setClaims(List.of(claim)); + authDetail.setAdditionalField(CREDENTIAL_IDENTIFIERS, expectedCredentialIdentifiers); + authDetail.setClaims(expectedClaims); // Verify the data structure that buildAuthorizationDetailResponse() would process assertValidAuthorizationDetail(authDetail); assertNotNull("Claims should not be null", authDetail.getClaims()); assertEquals("Should have exactly one claim", 1, authDetail.getClaims().size()); - // Test the response structure that would be built - String expectedType = "openid_credential"; - String expectedCredentialConfigurationId = "test-config-id"; - List expectedCredentialIdentifiers = List.of("test-identifier-123"); - List expectedClaims = List.of(claim); + @SuppressWarnings("unchecked") + List actualCredentialIdentifiers = (List) authDetail.getAdditionalFields().get(CREDENTIAL_IDENTIFIERS); // Verify the response data that would be created - assertEquals("Response type should match", expectedType, "openid_credential"); - assertEquals("Response credential configuration ID should match", expectedCredentialConfigurationId, "test-config-id"); - assertNotNull("Response credential identifiers should not be null", expectedCredentialIdentifiers); - assertEquals("Response should have exactly one credential identifier", 1, expectedCredentialIdentifiers.size()); - assertNotNull("Response claims should not be null", expectedClaims); - assertEquals("Response should have exactly one claim", 1, expectedClaims.size()); + assertEquals("Response type should match", OPENID_CREDENTIAL, authDetail.getType()); + assertEquals("Response credential configuration ID should match", expectedCredentialConfigurationId, authDetail.getCredentialConfigurationId()); + assertNotNull("Response credential identifiers should not be null", actualCredentialIdentifiers); + assertEquals("Response should have exactly one credential identifier", 1, actualCredentialIdentifiers.size()); + assertEquals("Response credential identifiers should match", expectedCredentialIdentifiers, actualCredentialIdentifiers); + assertNotNull("Response claims should not be null", authDetail.getClaims()); + assertEquals("Response should have exactly one claim", 1, authDetail.getClaims().size()); + assertEquals("Response claims should match", expectedClaims, authDetail.getClaims()); } @Test diff --git a/tests/base/src/test/java/org/keycloak/tests/admin/client/ClientScopeTestOid4Vci.java b/tests/base/src/test/java/org/keycloak/tests/admin/client/ClientScopeTestOid4Vci.java index 7c61f0a8875..c35ca90e350 100644 --- a/tests/base/src/test/java/org/keycloak/tests/admin/client/ClientScopeTestOid4Vci.java +++ b/tests/base/src/test/java/org/keycloak/tests/admin/client/ClientScopeTestOid4Vci.java @@ -25,7 +25,7 @@ import jakarta.ws.rs.core.HttpHeaders; import jakarta.ws.rs.core.Response; import org.keycloak.common.Profile; -import org.keycloak.constants.Oid4VciConstants; +import org.keycloak.constants.OID4VCIConstants; import org.keycloak.models.oid4vci.CredentialScopeModel; import org.keycloak.representations.idm.ClientScopeRepresentation; import org.keycloak.testframework.annotations.KeycloakIntegrationTest; @@ -48,7 +48,7 @@ public class ClientScopeTestOid4Vci extends AbstractClientScopeTest { ClientScopeRepresentation clientScope = new ClientScopeRepresentation(); clientScope.setName("test-client-scope"); clientScope.setDescription("test-client-scope-description"); - clientScope.setProtocol(Oid4VciConstants.OID4VC_PROTOCOL); + clientScope.setProtocol(OID4VCIConstants.OID4VC_PROTOCOL); clientScope.setAttributes(Map.of("test-attribute", "test-value")); String clientScopeId = null; diff --git a/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/AccessTokenRequest.java b/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/AccessTokenRequest.java index abbe83c879b..3114149a49f 100644 --- a/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/AccessTokenRequest.java +++ b/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/AccessTokenRequest.java @@ -1,8 +1,11 @@ package org.keycloak.testsuite.util.oauth; import java.io.IOException; +import java.util.List; import org.keycloak.OAuth2Constants; +import org.keycloak.protocol.oid4vc.model.AuthorizationDetail; +import org.keycloak.util.JsonSerialization; import org.keycloak.util.TokenUtil; import org.apache.http.client.methods.CloseableHttpResponse; @@ -40,6 +43,11 @@ public class AccessTokenRequest extends AbstractHttpPostRequest authDetails) { + parameter(OAuth2Constants.AUTHORIZATION_DETAILS, JsonSerialization.valueAsString(authDetails)); + return this; + } + public AccessTokenRequest dpopProof(String dpopProof) { header(TokenUtil.TOKEN_TYPE_DPOP, dpopProof); return this; diff --git a/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/AccessTokenResponse.java b/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/AccessTokenResponse.java index 112289ca11a..85eb765a69b 100644 --- a/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/AccessTokenResponse.java +++ b/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/AccessTokenResponse.java @@ -1,10 +1,14 @@ package org.keycloak.testsuite.util.oauth; import java.io.IOException; +import java.util.Arrays; import java.util.HashMap; +import java.util.List; import java.util.Map; import org.keycloak.OAuth2Constants; +import org.keycloak.protocol.oid4vc.model.AuthorizationDetail; +import org.keycloak.util.JsonSerialization; import org.apache.http.client.methods.CloseableHttpResponse; @@ -19,6 +23,7 @@ public class AccessTokenResponse extends AbstractHttpResponse { private String refreshToken; private String scope; private String sessionState; + private List authorizationDetails; private Map otherClaims; @@ -61,6 +66,11 @@ public class AccessTokenResponse extends AbstractHttpResponse { case OAuth2Constants.REFRESH_TOKEN: refreshToken = (String) entry.getValue(); break; + case OAuth2Constants.AUTHORIZATION_DETAILS: + var valJson = JsonSerialization.valueAsString(entry.getValue()); + var arr = JsonSerialization.valueFromString(valJson, AuthorizationDetail[].class); + authorizationDetails = Arrays.asList(arr); + break; default: otherClaims.put(entry.getKey(), entry.getValue()); break; @@ -108,4 +118,7 @@ public class AccessTokenResponse extends AbstractHttpResponse { return otherClaims; } + public List getAuthorizationDetails() { + return authorizationDetails; + } } diff --git a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/forms/PassThroughClientAuthenticator.java b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/forms/PassThroughClientAuthenticator.java index 4bead277d99..d2a84e759fc 100755 --- a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/forms/PassThroughClientAuthenticator.java +++ b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/forms/PassThroughClientAuthenticator.java @@ -38,7 +38,9 @@ import org.keycloak.provider.ProviderConfigProperty; public class PassThroughClientAuthenticator extends AbstractClientAuthenticator { public static final String PROVIDER_ID = "testsuite-client-passthrough"; + public static String clientId = "test-app"; + public static String namedClientId = "named-test-app"; // If this parameter is present in the HTTP request, the error will be thrown during authentication public static final String TEST_ERROR_PARAM = "test_error_param"; diff --git a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/rest/TestingResourceProvider.java b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/rest/TestingResourceProvider.java index 233a59d7459..a42c968c5da 100644 --- a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/rest/TestingResourceProvider.java +++ b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/rest/TestingResourceProvider.java @@ -72,7 +72,6 @@ import org.keycloak.events.admin.AuthDetails; import org.keycloak.events.admin.OperationType; import org.keycloak.events.email.EmailEventListenerProviderFactory; import org.keycloak.http.HttpRequest; -import org.keycloak.models.AuthenticatedClientSessionModel; import org.keycloak.models.AuthenticationFlowModel; import org.keycloak.models.ClientModel; import org.keycloak.models.FederatedIdentityModel; @@ -85,9 +84,14 @@ import org.keycloak.models.UserProvider; import org.keycloak.models.UserSessionModel; import org.keycloak.models.utils.ModelToRepresentation; import org.keycloak.models.utils.ResetTimeOffsetEvent; +import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerWellKnownProvider; +import org.keycloak.protocol.oid4vc.issuance.credentialoffer.CredentialOfferStorage; +import org.keycloak.protocol.oid4vc.issuance.credentialoffer.CredentialOfferStorage.CredentialOfferState; +import org.keycloak.protocol.oid4vc.model.CredentialsOffer; +import org.keycloak.protocol.oid4vc.model.PreAuthorizedCode; +import org.keycloak.protocol.oid4vc.model.PreAuthorizedGrant; import org.keycloak.protocol.oidc.encode.AccessTokenContext; import org.keycloak.protocol.oidc.encode.TokenContextEncoderProvider; -import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantType; import org.keycloak.provider.Provider; import org.keycloak.provider.ProviderFactory; import org.keycloak.representations.idm.AdminEventRepresentation; @@ -1121,18 +1125,24 @@ public class TestingResourceProvider implements RealmResourceProvider { @NoCache public String getPreAuthorizedCode(@QueryParam("realm") final String realmName, @QueryParam("userSessionId") final String userSessionId, @QueryParam("clientId") final String clientId, @QueryParam("expiration") final int expiration) { RealmModel realm = getRealmByName(realmName); - AuthenticatedClientSessionModel ascm = session.sessions() - .getUserSession(realm, userSessionId) - .getAuthenticatedClientSessions() - .values() - .stream().filter(acsm -> acsm.getClient().getClientId().equals(clientId)) - .findFirst() - .orElseThrow(() -> new RuntimeException("No authenticatedClientSession found.")); - return PreAuthorizedCodeGrantType.getPreAuthorizedCode(session, ascm, expiration); + UserSessionModel userSession = session.sessions().getUserSession(realm, userSessionId); + + String code = "urn:oid4vci:code:" + UUID.randomUUID(); + CredentialsOffer credOffer = new CredentialsOffer() + .setCredentialIssuer(OID4VCIssuerWellKnownProvider.getIssuer(session.getContext())) + .setCredentialConfigurationIds(List.of("oid4vc_natural_person")) + .setGrants(new PreAuthorizedGrant().setPreAuthorizedCode( + new PreAuthorizedCode().setPreAuthorizedCode(code))); + + String userId = userSession.getUser().getUsername(); + var offerStorage = session.getProvider(CredentialOfferStorage.class); + offerStorage.putOfferState(session, new CredentialOfferState(credOffer, clientId, userId, expiration)); + + return code; } @POST - @Path("/email-event-litener-provide/add-events") + @Path("/email-event-listener-provide/add-events") @Consumes(MediaType.APPLICATION_JSON) public void addEventsToEmailEventListenerProvider(List events) { if (events != null && !events.isEmpty()) { @@ -1143,7 +1153,7 @@ public class TestingResourceProvider implements RealmResourceProvider { } @POST - @Path("/email-event-litener-provide/remove-events") + @Path("/email-event-listener-provide/remove-events") @Consumes(MediaType.APPLICATION_JSON) public void removeEventsToEmailEventListenerProvider(List events) { if (events != null && !events.isEmpty()) { diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/client/resources/TestingResource.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/client/resources/TestingResource.java index cf09a62d203..317c425e6d5 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/client/resources/TestingResource.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/client/resources/TestingResource.java @@ -457,7 +457,7 @@ public interface TestingResource { * @param events The events to be included */ @POST - @Path("/email-event-litener-provide/add-events") + @Path("/email-event-listener-provide/add-events") @Consumes(MediaType.APPLICATION_JSON) public void addEventsToEmailEventListenerProvider(List events); @@ -466,7 +466,7 @@ public interface TestingResource { * @param events The events to be removed */ @POST - @Path("/email-event-litener-provide/remove-events") + @Path("/email-event-listener-provide/remove-events") @Consumes(MediaType.APPLICATION_JSON) public void removeEventsToEmailEventListenerProvider(List events); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/PreAuthorizedGrantTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/PreAuthorizedGrantTest.java index b0c989d3138..22e38c3de1c 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/PreAuthorizedGrantTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/PreAuthorizedGrantTest.java @@ -65,7 +65,6 @@ public class PreAuthorizedGrantTest extends AbstractTestRealmKeycloakTest { AccessTokenResponse accessTokenResponse = postCode(preAuthorizedCode); assertEquals("An access token should have successfully been returned.", HttpStatus.SC_OK, accessTokenResponse.getStatusCode()); - assertEquals("The correct session should have been used for the pre-authorized code.", userSessionId, accessTokenResponse.getSessionState()); } @Test diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/par/ParEndpointTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/par/ParEndpointTest.java index 3986093b01a..1552b1562a3 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/par/ParEndpointTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/par/ParEndpointTest.java @@ -27,7 +27,7 @@ import org.keycloak.protocol.oidc.par.endpoints.ParEndpoint; import org.junit.Assert; import org.junit.Test; -import static org.keycloak.OAuth2Constants.AUTHORIZATION_DETAILS_PARAM; +import static org.keycloak.OAuth2Constants.AUTHORIZATION_DETAILS; public class ParEndpointTest { @@ -35,12 +35,12 @@ public class ParEndpointTest { public void testFlattenDecodedFormParametersRetainAuthorizationDetails() { var decodedFormParameters = new MultivaluedHashMap(); String authorizationDetails = "[{\"type\": \"urn:openfinanceuae:account-access-consent:v1.0\",\"foo\":\"bar\"},{\"type\": \"urn:openfinanceuae:account-access-consent:v1.0\",\"gugu\":\"gaga\"}]"; - decodedFormParameters.put(AUTHORIZATION_DETAILS_PARAM, List.of(authorizationDetails)); + decodedFormParameters.put(AUTHORIZATION_DETAILS, List.of(authorizationDetails)); var params = new HashMap(); ParEndpoint.flattenDecodedFormParametersToParamsMap(decodedFormParameters, params); - Assert.assertEquals(authorizationDetails, params.get(AUTHORIZATION_DETAILS_PARAM)); + Assert.assertEquals(authorizationDetails, params.get(AUTHORIZATION_DETAILS)); } @Test diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/OID4VCICredentialOfferMatrixTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/OID4VCICredentialOfferMatrixTest.java new file mode 100644 index 00000000000..54df3d40070 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/OID4VCICredentialOfferMatrixTest.java @@ -0,0 +1,460 @@ +/* + * Copyright 2025 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.testsuite.oid4vc.issuance; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; + +import jakarta.ws.rs.core.HttpHeaders; + +import org.keycloak.OAuth2Constants; +import org.keycloak.TokenVerifier; +import org.keycloak.jose.jws.JWSInput; +import org.keycloak.protocol.oid4vc.model.AuthorizationDetail; +import org.keycloak.protocol.oid4vc.model.CredentialIssuer; +import org.keycloak.protocol.oid4vc.model.CredentialOfferURI; +import org.keycloak.protocol.oid4vc.model.CredentialRequest; +import org.keycloak.protocol.oid4vc.model.CredentialResponse; +import org.keycloak.protocol.oid4vc.model.CredentialsOffer; +import org.keycloak.protocol.oid4vc.model.PreAuthorizedCode; +import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration; +import org.keycloak.protocol.oid4vc.model.VerifiableCredential; +import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantTypeFactory; +import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation; +import org.keycloak.representations.JsonWebToken; +import org.keycloak.representations.idm.ClientRepresentation; +import org.keycloak.testsuite.oid4vc.issuance.signing.OID4VCIssuerEndpointTest; +import org.keycloak.testsuite.util.oauth.AccessTokenResponse; +import org.keycloak.util.JsonSerialization; + +import org.apache.commons.io.IOUtils; +import org.apache.directory.api.util.Strings; +import org.apache.http.HttpEntity; +import org.apache.http.HttpStatus; +import org.apache.http.NameValuePair; +import org.apache.http.client.entity.UrlEncodedFormEntity; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.entity.ContentType; +import org.apache.http.entity.StringEntity; +import org.apache.http.message.BasicNameValuePair; +import org.apache.http.util.EntityUtils; +import org.junit.Test; + +import static org.keycloak.OAuth2Constants.CREDENTIAL_IDENTIFIERS; +import static org.keycloak.OAuth2Constants.OPENID_CREDENTIAL; +import static org.keycloak.constants.OID4VCIConstants.CREDENTIAL_OFFER_CREATE; +import static org.keycloak.protocol.oid4vc.model.ErrorType.INVALID_CREDENTIAL_OFFER_REQUEST; +import static org.keycloak.testsuite.admin.ApiUtil.findUserByUsernameId; +import static org.keycloak.testsuite.forms.PassThroughClientAuthenticator.clientId; +import static org.keycloak.testsuite.forms.PassThroughClientAuthenticator.namedClientId; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + + +/** + * Credential Offer Validity Matrix + *

+ * +----------+-----------+---------+---------+------------------------------------------------------+ + * | pre-auth | clientId | userId | Valid | Notes | + * +----------+-----------+---------+---------+------------------------------------------------------+ + * | no | no | no | yes | Generic offer; any logged-in user may redeem. | + * | no | no | yes | yes | Offer restricted to a specific user. | + * | no | yes | no | yes | Bound to client; user determined at login. | + * | no | yes | yes | yes | Bound to both client and user. | + * +----------+-----------+---------+---------+------------------------------------------------------+ + * | yes | no | no | no | Pre-auth requires a user subject; missing userId. | + * | yes | no | yes | yes | Pre-auth for a specific user; client issuer defined. | + * | yes | yes | no | no | Same as above; userId required. | + * | yes | yes | yes | yes | Fully constrained: user + client. | + * +----------+-----------+---------+---------+------------------------------------------------------+ + */ +public class OID4VCICredentialOfferMatrixTest extends OID4VCIssuerEndpointTest { + + String issUserId = "john"; + String issClientId = clientId; + + String namedUserId = "alice"; + + String credScopeName = jwtTypeCredentialScopeName; + String credConfigId = jwtTypeCredentialConfigurationIdName; + + static class OfferTestContext { + boolean preAuthorized; + String issUser; + String issClient; + String appUser; + String appClient; + CredentialIssuer issuerMetadata; + OIDCConfigurationRepresentation authorizationMetadata; + SupportedCredentialConfiguration supportedCredentialConfiguration; + } + + OfferTestContext newTestContext(boolean preAuth, String appClient, String appUser) { + var ctx = new OfferTestContext(); + ctx.preAuthorized = preAuth; + ctx.issUser = issUserId; + ctx.issClient = issClientId; + ctx.appUser = appUser; + ctx.appClient = appClient; + ctx.issuerMetadata = getCredentialIssuerMetadata(); + ctx.authorizationMetadata = getAuthorizationMetadata(ctx.issuerMetadata.getAuthorizationServers().get(0)); + ctx.supportedCredentialConfiguration = ctx.issuerMetadata.getCredentialsSupported().get(credConfigId); + return ctx; + } + + @Test + public void testVariousLogins() { + assertNotNull(getBearerTokenAndLogout(issClientId, issUserId, "openid")); + assertNotNull(getBearerTokenAndLogout(issClientId, namedUserId, "openid")); + assertNotNull(getBearerTokenAndLogout(namedClientId, issUserId, "openid")); + assertNotNull(getBearerTokenAndLogout(namedClientId, namedUserId, "openid")); + } + + @Test + public void testCredentialWithoutOffer() throws Exception { + + var ctx = newTestContext(false, null, namedUserId); + + AuthorizationDetail authDetail = new AuthorizationDetail(); + authDetail.setType(OPENID_CREDENTIAL); + authDetail.setCredentialConfigurationId(credConfigId); + authDetail.setLocations(List.of(ctx.issuerMetadata.getCredentialIssuer())); + + // [TODO] Requires Credential scope in AuthorizationRequest although already given in AuthorizationDetails + // https://github.com/keycloak/keycloak/issues/44320 + String accessToken = getBearerToken(issClientId, ctx.appUser, credScopeName, authDetail); + + CredentialResponse credResponse = getCredentialByAuthDetail(ctx, accessToken, authDetail); + verifyCredentialResponse(ctx, credResponse); + } + + @Test + public void testCredentialOffer_noPreAuth_noClientId_noUserId() throws Exception { + runCredentialOfferTest(newTestContext(false, null, null)); + } + + @Test + public void testCredentialOffer_noPreAuth_noClientId_UserId() throws Exception { + runCredentialOfferTest(newTestContext(false, null, namedUserId)); + } + + @Test + public void testCredentialOffer_noPreAuth_ClientId_noUserId() throws Exception { + runCredentialOfferTest(newTestContext(false, namedClientId, null)); + } + + @Test + public void testCredentialOffer_noPreAuth_ClientId_UserId() throws Exception { + runCredentialOfferTest(newTestContext(false, namedClientId, namedUserId)); + } + + // Pre Authorized -------------------------------------------------------------------------------------------------- + + @Test + public void testCredentialOffer_PreAuth_noClientId_noUserId() throws Exception { + try { + runCredentialOfferTest(newTestContext(true, null, null)); + fail("Expected " + INVALID_CREDENTIAL_OFFER_REQUEST.name()); + } catch (RuntimeException ex) { + List.of(INVALID_CREDENTIAL_OFFER_REQUEST.name(), "Pre-Authorized credential offer requires a target user") + .forEach(it -> assertTrue(ex.getMessage() + " does not contain " + it, ex.getMessage().contains(it))); + } + } + + @Test + public void testCredentialOffer_PreAuth_noClientId_UserId() throws Exception { + runCredentialOfferTest(newTestContext(true, null, namedUserId)); + } + + @Test + public void testCredentialOffer_PreAuth_ClientId_noUserId() throws Exception { + try { + runCredentialOfferTest(newTestContext(true, namedClientId, null)); + fail("Expected " + INVALID_CREDENTIAL_OFFER_REQUEST.name()); + } catch (RuntimeException ex) { + List.of(INVALID_CREDENTIAL_OFFER_REQUEST.name(), "Pre-Authorized credential offer requires a target user") + .forEach(it -> assertTrue(ex.getMessage() + " does not contain " + it, ex.getMessage().contains(it))); + } + } + + @Test + public void testCredentialOffer_PreAuth_ClientId_UserId() throws Exception { + runCredentialOfferTest(newTestContext(true, namedClientId, namedUserId)); + } + + void runCredentialOfferTest(OfferTestContext ctx) throws Exception { + + // Issuer login + // + String issToken = getBearerToken(ctx.issClient, ctx.issUser, "openid"); + + // Exclude scope: + // Require role: credential-offer-create + verifyTokenJwt(ctx, issToken, + List.of(), List.of(ctx.supportedCredentialConfiguration.getScope()), + List.of(CREDENTIAL_OFFER_CREATE.getName()), List.of()); + + // Retrieving the credential-offer-uri + // + String offerUri = getCredentialOfferUriUrl(ctx, issToken); + + // Issuer logout in order to remove unwanted session state + // + logout(ctx.issUser); + + try { + + // Using the uri to get the actual credential offer + // + CredentialsOffer credOffer = getCredentialsOffer(ctx, offerUri); + + if (credOffer.getCredentialConfigurationIds().size() > 1) + throw new IllegalStateException("Multiple credential configuration ids not supported in: " + JsonSerialization.valueAsString(credOffer)); + + if (ctx.preAuthorized) { + + // Get an access token for the pre-authorized code (PAC) + // + // For a PAC access token, we treat all scopes and all roles as non-meaningful. + // The access token: + // 1. has no authenticated user, and therefore cannot carry any user roles + // 2. does not perform authorization-based scope filtering + // 3. does not derive scopes from the client configuration + // 4. does not reflect anything from the credential offer + // + AccessTokenResponse accessToken = getPreAuthorizedAccessTokenResponse(ctx, credOffer); + List authDetails = accessToken.getAuthorizationDetails(); + if (authDetails == null) + throw new IllegalStateException("No authorization_details in token response"); + if (authDetails.size() > 1) + throw new IllegalStateException("Multiple authorization_details in token response"); + + // Get the credential and verify + // + CredentialResponse credResponse = getCredentialByAuthDetail(ctx, accessToken.getAccessToken(), authDetails.get(0)); + verifyCredentialResponse(ctx, credResponse); + + } else { + + String clientId = ctx.appClient != null ? ctx.appClient : namedClientId; + String userId = ctx.appUser != null ? ctx.appUser : namedUserId; + String credConfigId = credOffer.getCredentialConfigurationIds().get(0); + + SupportedCredentialConfiguration credConfig = ctx.issuerMetadata.getCredentialsSupported().get(credConfigId); + String scope = credConfig.getScope(); + + String accessToken = getBearerToken(clientId, userId, scope); + + // Get the credential and verify + // + CredentialResponse credResponse = getCredentialByOffer(ctx, accessToken, credOffer); + verifyCredentialResponse(ctx, credResponse); + } + } finally { + if (ctx.appUser != null) { + logout(ctx.appUser); + } + } + } + + // Private --------------------------------------------------------------------------------------------------------- + + private String getBearerToken(String clientId, String username, String scope) { + ClientRepresentation client = testRealm().clients().findByClientId(clientId).get(0); + if (client.isDirectAccessGrantsEnabled()) { + return getBearerTokenDirectAccess(oauth, client, username, scope).getAccessToken(); + } else { + return getBearerTokenCodeFlow(oauth, client, username, scope).getAccessToken(); + } + } + + private String getBearerToken(String clientId, String username, String scope, AuthorizationDetail... authDetail) { + ClientRepresentation client = testRealm().clients().findByClientId(clientId).get(0); + String authCode = getAuthorizationCode(oauth, client, username, scope); + return getBearerToken(oauth, authCode, authDetail).getAccessToken(); + } + + private String getBearerTokenAndLogout(String clientId, String userId, String scope) { + String token = getBearerToken(clientId, userId, scope); + logout(userId); + return token; + } + + private void logout(String userId) { + findUserByUsernameId(testRealm(), userId).logout(); + } + + private String getCredentialOfferUriUrl(OfferTestContext ctx, String token) throws Exception { + CredentialOfferURI offerURI = getCredentialOfferUri(ctx, token); + return offerURI.getIssuer() + offerURI.getNonce(); + } + + private CredentialOfferURI getCredentialOfferUri(OfferTestContext ctx, String token) throws Exception { + String credConfigId = ctx.supportedCredentialConfiguration.getId(); + String credOfferUriUrl = getCredentialOfferUriUrl(credConfigId, ctx.preAuthorized, ctx.appUser, ctx.appClient); + HttpGet getCredentialOfferURI = new HttpGet(credOfferUriUrl); + getCredentialOfferURI.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token); + CloseableHttpResponse credentialOfferURIResponse = httpClient.execute(getCredentialOfferURI); + int statusCode = credentialOfferURIResponse.getStatusLine().getStatusCode(); + if (HttpStatus.SC_OK != statusCode) { + HttpEntity entity = credentialOfferURIResponse.getEntity(); + throw new IllegalStateException(EntityUtils.toString(entity)); + } + String s = IOUtils.toString(credentialOfferURIResponse.getEntity().getContent(), StandardCharsets.UTF_8); + CredentialOfferURI credentialOfferURI = JsonSerialization.valueFromString(s, CredentialOfferURI.class); + assertTrue(credentialOfferURI.getIssuer().startsWith(ctx.issuerMetadata.getCredentialIssuer())); + assertTrue(Strings.isNotEmpty(credentialOfferURI.getNonce())); + return credentialOfferURI; + } + + private CredentialsOffer getCredentialsOffer(OfferTestContext ctx, String offerUri) throws Exception { + HttpGet getCredentialOffer = new HttpGet(offerUri); + CloseableHttpResponse credentialOfferResponse = httpClient.execute(getCredentialOffer); + int statusCode = credentialOfferResponse.getStatusLine().getStatusCode(); + if (HttpStatus.SC_OK != statusCode) { + HttpEntity entity = credentialOfferResponse.getEntity(); + throw new IllegalStateException(EntityUtils.toString(entity)); + } + String s = IOUtils.toString(credentialOfferResponse.getEntity().getContent(), StandardCharsets.UTF_8); + CredentialsOffer credOffer = JsonSerialization.valueFromString(s, CredentialsOffer.class); + assertEquals(List.of(ctx.supportedCredentialConfiguration.getId()), credOffer.getCredentialConfigurationIds()); + return credOffer; + } + + private AccessTokenResponse getPreAuthorizedAccessTokenResponse(OID4VCICredentialOfferMatrixTest.OfferTestContext ctx, CredentialsOffer credOffer) throws Exception { + PreAuthorizedCode preAuthorizedCode = credOffer.getGrants().getPreAuthorizedCode(); + HttpPost postPreAuthorizedCode = new HttpPost(ctx.authorizationMetadata.getTokenEndpoint()); + List parameters = new LinkedList<>(); + parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, PreAuthorizedCodeGrantTypeFactory.GRANT_TYPE)); + parameters.add(new BasicNameValuePair(PreAuthorizedCodeGrantTypeFactory.CODE_REQUEST_PARAM, preAuthorizedCode.getPreAuthorizedCode())); + UrlEncodedFormEntity formEntity = new UrlEncodedFormEntity(parameters, StandardCharsets.UTF_8); + postPreAuthorizedCode.setEntity(formEntity); + CloseableHttpResponse accessTokenResponse = httpClient.execute(postPreAuthorizedCode); + int statusCode = accessTokenResponse.getStatusLine().getStatusCode(); + if (HttpStatus.SC_OK != statusCode) { + HttpEntity entity = accessTokenResponse.getEntity(); + throw new IllegalStateException(EntityUtils.toString(entity)); + } + return new AccessTokenResponse(accessTokenResponse); + } + + private CredentialResponse getCredentialByAuthDetail(OfferTestContext ctx, String accessToken, AuthorizationDetail authDetail) throws Exception { + @SuppressWarnings("unchecked") + List credIdentifiers = (List) authDetail.getAdditionalFields().get(CREDENTIAL_IDENTIFIERS); + var credentialRequest = new CredentialRequest(); + if (credIdentifiers != null) { + if (credIdentifiers.size() > 1) + throw new IllegalStateException("Multiple credential ids not supported"); + credentialRequest.setCredentialIdentifier(credIdentifiers.get(0)); + } else { + if (authDetail.getCredentialConfigurationId() == null) + throw new IllegalStateException("No credential_configuration_id in: " + JsonSerialization.valueAsString(authDetail)); + credentialRequest.setCredentialConfigurationId(authDetail.getCredentialConfigurationId()); + } + return sendCredentialRequest(ctx, accessToken, credentialRequest); + } + + private CredentialResponse getCredentialByOffer(OfferTestContext ctx, String accessToken, CredentialsOffer credOffer) throws Exception { + List credConfigIds = credOffer.getCredentialConfigurationIds(); + if (credConfigIds.size() > 1) + throw new IllegalStateException("Multiple credential configuration ids not supported in: " + JsonSerialization.valueAsString(credOffer)); + var credentialRequest = new CredentialRequest(); + credentialRequest.setCredentialConfigurationId(credConfigIds.get(0)); + return sendCredentialRequest(ctx, accessToken, credentialRequest); + } + + private CredentialResponse sendCredentialRequest(OfferTestContext ctx, String accessToken, CredentialRequest credentialRequest) throws Exception { + HttpPost postCredential = new HttpPost(ctx.issuerMetadata.getCredentialEndpoint()); + postCredential.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken); + StringEntity stringEntity = new StringEntity(JsonSerialization.valueAsString(credentialRequest), ContentType.APPLICATION_JSON); + postCredential.setEntity(stringEntity); + + CloseableHttpResponse credentialRequestResponse = httpClient.execute(postCredential); + int statusCode = credentialRequestResponse.getStatusLine().getStatusCode(); + if (HttpStatus.SC_OK != statusCode) { + HttpEntity entity = credentialRequestResponse.getEntity(); + throw new IllegalStateException(EntityUtils.toString(entity)); + } + + String s = IOUtils.toString(credentialRequestResponse.getEntity().getContent(), StandardCharsets.UTF_8); + CredentialResponse credentialResponse = JsonSerialization.valueFromString(s, CredentialResponse.class); + + assertNotNull("The credentials array should be present in the response", credentialResponse.getCredentials()); + assertFalse("The credentials array should not be empty", credentialResponse.getCredentials().isEmpty()); + return credentialResponse; + } + + private void verifyCredentialResponse(OfferTestContext ctx, CredentialResponse credResponse) throws Exception { + + String scope = ctx.supportedCredentialConfiguration.getScope(); + CredentialResponse.Credential credentialObj = credResponse.getCredentials().get(0); + assertNotNull("The first credential in the array should not be null", credentialObj); + + String expUsername = ctx.appUser != null ? ctx.appUser : namedUserId; + + JsonWebToken jsonWebToken = TokenVerifier.create((String) credentialObj.getCredential(), JsonWebToken.class).getToken(); + assertEquals("did:web:test.org", jsonWebToken.getIssuer()); + Object vc = jsonWebToken.getOtherClaims().get("vc"); + VerifiableCredential credential = JsonSerialization.mapper.convertValue(vc, VerifiableCredential.class); + assertEquals(List.of(scope), credential.getType()); + assertEquals(URI.create("did:web:test.org"), credential.getIssuer()); + assertEquals(expUsername + "@email.cz", credential.getCredentialSubject().getClaims().get("email")); + } + + private void verifyTokenJwt( + OfferTestContext ctx, + String token, + List includeScopes, + List excludeScopes, + List includeRoles, + List excludeRoles + ) throws Exception { + JsonWebToken jwt = JsonSerialization.readValue(new JWSInput(token).getContent(), JsonWebToken.class); + List wasScopes = Arrays.stream(((String) jwt.getOtherClaims().get("scope")).split("\\s")).toList(); + includeScopes.forEach(it -> assertTrue("Missing scope: " + it, wasScopes.contains(it))); + excludeScopes.forEach(it -> assertFalse("Invalid scope: " + it, wasScopes.contains(it))); + + List allRoles = new ArrayList<>(); + Object realmAccess = jwt.getOtherClaims().get("realm_access"); + if (realmAccess != null) { + @SuppressWarnings("unchecked") + var realmRoles = ((Map>) realmAccess).get("roles"); + allRoles.addAll(realmRoles); + } + Object resourceAccess = jwt.getOtherClaims().get("resource_access"); + if (resourceAccess != null) { + @SuppressWarnings("unchecked") + var resourceAccessMapping = (Map>>) resourceAccess; + resourceAccessMapping.forEach((k, v) -> { + allRoles.addAll(v.get("roles")); + }); + } + includeRoles.forEach(it -> assertTrue("Missing role: " + it, allRoles.contains(it))); + excludeRoles.forEach(it -> assertFalse("Invalid role: " + it, allRoles.contains(it))); + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/mappers/OID4VCTargetRoleMapperTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/mappers/OID4VCTargetRoleMapperTest.java index 1e4c7695542..682b27ed70e 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/mappers/OID4VCTargetRoleMapperTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/mappers/OID4VCTargetRoleMapperTest.java @@ -115,15 +115,12 @@ public class OID4VCTargetRoleMapperTest extends OID4VCTest { return mergedRoles; } ); - } else { - testRealm.getRoles() - .setClient(Map.of(existingClient.getClientId(), - List.of(getRoleRepresentation("testRole", existingClient.getClientId())))); } List realmUsers = Optional.ofNullable(testRealm.getUsers()).map(ArrayList::new) .orElse(new ArrayList<>()); - realmUsers.add(getUserRepresentation(Map.of(existingClient.getClientId(), List.of("testRole"), "newClient", List.of("newRole")))); + realmUsers.add(getUserRepresentation("John Doe", List.of(), + Map.of(clientId, List.of("testRole"), "newClient", List.of("newRole")))); testRealm.setUsers(realmUsers); } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCAuthorizationCodeFlowTestBase.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCAuthorizationCodeFlowTestBase.java index c0e49e769c8..3a2561632d6 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCAuthorizationCodeFlowTestBase.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCAuthorizationCodeFlowTestBase.java @@ -52,6 +52,8 @@ import org.apache.http.entity.StringEntity; import org.apache.http.message.BasicNameValuePair; import org.junit.Test; +import static org.keycloak.OAuth2Constants.OPENID_CREDENTIAL; + import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; @@ -63,8 +65,6 @@ import static org.junit.Assert.assertNotNull; */ public abstract class OID4VCAuthorizationCodeFlowTestBase extends OID4VCIssuerEndpointTest { - public static final String OPENID_CREDENTIAL_TYPE = "openid_credential"; - /** * Test context for OID4VC tests */ @@ -140,7 +140,7 @@ public abstract class OID4VCAuthorizationCodeFlowTestBase extends OID4VCIssuerEn claim.setMandatory(true); AuthorizationDetail authDetail = new AuthorizationDetail(); - authDetail.setType(OPENID_CREDENTIAL_TYPE); + authDetail.setType(OPENID_CREDENTIAL); authDetail.setCredentialConfigurationId(getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID)); authDetail.setClaims(Arrays.asList(claim)); authDetail.setLocations(Collections.singletonList(ctx.credentialIssuer.getCredentialIssuer())); @@ -176,8 +176,8 @@ public abstract class OID4VCAuthorizationCodeFlowTestBase extends OID4VCIssuerEn assertNotNull("Credential identifiers should be present", authDetailResponse.getCredentialIdentifiers()); assertEquals(1, authDetailResponse.getCredentialIdentifiers().size()); - String credentialIdentifier = authDetailResponse.getCredentialIdentifiers().get(0); - assertNotNull("Credential identifier should not be null", credentialIdentifier); + String credentialConfigurationId = authDetailResponse.getCredentialConfigurationId(); + assertNotNull("Credential configuration id should not be null", credentialConfigurationId); // Request the actual credential using the identifier HttpPost postCredential = new HttpPost(ctx.credentialIssuer.getCredentialEndpoint()); @@ -185,7 +185,7 @@ public abstract class OID4VCAuthorizationCodeFlowTestBase extends OID4VCIssuerEn postCredential.addHeader(HttpHeaders.CONTENT_TYPE, "application/json"); CredentialRequest credentialRequest = new CredentialRequest(); - credentialRequest.setCredentialIdentifier(credentialIdentifier); + credentialRequest.setCredentialConfigurationId(credentialConfigurationId); String requestBody = JsonSerialization.writeValueAsString(credentialRequest); postCredential.setEntity(new StringEntity(requestBody, StandardCharsets.UTF_8)); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCAuthorizationCodeFlowWithPARTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCAuthorizationCodeFlowWithPARTest.java index 8c964359d30..ba8b5b93889 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCAuthorizationCodeFlowWithPARTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCAuthorizationCodeFlowWithPARTest.java @@ -53,7 +53,7 @@ import org.apache.http.entity.StringEntity; import org.apache.http.message.BasicNameValuePair; import org.junit.Test; -import static org.keycloak.protocol.oid4vc.issuance.OID4VCAuthorizationDetailsProcessor.OPENID_CREDENTIAL_TYPE; +import static org.keycloak.OAuth2Constants.OPENID_CREDENTIAL; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; @@ -131,7 +131,7 @@ public class OID4VCAuthorizationCodeFlowWithPARTest extends OID4VCIssuerEndpoint claim.setMandatory(true); AuthorizationDetail authDetail = new AuthorizationDetail(); - authDetail.setType(OPENID_CREDENTIAL_TYPE); + authDetail.setType(OPENID_CREDENTIAL); authDetail.setCredentialConfigurationId(credentialConfigurationId); authDetail.setClaims(List.of(claim)); authDetail.setLocations(Collections.singletonList(ctx.credentialIssuer.getCredentialIssuer())); @@ -198,7 +198,7 @@ public class OID4VCAuthorizationCodeFlowWithPARTest extends OID4VCIssuerEndpoint assertEquals("Should have exactly one authorization detail", 1, authDetailsResponse.size()); OID4VCAuthorizationDetailsResponse authDetailResponse = authDetailsResponse.get(0); - assertEquals("Type should be openid_credential", OPENID_CREDENTIAL_TYPE, authDetailResponse.getType()); + assertEquals("Type should be openid_credential", OPENID_CREDENTIAL, authDetailResponse.getType()); assertEquals("Credential configuration ID should match", credentialConfigurationId, authDetailResponse.getCredentialConfigurationId()); // Verify claims are preserved @@ -229,7 +229,7 @@ public class OID4VCAuthorizationCodeFlowWithPARTest extends OID4VCIssuerEndpoint postCredential.addHeader(HttpHeaders.CONTENT_TYPE, "application/json"); CredentialRequest credentialRequest = new CredentialRequest(); - credentialRequest.setCredentialIdentifier(credentialIdentifier); + credentialRequest.setCredentialConfigurationId(credentialConfigurationId); String requestBody = JsonSerialization.writeValueAsString(credentialRequest); postCredential.setEntity(new StringEntity(requestBody, StandardCharsets.UTF_8)); @@ -263,7 +263,7 @@ public class OID4VCAuthorizationCodeFlowWithPARTest extends OID4VCIssuerEndpoint // Step 1: Create PAR request with INVALID authorization_details // Create authorization details with INVALID credential configuration ID AuthorizationDetail authDetail = new AuthorizationDetail(); - authDetail.setType(OPENID_CREDENTIAL_TYPE); + authDetail.setType(OPENID_CREDENTIAL); authDetail.setCredentialConfigurationId("INVALID_CONFIG_ID"); // This should cause failure authDetail.setLocations(Collections.singletonList(ctx.credentialIssuer.getCredentialIssuer())); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCAuthorizationDetailsFlowTestBase.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCAuthorizationDetailsFlowTestBase.java index 0ecc5e2e837..5630394ed49 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCAuthorizationDetailsFlowTestBase.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCAuthorizationDetailsFlowTestBase.java @@ -39,6 +39,7 @@ import org.keycloak.protocol.oid4vc.model.CredentialOfferURI; import org.keycloak.protocol.oid4vc.model.CredentialRequest; import org.keycloak.protocol.oid4vc.model.CredentialResponse; import org.keycloak.protocol.oid4vc.model.CredentialsOffer; +import org.keycloak.protocol.oid4vc.model.PreAuthorizedCode; import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantTypeFactory; import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation; import org.keycloak.representations.idm.ClientScopeRepresentation; @@ -56,7 +57,7 @@ import org.apache.http.entity.StringEntity; import org.apache.http.message.BasicNameValuePair; import org.junit.Test; -import static org.keycloak.protocol.oid4vc.issuance.OID4VCAuthorizationDetailsProcessor.OPENID_CREDENTIAL_TYPE; +import static org.keycloak.OAuth2Constants.OPENID_CREDENTIAL; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; @@ -103,7 +104,7 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue Oid4vcTestContext ctx = new Oid4vcTestContext(); String credentialConfigurationId = getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID); - HttpGet getCredentialOfferURI = new HttpGet(getBasePath(TEST_REALM_NAME) + "credential-offer-uri?credential_configuration_id=" + credentialConfigurationId); + HttpGet getCredentialOfferURI = new HttpGet(getCredentialOfferUriUrl(credentialConfigurationId)); getCredentialOfferURI.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token); CredentialOfferURI credentialOfferURI; @@ -144,7 +145,7 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue Oid4vcTestContext ctx = prepareOid4vcTestContext(token); AuthorizationDetail authDetail = new AuthorizationDetail(); - authDetail.setType(OPENID_CREDENTIAL_TYPE); + authDetail.setType(OPENID_CREDENTIAL); authDetail.setCredentialConfigurationId(getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID)); authDetail.setLocations(Collections.singletonList(ctx.credentialIssuer.getCredentialIssuer())); @@ -166,7 +167,7 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue assertNotNull("authorization_details should be present in the response", authDetailsResponse); assertEquals(1, authDetailsResponse.size()); OID4VCAuthorizationDetailsResponse authDetailResponse = authDetailsResponse.get(0); - assertEquals(OPENID_CREDENTIAL_TYPE, authDetailResponse.getType()); + assertEquals(OPENID_CREDENTIAL, authDetailResponse.getType()); assertEquals(getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID), authDetailResponse.getCredentialConfigurationId()); assertNotNull(authDetailResponse.getCredentialIdentifiers()); assertEquals(1, authDetailResponse.getCredentialIdentifiers().size()); @@ -202,7 +203,7 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue claim.setMandatory(true); AuthorizationDetail authDetail = new AuthorizationDetail(); - authDetail.setType(OPENID_CREDENTIAL_TYPE); + authDetail.setType(OPENID_CREDENTIAL); authDetail.setCredentialConfigurationId(getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID)); authDetail.setClaims(Arrays.asList(claim)); authDetail.setLocations(Collections.singletonList(ctx.credentialIssuer.getCredentialIssuer())); @@ -225,7 +226,7 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue assertNotNull("authorization_details should be present in the response", authDetailsResponse); assertEquals(1, authDetailsResponse.size()); OID4VCAuthorizationDetailsResponse authDetailResponse = authDetailsResponse.get(0); - assertEquals(OPENID_CREDENTIAL_TYPE, authDetailResponse.getType()); + assertEquals(OPENID_CREDENTIAL, authDetailResponse.getType()); assertEquals(getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID), authDetailResponse.getCredentialConfigurationId()); assertNotNull(authDetailResponse.getClaims()); assertEquals(1, authDetailResponse.getClaims().size()); @@ -257,7 +258,7 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue claim.setMandatory(false); AuthorizationDetail authDetail = new AuthorizationDetail(); - authDetail.setType(OPENID_CREDENTIAL_TYPE); + authDetail.setType(OPENID_CREDENTIAL); authDetail.setCredentialConfigurationId(getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID)); authDetail.setClaims(Arrays.asList(claim)); authDetail.setLocations(Collections.singletonList(ctx.credentialIssuer.getCredentialIssuer())); @@ -293,7 +294,7 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue claim.setMandatory(true); AuthorizationDetail authDetail = new AuthorizationDetail(); - authDetail.setType(OPENID_CREDENTIAL_TYPE); + authDetail.setType(OPENID_CREDENTIAL); authDetail.setCredentialConfigurationId(getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID)); authDetail.setClaims(Arrays.asList(claim)); authDetail.setLocations(Collections.singletonList(ctx.credentialIssuer.getCredentialIssuer())); @@ -329,7 +330,7 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue claim.setMandatory(false); AuthorizationDetail authDetail = new AuthorizationDetail(); - authDetail.setType(OPENID_CREDENTIAL_TYPE); + authDetail.setType(OPENID_CREDENTIAL); authDetail.setCredentialConfigurationId(getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID)); authDetail.setClaims(Arrays.asList(claim)); authDetail.setLocations(Collections.singletonList(ctx.credentialIssuer.getCredentialIssuer())); @@ -369,7 +370,7 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue Oid4vcTestContext ctx = prepareOid4vcTestContext(token); AuthorizationDetail authDetail = new AuthorizationDetail(); - authDetail.setType(OPENID_CREDENTIAL_TYPE); + authDetail.setType(OPENID_CREDENTIAL); // Missing credential_configuration_id - should fail authDetail.setLocations(Collections.singletonList(ctx.credentialIssuer.getCredentialIssuer())); @@ -403,7 +404,7 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue claim.setMandatory(false); AuthorizationDetail authDetail = new AuthorizationDetail(); - authDetail.setType(OPENID_CREDENTIAL_TYPE); + authDetail.setType(OPENID_CREDENTIAL); authDetail.setCredentialConfigurationId(getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID)); authDetail.setClaims(Arrays.asList(claim)); authDetail.setLocations(Collections.singletonList(ctx.credentialIssuer.getCredentialIssuer())); @@ -481,7 +482,7 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue String expectedConfigId = ctx.credentialsOffer.getCredentialConfigurationIds().get(i); OID4VCAuthorizationDetailsResponse authDetailResponse = authDetailsResponse.get(i); - assertEquals(OPENID_CREDENTIAL_TYPE, authDetailResponse.getType()); + assertEquals(OPENID_CREDENTIAL, authDetailResponse.getType()); assertEquals("Credential configuration ID should match the one from the offer", expectedConfigId, authDetailResponse.getCredentialConfigurationId()); assertNotNull("Credential identifiers should be present", authDetailResponse.getCredentialIdentifiers()); @@ -502,18 +503,22 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue @Test public void testCompleteFlowWithCredentialOfferBasedAuthorizationDetails() throws Exception { String token = getBearerToken(oauth, client, getCredentialClientScope().getName()); + Oid4vcTestContext ctx = prepareOid4vcTestContext(token); + PreAuthorizedCode preAuthorizedCode = ctx.credentialsOffer.getGrants().getPreAuthorizedCode(); // Step 1: Request token without authorization_details parameter (no scope needed) HttpPost postPreAuthorizedCode = new HttpPost(ctx.openidConfig.getTokenEndpoint()); List parameters = new LinkedList<>(); parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, PreAuthorizedCodeGrantTypeFactory.GRANT_TYPE)); - parameters.add(new BasicNameValuePair(PreAuthorizedCodeGrantTypeFactory.CODE_REQUEST_PARAM, ctx.credentialsOffer.getGrants().getPreAuthorizedCode().getPreAuthorizedCode())); + parameters.add(new BasicNameValuePair(PreAuthorizedCodeGrantTypeFactory.CODE_REQUEST_PARAM, preAuthorizedCode.getPreAuthorizedCode())); UrlEncodedFormEntity formEntity = new UrlEncodedFormEntity(parameters, StandardCharsets.UTF_8); postPreAuthorizedCode.setEntity(formEntity); - String credentialIdentifier = null; + String credentialIdentifier; + String credentialConfigurationId; + OID4VCAuthorizationDetailsResponse authDetailResponse; try (CloseableHttpResponse tokenResponse = httpClient.execute(postPreAuthorizedCode)) { assertEquals(HttpStatus.SC_OK, tokenResponse.getStatusLine().getStatusCode()); String responseBody = IOUtils.toString(tokenResponse.getEntity().getContent(), StandardCharsets.UTF_8); @@ -524,46 +529,94 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue ctx.credentialsOffer.getCredentialConfigurationIds().size(), authDetailsResponse.size()); // Use the first authorization detail for credential request - OID4VCAuthorizationDetailsResponse authDetailResponse = authDetailsResponse.get(0); + authDetailResponse = authDetailsResponse.get(0); assertNotNull("Credential identifiers should be present", authDetailResponse.getCredentialIdentifiers()); assertEquals(1, authDetailResponse.getCredentialIdentifiers().size()); credentialIdentifier = authDetailResponse.getCredentialIdentifiers().get(0); assertNotNull("Credential identifier should not be null", credentialIdentifier); + + credentialConfigurationId = authDetailResponse.getCredentialConfigurationId(); + assertNotNull("Credential configuration id should not be null", credentialConfigurationId); } // Step 2: Request the actual credential using ONLY the identifier (no credential_configuration_id) - // This tests that the mapping from credential identifier to credential configuration ID works correctly - HttpPost postCredential = new HttpPost(ctx.credentialIssuer.getCredentialEndpoint()); - postCredential.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token); - postCredential.addHeader(HttpHeaders.CONTENT_TYPE, "application/json"); + // This tests that the mapping from credential identifier to credential configuration ID works as expected. + // + // The Pre-Authorized code flow is treated as a separate authentication event. + // Even if the underlying user and client match an existing session. + // A new user session is created because: + // * The pre-auth code is defined as a standalone authentication mechanism. + // * It does not assume the caller already has an authenticated session. + // * It must guarantee isolation of state tied to the VC issuance flow. + { + HttpPost postCredential = new HttpPost(ctx.credentialIssuer.getCredentialEndpoint()); + postCredential.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token); + postCredential.addHeader(HttpHeaders.CONTENT_TYPE, "application/json"); - CredentialRequest credentialRequest = new CredentialRequest(); - credentialRequest.setCredentialIdentifier(credentialIdentifier); + CredentialRequest credentialRequest = new CredentialRequest(); + credentialRequest.setCredentialIdentifier(credentialIdentifier); - String requestBody = JsonSerialization.writeValueAsString(credentialRequest); - postCredential.setEntity(new StringEntity(requestBody, StandardCharsets.UTF_8)); + String requestBody = JsonSerialization.writeValueAsString(credentialRequest); + postCredential.setEntity(new StringEntity(requestBody, StandardCharsets.UTF_8)); - try (CloseableHttpResponse credentialResponse = httpClient.execute(postCredential)) { - assertEquals(HttpStatus.SC_OK, credentialResponse.getStatusLine().getStatusCode()); - String responseBody = IOUtils.toString(credentialResponse.getEntity().getContent(), StandardCharsets.UTF_8); + try (CloseableHttpResponse credentialResponse = httpClient.execute(postCredential)) { + assertEquals(HttpStatus.SC_OK, credentialResponse.getStatusLine().getStatusCode()); + String responseBody = IOUtils.toString(credentialResponse.getEntity().getContent(), StandardCharsets.UTF_8); - // Parse the credential response - CredentialResponse parsedResponse = JsonSerialization.readValue(responseBody, CredentialResponse.class); - assertNotNull("Credential response should not be null", parsedResponse); - assertNotNull("Credentials should be present", parsedResponse.getCredentials()); - assertEquals("Should have exactly one credential", 1, parsedResponse.getCredentials().size()); + // Parse the credential response + CredentialResponse parsedResponse = JsonSerialization.readValue(responseBody, CredentialResponse.class); + assertNotNull("Credential response should not be null", parsedResponse); + assertNotNull("Credentials should be present", parsedResponse.getCredentials()); + assertEquals("Should have exactly one credential", 1, parsedResponse.getCredentials().size()); - // Step 3: Verify that the issued credential structure is valid - CredentialResponse.Credential credentialWrapper = parsedResponse.getCredentials().get(0); - assertNotNull("Credential wrapper should not be null", credentialWrapper); + // Step 3: Verify that the issued credential structure is valid + CredentialResponse.Credential credentialWrapper = parsedResponse.getCredentials().get(0); + assertNotNull("Credential wrapper should not be null", credentialWrapper); - // The credential is stored as Object, so we need to cast it - Object credentialObj = credentialWrapper.getCredential(); - assertNotNull("Credential object should not be null", credentialObj); + // The credential is stored as Object, so we need to cast it + Object credentialObj = credentialWrapper.getCredential(); + assertNotNull("Credential object should not be null", credentialObj); - // Verify the credential structure based on format - verifyCredentialStructure(credentialObj); + // Verify the credential structure based on format + verifyCredentialStructure(credentialObj); + } + } + + // Step 3: Request a credential using the credentialConfigurationId + // + { + HttpPost postCredential = new HttpPost(ctx.credentialIssuer.getCredentialEndpoint()); + postCredential.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token); + postCredential.addHeader(HttpHeaders.CONTENT_TYPE, "application/json"); + + CredentialRequest credentialRequest = new CredentialRequest(); + credentialRequest.setCredentialConfigurationId(credentialConfigurationId); + + String requestBody = JsonSerialization.writeValueAsString(credentialRequest); + postCredential.setEntity(new StringEntity(requestBody, StandardCharsets.UTF_8)); + + try (CloseableHttpResponse credentialResponse = httpClient.execute(postCredential)) { + assertEquals(HttpStatus.SC_OK, credentialResponse.getStatusLine().getStatusCode()); + String responseBody = IOUtils.toString(credentialResponse.getEntity().getContent(), StandardCharsets.UTF_8); + + // Parse the credential response + CredentialResponse parsedResponse = JsonSerialization.readValue(responseBody, CredentialResponse.class); + assertNotNull("Credential response should not be null", parsedResponse); + assertNotNull("Credentials should be present", parsedResponse.getCredentials()); + assertEquals("Should have exactly one credential", 1, parsedResponse.getCredentials().size()); + + // Step 3: Verify that the issued credential structure is valid + CredentialResponse.Credential credentialWrapper = parsedResponse.getCredentials().get(0); + assertNotNull("Credential wrapper should not be null", credentialWrapper); + + // The credential is stored as Object, so we need to cast it + Object credentialObj = credentialWrapper.getCredential(); + assertNotNull("Credential object should not be null", credentialObj); + + // Verify the credential structure based on format + verifyCredentialStructure(credentialObj); + } } } @@ -603,7 +656,7 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue String expectedConfigId = ctx.credentialsOffer.getCredentialConfigurationIds().get(i); // Verify structure - assertEquals("Type should be openid_credential", OPENID_CREDENTIAL_TYPE, authDetail.getType()); + assertEquals("Type should be openid_credential", OPENID_CREDENTIAL, authDetail.getType()); assertEquals("Credential configuration ID should match the one from the offer", expectedConfigId, authDetail.getCredentialConfigurationId()); assertNotNull("Credential identifiers should be present", authDetail.getCredentialIdentifiers()); @@ -633,6 +686,7 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue @Test public void testCompleteFlowWithClaimsValidation() throws Exception { String token = getBearerToken(oauth, client, getCredentialClientScope().getName()); + String credConfigId = getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID); Oid4vcTestContext ctx = prepareOid4vcTestContext(token); // Step 1: Request token with authorization details containing specific claims @@ -650,13 +704,13 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue claim.setMandatory(true); AuthorizationDetail authDetail = new AuthorizationDetail(); - authDetail.setType(OPENID_CREDENTIAL_TYPE); - authDetail.setCredentialConfigurationId(getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID)); - authDetail.setClaims(Arrays.asList(claim)); + authDetail.setType(OPENID_CREDENTIAL); + authDetail.setCredentialConfigurationId(credConfigId); authDetail.setLocations(Collections.singletonList(ctx.credentialIssuer.getCredentialIssuer())); + authDetail.setClaims(List.of(claim)); List authDetails = List.of(authDetail); - String authDetailsJson = JsonSerialization.writeValueAsString(authDetails); + String authDetailsJson = JsonSerialization.valueAsString(authDetails); HttpPost postPreAuthorizedCode = new HttpPost(ctx.openidConfig.getTokenEndpoint()); List parameters = new LinkedList<>(); @@ -667,6 +721,8 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue postPreAuthorizedCode.setEntity(formEntity); String credentialIdentifier; + String credentialConfigurationId; + OID4VCAuthorizationDetailsResponse authDetailResponse; try (CloseableHttpResponse tokenResponse = httpClient.execute(postPreAuthorizedCode)) { assertEquals(HttpStatus.SC_OK, tokenResponse.getStatusLine().getStatusCode()); String responseBody = IOUtils.toString(tokenResponse.getEntity().getContent(), StandardCharsets.UTF_8); @@ -674,15 +730,18 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue assertNotNull("authorization_details should be present in the response", authDetailsResponse); assertEquals(1, authDetailsResponse.size()); - OID4VCAuthorizationDetailsResponse authDetailResponse = authDetailsResponse.get(0); + authDetailResponse = authDetailsResponse.get(0); assertNotNull("Credential identifiers should be present", authDetailResponse.getCredentialIdentifiers()); assertEquals(1, authDetailResponse.getCredentialIdentifiers().size()); credentialIdentifier = authDetailResponse.getCredentialIdentifiers().get(0); assertNotNull("Credential identifier should not be null", credentialIdentifier); + + credentialConfigurationId = authDetailResponse.getCredentialConfigurationId(); + assertNotNull("Credential configuration id should not be null", credentialConfigurationId); } - // Step 2: Request the actual credential using the identifier + // Step 2: Request the actual credential using the identifier and config id HttpPost postCredential = new HttpPost(ctx.credentialIssuer.getCredentialEndpoint()); postCredential.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token); postCredential.addHeader(HttpHeaders.CONTENT_TYPE, "application/json"); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCAuthorizationDetailsTypesSupportedTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCAuthorizationDetailsTypesSupportedTest.java index 73f8d45642e..1de38c362cb 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCAuthorizationDetailsTypesSupportedTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCAuthorizationDetailsTypesSupportedTest.java @@ -23,7 +23,6 @@ import jakarta.ws.rs.client.Client; import jakarta.ws.rs.core.Response; import org.keycloak.common.Profile; -import org.keycloak.protocol.oid4vc.issuance.OID4VCAuthorizationDetailsProcessor; import org.keycloak.protocol.oid4vc.model.CredentialIssuer; import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation; import org.keycloak.representations.idm.RealmRepresentation; @@ -34,7 +33,7 @@ import org.keycloak.testsuite.util.oauth.OAuthClient; import org.junit.Before; import org.junit.Test; -import static org.keycloak.protocol.oid4vc.issuance.OID4VCAuthorizationDetailsProcessor.OPENID_CREDENTIAL_TYPE; +import static org.keycloak.OAuth2Constants.OPENID_CREDENTIAL; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; @@ -74,7 +73,7 @@ public class OID4VCAuthorizationDetailsTypesSupportedTest extends OID4VCIssuerEn // Verify that it contains openid_credential List supportedTypes = oauthConfig.getAuthorizationDetailsTypesSupported(); assertTrue("authorization_details_types_supported should contain openid_credential", - supportedTypes.contains(OID4VCAuthorizationDetailsProcessor.OPENID_CREDENTIAL_TYPE)); + supportedTypes.contains(OPENID_CREDENTIAL)); } } @@ -94,7 +93,7 @@ public class OID4VCAuthorizationDetailsTypesSupportedTest extends OID4VCIssuerEn assertNotNull("Authorization server should support authorization_details_types_supported", authServerConfig.getAuthorizationDetailsTypesSupported()); assertTrue("Authorization server should support openid_credential", - authServerConfig.getAuthorizationDetailsTypesSupported().contains(OPENID_CREDENTIAL_TYPE)); + authServerConfig.getAuthorizationDetailsTypesSupported().contains(OPENID_CREDENTIAL)); } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCCredentialOfferCorsTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCCredentialOfferCorsTest.java index 23f754485e1..3170eeb0d2c 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCCredentialOfferCorsTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCCredentialOfferCorsTest.java @@ -255,11 +255,7 @@ public class OID4VCCredentialOfferCorsTest extends OID4VCIssuerEndpointTest { } private String getCredentialOfferUriUrl() { - return getBasePath("test") + "credential-offer-uri?credential_configuration_id=" + jwtTypeCredentialConfigurationIdName; - } - - private String getCredentialOfferUrl(String sessionCode) { - return getBasePath("test") + "credential-offer/" + sessionCode; + return getCredentialOfferUriUrl(jwtTypeCredentialConfigurationIdName); } private String getSessionCodeFromOfferUri(String accessToken) throws Exception { diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerEndpointEncryptionTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerEndpointEncryptionTest.java index 4fad9572388..84e8d71dbbd 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerEndpointEncryptionTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerEndpointEncryptionTest.java @@ -38,6 +38,7 @@ import org.keycloak.jose.jwk.JWK; import org.keycloak.jose.jwk.JWKParser; import org.keycloak.models.KeyManager; import org.keycloak.models.RealmModel; +import org.keycloak.models.oid4vci.CredentialScopeModel; import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint; import org.keycloak.protocol.oid4vc.model.CredentialRequest; import org.keycloak.protocol.oid4vc.model.CredentialResponse; @@ -75,6 +76,7 @@ public class OID4VCIssuerEndpointEncryptionTest extends OID4VCIssuerEndpointTest @Test public void testRequestCredentialWithEncryption() { final String scopeName = jwtTypeCredentialClientScope.getName(); + String credConfigId = jwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID); String token = getBearerToken(oauth, client, scopeName); testingClient .server(TEST_REALM_NAME) @@ -93,7 +95,7 @@ public class OID4VCIssuerEndpointEncryptionTest extends OID4VCIssuerEndpointTest PrivateKey privateKey = (PrivateKey) jwkPair.get("privateKey"); CredentialRequest credentialRequest = new CredentialRequest() - .setCredentialIdentifier(scopeName) + .setCredentialConfigurationId(credConfigId) .setCredentialResponseEncryption( new CredentialResponseEncryption() .setEnc(A256GCM) @@ -169,6 +171,7 @@ public class OID4VCIssuerEndpointEncryptionTest extends OID4VCIssuerEndpointTest @Test public void testEncryptedCredentialRequest() { final String scopeName = jwtTypeCredentialClientScope.getName(); + String credConfigId = jwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID); String token = getBearerToken(oauth, client, scopeName); testingClient.server(TEST_REALM_NAME).run(session -> { try { @@ -194,7 +197,7 @@ public class OID4VCIssuerEndpointEncryptionTest extends OID4VCIssuerEndpointTest PrivateKey responsePrivateKey = (PrivateKey) jwkPair.get("privateKey"); CredentialRequest credentialRequest = new CredentialRequest() - .setCredentialIdentifier(scopeName) + .setCredentialConfigurationId(credConfigId) .setCredentialResponseEncryption( new CredentialResponseEncryption() .setEnc(A256GCM) @@ -234,6 +237,7 @@ public class OID4VCIssuerEndpointEncryptionTest extends OID4VCIssuerEndpointTest @Test public void testEncryptedCredentialRequestWithCompression() { final String scopeName = jwtTypeCredentialClientScope.getName(); + String credConfigId = jwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID); String token = getBearerToken(oauth, client, scopeName); testingClient.server(TEST_REALM_NAME).run(session -> { try { @@ -262,7 +266,7 @@ public class OID4VCIssuerEndpointEncryptionTest extends OID4VCIssuerEndpointTest // Create credential request with response encryption parameters CredentialRequest credentialRequest = new CredentialRequest() - .setCredentialIdentifier(scopeName) + .setCredentialConfigurationId(credConfigId) .setCredentialResponseEncryption( new CredentialResponseEncryption() .setEnc(A256GCM) @@ -474,6 +478,7 @@ public class OID4VCIssuerEndpointEncryptionTest extends OID4VCIssuerEndpointTest @Test public void testRequestCredentialWithInvalidJWK() throws Throwable { final String scopeName = jwtTypeCredentialClientScope.getName(); + String credConfigId = jwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID); String token = getBearerToken(oauth, client, scopeName); testingClient.server(TEST_REALM_NAME).run(session -> { AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); @@ -483,7 +488,7 @@ public class OID4VCIssuerEndpointEncryptionTest extends OID4VCIssuerEndpointTest // Invalid JWK (missing modulus but WITH alg parameter) JWK jwk = JWKParser.create().parse("{\"kty\":\"RSA\",\"alg\":\"RSA-OAEP-256\",\"e\":\"AQAB\"}").getJwk(); CredentialRequest credentialRequest = new CredentialRequest() - .setCredentialIdentifier(scopeName) + .setCredentialConfigurationId(credConfigId) .setCredentialResponseEncryption( new CredentialResponseEncryption() .setEnc("A256GCM") diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerEndpointTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerEndpointTest.java index e70365ab2fc..b3ce0c0749a 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerEndpointTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerEndpointTest.java @@ -35,6 +35,8 @@ import java.util.Optional; import java.util.function.BiConsumer; import java.util.function.BiFunction; import java.util.function.Consumer; +import java.util.function.Function; +import java.util.stream.Collectors; import java.util.zip.Deflater; import java.util.zip.DeflaterOutputStream; @@ -54,7 +56,7 @@ import org.keycloak.common.util.Base64Url; import org.keycloak.common.util.MultivaluedHashMap; import org.keycloak.common.util.SecretGenerator; import org.keycloak.common.util.Time; -import org.keycloak.constants.Oid4VciConstants; +import org.keycloak.constants.OID4VCIConstants; import org.keycloak.crypto.KeyWrapper; import org.keycloak.jose.jwe.JWE; import org.keycloak.jose.jwe.JWEException; @@ -79,6 +81,7 @@ import org.keycloak.protocol.oid4vc.model.DisplayObject; import org.keycloak.protocol.oid4vc.model.Format; import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration; import org.keycloak.protocol.oid4vc.model.VerifiableCredential; +import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation; import org.keycloak.protocol.oidc.utils.OAuth2Code; import org.keycloak.protocol.oidc.utils.OAuth2CodeParser; import org.keycloak.representations.JsonWebToken; @@ -88,6 +91,7 @@ import org.keycloak.representations.idm.ComponentExportRepresentation; import org.keycloak.representations.idm.ProtocolMapperRepresentation; import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.representations.idm.RoleRepresentation; +import org.keycloak.representations.idm.RolesRepresentation; import org.keycloak.representations.idm.UserRepresentation; import org.keycloak.services.managers.AppAuthManager; import org.keycloak.services.managers.AuthenticationManager; @@ -110,12 +114,14 @@ import org.apache.http.impl.client.HttpClientBuilder; import org.jboss.logging.Logger; import org.junit.Before; +import static org.keycloak.constants.OID4VCIConstants.CREDENTIAL_OFFER_CREATE; import static org.keycloak.jose.jwe.JWEConstants.A256GCM; import static org.keycloak.jose.jwe.JWEConstants.RSA_OAEP; import static org.keycloak.jose.jwe.JWEConstants.RSA_OAEP_256; import static org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint.CREDENTIAL_OFFER_URI_CODE_SCOPE; import static org.keycloak.protocol.oid4vc.model.ProofType.JWT; import static org.keycloak.testsuite.forms.PassThroughClientAuthenticator.clientId; +import static org.keycloak.testsuite.forms.PassThroughClientAuthenticator.namedClientId; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; @@ -139,33 +145,31 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest { protected static ClientScopeRepresentation minimalJwtTypeCredentialClientScope; protected CloseableHttpClient httpClient; + protected ClientRepresentation client; + protected ClientRepresentation namedClient; + + record OAuth2CodeEntry(String key, OAuth2Code code) {} protected boolean shouldEnableOid4vci() { return true; } - protected static String prepareSessionCode(KeycloakSession session, AppAuthManager.BearerTokenAuthenticator authenticator, String note) { + protected static OAuth2CodeEntry prepareSessionCode(KeycloakSession session, AppAuthManager.BearerTokenAuthenticator authenticator, String note) { AuthenticationManager.AuthResult authResult = authenticator.authenticate(); UserSessionModel userSessionModel = authResult.session(); AuthenticatedClientSessionModel authenticatedClientSessionModel = userSessionModel.getAuthenticatedClientSessionByClient( authResult.client().getId()); - String codeId = SecretGenerator.getInstance().randomString(); - String nonce = SecretGenerator.getInstance().randomString(); - OAuth2Code oAuth2Code = new OAuth2Code(codeId, + OAuth2Code oauth2Code = new OAuth2Code( + SecretGenerator.getInstance().randomString(), Time.currentTime() + 6000, - nonce, + SecretGenerator.getInstance().randomString(), CREDENTIAL_OFFER_URI_CODE_SCOPE, - null, - null, - null, - null, authenticatedClientSessionModel.getUserSession().getId()); - String oauthCode = OAuth2CodeParser.persistCode(session, authenticatedClientSessionModel, oAuth2Code); - - authenticatedClientSessionModel.setNote(oauthCode, note); - return oauthCode; + String nonce = OAuth2CodeParser.persistCode(session, authenticatedClientSessionModel, oauth2Code); + authenticatedClientSessionModel.setNote(nonce, note); + return new OAuth2CodeEntry(nonce, oauth2Code); } protected static OID4VCIssuerEndpoint prepareIssuerEndpoint(KeycloakSession session, @@ -201,6 +205,7 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest { CryptoIntegration.init(this.getClass().getClassLoader()); httpClient = HttpClientBuilder.create().build(); client = testRealm().clients().findByClientId(clientId).get(0); + namedClient = testRealm().clients().findByClientId(namedClientId).get(0); // Lookup the pre-installed oid4vc_natural_person client scope sdJwtTypeNaturalPersonClientScope = requireExistingClientScope(sdJwtTypeNaturalPersonScopeName); @@ -228,13 +233,23 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest { null, null); - // Assign the registered optional client scopes to the client - assignOptionalClientScopeToClient(sdJwtTypeCredentialClientScope.getId(), client.getClientId()); - assignOptionalClientScopeToClient(jwtTypeCredentialClientScope.getId(), client.getClientId()); - assignOptionalClientScopeToClient(minimalJwtTypeCredentialClientScope.getId(), client.getClientId()); + List.of(client, namedClient).forEach(client -> { + String clientId = client.getClientId(); - // Enable OID4VCI for the client by default, but allow tests to override - setClientOid4vciEnabled(clientId, shouldEnableOid4vci()); + // Assign the registered optional client scopes to the client + assignOptionalClientScopeToClient(sdJwtTypeNaturalPersonClientScope.getId(), clientId); + assignOptionalClientScopeToClient(sdJwtTypeCredentialClientScope.getId(), clientId); + assignOptionalClientScopeToClient(jwtTypeCredentialClientScope.getId(), clientId); + assignOptionalClientScopeToClient(minimalJwtTypeCredentialClientScope.getId(), clientId); + + assignOptionalClientScopeToClient(sdJwtTypeNaturalPersonClientScope.getId(), clientId); + assignOptionalClientScopeToClient(sdJwtTypeCredentialClientScope.getId(), clientId); + assignOptionalClientScopeToClient(jwtTypeCredentialClientScope.getId(), clientId); + assignOptionalClientScopeToClient(minimalJwtTypeCredentialClientScope.getId(), clientId); + + // Enable OID4VCI for the client by default, but allow tests to override + setClientOid4vciEnabled(clientId, shouldEnableOid4vci()); + }); } private ClientResource findClientByClientId(RealmResource realm, String clientId) { @@ -264,7 +279,7 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest { // Create a new ClientScope if not found ClientScopeRepresentation clientScope = new ClientScopeRepresentation(); clientScope.setName(scopeName); - clientScope.setProtocol(Oid4VciConstants.OID4VC_PROTOCOL); + clientScope.setProtocol(OID4VCIConstants.OID4VC_PROTOCOL); Map attributes = new HashMap<>(Map.of(ClientScopeModel.INCLUDE_IN_TOKEN_SCOPE, "true", CredentialScopeModel.EXPIRY_IN_SECONDS, "15")); @@ -531,6 +546,29 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest { return contextRoot + "/auth/.well-known/openid-credential-issuer/realms/" + realm; } + protected String getCredentialOfferUriUrl(String configId) { + return getCredentialOfferUriUrl(configId, true, "john"); + } + + protected String getCredentialOfferUriUrl(String configId, boolean preAuthorized, String targetUser) { + return getCredentialOfferUriUrl(configId, preAuthorized, targetUser, null); + } + + protected String getCredentialOfferUriUrl(String configId, Boolean preAuthorized, String appUserId, String appClientId) { + String res = getBasePath("test") + "credential-offer-uri?credential_configuration_id=" + configId; + if (preAuthorized != null) + res += "&pre_authorized=" + preAuthorized; + if (appClientId != null) + res += "&client_id=" + appClientId; + if (appUserId != null) + res += "&user_id=" + appUserId; + return res; + } + + protected String getCredentialOfferUrl(String nonce) { + return getBasePath("test") + "credential-offer/" + nonce; + } + protected void requestCredential(String token, String credentialEndpoint, SupportedCredentialConfiguration offeredCredential, @@ -569,6 +607,24 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest { } } + public OIDCConfigurationRepresentation getAuthorizationMetadata(String authServerUrl) { + HttpGet getOpenidConfiguration = new HttpGet(authServerUrl + "/.well-known/openid-configuration"); + try (CloseableHttpResponse response = httpClient.execute(getOpenidConfiguration)) { + assertEquals(HttpStatus.SC_OK, response.getStatusLine().getStatusCode()); + String s = IOUtils.toString(response.getEntity().getContent(), StandardCharsets.UTF_8); + return JsonSerialization.readValue(s, OIDCConfigurationRepresentation.class); + } catch (Exception ex) { + throw new RuntimeException(ex); + } + } + + public SupportedCredentialConfiguration getSupportedCredentialConfigurationByScope(CredentialIssuer metadata, String scope) { + SupportedCredentialConfiguration result = metadata.getCredentialsSupported().values().stream() + .filter(it -> it.getScope().equals(scope)) + .findFirst().orElse(null); + return result; + } + @Override public void configureTestRealm(RealmRepresentation testRealm) { if (testRealm.getComponents() == null) { @@ -583,15 +639,15 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest { getRsaEncKeyProvider(RSA_OAEP, "enc-key-oaep", 101)); // Find existing client representation - ClientRepresentation existingClient = testRealm.getClients().stream() - .filter(client -> client.getClientId().equals(clientId)) - .findFirst() + Map realmClients = testRealm.getClients().stream() + .collect(Collectors.toMap(ClientRepresentation::getClientId, Function.identity())); + ClientRepresentation existingClient = Optional.ofNullable(realmClients.get(clientId)) .orElseThrow(() -> new IllegalStateException("Client with ID " + clientId + " not found in realm")); // Add a role to an existing client - if (testRealm.getRoles() != null) { - Map> clientRoles = testRealm.getRoles().getClient(); - clientRoles.merge( + RolesRepresentation realmRoles = testRealm.getRoles(); + if (realmRoles != null) { + realmRoles.getClient().merge( existingClient.getClientId(), List.of(getRoleRepresentation("testRole", existingClient.getClientId())), (existingRoles, newRoles) -> { @@ -600,15 +656,12 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest { return mergedRoles; } ); - } else { - testRealm.getRoles() - .setClient(Map.of(existingClient.getClientId(), - List.of(getRoleRepresentation("testRole", existingClient.getClientId())))); } - List realmUsers = Optional.ofNullable(testRealm.getUsers()).map(ArrayList::new) - .orElse(new ArrayList<>()); - realmUsers.add(getUserRepresentation(Map.of(existingClient.getClientId(), List.of("testRole")))); + Map> clientRoles = Map.of(clientId, List.of("testRole")); + List realmUsers = Optional.ofNullable(testRealm.getUsers()).map(ArrayList::new).orElse(new ArrayList<>()); + realmUsers.add(getUserRepresentation("John Doe", List.of(CREDENTIAL_OFFER_CREATE.getName()), clientRoles)); + realmUsers.add(getUserRepresentation("Alice Wonderland", List.of(), Map.of())); testRealm.setUsers(realmUsers); } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerWellKnownProviderTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerWellKnownProviderTest.java index bab0b2f1f0b..c37daeebbc5 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerWellKnownProviderTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerWellKnownProviderTest.java @@ -61,7 +61,6 @@ import org.keycloak.protocol.oid4vc.model.DisplayObject; import org.keycloak.protocol.oid4vc.model.Format; import org.keycloak.protocol.oid4vc.model.ProofTypesSupported; import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration; -import org.keycloak.representations.idm.ClientRepresentation; import org.keycloak.representations.idm.ClientScopeRepresentation; import org.keycloak.representations.idm.ProtocolMapperRepresentation; import org.keycloak.representations.idm.RealmRepresentation; @@ -88,7 +87,7 @@ import org.hamcrest.Matchers; import org.junit.Test; import static org.keycloak.OID4VCConstants.SIGNED_METADATA_JWT_TYPE; -import static org.keycloak.constants.Oid4VciConstants.BATCH_CREDENTIAL_ISSUANCE_BATCH_SIZE; +import static org.keycloak.constants.OID4VCIConstants.BATCH_CREDENTIAL_ISSUANCE_BATCH_SIZE; import static org.keycloak.jose.jwe.JWEConstants.A256GCM; import static org.keycloak.jose.jwe.JWEConstants.RSA_OAEP; import static org.keycloak.jose.jwe.JWEConstants.RSA_OAEP_256; @@ -783,31 +782,4 @@ public class OID4VCIssuerWellKnownProviderTest extends OID4VCIssuerEndpointTest } }); } - - public static void extendConfigureTestRealm(RealmRepresentation testRealm, ClientRepresentation clientRepresentation) { - if (testRealm.getComponents() == null) { - testRealm.setComponents(new MultivaluedHashMap<>()); - } - - testRealm.getComponents().add("org.keycloak.keys.KeyProvider", getRsaKeyProvider(RSA_KEY)); - testRealm.getComponents().add("org.keycloak.protocol.oid4vc.issuance.credentialbuilder.CredentialBuilder", getCredentialBuilderProvider(Format.JWT_VC)); - - if (testRealm.getClients() != null) { - testRealm.getClients().add(clientRepresentation); - } else { - testRealm.setClients(new ArrayList<>(List.of(clientRepresentation))); - } - - if (testRealm.getUsers() != null) { - testRealm.getUsers().add(OID4VCTest.getUserRepresentation(Map.of(clientRepresentation.getClientId(), List.of("testRole")))); - } else { - testRealm.setUsers(new ArrayList<>(List.of(OID4VCTest.getUserRepresentation(Map.of(clientRepresentation.getClientId(), List.of("testRole")))))); - } - - if (testRealm.getAttributes() != null) { - testRealm.getAttributes().put("issuerDid", TEST_DID.toString()); - } else { - testRealm.setAttributes(new HashMap<>(Map.of("issuerDid", TEST_DID.toString()))); - } - } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCJWTIssuerEndpointDisabledTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCJWTIssuerEndpointDisabledTest.java index 075ddf7f96e..d3e32af72c4 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCJWTIssuerEndpointDisabledTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCJWTIssuerEndpointDisabledTest.java @@ -6,7 +6,6 @@ import jakarta.ws.rs.core.Response; import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint; import org.keycloak.protocol.oid4vc.model.CredentialRequest; -import org.keycloak.protocol.oid4vc.model.OfferUriType; import org.keycloak.services.CorsErrorResponseException; import org.keycloak.services.managers.AppAuthManager; import org.keycloak.testsuite.Assert; @@ -36,7 +35,7 @@ public class OID4VCJWTIssuerEndpointDisabledTest extends OID4VCIssuerEndpointTes // Test getCredentialOfferURI CorsErrorResponseException offerUriException = Assert.assertThrows(CorsErrorResponseException.class, () -> - issuerEndpoint.getCredentialOfferURI("test-credential", OfferUriType.URI, 0, 0) + issuerEndpoint.getCredentialOfferURI("test-credential") ); assertEquals("Should fail with 403 Forbidden when client is not OID4VCI-enabled", Response.Status.FORBIDDEN.getStatusCode(), offerUriException.getResponse().getStatus()); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCJWTIssuerEndpointTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCJWTIssuerEndpointTest.java index f3a07c992d5..41f19e6ac14 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCJWTIssuerEndpointTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCJWTIssuerEndpointTest.java @@ -35,9 +35,12 @@ import jakarta.ws.rs.core.Response; import org.keycloak.OAuth2Constants; import org.keycloak.TokenVerifier; import org.keycloak.common.VerificationException; +import org.keycloak.common.util.Time; import org.keycloak.models.oid4vci.CredentialScopeModel; import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint; import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerWellKnownProvider; +import org.keycloak.protocol.oid4vc.issuance.credentialoffer.CredentialOfferStorage; +import org.keycloak.protocol.oid4vc.issuance.credentialoffer.CredentialOfferStorage.CredentialOfferState; import org.keycloak.protocol.oid4vc.model.Claim; import org.keycloak.protocol.oid4vc.model.ClaimDisplay; import org.keycloak.protocol.oid4vc.model.Claims; @@ -50,7 +53,6 @@ import org.keycloak.protocol.oid4vc.model.ErrorResponse; import org.keycloak.protocol.oid4vc.model.ErrorType; import org.keycloak.protocol.oid4vc.model.Format; import org.keycloak.protocol.oid4vc.model.JwtProof; -import org.keycloak.protocol.oid4vc.model.OfferUriType; import org.keycloak.protocol.oid4vc.model.PreAuthorizedCode; import org.keycloak.protocol.oid4vc.model.PreAuthorizedGrant; import org.keycloak.protocol.oid4vc.model.Proofs; @@ -59,9 +61,8 @@ import org.keycloak.protocol.oid4vc.model.VerifiableCredential; import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantTypeFactory; import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation; import org.keycloak.representations.JsonWebToken; -import org.keycloak.sdjwt.vp.SdJwtVP; import org.keycloak.services.CorsErrorResponseException; -import org.keycloak.services.managers.AppAuthManager; +import org.keycloak.services.managers.AppAuthManager.BearerTokenAuthenticator; import org.keycloak.testsuite.util.oauth.AccessTokenResponse; import org.keycloak.util.JsonSerialization; @@ -90,19 +91,20 @@ import static org.junit.Assert.fail; * Test from org.keycloak.testsuite.oid4vc.issuance.signing.OID4VCIssuerEndpointTest */ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest { + // ----- getCredentialOfferUri @Test public void testGetCredentialOfferUriUnsupportedCredential() { String token = getBearerToken(oauth); testingClient.server(TEST_REALM_NAME).run((session -> { - AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); + BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session); authenticator.setTokenString(token); OID4VCIssuerEndpoint oid4VCIssuerEndpoint = prepareIssuerEndpoint(session, authenticator); CorsErrorResponseException exception = Assert.assertThrows(CorsErrorResponseException.class, () -> - oid4VCIssuerEndpoint.getCredentialOfferURI("inexistent-id", OfferUriType.URI, 0, 0) + oid4VCIssuerEndpoint.getCredentialOfferURI("inexistent-id") ); assertEquals("Should return BAD_REQUEST", Response.Status.BAD_REQUEST.getStatusCode(), exception.getResponse().getStatus()); @@ -112,12 +114,12 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest { @Test public void testGetCredentialOfferUriUnauthorized() { testingClient.server(TEST_REALM_NAME).run((session -> { - AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); + BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session); authenticator.setTokenString(null); OID4VCIssuerEndpoint oid4VCIssuerEndpoint = prepareIssuerEndpoint(session, authenticator); CorsErrorResponseException exception = Assert.assertThrows(CorsErrorResponseException.class, () -> - oid4VCIssuerEndpoint.getCredentialOfferURI("test-credential", OfferUriType.URI, 0, 0) + oid4VCIssuerEndpoint.getCredentialOfferURI("test-credential", true, "john") ); assertEquals("Should return BAD_REQUEST", Response.Status.BAD_REQUEST.getStatusCode(), exception.getResponse().getStatus()); @@ -127,12 +129,12 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest { @Test public void testGetCredentialOfferUriInvalidToken() { testingClient.server(TEST_REALM_NAME).run((session -> { - AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); + BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session); authenticator.setTokenString("invalid-token"); OID4VCIssuerEndpoint oid4VCIssuerEndpoint = prepareIssuerEndpoint(session, authenticator); CorsErrorResponseException exception = Assert.assertThrows(CorsErrorResponseException.class, () -> - oid4VCIssuerEndpoint.getCredentialOfferURI("test-credential", OfferUriType.URI, 0, 0) + oid4VCIssuerEndpoint.getCredentialOfferURI("test-credential", true, "john") ); assertEquals("Should return BAD_REQUEST", Response.Status.BAD_REQUEST.getStatusCode(), exception.getResponse().getStatus()); @@ -148,15 +150,11 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest { testingClient.server(TEST_REALM_NAME).run((session) -> { try { - AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator( - session); + BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session); authenticator.setTokenString(token); OID4VCIssuerEndpoint oid4VCIssuerEndpoint = prepareIssuerEndpoint(session, authenticator); - Response response = oid4VCIssuerEndpoint.getCredentialOfferURI(credentialConfigurationId, - OfferUriType.URI, - 0, - 0); + Response response = oid4VCIssuerEndpoint.getCredentialOfferURI(credentialConfigurationId); assertEquals("An offer uri should have been returned.", HttpStatus.SC_OK, response.getStatus()); CredentialOfferURI credentialOfferURI = JsonSerialization.mapper.convertValue(response.getEntity(), @@ -177,7 +175,7 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest { testingClient .server(TEST_REALM_NAME) .run((session) -> { - AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); + BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session); authenticator.setTokenString(null); OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); Response response = issuerEndpoint.getCredentialOffer("nonce"); @@ -193,7 +191,7 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest { testingClient .server(TEST_REALM_NAME) .run((session -> { - AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); + BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session); authenticator.setTokenString(token); OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); issuerEndpoint.getCredentialOffer(null); @@ -208,7 +206,7 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest { testingClient .server(TEST_REALM_NAME) .run((session -> { - AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); + BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session); authenticator.setTokenString(token); OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); issuerEndpoint.getCredentialOffer("unpreparedNonce"); @@ -223,11 +221,11 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest { testingClient .server(TEST_REALM_NAME) .run((session -> { - AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); + BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session); authenticator.setTokenString(token); - String sessionCode = prepareSessionCode(session, authenticator, "invalidNote"); + String nonce = prepareSessionCode(session, authenticator, "invalidNote").key(); OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); - issuerEndpoint.getCredentialOffer(sessionCode); + issuerEndpoint.getCredentialOffer(nonce); })); }); } @@ -238,25 +236,28 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest { testingClient .server(TEST_REALM_NAME) .run((session) -> { - AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); + BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session); authenticator.setTokenString(token); - CredentialsOffer credentialsOffer = new CredentialsOffer() + CredentialsOffer credOffer = new CredentialsOffer() .setCredentialIssuer("the-issuer") .setGrants(new PreAuthorizedGrant().setPreAuthorizedCode(new PreAuthorizedCode().setPreAuthorizedCode("the-code"))) .setCredentialConfigurationIds(List.of("credential-configuration-id")); - String sessionCode = prepareSessionCode(session, authenticator, JsonSerialization.writeValueAsString(credentialsOffer)); + CredentialOfferStorage offerStorage = session.getProvider(CredentialOfferStorage.class); + CredentialOfferState offerState = new CredentialOfferState(credOffer, null, null, Time.currentTime() + 60); + offerStorage.putOfferState(session, offerState); + // The cache transactions need to be committed explicitly in the test. Without that, the OAuth2Code will only be committed to // the cache after .run((session)-> ...) session.getTransactionManager().commit(); OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); - Response credentialOfferResponse = issuerEndpoint.getCredentialOffer(sessionCode); + Response credentialOfferResponse = issuerEndpoint.getCredentialOffer(offerState.getNonce()); assertEquals("The offer should have been returned.", HttpStatus.SC_OK, credentialOfferResponse.getStatus()); Object credentialOfferEntity = credentialOfferResponse.getEntity(); assertNotNull("An actual offer should be in the response.", credentialOfferEntity); CredentialsOffer retrievedCredentialsOffer = JsonSerialization.mapper.convertValue(credentialOfferEntity, CredentialsOffer.class); - assertEquals("The offer should be the one prepared with for the session.", credentialsOffer, retrievedCredentialsOffer); + assertEquals("The offer should be the one prepared with for the session.", credOffer, retrievedCredentialsOffer); }); } @@ -265,7 +266,7 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest { @Test public void testRequestCredentialUnauthorized() { testingClient.server(TEST_REALM_NAME).run(session -> { - AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); + BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session); authenticator.setTokenString(null); OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); CredentialRequest credentialRequest = new CredentialRequest() @@ -285,7 +286,7 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest { @Test public void testRequestCredentialInvalidToken() { testingClient.server(TEST_REALM_NAME).run(session -> { - AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); + BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session); authenticator.setTokenString("token"); OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); CredentialRequest credentialRequest = new CredentialRequest() @@ -311,8 +312,8 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest { try { withCausePropagation(() -> { testingClient.server(TEST_REALM_NAME).run((session -> { - AppAuthManager.BearerTokenAuthenticator authenticator = - new AppAuthManager.BearerTokenAuthenticator(session); + BearerTokenAuthenticator authenticator = + new BearerTokenAuthenticator(session); authenticator.setTokenString(token); // Prepare the issue endpoint with no credential builders. @@ -337,7 +338,7 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest { String token = getBearerToken(oauth); withCausePropagation(() -> { testingClient.server(TEST_REALM_NAME).run(session -> { - AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); + BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session); authenticator.setTokenString(token); OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); CredentialRequest credentialRequest = new CredentialRequest() @@ -352,14 +353,15 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest { @Test public void testRequestCredential() { - final String scopeName = jwtTypeCredentialClientScope.getName(); + String scopeName = jwtTypeCredentialClientScope.getName(); + String credConfigId = jwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID); String token = getBearerToken(oauth, client, scopeName); testingClient.server(TEST_REALM_NAME).run(session -> { - AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); + BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session); authenticator.setTokenString(token); OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); CredentialRequest credentialRequest = new CredentialRequest() - .setCredentialIdentifier(scopeName); + .setCredentialConfigurationId(credConfigId); String requestPayload = JsonSerialization.writeValueAsString(credentialRequest); @@ -392,27 +394,22 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest { } @Test - public void testRequestCredentialWithConfigurationIdNotSet() { + public void testRequestCredentialWithNeitherIdSet() { final String scopeName = minimalJwtTypeCredentialClientScope.getName(); String token = getBearerToken(oauth, client, scopeName); testingClient.server(TEST_REALM_NAME).run(session -> { - AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); + BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session); authenticator.setTokenString(token); OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); - CredentialRequest credentialRequest = new CredentialRequest() - .setCredentialIdentifier(scopeName); - - String requestPayload = JsonSerialization.writeValueAsString(credentialRequest); - - Response credentialResponse = issuerEndpoint.requestCredential(requestPayload); - assertEquals("The credential request should be answered successfully.", - HttpStatus.SC_OK, credentialResponse.getStatus()); - assertNotNull("A credential should be responded.", credentialResponse.getEntity()); - CredentialResponse credentialResponseVO = JsonSerialization.mapper - .convertValue(credentialResponse.getEntity(), CredentialResponse.class); - String credentialString = (String) credentialResponseVO.getCredentials().get(0).getCredential(); - SdJwtVP sdJwtVP = SdJwtVP.of(credentialString); - assertNotNull("A valid credential string should have been responded", sdJwtVP); + CredentialRequest credentialRequest = new CredentialRequest(); + try { + String requestPayload = JsonSerialization.writeValueAsString(credentialRequest); + issuerEndpoint.requestCredential(requestPayload); + Assert.fail("Expected BadRequestException due to unknown credential identifier"); + } catch (BadRequestException e) { + ErrorResponse error = (ErrorResponse) e.getResponse().getEntity(); + assertEquals(ErrorType.MISSING_CREDENTIAL_IDENTIFIER_AND_CONFIGURATION_ID, error.getError()); + } }); } @@ -430,9 +427,7 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest { // 1. Retrieving the credential-offer-uri final String credentialConfigurationId = jwtTypeCredentialClientScope.getAttributes() .get(CredentialScopeModel.CONFIGURATION_ID); - HttpGet getCredentialOfferURI = new HttpGet(getBasePath(TEST_REALM_NAME) - + "credential-offer-uri?credential_configuration_id=" - + credentialConfigurationId); + HttpGet getCredentialOfferURI = new HttpGet(getCredentialOfferUriUrl(credentialConfigurationId)); getCredentialOfferURI.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token); CloseableHttpResponse credentialOfferURIResponse = httpClient.execute(getCredentialOfferURI); @@ -602,15 +597,16 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest { @Test public void testRequestCredentialWithNotificationId() { - String token = getBearerToken(oauth, client, jwtTypeCredentialClientScope.getName()); final String scopeName = jwtTypeCredentialClientScope.getName(); + String credConfigId = jwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID); + String token = getBearerToken(oauth, client, scopeName); testingClient.server(TEST_REALM_NAME).run((session) -> { - AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); + BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session); authenticator.setTokenString(token); OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); CredentialRequest credentialRequest = new CredentialRequest() - .setCredentialIdentifier(scopeName); + .setCredentialConfigurationId(credConfigId); String requestPayload = JsonSerialization.writeValueAsString(credentialRequest); @@ -639,12 +635,13 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest { @Test public void testRequestMultipleCredentialsWithProofs() { final String scopeName = jwtTypeCredentialClientScope.getName(); + String credConfigId = jwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID); String token = getBearerToken(oauth, client, scopeName); String cNonce = getCNonce(); testingClient.server(TEST_REALM_NAME).run(session -> { try { - AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); + BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session); authenticator.setTokenString(token); String issuer = OID4VCIssuerWellKnownProvider.getIssuer(session.getContext()); @@ -654,7 +651,7 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest { CredentialRequest request = new CredentialRequest() - .setCredentialIdentifier(scopeName) + .setCredentialConfigurationId(credConfigId) .setProofs(proofs); OID4VCIssuerEndpoint endpoint = prepareIssuerEndpoint(session, authenticator); @@ -874,7 +871,7 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest { String token = getBearerToken(oauth, client, jwtTypeCredentialClientScope.getName()); testingClient.server(TEST_REALM_NAME).run(session -> { - AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); + BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session); authenticator.setTokenString(token); OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); @@ -901,7 +898,7 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest { String token = getBearerToken(oauth, client, jwtTypeCredentialClientScope.getName()); testingClient.server(TEST_REALM_NAME).run(session -> { - AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); + BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session); authenticator.setTokenString(token); OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); @@ -931,7 +928,7 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest { String token = getBearerToken(oauth, client, jwtTypeCredentialClientScope.getName()); testingClient.server(TEST_REALM_NAME).run(session -> { - AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); + BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session); authenticator.setTokenString(token); // Prepare endpoint with no credential builders to simulate missing builder for the configured format OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator, Map.of()); @@ -963,7 +960,7 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest { .get(CredentialScopeModel.CONFIGURATION_ID); testingClient.server(TEST_REALM_NAME).run(session -> { - AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); + BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session); authenticator.setTokenString(token); OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCSdJwtIssuingEndpointDisabledTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCSdJwtIssuingEndpointDisabledTest.java index 3fb9c470ed4..ec002434c87 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCSdJwtIssuingEndpointDisabledTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCSdJwtIssuingEndpointDisabledTest.java @@ -6,7 +6,6 @@ import jakarta.ws.rs.core.Response; import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint; import org.keycloak.protocol.oid4vc.model.CredentialRequest; -import org.keycloak.protocol.oid4vc.model.OfferUriType; import org.keycloak.services.CorsErrorResponseException; import org.keycloak.services.managers.AppAuthManager; import org.keycloak.testsuite.Assert; @@ -36,7 +35,7 @@ public class OID4VCSdJwtIssuingEndpointDisabledTest extends OID4VCIssuerEndpoint // Test getCredentialOfferURI CorsErrorResponseException offerUriException = Assert.assertThrows(CorsErrorResponseException.class, () -> - issuerEndpoint.getCredentialOfferURI("test-credential", OfferUriType.URI, 0, 0) + issuerEndpoint.getCredentialOfferURI("test-credential") ); assertEquals("Should fail with 403 Forbidden when client is not OID4VCI-enabled", Response.Status.FORBIDDEN.getStatusCode(), offerUriException.getResponse().getStatus()); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCSdJwtIssuingEndpointTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCSdJwtIssuingEndpointTest.java index 119dbc0b7b9..cd2aa304a25 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCSdJwtIssuingEndpointTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCSdJwtIssuingEndpointTest.java @@ -32,7 +32,7 @@ import org.keycloak.OAuth2Constants; import org.keycloak.TokenVerifier; import org.keycloak.common.VerificationException; import org.keycloak.common.util.Base64Url; -import org.keycloak.constants.Oid4VciConstants; +import org.keycloak.constants.OID4VCIConstants; import org.keycloak.models.ClientScopeModel; import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; @@ -231,7 +231,7 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest { final String nonceEndpoint = OID4VCIssuerWellKnownProvider.getNonceEndpoint(session.getContext()); try { // make the exp-value negative to set the exp-time in the past - session.getContext().getRealm().setAttribute(Oid4VciConstants.C_NONCE_LIFETIME_IN_SECONDS, -1); + session.getContext().getRealm().setAttribute(OID4VCIConstants.C_NONCE_LIFETIME_IN_SECONDS, -1); String cNonce = cNonceHandler.buildCNonce(List.of(credentialsEndpoint), Map.of(JwtCNonceHandler.SOURCE_ENDPOINT, nonceEndpoint)); Proofs proof = new Proofs().setJwt(List.of(generateJwtProof(getCredentialIssuer(session), cNonce))); @@ -241,7 +241,7 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest { testRequestTestCredential(session, clientScope, token, proof); } finally { // make sure other tests are not affected by the changed realm-attribute - session.getContext().getRealm().removeAttribute(Oid4VciConstants.C_NONCE_LIFETIME_IN_SECONDS); + session.getContext().getRealm().removeAttribute(OID4VCIConstants.C_NONCE_LIFETIME_IN_SECONDS); } }))); Assert.fail("Should have thrown an exception"); @@ -303,9 +303,7 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest { // 1. Retrieving the credential-offer-uri final String credentialConfigurationId = clientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID); - HttpGet getCredentialOfferURI = new HttpGet(getBasePath(TEST_REALM_NAME) + - "credential-offer-uri?credential_configuration_id=" + - credentialConfigurationId); + HttpGet getCredentialOfferURI = new HttpGet(getCredentialOfferUriUrl(credentialConfigurationId)); getCredentialOfferURI.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token); CloseableHttpResponse credentialOfferURIResponse = httpClient.execute(getCredentialOfferURI); @@ -515,7 +513,7 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest { public static ProtocolMapperRepresentation getJtiGeneratedIdMapper() { ProtocolMapperRepresentation protocolMapperRepresentation = new ProtocolMapperRepresentation(); protocolMapperRepresentation.setName("generated-id-mapper"); - protocolMapperRepresentation.setProtocol(Oid4VciConstants.OID4VC_PROTOCOL); + protocolMapperRepresentation.setProtocol(OID4VCIConstants.OID4VC_PROTOCOL); protocolMapperRepresentation.setId(UUID.randomUUID().toString()); protocolMapperRepresentation.setProtocolMapper("oid4vc-generated-id-mapper"); protocolMapperRepresentation.setConfig(Map.of( @@ -530,7 +528,7 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest { .addClientScope(realmModel, jwtTypeCredentialScopeName); credentialScope.setAttribute(CredentialScopeModel.CREDENTIAL_IDENTIFIER, jwtTypeCredentialScopeName); - credentialScope.setProtocol(Oid4VciConstants.OID4VC_PROTOCOL); + credentialScope.setProtocol(OID4VCIConstants.OID4VC_PROTOCOL); return credentialScope; } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCTest.java index cd2aeabc008..219fa195680 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCTest.java @@ -29,6 +29,7 @@ import java.security.Security; import java.security.cert.Certificate; import java.time.Instant; import java.time.temporal.ChronoUnit; +import java.util.Arrays; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; @@ -53,7 +54,7 @@ import org.keycloak.common.util.CertificateUtils; import org.keycloak.common.util.KeyUtils; import org.keycloak.common.util.MultivaluedHashMap; import org.keycloak.common.util.PemUtils; -import org.keycloak.constants.Oid4VciConstants; +import org.keycloak.constants.OID4VCIConstants; import org.keycloak.crypto.ECDSASignatureSignerContext; import org.keycloak.crypto.KeyUse; import org.keycloak.crypto.KeyWrapper; @@ -70,6 +71,7 @@ import org.keycloak.protocol.oid4vc.issuance.VCIssuanceContext; import org.keycloak.protocol.oid4vc.issuance.keybinding.AttestationValidatorUtil; import org.keycloak.protocol.oid4vc.issuance.keybinding.JwtProofValidator; import org.keycloak.protocol.oid4vc.issuance.mappers.OID4VCIssuedAtTimeClaimMapper; +import org.keycloak.protocol.oid4vc.model.AuthorizationDetail; import org.keycloak.protocol.oid4vc.model.CredentialRequest; import org.keycloak.protocol.oid4vc.model.CredentialSubject; import org.keycloak.protocol.oid4vc.model.Format; @@ -91,7 +93,8 @@ import org.keycloak.testsuite.AbstractTestRealmKeycloakTest; import org.keycloak.testsuite.arquillian.annotation.EnableFeature; import org.keycloak.testsuite.util.AdminClientUtil; import org.keycloak.testsuite.util.UserBuilder; -import org.keycloak.testsuite.util.oauth.AuthorizationEndpointResponse; +import org.keycloak.testsuite.util.oauth.AccessTokenRequest; +import org.keycloak.testsuite.util.oauth.AccessTokenResponse; import org.keycloak.testsuite.util.oauth.OAuthClient; import org.keycloak.util.JsonSerialization; @@ -336,7 +339,7 @@ public abstract class OID4VCTest extends AbstractTestRealmKeycloakTest { ProtocolMapperRepresentation protocolMapperRepresentation = new ProtocolMapperRepresentation(); protocolMapperRepresentation.setName("role-mapper"); protocolMapperRepresentation.setId(UUID.randomUUID().toString()); - protocolMapperRepresentation.setProtocol(Oid4VciConstants.OID4VC_PROTOCOL); + protocolMapperRepresentation.setProtocol(OID4VCIConstants.OID4VC_PROTOCOL); protocolMapperRepresentation.setProtocolMapper("oid4vc-target-role-mapper"); protocolMapperRepresentation.setConfig( Map.of("claim.name", "roles", "clientId", clientId) @@ -347,7 +350,7 @@ public abstract class OID4VCTest extends AbstractTestRealmKeycloakTest { public static ProtocolMapperRepresentation getIdMapper() { ProtocolMapperRepresentation protocolMapperRepresentation = new ProtocolMapperRepresentation(); protocolMapperRepresentation.setName("id-mapper"); - protocolMapperRepresentation.setProtocol(Oid4VciConstants.OID4VC_PROTOCOL); + protocolMapperRepresentation.setProtocol(OID4VCIConstants.OID4VC_PROTOCOL); protocolMapperRepresentation.setId(UUID.randomUUID().toString()); protocolMapperRepresentation.setProtocolMapper("oid4vc-subject-id-mapper"); protocolMapperRepresentation.setConfig(Map.of()); @@ -357,7 +360,7 @@ public abstract class OID4VCTest extends AbstractTestRealmKeycloakTest { public static ProtocolMapperRepresentation getStaticClaimMapper(String scopeName) { ProtocolMapperRepresentation protocolMapperRepresentation = new ProtocolMapperRepresentation(); protocolMapperRepresentation.setName(UUID.randomUUID().toString()); - protocolMapperRepresentation.setProtocol(Oid4VciConstants.OID4VC_PROTOCOL); + protocolMapperRepresentation.setProtocol(OID4VCIConstants.OID4VC_PROTOCOL); protocolMapperRepresentation.setId(UUID.randomUUID().toString()); protocolMapperRepresentation.setProtocolMapper("oid4vc-static-claim-mapper"); protocolMapperRepresentation.setConfig( @@ -394,25 +397,36 @@ public abstract class OID4VCTest extends AbstractTestRealmKeycloakTest { return componentExportRepresentation; } - public static UserRepresentation getUserRepresentation(Map> clientRoles) { - UserBuilder userBuilder = UserBuilder.create() - .id(KeycloakModelUtils.generateId()) - .username("john") - .enabled(true) - .email("john@email.cz") - .emailVerified(true) - .firstName("John") - .lastName("Doe") - .password("password") - .role("account", "manage-account") - .role("account", "view-profile"); + public static UserRepresentation getUserRepresentation( + String fullName, + List realmRoles, + Map> clientRoles + ) { + String[] nameToks = fullName.split("\\s"); + String firstName = nameToks[0]; + String lastName = nameToks[1]; + String username = firstName.toLowerCase(); + UserBuilder userBuilder = UserBuilder.create() + .id(KeycloakModelUtils.generateId()) + .username(username) + .enabled(true) + .email(username + "@email.cz") + .emailVerified(true) + .firstName(firstName) + .lastName(lastName) + .password("password") + .role("account", "manage-account") + .role("account", "view-profile"); - clientRoles.entrySet().forEach(entry -> { - entry.getValue().forEach(role -> userBuilder.role(entry.getKey(), role)); - }); - - return userBuilder.build(); - } + // When Keycloak issues a token for a user and client: + // + // 1. It looks up all effective realm roles and all effective client roles assigned to the user. + // 2. The token includes only those roles that the user actually has. + // + realmRoles.forEach(userBuilder::addRoles); + clientRoles.forEach((cid, roles) -> roles.forEach(role -> userBuilder.role(cid, role))); + return userBuilder.build(); + } public static RoleRepresentation getRoleRepresentation(String roleName, String clientId) { @@ -423,27 +437,62 @@ public abstract class OID4VCTest extends AbstractTestRealmKeycloakTest { return role; } - protected String getBearerToken(OAuthClient oAuthClient) { - return getBearerToken(oAuthClient, null); + protected String getAuthorizationCode(OAuthClient oAuthClient, ClientRepresentation client, String username, String scope) { + if (client != null) { + oAuthClient.client(client.getClientId(), client.getSecret()); + } + if (scope != null) { + oAuthClient.scope(scope); + } + var authorizationEndpointResponse = oAuthClient.doLogin(username,"password"); + return authorizationEndpointResponse.getCode(); + } + + protected String getBearerToken(OAuthClient oauthClient) { + return getBearerToken(oauthClient, null); } - protected String getBearerToken(OAuthClient oAuthClient, ClientRepresentation client) { - return getBearerToken(oAuthClient, client, null); + protected String getBearerToken(OAuthClient oauthClient, ClientRepresentation client) { + return getBearerToken(oauthClient, client, null); } - protected String getBearerToken(OAuthClient oAuthClient, ClientRepresentation client, String credentialScopeName) { - if (client != null) { - oAuthClient.client(client.getClientId(), client.getSecret()); - } - if (credentialScopeName != null) { - oAuthClient.scope(credentialScopeName); - } - AuthorizationEndpointResponse authorizationEndpointResponse = oAuthClient.doLogin("john", - "password"); - return oAuthClient.doAccessTokenRequest(authorizationEndpointResponse.getCode()).getAccessToken(); + protected String getBearerToken(OAuthClient oauthClient, ClientRepresentation client, String scope) { + return getBearerToken(oauthClient, client, "john", scope); } - public static class StaticTimeProvider implements TimeProvider { + protected String getBearerToken(OAuthClient oauthClient, ClientRepresentation client, String username, String scope) { + return getBearerTokenCodeFlow(oauthClient, client, username, scope).getAccessToken(); + } + + protected AccessTokenResponse getBearerToken(OAuthClient oauthClient, String authCode, AuthorizationDetail... authDetail) { + AccessTokenRequest accessTokenRequest = oauthClient.accessTokenRequest(authCode); + if (authDetail != null) { + accessTokenRequest.authorizationDetails(Arrays.asList(authDetail)); + } + AccessTokenResponse tokenResponse = accessTokenRequest.send(); + if (!tokenResponse.isSuccess()) { + throw new IllegalStateException(tokenResponse.getErrorDescription()); + } + return tokenResponse; + } + + protected AccessTokenResponse getBearerTokenCodeFlow(OAuthClient oauthClient, ClientRepresentation client, String username, String scope) { + var authCode = getAuthorizationCode(oauthClient, client, username, scope); + return oauthClient.accessTokenRequest(authCode).send(); + } + + protected AccessTokenResponse getBearerTokenDirectAccess(OAuthClient oauthClient, ClientRepresentation client, String username, String scope) { + if (client != null) { + oauthClient.client(client.getClientId(), client.getSecret()); + } + if (scope != null) { + oauthClient.scope(scope); + } + var accessTokenResponse = oauthClient.doPasswordGrantRequest(username, "password"); + return accessTokenResponse; + } + + public static class StaticTimeProvider implements TimeProvider { private final int currentTimeInS; public StaticTimeProvider(int currentTimeInS) { @@ -464,7 +513,7 @@ public abstract class OID4VCTest extends AbstractTestRealmKeycloakTest { protected ProtocolMapperRepresentation getUserAttributeMapper(String subjectProperty, String attributeName) { ProtocolMapperRepresentation protocolMapperRepresentation = new ProtocolMapperRepresentation(); protocolMapperRepresentation.setName(attributeName + "-mapper"); - protocolMapperRepresentation.setProtocol(Oid4VciConstants.OID4VC_PROTOCOL); + protocolMapperRepresentation.setProtocol(OID4VCIConstants.OID4VC_PROTOCOL); protocolMapperRepresentation.setId(UUID.randomUUID().toString()); protocolMapperRepresentation.setProtocolMapper("oid4vc-user-attribute-mapper"); protocolMapperRepresentation.setConfig( @@ -478,7 +527,7 @@ public abstract class OID4VCTest extends AbstractTestRealmKeycloakTest { protected ProtocolMapperRepresentation getIssuedAtTimeMapper(String subjectProperty, String truncateToTimeUnit, String valueSource) { ProtocolMapperRepresentation protocolMapperRepresentation = new ProtocolMapperRepresentation(); protocolMapperRepresentation.setName(subjectProperty + "-oid4vc-issued-at-time-claim-mapper"); - protocolMapperRepresentation.setProtocol(Oid4VciConstants.OID4VC_PROTOCOL); + protocolMapperRepresentation.setProtocol(OID4VCIConstants.OID4VC_PROTOCOL); protocolMapperRepresentation.setId(UUID.randomUUID().toString()); protocolMapperRepresentation.setProtocolMapper("oid4vc-issued-at-time-claim-mapper"); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCTimeNormalizationSdJwtTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCTimeNormalizationSdJwtTest.java index 09ef71aa42f..ef7b0fca001 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCTimeNormalizationSdJwtTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCTimeNormalizationSdJwtTest.java @@ -24,7 +24,7 @@ import jakarta.ws.rs.core.Response; import org.keycloak.TokenVerifier; import org.keycloak.common.VerificationException; -import org.keycloak.constants.Oid4VciConstants; +import org.keycloak.constants.OID4VCIConstants; import org.keycloak.models.oid4vci.CredentialScopeModel; import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint; import org.keycloak.protocol.oid4vc.issuance.mappers.OID4VCIssuedAtTimeClaimMapper; @@ -68,7 +68,7 @@ public class OID4VCTimeNormalizationSdJwtTest extends OID4VCSdJwtIssuingEndpoint ClientScopeRepresentation clientScope = fromJsonString(clientScopeString, ClientScopeRepresentation.class); ProtocolMapperRepresentation pr = new ProtocolMapperRepresentation(); pr.setName("iat-from-vc"); - pr.setProtocol(Oid4VciConstants.OID4VC_PROTOCOL); + pr.setProtocol(OID4VCIConstants.OID4VC_PROTOCOL); pr.setProtocolMapper(OID4VCIssuedAtTimeClaimMapper.MAPPER_ID); pr.setConfig(Map.of( OID4VCIssuedAtTimeClaimMapper.CLAIM_NAME, "iat", diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCTimeNormalizationTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCTimeNormalizationTest.java index c9b63d7ecd6..e80b6af5e45 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCTimeNormalizationTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCTimeNormalizationTest.java @@ -24,6 +24,7 @@ import jakarta.ws.rs.core.Response; import org.keycloak.TokenVerifier; import org.keycloak.common.VerificationException; +import org.keycloak.models.oid4vci.CredentialScopeModel; import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint; import org.keycloak.protocol.oid4vc.model.CredentialRequest; import org.keycloak.protocol.oid4vc.model.CredentialResponse; @@ -55,6 +56,7 @@ public class OID4VCTimeNormalizationTest extends OID4VCJWTIssuerEndpointTest { }); final String scopeName = jwtTypeCredentialClientScope.getName(); + String credConfigId = jwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID); String token = getBearerToken(oauth, client, scopeName); testingClient.server(TEST_REALM_NAME).run(session -> { @@ -64,7 +66,7 @@ public class OID4VCTimeNormalizationTest extends OID4VCJWTIssuerEndpointTest { OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); CredentialRequest credentialRequest = new CredentialRequest() - .setCredentialIdentifier(scopeName); + .setCredentialConfigurationId(credConfigId); String requestPayload = JsonSerialization.writeValueAsString(credentialRequest); Response response = issuerEndpoint.requestCredential(requestPayload); diff --git a/testsuite/integration-arquillian/tests/base/src/test/resources/testrealm.json b/testsuite/integration-arquillian/tests/base/src/test/resources/testrealm.json index fc0a86aafa4..ea80cd573ac 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/resources/testrealm.json +++ b/testsuite/integration-arquillian/tests/base/src/test/resources/testrealm.json @@ -405,6 +405,7 @@ "clientId": "named-test-app", "name": "My Named Test App", "enabled": true, + "directAccessGrantsEnabled": true, "baseUrl": "http://localhost:8180/namedapp/base", "redirectUris": [ "http://localhost:8180/namedapp/base/*",