mirror of
https://github.com/keycloak/keycloak.git
synced 2026-05-26 13:50:48 +00:00
rest credential offer experimental feature
Closes #46279 Signed-off-by: Giuseppe Graziano <g.graziano94@gmail.com>
This commit is contained in:
committed by
Marek Posolda
parent
b3602649f6
commit
b814ff8003
@@ -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),
|
||||
|
||||
+25
@@ -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());
|
||||
|
||||
+69
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+67
@@ -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
|
||||
|
||||
+1
-2
@@ -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
|
||||
|
||||
+3
-3
@@ -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);
|
||||
|
||||
-3
@@ -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";
|
||||
|
||||
+1
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user