rest credential offer experimental feature

Closes #46279

Signed-off-by: Giuseppe Graziano <g.graziano94@gmail.com>
This commit is contained in:
Giuseppe Graziano
2026-05-08 13:07:12 +02:00
committed by Marek Posolda
parent b3602649f6
commit b814ff8003
12 changed files with 196 additions and 15 deletions
+1 -1
View File
@@ -285,7 +285,7 @@ jobs:
- name: Start Keycloak server
run: |
tar xfvz keycloak-999.0.0-SNAPSHOT.tar.gz
keycloak-999.0.0-SNAPSHOT/bin/kc.sh start-dev --http-port 8180 --features transient-users,oid4vc-vci,declarative-ui,quick-theme,spiffe,kubernetes-service-accounts,workflows,client-auth-federated,jwt-authorization-grant,client-admin-api:v2 &> ~/server.log &
keycloak-999.0.0-SNAPSHOT/bin/kc.sh start-dev --http-port 8180 --features transient-users,oid4vc-vci,oid4vc-vci-rest-credential-offer,declarative-ui,quick-theme,spiffe,kubernetes-service-accounts,workflows,client-auth-federated,jwt-authorization-grant,client-admin-api:v2 &> ~/server.log &
curl --connect-timeout 5 --max-time 10 --retry 10 --retry-all-errors --retry-delay 10 --retry-max-time 120 -s -o /dev/null http://127.0.0.1:8180
env:
KC_BOOTSTRAP_ADMIN_USERNAME: admin
@@ -133,6 +133,7 @@ public class Profile {
OID4VC_VCI("Support for the OID4VCI protocol as part of OID4VC.", Type.EXPERIMENTAL),
OID4VC_VCI_PREAUTH_CODE("Support for credential offers with `pre-authorized_code` grant.", Type.EXPERIMENTAL, OID4VC_VCI),
OID4VC_VCI_REST_CREDENTIAL_OFFER("Support for the REST endpoint to create credential offers.", Type.EXPERIMENTAL, OID4VC_VCI),
OPENTELEMETRY("OpenTelemetry support", Type.DEFAULT),
OPENTELEMETRY_LOGS("OpenTelemetry Logs support", Type.PREVIEW, OPENTELEMETRY),
@@ -51,6 +51,7 @@ import jakarta.ws.rs.core.Response;
import org.keycloak.OAuth2Constants;
import org.keycloak.OID4VCConstants;
import org.keycloak.VCFormat;
import org.keycloak.common.Profile;
import org.keycloak.common.VerificationException;
import org.keycloak.crypto.KeyUse;
import org.keycloak.crypto.KeyWrapper;
@@ -268,6 +269,29 @@ public class OID4VCIssuerEndpoint {
}
}
/**
* Validates if the REST credential offer feature is enabled.
* If disabled, logs the status and throws
* a {@link CorsErrorResponseException}.
*/
private void checkRestCredentialOfferEnabled(EventBuilder eventBuilder) {
if (!Profile.isFeatureEnabled(org.keycloak.common.Profile.Feature.OID4VC_VCI_REST_CREDENTIAL_OFFER)) {
LOGGER.debugf("REST credential offer endpoint is disabled. Feature oid4vci-rest-credential-offer is not enabled.");
if (eventBuilder != null) {
eventBuilder.error(ErrorType.INVALID_CLIENT.getValue());
}
if (cors == null) {
configureCors(false);
}
throw new CorsErrorResponseException(
cors,
ErrorType.INVALID_CLIENT.getValue(),
"REST credential offer functionality is not enabled",
Response.Status.FORBIDDEN
);
}
}
/**
* Validates whether the authenticated client is enabled for OID4VCI features.
* <p>
@@ -462,6 +486,7 @@ public class OID4VCIssuerEndpoint {
cors.checkAllowedOrigins(session, clientModel);
checkIsOid4vciEnabled(eventBuilder);
checkRestCredentialOfferEnabled(eventBuilder);
checkClientEnabled(eventBuilder);
// Verify required credConfigId
@@ -285,10 +285,10 @@ public class RealmManager {
realm.setOTPPolicy(OTPPolicy.DEFAULT_POLICY);
realm.setLoginWithEmailAllowed(true);
if (Profile.isFeatureEnabled(Profile.Feature.OID4VC_VCI)) {
if (Profile.isFeatureEnabled(Profile.Feature.OID4VC_VCI_REST_CREDENTIAL_OFFER)) {
// Only create the role if it doesn't exist in the realm representation (during import)
// or if it doesn't exist in the realm model (during fresh creation)
if ((realmRep == null || !hasRealmRole(realmRep, CREDENTIAL_OFFER_CREATE.getName()))
if ((realmRep == null || !hasRealmRole(realmRep, CREDENTIAL_OFFER_CREATE.getName()))
&& realm.getRole(CREDENTIAL_OFFER_CREATE.getName()) == null) {
RoleModel roleModel = realm.addRole(CREDENTIAL_OFFER_CREATE.getName());
roleModel.setDescription(CREDENTIAL_OFFER_CREATE.getDescription());
@@ -0,0 +1,69 @@
/*
* 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.tests.oid4vc;
import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.core.Response;
import org.keycloak.common.Profile;
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint;
import org.keycloak.services.CorsErrorResponseException;
import org.keycloak.services.managers.AppAuthManager.BearerTokenAuthenticator;
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
import org.keycloak.testframework.server.KeycloakServerConfig;
import org.keycloak.testframework.server.KeycloakServerConfigBuilder;
import org.junit.jupiter.api.Test;
import static org.keycloak.constants.OID4VCIConstants.CREDENTIAL_OFFER_CREATE;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
@KeycloakIntegrationTest(config = OID4VCIRestCredentialOfferFeatureDisabledTest.CredentialOfferDisabledServerConfig.class)
public class OID4VCIRestCredentialOfferFeatureDisabledTest extends OID4VCIssuerEndpointTest {
@Test
public void testRoleNotCreated() {
assertThrows(NotFoundException.class, () -> testRealm.admin().roles().get(CREDENTIAL_OFFER_CREATE.getName()).toRepresentation());
}
@Test
public void testRestEndpoint() {
String token = getBearerToken(oauth);
runOnServer.run(session -> {
BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session);
authenticator.setTokenString(token);
OID4VCIssuerEndpoint oid4VCIssuerEndpoint = prepareIssuerEndpoint(session, authenticator);
CorsErrorResponseException exception = assertThrows(CorsErrorResponseException.class, () -> oid4VCIssuerEndpoint.createCredentialOffer("test-credential"));
// Verify it's a 403 Forbidden
assertEquals(Response.Status.FORBIDDEN.getStatusCode(), exception.getResponse().getStatus());
});
}
public static class CredentialOfferDisabledServerConfig implements KeycloakServerConfig {
@Override
public KeycloakServerConfigBuilder configure(KeycloakServerConfigBuilder config) {
return config.features(Profile.Feature.OID4VC_VCI);
}
}
}
@@ -0,0 +1,67 @@
/*
* 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.tests.oid4vc;
import jakarta.ws.rs.core.Response;
import org.keycloak.models.oid4vci.CredentialScopeModel;
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint;
import org.keycloak.protocol.oid4vc.model.CredentialOfferURI;
import org.keycloak.services.managers.AppAuthManager.BearerTokenAuthenticator;
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
import org.keycloak.util.JsonSerialization;
import org.apache.http.HttpStatus;
import org.junit.jupiter.api.Test;
import static org.keycloak.constants.OID4VCIConstants.CREDENTIAL_OFFER_CREATE;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
@KeycloakIntegrationTest(config = OID4VCIssuerTestBase.VCTestServerConfigRestCredentialOffer.class)
public class OID4VCIRestCredentialOfferFeatureEnabledTest extends OID4VCIssuerEndpointTest {
@Test
public void testRoleCreated() {
assertEquals(CREDENTIAL_OFFER_CREATE.getName(), testRealm.admin().roles().get(CREDENTIAL_OFFER_CREATE.getName()).toRepresentation().getName());
}
@Test
public void testRestEndpoint() {
String scopeName = jwtTypeCredentialScope.getName();
String credentialConfigurationId = jwtTypeCredentialScope.getAttributes().get(CredentialScopeModel.VC_CONFIGURATION_ID);
String token = getBearerToken(oauth, client, scopeName);
runOnServer.run(session -> {
BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session);
authenticator.setTokenString(token);
OID4VCIssuerEndpoint oid4VCIssuerEndpoint = prepareIssuerEndpoint(session, authenticator);
Response response = oid4VCIssuerEndpoint.createCredentialOffer(credentialConfigurationId);
assertEquals(HttpStatus.SC_OK, response.getStatus());
CredentialOfferURI credentialOfferURI = JsonSerialization.mapper.convertValue(response.getEntity(), CredentialOfferURI.class);
assertNotNull(credentialOfferURI.getNonce());
assertNotNull(credentialOfferURI.getIssuer());
});
}
}
@@ -55,6 +55,7 @@ import org.keycloak.representations.idm.ComponentRepresentation;
import org.keycloak.representations.idm.ProtocolMapperRepresentation;
import org.keycloak.representations.idm.RealmEventsConfigRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.representations.idm.RoleRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.representations.userprofile.config.UPConfig;
import org.keycloak.testframework.annotations.InjectAdminClient;
@@ -73,6 +74,8 @@ import org.keycloak.testframework.realm.ManagedRealm;
import org.keycloak.testframework.realm.RealmBuilder;
import org.keycloak.testframework.realm.RealmConfig;
import org.keycloak.testframework.realm.UserBuilder;
import org.keycloak.testframework.remote.runonserver.InjectRunOnServer;
import org.keycloak.testframework.remote.runonserver.RunOnServerClient;
import org.keycloak.testframework.remote.timeoffset.InjectTimeOffSet;
import org.keycloak.testframework.remote.timeoffset.TimeOffSet;
import org.keycloak.testframework.server.KeycloakServerConfig;
@@ -163,6 +166,9 @@ public abstract class OID4VCIssuerTestBase {
@InjectKeycloakUrls
protected KeycloakUrls keycloakUrls;
@InjectRunOnServer
protected RunOnServerClient runOnServer;
protected CredentialScopeRepresentation jwtTypeCredentialScope;
protected CredentialScopeRepresentation sdJwtTypeCredentialScope;
protected CredentialScopeRepresentation minimalJwtTypeCredentialScope;
@@ -181,6 +187,16 @@ public abstract class OID4VCIssuerTestBase {
realmResource.users().userProfile().update(upConfig);
AuthorizationDetailsParser.registerParser(OPENID_CREDENTIAL, new OID4VCAuthorizationDetailsParser());
boolean isRestCredentialEnabled = runOnServer.fetch(session -> Profile.isFeatureEnabled(Profile.Feature.OID4VC_VCI_REST_CREDENTIAL_OFFER), Boolean.class);
if (isRestCredentialEnabled) {
UserRepresentation testUser = getExistingUser(TEST_USER);
RoleRepresentation credentialOfferRole = realmResource.roles().get(CREDENTIAL_OFFER_CREATE.getName()).toRepresentation();
testUser.setRealmRoles(List.of(CREDENTIAL_OFFER_CREATE.getName()));
realmResource.users().get(testUser.getId()).roles().realmLevel().add(List.of(credentialOfferRole));
}
}
@BeforeEach
@@ -414,10 +430,17 @@ public abstract class OID4VCIssuerTestBase {
}
}
public static class VCTestServerConfigRestCredentialOffer implements KeycloakServerConfig {
@Override
public KeycloakServerConfigBuilder configure(KeycloakServerConfigBuilder config) {
return config.features(Profile.Feature.OID4VC_VCI, Profile.Feature.OID4VC_VCI_REST_CREDENTIAL_OFFER);
}
}
public static class VCTestServerWithPreAuthCodeEnabled implements KeycloakServerConfig {
@Override
public KeycloakServerConfigBuilder configure(KeycloakServerConfigBuilder config) {
return config.features(Profile.Feature.OID4VC_VCI, Profile.Feature.OID4VC_VCI_PREAUTH_CODE);
return config.features(Profile.Feature.OID4VC_VCI, Profile.Feature.OID4VC_VCI_PREAUTH_CODE, Profile.Feature.OID4VC_VCI_REST_CREDENTIAL_OFFER);
}
}
@@ -441,7 +464,6 @@ public abstract class OID4VCIssuerTestBase {
CryptoIntegration.init(this.getClass().getClassLoader());
realm.verifiableCredentialsEnabled(true);
realm.realmRoles(CREDENTIAL_OFFER_CREATE);
// Allow the default client scopes to be added as well
realm.attribute(CREATE_DEFAULT_CLIENT_SCOPES, "true");
@@ -493,7 +515,7 @@ public abstract class OID4VCIssuerTestBase {
null
));
realm.users(getUserRepresentation("John Doe", Map.of("did", "did:key:1234"), List.of(CREDENTIAL_OFFER_CREATE.getName()), Collections.emptyMap()));
realm.users(getUserRepresentation("John Doe", Map.of("did", "did:key:1234"), List.of(), Collections.emptyMap()));
realm.users(getUserRepresentation("Alice Wonderland", Map.of("did", "did:key:5678"), List.of(), Map.of()));
return realm;
@@ -125,7 +125,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;
@KeycloakIntegrationTest(config = OID4VCIssuerTestBase.VCTestServerConfig.class)
@KeycloakIntegrationTest(config = OID4VCIssuerTestBase.VCTestServerConfigRestCredentialOffer.class)
public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
@InjectRunOnServer
@@ -13,7 +13,6 @@ import org.keycloak.representations.JsonWebToken;
import org.keycloak.sdjwt.IssuerSignedJWT;
import org.keycloak.sdjwt.vp.SdJwtVP;
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
import org.keycloak.tests.oid4vc.OID4VCIssuerTestBase.VCTestServerConfig;
import org.keycloak.testsuite.util.oauth.AccessTokenResponse;
import org.keycloak.testsuite.util.oauth.AuthorizationEndpointResponse;
import org.keycloak.testsuite.util.oauth.oid4vc.CredentialOfferResponse;
@@ -46,7 +45,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
* | yes | yes | yes | Pre-auth for a specific target user. |
* +----------+----------+---------+------------------------------------------------------+
*/
@KeycloakIntegrationTest(config = VCTestServerConfig.class)
@KeycloakIntegrationTest(config = OID4VCIssuerTestBase.VCTestServerConfigRestCredentialOffer.class)
public class OID4VCredentialOfferAuthCodeTest extends OID4VCIssuerTestBase {
@Test
@@ -329,11 +329,11 @@ public class ExportImportTest extends AbstractKeycloakTest {
}
@Test
@EnableFeature(value = Profile.Feature.OID4VC_VCI, skipRestart = true)
@EnableFeature(value = Profile.Feature.OID4VC_VCI_REST_CREDENTIAL_OFFER, skipRestart = true)
public void testRealmImportWithOID4VCICredentialOfferCreateRole() throws Throwable {
String testRealmName = "oid4vci-import-test";
// Create a realm with OID4VCI enabled - credential-offer-create role will be created automatically
// Create a realm with OID4VCI REST credential offer enabled - credential-offer-create role will be created automatically
RealmRepresentation realmRep = new RealmRepresentation();
realmRep.setRealm(testRealmName);
realmRep.setEnabled(true);
@@ -25,7 +25,6 @@ import java.util.stream.Collectors;
import jakarta.ws.rs.core.HttpHeaders;
import org.keycloak.common.Profile;
import org.keycloak.common.util.Time;
import org.keycloak.events.Details;
import org.keycloak.events.Errors;
@@ -39,7 +38,6 @@ import org.keycloak.protocol.oid4vc.model.PreAuthorizedCodeGrant;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.services.cors.Cors;
import org.keycloak.testsuite.AssertEvents;
import org.keycloak.testsuite.arquillian.annotation.EnableFeature;
import org.keycloak.testsuite.util.TokenUtil;
import org.keycloak.testsuite.util.oauth.AbstractHttpResponse;
import org.keycloak.testsuite.util.oauth.AccessTokenResponse;
@@ -67,7 +65,6 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
*
* @author <a href="https://github.com/forkimenjeckayang">Forkim Akwichek</a>
*/
@EnableFeature(value = Profile.Feature.OID4VC_VCI, skipRestart = true)
public class OID4VCCredentialOfferCorsTest extends OID4VCIssuerEndpointTest {
private static final String VALID_CORS_URL = "http://localtest.me:8180";
@@ -123,6 +123,7 @@ import static org.keycloak.testsuite.oid4vc.issuance.signing.OID4VCSdJwtIssuingE
*/
@EnableFeature(value = Profile.Feature.OID4VC_VCI, skipRestart = true)
@EnableFeature(value = Profile.Feature.OID4VC_VCI_PREAUTH_CODE, skipRestart = true)
@EnableFeature(value = Profile.Feature.OID4VC_VCI_REST_CREDENTIAL_OFFER, skipRestart = true)
public abstract class OID4VCTest extends AbstractTestRealmKeycloakTest {
private static final Logger LOGGER = Logger.getLogger(OID4VCTest.class);